docs: add Users API reference page#586
Draft
danielfsousa wants to merge 7 commits into
Draft
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a public API reference page for the Users API — the endpoints exposed by the option-gated,
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 incontent/api/table-of-contents.json(aftersubaccounts.apib).Everything in the
.apibis public. Implementation-internal details are intentionally kept out of the page and captured in Reviewer notes below instead.Endpoints documented
GET /api/v1/users— list usersGET /api/v1/users/:username— retrieve a userPOST /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 invitePUT /api/v1/users/:username— update a userDELETE /api/v1/users/:username— delete a userPOST /api/v1/users/:username/subaccounts/DELETE /api/v1/users/:username/subaccounts/:subaccountId— manage subaccount mappingsThe read-side
GET /api/v1/users/:username/subaccountsendpoint was dropped per design review; a user's subaccount access is now documented as thesubaccountsarray embedded in user objects (see Reviewer notes).Also covered
api_key-role grants, attached by an admin through the web app (never by another API key):Users: Viewfor read-only access (theGETendpoints) orUsers: Managefor full management (required by every write endpoint). No account-option / support-enablement step is documented — see the GA rollout note under Reviewer notes.token, documented as a credential. Invitations expire after 3 days; expired invites drop off the pending list automatically; there is no resend (re-invite the same email to create a new independent invitation); invite creation is rate limited (429).access_level(andaccess_policieswhenaccess_leveliscustom); any other field is rejected with403.Reviewer notes (not in public docs)
These are the implementation-internal details behind the public wording, for reviewer context only. They are deliberately not in the
.apib:allow_user_management_via_api(set only by SparkPost support) — that gate is expected to be gone by the time these docs go live.PUT /users/:usernameaccepts onlyaccess_level(plusaccess_policieswhenaccess_leveliscustom); any other field in the body is rejected with403. This replaces the earlier deny-list approach (reject onlypassword/tfa_enabled/is_sso/email, allow names/options through) —first_name/last_name/optionsare no longer editable via the API. The web UI is unaffected and retains full user editing. The page documents this positively (lists the accepted fields) rather than enumerating forbidden ones.429is driven byinviteThrottle(config.emailThrottling.invite):maxPerRecipient= 3,maxPerRequester= 20, over attlof 3600s (1-hour window). The public docs intentionally omit these numbers.config.emailInvite.ttl= 259200s. Expired invites are filtered out of the listing (invite-dynamo.getInvitesisExpiredcheck) and DynamoDB TTL reaps the records.GET /api/v1/users/:username/subaccountsendpoint was dropped per design review. A user's subaccount access is documented instead via thesubaccountsarray embedded in user objects (see next note).lib/models/users.jsformatUserfor List vsgetUserByUsername+lookUpUserfor Retrieve). The page documents them as two objects: User object (List) and Retrieved user object (Retrieve).name,username,access(enum; empty for subaccount-scoped users — nothing is removed top-level),access_policies,email,is_sso,email_verified,tfa_enabled,subaccount_id,last_login,options(only when set). Mapped users additionally getsubaccounts: [{subaccount_id, access_level, subaccount_name}]— 3 fields, nostatus.nameand noaccess; insteadfirst_name,last_name,access_level(omitted entirely for subaccount-scoped users), pluscreated,updated, and an always-presentsubaccountsarray ([{subaccount_id, subaccount_name, access_level, status}], empty for users with no mappings). Whensubaccountsis non-empty, top-levelaccess_levelandsubaccount_idare omitted..apib:customer,cookie_consent,tou,tou_auto_accept,creation_params,auth_migrated,auth_connection,tokens(API-key IDs, not secrets), andcustomer_idinside eachsubaccountsentry.last_loginis returned by List Users but the page does not document it (I could not verify its type/format). Confirm whether it should be added and I will document it.Verification status
Request/response shapes and error bodies were verified against the accusers-api source (
resources/user-controller.js,resources/user-endpoints.js,lib/validator/*,lib/models/*,config/default.json) and the hardening PR SparkPost/accusers-api#1294, and the grant patterns/labels against@sparkpost/access(lib/token-access.js) and access PR SparkPost/access#124.Verified against current code: list users, invite (request/response incl.
token,400,429), revoke invite, update user (updatable fields,Successfully modified user <username>, last-admin400), delete user, add/remove subaccount mapping, the invite lifecycle facts (3-day TTL, expired-invite filtering, throttle →429), and the existing role-ceiling / last-admin / key-revocation constraints. TheUsers: Viewgrant (label "Users: View", read-only GET surface: list, single-user, pending invites) is present in the accessusers-manage-programmaticworking branch.Could NOT yet verify against the pushed PR #1294 / access #124 — documented per the agreed spec and flagged as forthcoming. As of their latest pushes, the pushed PRs don't yet contain these; they line up with the accusers-api work items still in progress (retrieve-user hardening, embedded subaccounts array, pending-invites enrichment + canonical alias, PUT allow-list) and the access grant additions:
Users: Viewgrant — defined in the access working branch but not yet in the pushed access Add redirects for subaccounts docs changes #124 diff. Confirm it ships in the release.GET /api/v1/users/:usernamegrant coverage and a grant-scoped response shape. The page documents the detailedgetUserByUsername/lookUpUsershape (first_name,last_name,access_level,created,updated, always-presentsubaccounts), with the internal fields (tokens,auth_connection,customer, etc. — see Reviewer notes) deliberately omitted. Confirm the grant-scoped response exposes only the documented fields and none of the internal ones.subaccountsarray (replaces the droppedGET /api/v1/users/:username/subaccountsroute). Retrieve a User: always present, entries{subaccount_id, subaccount_name, access_level, status}, top-levelaccess_level/subaccount_idomitted when non-empty. List Users: slimmer{subaccount_id, access_level, subaccount_name}, nostatus, present only for mapped users. Confirm both grant-scoped user responses match these shapes.GET /api/v1/users/pending-invitescanonical path — only/pending-invites/allexists today. Confirm the canonical path is added to the route and both grants.access_level+expires(epoch seconds) — the current listing returns only{id, email, from}. Confirm the enriched fields.access_level/access_policiesand reject every other field with403(supersedes the earlierpassword/tfa_enabled/is_sso/emaildeny-list, which allowed names/options through). Confirm the allow-list is enforced for programmatic callers.Users: ManageGET coverage — the manage grant's currentget_grantscover only/usersand/pending-invites/all; the single-user GET is covered byUsers: View. Confirm manage keys can also reach those read endpoints (or that the docs should scope those reads toUsers: View).This documents an unreleased feature. Hold until the implementation ships:
@sparkpost/access4.13.0)Closes #584
Part of SparkPost/access#121
https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU