Support access grants API
A support access grant is a time-boxed permission that a platform admin asks a tenant for, and that only the tenant's own admin can give. The platform admin requests; the tenant admin approves or denies; either side can end it early.
Not enforced yet
This release adds the grant records, the routes, the approver notification, the require_support_grant setting and the single check function that will enforce it. The check is not yet wired into any log, trace or analytics endpoint, so turning the setting on changes nothing a user can observe today. Wiring it into those call sites is a separate change.
All routes live under /admin/v1/support-access-grants, need a signed-in session, and are refused while "View as User" (impersonation) is active.
| Endpoint | Who may call it | What it does |
|---|---|---|
POST /admin/v1/support-access-grants |
Platform admins | Request access to one tenant; creates a pending grant |
GET /admin/v1/support-access-grants |
Platform admins, tenant admins | List grants (see Listing) |
POST /admin/v1/support-access-grants/{id}/approve |
The tenant admin of the grant's tenant | Approve a pending request |
POST /admin/v1/support-access-grants/{id}/deny |
The tenant admin of the grant's tenant | Deny a pending request |
POST /admin/v1/support-access-grants/{id}/revoke |
The tenant admin of the grant's tenant, or the platform admin who requested it | End a pending or approved grant |
A platform admin can never approve or deny (403). Any other role gets 403 on every route.
A decision on a request whose record carries no organization is answered like a request of a foreign organization: 404, nothing changed, for every caller.
Requesting
| Field | Rule |
|---|---|
tenant_id |
Required, a tenant id. Matched case-insensitively; the tenant's stored id is what is persisted and returned. Absent or "" is 400 tenant_id_required; JSON null, a non-string or a value that is not a tenant id is 400 invalid_tenant_id. Not your own tenant (400, in any letter case). A well-formed id of no live tenant (unknown or deleted) is 404 tenant_not_found; a tenant lookup failure is 503. |
reason |
Required string, 3 to 500 characters after trimming, valid UTF-8. Tabs and newlines are allowed; every other control or formatting character (carriage return, including CRLF, NUL, ESC, DEL, zero-width and bidi-override characters) is rejected. |
duration_minutes |
Optional whole number from 15 to 1440, default 120. A fraction, string, boolean, negative or out-of-range value is rejected; 120.0 is accepted as 120. |
Anything else is 400 with a code (tenant_id_required, invalid_tenant_id, own_tenant, invalid_reason, invalid_duration, or the unknown-key error). Unknown body keys are rejected, never ignored. An absent field and a malformed field are both refused.
A platform admin can hold only one open request per tenant (pending and not lapsed, or approved and not expired): a second one is 409 with code duplicate_open. Two simultaneous requests create exactly one grant; the other gets 409.
Success is 201:
{ "grant": { "id": "...", "tenant_id": "...", "requested_by": "...", "reason": "...", "duration_minutes": 120,
"state": "pending", "requested_at": 1792367240 },
"notified": 2 }
notified is the number of tenant approvers (users with the tenant_admin role of that tenant, not platform admins) the request email was sent to (at most 20). A tenant with no active tenant admin still gets 201 with notified: 0 and a warning in the gateway log; the request stays pending and its admin can still find it with the list call. notified is also 0 when the approver lookup itself failed; see the gateway log. A mail failure never fails the request. The reason appears only in the email body, HTML-escaped.
Approving, denying, revoking
These calls take no body; anything sent is ignored. The response is 200 with the updated { "grant": {...} }. Approving sets approved_by, decided_at and expires_at (decided_at plus the requested duration).
Each change is a single atomic step. A request that is no longer open (already decided, revoked, expired, or pending for more than 24 hours) is 409 with code not_open; double-approving is never a silent success.
A tenant admin of a different tenant, an unknown id and a malformed id all get the same 404 body, so the response does not reveal whether a grant exists.
Approving or denying a request of an organization that has been deleted (but not yet purged) is 404, nothing changed; revoking stays possible until the organization is purged. (The deleted organization's own admins can no longer sign in, so after deletion a revocation is made by the requesting platform admin.)
States
| State | Meaning |
|---|---|
pending |
Waiting for the tenant admin; can be approved for 24 hours |
approved |
Approved and not yet expired |
denied |
Refused by the tenant admin |
revoked |
Ended early by the tenant admin, or withdrawn by the requester |
lapsed |
Derived: still pending after 24 hours; can no longer be approved |
expired |
Derived: approved and expires_at has passed |
lapsed and expired are computed when a grant is read from the stored state and the current time; they are never stored. Times are unix seconds.
Listing
GET /admin/v1/support-access-grants returns { "grants": [...] } newest first, [] when there are none. A platform admin sees only the requests they made (optionally filtered with tenant_id; an empty value means no filter, a value that is repeated, bare or not a tenant id is 400 invalid_tenant_id); a tenant admin sees every grant of their own tenant. limit is 1 to 100, default 50; a value above 100 is capped to 100, and 0 or a non-number is 400 with code invalid_limit. The reason is returned only to these two audiences.
Audit trail
Every request, approval, denial and revocation writes an audit row (support_access.request, .approve, .deny, .revoke, entity type support_access_grant) under the grant's tenant, in the same transaction as the change: if the audit row cannot be written the change is not made and the call is 503.
The require_support_grant setting
A platform setting, default false (off), seeded by migration. It is the switch that makes cross-tenant access by a platform admin require an approved, unexpired grant for that tenant, requested by that same admin. The check function behaves like this:
- Setting off or absent: access is allowed.
- Setting
true, or any other value (including an empty or unrecognised one): the grant is required. An unreadable value fails closed rather than switching enforcement off. - Callers who are not platform admins, and a platform admin working in their own tenant, are never affected.
- A grant for tenant A never satisfies tenant B, and one admin's grant is not honoured for another admin. A database error is a refusal.
- Under "View as User" the rule is applied to the real platform admin behind the session, not to the user being viewed.
Accepted trade-off
Platform admins can change this setting through the normal settings API, so a platform admin could switch enforcement off. This is accepted for the rollout: the setting is the rollout switch, and every change is recorded in the audit log. Whether to make it non-writable once on is an open product decision.
Data retention
- User erasure. When a user is erased, every support access request that user made and that is older than 730 days is deleted (its reason text is personal data); a younger one is kept as audit evidence with the reason erased and the user id unchanged (with the account erased it no longer resolves to a person). A request of the erased user that is still open (pending, or approved and not yet expired) is closed as revoked at the moment of erasure, with no revoker recorded, so nobody can approve it afterwards. Where the user only approved, denied or revoked someone else's request, a grant older than 730 days is kept with that approver, decider or revoker field cleared; a younger grant is kept unchanged as the organisation's audit record for as long as the organisation's other grants are kept (the hourly retention sweep deletes them once they are closed and 730 days old, also after a tenant purge).
- Age bound. The hourly retention sweep deletes every closed grant (denied, revoked, lapsed or expired) 730 days after it was requested, for any organisation or none. A grant that is still open is never deleted by it. Each sweep that deletes, fails or is truncated writes a counts-only audit row (
support_access_grant.retention_purged). - Tenant purge. Purging a tenant deletes its support access grants older than 730 days. Younger grants are kept as audit evidence with the free-text reason erased, are no longer returned by any list or lookup, never count as active access, and can no longer be approved, denied or revoked (
404); the hourly sweep deletes them once they are closed and 730 days old (an open grant is never deleted). Grants are not part of the tenant data export: they are operator-side security evidence, and the audit log remains the record of each change.