All projects

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

Diagram: how the pacod projects fit together LLM calls Tap a project to see what it connects to.

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 act naming 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:

  1. 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.
  2. It then long-polls the changes after that seq, waiting up to 30 seconds per call. The answer comes as soon as something changes: PostgreSQL LISTEN wakes the waiting request.
  3. 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: /readyz checks the database and the signing key. /metrics has 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 lint and 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 acr claim; the docs list them as planned.
  • No address or phone claims; the profile scope gives name and locale.
  • The internal listener speaks plain HTTP, and there is no general rate limit beyond the sign-in throttles.

Further reading

Screenshots

Tech stack

Written in
  • Rust
  • TypeScript
  • Python
Tech stack
  • Rust
  • axum
  • PostgreSQL
  • OpenID Connect
  • SvelteKit
  • Docker
  • Helm

Quick start

Develop and test
make test-servicesmake verifymake e2e
Build and run the image
make imagemake compose-up

Works 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