Skip to content
Updated Aug 28, 2026 by barcadvorakova-starkys · Owner: analysisactiveconceptgenericapi-a Edit on GitHub

Users & Groups — API Analysis (API-A) ​

Concept layer — frozen. The Users & Groups generic. Nothing here is written by a normal playbook run; a project's own feature analysis is the live document and takes every edit. This layer names no project and links to none — the dependency runs one way, from an application to its concept.

Feature: Users & Groups

Paths below carry no version or gateway prefix: how a project versions and mounts these routes is an application decision, not part of the concept. Success envelope: { data, status, message, requestId }. An error carries a stable machine-readable code alongside the human-readable message, and a refusal names the capability the caller was missing — which turns "why can't I?" into a support answer rather than a ticket.

Two surfaces, not one ​

The directory is read far more widely than it is written, and the two audiences want different things. The concept separates them:

Administrative surfaceConsuming surface
Who calls itdirectory administratorsevery feature with an assignee or a mention
Operationscreate, update, activate, deactivate, delete, curate membershipread only
Reacheveryone the administrator may seethe people visible in the caller's scope
Fieldseverything, including audit columnsthe least a name can be rendered from

Merging them yields one endpoint whose behaviour depends on the caller's capabilities, which is hard to document and harder to test. Keeping them apart also keeps the administrative capabilities away from ordinary users, which is where the interesting leaks come from.

Everything below is described for people. Groups are symmetric — same shapes, same pagination, same activate/deactivate/delete semantics — and are called out only where they differ.

Endpoint index: the git host renders an outline from the ## headings below — there is no hand-maintained endpoint list to fall out of date.


1. List people ​

Endpoint: GET /users

Description ​

Paginated listing, for administration tables and for pickers that can page.

Query Parameters ​

ParameterTypeRequiredDescription
page / pageSizeIntegerNoDocumented defaults; page size has a hard maximum
activeBooleanNoFilter by usable state
displayNameStringNoCase-insensitive substring
contactStringNoCase-insensitive substring
sortEnumNoFrom a closed list

An unrecognised filter or sort value should be rejected, not quietly treated as absent. Treating it as absent turns a typo into a listing of everybody, which is the wrong direction for a mistake to fail in.

Request Logic ​

  • Excludes deleted records and applies the scope predicate — whose shape follows from the scoping decisions on the feature page.
  • Runs a count query and a page query. They share no snapshot, so a concurrent write between them can make the reported total disagree with the rows returned. Harmless in a table, and worth knowing before it is reported as a defect.

Success Response (200 OK) ​

json
{
  "data": {
    "users": [
      {
        "id": "bbbbbbbb-0000-4000-8000-000000000001",
        "displayName": "Anna Novak",
        "contact": "anna.novak@example.com",
        "active": true
      }
    ],
    "page": 1,
    "pageSize": 20,
    "totalCount": 1,
    "totalPages": 1
  },
  "status": 200,
  "message": "Fetched users.",
  "requestId": "7b0a1a41-fd2a-4bd9-9c2c-0f57db3e3f15"
}

2. List people without pagination ​

Endpoint: GET /users/all

Description ​

The same listing, unpaginated, for dropdowns and mention suggestions — surfaces that cannot page.

Request Logic ​

Identical predicate and filters, and no limit. Response size grows with the directory, with no back-pressure and nothing watching it.

This is the endpoint that will eventually hurt. It is acceptable only under a stated assumption about directory size, and that assumption belongs next to it. Two ways out when it stops holding, both better than raising a limit: make it a search that requires a couple of characters and caps its results, or return only identifiers the client can already resolve. Decide which before it is needed.

Success Response (200 OK) ​

The same objects as the paginated listing, without the pagination metadata.


3. Read one person ​

Endpoint: GET /users/{userId}

Description ​

Detail for the administration panel. Typically the only read returning the avatar reference — a table does not need it, and it is the largest field.

Error Responses ​

StatusWhen
400Malformed path identifier — rejected before any query runs
404No live person holds that identifier

Error messages leak the surface they were written for. A not-found message mentioning a scope, returned by an endpoint that has no scope, means the code was shared between the two surfaces without its wording being revisited. Cheap to get right, and quietly confusing for years if not.


