26 KiB
API Routing Design: Header-Based Services, CRD-Driven Onboarding, Unified Auth
Supersedes the earlier /unified-endpoint draft of this doc. Routing is now
header-only at the gateway root — api.riotpiao.com itself is the single entry
point, no dedicated sub-path. New backend services onboard via a CRD the gateway
consumes directly, not via hand-written Go switch cases.
Context
Most of the original hybrid design was aspirational — it didn't exist in code. This
revision reflects decisions made while reviewing the repo against the actual state:
internal/auth/ was empty, s3/sqs/iam didn't exist as handlers, and only a
body-action POST /workflow endpoint was real. Four extensions were decided:
CRD-driven service onboarding, X-Resource-based REST verbs replacing body actions,
unified JWT auth (bearer token or service-account password, one validation path), and
blind 5xx retry.
Known constraints, and how these decisions land on them:
- Rule G2 (originally specced in the now-retired
tasks/6.2-kubernetes-manifests.md, deleted 2026-08-25 since Phase 6 shipped and is live in-cluster) mandated no ServiceAccount/RBAC on the gateway pod, and routes loaded from a git-sourced ConfigMap with a pod roll on every change. The gateway pod now consumes the CRD directly — no separate operator/controller pod. This is a deliberate, acknowledged supersession of G2 for this one narrow purpose: the existingapi-gatewayServiceAccount(already bound to the pod atk8s/rbac.yaml:1-7, currently with zero Role — seek8s/rbac.yaml:9-10's own comment "the gateway has no k8s API access") gains a namespace-scopedRolelimited to read-only access on one CRD, defined in this same repo and synced by the existingapi-gwArgoCD Application (homelab/k8s/argocd/apps/55-api-gateway.yaml) — no new repo, no new Application. Static ConfigMap-sourced routes keep the existing roll-on-change behavior; CRD-sourced routes update live in-process instead (see §1). - Retired
kmsvc.ioTemporalWorker/QueueCRD (homelab/k8s/apps/messaging/queue-crd/, controller in the separatekmsvc-managerepo) broke via an uncleared finalizer and RBAC rules that drifted out of sync with the CRD's resources (temporalworkers.kmsvc.io is forbidden ... at the cluster scope). Cluster-scoped RBAC, RBAC/CRD drift, and finalizer complexity are explicitly avoided this time — this CRD is namespace-scoped (apionly) and read-only from the gateway's side.
0. Repo hygiene — do this first, before any code
Already staged for deletion (git status) — just needs a commit:
DELIVERY_COMPLETE.md, IMPLEMENTATION_SUMMARY.md, PHASE3_GRPC_IMPLEMENTATION.md,
PHASE3_PROGRESS.md, TEMPORAL_API_DESIGN_SUMMARY.md,
TEMPORAL_IMPLEMENTATION_CHECKPOINT.md, TEMPORAL_IMPLEMENTATION_COMPLETE.md,
TEMPORAL_TEST_REPORT.md, WORKFLOWS_INDEX.md, WORKFLOWS_QUICK_START.md,
WORKFLOWS_README.md.
Removed as part of this revision: IMPLEMENTATION_PLAN_UPDATED.md — its reading
order pointed at this doc's now-superseded /unified-endpoint design; folded into
this doc instead of kept as a separate index.
Untracked, flagged but left alone (different topic, not touched by this doc):
AUTHENTIK_AS_KEY_MANAGER.md, AUTHENTIK_KEY_ROTATION_SIMPLE.md,
AUTHENTIK_SOPS_NATIVE.md, AUTHENTIK_WITH_FIPS_HSM.md, JWT_VS_RANDOM_TOKEN.md —
SOPS secrets-at-rest key rotation via Authentik, unrelated to API routing/CRD work.
1. Service onboarding (new backend service = code change vs. YAML)
Existing System
- No CRD, no controller, no generic service registry anywhere in either repo.
- Adding a service today means: write a new Go handler, add a
switchcase, redeploy the gateway image. Onlyworkflowexists (internal/temporal/handler.go);s3,sqs,iamdon't exist in code at all. internal/config/config.go+loader.goload only static, hand-edited route config from the ConfigMap already live per Phase 6 (k8s/configmap.yaml) — no per-service schema, no CRD.
Future System
New CRD ServiceAdapter (gateway.riotpiao.com/v1, namespace-scoped to api
only — deliberately not cluster-scoped, unlike the retired CRD). Named for what it
actually does — adapts an in-cluster service to the gateway's
X-Service/X-Resource contract — rather than a generic "Definition".
No separate operator pod. The API gateway pod itself consumes the CRD directly: it
runs a client-go informer against ServiceAdapter in namespace api and keeps an
in-memory route registry that adds/updates/removes entries as CRs change — no
ConfigMap render step, no restart-to-pick-up-changes, no second binary/Deployment/image
to build and operate.
Where the CRD is defined — this repo, not homelab. Confirmed at
homelab/k8s/argocd/apps/55-api-gateway.yaml:42-45: the api-gw ArgoCD Application
already sources this repo's own k8s path directly (kustomize — Deployment, Service,
ConfigMap, RBAC, NetworkPolicy), auto-syncing on every push to main, wave 7. So the
CRD manifest is just another file in that same kustomization: new
k8s/crd-serviceadapter.yaml, added to k8s/kustomization.yaml's resources: list.
When a new ServiceAdapter CR is introduced (i.e. someone commits a CR YAML for a new
service), ArgoCD syncs it through this same already-existing Application, and the
already-running api-gw pod's informer notices it and starts accepting
X-Service: <new-service> calls — no new Application, no new repo, no manual step in
homelab at all. Go API types back the CRD from
apis/gateway/v1/serviceadapter_types.go (new package, generates the CRD YAML via
controller-gen, same struct also used by the informer).
spec.auth.capability is the default capability required for every resource on the
service. A method entry may override it with its own auth.capability — needed once
a service splits read vs. write access (see §6's memory adapter). No override means
inherit the spec-level default; auth.required: false at either level means no
capability check at all for that scope.
Example CR (onboarding a hypothetical postgres service — no Go code, no image
rebuild, no manual gateway restart):
apiVersion: gateway.riotpiao.com/v1
kind: ServiceAdapter
metadata:
name: postgres
namespace: api
spec:
serviceName: postgres # matches X-Service: postgres
upstream:
url: http://kmsvc-postgres-helper.sqs.svc.cluster.local:8080
timeoutSeconds: 10
auth:
required: true
capability: postgres:access # checked against the caller's JWT claims
retryable: true # eligible for the blind 5xx retry middleware, §4
resources:
- name: query # X-Resource: query
methods:
- verb: POST
upstreamPath: /v1/query
requestSchema:
fields:
sql: string
params: array
required: ["sql"]
responseSchema:
fields:
rows: array
- name: table
methods:
- verb: GET
upstreamPath: /v1/tables/{id} # {id} filled from the trailing path segment
- verb: DELETE
upstreamPath: /v1/tables/{id}
status:
phase: Ready
observedGeneration: 1
Calling it once reconciled — straight to the gateway root, no dedicated sub-path:
curl -X POST https://api.riotpiao.com/ \
-H 'Authorization: Bearer <jwt>' \
-H 'X-Service: postgres' \
-H 'X-Resource: query' \
-d '{"sql": "select 1"}'
RBAC — extend k8s/rbac.yaml in place (same file that already defines the
api-gateway ServiceAccount at lines 1-6, currently role-less per its own "the
gateway has no k8s API access" comment). A namespace-scoped Role (not ClusterRole),
read-only, nothing else — this is the entire RBAC footprint added to the gateway pod,
shipped in the same repo/PR as the CRD itself:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: api-gateway-serviceadapter-reader
namespace: api
rules:
- apiGroups: ["gateway.riotpiao.com"]
resources: ["serviceadapters"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: api-gateway-serviceadapter-reader
namespace: api
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: api-gateway-serviceadapter-reader
subjects:
- kind: ServiceAccount
name: api-gateway
namespace: api
No status/finalizers verbs, no write access at all — the gateway only ever reads.
No new ServiceAccount, no new Deployment, no new container image.
Gateway-side consumer — new internal/serviceadapter/registry.go: a client-go
SharedInformer on ServiceAdapter in namespace api, feeding an in-memory map keyed
by spec.serviceName that the internal/serviceadapter dispatcher (§2) reads on every
request. Add/Update/Delete informer callbacks update the map directly — a new
ServiceAdapter CR is live within the informer's resync/watch latency, no gateway
restart, no ArgoCD change beyond the existing auto-sync of this repo's k8s path.
Request/response validation — requestSchema/responseSchema are not JSON
Schema. Deliberately a flat key→type map, author-facing (whoever writes the CR
specifies it, no JSON Schema knowledge needed):
requestSchema:
fields:
sql: string # one of: string | number | boolean | array | object
params: array
required: ["sql"] # optional; omit if every field is optional
strict: false # optional, default false — true rejects unlisted keys
responseSchema:
fields:
rows: array
One level deep only — params: array checks "is this an array," not what's
inside it. No enums, no formats, no nested field validation. Accepted limitation:
none of the adapters in this doc (postgres, workflow, iam, memory) need more than
top-level shape checking.
Extension forced by real upstream shapes (poimen-memory, §6): the DSL above
only describes a top-level JSON object. GET /memory/projects returns a bare
array (["poimen", ...]), GET /memory/query returns an array of objects, and
GET /memory/skills returns objects with an explicit "generated_from": null.
Neither is expressible in the object-only form, so two additions, kept minimal
rather than backsliding toward full JSON Schema:
responseSchema:
type: array # top-level shape; default "object" if omitted
items: string # scalar array — every element must be this type
responseSchema:
type: array
items:
fields: # object array — one level deep INSIDE each item
level: string
sha256: string
score: number
responseSchema:
fields:
generated_from: { type: string, nullable: true } # long form only needed for nullable
Rule: a field written as a bare type string (sql: string) is shorthand for
{type: string, nullable: false}. null satisfies any field marked
nullable: true regardless of its declared type; a null on a non-nullable
field is a type_mismatch. Still one level deep — items: { fields: {...} }
does not itself accept a nested items.
Validated in Go via internal/serviceadapter/validate.go (new, ~40 lines, no
external dependency): json.Unmarshal the body into map[string]interface{},
check Required fields are present, check each present field in Fields against
its Go runtime type (string→string, float64→number, bool→boolean,
[]interface{}→array, map[string]interface{}→object), and if strict: true
reject any body key not listed in Fields. Compiled once per CR add/update in the
informer callback above, not per-request — the parsed FieldSchema struct is
stored directly in the same registry map entry as the route.
Request-side violations are enforced: 400, RFC 9457 problem+json, body
{type, title, detail, errors: [{field, reason}]} where reason is one of
missing, type_mismatch: want X got Y, or unknown_field (strict mode only).
Response-side violations are not enforced — the response is forwarded to the
caller unchanged; a mismatch only emits a
serviceadapter_response_schema_mismatch{service,resource} metric and a log line.
Rationale: request validation is a trust boundary (external caller input);
response validation is contract-drift detection against our own upstream — turning
a backend field-shape typo into a caller-facing 502 does more harm than logging it.
2. Request shape: body action vs. header verb
Existing System
internal/temporal/handler.go:POST /workflowonly, body is{action, namespace, payload}(RequestPayloadstruct) — action dispatch happens by reading and branching on the JSON body, not by header/method.internal/server/router.godispatches purely by path: exact-matches/healthz,/readyz,/workflow,/workflow/health,/workflow/metrics, and falls through everything else (including/v1/chat/completions) toupstreamHandler. There is no header-based dispatch anywhere in the router today.s3/sqs/iambodies withoperation/actionfields, and a/unifiedsub-path, were only ever prose in an earlier draft of this doc — never implemented, and now dropped.
Future System
No new sub-path is introduced. internal/server/router.go's ServeHTTP gets one new
branch, checked before the existing path switch: if the request carries an
X-Service header, dispatch straight to a new internal/serviceadapter/router.go
dispatcher, regardless of r.URL.Path (callers hit https://api.riotpiao.com/ at the
root). /v1/chat/completions, /v1/embeddings, /v1/rerank, /healthz, /readyz
keep their existing path-based routing in internal/proxy/router.go — those never
carry X-Service and are matched first. /workflow* stays mounted during migration,
delegating internally to the same dispatcher, then gets removed once callers move to
header-based calls.
Dispatch key becomes X-Service header → adapter, then HTTP method +
X-Resource header → {verb, upstreamPath} — e.g. GET to the gateway root +
X-Service: iam + X-Resource: user → list Authentik users. This replaces the body
action/operation field convention everywhere, not just for iam — so the
rewritten workflow adapter looks like:
POST X-Resource: workflow→START_WORKFLOWGET X-Resource: workflow/{id}→QUERY_WORKFLOWGET X-Resource: workflow→LIST_WORKFLOWSDELETE X-Resource: workflow/{id}→TERMINATE_WORKFLOWGET X-Resource: workflow/{id}/history→GET_WORKFLOW_HISTORY- Same pattern for new
s3(bucket/{key}) andsqs(queue/{name}/message) adapters — built fresh, since they don't exist in code today.
/v1/chat/completions, /v1/embeddings, /v1/rerank in internal/proxy/router.go
are unchanged — local-LLM-only, no X-Service involvement.
3. IAM access to Authentik
Existing System
- No
X-Service: iamadapter in the gateway. - Authentik admin API access exists today only client-side, in the Rust
coreCLI (/Users/rockliang/workplace/core/src/cmd/iam/*.rs), which already calls{authentik_url}/api/v3/core/applications/,/api/v3/providers/oauth2/,/api/v3/core/flows/,/api/v3/crypto/certificatekeypairs/for apps/providers/flows — confirmed atcore/src/cmd/iam/app.rs:136,160,191,205,219. No users/groups/roles coverage there, and none of it is reachable through the gateway.
Future System
New internal/iam/handler.go server-side adapter, reachable at the gateway root with
X-Service: iam, mapping X-Resource + method onto Authentik's /api/v3/... surface:
| X-Resource | Method | Authentik endpoint |
|---|---|---|
user |
GET/POST | /api/v3/core/users/ |
user/{id} |
GET/PATCH/DELETE | /api/v3/core/users/{id}/ |
service-account |
POST | /api/v3/core/users/service_account/ |
role (group) |
GET/POST | /api/v3/core/groups/ |
permission |
GET/POST | /api/v3/rbac/permissions/ |
flow |
GET | /api/v3/flows/instances/ |
This is additive to, not a replacement of, the core iam CLI subcommand — different
caller (server vs. local CLI), same upstream API.
4. Auth: how a caller proves identity
Existing System
internal/auth/is an empty directory — zero implementation.tasks/Phase 3 (JWKS fetch/rotation, Bearer validation, Authentik service account, flag-gated rollout, capability authorization) was retired 2026-08-25 — this doc is now the spec for that work instead of those deleted task files.kmsvc.riotpiao.com(separate service) has an auth interceptor (kmsvc-manage/internal/api/interceptors/auth.go, JWKS validation vialestrrat-go/jwx/v2/jwt) but it's never wired in —cmd/server/main.gobuilds a baregrpc.NewServer()(kmsvc-manage/cmd/server/main.go:84), and the REST surface bypasses gRPC interceptors entirely even once fixed. Useful code to reference, not to copy the deployment of.- The
coreCLI already does human OIDC login (core auth login— device-code/OOB against Authentik) and caches a token at~/.cache/talos/authentik_id_token(core/src/cmd/auth/mod.rs:336-338) — a different token/cache than what §5 adds.
Future System
Implement in internal/auth, with two entry paths converging on one validated-JWT
middleware — no separate downstream logic per path:
- Bearer passthrough —
Authorization: Bearer <jwt>sourced from~/.talos/.riotpiao-auth(written bymwinit, §5), validated against Authentik's JWKS. - Service-account password grant — Authentik service-account username/password
exchanged for a JWT. Reuse the validation logic already written (but unwired) in
kmsvc-manage/internal/api/interceptors/auth.go, not its deployment config.
Retry middleware (internal/resilience/retry.go, new) wraps every outbound upstream
call from the internal/serviceadapter dispatcher: blind retry on any 5xx, bounded
attempts with backoff, no idempotency-key gating — Temporal (and other backends) own
dedup on their side. ServiceAdapter.spec.retryable (default true) is the only
opt-out.
5. CLI-MWNINIT.md (separate doc)
Existing System
The Rust core CLI (/Users/rockliang/workplace/core) has core auth login
(src/cmd/auth/mod.rs) for human OIDC device-code/OOB login, and a separate core iam
subcommand tree (src/cmd/iam/) for app/group/scope management. There is no
service-account login path and no ~/.talos/ token file — the existing cache is
~/.cache/talos/authentik_id_token, and ~/.talos today only holds Talos node
config (TALOSCONFIG, per core/src/config.rs:25-26), unrelated to Authentik JWTs.
Future System
New core mwinit subcommand (new src/cmd/mwinit/mod.rs, same structure as
src/cmd/auth/mod.rs — not a new binary), documented in a separate CLI-MWNINIT.md:
- Purpose — authenticate as a dedicated Authentik service account (not a human
device-code login) and cache the resulting JWT for use against
api.riotpiao.com. - Two paths:
core mwinit login --username <svc-account> --password <...>(Authentik service-account grant, sameAUTHENTIK_URLdefault ascore auth login,core/src/cmd/auth/config.rs:18), orcore mwinit login --token <existing-jwt>to just re-cache an already-issued token. - Storage:
~/.talos/.riotpiao-auth— a new, separate path fromcore auth login's~/.cache/talos/authentik_id_token, so the two tools never collide. - Usage:
curl -H "Authorization: Bearer $(cat ~/.talos/.riotpiao-auth)" -H 'X-Service: ...' https://api.riotpiao.com/ .... - Lifecycle:
core mwinit status/core mwinit clear(mirroringcore auth status/clear); gateway behavior on expired token is 401 +WWW-Authenticate, RFC 9457 problem+json.
6. X-Service: memory — poimen-memory
Existing System
poimen-memory (~/workplace/Poimen/memory) is real and already deployed —
namespace poimen, Service poimen-memory.poimen.svc.cluster.local:8080, 2 pods
Running. Its own README.md claims "design complete, no code yet, 0/37 tasks",
which is badly stale: memory-tasks/M3.5.*.md (per that repo's
DESIGN.md) show the HTTP API layer (M3.5 phase) is done — GET /memory/projects,
GET /memory/projects/{id}/status,
GET /memory/query?query=...&level=L1,L2&project=...&limit=...,
POST /memory/ingest, GET /memory/skills, GET /memory/skills/{name} all exist and
are tested. It is not currently reachable from api.riotpiao.com — the live ingress
has only a / catch-all to api-gateway, no /memory path.
Three more endpoints are specced in DESIGN.md but not confirmed built —
GET /memory/projects/{id}/notes (M3.5.6, listed in the task table but not in the
"is done" set above), POST /memory/context (M3.7, tier-1/2/3 lookup — depends on
M3.7.7/M3.7.8 signature+symptom work, not started), and POST /memory/nodes/by-git
/by-commit/by-author (M3.5.9, explicitly optional in that repo's own task
board). These get CR entries below so the schema is defined once, but the
gateway-side task for each is blocked on upstream, not just on our own code —
see §7's status column.
It also currently does its own auth: an apikey: header check, exact-match against a
stored key, implemented in the app itself
(tasks/M3.5.1-http-server.md:36-39) — independent of Kong, which its own
architecture diagram assumed sat in front but never actually required for this check
to work. There is no NetworkPolicy on it today (k8s/ has none) — the apikey
check is the only thing standing between it and any pod in the cluster.
Future System
Per explicit decision: drop poimen-memory's own apikey check entirely and gate it
through Authentik instead, with separate read/write capabilities — using the
per-method auth.capability override introduced in §1.
apiVersion: gateway.riotpiao.com/v1
kind: ServiceAdapter
metadata:
name: memory
namespace: api
spec:
serviceName: memory # matches X-Service: memory
upstream:
url: http://poimen-memory.poimen.svc.cluster.local:8080
timeoutSeconds: 15
auth:
required: true
capability: memory:read # default for every resource below
retryable: true
resources:
- name: query # X-Resource: query — "specific documents belonging to a project"
methods:
- verb: GET
upstreamPath: /memory/query
- name: skill # X-Resource: skill or skill/{name}
methods:
- verb: GET
upstreamPath: /memory/skills
- verb: GET
upstreamPath: /memory/skills/{name}
- name: project # X-Resource: project or project/{id}/status
methods:
- verb: GET
upstreamPath: /memory/projects
- verb: GET
upstreamPath: /memory/projects/{id}/status
- name: ingest # X-Resource: ingest — write path
methods:
- verb: POST
upstreamPath: /memory/ingest
auth:
capability: memory:write # overrides the memory:read default
curl -X GET https://api.riotpiao.com/ \
-H 'Authorization: Bearer <jwt-with-memory:read>' \
-H 'X-Service: memory' \
-H 'X-Resource: query' \
-G --data-urlencode 'query=why did requests over 10KB fail' \
--data-urlencode 'project=poimen' --data-urlencode 'level=L1,L2'
Required companion changes, in this order — not optional, not cosmetic:
- Add a
NetworkPolicyin namespacepoimenrestricting ingress onpoimen-memoryto theapinamespace's gateway pod only (mirrors the MinIO-protection rationale from the very first version of this doc). This must land before step 2, or there's a window where the service has no auth of any kind. - Remove the
apikey:middleware frompoimen-memoryitself (tasks/M3.5.1-http-server.md's auth hook) — separate change in the~/workplace/Poimen/memoryrepo, out of scope forhomelab-frontendbut a hard prerequisite for this adapter being safe to expose. - Provision
memory:read/memory:writeas real Authentik scopes/capabilities via theX-Service: iamadapter (§3) orcore mwinit-issued tokens, so callers can actually be granted one without the other.
Verification
go build ./...and existing tests (internal/config/*_test.go, plus new tests for the rewritteninternal/temporal/ newinternal/serviceadapter,internal/iam,internal/resilience) pass.curlsmoke tests per adapter (workflow/s3/sqs/iam/memory) against a dev gateway, covering both the legacy/workflowbody-action path (still mounted) and the new root + verb+X-Resourcepath.internal/serviceadapter/validate_test.go(new): missing required field → 400 withreason: missing; wrong type (e.g.sqlsent as a number) → 400 withtype_mismatch; unlisted key understrict: true→ 400 withunknown_field; unlisted key under default (strict: false) → passes. Postgresqueryresource'srequestSchema(§1) is the fixture. Confirm a response-side mismatch does not block the response and instead incrementsserviceadapter_response_schema_mismatch{service,resource}.- Apply the
memoryServiceAdapterCR only after thepoimenNetworkPolicy lands andpoimen-memory's ownapikeymiddleware is removed (§6's ordering). Confirm amemory:read-scoped token canGET X-Resource: query/skill/projectand amemory:write-scoped token canPOST X-Resource: ingest, and that amemory:readtoken gets 403 oningest. - Commit the CRD +
Role/RoleBindingtok8s/, push tomain; confirm the existingapi-gwArgoCD Application auto-syncs them with nohomelabchanges and no new Application. Apply the examplepostgresCR; confirm the running gateway pod's informer picks it up and servesX-Service: postgreswithin the watch/resync latency — no gateway restart, no manualkubectlstep. core mwinit loginend-to-end against dev Authentik; confirm~/.talos/.riotpiao-authis written, a root-levelX-Service-header call using it succeeds, and~/.cache/talos/authentik_id_tokenis untouched.