Workspace and Project Membership Behavior¶
Default Membership¶
- Every user must have a workspace-level membership in order to act inside a workspace.
- This membership is unique per
(account_id, workspace_id). - A default membership is created when a user first joins a workspace.
- This default membership is automatically assigned the
Memberrole (or whatever base role name you choose).
Default Role: Member¶
- The
Memberrole represents minimal access inside a workspace. - Typical permissions:
- Can see workspace metadata (name, description).
- Can view a list of projects inside the workspace.
- Cannot create, update, or delete anything without further role assignments.
- This ensures that registering to a workspace always results in a valid membership, but with only safe, non-destructive access.
Role Scoping¶
-
Workspace-level role assignment
-
Attaches directly to the workspace membership.
- Grants permissions across all projects in that workspace.
-
Example:
Editorat workspace scope → user can edit tasks in any project in the workspace. -
Project-level role assignment
- Attaches to a project membership (which itself hangs off the workspace membership).
- Grants permissions only for that specific project.
- Example:
Editorat project scope → user can edit tasks in that one project, but not others.
Effective Permissions¶
- Default: User joins → gets workspace membership with
Memberrole. - Workspace role: If given
Editor(workspace scope), applies to all projects. - Project role: If user only has
Memberworkspace role, you can assignEditorat project scope to grant elevated permissions only in that project. - Policies: May constrain or override permissions, but never add new ones.
Benefits of Default Membership¶
- Ensures all users have a hard scope by default.
- Simplifies registration: "join workspace" → you’re a member, but with minimal access.
- Makes it explicit when additional project-specific access is needed.
- Prevents dangling access (no project membership without workspace membership).
Unified Roles and Membership Model¶
Core Idea¶
- One
rolestable for everything. - Permissions attached to roles as usual.
- Memberships are workspace-level (required).
- ProjectMemberships hang off a workspace membership for per-project scoping.
- Role assignments use a single table that can point to either a workspace membership or a project membership (but not both).
Schema¶
-- Roles & permissions
CREATE TABLE roles (
id UUID PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE permissions (
name TEXT PRIMARY KEY,
description TEXT
);
CREATE TABLE permission_bindings (
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
permission_name TEXT REFERENCES permissions(name) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_name)
);
-- Workspace membership (required to act inside a workspace)
CREATE TABLE memberships (
id UUID PRIMARY KEY,
account_id UUID NOT NULL, -- → accounts(id)
workspace_id UUID NOT NULL, -- → workspaces(id)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, workspace_id)
);
-- Optional per-project scoping
CREATE TABLE project_memberships (
id UUID PRIMARY KEY,
membership_id UUID NOT NULL REFERENCES memberships(id) ON DELETE CASCADE,
project_id UUID NOT NULL, -- → projects(id)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (membership_id, project_id)
);
-- Unified role assignment (either workspace OR project scope)
CREATE TABLE role_assignments (
id UUID PRIMARY KEY,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
membership_id UUID REFERENCES memberships(id) ON DELETE CASCADE,
project_membership_id UUID REFERENCES project_memberships(id) ON DELETE CASCADE,
-- Exactly one of the two must be set
CHECK (
(membership_id IS NOT NULL AND project_membership_id IS NULL) OR
(membership_id IS NULL AND project_membership_id IS NOT NULL)
),
-- Prevent duplicate assignments at the same scope
UNIQUE (role_id, membership_id),
UNIQUE (role_id, project_membership_id)
);
Benefits¶
- One roles catalog for the whole system (no split roles).
- Assign a role:
- to a workspace → set
membership_id. - to a project → set
project_membership_id. - No “immortal main project.” Admin lives at workspace by assigning an admin role at the workspace scope.
Auth Resolution¶
- Ensure user has a membership in
resource.workspace_id. If not → deny. - Gather workspace roles from
role_assignmentswheremembership_id = user.membership.id. - If a workspace role grants the action → allow (unless a policy later denies).
- Else, if a project_membership exists for
resource.project_id, gather roles fromrole_assignmentswhereproject_membership_id = that.id. - If a project role grants the action → allow, else deny.
Example Queries¶
-- Workspace-scoped effective permissions
SELECT pb.permission_name
FROM role_assignments ra
JOIN permission_bindings pb ON pb.role_id = ra.role_id
WHERE ra.membership_id = $1;
-- Project-scoped effective permissions
SELECT pb.permission_name
FROM role_assignments ra
JOIN permission_bindings pb ON pb.role_id = ra.role_id
WHERE ra.project_membership_id = $1;
Notes & Guardrails¶
- Keep role names generic (e.g.,
WorkspaceAdmin,Editor,Viewer) since scope is determined by where you assign them. - If you need “applies to all projects in workspace,” just assign at workspace scope (no special flag needed).
- Add indexes on
role_assignments(membership_id)and(project_membership_id)for fast lookups. - Policies (later) remain constraints only on top of these grants.