Time-bounded membership
A grant carries a validity period, so access that should stop does, and "who had this in March" is a query.
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.