Skip to content

Security design

How openKMS thinks about security: principles and trust boundaries. For mechanics (login flows, env vars, curl examples, sharing tables), use the linked feature pages—not this one.


Principles

  1. Authenticate before data — Product APIs require a known principal. The public surface is intentionally small (marketing home, health, public-config, system name).

  2. Two layers, both must pass — Operation permissions answer “may this user use Documents / Console / …?” Resource ACL answers “may this user see this channel, KB, or wiki?” Coarse gates do not replace per-resource sharing. See Data security.

  3. Restrict data by explicit choice — With no ACL rows on a resource or its container chain, any authenticated user who holds the right operation key can access it. Turning on sharing is opt-in closure, not opt-in openness.

  4. Platform admin ≠ data superuser — JWT admin / catalog key all can run Console and configure sharing, but read and write on content still follow resource ACL when sharing is configured. That supports least-privilege and audit-friendly deployments.

  5. Identity outside, authorization inside — In OIDC mode the IdP proves who someone is; PostgreSQL holds the permission catalog, roles, groups, and ACL. Realm role names map to security_roles.name; API keys snapshot that mapping at creation time.

  6. Least privilege in the catalog — Keys are feature-sized (documents:read, console:groups, …). The built-in all key is for bootstrap; operators are nudged toward granular keys via the Console permission reference.

  7. Secrets stay out of git and argv — Credentials live in environment or a secret store. The CLI never accepts cloud storage keys on the command line. Object access uses presigned URLs after authorization checks.

  8. Power paths are visible — Arbitrary read-only Cypher (Object Explorer), internal model credential routes, and service subjects (local-cli) are separate trust decisions—not “just another API.”


Audit log

Every mutation, whoever made it — a person in the UI, a script, an agent. It is not an agent feature: the question "what changed this dataset, and did it work?" belongs to anyone who can change one.

  • Written by a middleware (AuditMiddleware), so no endpoint can forget it and a new feature needs no change. Mutations only (POST / PUT / PATCH / DELETE); reads are volume without a destruction story.
  • Subject, generically: resource_type is the path's first segment, and resource_id is the id in the path when there is one — otherwise the id of the JSON object the route returned. That is how POST /api/whatever naming nothing in its URL still records what it created, without any feature being special-cased here.
  • A name, not just an id. resource_label is the label a reader recognises: from the JSON response (display_name first, then name / title / label / api_name), or — when the response carries nothing, as a 204 delete does — from the handler, which is the only code that knows what it is about to destroy: request.state.audit_label = fn.display_name. An id answers "which row"; a name answers "what did I lose".
  • And when nobody volunteered it, the read fills it in: one query per page takes the newest resource_label any row recorded for the same resource_id — a create or a patch usually did — so a 204 delete shows the name it used to have, for every entity, including rows written before the column existed. Only an object that was never created or renamed through this API has no name to find.
  • Failures are the point: a 4xx/5xx is recorded, and a handler that raises is recorded as 500 with detail.error = the exception type before the exception propagates.
  • Its own session (a request rollback must not erase the record of the attempt), and it swallows only SQLAlchemyError/OSError, loudly: an audit outage must not turn a successful write into a 500.
  • A sign-out names its actor from the cookie's token, verified: an unverified decode would let a forged token name itself in the log, which is the misattribution an audit log exists to prevent.
  • One session event, one row. The SPA syncs the token twice after a sign-in and calls clear-session twice after a sign-out; each pair lands in the same second. The writer serialises the same (path, actor) pair under pg_advisory_xact_lock and drops a repeat inside five seconds — a client cannot be relied on to be careful, so the idempotence lives where the write happens.
  • One sign-out, one event. The SPA calls clear-session once deliberately and again from its session-invalid handler seconds later; by then the cookie is gone. Nothing was ended, so nothing is recorded — the same for an expired or unverified token. Only a live session produces a row.
  • No request bodies — they carry secrets. Response bodies are buffered only for application/json under 32 KB, and only to read an id.
  • Read it three ways: the app's user menu → Audit log (/audit-log; the item appears only to accounts holding console:permissions, which is what the API requires), GET /api/console/audit-events (console:permissions, keyset paging), or openkms audit list --actor/--resource-type/--resource-id/--method/--path/--since.

What "every operation" actually covers. Not everything:

