This document was written by AI and has been manually reviewed.
Teams
A team is a shared owner for OAuth applications and verified domains. Members can manage the team's apps and domains in the same UI as their personal resources, while ownership of those resources cleanly survives any single member leaving the team.
Roles
| Role | Can manage members | Can edit team settings | Can manage apps/domains | Can transfer ownership | Can disband |
|---|---|---|---|---|---|
owner | yes | yes | yes | yes (to a co-owner) | yes |
co-owner | yes (except owner) | yes | yes | no | no |
admin | yes (member only) | no | yes | no | no |
member | no | no | yes (read; write apps the team allows) | no | no |
There is exactly one owner per team. Transferring ownership is a single, audited operation; the previous owner is demoted to co-owner.
Site administrators hold owner-level authority on every team — as the site, even on teams they belong to or own — and can override the team's own join requirements when adding people. The team page says so with a banner, and their actions land in the team's audit log marked site_admin: true. An admin can switch to normal view to act as their own membership instead. See Admin → Site-admin access to every team.
Joining a team
There are three ways to join:
- Direct add by an admin/co-owner/owner from Teams → <team> → Members → Add member. A site administrator can do this for any team, from Admin → Teams → Add member.
- Invite link generated from Members → Generate invite. Optional email lock, max-uses cap, and expiry. Visiting
/teams/join/:tokenshows the team profile and any unmet requirements before accepting. - API —
POST /api/teams/join/:tokenwith a session bearer.
Join requirements
Team owners can require members to satisfy security factors before joining (and again whenever a member tries to drop below the bar). Admins can also enforce a site floor — a minimum every team is forced to require, regardless of the team-level flag.
| Requirement | Team flag | Site floor key |
|---|---|---|
| At least one TOTP authenticator or passkey | teams.require_2fa | default_team_require_2fa |
| Verified primary email | teams.require_verified_email | default_team_require_verified_email |
Effective requirement = team flag OR site floor. Owners can opt their team in further than the floor but cannot opt out below it. The team-settings UI greys out the toggle for any factor forced by the site.
Retroactive enforcement
Turning a requirement on flips it for every existing member immediately. Any member who hasn't enrolled the factor is locked out of team operations until they do. The unmetRequirements helper surfaces this on the join confirmation screen and on the user-side mutation paths (e.g. removing the last TOTP authenticator) so members get a clear error before data is changed. Notify members before flipping a requirement on a populated team.
The user-facing join flow at /teams/join/:token shows the requirements first and links to Profile → Security / Profile → Email to satisfy them. The endpoint payload includes:
{
"team": { "id": "...", "name": "Acme", "avatar_url": "..." },
"requirements": {
"require_2fa": true,
"require_verified_email": true,
"forced_by_site": { "require_2fa": false, "require_verified_email": true }
},
"unmet": ["2fa"]
}Sub-teams (nested teams)
A team can be created under another team to mirror an organisation's internal structure. Sub-teams nest recursively up to the operator-configured max_team_depth (default 5 levels); the server rejects cycles and over-depth re-parents on both create and move.
The entire feature is gated behind site config — operators can turn it off, tighten the depth cap, or toggle the inheritance semantics independently. See Configurability below.
Inheritance — what flows down
Sub-teams are not isolated copies. Two things automatically flow from an ancestor to every team underneath it (each can be turned off independently in site config):
- Membership (
inherit_team_membership, default on) — a member of team A has at least their A-role on every descendant of A. A direct membership on a sub-team stacks on top: the effective role ismax(direct, inherited). Useful for "department head is an admin of every project sub-team" without duplicating rows. When the toggle is off,getEffectiveMemberdegenerates to a direct-only lookup and listings stop expanding into sub-team subtrees. - Verified domains (
inherit_team_domains, default on) — every domain owned by an ancestor is visible to sub-teams as a read-only entry tagged withinherited_from. This lets a sub-team auto-verify a sub-domain when the parent already owns the apex domain. When the toggle is off, both the listing and the auto-verify lookup are restricted to the team's own domains.
Everything else stays independent. Apps belong to whatever team created them; the existing share-to-team flow still works for explicit copies.
Configurability
All sub-team behavior is driven by site config (admin → Settings → Sub-teams). Each value is also surfaced on the unauthenticated /api/site payload so SDK clients can mirror the gates in their UIs.
| Key | Type | Default | Effect |
|---|---|---|---|
enable_sub_teams | bool | true | Master switch — when false the sub-team endpoints reject every request and the UI hides the Sub-teams tab. |
max_team_depth | int | 5 | Server-enforced cap on nesting depth. Validated 1–20 on the admin API. |
inherit_team_membership | bool | true | Cascade member roles to descendants. |
inherit_team_domains | bool | true | Surface ancestor-owned domains on sub-team listings + auto-verify. |
default_team_profile_show_sub_teams | bool | true | Public-profile default for the sub-team listing section. |
The per-team override profile_show_sub_teams (column on teams) follows the same null/0/1 convention as every other profile_show_* flag: null follows the site default, 0/1 override.
Managing sub-teams
- Create:
POST /api/teams/:parentId/sub-teams(admin+ on the parent — direct or inherited counts) or the equivalent Teams → <team> → Sub-teams → New sub-team button. The creator becomes the new sub-team's owner. - List:
GET /api/teams/:id/sub-teamsreturns the immediate children with member counts and the caller's effective role. Members of an ancestor team can list. - Move / re-parent:
PATCH /api/teams/:id { "parent_team_id": "..." }withnullto promote back to top-level. Owner-only on the team being moved, and the caller must be admin+ on the new parent. The server walks both subtrees to reject moves that would create a cycle or exceedMAX_TEAM_DEPTH. - Delete:
DELETE /api/teams/:idcascades through every descendant. For each level (deepest first)dissolveTeamreassigns that level's apps to that level's own owner — sub-team owners keep their apps, with the deleting user as the final fallback. The DB schema hasparent_team_id REFERENCES teams(id) ON DELETE CASCADEas a belt to the application-level braces.
Inherited memberships in listings
GET /api/teams(session) andGET /api/oauth/me/teamsboth expand each direct membership to its full subtree. Entries carry aninherited_fromancestor id (ornullfor direct memberships). The session listing is paginated (?page=,?limit=,?q=name search) and returnstotal.GET /api/teams/:idreturns:team.ancestors—[{id, name, avatar_url}]from immediate parent to root, useful for breadcrumbs.team.sub_teams— the first page of immediate children (20) with member counts, plusteam.sub_team_countfor all of them. Page throughGET /api/teams/:id/sub-teamsfor the rest.team.my_role— the effective role;team.inherited_fromcarries the ancestor id when the role came from inheritance.members— the first page of direct members (50), plusmember_countfor the whole team. Page and filter throughGET /api/teams/:id/members; the detail response carries page one so the initial render needs a single request. Inherited members are not listed — they are visible by inspecting ancestor teams, and surfacing them here would multiply listings.
- The other team-scoped lists are paginated too:
GET /api/teams/:id/apps,GET /api/teams/:id/domains,GET /api/teams/:id/invitesandGET /api/teams/:id/sub-teamsall accept?page=,?limit=and?q=(name/domain/email search) and return atotalalongside the page. The teams and apps lists on the dashboard are paginated the same way.
Member groups
Member groups are team-defined labels you attach to members — a member can hold several at once. They are pure labels: they never enter the role ladder, so nothing about what anyone can do inside Prism changes when you hand one out. Their entire purpose is to ride along with member information to the apps your team connects, so those apps can authorize on them.
Off by default. Only the team owner can turn them on, under Teams → <team> → Settings → Member groups.
Groups vs. sub-teams
Both can drive group-based authorization downstream, so it's worth being explicit about which one fits:
| Sub-team | Member group | |
|---|---|---|
| Independent entity (own page, id) | yes | no — a label inside one team |
| Can own apps and domains | yes | no |
| Cardinality | tree, depth-capped | flat, many-per-member |
| Affects Prism's own authorization | yes — roles cascade with it | no |
| Shape in claims | its own in_team_<sub-team-id> | groups_in_team_<team-id> array |
| Can be granted to an app on its own | yes, it has its own team: scope | no, rides with member info |
| Membership vs. the parent team | may be a different set of people | always a subset of the members |
Split organisation and resources with a sub-team; label attributes of the same set of people with a group. Using sub-teams as labels is the trap worth naming: every "label" becomes another team id in the claims, and because membership cascades downward the person you only meant to tag ends up holding a role there too.
Defining and assigning
Definitions live under Teams → <team> → Groups. Each has:
| Field | Notes |
|---|---|
slug | The stable identifier apps authorize on. Immutable — renaming would silently break downstream policies. |
name | Display label, freely editable. |
| description | Optional, for humans. |
| colour | Optional #rrggbb, used for the badge. |
Limits: 50 groups per team, 20 groups per member, slug ≤ 32 characters of lowercase alphanumerics and single hyphens.
Assign from the members table — the tag button on a member row. The dialog sends the full desired set and the server reconciles it, permission-checking only the groups that actually change.
Who can manage and assign
Owners and co-owners always can. What admins may do is configurable by the owner, resolved through a chain where each level only contributes the keys it explicitly sets:
per-group admin_assignable → team role_permissions → site default → built-in
(assignment only) (Groups → Admin (default_team_ (manage: off
permissions) role_permissions) assign: on)| Capability | What it covers | Built-in default |
|---|---|---|
groups:manage | Create, edit and delete definitions | off |
groups:assign | Attach and detach groups on members | on |
The per-group admin_assignable override exists so a team that generally lets admins hand out labels can still reserve a sensitive one for owners.
The switches themselves are owner-only
Toggling the feature, editing role_permissions, and setting a per-group admin_assignable are all restricted to the owner — deliberately, and not merely to "admin and above". A capability set that the constrained role could edit would be no constraint at all: an admin would simply grant themselves whatever the owner withheld.
Inheritance
Groups flow down the sub-team tree the same way roles do: a member of an ancestor team carries that team's labels on every descendant, flagged with inherited_from so the UI can render them read-only. Inherited labels are managed at the team they come from.
This rides on the same inherit_team_membership switch as role inheritance — when ancestors and descendants are decoupled for membership, labels stop crossing that boundary too.
Turning them off
Switching the feature off hides, it does not erase. Definitions and assignments stay in the database; every read surface simply stops emitting them, so connected apps see no groups from the next token refresh onward. Turning it back on restores every assignment exactly as it was.
The same holds along the inheritance chain: an ancestor with groups switched off contributes nothing to its descendants, so a disabled team's labels can't leak out through its children.
Downstream effect
Because apps authorize on these labels, disabling the feature (or deleting a group) makes downstream authorization stricter, not looser — the affected users simply stop presenting the label.
Where they show up
| Surface | Shape |
|---|---|
GET /api/teams/:id (session) | members[].groups — {slug, name, color, inherited_from} |
GET /api/oauth/me/team/:id/members | same, under team:<id>:member:read |
.../members/:userId/profile | same, under team:<id>:member:profile:read |
| ID token / userinfo | groups_in_team_<id> — array of slugs; teams[].groups via oidc_fields |
No new OAuth scope: groups ride along with the member information an app is already trusted to read. An app already holding team:<id>:member:read starts seeing groups on its next call without re-consent — the trade-off accepted because these are team-authored labels, not personal data. Management and assignment are dashboard-only; team:<id>:member:write does not grant them.
Team role_permissions never leave the dashboard API — downstream apps see which labels a member holds, not how the team configured who may hand them out.
Invite-link registration
Normally a team invite only admits people who already have a Prism account. A team the site administrator has authorised can instead hand out links that create accounts, from a standalone page at /join/<team-id>.
Accounts made this way are restricted: every resource-creating feature is off unless the site administrator grants it. The point is to let a large, single-purpose population sign in without each of them also becoming able to create teams, applications and verified domains.
What this does and doesn't save
Restricting features saves rows and abuse surface. It does not reduce authentication traffic — a user who only ever signs in to one application still goes through the full /authorize → /token → refresh cycle.
Two doors
The channel is closed twice over, and both must be open:
- Site master switch —
enable_team_invite_registration, off by default. - Per-team grant — given by a site administrator in Admin → Teams. The team owner cannot grant it to themselves.
Only then does the team owner's own switch, under Settings → Invite-link registration, do anything. Without the second door, turning on the first would make every team owner on the instance a registrar.
Creating a registration invite
Tick Usable for creating new accounts when generating an invite. Such an invite:
- must have a finite usage limit, capped by
team_invite_registration_max_uses_cap. The ordinary default of0means unlimited, which is harmless for admission but would mean unbounded registration here. - always grants
member. Someone arriving through a link should never begin able to manage the team.
The link points at /join/<team-id>?invite=<code>, not the ordinary /teams/join/<token> route.
What a registrant goes through
/join/<team-id> → account created (pending) → requirements → memberThe account exists before its requirements are met, because everything that satisfies a requirement — enrolling 2FA, verifying an address — needs an authenticated session. This mirrors what ordinary registration already does when the site requires email verification.
While pending, the account can do nothing but manage its own security, and cannot sign in to any application. A pending account holds no membership, so its token would carry no in_team_* or groups_in_team_* claims — an app receiving one would read it as "not a member" and likely create a local record it then has to reconcile.
Pass ?continue=<url> (same-origin only) to send the user somewhere specific once they finish.
Abandoned registrations are deleted after restricted_pending_ttl_hours.
Email
If the site administrator exempts this team from email verification, the registration form does not ask for an address at all and the account gets a synthesised @users.invalid placeholder.
That is deliberate. Storing an unverified real address instead would be worse: users.email is UNIQUE, so one typo permanently locks the true owner of that address out of the instance — and the person who typed it can never verify it, so their own account can never be converted either.
The holder can bind a real address whenever they like, and must before converting.
Anti-abuse
Four defences, none of them exemptible:
| Defence | Bounds |
|---|---|
| Per-IP rate limit | 5 registrations per 5 minutes, as ordinary registration |
| Captcha / proof-of-work | Bot resistance |
| Per-invite rate limit | team_invite_registration_rate_per_hour — the burst |
max_uses, claimed atomically | The total. The only hard bound |
The per-invite limit matters because per-IP limiting does nothing against a link shared to thousands of people who each register once from their own address.
A seat is claimed when the account is created, not when the join completes. An abandoned registration burns one — accepted, because deferring the claim would make the check racy and would also strand people who enrolled 2FA only to be told the invite had filled up.
Restricted accounts
What is restricted
| Capability | Default |
|---|---|
| Create teams | off |
| Create applications | off |
| Verify domains | off |
| Personal access tokens | off |
| Public profile | off |
| GPG keys | off |
| Convert to a full account | off |
| Team features, and their own account security | always on |
Site administrators lift individual capabilities through restricted_user_capabilities. Defaults deny, so a feature added later is automatically off for these accounts without anyone having to remember.
Account security is never gated — gating it would deadlock registration, since a pending account has to enrol 2FA to finish joining.
Scope
A restricted account is confined to its origin team's subtree:
- it may join that team and its descendants, and no others — being added by an admin is not an exception;
- it may sign in only to applications owned by that team or its descendants.
The anchor is the origin team, never the account's current memberships. Anchoring on memberships would be self-defeating: join a second team and its whole subtree opens up.
Application scope is checked at /authorize only, not on refresh — otherwise transferring an app out of the team would silently invalidate live tokens.
Converting to a full account
Where enabled (self:convert), the holder converts from Security → Account type. Conversion:
- unlocks every ordinary feature,
- keeps their team membership,
- requires a real, verified address first — the deferred email cost comes due here rather than at registration, so only people who actually want the rest of Prism pay it,
- is one-way,
- and takes the account out of the set a dissolution would delete.
origin_team_id is kept afterwards for traceability but stops constraining anything.
Dissolution
Dissolving a team that minted accounts deletes those accounts. Because of that:
- only a site administrator can do it, through a staged flow;
- all four ordinary dissolution routes refuse — owner delete, last-member leave, the OAuth
teams:deletescope, and the plain admin delete; - only accounts whose
origin_team_idis that team are deleted. Members who joined with their own Prism accounts, and anyone who converted, are untouched; - stage one deactivates every affected account at once; stage two deletes them in batches after
restricted_dissolve_grace_hours, leaving a window to cancel.
The registration page states this before anyone signs up.
Protection is keyed on accounts, not on the switch
A team is protected while it still has live restricted accounts — not while its invite registration switch is on. Otherwise the switch could be turned off and the team dissolved by its owner with thousands of accounts still anchored to it. Protection lapses by itself once those accounts are gone.
A team containing restricted members also cannot be moved out of its origin team's subtree, and ownership cannot be transferred to a restricted account.
Team-owned apps and domains
OAuth apps can be created directly under a team (Teams → <team> → Apps → New) or transferred in from a member's personal apps (Apps → <app> → Settings → Transfer). Personal apps that are transferred in are reassigned in-place — the client_id and client_secret remain valid, so partner integrations don't break.
Domains work the same way. A domain verified on a personal account can be shared with a team (POST /api/teams/:id/domains/:domainId/share-to-team) and later moved back (/share-to-personal), or fully transferred (/to-personal) to remove the team's edit access.
Team-as-user storage
Every team has a synthetic users row with kind = 'team' and id matching teams.id. This row exists only so oauth_apps.owner_id joins to a single table for both personal and team apps — it has no password, no sessions, no social connections, and cannot log in. The synthetic email and username (team-<id>@teams.invalid / team:<id>) are colon-prefixed to guarantee they can never collide with a real registration.
When a team is disbanded, dissolveTeam first reassigns any remaining team-owned apps to the team's owner (or, if there's no owner row, to the deleting admin). This survives the cascading delete on oauth_apps.owner_id.
Public team profiles
Like users, teams are private by default. The team owner (or admin) explicitly opts the team in at Teams → <team> → Settings → Public profile, then picks which sections to expose. Site-wide defaults and the master enable_public_profiles kill switch live in Configuration. See Public Profiles for the full per-section breakdown.
A team's public page links to the owner's /u/<username> page only when both profiles are public. If the owner's profile is private, the team page shows the display name without a link.
OAuth scopes
Three scope families touch teams, with very different blast radius. The full table with consent rules and worked examples is in OAuth → Team scopes — three tiers; the short version:
Aggregate teams:*
Acts on every team the user is a member of at once. One consent covers all of them. Endpoints under /api/oauth/me/teams[/...].
| Scope | Grants |
|---|---|
teams:read | List team memberships and roles |
teams:create | Create a new team |
teams:write | Update team settings; add and remove members across the user's teams |
teams:delete | Delete a team (owner only — checked at request time) |
Right shape for: "what teams is this user in?" use cases — workspace pickers, syncing membership lists, OIDC IdP claims for Cloudflare Access policies.
Single-team team:*
Acts on exactly one team, picked by the user at consent time. Prism rewrites team:read → team:<team-id>:read (via bindTeamScopes()) before issuing the token, so the token can only ever touch that team. Endpoints under /api/oauth/me/team/:teamId/....
| Requested | Bound form | Grants |
|---|---|---|
team:read | team:<id>:read | Read the team's settings |
team:write | team:<id>:write | Update the team's settings |
team:delete | team:<id>:delete | Disband the team |
team:member:read | team:<id>:member:read | List members and their roles |
team:member:write | team:<id>:member:write | Add/remove members and change roles |
team:member:profile:read | team:<id>:member:profile:read | Read a member's profile through the team scope |
Two extra rules at consent time (see worker/routes/oauth.ts:830-859):
- The user must be
owner,co-owner, oradminof the chosen team. team:deleteadditionally requiresownerorco-owner— admins can grant reads/writes but only the people who could actually disband the team can grant deletion.
team:member:write also can't escalate beyond what the granting user could do themselves: an admin granting it cannot give the app the ability to promote past admin — the cap is enforced on every member mutation.
Each grant is audited in team_scope_grants (team id + permissions), independent of the OAuth consent record.
Right shape for: an integration scoped to a single team — a deploy bot for one workspace, a chatbot for one team's channel, etc.
Cross-instance site:team:*
Cross-team admin access without a per-team consent. Granting requires the user to be a site admin and goes through the site-scope confirmation flow (2FA + the exact phrase grant site access). Use only for site-administration tools.
oidc_fields claim
The same oidc_fields mechanism that surfaces user role in ID tokens also emits per-team claims when an app declares them — useful for Cloudflare Access policies that depend on team membership. The teams:read scope unlocks the flat teams claim plus the in_team_<id> / role_in_team_<id> per-team markers, and groups_in_team_<id> for teams using member groups. See the Cloudflare Access integration for details.
Picking a tier
- Use
teams:*when the integration cares about the user's whole team graph (membership sync, claim mapping). - Use
team:*when the integration scopes to one workspace at a time — the smaller blast radius is worth the team-id picker on the consent screen. - Don't request
teams:*andteam:*together: you'll get the union, but the consent UX shows both an all-teams notice and a team-id picker on the same screen, which confuses users. - Reserve
site:team:*for site-administration tooling — anything granted there bypasses individual team owners' consent.
Endpoint summary
See API → Teams for the full table. The most-used endpoints:
GET /api/teams list memberships (expanded with inherited)
POST /api/teams create (optionally with parent_team_id; admins may set owner_username)
PATCH /api/teams/:id update settings, requirements, parent_team_id
GET /api/teams/:id team + ancestors + sub_teams summary
GET /api/teams/:id/sub-teams list immediate children
POST /api/teams/:id/sub-teams create a sub-team under :id
GET /api/teams/:id/members list members (direct only) — paginated, ?q= and ?group=
POST /api/teams/:id/members add by username/id
PATCH /api/teams/:id/members/:userId change role (site admins may set role=owner, or demote the owner)
DELETE /api/teams/:id/members/:userId remove (or leave with self)
GET /api/teams/:id/groups list group definitions + capabilities
POST /api/teams/:id/groups create a group
PATCH /api/teams/:id/groups/:groupId rename / recolour (slug immutable)
DELETE /api/teams/:id/groups/:groupId delete (unassigns everywhere)
PUT /api/teams/:id/members/:userId/groups replace a member's group set
POST /api/teams/:id/transfer-ownership transfer to another member (owner, or any site admin)
GET /api/teams/join/:token preview an invite (auth optional)
POST /api/teams/join/:token accept