Skip to content

Operations — identity service

The server binary reads configuration from environment variables via sdk/config.MustBind. Missing required fields cause the process to exit at startup.

VariableRequiredDefaultNotes
DATABASE_URLyesPgBouncer URL (port 6432, transaction pool). Server only.
MIGRATION_DB_URLyes*Direct Postgres URL (port 5432, session pool). Migrator only.
IDENTITY_AUTHKEY_KEKyes64 hex chars (32-byte AES-256 key). Encrypts RS256 privkeys.
SERVER_PORTno8081HTTP listen port.
IDENTITY_GRPC_PORTno50051gRPC listen port.
ENVnodevdev | staging | prod. Stamped into JWT env claim.
JWT_TTLno3600sAccess token lifetime. Parsed as Go duration string.
REQUEST_TIMEOUTno30sPer-request deadline.
READ_HEADER_TIMEOUTno5sHTTP header read deadline (Slowloris mitigation).
SHUTDOWN_TIMEOUTno30sGraceful shutdown window.
BODY_LIMIT_BYTESno1048576Maximum request body size (1 MiB).
DB_MAX_CONNSno20PgBouncer pool max connections.
DB_MIN_CONNSno2PgBouncer pool min connections.
DB_MAX_CONN_LIFETIMEno30mPgBouncer connection max lifetime.
LOG_LEVELnoinfodebug | info | warn | error.
OTEL_EXPORTER_OTLP_ENDPOINTno""OTLP gRPC endpoint. Empty disables tracing export.

*MIGRATION_DB_URL is required for cmd/migrator; not read by cmd/server.

Source:

// from paper-board/identity/internal/config/config.go:23-39
type Config struct {
DatabaseURL string `env:"DATABASE_URL" validate:"omitempty,url"`
MigrationDatabaseURL string `env:"MIGRATION_DB_URL" validate:"omitempty,url"`
ServerPort int `env:"SERVER_PORT" default:"8081"`
// ...
AuthKeyKEKHex string `env:"IDENTITY_AUTHKEY_KEK"`
}

Chart: infra/helm/agent-manager/charts/identity (or standalone identity/helm/identity).

Key values:

# from paper-board/identity/helm/identity/values.yaml
server:
replicas: 1
service:
port: 8081
grpcPort: 50051
config:
jwtTTL: "3600s"
requestTimeout: "30s"
bodyLimitBytes: 1048576
dbMaxConns: 20
logLevel: "info"
env: "dev"
migrator:
enabled: true
image:
repository: ghcr.io/paper-board/identity-migrator
tag: v0.2.1
bootstrap:
enabled: false # set true on first install; idempotent on upgrade
owner:
email: ""
name: "Owner"
orgName: ""
orgSlug: ""
env: "live"
withInitialKey: false
passwordSecret:
name: "" # K8s Secret name carrying the owner password
key: "password"

Secrets are injected as K8s Secret objects. Do not commit secret values in values.yaml. Use secrets.create: true with authkeyKEK, dbURL, and migrationDbURL set via --set or an external secrets operator.

The migrator runs as a Helm pre-install / pre-upgrade Job hook (ADR-0004). It uses a direct Postgres connection (MIGRATION_DB_URL, port 5432) so that the advisory lock (lock_id=1) works in session mode. Never point the migrator at PgBouncer (port 6432) — the transaction-pool mode releases the session-level advisory lock between statements.

Terminal window
# Apply all pending migrations
identity-migrator up
# Dry-run — print SQL without executing
identity-migrator up --dry-run
# Roll back N steps
identity-migrator down 1
# Force schema_migrations version (recovery only)
identity-migrator force 3
# Print current applied version
identity-migrator version
# Drop all tables (dev only — requires MIGRATOR_ENV=dev)
identity-migrator drop

Location: migrations/schema/. Naming: NNNNNN_<name>.up.sql / .down.sql (6-digit zero-padded).

Current migrations:

VersionNameDescription
000001initusers, organizations, org_members, api_keys, auth_keys
000003refresh_tokensrefresh_tokens table for RFC 6749 rotation
000004idempotency_keysidempotency_keys table for sdk/idempotency

The server pod has no direct Postgres access. Egress is restricted to PgBouncer (:6432) and kube-dns (:53). The migrator Job has a separate policy allowing direct Postgres (:5432) only during the pre-install/pre-upgrade hook window.

# from paper-board/identity/helm/identity/values.yaml
networkPolicy:
enabled: true
ingress:
- from:
- podSelector:
matchLabels:
app: agents
ports:
- port: 50051

C4-A — Refresh-token rotation and reuse-detection

Section titled “C4-A — Refresh-token rotation and reuse-detection”

Refresh tokens follow a token-family model. Each POST /v1/auth/login creates a new family. POST /v1/auth/refresh rotates the token within the family (one-time use). Presenting a previously-consumed token triggers reuse-detection: the entire family is revoked immediately and the response body includes "action": "re_authenticate".

All mutating endpoints except the three auth flows (/v1/auth/login, /v1/auth/refresh, /v1/auth/logout) require an Idempotency-Key header. Duplicate requests within the deduplication window return the stored response without re-executing the handler. Keys are stored in identity.idempotency_keys.

A post-install / post-upgrade Helm Job runs identity-cli bootstrap to create the initial owner user and organization. The job is idempotent: re-running on upgrade is a no-op once the owner row exists. Enable with bootstrap.enabled=true; supply owner credentials via a K8s Secret (never in plain values.yaml).

At server startup the process checks for an active auth key in identity.auth_keys. If none exists it generates a new RS256 key pair, encrypts the private key with the AES-256 Key Encryption Key (KEK) loaded from IDENTITY_AUTHKEY_KEK, and inserts it as active. This ensures the service is self-bootstrapping: a fresh deployment never fails because no signing key exists.

The auth_keys table enforces a partial unique index (auth_keys_one_active) that allows at most one active key at a time.

Images are pushed to ghcr.io/paper-board/identity-{server,migrator,cli}:<tag> via the release workflow. The Helm chart references these images by digest-pinned tag. Internal visibility on GHCR requires the consuming repo to be added to the package’s “Manage Actions access” list with Read permission (see memory note paper_board_ghcr_internal_helm_access.md).

flowchart TB
subgraph k8s ["Kubernetes namespace: paper-board"]
subgraph identity_ns ["identity"]
server["identity-server\nDeployment\n:8081 HTTP / :50051 gRPC"]
migJob["identity-migrator\npre-install/pre-upgrade Job"]
bootJob["identity-cli bootstrap\npost-install Job (optional)"]
end
pgbouncer["PgBouncer\nService :6432"]
end
subgraph postgres ["Postgres cluster"]
pg[("identity schema")]
pgDirect[("Postgres :5432\n(migrator direct)")]
end
server --> pgbouncer --> pg
migJob --> pgDirect
bootJob --> pgbouncer
classDef controlPlane fill:#10b981,stroke:#047857,color:#fff
classDef dataPlane fill:#3b82f6,stroke:#1d4ed8,color:#fff
classDef sandbox fill:#f97316,stroke:#c2410c,color:#fff
classDef external fill:#ef4444,stroke:#b91c1c,color:#fff
classDef persistence fill:#6b7280,stroke:#374151,color:#fff
class server controlPlane
class migJob,bootJob dataPlane
class pgbouncer,pg,pgDirect persistence