Teams
A team is an org-scoped group of users that can be granted a single role on a workspace as a unit — every team member effectively holds that workspace role, without being added individually to workspace_members. Teams live under the organization, not under a specific workspace: one team can hold roles on several workspaces at once (e.g. "Marketing" holds WORKSPACE_MEMBER on both the KLCC and Penang branch workspaces).
Handles:
- Team CRUD — create, list, get, update, delete (all org-scoped)
- Team membership — add/remove users
- Team-to-workspace role grants — add/update/remove which role a team holds on a given workspace
- Mutual exclusivity with direct workspace membership — a user can reach a workspace either directly or via a team, never both
Architecture
graph LR
OrgAdmin["Org Admin"]
CoreAPI["CoreAPI (Tenancy — teams)"]
CoreDB["CoreAPI DB\n(teams, team_members, team_workspace_roles)"]
OrgAdmin -->|CRUD teams, members, workspace roles| CoreAPI
CoreAPI --> CoreDB
CoreAPI -->|union with workspace_members\nfor permission checks| CoreDB
Teams are entirely CoreAPI-local, like workspaces. Effective workspace permission checks (RequireWorkspaceRole, WorkspaceHasPermission) union direct workspace_members rows with team-derived access (team_workspace_roles joined through team_members) for the calling user, taking the higher-weight role if both paths somehow apply. Write-time checks are meant to prevent that overlap ever existing — see below.
Data Model
erDiagram
Team {
string id
string organizationId
string name
string description
string leadUserId "unused by the API"
}
TeamMember {
string teamId
string userId
}
TeamWorkspaceRole {
string teamId
string workspaceId
string roleId
}
Organization ||--o{ Team : owns
Team ||--o{ TeamMember : has
Team ||--o{ TeamWorkspaceRole : "holds role on"
Workspace ||--o{ TeamWorkspaceRole : "role granted by"
(team_id, workspace_id) is a composite primary key on team_workspace_roles — a team can hold at most one role on a given workspace at a time (use PATCH to change it). Team.LeadUserID exists as a column but isn't exposed or settable through any endpoint yet.
Endpoints
All routes are mounted under /api/v1/tenancy/organizations/:orgId/teams and require a valid session. Mutations (create/update/delete team, membership, workspace-role grants) additionally require org permission org:teams:manage — granted by default to ORG_OWNER, ORG_ADMIN, and ORG_MANAGER, but not ORG_OPERATOR/ORG_MEMBER. View endpoints only require org membership.
GET /
Lists every team in the org.
Response 200
{ "items": [ { "id": "team_...", "organizationId": "org_...", "name": "Marketing", "description": "", "memberCount": 4, "workspaceCount": 2, "createdAt": "...", "updatedAt": "..." } ] }
POST / — requires org:teams:manage
Request
{ "name": "Marketing", "description": "Cross-branch marketing team" }
name is required. A case-insensitive duplicate name within the org returns 409.
Response 201 — same shape as a list item.
GET /:teamId
Fetches a single team by ID, scoped to orgId.
PATCH /:teamId — requires org:teams:manage
Updates name and/or description; at least one must be non-empty. A new name is checked for uniqueness (excluding this team) — 409 on collision.
Request
{ "description": "Cross-branch marketing & promotions" }
Response 200
{ "message": "Team updated successfully" }
DELETE /:teamId — requires org:teams:manage
Deletes the team, its memberships, and its workspace-role grants in one transaction. 204 No Content.
GET /:teamId/members
Response 200
{ "items": [ { "userId": "usr_...", "name": "Ada Lovelace", "email": "ada@acme.example.com" } ] }
POST /:teamId/members — requires org:teams:manage
Adds a user to the team.
Request
{ "userId": "usr_..." }
userId is required. Rules:
- The user must exist locally (
400otherwise). - The user must already be a member of the org (
403otherwise). - Conflict check: if the user already has direct membership (
workspace_members) on any workspace this team currently holds a role on, the request is rejected.
Response 409 (conflict case)
{ "error": "user already has direct membership on one or more workspaces this team has access to", "workspaceIds": ["ws_..."] }
Adding an already-existing member is idempotent (no error).
DELETE /:teamId/members/:userId — requires org:teams:manage
404 if the user isn't a member of this team.
GET /:teamId/workspaces
Lists the workspaces this team holds a role on.
Response 200
{ "items": [ { "workspaceId": "ws_...", "workspaceName": "KLCC Branch", "roleId": "WORKSPACE_MEMBER" } ] }
POST /:teamId/workspaces — requires org:teams:manage
Grants the team a role on a workspace.
Request
{ "workspaceId": "ws_...", "roleId": "WORKSPACE_MEMBER" }
Both fields required. Rules:
- The workspace must belong to this org (
400otherwise). roleIdmust be a workspace-scope role — system or custom, no org-ownership check on the role itself (400 "role must be a workspace-scoped role"otherwise).- The team must not already have a grant on this workspace — use
PATCHinstead (409 "team already has a role on this workspace"). - Conflict check (inverse of the member-add one): if any current team member already has direct membership on that workspace, the request is rejected.
Response 409 (conflict case)
{ "error": "one or more team members already have direct membership on this workspace", "userIds": ["usr_..."] }
Response 201
{ "message": "Workspace role added successfully" }
PATCH /:teamId/workspaces/:workspaceId — requires org:teams:manage
Changes the role the team holds on a workspace it's already granted on.
Request
{ "roleId": "WORKSPACE_ADMIN" }
404 if no existing grant. Same workspace-scope-role validation as create.
DELETE /:teamId/workspaces/:workspaceId — requires org:teams:manage
Revokes the team's role on a workspace. 404 if no grant existed.
GET /:orgId/workspaces/:workspaceId/teams
Mounted on the workspace group, not the team group — lists the teams (and their granted role) that have access to a given workspace. Used by a workspace's "Access" view alongside its direct member list.
Response 200
{ "items": [ { "teamId": "team_...", "name": "Marketing", "memberCount": 4, "roleId": "WORKSPACE_MEMBER" } ] }
Interaction with Direct Workspace Membership
A user reaches a workspace's resources through exactly one path — direct membership or team membership, never both, for the same workspace:
POST .../workspaces/:workspaceId/members(adding someone directly) is rejected with409 "user already has access to this workspace through a team"if the user would gain a second path via an existing team grant.- The two team-side conflict checks above (adding a team member, granting a team a workspace role) enforce the same invariant from the other direction.
Despite these write-time guards, permission resolution is defensive: WorkspaceHasPermission and RequireWorkspaceRole both union direct and team-derived roles for a user and take the highest-weight one if a conflict somehow exists.