Skip to main content

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

RoleWhat it can do
ownerEverything, including delete the organisation, purge traces, and remove other owners
adminManage team, policies, agents, alerts, integrations, billing checkout; cannot delete the org or hard-delete an alert
memberCreate / edit own resources, resolve review items, read dashboards
viewerRead-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:

Actionowneradminmemberviewer
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:

FieldTypeDescription
userIdstringUser ObjectId
namestringDisplay name
emailstringLogin email
roleenumOne of owner / admin / member / viewer
joinedAtstring (ISO 8601)When the member's OrgMember row was created
isActivebooleanfalse after a suspend; the member appears greyed-out in the dashboard
mfaEnabledbooleanTOTP-enrolled flag from the User record
lastLoginIpstring | nullLast login IP, useful for suspicious-access investigation
createdAtstring (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.isActive to false. The user keeps their OrgMember row and any open JWTs return 401 ACCOUNT_DEACTIVATED on the next request. A dashboard re-login attempt fails until reactivation.
  • Reactivate is the inverse — isActive: true and membership intact.
  • Remove hard-deletes the OrgMember row. The User document 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
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 — accept
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: false and are filtered from GET /invites. Resend bumps the expiry; revoke clears it permanently.
  • Suspend ≠ remove. Suspend keeps the OrgMember row; remove deletes it. A reviewer's audit footprint (reviewedBy: <userId>) survives both because the User document is not deleted.
  • Idempotency. The Idempotency-Key middleware is wired only on POST /traces; team mutations do not auto-receive replay protection. The (organizationId, userId) unique index defends against duplicate OrgMember rows; the (organizationId, email) lookup defends against duplicate pending invites.

See also