Reaches the log Does not
Every POST / PUT / PATCH / DELETE over HTTP — 2xx, 4xx, 5xx, authenticated or not Reads — deliberate: volume without a destruction story
Worker runs (job_app.task wraps each task, so this cannot be forgotten): JOB job/<task>, the job id as subject, 500 with the exception type when it raises Direct database access and migrations — an app-level log cannot see those; that is what a database audit log is for
Sign-in and sign-out: AUTH auth/login (POST /api/auth/login for local auth; for the SPA's PKCE flow, POST /sync-session?reason=login — the only call the backend sees from a browser sign-in, marked once by the client), AUTH auth/login-failed (with the account attempted as data — nobody authenticated), AUTH auth/logout (GET /logout and POST /clear-session — one act, one event) /sync-session — a cookie copy that fires on every app load and changes nothing after the first

A worker run and the HTTP call that enqueued it are two rows with the same detail.job_id: "asked by X" and "ran, and here is how it ended".

Not done: retention (nothing prunes it), and reads of sensitive data. Soft delete is separate — this log makes an incident readable, not undoable.

Agents as actors

An agent is meant to do a person's work, so the goal is not fewer capabilities — it is a blast radius you can live with when the agent is wrong, and a trail you can read afterwards. Ordered by what actually bites:

1. Destruction has no undo

The risk is not a hostile agent; it is a misread instruction. DELETE /api/datasets/{id}, DELETE /api/stories/{id}, objects delete, kubernetes delete are hard deletes today: there is no soft delete, no trash, no general audit log, and no backup job in the repository. An agent that confuses two datasets destroys one and nothing can bring it back.

Safeguard State
Backups with a tested restore ✓ scripts, not yet run on this machine (pg_dump absent): scripts/pg_backup.sh (custom-format dump, prunes to the newest N) and scripts/restore_drill.sh (restores into openkms_drill, compares row counts, drops it). A backup that has never been restored is a hypothesis — run the drill
A log of every mutation ✓ audit_events, written by AuditMiddleware — method, path, status, the key's owner, and the resource (see below)
Soft delete / row retention ✗ not implemented (deleted_at exists nowhere)
-y confirmation before a write ✓ in the CLI — but a client-side convention, bypassable by design
"Destructive operations need a human's explicit yes" ✓ in the skill's instructions — prompt-level, not enforcement

2. Attribution: which agent, not just which person

A per-agent key also makes the audit log answer "which agent", not just "which person".

A human's key makes every action look like that human. user_api_keys already carries what is needed for the opposite: purpose, agent_conversation_id, last_used_at, key_prefix, revoked_at — and secrets are stored hashed.

Safeguard State
One key per agent, same role ✓ supported (purpose + agent_conversation_id) — no capability is lost, only ambiguity
Revoke one agent without touching the person ✓ revoked_at
Expiry ✗ no expires_at — a key lives until revoked, so rotation is a habit, not a guarantee

3. Prompt injection through content — the likeliest incident

This system's job is to read documents, wiki pages, KB chunks and articles. That text is untrusted, and an agent that treats it as instruction will do what a document says: delete, export, rewrite.

Safeguard State
Retrieved content is data, never instructions ✓ stated in the agent guidance; the one rule that must never be relaxed
No destructive tool in the same context as untrusted content without confirmation ✓ by convention
Provenance on retrieved text partial — citations exist, "untrusted" marking does not

4. Blast radius: secrets and the console

/internal-api/* (provider credentials) is behind an internal service JWT, not a user key ✓ — but console:* still reaches deploy secrets, kubeconfigs and model credentials. An agent doing analysis work does not need those, which makes them the one place worth narrowing for reach, not for ability.

5. The rest

  • No application-level rate limiting ✗ — a looping agent can hammer the API; the CLI's timeouts are the only brake today.
  • /openapi.json is public ✓ by design (it describes endpoints, not data); every call is authorised.
  • Egress: the agent runs in a network that can reach the API and (sometimes) not the public internet; treat outbound allow-listing as a control, not a nuisance.

What to do first

  1. Install the client tools and run the drill — brew install libpq (or postgresql-client), then scripts/pg_backup.sh && scripts/restore_drill.sh. The scripts exist and their preflight is verified; the round trip has not run here, so treat it as untested until it prints restore drill ok.
  2. A key per agent (purpose, agent_conversation_id) — cheap, and turns "did I do that?" into a query.
  3. Keep the injection rule (content is data) in the agent's instructions, verbatim.
  4. Then decide on soft delete and an audit log; both are real work and both make the next incident readable.

Trust boundaries

flowchart TB
  subgraph clients [Clients]
    SPA[Browser SPA]
    Agent[Scripts / agents / CLI]
  end
  API[FastAPI backend]
  IdP[OIDC IdP]
  PG[(PostgreSQL)]
  Store[(S3 / MinIO)]
  VLM[VLM server]

  SPA -->|session or Bearer| API
  Agent -->|Bearer / Basic| API
  SPA -.->|PKCE| IdP
  API -.->|JWKS| IdP
  API --> PG
  API --> Store
  API -.->|worker only| VLM
Boundary Responsibility
Browser Route gate from permission-catalog patterns; backend remains authoritative on every /api/* call.
Backend Verify JWT/session/API key; apply operation checks and resource ACL; issue presigned URLs only after access checks.
IdP (OIDC) Authentication and realm roles; not the ACL catalog.
Object storage Bytes at rest; access mediated by the backend, not direct public buckets for documents.
VLM server Parsing only; keep off the public internet; workers call it internally.

Two-layer model (summary)

Layer Question Detail
Operation RBAC Can the user open this feature or Console tool? Data security — Layer 1, Console & authentication
Resource ACL Can the user read/write this instance? Data security — Layer 2

Optional strict mode (OPENKMS_ENFORCE_PERMISSION_PATTERNS_STRICT) tightens Layer 1 so every /api/* path must match catalog patterns—documented in Configuration.


Identity modes

Mode Typical use Design intent
OIDC (default) Enterprise SSO Single sign-on; map IdP realm roles to openKMS roles; no passwords in openKMS.
Local Dev, air-gapped, small installs openKMS stores users and issues JWTs; still require TLS and strong secrets in production.

Login behaviour, API keys, and token examples: Console & authentication.


Deliberate non-goals (today)

  • No implicit “admin sees every channel” for regulated tenants.
  • No automatic ACL filtering on arbitrary Object Explorer Cypher—gate with operation permissions and deployment policy instead.
  • No committed secrets or long-lived tokens treated as low risk—see Tech debt — API tokens.

You need… Read
Sharing, groups, inheritance, enforcement code Data security
Console, catalog, OIDC/local login, obtaining tokens Console & authentication
Env vars and toggles Configuration
Deploy and auth-mode alignment Docker operations
Schemas Data models — Data security

Reporting vulnerabilities

If you find a security issue, report it privately (do not open a public issue with exploit details).