Skip to content

identity

identity is the service that knows who someone is. It owns users and sessions, accounts and tenancy, roles and invitations, and the OIDC provider — which means every other service (billing, courier, gateway, parlor, darkroom) asks this one who is calling and what they may do. It is written in Go because it holds the keys to everything and wants a static binary and a compiler’s worth of discipline around them.

Because this is the platform’s security boundary, the bar is higher

Section titled “Because this is the platform’s security boundary, the bar is higher”

An authorization matrix test generated from a route table asserts every endpoint against every role and against an anonymous caller, and TestEveryRouteIsInTheMatrix fails if a route is added without a row. A new route goes into registerTenancyRoutes and the matrix, in the same commit.

Two properties that are load-bearing and will not be relaxed:

  • Redirect URIs are matched exactly, always. Nothing implements op.HasRedirectGlobs, and the registration validator refuses a wildcard, so a redirect_uri can only ever be matched by equality. A prefix comparison turns this service into an open redirector for every product registered on it.
  • One signing key, and it is configured. OIDC_SIGNING_KEY is read from the environment and never generated. A key generated at boot publishes a document no caching verifier has seen, and two processes behind a load balancer would each publish a different one. The library’s token-encryption key is derived from it with a domain-separated SHA-256 rather than configured separately — one secret to rotate, and it is genuinely used, because the userinfo handler decrypts an access token before it verifies one.

The OIDC protocol is delegated, not reimplemented

Section titled “The OIDC protocol is delegated, not reimplemented”

zitadel/oidc owns the OAuth 2.0 and OIDC state machines. internal/oidc supplies the two things a library cannot have: a storage implementation over this service’s own tables, and a login UI bound to this service’s own sessions. PKCE verification, redirect-URI matching, the code response and the id_token are the library’s job, and where this service disagrees with its defaults the disagreement is a refusal with a reason at its own definition — requireS256, the absence of op.HasRedirectGlobs, the narrowing in the discovery document — rather than a branch inside a handler.

go-jose/v4 is a forced dependency, not a chosen one: the library’s storage interface cannot be implemented without it.

Client management is owner-gated, and that is not an oversight

Section titled “Client management is owner-gated, and that is not an oversight”

A product cannot register itself. Client management is owner-gated on an account, reusing the tenancy role check, because identity has no platform-admin role yet — the admin API is a later packet, and a rule this service cannot express would be a rule with a bypass in it.

Tenancy: the shape, and the rule that protects an account

Section titled “Tenancy: the shape, and the rule that protects an account”

Registration creates two things in one transaction: a users row and a personal account for that user, plus an owner membership. A design partner’s tenant is the second account — a deliberate, non-personal one with a slug somebody can put in a hostname. Both existing is normal.

Roles are owner, admin, member: a PostgreSQL enum, ordered, and the ordering is what the minimum-role check compares.

Operation Minimum role
Read the account, list members member
Rename, invite, remove a member admin
Change a role, delete the account owner

A non-member gets 404, never 403. A 403 would tell any authenticated caller that an account id they guessed is real, which is a free tenant-enumeration oracle on the table whose ids are the only thing between one customer and another. So the two denials are kept apart: 404 when the resource is not yours to know about, 403 when it is and you lack the permission. A 404 on an account you believe exists is usually a membership problem, not a missing account.

The last owner is never removed. An owner demoting themselves while they are the only owner gets a 422 naming the field and the code last_owner.

Three things to know before you rely on it

Section titled “Three things to know before you rely on it”

identity declares five event types and writes ten. Its manifest lists identity.user.created, identity.oidc_client.created, identity.oidc_client.revoked, identity.mfa.enabled and identity.mfa.disabled. It also writes identity.account.created, identity.member.invited, identity.member.accepted, identity.member.role_changed and identity.member.removed.

Only identity.user.created has a payload schema in core. Six of the other nine have a catalog row and no schema; three — both oidc_client types and identity.member.accepted — have no catalog row at all, and accepted is not even core’s spelling (core says identity.member.joined). The two OIDC types are the ones that matter to a consumer: they are advertised in the manifest with no published contract behind them. See Upgrading.

No password recovery. POST /v1/users creates a user with a password and there is no reset, no recovery token, and no admin API to set one. A user who forgets their password has no path back in, which in a pilot means somebody has to be on the other end of a support channel. Plan for that before you ship it, and read the password policy rather than guessing it — this service refuses a short password with a 422 naming the field.

MFA needs a key you supply. It is built, but MFA_ENCRYPTION_KEY is never generated: unset, and the management routes are not mounted at all. A local stack from cafe dev will not set it, so a 404 on /v1/mfa there is the configuration, not a missing feature. See Hosted pilot onboarding.

go test ./... is green on a machine with no database and no Docker: the integration tests skip themselves unless TEST_DATABASE_URL is set. Running them needs the database and its migrations — goose up is a deploy step, and without it the OIDC and MFA tests fail with relation "public.oidc_clients" does not exist rather than skipping.

Terminal window
bin/prime && go vet ./... && gofmt -l . && go test -race ./...

bin/prime itself needs no database — the integration tests skip unless TEST_DATABASE_URL is set. To run them you need a Postgres, and this repository cannot start one on its own: identity/docker-compose.yml is an override with no image: on postgres, because kit’s stack ships that container. docker compose up -d postgres here fails with “service "postgres" has neither an image nor a build context specified”.

identity carries bin/dev, which fetches kit’s stack at the ref in kit.ref and brings up a database on KIT_POSTGRES_PORT (default 15500):

Terminal window
bin/dev # fetch kit, compose up --wait, migrate, seed, print URLs
export DATABASE_URL="postgres://identity:identity@localhost:15500/identity?sslmode=disable"
goose -dir migrations postgres "$DATABASE_URL" up
TEST_DATABASE_URL="$DATABASE_URL" go test ./...

The port is the part to get right. identity’s compose file publishes no Postgres host port at all, and the stack bin/dev brings up publishes one on 15500, because that is kit’s port block and not 5432. A DSN written for 5432 either fails or — the worse outcome — reaches a different Postgres that happens to be listening there, and fails with role "identity" does not exist, which reads like a missing migration. Read the port out of the rendered stack with bin/dev stack rather than assuming one, and note that bin/dev currently does not finish for this repository — see Getting started.

gofmt -l . must print nothing. See Running the gates.

  • Repository: github.com/cafaye/identity
  • Language: Go
  • Namespace / event source: identity
  • Container port: 8080 · gcr.io/distroless/static-debian12:nonroot