4. Create a person ​

Endpoint: POST /users

Request Body ​

FieldTypeRequiredDescription
displayNameStringYesShown wherever the person appears
contactStringYesFormat- and length-validated; unique among live records
avatarRefStringNoAbsolute reference; defaults to empty
activeBooleanNoDefaults to true
provisionSignInBooleanNoAlso create the external sign-in account; defaults to true

Request Logic ​

  • Validates the contact detail before touching the database.
  • Checks that no live person already holds it, compared case-insensitively. The constraint is the real guard; this check exists only to produce a friendlier error than a unique violation.
  • Writes the record, then — unless opted out — creates the external account and links it.
  • On provisioning failure, retires the record just written. On linking failure, keeps both and logs for manual repair. See the feature page, Provisioning a sign-in account alongside the record.

Transactional Operations ​

The insert commits before the external call. The two systems are reconciled by compensation, not rollback: a second write undoes the first. A process that dies between them leaves a live record with no sign-in account — the one inconsistency this shape can leave, and one to state rather than let a reader discover.

Success Response (201 Created) ​

json
{
  "data": { "userId": "bbbbbbbb-0000-4000-8000-000000000010" },
  "status": 201,
  "message": "Created user.",
  "requestId": "f2d93550-51f5-4e14-95b2-4fef7d9f1a2a"
}

Error Responses ​

StatusWhen
400The contact detail fails format or length validation
409A live person already holds it — from the pre-check, or from the constraint when two requests race
500The external identity system refused; the record created moments earlier has already been withdrawn

5. Update a person ​

Endpoint: PUT /users/{userId}

Description ​

Replaces the display name, contact detail, avatar and usable state.

Request Logic ​

  • A replace is not a merge. Omitting the avatar clears it. A project preferring partial updates should use PATCH and say so — what it must not do is name the method PUT and behave like PATCH, because a caller will eventually omit a field it meant to keep.
  • Matches on the identifier excluding deleted records, and reports "matched no row" as not-found, which is how a deleted or unknown person surfaces.
  • Takes no lock and re-reads nothing: concurrent edits resolve last-writer-wins with no detection. Defensible for a directory — state it, rather than implying an optimistic locking that is not there.

6. Activate and deactivate a person ​

Endpoints: POST /users/{userId}/activate · POST /users/{userId}/deactivate

Description ​

Separate endpoints rather than a flag on the update, because they are separate acts deserving separate capabilities: an administrator may be trusted to suspend someone without being trusted to rename them.

Request Logic ​

  • Idempotent — activating an already-active person succeeds and moves only the timestamp.
  • Deactivation does not by itself end an existing session. Whether it should is a project decision, and whichever way it goes, the caches involved must be named. Silence here is how a suspended person keeps working until their session happens to lapse.

Success Response (200 OK) ​

Returns the state it set. Note set, not re-read — a response that must reflect the row as it now stands has to be returned by the write itself.


7. Delete a person ​

Endpoint: DELETE /users/{userId}

Description ​

Removes the person from the directory and withdraws their ability to sign in. The record is retained — see the feature page, Deactivation is not deletion.

Request Logic ​

  • Reads the person's identity links first, so their cached lookups can be cleared afterwards.
  • Locks the row, marks it deleted and inactive, and marks every identity link deleted, in one transaction. The lock is what makes a concurrent double-delete safe: the second caller finds no live row and gets not-found.
  • Clears the cached identity lookups after the commit, so a failure there leaves the person deleted while a cached lookup may briefly still resolve to them.
  • What else the deletion touches — memberships, access grants — is the decision described on the feature page, and belongs in this endpoint's documented logic. It is the difference between a clean removal and a deleted person who still holds access.

Success Response (204 No Content) ​

No body.


8. Groups — where they differ ​

Endpoints: GET /groups · GET /groups/all · GET /groups/{groupId} · POST /groups · PUT /groups/{groupId} · POST /groups/{groupId}/activate · POST /groups/{groupId}/deactivate · DELETE /groups/{groupId}

Same shapes as the person endpoints, with a name in place of a display name, and no contact detail, no avatar and no external provisioning — which removes the only multi-system step in the capability and leaves group creation genuinely atomic.

