CoDNA Request access
Contents

Docs Decisions

0013 · No administrators; management is a token, not a person

Status: Accepted · 2026-10-07

Context

CoDNA had two roles. A member saw what they created and what was shared with them or with a group they were in. An admin saw everything: the policy, the visibility queries, the search filter, automations and export each had a branch that let an admin through, and in the search filter that branch was a query with no ACL clause at all.

Running the instance was tied to the same role. Adding people, filling groups, issuing tokens, reading the status and retrying dead letters were admin-only, so whoever ran the instance could read every document in it, and whoever had to run it had to be trusted with every document. Service tokens (0012) inherited this: each acted as the admin who issued it. A read token was a long-lived credential for the whole knowledge base, usually held by an unattended job. A manage token kept a console away from the content routes, and then needed a list of exceptions — no creating or promoting admins, no resetting or deactivating one — because every way of making or becoming an admin led back to reading everything.

That contradicts the constraint the rest of the design exists for: an agent sees exactly what its person sees. An admin's agent saw the whole company. And a console or other tooling running an instance on someone's behalf held a credential one exception away from reading it.

Decision

Nobody sees everything. A person sees what they created, plus what is shared with them or with a group they belong to. There are no roles. The bypass is gone from the policy, the visibility queries, the search filter, automations and export, and every search filter carries exactly one ACL clause.

Everyone is a group. A built-in group, slug everyone, with a fixed id and no member rows. Every person's group ids include it — added where their groups are read, so someone who joins later is in it from their first request. Sharing with it is how a document reaches the whole organisation, and it notifies nobody. It cannot be renamed or deleted (a database trigger refuses the delete too), and its members cannot be edited or listed.

Running the instance is management's. Management is one built-in principal: a users row with kind = 'management', a fixed id, an empty email and no password. It never signs in, is never shared with, never joins a group and never owns anything. Every service token is a manage token that acts as it, and the codna command on the host acts as it too. Management may manage people, groups and tokens, read the status, retry dead letters, read its own audit, hand on what a departed person owned, and act on a reported document by its metadata (below). It is refused every decision the policy makes about a document: each policy function says no to management before it looks at anything else, and every content route turns a management token away before a handler runs.

Every route has one class: public, authed, content (people only), or manage (management only). No person manages the instance from inside it, the first one included. A test walks every route in each class, not only the ones someone remembered.

A token cannot set a password someone could sign in with. On an instance whose web UI signs in through the identity provider, a manage token sets no password at all, and it never gives one to an account that has none. A reset signs the person out everywhere and tells them in their inbox. The command line may set any password: whoever runs it already holds the database.

When someone leaves, management hands on what they owned. Only away from someone already deactivated, so it is never quiet — they were signed out first — and only to someone active. Documents move whole, trashed ones included, with nothing changed but the owner; who had them before is kept. Automations arrive switched off, to be read by the person they will act as, and without their run history, which was written as someone else. The recipient is told.

Break-glass without reading. Management finds documents by what it may know of them — owner, space, status, dates, sharing — and never by what they say: no title, path, slug, tags or content. It can take a share away, which the owner may undo, or quarantine a document: every share is moved aside and kept, the policy refuses any new one — the owner's included, before it asks who is sharing — and the owner keeps reading and editing it and is told why. A release gives the shares back.

What management does is recorded. management_audit gets one row per change, written in the transaction that makes it: the action, whether it came through a token (and which) or the command line, the ids it touched, and the request id. Never content.

The only complete export is on the host. codna export --all reads every document; it is the one read in CoDNA that no permission bounds, no API call can make it, and each run is in the audit. The API's export is a person's, of what they can read.

The schema change is expand-only. Migration 00002 adds users.kind, groups.kind, the management row, Everyone and the audit table, moves manage tokens to management, and revokes admin and read tokens. It leaves users.role in place, unread, so the previous binary keeps working while an upgrade replaces it; a later migration, 00009, drops it. Transfer (00003) and quarantine (00004) only add.

