permesi Identity and Access Management
Prerequisites (intentional):
- System:
zsh,podman,tmux,jq,curl,xh,ripgrep(rg) - Languages:
rust(stable),node(LTS) - Infrastructure:
just,terraform,vault,mkcert,direnv
These tools are required to run a real IAM stack locally: isolated services, TLS everywhere, Vault-backed cryptography, and reproducible infrastructure.
# 1. Clone the repo
git clone https://github.com/permesi/permesi.git
cd permesi
# 2. Allow listening on privileged ports (Linux only, for HAProxy on :443)
just haproxy-sysctl
# 3. Ignite the engine, This will open a tmux session with all services running in panes.
just start
# 4. (Optional) run firefox in deveper mode:
just firefoxjust start launches the full infrastructure (Postgres, Vault, Jaeger,
HAProxy) and starts the services (genesis, permesi, and the web console)
in a tmux session using Unix domain sockets for backend communication. If
tmux is not installed, it will start the infra and you can run the services
manually.
Verify the stack is healthy:
- Web Console: https://permesi.localhost
- API (Permesi): https://api.permesi.localhost/health
- API (Genesis): https://genesis.permesi.localhost/health
- Tracing (Jaeger): http://localhost:16686
This repository is a Rust workspace (monorepo) containing:
services/permesi: core IAM / OIDC authorityservices/genesis: edge admission token mintcrates/admission_token: shared admission token contract + sign/verify helpersapps/web: CSR-only Leptos admin console (Trunk + Tailwind, staticdist/)
Note: service HTTP modules live under src/api/ (previously src/permesi/ and src/genesis/).
permesi employs a Split-Trust Architecture to separate network noise from core identity logic.
- Role: Public-facing edge service.
- Responsibility: Handles raw TCP/HTTP connections, sanitizes inputs, and issues short-lived admission tokens. Abuse controls (for example, rate limiting and challenges) are currently enforced at the edge layer (for example, Cloudflare WAF) while native controls remain on the Genesis hardening roadmap.
- Output: Issues a short-lived, cryptographically signed Admission Token.
- State: Stateless / Ephemeral.
- Key Publication: Publishes a PASERK keyset at
GET /paserk.json.
- Role: The OIDC Authority.
- Responsibility: OPAQUE signup/login, email verification, and OIDC flows.
- Trust Model: Verifies Admission Tokens from
genesisoffline (signature +exp+aud+iss) without callinggenesisduring normal request handling. Validates short-lived Zero Tokens offline using the PASERK keyset for auth POSTs. - Output: Issues standard OIDC Access/ID Tokens (JWTs).
- Role: System of Record.
- Usage: Stores user records (OPAQUE registration records), email verification tokens/outbox, plus Audit Logs and Revocation Lists. It is not required for the hot-path verification of Admission Tokens, ensuring high availability even during DB latency spikes.
To bootstrap Postgres without the local container flow, run the SQL directly. db/sql/ is the
single source of truth for dev containers and bare-metal setups:
# 1) Create Vault root users, runtime roles, grants, and load schemas (edit passwords first).
psql "postgres://<admin>@<host>:5432/postgres" -v ON_ERROR_STOP=1 -f db/sql/00_init.sqldb/sql/00_init.sql uses dev defaults (vault_genesis / vault_permesi with the same password).
For production, update those passwords and remove the seed_test_client.sql include before
running it, or use it as a template for your own bootstrap script. If you choose not to run
db/sql/00_init.sql, load the service schemas directly with db/sql/01_genesis.sql and
db/sql/02_permesi.sql.
For scheduled maintenance, db/sql/cron_jobs.sql is the only place where pg_cron jobs are
registered (run it against the postgres database). Application schemas never install or
schedule pg_cron jobs directly.
- Admission tokens: PASETO v4.public (Ed25519).
genesissigns via Vault Transit; private keys never leave Vault. Public keys are published via a PASERK keyset for offline verification. - permesi encryption: Vault Transit key type
chacha20-poly1305(defaulttransit/permesi/ keyusers) for encrypt/decrypt operations. - OPAQUE (user auth): Client-side OPAQUE; server stores only the registration record. The server setup seed is stored in Vault KV v2 (
opaque_server_seed).
Admission token verification never calls genesis on the hot path. The flow is:
genesissigns a PASETO v4.public token with Vault Transit and puts the PASERK ID (k4.pid...) in the token footer askid.permesiparses the footerkid, looks up the matchingk4.public...key in the PASERK keyset, and verifies the signature.permesivalidates claims (iss,aud,action,iat/exp, TTL). If any check fails, the request is rejected.
Keyset behavior:
active_kidis only used bygenesisto choose the signing key. Verification always uses the token's footerkid.- When configured with a PASERK URL,
permesicaches/paserk.json(default TTL 5 minutes) and refreshes it on unknownkidwith a cooldown. No per-request calls are made. - When configured with a local file or JSON string, verification is fully offline (no network fetches).
Missing / planned:
- Optional revocation mode (DB lookup or cached revocation list). There is no public token introspection endpoint.
Organizations are the tenant boundary in permesi. Each organization owns projects, projects own environments, and environments own applications. Org-scoped membership and roles are the source of authorization for tenant resources, and environment tiers enforce a single production environment per project with non-production blocked until production exists.
More details and the creation flow live in services/permesi/README.md under “Organization
endpoints and authorization”.
flowchart LR
subgraph Internet["Untrusted: Internet"]
U[User / Client]
end
subgraph Edge["Trust Boundary: Edge"]
G["genesis<br/>edge admission token mint"]
PASERK[("PASERK<br/>GET /paserk.json")]
end
subgraph Core["Trust Boundary: Core IAM"]
P["permesi<br/>core IAM / OIDC authority"]
end
subgraph Data["Optional: Data Plane"]
DB[("Audit / Revocation DB")]
end
U -->|1. Request admission| G
G -->|"2. Signed Admission Token (PASETO)"| U
G -->|Publishes public keys| PASERK
P -->|Loads PASERK keyset at deploy/startup| PASERK
U -->|3. Credentials + Admission Token| P
P -->|"4. Offline verify: sig + exp + aud + iss"| P
G -.->|"Optional audit write (jti)"| DB
P -.->|"Optional revocation check (jti)"| DB
All auth POSTs require a Genesis zero token (validated offline using the PASERK keyset).
sequenceDiagram
participant U as User / Client
participant G as Genesis (Edge)
participant P as Permesi (Core)
participant DB as Postgres
Note over U, G: Zero token mint
U->>G: Request zero token
G-->>U: Zero token
Note over U, P: OPAQUE login
U->>P: /v1/auth/opaque/login/start + zero token
P->>P: Verify token (PASERK keyset)
P-->>U: credential_response + login_id
U->>P: /v1/auth/opaque/login/finish + zero token
P->>P: Verify token (PASERK keyset)
P->>P: OPAQUE finish (no password sent)
P->>DB: Persist session
P-->>U: 204 + Set-Cookie (session)
Note over U, P: Session hydration
U->>P: /v1/auth/session (cookie)
P->>DB: Load session
P-->>U: 200 session or 204
Signup uses /v1/auth/opaque/signup/start + /finish and email verification uses /v1/auth/verify-email + /v1/auth/resend-verification (all require zero tokens).
Users sign up with email and password, then can register passkeys from /console/me/security once they are logged in. The login page prioritizes passwordless flows; users enter their email and can sign in with a passkey, or expand the password form if they want to use OPAQUE.
MFA enforcement is consistent across login paths. If TOTP is enabled for the account, the login flow always proceeds to the MFA challenge after either password or passkey authentication succeeds.
flowchart TD
Signup[Signup: email + password] --> Verify[Email verification]
Verify --> Console["/console/me/security"]
Console --> AddPasskey[Register passkey]
Login[Login: enter email] --> Passkey{Use passkey?}
Passkey -->|Yes| PasskeyAuth[Passkey auth]
Passkey -->|No| ShowPassword[Show password fields]
ShowPassword --> Opaque[OPAQUE password login]
PasskeyAuth --> Session[Session issued]
Opaque --> Session
Session --> MFA{TOTP enabled?}
MFA -->|Yes| Challenge[MFA challenge]
MFA -->|No| Success[Signed in]
Challenge --> Success
Administrative endpoints (bootstrap and elevation) are strictly rate-limited to 3 attempts per 10 minutes per user to protect against Vault token brute-forcing. Consecutive failures trigger a 15-minute cooldown.
| Method | Path | Notes |
|---|---|---|
POST |
/v1/auth/opaque/signup/start |
OPAQUE registration start; requires zero token |
POST |
/v1/auth/opaque/signup/finish |
OPAQUE registration finish; requires zero token |
POST |
/v1/auth/opaque/login/start |
OPAQUE login start; requires zero token |
POST |
/v1/auth/opaque/login/finish |
OPAQUE login finish; requires zero token |
POST |
/v1/auth/verify-email |
Consume email verification token; requires zero token |
POST |
/v1/auth/resend-verification |
Resend verification link; requires zero token |
Vault is required for both services in production (AppRole auth or Agent proxy, dynamic DB creds, transit encryption, and the OPAQUE seed in KV v2). Running without Vault is not supported.
The vault-url (and its env equivalents) supports two operational modes:
- TCP Mode (
http://orhttps://):- Requires
vault-role-idandvault-secret-id(orvault-wrapped-token). - The application performs the AppRole login and manages background token/lease renewals.
- Requires
- Agent Mode (
/path/to/socketorunix:///path/to/socket):- Connects to a Vault Agent
api_proxyvia a Unix domain socket. - No role/secret IDs are required.
- The application delegates authentication and renewals to the Agent.
- Requirement: Vault Agent must be configured with
use_auto_auth_token = true.
- Connects to a Vault Agent
Production readiness checklist:
- HA cluster with tested failover.
- Automated unseal or a documented unseal runbook.
- Backups plus restore drills (e.g., raft snapshots or storage backups).
- Monitoring and alerts for health, sealed state, and token/lease renew failures.
cargo build -p permesicargo build -p genesis- Terraform (v1.5+): Required for provisioning local Vault infrastructure.
just web: Tailwind build/watch + Trunk dev server.just web-build: production build (apps/web/dist).- Node.js is only required for CSS tooling; the output is fully static.
- Frontend env is compile-time (via
option_env!). SetPERMESI_API_BASE_URL,PERMESI_TOKEN_BASE_URL, andPERMESI_CLIENT_IDbefore build. PERMESI_CLIENT_IDis public (embedded in WASM); store it in GitHub Actions Variables, not Secrets.
Default ports: genesis 8000, permesi 8001, web 8080.
Local HTTPS is the default for development. HAProxy terminates TLS for permesi.localhost, api.permesi.localhost, and genesis.permesi.localhost using a mkcert-issued certificate, then forwards to the services over TLS using Vault-issued certificates. just start launches HAProxy with TLS termination on port 443. The Trunk dev server runs on 8081 behind HAProxy and binds to 0.0.0.0 for container access.
If HAProxy can't reach host services on macOS, it falls back to host.docker.internal automatically.
In socket mode, the HAProxy container runs with your current UID/GID so it can open the 0660 Unix sockets created under .tmp/ without widening local socket permissions.
If you want to run services manually instead of using the all-in-one just start (socket mode):
- Run services:
just genesis-socketandjust permesi-socket(orjust start-httpfor the TCP flow). They auto-source.envrc, so direnv is optional.
just start uses tmux when available to start a permesi session with genesis + permesi + web panes, plus a fourth pane for ad hoc commands.
If you're already inside tmux, it creates the permesi session in the background and prints attach instructions.
Re-running attaches to the existing session when not inside tmux; stop with tmux kill-session -t permesi.
Because AppRole SecretIDs are single-use (secret_id_num_uses=1), just genesis and just permesi fetch a fresh
SecretID before each cargo watch run using the Vault CLI. Make sure vault is installed and authenticated (via
VAULT_ADDR/VAULT_TOKEN or your Vault token helper).
If you want infra only: just dev-start-infra then just dev-envrc (this also runs direnv allow if available).
If Postgres init scripts didn't run (for example, an existing db/data), run just db-bootstrap
to (re)apply schemas and runtime roles, then just db-verify to confirm constraints.
Cleanup: just stop to stop containers, and just reset to remove the infra containers, wipe Vault data, and delete local Postgres data/logs (db/data, db/logs).
just dev-envrc emits Vault credentials plus local endpoints. In local dev, both services use TLS certificates issued by a single Vault PKI CA. PERMESI_ADMISSION_PASERK_CA_PATH should point at the Genesis Vault CA bundle when fetching paserk.json directly from the Genesis service.
PERMESI_TLS_PEM_BUNDLE=.../certs/permesi/tls.bundle.pemPERMESI_ADMISSION_PASERK_CA_PATH=.../certs/genesis/ca.pemGENESIS_TLS_PEM_BUNDLE=.../certs/genesis/tls.bundle.pemPERMESI_ADMISSION_PASERK_URL=https://genesis.permesi.localhost:8000/paserk.jsonPERMESI_FRONTEND_BASE_URL=https://permesi.localhostPERMESI_API_BASE_URL=https://api.permesi.localhostPERMESI_TOKEN_BASE_URL=https://genesis.permesi.localhostPERMESI_PASSKEYS_ALLOWED_ORIGINS=https://permesi.localhostPERMESI_OPERATOR_TOKEN(used for/admin/claim)
just signup-verify-url: Extract the latest email verification link from the database.just operator-token: Generate a fresh platform operator token for admin claim/elevation.just db-verify: Confirm database constraints and schema state.just openapi: Regenerate OpenAPI specs from code.
Passkey registration is available in preview mode by default and does not persist credentials without additional storage.
Configure the relying party and origin validation via PERMESI_PASSKEYS_RP_ID, PERMESI_PASSKEYS_RP_NAME, and PERMESI_PASSKEYS_ALLOWED_ORIGINS, adjust challenge TTL with PERMESI_PASSKEYS_CHALLENGE_TTL_SECONDS, and toggle preview behavior with PERMESI_PASSKEYS_PREVIEW_MODE. Persisting passkeys would require a dedicated table to store credential_id (bytes), user_id, public_key (serialized passkey), sign_count, transports, created_at, and last_used_at (nullable).
Passkeys require HTTPS. The repo includes a local HAProxy config at config/haproxy/haproxy.cfg that terminates TLS and routes permesi.localhost, api.permesi.localhost, and genesis.permesi.localhost to the usual local ports. Generate a local certificate with mkcert, combine it for HAProxy, then run the container:
just mkcert-local
just haproxy-start
Manual steps (if you prefer to run them directly):
mkcert -install
mkcert -key-file config/haproxy/certs/permesi.localhost-key.pem \
-cert-file config/haproxy/certs/permesi.localhost-cert.pem \
"localhost" "127.0.0.1" "::1" "*.localhost" \
"permesi.localhost" "api.permesi.localhost" "genesis.permesi.localhost" "*.permesi.localhost"
cat config/haproxy/certs/permesi.localhost-cert.pem config/haproxy/certs/permesi.localhost-key.pem \
> config/haproxy/certs/permesi.localhost.pem
podman run -d --name permesi-haproxy \
--add-host=host.containers.internal:host-gateway \
-p 443:8080 \
-v "$(pwd)/config/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro" \
-v "$(pwd)/config/haproxy/certs:/usr/local/etc/haproxy/certs:ro" \
docker.io/haproxy:latest
On Linux, binding to :443 may require allowing unprivileged ports: sudo sysctl -w net.ipv4.ip_unprivileged_port_start=443 (persist with a sysctl.d config if desired). If you want to avoid IPv6 resolution issues, add IPv4 host entries via just localhost-hosts.
You can run just haproxy-sysctl to apply the sysctl setting.
To test bootstrapping the first admin or elevating privileges, you need a Vault token with the permesi-operators policy.
The dev bootstrap automatically generates one and prints it to stdout (or exports it via just dev-envrc).
- Copy the Operator Token from startup logs or run
echo $PERMESI_OPERATOR_TOKEN. - Navigate to
https://permesi.localhost/admin/claim. - Paste the token and submit to claim the operator role.
This repo treats the OpenAPI specs as versioned artifacts, checked in under:
docs/openapi/permesi.jsondocs/openapi/genesis.json
Regenerate them from code:
cargo run -p permesi --bin permesi-openapi > docs/openapi/permesi.jsoncargo run -p genesis --bin genesis-openapi > docs/openapi/genesis.json
podman build -f services/permesi/Dockerfile -t permesi:dev .podman build -f services/genesis/Dockerfile -t genesis:dev .podman build -f apps/web/Dockerfile -t web:dev .
Send OTLP traces directly to the local Jaeger collector:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317Open the Jaeger UI at http://localhost:16686 to inspect traces.
We welcome contributions of all kinds!
- Read the Agent & Contributor Contract: It contains mandatory guidelines on code style, security invariants, and module organization.
- Pick an issue: Check the TODO.md or open issues.
- Run tests:
just testcovers the full workspace. - Linting: We use strict Clippy rules. Run
just clippybefore submitting.
Note: This project uses a "Reference Quality" approach. We prefer small, well-documented, and secure diffs over large refactors.
cargo fmt --all -- --checkcargo clippy --all-targets --all-featurescargo test --workspace