Team
The Team API covers the people inside an organisation: the active
members, their roles, and the pending invitations not yet accepted.
Members live on the OrgMember collection (one row per
(organizationId, userId) pair); invitations live on OrgInvite
with a 7-day expiry. The four-role system — owner, admin,
member, viewer — is fixed: customers cannot define new
roles, and the audit trail relies on stable role names that don't
drift between customers. Tested against API version v1. Mounted
at /api/v1/team. JWT auth on every endpoint except the two
public invite-acceptance routes; mutations require owner or
admin.
The four roles
| Role | What it can do |
|---|---|
owner | Everything, including delete the organisation, purge traces, and remove other owners |
admin | Manage team, policies, agents, alerts, integrations, billing checkout; cannot delete the org or hard-delete an alert |
member | Create / edit own resources, resolve review items, read dashboards |
viewer | Read-only across every resource the dashboard surfaces; cannot mutate anything |
Roles are stored on OrgMember.role per (org, user) pair. A user
can be owner of org A and viewer of org B at the same time;
the JWT carries the role for the active org and the
Auth API's switch-org endpoint re-mints the
JWT with the role appropriate for the new org.
The role list is enforced both at the route layer
(requireRole('owner', 'admin') middleware) and at the validator
layer (role body field allow-list). Bypass attempts on either
layer return 400 VALIDATION_ERROR or 403 FORBIDDEN.
Endpoints
GET /api/v1/team/members
PATCH /api/v1/team/members/:userId/role
PATCH /api/v1/team/members/:userId/status ── suspend (deactivate)
PATCH /api/v1/team/members/:userId/reactivate
DELETE /api/v1/team/members/:userId
GET /api/v1/team/invites
POST /api/v1/team/invite
DELETE /api/v1/team/invites/:id ── revoke
POST /api/v1/team/invites/:id/resend
# Public (no auth)
GET /api/v1/team/invites/validate/:token
POST /api/v1/team/invites/:token/accept
What each role gates concretely
The role list is short, but the dashboard surfaces dozens of actions. The matrix below is the canonical resolution for the load-bearing differences:
| Action | owner | admin | member | viewer |
|---|---|---|---|---|
| Read traces, decisions, dashboards | ✓ | ✓ | ✓ | ✓ |
| Create / edit policies | ✓ | ✓ | — | — |
| Create / edit agents, alerts, integrations | ✓ | ✓ | ✓ † | — |
| Resolve review-queue items | ✓ | ✓ | ✓ | — |
| Invite members, change roles | ✓ | ✓ | — | — |
| Delete an alert | ✓ | — | — | — |
| Start Stripe checkout / open Customer Portal | ✓ | ✓ | — | — |
| Purge traces, delete the organisation | ✓ | — | — | — |
† A member can edit resources they created; an admin
can edit resources any member created. The line is enforced at the
controller level on each resource, not at this layer.
The asymmetric owner-only powers (delete-org, purge-traces,
delete-alert) are intentional: each is a non-recoverable action
that one accidentally-promoted admin should not be able to
trigger.
Member response object
GET /members returns an array of objects shaped:
| Field | Type | Description |
|---|---|---|
userId | string | User ObjectId |
name | string | Display name |
email | string | Login email |
role | enum | One of owner / admin / member / viewer |
joinedAt | string (ISO 8601) | When the member's OrgMember row was created |
isActive | boolean | false after a suspend; the member appears greyed-out in the dashboard |
mfaEnabled | boolean | TOTP-enrolled flag from the User record |
lastLoginIp | string | null | Last login IP, useful for suspicious-access investigation |
createdAt | string (ISO 8601) | User-account creation timestamp (may pre-date joinedAt if the user joined another org first) |
List members
GET /api/v1/team/members
Returns every active member of the caller's org with
role, joinedAt, isActive, mfaEnabled, and the user's
name, email, createdAt, and lastLoginIp. Suspended
members appear with isActive: false; deleted members do not
appear. Errors: 401, 500.
Change a member's role
PATCH /api/v1/team/members/:userId/role
Body: { role: 'owner' | 'admin' | 'member' | 'viewer' }.
Mutations to owner are permitted but the new owner does not
displace the existing owner — you can have multiple owners
simultaneously. Demoting the last owner returns
400 VALIDATION_ERROR; an organisation must always have at
least one active owner. Writes one
team.member.role_changed entry to the
Operations Audit Log. Errors: 400,
401, 403 (role), 404, 500.
Suspend / reactivate / remove
PATCH /api/v1/team/members/:userId/status ── flips isActive=false
PATCH /api/v1/team/members/:userId/reactivate ── flips isActive=true
DELETE /api/v1/team/members/:userId ── removes the OrgMember row
The three endpoints are graduated:
- Suspend flips
User.isActivetofalse. The user keeps theirOrgMemberrow and any open JWTs return401 ACCOUNT_DEACTIVATEDon the next request. A dashboard re-login attempt fails until reactivation. - Reactivate is the inverse —
isActive: trueand membership intact. - Remove hard-deletes the
OrgMemberrow. TheUserdocument survives because the user may still be a member of other organisations. Removed users see no traces, no policies, no settings of the org they were removed from.
Suspend before remove is the recommended sequence: an admin who
suspends gets time to recover the account before a
non-recoverable removal. Removing the last owner returns
400 VALIDATION_ERROR with the same one-active-owner invariant
as the role-change endpoint.
Invite a member
POST /api/v1/team/invite
Body: { email, role? }. The email gets an
invitation message with an opaque token; the OrgInvite
document is created with expiresAt = now + 7 days. The
default role is member if omitted.
curl -X POST https://api.adjudon.com/api/v1/team/invite \
-H "Authorization: Bearer $ADJUDON_JWT" \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]", "role": "admin" }'
The response contains the invite ID + email + role + expiry.
The token itself is not returned to the caller — only
the invitee's email contains it. Inviting an email that is
already a member returns 400 VALIDATION_ERROR with
code: 'ALREADY_MEMBER'. Re-inviting an email with a pending
unexpired invitation returns the same code; use resend instead.
Errors: 400, 401, 403, 500.
List, resend, and revoke invites
GET /api/v1/team/invites ── list pending invites
POST /api/v1/team/invites/:id/resend ── re-send the email + reset expiry
DELETE /api/v1/team/invites/:id ── revoke the invitation
GET /invites returns invitations whose expiresAt > now;
expired invitations are filtered out automatically. resend
emits the same email content and resets expiresAt to seven
days from the resend moment — the token itself stays the
same so the invitee's existing email link still works.
DELETE permanently revokes the token; a subsequent accept
attempt returns 404 with the validate endpoint surfacing
{ valid: false, reason: 'revoked' }.
Public: validate and accept
GET /api/v1/team/invites/validate/:token
POST /api/v1/team/invites/:token/accept
These two endpoints are public because the invitee has not
yet authenticated to Adjudon. validate returns the org name,
the role being offered, the inviter's email, and a valid
flag — expired or revoked tokens return valid: false
without leaking the org name. accept consumes the token,
creates the User if the email is new, creates the OrgMember
row, mints a JWT, and writes a team.invite.accepted audit
entry.
curl -X POST https://api.adjudon.com/api/v1/team/invites/$TOKEN/accept \
-H "Content-Type: application/json" \
-d '{ "name": "Alice Schmidt", "password": "..." }'
A token can be accepted exactly once; replays return
400 INVITE_ALREADY_USED. The token is opaque and rate-limited
on the same IP-based bucket as auth endpoints; brute-forcing the
token space is throttled.
Invite vs SCIM — pick one
Adjudon supports two onboarding paths, and they should not run in parallel against the same set of users. Manual invites are ergonomic for orgs of fewer than 50 people and do not require an IdP. SCIM is the right answer once Okta or Azure AD owns the identity directory and de-provisioning latency matters — an employee leaving the company is removed from Adjudon within seconds of their IdP entry deactivating, instead of whenever an admin remembers to clean up.
A user provisioned via SCIM and then re-invited manually creates
two OrgMember rows pointing at the same User, which the unique
index forbids; the invite returns ALREADY_MEMBER. Pick the path,
not both.
Common gotchas
- Roles cannot be customised. The four-role enum is fixed. Custom RBAC is not on the roadmap; the regulator-readable audit trail relies on stable role names across customers.
- Always-one-owner invariant. Demoting or removing the last
active owner returns
400 VALIDATION_ERROR. The dashboard prompts the operator to promote a replacement first; the API enforces the rule unconditionally. - Invite tokens last 7 days. Expired tokens return
valid: falseand are filtered fromGET /invites. Resend bumps the expiry; revoke clears it permanently. - Suspend ≠ remove. Suspend keeps the
OrgMemberrow; remove deletes it. A reviewer's audit footprint (reviewedBy: <userId>) survives both because theUserdocument is not deleted. - Idempotency. The
Idempotency-Keymiddleware is wired only onPOST /traces; team mutations do not auto-receive replay protection. The(organizationId, userId)unique index defends against duplicateOrgMemberrows; the(organizationId, email)lookup defends against duplicate pending invites.
See also
- Organizations API — the org-document surface this resource sits inside
- Auth API — switch-org and per-user session management
- SCIM 2.0 — the IdP-driven alternative to manual invitations
- Audit Log API — where every
team.*event lands - Error Codes — the broader error taxonomy