Developer Guide
Bounded context: Identity & Access · Stack: Keycloak (identity provider) + Go (control plane)
Keycloak owns authentication for every tier; claims-gateway, tenancy-service, registry-service, and provisioner (all Go) own everything else — token exchange, membership resolution, organization lifecycle, and provisioning, respectively. Nothing on this page is code — see Auth Middleware, Auth Contracts, and Auth Policy for the implementation-level walkthroughs, or Node Auth & Error Middleware for how the Node services on the other side of claims-gateway trust its output.
graph TD
Browser["Browser / Vue Client"]
Browser -->|"1: OIDC login (PKCE)"| KC["Keycloak"]
Browser -->|"2: POST idToken → /exchange"| GW["claims-gateway"]
GW -->|verify id token| KC
GW -->|"3: resolve/JIT-provision memberships, once per login"| TS["tenancy-service"]
GW -->|"4: session cookie, RS256 internal JWT"| Browser
Browser -->|"5: request, cookie attached"| EDGE["Edge: Caddy forward_auth (compose)\nIstio RequestAuthentication (k8s)"]
EDGE -->|"/verify — the only per-request JWKS check"| GW
EDGE -->|"6: inject x-gateway-claims header"| SVC["twin · sensor · notification\nsocket · chat · tenancy-service"]
SVC -->|"Cedar, evaluated locally — tenancy-service only"| Cedar["auth-policy (Cedar)"]
REG["registry-service"] -->|"organizations: tier, status"| PRV["provisioner"]
PRV -->|"poll every N seconds, pooled tier only"| REG
PRV -->|"create domain when org is ready"| TS
Browser -.->|"signup"| REGMicroservices. Each of the four Go services is deployed, scaled, and released independently, and each owns its own data: tenancy-service and registry-service hold a private Postgres schema; claims-gateway and provisioner are stateless. The topology diagram above is the real call graph between separately running processes.
Every one of the four follows the same Ports & Adapters (Hexagonal) shape — a core that depends only on interfaces it defines itself, driven by one inbound adapter, backed by one or more outbound adapters, wired together in cmd/<service>/main.go, the composition root. What differs per service is what plays each role.
In the diagrams below, hexagons (⬡) are ports — interfaces the core itself defines — and rectangles are adapters — concrete implementations. Only the outbound (driven) side actually has a port in this codebase: the core depends on an interface like store.Store, and a Postgres implementation or a test fake satisfies it. The inbound (driving) side has no equivalent port — internal/api calls the core’s concrete struct directly (*service.Gateway, *service.Service), since there’s normally only one real caller and nothing to swap it for.
Code-level walkthrough: Claims Gateway
The Authentication Gateway. Its one job is exchanging an external IdP token for the platform’s internal claims shape — nothing else. The core (internal/service) depends on interfaces for identity verification, token signing, admin operations, and membership resolution; four separate driven adapters implement them.
graph LR
subgraph Driving
API["internal/api (HTTP)"]
end
subgraph Core
SVC["internal/service\nexchange · profile · register"]
end
subgraph "Outbound Ports — interfaces internal/service defines"
P1{{"Verifier"}}
P2{{"ProfileReader · ProfileUpdater\nPasswordChanger · PasswordAuthenticator\nUserRegistrar"}}
P3{{"Signer"}}
P4{{"TenancyClient"}}
end
subgraph "Driven Adapters"
VER["internal/oidcverifier"]
KCA["internal/keycloakadmin"]
SIG["internal/signer"]
TC["internal/tenancyclient"]
end
API -->|"direct call — no port"| SVC
SVC --> P1 & P2 & P3 & P4
P1 -.->|implements| VER
P2 -.->|implements| KCA
P3 -.->|implements| SIG
P4 -.->|implements| TCCode-level walkthrough: Tenancy Service
Owns authorization: Cedar (auth-policy) is called directly from internal/api’s handlers, at the HTTP boundary, evaluated locally against a policy bundle compiled into the binary — not through the core, and not behind a port, the same way the driving adapter itself calls the core directly. The core’s only outbound port is store.Store, satisfied by a Postgres adapter in production and a fake adapter in tests; internal/events (the account.deleted consumer) is a second driving adapter into the same core, alongside HTTP.
graph LR
subgraph Driving
API["internal/api\nHTTP + internalauth.go"]
EVT["internal/events\naccount.deleted consumer"]
end
subgraph Core
SVC["internal/service"]
end
subgraph "Outbound Port"
PORT{{"store.Store"}}
end
subgraph "Driven Adapters"
PG["internal/store/postgres.go"]
FAKE["internal/store/storefake (tests)"]
end
CEDAR["auth-policy (Cedar)\ncalled directly — no port"]
API -->|"direct call — no port"| SVC
API --> CEDAR
EVT -->|"direct call — no port"| SVC
SVC --> PORT
PORT -.->|implements| PG
PORT -.->|implements| FAKECode-level walkthrough: Registry Service
The plainest of the four: a Store-backed core behind an HTTP driving adapter, with no dependency on any other service. provisioner polls it; it never calls out.
graph LR
subgraph Driving
API["internal/api (HTTP + internalauth.go)"]
end
subgraph Core
SVC["internal/service"]
end
subgraph "Outbound Port"
PORT{{"store.Store"}}
end
subgraph "Driven Adapters"
PG["internal/store/postgres.go"]
FAKE["internal/store/storefake (tests)"]
end
API -->|"direct call — no port"| SVC
SVC --> PORT
PORT -.->|implements| PG
PORT -.->|implements| FAKECode-level walkthrough: Provisioner
A reconciler / control loop, the same converge-toward-desired-state shape Kubernetes controllers use. Its driving adapter isn’t HTTP but a ticker in main.go, calling the core directly with no port in front of it either; the core (internal/reconciler) defines the RegistryClient and TenancyClient outbound ports it needs, and two small HTTP clients implement them.
graph LR
subgraph Driving
TICK["main.go (ticker loop)"]
end
subgraph Core
REC["internal/reconciler"]
end
subgraph "Outbound Ports — defined in reconciler.go"
P1{{"RegistryClient"}}
P2{{"TenancyClient"}}
end
subgraph "Driven Adapters"
RC["internal/registryclient"]
TC["internal/tenancyclient"]
end
TICK -->|"direct call — no port"| REC
REC --> P1 & P2
P1 -.->|implements| RC
P2 -.->|implements| TCEvery service downstream of the edge trusts exactly one thing: a header called x-gateway-claims, base64-encoded JSON matching the Stable Claims Contract. How that header gets there differs by environment, but the contract it delivers is identical either way — this is deliberate, so the same authmiddleware.RequireMeshClaims() (Go) and its per-language equivalents work unmodified in both.
| Aspect | docker-compose (dev) | Kubernetes |
|---|---|---|
| Mechanism | Caddy forward_auth on every gated route, calling claims-gateway’s GET /verify | Istio RequestAuthentication at the ingress gateway, JWKS fetched from claims-gateway once and cached |
| Where the JWT is actually verified | Inside claims-gateway itself (authmiddleware.RequireAuthentication, RS256 against its own JWKS) | Inside Envoy, at the mesh edge — claims-gateway is never called |
| Per-request network hop to claims-gateway? | Yes — every gated request pays a real sub-request to /verify before reaching its target | No — Envoy validates the cached JWKS locally; claims-gateway is only hit for its own routes (/gateway/*) |
| Header injection | Caddy’s copy_headers X-Gateway-Claims copies /verify’s response header onto the forwarded request | Envoy’s outputPayloadToHeader injects the validated payload directly |
Downstream of either path, the receiving service does the same thing: decode x-gateway-claims, trust it, done — no JWKS, no crypto, no network call. claims-gateway is therefore the only service in the whole platform that ever verifies a raw JWT signature; every other service is a pure consumer of an already-verified identity.
/agent/* is not gated by require_gateway_auth in the Caddyfile, and has no equivalent AuthorizationPolicy requirement at the Istio edge. evals/run_evals.py’s local-dev bypass token is HS256-signed with a secret claims-gateway never sees — it was never going to pass gateway verification, by design. agent-service checks for the mesh claims header first and falls back to its own eval-token verification (app/auth.py). Since the edge can’t inject a verified x-gateway-claims on an ungated route, both edges strip any client-supplied one on /agent (agent decodes that header without a signature check, so an ungated caller could otherwise forge it); real user traffic reaches agent through chat-service, which forwards mesh-verified claims internally.
The internal HMAC-signed contract used for claims-gateway → tenancy-service and provisioner → registry-service/tenancy-service calls is the same convention across the whole Go control plane — see each service’s internal/api/internalauth.go for the X-Signature implementation.
The Vue frontend authenticates against Keycloak (authorization code + PKCE) and calls claims-gateway’s /exchange, /me, /logout for session lifecycle, and tenancy-service’s /domains, /me/memberships, /domains/{domain}/subdomains, join/leave, and invite-code endpoints for all domain-management UI.
Membership and role decisions — who can read, edit, or manage a domain — are not baked into the identity layer at all. tenancy-service is the only one of the four services that imports backend/auth-policy and evaluates Cedar locally, in-process. claims-gateway, registry-service, and provisioner have no Cedar dependency: the gateway only authenticates, and the control-plane services (registry-service, provisioner) operate on organizations, a concept Cedar’s tenant/domain model doesn’t cover.
Roles are pre-expanded before Cedar ever runs. Cedar’s \.contains() checks set membership, but it can’t compare role weights — there’s no way to ask it “does this role outrank that one.” So the weight math happens once, in tenancy-service, before calling Cedar: every membership {domain, role} is expanded into which role tiers it qualifies for in that domain, and the result — flat per-tier domain sets — is what the Account entity carries:
entity Account = {
domainsAsStandardCustomer: Set<String>, // member, any role
domainsAsBusinessStaff: Set<String>, // role weight >= business_staff
domainsAsBusinessAdmin: Set<String>, // role weight >= business_admin
domainsAsAdmin: Set<String>, // role weight >= admin
maxRoleWeight: Long, // for GLOBAL (non-domain) checks
};policy.cedar itself only ever does principal.domainsAsX.contains(resource.domain) — simple containment, no weight arithmetic. Five actions (Read, ReadWithAdminBypass, Edit, ManageDomain, ModelOverride) exist as separate Cedar actions instead of one parameterized check, because each one’s policy is which pre-expanded set to test against — a decision Cedar owns, not the service.
Two checks are deliberately kept outside Cedar: self-removal from a domain (an identity comparison, target == caller, not a role decision) and auth-disabled anonymous access in dev/test (a deployment-setting check, not a policy decision about a real caller’s roles).
in is entity hierarchy, not set membershipresource.domain in principal.domainsAsStandardCustomer looks correct but silently denies every case — Cedar’s in operator tests entity-hierarchy membership (is this entity a descendant of that one), not general set membership. For a plain value in a Set<String>, the correct call is principal.domainsAsStandardCustomer.contains(resource.domain). A golden conformance suite that runs real policy text through the real engine catches this; reasoning about the policy text alone does not.
The role required to override the assistant’s model on /ask is operator-configurable (MODEL_OVERRIDE_MIN_ROLE, default business_admin), so it can’t be a fixed threshold baked into policy.cedar — ModelOverride’s rule instead reads it from the request context (context.requiredWeight), the same mechanism every language’s Cedar binding supports.
registry-service and provisioner implement organization signup as a poll-based reconcile loop rather than a synchronous provisioning call — deliberately, since standing up a domain is not instant and a browser request shouldn’t block on it.
registry-service’s organizations table with status = 'provisioning', a tier (pooled / dedicated / on-prem), and an identity_mode (platform / byo-idp).provisioner runs an internal ticker (interval set by cfg.ReconcileInterval, no external scheduler) that, on every tick, asks registry-service for all organizations still provisioning and reconciles each one independently:tier isn’t pooled, the organization is immediately marked failed with an explanatory detail — provisioner only implements the pooled tier today; dedicated/on-prem provisioning is real, separately-sized future work (see backend/provisioner/CLAUDE.md).join_policy for the new domain — open-via-idp if the organization is byo-idp (self-service join, since membership can be verified against the org’s own IdP), invite-only otherwise — and calls tenancy-service to create the domain with that policy.ready; on failure at any step it’s marked failed with the underlying error, never left silently stuck.provisioner exposes /health for its own liveness/readiness probes and nothing else — it has no public API; the reconcile loop is its entire job.This is why an organization can sit visibly “provisioning” for up to one reconcile interval after signup, and why retrying is free — ReconcileOnce is naturally idempotent per organization since it only re-processes rows still in provisioning.
tenancy-service authorizes via Cedar, evaluated locally; registry-service and provisioner own organization lifecycle and provisioning. No component does more than one of these jobs.claims-gateway mints a fixed-shape internal token ({sub, accountName, sid, memberships}), frozen by auth-contracts; every consumer verifies against the public JWKS, directly or via a trusted header.tenancy-service. A stale session is fixed via claims-gateway’s /refresh, not a fresh IdP verification.tenancy-service and registry-service each own a private Postgres schema; no foreign keys cross the boundary — cross-service consistency is event-driven, not transactional.registry-service’s organization (billing, tier, license) and tenancy-service’s domain (tenant, memberships) are kept apart even though today’s relationship is 1:1, because that relationship isn’t guaranteed to stay 1:1.None of the four services (claims-gateway, tenancy-service, registry-service, provisioner) appear anywhere in \.github/workflows/ — no lint, test, or build gate runs on them in CI today. This is a real, still-open gap, unrelated to the Kubernetes work below.
All four services have Deployment/Service manifests and are reachable in-cluster, plus Postgres StatefulSets for registry-db/tenancy-db. The Istio gateway (k8s/istio-gateway.yml) routes /gateway → claims-gateway and /tenancy → tenancy-service; registry-service and provisioner have no external route by design — they’re control-plane-internal, reached only over the mesh via HMAC-signed calls (see Deployment & Kubernetes.
Neither claims-gateway nor tenancy-service exposes an equivalent of enterprise self-registration or per-domain BYO-OIDC. Building either would need a fresh design against Keycloak’s Organizations/identity-brokering features.
See Communication & Data Flow for how these calls fit into the platform’s overall synchronous/asynchronous split, and Observability for what is and isn’t currently monitored on this stack (none of these four services expose Prometheus metrics today).