Skip to content

docs: add Users API reference page - #586

Open
danielfsousa wants to merge 8 commits into
mainfrom
feat/users-api-docs
Open

docs: add Users API reference page#586
danielfsousa wants to merge 8 commits into
mainfrom
feat/users-api-docs

Conversation

@danielfsousa

@danielfsousa danielfsousa commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a public API reference page for the Users API: the endpoints reachable by the api_key-role user-management grants that let an API key manage the users on an account.

New page content/api/users.apib, registered under the Accounts category in content/api/table-of-contents.json (after subaccounts.apib).

Everything in the .apib is public. Implementation-internal details are kept out of the page and captured in Reviewer notes below instead.

Endpoints documented

  • GET /api/v1/users — list users
  • GET /api/v1/users/:username — retrieve a user
  • POST /api/v1/users/invite — invite a user (creation is invite-based only)
  • GET /api/v1/users/pending-invites — list pending invites (canonical path)
  • DELETE /api/v1/users/pending-invites/:id — revoke a pending invite
  • PUT /api/v1/users/:username — update a user
  • DELETE /api/v1/users/:username — delete a user
  • POST /api/v1/users/:username/subaccounts / DELETE /api/v1/users/:username/subaccounts/:subaccountId — manage subaccount mappings

The read-side GET /api/v1/users/:username/subaccounts endpoint was dropped per design review (accusers-api 9a1ca17ce); a user's subaccount access is documented instead as the subaccounts array embedded in user objects.

Also covered

  • Prerequisites / grants. A key needs one of two api_key-role grants, attached by an admin from the web app and never by another API key: Users: View for the three GET endpoints, or Users: Manage for the same reads plus every write. No account-option or support-enablement step is documented; see the GA rollout note under Reviewer notes.
  • Constraints. An API key can never assign admin, even when an admin owns it. Below admin, the role ceiling still applies. The last admin cannot be deleted or demoted. A key cannot delete its own owner.
  • Invite semantics and lifecycle. Creation is invite-based only. The response returns the invite id and nothing else: the registration token is never returned to any caller. Invitations expire after 3 days, expired invites drop off the pending list automatically, there is no resend, and invite creation is rate limited (429).
  • Update is role-only. PUT accepts access_level and access_policies and rejects any other field with 403.
  • Subaccount mapping rules. POST .../subaccounts requires the target to already hold at least one mapping; DELETE .../subaccounts/:id refuses to remove the last one. Both 400s are documented.
  • Two-factor, email-verification, SCIM, and password endpoints are out of scope.

Verified against the code

Every endpoint, field, response shape, and error body on the page was checked against the feat/users-manage-programmatic branches of accusers-api and access, including the uncommitted lib/errors.js refactor that sets the current userMessage strings.

Checked: route registrations (resources/user-endpoints.js), the grant definitions and labels (@sparkpost/access lib/token-access.js), the response shapes (lib/models/users.js formatUser and getUserByUsername, resources/user-controller.js lookUpUser), the invite listing fields (lib/models/helpers/invite-dynamo.js), and every validator behind the documented status codes.

Corrections made in this PR after re-verification

  • Invite no longer returns the token. accusers-api 086008f1f stopped returning it from POST /users/invite for all callers. The earlier revision of this page documented token in the response and carried a "treat the token as a credential" banner. Both are gone.
  • API keys cannot assign admin. The programmatic cap in lib/access/role-ceiling.js and lib/validator/edit-user-validator.js rejects admin on invite, create, and PUT, even for an admin-owned key. The earlier revision said an admin-owned key could assign up to admin. Now its own constraint, and admin is dropped from the invite and update request enums.
  • Dropped the key-revocation constraint. Reverted in 5b1dbdee5: API keys are account-owned and get reassigned to another admin on owner deletion, not revoked.
  • Documented the two subaccount-mapping 400s (has-account-subaccount-access, last-subaccount-check), neither of which was on the page.
  • Real error bodies. Placeholder "Forbidden" bodies replaced with the userMessage strings the service sends. The Retrieve 404 is "User not found." (userNotFoundOptions), not the delete validator's "User does not exist".
  • access is omitted, not empty, for subaccount-scoped users: a subaccounts-only invite never sets access_level on the user record.
  • last_login documented on the List Users object (ISO string or null, per lib/validator/auth0-post-login-action.js). This closes the open question from the earlier revision.
  • access_policies on PUT is optional when access_level is custom, not required (areValidPolicies only forbids it for non-custom roles).

Reviewer notes (not in public docs)

  • GA rollout / no option-gate wording. The page is slated to publish at GA, after the account-option gate is removed, so it omits any "contact support to enable" step. Both grants still carry option: 'allow_user_management_via_api' in the access branch; that gate is expected to be gone by publish time.
  • Invite rate-limit internals. The 429 comes from inviteThrottle (config.emailThrottling.invite): maxPerRecipient 3, maxPerRequester 20, over a 3600s window. The public page omits the numbers.
  • Invite expiry source. The documented 3-day expiry is config.emailInvite.ttl = 259200s. Expired invites are filtered out in invite-dynamo.getInvites and reaped by DynamoDB TTL.
  • List vs Retrieve response shapes. These genuinely differ, and the page documents both as separate objects rather than papering over it.
    • List item (formatUser): name, username, access, access_policies, email, is_sso, email_verified, tfa_enabled, last_login, subaccount_id, options (only when set). Mapped users also get subaccounts: [{subaccount_id, access_level, subaccount_name}], with no status.
    • Retrieve (getUserByUsername + lookUpUser): first_name/last_name instead of name, access_level instead of access (omitted for subaccount-scoped users), plus created, updated, and an always-present subaccounts array ({subaccount_id, subaccount_name, access_level, status}). Top-level access_level and subaccount_id drop out when subaccounts is non-empty.
    • The list response also attaches a link object per user, and auth_migrated. Neither is documented.

