DocsAdministrationCardinal 0.7.0

Two ways in

Every change reaches Cardinal either through the API as a person, or against the database as whoever holds the connection string. Which one you are on decides whether policy applies.

llms.txt

There are two mechanisms, not two tools. Every change either goes through the API as a person, where policy decides and the journal names who acted, or against the database as whoever holds the connection string, where neither is true.

Through the API Against the database
Authenticates as the person signed in nobody; the connection string is the authority
Governed by policy yes, every request no
Journal records the person direct-database: the path, because there is no person
Needs Cardinal running yes no
Can act as another person no yes

The second row is the one to read twice.

Which tool is on which path

The console is on the API. Every request it makes is evaluated by Cedar like any other.

cardinal, the CLI, is being moved onto it and is not there yet. Membership (grant, revoke, members, memberships, history), creating or disabling anything, reading it with list and show, and every app, posix, token and mail subcommand sign in with a passkey and ask the API, as does cardinal ssh. Everything else still opens the database, and passing -dsn to a command that has moved prints the rule rather than a connection.

Because granting now needs a passkey used minutes ago on a bound device, no unattended process can make one. Cardinal's own end-to-end fixtures were the first thing to hit that, and what they do about it is the honest answer for anybody else in the same position: use the database credential for the one thing it is uniquely for (minting a session) and then make the change through the API as that account, so policy still applies and the journal still names it.

Signing in works from anywhere: the browser handoff where one can reach this machine, and a printed code where it cannot. That matters most for the command run on a server somebody is SSH'd into, which is where a loopback approval cannot arrive.

cardinal-server is on the database by definition: it runs the server, applies migrations, and performs first-run setup. Those happen before anybody can sign in, so there is nobody to authenticate as.

This is mid-migration, and this page will say so until it is not (ADR 0033).

Shell access is administrative access

Not "can escalate to". Is. Whoever holds the connection string can create an administrator, mint a session, or read every credential hash. psql is enough; no Cardinal tool is required.

That is not an oversight to be closed later. It is the deliberate answer to a circularity: enrolling the first passkey needs a session, and getting a session needs a passkey. Cardinal previously had an offline break-glass key for exactly that and removed it, because the database credential was already the true last resort and maintaining a second one (reachable from any browser rather than from a shell) was strictly worse (ADR 0014).

What changed recently is what a shell hands you. The published image used to contain the administrative CLI as its entrypoint, and the configuration it reads carries the connection string, so a shell in a running container was an unauthenticated administrator in one command, with nothing to discover. The image now carries cardinal-server and not cardinal.

Be precise about what that buys: it raises the cost from "type the command you already know" to "know the credential and bring a tool". It closes nothing, because psql exists.

Which to use

Use the console for day-to-day administration. It is the path with policy in front of it, the one that records who did something, and the one where a mistake is refused rather than executed.

Use the direct path for:

  • first run, before anybody can sign in: cardinal-server migrate, cardinal-server init;
  • recovery, when nobody can sign in: cardinal invite <administrator>;
  • undoing a lockout: cardinal policy activate <version>, which must not need the permission the set you are replacing has just refused you;
  • diagnosis during an incident: cardinal decisions, for the same reason;
  • erasure, which is irreversible and should require going to the machine.

Those are chosen by a rule rather than by habit: a command that exists to recover from Cardinal refusing you must not require Cardinal's permission.

Protect the connection string accordingly

Everything else rests on this:

  • It should not be in a shell history, in a CI variable ordinary jobs can read, or baked into an image.
  • Give the application its own PostgreSQL role. It does not need to own the schema it queries.
  • Anybody with a shell on the database host administers your identity system, whether or not they appear in directory-admins.

What the journal says about the direct path

Changes made through the API record the person who made them. Changes made against the database record direct-database, a system service account that means exactly what it says: this came through the command line, and there was no authenticated person to name. It has no passkeys and cannot be signed into.

That is a recent fix, and the previous behaviour is worth knowing because it was worse than an omission. A grant made against the database recorded its own member as the granter (granted_by cannot be null, and their identifier was to hand), so an auditor asking who put alice in engineers was told "alice", with no way to tell that from a real self-grant.

The last place that survived was the worst one: first-run setup, whose single grant is how the first administrator became one. It said they had granted themselves the group, under a comment claiming it did the opposite.

Attribution nobody can check is worse than none, because it reads as evidence. Every bypass is now visible in the journal as a bypass.