Skip to content

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 (400 otherwise).
  • The user must already be a member of the org (403 otherwise).
  • 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 (400 otherwise).
  • roleId must 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 PATCH instead (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 with 409 "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.