Four differences worth documenting rather than leaving to be inferred:

  • Group names are usually not unique, and nothing arbitrates concurrent creation, so two live groups can share a name. That is tolerable for a set that is chosen from a list and referenced by identifier, and intolerable if anything resolves a group by name. Decide, and say so.
  • A group whose scope is derived rather than stored has no single answer to where it belongs — and the two derivation rules a project ends up with will disagree. See the feature page, Scope membership.
  • Deactivating or deleting a group withdraws whatever access it granted its members, so both must invalidate the access-decision cache. Only a rename may skip it.
  • Deleting a group does not delete its members, and leaves the membership rows pointing at a deleted group. They stay out of reads only because every membership query joins the group and requires it live — which is a load-bearing detail, not an incidental one.

9. Add people to a group ​

Endpoint: POST /groups/{groupId}/members

Request Body ​

FieldTypeRequiredDescription
userIdsUUID[]YesNon-empty; every identifier must name an existing person

Request Logic ​

  • Refuses if the group does not exist or is not live.
  • Refuses the whole addition if any named person is missing. A partial success leaves the caller unable to say what happened.
  • Skips people already in the group, so repeating the call is harmless.
  • Deduplication must rest on a uniqueness constraint over the group-and-person pair, not on reading the membership list inside the transaction. Two concurrent additions each read a list without the other's row, and both insert. The application check is a friendlier error, never a guarantee.
  • On commit, invalidates the access-decision cache for every person added, in every scope the group's grants reach.
  • Follow-up work — seeding a new member's notification preferences, for instance — is best-effort: logged when it fails, never fatal to the addition.

Success Response (201 Created) ​

Returns the memberships actually created, so a caller can tell what was new from what was already there. An empty list therefore means everyone requested was already a member — which is only unambiguous because the group's existence was checked first.


10. Remove people from a group ​

Endpoint: DELETE /groups/{groupId}/members

Request Logic ​

  • Marks the membership rows removed rather than erasing them, so "who was in this group in March" stays answerable.
  • Returns the rows actually affected, and checks the group exists first. Without that check an empty result is ambiguous: it means either "nobody named was a member" or "there is no such group", and the caller cannot tell which.
  • Invalidates the access-decision cache on the same terms as the addition. Removing a member is an access-removal act; forgetting to invalidate here leaves the withdrawn access live.

A body on a DELETE is a wart. Removing a batch needs a list, and a DELETE conventionally carries none. The alternatives — one call per person, or a POST to a removal sub-resource — are each awkward in their own way. Whichever a project picks, pick it consistently with the addition and record why.


11. The assignable-people read ​

Endpoint: GET /users/assignable

Description ​

The read every consuming feature calls to populate an assignee picker. It is the one endpoint here reachable with an ordinary user's capability rather than an administrator's, which makes it the highest-risk read in the capability.

Request Logic ​

  • Live, active people in the caller's scope, carrying the name, contact and avatar a picker renders.
  • The scope predicate is the whole endpoint. An assignable-people read that omits it returns the entire directory — every name and contact detail in the system — to any ordinary user. It looks identical to the correct version in every respect but the predicate, it passes every functional test, and nothing in the response reveals the fault.
  • Returning contact details is a genuine trade-off: it is what disambiguates two people with the same display name, and it exposes everyone's contact detail to everyone who can open a picker. A project may prefer a partially masked form; either way, state the choice.

Success Response (200 OK) ​

json
{
  "data": {
    "users": [
      {
        "id": "bbbbbbbb-0000-4000-8000-000000000001",
        "displayName": "Anna Novak",
        "contact": "anna.novak@example.com",
        "avatarRef": null
      }
    ]
  },
  "status": 200,
  "message": "Listed assignable users.",
  "requestId": "510c4619-e02e-7bb9-0183-e6336d1ff989"
}

Endpoints that sit here but belong elsewhere ​

A person's detail path attracts sub-resources owned by neighbouring capabilities: their external identity links, their scope memberships, the access they effectively hold. They are routed under the person because that is where an administrator looks for them, and they are documented with the capability that owns them, not here.

Listing them at the foot of this page — endpoint, capability code, and where each is documented — costs a table and saves the next reader from concluding that the directory owns authentication.