Identity and access
pacod-id
One place where people and machines sign in — and where it is recorded what they may do, across all your products.
pacod-id is an OpenID Connect provider for first-party applications, with accounts, organisations, role bindings and a change feed that products follow to decide access locally. Written in Rust, with client libraries for Rust, TypeScript and Python.
- Apache-2.0
- Version 0.1.0
- Preview
Features
-
OpenID Connect provider
Sign-in for your own applications, run against the OpenID Foundation's conformance suite.
-
Accounts and organisations
People, organisations, role bindings, service accounts, mail and an audit trail.
-
One rules engine, three ports
Product manifests, scopes, allowed, has_role and touches — in Rust, TypeScript and Python, all passing the same YAML test vectors.
-
Authorization feed
Products follow a change feed of role bindings and decide access locally, without a round trip per request.
-
Tokens done properly
Compact JWS with RS256 and ES256 on ring, JWKS, introspection and token exchange.
-
Branded sign-in
Hosted sign-in and account pages with per-product brands, in light and dark, in four languages.
-
Drop-in clients
A Rust client crate, a Python package with FastAPI dependencies, and a SvelteKit backend-for-frontend sign-in flow.
-
Ready to run
A Docker image, a development stack with mail and a mock Google, and a Helm chart.
Where it fits
Sign-in for a family of products
pacod-id is the identity service behind the pacod products. People sign in once and reach the gateway console, the agent platform, the CMS studio and the knowledge base with the same account. Machines sign in too, as service accounts.
It exists so that no product has to run its own passwords, account mails and admin pages. There
is one account pool: a person's sub is the same in every realm. What differs from product to
product is how sign-in looks and who may do what there, and pacod-id records both.
It is an OpenID Connect provider for your own, first-party applications. Every client is listed in the configuration: there is no dynamic registration and no consent screen.
Realms, clients and tokens
A realm is one issuer host with its own brand, sign-in cookie, languages and policies:
password length, session length, whether anyone may sign up, which roles may manage accounts.
pacod-id picks the realm from the request's host, so two brands can share one account pool and
still look like two products. A realm's brand is one of five built in: gateway, cms, kb,
and shop and bazaar for the fictional product the docs and tests use.
A client is one application, of one of three kinds:
| Kind | Typical use | Grants |
|---|---|---|
confidential |
A product's backend-for-frontend, which keeps a secret | authorization_code, refresh_token |
public |
A native or mobile app; PKCE alone protects its codes | authorization_code, refresh_token |
service |
A product's server talking to pacod-id | client_credentials, token exchange |
Only the authorization code flow is offered, with PKCE (S256) required of every client, confidential ones included.
- Access tokens are JWTs (RFC 9068), RS256 or ES256 per client, valid 5 minutes by default (2 to 15). They say who, in which session and with which scope, but carry no roles.
- id_tokens live 5 minutes and carry the address and name when the scopes ask for them.
- Refresh tokens are opaque and rotate on every use. Presenting one that was already replaced, after a 30-second grace, ends every session of the account and mails its owner.
- Token exchange (RFC 8693) lets a product's service act for a person, within that person's
rights, with
actnaming the service.
Sign-in with Google can be switched on per realm. When a session ends, every client that had a grant in it and has a back-channel logout URI receives a logout token.
Decide access locally
Roles are kept out of access tokens on purpose. Each product pushes a manifest at start: its scope
types, its roles and their permissions, and who may grant what. Scopes form a tree, from
platform, optionally through organisation:<id>, down to the product's own types, such as
gw.workspace:ws_1. A binding is a role held at one scope, and its permissions apply
downward: a role held at an organisation counts in every workspace below it.
product: shop
scope_types:
profile: { parent: platform }
roles:
admin:
at: [platform]
permissions: [admin.read, admin.write, profiles.assign]
protected: true
profile_editor:
at: [profile]
permissions: [profile.edit]
grant:
default: admin.write
pacod-id stores the bindings and checks every change to them: giving a role needs the manifest's
grant permission at that scope, and a protected role never loses its last holder.
The rules engine answers allowed, has_role, touches and can_grant. It exists in
Rust, TypeScript and Python, and all three pass the same YAML test vectors
(policy/engine-vectors/). The vectors are the specification, so a decision is the same
wherever it is made.
The authorization feed
Each product keeps its own copy of the bindings and follows the changes:
- At start it loads a snapshot: its catalogue, the organisations and its scopes, the
bindings of its roles, disabled and deleted accounts, and recently revoked sessions, all as of
one sequence number,
seq. - It then long-polls the changes after that
seq, waiting up to 30 seconds per call. The answer comes as soon as something changes: PostgreSQLLISTENwakes the waiting request. - Changes are kept for 7 days. A cursor older than that gets a 410, and the product loads the snapshot again.
Events cover bindings, accounts, sessions, service tokens, scopes and catalogues. Writes answer with the feed's
seq, so a product that must read its own write waits until its copy has caught up. The Rust
and Python client libraries do all of this in their bindings cache.
Measured with make measure on one developer machine (Docker Desktop on Apple silicon), a
change reached a waiting follower with a p99 of about 10 ms, against a target of under a second.
The pieces
| Part | What |
|---|---|
crates/pacod-id |
The service: OpenID Connect provider, accounts, organisations, role bindings, the authorization feed, mail, audit |
crates/pacod-id-authz |
The rules engine |
crates/pacod-id-jose |
Compact JWS (RS256, ES256) on ring, and JWKS |
crates/pacod-id-client |
Token checks, the bindings cache, introspection, token exchange for Rust products |
python/ |
The pacod_id package: rules engine, token checks, FastAPI dependencies |
packages/* |
@pacod/id-authz, @pacod/ui, @pacod/id-client, @pacod/id-sveltekit |
ui/ |
The hosted sign-in and account pages |
The service is one Rust binary with three listeners:
| Listener | Port | For |
|---|---|---|
| public | 8070 | Browsers and apps, through a TLS reverse proxy: the OpenID Connect endpoints and the hosted pages |
| internal | 8071 | Products' servers only: tokens, the feed, introspection, session revocation, the admin API |
| metrics | 9170 | /healthz, /readyz and /metrics |
The hosted sign-in, account and admin pages are a static SvelteKit build that the binary serves
itself. @pacod/ui carries the design tokens, brand themes and sign-in components that
every pacod product uses, including this website.
Security model
The main controls, from the threat model:
- Passwords: Argon2id at OWASP's minimum (19 MiB, 2 passes), 12 characters by default, and a blocklist of common passwords. Sign-in throttles live in PostgreSQL, shared by every instance: per account, per known browser and per IP address.
- No account probing: sign-in, password reset and sign-up answer alike whether or not an address has an account.
- Signing keys: made by pacod-id, sealed in the database under a key-encryption key
(
PID_KEK), rotated every 90 days with the next key published a day early. An emergency rotation is one command. - Stored secrets: refresh tokens and mail links as SHA-256, client secrets and service tokens as HMACs under a separate pepper. pacod-id's own log lines never carry a password, token or key.
- Exposure: the public listener serves no internal or admin routes, and the Helm chart's NetworkPolicy admits the internal port only from the sources you list. On the shared edge of the full pacod stack, only the public listener has a route.
- Image: a static musl binary on distroless, run as non-root, without a shell.
Running it
Realms and clients live in one TOML file; secrets never do. A client's secret comes from the
variable its secret_env names.
[[realm]]
name = "gateway"
issuer = "https://id.example.com"
brand = "gateway"
locales = ["en", "bg", "de", "ru"]
self_signup = false
[[client]]
id = "pacod-gw-console"
realm = "gateway"
kind = "confidential"
secret_env = "PGW_UI_CLIENT_SECRET"
redirect_uris = ["https://console.example.com/auth/callback"]
audience = "pacod-gw"
pacod-id check-config checks the file without a database. Beyond it, the service needs
PID_DATABASE_URL, PID_KEK and PID_TOKEN_PEPPER, each given as a variable, a file or a
secrets directory.
- Database: PostgreSQL, with a role of its own that is not a superuser. Migrations only add, and run at every start under an advisory lock.
- Instances: any number share one database. Each sends due mails and logouts and reloads keys and catalogues; one, holding a lease, rotates keys and removes old rows.
- Mail: no request waits for SMTP. Mails go to an outbox, in the realm's brand and languages, and are retried for about eight hours.
- First administrator: created at start from
PID_BOOTSTRAP_ADMIN_*. - Watching:
/readyzchecks the database and the signing key./metricshas request counts and durations per listener. Logs are JSON, without a line per request. Sign-ins, role changes and administrators' actions go to an audit log, kept 400 days and readable on the admin pages.
A gateway that signed people in itself can hand them over with pacod-id import gw: passwords,
admin tokens and invitation links keep working.
make compose-up starts a development stack on pacod-net, the Docker network every pacod
product joins. Its compose setup has two files. docker-compose.yml holds pacod-id alone.
docker-compose.standalone.yml adds its own copies of the shared components it needs:
PostgreSQL (pacod-postgres), pacod-db-init, which creates the role pacod_id and its
database at every start when they are missing, and a mail sink (pacod-mailpit), plus the mock
Google (id-mock-idp). The stack listens on 127.0.0.1 only, with the issuer at
http://127.0.0.1:26070.
The full pacod stack, pacod-demo (running at
demo.pacod.io), loads only the first file. It runs one PostgreSQL, one
mail sink, one edge and one pacod-id for every product, with a single realm for all of them.
platform/ holds pacod-id's share of that configuration: its database script, its route on the
shared Caddy and its Prometheus job. On a shared PostgreSQL server, the threat model advises a
non-superuser role for every product: one that connects as a superuser can read pacod-id's tables.
Not there yet
- pacod-id 0.1.0 has not been released. Upgrades, backup and restore have not been exercised.
- The Helm chart passes
helm lintand schema checks but has not been installed on a cluster. A real SMTP server and the real Google have not been tried; the development stack's mock has. - The OpenID Foundation's conformance suite has been run locally: the Config, Basic and RP-Initiated Logout plans with no failed condition, while one Back-Channel Logout test was blocked by the suite's own certificate. Local runs are not a certification.
- No MFA or passkeys yet, and so no
acrclaim; the docs list them as planned. - No address or phone claims; the profile scope gives
nameandlocale. - The internal listener speaks plain HTTP, and there is no general rate limit beyond the sign-in throttles.
Further reading
- Runbook: installing and operating.
- HTTP API: every endpoint and the feed's events.
- Threat model: controls and remaining risks.
- Measurements: token endpoint and feed latency.
- Conformance results and the rules engine's test vectors.
Screenshots
Tech stack
- Written in
- Rust
- TypeScript
- Python
- Tech stack
- Rust
- axum
- PostgreSQL
- OpenID Connect
- SvelteKit
- Docker
- Helm
Quick start
make test-servicesmake verifymake e2emake imagemake compose-upWorks with
-
pacod-gw
The gateway's console can sign people in through pacod-id and checks workspace roles with its rules engine.
-
pacod-ap
The agent platform's app and admin console sign in through pacod-id as a confidential client.
-
pacod-cms
The studio signs people in through pacod-id and holds roles per organisation and per website there.
-
pacod-kb
The knowledge base's UI is an OIDC client of pacod-id; viewer, editor and admin roles are granted there.
Licence
Apache-2.0