⚠️ One open item for the implementation

lookUpUser applies no grant-scoped field filtering. A programmatic GET /users/:username currently returns customer, cookie_consent, tou, tou_auto_accept, creation_params, auth_migrated, auth_connection, tokens (API-key IDs, not secrets), and customer_id inside each subaccounts entry. The page deliberately documents only the supported subset, so publishing as-is is fine, but someone should decide whether the programmatic response ought to be trimmed before GA.

⚠️ Do not merge/publish until the feature ships

This documents an unreleased feature. Hold until the implementation ships:

  • SparkPost/access#124 (grants, @sparkpost/access 4.13.0)
  • SparkPost/accusers-api#1294 (endpoint hardening)
  • SparkPost/auth-api#319 (grant enforcement)

Closes #584
Part of SparkPost/access#121


Note

Low Risk
Documentation-only change with no runtime or security logic; risk is limited to accuracy relative to the unreleased Users API implementation.

Overview
Adds a new Users API reference (users.apib) under the Accounts section in table-of-contents.json (after subaccounts.apib).

The page documents programmatic user management via Users: View and Users: Manage API key grants: list/retrieve users, invite-only onboarding (no password create), pending invites, role-only PUT, delete, and subaccount mapping add/remove. It spells out constraints (no admin via API, role ceiling, last-admin protection, no self-delete), invite lifecycle (3-day expiry, no token in responses, rate limits), and the intentional List vs Retrieve response shape differences. SCIM, 2FA, and password flows are explicitly out of scope.

Reviewed by Cursor Bugbot for commit fb3c200. Bugbot is set up for automated code reviews on this repo. Configure here.

Document the option-gated Users API exposed by the api_key-role
`users/manage-programmatic` grant (label "Users: Manage"): list users,
invite a user, list/revoke pending invites, update and delete a user,
and manage a user's subaccount mappings.

Covers the prerequisites (the `allow_user_management_via_api` account
option enabled by support, plus an admin attaching the grant via the web
app), the invite-based-only creation flow and treating the invite token
as a credential, the update field deny-list (password, tfa_enabled,
is_sso, email), and the role-ceiling / last-admin / key-revocation
constraints. Registered under the Accounts category in the table of
contents.

Closes #584
Part of SparkPost/access#121

Do not merge/publish until the feature ships. Implementation PRs:
SparkPost/access#124, SparkPost/accusers-api#1294, SparkPost/auth-api#319.

Claude-Session: https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU
Remove non-public details: the account option key, the internal
auth_migrated user field, and the PUT restricted-fields enumeration.
Prerequisites and the update endpoint are now worded positively (what
is enabled / what is updatable) rather than naming internal config or
forbidden fields.

Add the invite lifecycle (3-day expiry, expired invites drop off the
pending list automatically, no resend — re-invite instead, 429 when
rate limited), the Retrieve a User and List a User's Subaccounts
endpoints, and the canonical GET /v1/users/pending-invites listing
(replacing the legacy /all variant). The Invite object now carries
access_level and expires.

Claude-Session: https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU
These docs publish at GA, after the account-option gate is removed, so
there is no "enabled by SparkPost support" prerequisite at publish time;
remove that wording and the contact-support banner.

Document both grants: `Users: View` (read-only, GET endpoints) and
`Users: Manage` (full management; required by every write endpoint). The
only remaining prerequisite is that an admin attaches the grant to the
key via the web app.

Claude-Session: https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU
Retrieve a User returns first_name/last_name, access_level, created,
updated, and an always-present subaccounts array (with status) - a
different shape from the List Users item. Document them separately.

Claude-Session: https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU
PUT now changes a user's role only; any other field in the request
body is rejected with 403.

Claude-Session: https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU
@danielfsousa danielfsousa self-assigned this Jul 31, 2026
Verified every endpoint, field, and error body against the
feat/users-manage-programmatic branches of accusers-api and access.

Corrections:

- Invite no longer returns the registration token. accusers-api
  086008f1f stopped returning it from POST /users/invite entirely, so
  the response is just `{ id }`. Dropped the token from the example and
  replaced the "treat the token as a credential" banner with a note that
  the token only ever reaches the invitee by email.
- API keys cannot assign `admin`. The programmatic cap in
  role-ceiling.js and edit-user-validator.js rejects admin on invite,
  create, and PUT, even for an admin-owned key. The page previously said
  an admin-owned key could assign up to `admin`. Added it as its own
  constraint and dropped `admin` from the invite and update request
  enums.
- Removed the "key revocation on owner deletion" constraint. Reverted in
  5b1dbdee5: keys are account-owned and get reassigned, not revoked.
- Add a Subaccount Mapping requires the target to already hold at least
  one mapping (has-account-subaccount-access), and Remove a Subaccount
  Mapping refuses the last one (last-subaccount-check). Both documented
  with their 400 bodies.
- Replaced placeholder "Forbidden" bodies with the userMessage strings
  the service actually sends, and fixed the Retrieve 404 to
  "User not found." (userNotFoundOptions, not the delete validator's
  "User does not exist").
- `access` is omitted, not empty, for subaccount-scoped users; a
  subaccounts-only invite never sets access_level on the user record.
- Documented `last_login` on the List Users object. It is an ISO string
  or null, per auth0-post-login-action.js.
- Update a User: access_policies is optional when access_level is
  custom, not required.

Copy pass for readability: no em dashes, active voice, plainer wording.
@danielfsousa
danielfsousa marked this pull request as ready for review August 1, 2026 00:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Document the Users API (User Management via API Keys)

1 participant