/

Developer Guide

Identity & Tenancy Architecture

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.


Component Topology

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"| REG

Architectural Style

System Level

Microservices. 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.

Service Level

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.

claims-gateway

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| TC

tenancy-service

Code-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| FAKE

registry-service

Code-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| FAKE

provisioner

Code-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| TC

Identity Verification: Two Paths, One Contract

Every 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.

Aspectdocker-compose (dev)Kubernetes
MechanismCaddy forward_auth on every gated route, calling claims-gateway’s GET /verifyIstio RequestAuthentication at the ingress gateway, JWKS fetched from claims-gateway once and cached
Where the JWT is actually verifiedInside 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 targetNo — Envoy validates the cached JWKS locally; claims-gateway is only hit for its own routes (/gateway/*)
Header injectionCaddy’s copy_headers X-Gateway-Claims copies /verify’s response header onto the forwarded requestEnvoy’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-service is the deliberate exception

/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-gatewaytenancy-service and provisionerregistry-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.


Client Integration

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.


Authorization: Cedar in tenancy-service

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).

Cedar’s in is entity hierarchy, not set membership

resource.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.cedarModelOverride’s rule instead reads it from the request context (context.requiredWeight), the same mechanism every language’s Cedar binding supports.


Provisioning: The Reconcile Loop

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.

  1. Signup writes a row to registry-service’s organizations table with status = 'provisioning', a tier (pooled / dedicated / on-prem), and an identity_mode (platform / byo-idp).
  2. 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:
    • If 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).
    • Otherwise, it derives a 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.
    • On success the organization is marked ready; on failure at any step it’s marked failed with the underlying error, never left silently stuck.
  3. 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.


Key Architectural Decisions


Known Gaps

No CI coverage for the Go identity/tenancy stack

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.

Only claims-gateway and tenancy-service are exposed at the edge

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 /gatewayclaims-gateway and /tenancytenancy-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.

Enterprise self-registration and per-domain BYO-OIDC are not currently supported

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).