Users & access: report what Cairn can see of the directory #345

Merged
Cordy merged 17 commits from users-access into main 2026-08-27 23:49:24 +00:00
Owner

The page said "Where admins and two-factor are managed", which is false on both counts: neither is managed here, and two-factor is not even visible from here. It now answers where accounts and administrators come from, and what Cairn can see of them.

The rule

Every row is conditional on its source answering, and a row that cannot be filled is absent rather than blank. An empty value reads as "none" — no administrators, nobody signed in. A missing row reads as "not visible from here", which is the truth. Enforced in the response shape: every field is omitempty.

The sharpest case is the administrator list. If an admin group grants rights and Cairn cannot enumerate it, the list is withheld entirely rather than shown with only the statically configured names, because a partial list looks authoritative. TestAdminNamesIncompleteWithoutAdminClient and TestAdminNamesIncompleteOnResolveError pin that down.

What the investigation found

spaces.GroupMembers() already existed — built for peering receiver groups in #243 — so the administrator list and the member count needed no new Keycloak plumbing.

Two of the mockup's rows did not survive contact:

  • Two-factor has no source. Enforcement lives in the provider's own authentication flow (in Keycloak a conditional OTP subflow whose shape varies per deployment) and LDAP cannot answer it at all. The page does not raise the subject. Recorded as a decision in the docs, not left as a gap.
  • "in the last 24 hours" cannot come from sessions. The store is an in-memory map, lost on restart by design. So there are two separate rows: Signed in now from sessions (present tense only) and Active today from the audit log (restart-proof). The same split drives the per-admin line — "Signed in now" when there is a live session, else "Last active N ago".

Provider names the protocol and shows the issuer rather than guessing "Keycloak" from a path segment, which is not knowable.

Backend

  • GET /api/v1/admin/access
  • auth.Sessions.ActiveUsers() — plus an issued timestamp on session entries
  • audit.LastSeen() / audit.ActorsSince(), bounded to 3 months and stopping early
  • main.go captures the session store, which was constructed inline in two branches and never held

Docs

docs/handbook/users-access.md: a table of which row needs which source, the exact Keycloak roles required (view-users + query-groups, explicitly not manage-users), why there are two people-counts instead of one window, and why two-factor is absent.

Also

.audlock.notebox. It was named for the audit page's export note and this page needs the same component twice; renamed before the second use rather than reusing a name that lies about its scope. The admin table treatment moved from a growing list of ids to a .admkv class — this was the third page to want it.

The page said *"Where admins and two-factor are managed"*, which is false on both counts: neither is managed here, and two-factor is not even visible from here. It now answers where accounts and administrators come from, and what Cairn can see of them. ## The rule **Every row is conditional on its source answering, and a row that cannot be filled is absent rather than blank.** An empty value reads as "none" — no administrators, nobody signed in. A missing row reads as "not visible from here", which is the truth. Enforced in the response shape: every field is `omitempty`. The sharpest case is the administrator list. If an admin group grants rights and Cairn cannot enumerate it, the list is **withheld entirely** rather than shown with only the statically configured names, because a partial list looks authoritative. `TestAdminNamesIncompleteWithoutAdminClient` and `TestAdminNamesIncompleteOnResolveError` pin that down. ## What the investigation found `spaces.GroupMembers()` already existed — built for peering receiver groups in #243 — so the administrator list and the member count needed no new Keycloak plumbing. Two of the mockup's rows did not survive contact: - **Two-factor has no source.** Enforcement lives in the provider's own authentication flow (in Keycloak a conditional OTP subflow whose shape varies per deployment) and LDAP cannot answer it at all. The page does not raise the subject. Recorded as a decision in the docs, not left as a gap. - **"in the last 24 hours" cannot come from sessions.** The store is an in-memory map, lost on restart by design. So there are two separate rows: `Signed in now` from sessions (present tense only) and `Active today` from the audit log (restart-proof). The same split drives the per-admin line — "Signed in now" when there is a live session, else "Last active N ago". Provider names the protocol and shows the issuer rather than guessing "Keycloak" from a path segment, which is not knowable. ## Backend - `GET /api/v1/admin/access` - `auth.Sessions.ActiveUsers()` — plus an `issued` timestamp on session entries - `audit.LastSeen()` / `audit.ActorsSince()`, bounded to 3 months and stopping early - `main.go` captures the session store, which was constructed inline in two branches and never held ## Docs `docs/handbook/users-access.md`: a table of which row needs which source, the exact Keycloak roles required (`view-users` + `query-groups`, explicitly **not** `manage-users`), why there are two people-counts instead of one window, and why two-factor is absent. ## Also `.audlock` → `.notebox`. It was named for the audit page's export note and this page needs the same component twice; renamed before the second use rather than reusing a name that lies about its scope. The admin table treatment moved from a growing list of ids to a `.admkv` class — this was the third page to want it.
The admin page can say "signed in now" for a person, but only for people
who actually are. The store is memory-only by design, so this is the
present tense and nothing longer -- which is exactly what the row claims.
The session store is memory-only, so "signed in now" is the only thing it
can answer. Anything with a yesterday in it has to come from the record
that survives restarts.
Every field is omitted when its source cannot answer, rather than sent as
an empty string for the page to render as a blank row. The page shows
what Cairn can actually see of the directory, and the shape of the
response is the honest answer to "what can it see".
Every field is omitted when its source cannot answer, rather than sent as
an empty value for the page to render as a blank row. A missing field
means "cannot see", and the page leaves that row out entirely.
The admin API gains the issuer, the admin group names, the session
store and group->member resolution. GroupMembers is nil when no IdP
admin client is configured, which is what lets the page omit the
administrator list instead of showing a partial one.
Every row on that page is conditional on a source answering. This is the
reference for which permission each row needs, so an operator can tell
"not configured" apart from "broken".
users & access: report what Cairn can see of the directory
All checks were successful
ci / test-and-build (pull_request) Successful in 42s
f9f0099871
The page said 'Where admins and two-factor are managed', which is false
on both counts: neither is managed here, and two-factor is not even
visible from here. It now answers where accounts and administrators
come from, and every row is conditional on its source answering.

A row that cannot be filled is absent rather than blank, because an
empty value reads as 'none' while a missing row reads as 'not visible
from here'. The sharpest case is the administrator list: when an admin
group grants rights and no IdP admin client can enumerate it, the list
is withheld rather than shown with only the statically configured
names, and the Admin client row says why.

Two-factor is not shown at all. Enforcement lives in the provider's own
authentication flow and LDAP cannot answer it, so the page does not
raise the subject rather than deriving a confident sentence from a
guess. docs/handbook/users-access.md records that as a decision.

.audlock becomes .notebox: it was named for the audit export note and
this page needs the same component twice.
Cordy merged commit ae5e631a19 into main 2026-08-27 23:49:24 +00:00
Cordy deleted branch users-access 2026-08-27 23:49:26 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#345
No description provided.