ADR-0024: Access: members, groups and keys, each where grantr allows its read¶
Status: Accepted, amended by ADR-0038 Date: 2026-10-13 Deciders: Alex Nodeland
Context¶
lattice RFC-0002 gives admin an Access area: members, invites, groups, roles (custom ones included), grants on workspaces, threads and artifacts, and service principals and their keys. grantr's API reads and changes each, and portalr's router invites someone through Supabase Auth and adds them to the tenant.
Each of grantr's reads takes the same action as its writes:
| Part | Its read and its changes | Removing and revoking |
|---|---|---|
| Members | grantr:add_member |
grantr:remove_member |
| Groups | grantr:manage_groups |
|
| Keys | grantr:issue_key |
grantr:revoke_key |
The decisions ADR-0021 records hold here:
- destructive actions are in the danger colour, inside a
ConfirmDialog; - whatever a person may not use is hidden;
- on a phone, Access is a list of cards rather than a table scrolled sideways.
Roles and grants need lists grantr's API doesn't read: the actions a custom role may hold, and the roles each kind of resource may be granted. They wait on that decision, and land as 6b-ii. ADR-0025 records them, on grantr's catalog read.
Decision¶
- Access is a route of parts,
/t/$tenant/access/members,/groupsand/keys, under a tab bar of those grantr allows. It asks grantr once per screen, on the tenant, about every action its parts take. A part shows only where its read is allowed. Access joins the rail only where one is, and opens on the first. A part opened by its address that isn't allowed is not found, and says so inside the tenant's frame, under Access's tabs: "There's no such part of Access here". A part whose read fails says so there too, with a way to read it again. - Each part's gate is in
beforeLoad, and its reads are inloader, as ADR-0023 settles who may open a tenant: the checks are read, and a part not allowed is not found, or Access's index redirected to its first part, before any part's read goes out. The screens read the checks live, through admin'suseAccess(tenant), which also giveschanged, so a change shows at once. - Members lists people first, then service principals.
- Remove shows where
grantr:remove_memberis allowed, on everyone but the person signed in. Its dialog says what goes with a member: every role in the tenant and their places in groups, and, for a service principal, every key issued to it. Only service principals have keys. - A person is invited by email, through portalr's
/invites. The answer says they are invited, and nothing more: grantr knows a person by Supabase Auth's id for them, a UUID, which is what Members lists for them, since nothing portalr reads maps an id to an email. A refused invite keeps the email typed. - A service principal is made by name, as grantr makes one: a member of the kind
service_principal.
- Remove shows where
- Groups lists each group with its members, each with their kind beside their id, such as "ci (service principal)".
- Create makes a group under a name not yet taken, and refuses one that is, rather than replace its members.
- Each group's row has "Change members", which reads the groups afresh and starts from that group's members. A change that takes anyone out is confirmed first, in a
ConfirmDialognaming who loses the group's grants. - Members are chosen from the tenant's, where reading them is allowed (
grantr:add_member), and typed asuser:idorservice_principal:idotherwise, each checked before anything is sent. - grantr replaces a group's members whole, and has no version to compare: two admins changing one group at once, the last to save wins, and its members are what they chose. The fresh read narrows that window; it doesn't close it.
- Deleting a group says that its grants go with it and its members keep theirs.
- Keys lists each key by its public id and its service principal, never its secret.
- A key is issued to a service principal, chosen from the tenant's where they may be read, or named otherwise. Where they may be read and there are none, Issue isn't shown: Members is where one is added.
- Its secret is shown once, in a read-only field with a copy button, from the screen's state alone: no query, cache or link holds it. Done clears it, as do leaving Keys and revoking that key.
- Revoke shows where
grantr:revoke_keyis allowed.
- Every change goes through
client's commands, and thenchangedAccessreads grantr's answers in the tenant again, done or refused, as the app's sharing does.orThrowturns a refusal into the error aConfirmDialogstays open to say. ui'sItemTableshows items as a table from 640 pixels, the breakpoint the app's run lists use, each row named by its first cell, and as a list of cards below, each headed and named by its first cell, with its labels and its actions. Only one is shown, so only one is in the accessibility tree. With no items, it is anEmptyState. The app'sRunListmoves onto it in a follow-up.ui'sTabNavandCheckboxGroupare Access's tabs, the current one marked witharia-current, and the choice of a group's members, on Base UI's checkbox group.- The scenarios each run again against the same application, with names made afresh:
- alice invites someone and removes them;
- she adds a service principal, issues it a key that is listed by its id alone, and revokes it;
- she creates a group and deletes it;
- on a phone, Members is a list of cards, with no table.
Options considered¶
| Option | For | Against |
|---|---|---|
| A route per part, under a tab bar (chosen) | Each part reads and hides on its own; a link opens one | A click to reach another part |
| One page of every part | Everything at once | Every read on one screen, for a part the admin came for |
| Show a key's secret once, from the form's state (chosen) | Nothing keeps it after the person leaves | Lost if not copied then |
| Keep it in the query cache for the page | Survives a re-render | A secret held where every read is |
| A table and a list of cards, one shown (chosen) | Each fits its screen; one in the accessibility tree | Two renderings of each item |
| A table scrolled sideways | One rendering | ADR-0021 decided against it |
| Create for new names, Change members per row (chosen) | A name taken is refused; a change starts from the group's members | Two ways in, rather than one form |
| One form that creates or replaces | One way in | Silently replaces a group's members under a name typed again |
Consequences¶
- Easier: Roles and Grants join as parts beside these, once their lists are decided (ADR-0025).
- Harder: a key not copied when issued is lost; issuing another is the remedy.
- Harder: a group's members are last-writer-wins until grantr can refuse a change made against an older version.
- Harder: people are listed by their UUID until portalr reads who an id is.