Users & access
3 usersIn Cloudflare, not assigned
| Name in Cloudflare | Client ID | Created | Expires | Status |
|---|
| When | Who | Via | Action | Entity | Summary |
|---|
| Name in Cloudflare | Client ID | Created | Expires | Status |
|---|
| When | Who | Via | Action | Entity | Summary |
|---|
What the sheet shows. Column choice is per-operator furniture and stays out of the URL; filters and sort are shareable and stay in it.
MR-218 … MR-224 · design docs/superpowers/specs/2026-09-06-user-management-design.md (v2.1) · requirements docs/requirement-inputs/RI-users.md (RI-306 … RI-340)
SCREENS.md tags Login · SKIP and that stays true — Cloudflare Zero Trust owns authentication at the edge and there is no sign-in form to build. This screen is the part Cloudflare cannot do, and every piece of it is already depended on somewhere else:
An app-issued bearer token was the obvious alternative and it was rejected: it is a second authentication system standing next to Access, and it forces the API path out from behind Access or makes every call carry two credentials. Paul's rule decided it — Cloudflare will always do security better than we do. The app never validates a secret; it validates one JWT signature on the same code path for browser and agent, and everything after that is a lookup: email → a user, common_name → a service token → its user.
The cost is two headers in an agent config instead of one, and a ceiling of 50 service tokens per account. Fine for three people.
Paul, 2026-09-06: “we should be able to use both”. A token minted here writes its row as part of creation. A token minted in the Zero Trust dashboard appears in In Cloudflare, not assigned — the app lists what Cloudflare holds, and an admin assigns it to a user. Nobody ever pastes a Client ID, which is what the list endpoint buys us, and until it is assigned the middleware refuses it, so the strictness of “app-minted only” survives without the restriction.
Disabling a user is one local transaction: status=disabled and every one of their tokens stamped disabled_at. The middleware reads those on every request, so the person is out the instant the transaction commits, whether or not Cloudflare has been told. Only then does the app make Cloudflare agree, in order: each token enabled=false · revoke_user(email) · remove the email from the Access group.
That ordering is the whole reason the write-back can fail safely, and it is why the failed third call reads Cloudflare not yet in sync (2 of 3) rather than an error. Nothing is rolled back — the person is already out, which is the outcome that matters. The one exception is creating a token, where Cloudflare goes first because the row needs its client_id; if that call fails there is simply no row.
Blast radius of a disable, stated on the switch before you touch it: their browser sessions end, their service tokens stop working, and any agent run using one fails at the edge on its next call — including a bulk job already in flight, which will land as partial. Re-enabling does not re-enable their tokens: each is turned back on deliberately.
RX-263 split the two stores and §6 of the guidance keeps them apart: the error log holds unexpected failures, and a remote call the app knows how to retry is a planned checkpoint. So a failed effect raises an Attention item carrying Retry sync, and the row wears the amber chip. The three calls are drawn as a lifecycle rather than a status word because 2 of 3 is not a state a cell can hold — which is MR-066's rule, on its fourth surface.
Cloudflare returns a service-token secret exactly once and the app never stores it, so the panel has one chance to be useful. It shows two values, each copied on its own — Client ID and Client Secret — and nothing else. Neither is printed twice. An earlier draft added a second box repeating both headers as a paste-ready snippet; that put the secret on the screen twice, doubling what a screenshot or a scrollback leaks, and handed over a two-line block when the destination is a single field. A later draft kept the header names on the labels and they read as clutter competing with the values they named — Cloudflare calls these two things the same thing everywhere, so the mapping is not something this panel has to teach. What that gives up: the panel does not say which header takes which value. If that ever goes wrong, the module's i tooltip is where the names belong.
It opens inside API tokens, not at the top of the record, and creating a token does not scroll the page. The button is near the bottom of a long record; a panel that appears above everything moves the page under the person who pressed it, and then they have to find their way back. Here the new token's row is directly below the secret that belongs to it. Not a modal, which would hide that row. Then the panel is replaced by a row that says the secret is gone for good — Rotate is the way back, and it keeps the previous secret alive for a grace window rather than breaking a running job.
role is admin | operator and it gates only the commands on this screen — add, disable, enable, change role, and everything to do with tokens. Everyone may rename themselves. Nothing else in the product reads it. Paul, 2026-09-06, on gating other areas now: no candidates yet, so nothing wider is built; the escalation path is one line in the dispatcher's allowlist, no migration, because role is already a column.
The role lives in the app rather than in a second Access Group because Access Groups are not in the JWT — only IdP-sent groups are, and only as a best-effort claim. One source of truth, one column.
An operator sees the admin controls absent, not disabled. A control you can never use is noise, not information — it invites a click that can only refuse. The one thing the operator view does keep is renaming themselves. View as is on the harness bar and not in the URL, because a role is not a state a user can walk to.
Last-admin guard: both the role control and the status switch refuse a change that would leave the account with zero active admins, and say so where the control is rather than after the click. On the sole admin the switch is disabled with the reason beside it and the confirm dialog never opens — the one place a disabled control is right, because it is a refusal about this record and not a route onward.
Intent, not a diff. action + entity + summary + input answer who changed what, when, and through which door. Field-level before/after is the upgrade and its place is the same dispatcher — snapshot the entity the command names, before and after — with no schema change first.
Written by the dispatcher, never by a handler, so coverage cannot drift: a command that exists is a command that is logged. In the same transaction as the command — if the audit write fails the command fails, which is the deliberate opposite of the error log's best-effort writer. An unlogged change is worse than a refused one; an unlogged failure is not.
Reads are not logged. Ceiling accepted knowingly: if a compliance question ever needs it, the fix is a read allowlist in the same dispatcher.
Logins are an identity_nonce compare, never an iat compare. When a request's JWT carries a different identity_nonce than the one stored for that user, that is an identity-provider sign-in. Measured (S16): the application session lapsing re-mints the JWT with a new iat and the same nonce, silently and with no prompt — so an iat compare writes a “login” every session window for someone who signed in once. Cloudflare’s own login log makes the same mistake, recording that silent renewal as a login row, so there is nothing external to check it against. A service-token request carries no nonce and never writes session.start.
The grid is RX-263's, reused rather than redrawn — facets, keyset Load more, URL-addressable filters, a row that opens onto input · note · provider_refs · effects. A via = token row carries the agent badge with its X-MR-Agent value, which is RX-310's actor column. A bulk agent job's rows share one idempotency key, so three listings pushed by one run read as one run and not as three coincidences.
Retention is forever. Three users produce rows in the low thousands a year, and the table is append-only — BEFORE UPDATE / BEFORE DELETE triggers that RAISE(ABORT), in a checked-in migration.
A History link anywhere else in the product is this state with &entity= set. Nothing is drawn on other screens this round; the audit state only has to prove the destination exists.
The honest cost: the app now holds a credential that can change who gets in, which the first version did not. Three things keep it small — the scope cannot touch Access applications, policies, DNS or anything outside Zero Trust groups and tokens; only admin commands reach the client that holds it, enforced as an import rule; and every use is written twice, here and in Cloudflare's own account audit log under the token's name and IP, which it keeps for 18 months.
The emergency levers are Cloudflare's, and are deliberately not built here: the application's own Revoke existing tokens in the dashboard signs everyone out at once, and deleting the Access application removes the gate in front of the app. Rebuilding either as a button in a screen that a compromised admin session can reach would put the panic switch inside the burning building.
The three users are the people the other mockups already name as actors, not invented ones: Paul (admin, seeded from ADMIN_EMAILS at first boot), DJ — who listed L-198 and drives batch B-0007 — and Dana Whitfield, who confirmed the QuickBooks item on CP-0102 and settled FP-0270's cost on CP-0120. Emails are on envisiagroup.com, the domain the public site already uses. DJ's display name is two letters because that is what every existing fixture says; it is our column and editable, so it is not evidence of anything.
Every audit row points at an entity that exists in SAMPLE-DATA — J-0112, L-201, L-202, CP-0102, CP-0120, SF-0007, B-0007, CA-04 — so a &entity= link is checkable rather than decorative. Dana is disabled with the third Cloudflare call failed, which is where the 2 of 3 chip comes from; DJ can be disabled live, which is where the three calls are drawn as they happen.
Open — needs Paul
1 · Nothing paginates, scopes or ages. Three users and about twenty audit rows is the whole fixture. Load more is drawn because RX-263 requires keyset paging, but it has never been tested against a log with a year in it, and no retention or export control exists anywhere on the screen.
2 · Who may read the audit log? The design gates the users module, and this screen holds both. As drawn the audit state is visible to an operator, so DJ can read what Dana did. That may be exactly right for three trusted people and it is a decision nobody has made out loud.
3 · The agent value is self-declared. It arrives in an X-MR-Agent header the harness sets, so the badge says what the caller claims, not what it proved. The token identifies the person; the agent string identifies nothing. Worth a fixed vocabulary rather than free text if the badge is ever used to decide anything.
4 · A rotation grace window has no number. Rotate keeps the previous secret alive so a running job does not break, and Cloudflare decides for how long. The screen says a grace window exists without saying how long it is, which is honest and not yet useful.