DocsAccounts and groupsCardinal 0.7.0

Time-bounded membership

A grant carries a validity period, so access that should stop does, and "who had this in March" is a query.

llms.txt

A grant is not a row that exists or does not. It is a row with a tstzrange: when it started, and when it stops.

cardinal grant engineers alonfils                       # open-ended
cardinal grant prod-oncall alonfils -for 72h            # bounded
cardinal grant prod-access contractor -until 2026-09-01T00:00:00Z \
    -reason "SIEM migration, ticket OPS-4412"

The database enforces that nobody holds two overlapping grants to the same group, using PostgreSQL's WITHOUT OVERLAPS primary key. Expiry is not a cron job that might not run: an expired grant simply stops matching the query.

Grants should normally be bounded. Whoever asks for access almost always knows when they will stop needing it, and a bounded grant cannot be forgotten. The CLI says so when you make one that is not.

Revoking keeps the history

cardinal revoke prod-access contractor
cardinal revoke prod-access contractor -at 2026-08-01T09:00:00Z

This closes the range rather than deleting the row, and the -reason recorded when the grant was made survives the revocation. PostgreSQL splits the range for you, so revoking in the middle of a bounded grant leaves the part that already happened intact.

-at takes an instant in either direction, and they do different things:

  • In the past: the case it exists for. Access actually ended on Friday and somebody said so on Monday, so the record shows Friday. The membership before that instant is untouched; a back-dated revocation shortens the period, it does not erase it.
  • In the future: the end is scheduled. The person stays a member until the instant arrives, then stops being one, with nothing having to run in between.

Both are the same operation on the range, which is why neither needs a job.

Asking about the past

Because validity is data rather than a side effect, an auditor's question is a query:

cardinal members prod-access -at 2026-03-14T09:00:00Z
cardinal memberships alonfils -at 2026-03-14T09:00:00Z
cardinal history prod-access contractor
cardinal history prod-access contractor -at 2026-03-14T09:00:00Z

The last form answers one question: was this member in this group at that instant, which is usually the shape the question arrives in.

The console still shows only now. -at is not a control anywhere in it, which is a gap rather than a decision. The API answers for an instant, so what is missing is the interface rather than the capability:

GET /api/directory/groups/{name}?at=2026-03-14T09:00:00Z
GET /api/directory/users/{login}?at=…
GET /api/directory/groups/{name}/members/{member}/history[?at=…]

An instant that does not parse is refused rather than quietly meaning now, because answering a different question than the one asked and looking like it worked is the worst outcome for an audit query.

What it costs

Every membership query carries a time predicate and the indexes are GiST rather than B-tree. At the scale an internal directory runs at (hundreds of people, thousands of hosts), that is not measurable.

The larger cost is conceptual: there is no "remove from group" that makes the past go away. Deleting history is not something the model can express, which is the point and is occasionally inconvenient.