Consequences

  • Former admins are ordinary people. They see what they created and what is shared with them. Nothing is backfilled: a document an admin could read only through the bypass stays out of reach until its creator shares it.
  • No API call reads everything, whatever the credential. A self-hoster manages with a manage token and the API, or with codna people, groups, tokens, documents, status and events on the host. A full backup is a database dump, or codna export --all.
  • A management credential can still reach content indirectly. Whoever adds people and recovers passwords can create a person whose password they chose, or, on an instance without single sign-on, reset the password of an account that has one and sign in as its owner — or deactivate someone and hand their documents to a person they control. That is inherent in running an instance. The rules above narrow it, and what remains is loud: a reset signs the person out and tells them, a deactivation signs them out, joining or leaving a group tells them, a transfer tells the recipient, and each of these is in the audit.
  • The host's root is fully trusted. It holds the database, so the command line may set any password and codna export --all reads everything. The line this record draws is around credentials that travel — tokens held by a console or other tooling — not around the machine.
  • Groups and accounts are management's alone. A person cannot make a group, put themselves in one, or list the people on the instance.
  • Deactivating someone leaves their documents, grants and automations in place: the grants keep working, and the automations wait. Sharing is the creator's, so nobody can share those documents further until management transfers them; then the new owner has them as if they had made them, document_owner_changes remembers who had them, and the automations wait to be switched on.
  • A reported document can be stopped without being read. The price is metadata: management sees every document's owner, dates and sharing — a sharing graph a person sees only for what they can read — but never what a document says. A person reports one by its id, which the web UI shows for that purpose.
  • Admin and read tokens stopped working at the migration. Tooling that used them needs a manage token, and a backup job that read documents through the API moves to the database or the host.
  • A console that already used a manage token keeps working: the management paths are unchanged, POST /api/users still accepts "role": "member" for one release, and POST /api/users/{id}/role answers 410.

Alternatives rejected

An admin who cannot read. Keep the role, take away the bypass. Rejected because that admin is still a person who manages groups and passwords, and a person who can put themselves in a group or reset someone's password can read whatever that reaches — from an ordinary browser session, with nothing about it looking unusual. A principal that never signs in and can never be a member closes the self-grant, and makes "may this caller manage?" a property of what the caller is rather than a flag on somebody's account.

Everyone as a new principal type. A third principal_type beside user and group, or a special ACL key. Rejected because it reaches every grant query, the policy's matching and the index's keys, and because principal_type is an enum: ALTER TYPE can add a value but not remove one, so the migration could not be cleanly reversed. A group with no member rows reuses all of it unchanged — group:<id> is already a key a person presents, and grants, revocation, access events and sharing already understand groups.

A principal per token. Each token its own row, so a console and a backup job would be different actors. Rejected because it multiplies the identities the policy has to refuse, and because per-principal permissions are how scopes come back, which is how admin and read tokens happened. Telling tokens apart is the audit's job, and it records the token on every row.

Keeping read tokens. Backups and monitoring used them. Rejected because a read token can only exist if somebody can read everything, which is what this record removes, and because it was the credential most likely to leak: long-lived, held by unattended jobs, and good for the entire knowledge base. Monitoring needs counts, which the status endpoint gives management without content; a backup is a database dump, or codna export --all on the host.

Break-glass that reads. An emergency read of content for management, logged. Rejected because a read that is logged is still a read: it is the bypass this record removes, with an audit row attached. Stopping a document from spreading needs to know where it is shared, not what it says; what it says, the person who reported it already knows.

Quarantine as a rule in the queries. A flag every visibility query and the search filter would check. Rejected because each would have to learn it, and the search filter is where forgetting one is a disclosure. Moving the grants aside lets the existing machinery show the document to its owner alone, and keeps them to give back.

Amendment

2026-10-20 — Editors share (0020). "Sharing is the creator's, so nobody can share those documents further until management transfers them" no longer holds: anyone with a write grant on a departed person's page can still share it. What still waits for a transfer is deleting the documents, and taking back the shares the departed person made.