docs/plans/saas/04.runners-and-auth.md
Runners poll for pending tasks across ALL workspaces with a single query (no workspace filter). They resolve workspace context from the task's parent chain when processing.
| Runner | Workspace resolution | Notes |
|---|---|---|
taskrun/ | task_run → task → plan → project → workspace | Main execution pipeline |
plancheck/ | plan_check_run → plan → project → workspace | Plan validation |
approval/ | issue → project → workspace | Approval workflows |
schemasync/ | instance → workspace (direct) | Instance is a root table |
mail/ | Varies by notification source | Resolve from the entity being notified about |
Located in backend/store/runner_queries.go:
GetProjectByResourceID — by globally unique PK, no workspace filterGetInstanceByResourceID — by globally unique PK, no workspace filterListAllInstances — returns all instances across workspacesDeleteExpiredExportArchivesAll — cleanup across all workspacesprincipal tableuser.Profile.LastLoginWorkspace if still validallUsers workspaces; SaaS requires explicit membershipworkspace_id claimweb_refresh_token table{
"sub": "[email protected]",
"workspace_id": "ws-abc123",
"aud": ["bb.user.access"],
"iss": "bytebase",
"iat": 1709654400,
"exp": 1709740800
}
Refresh() with refresh token cookieRefresh() extracts workspace from the expired JWT in the access token cookie (signature-verified)SwitchWorkspace RPC (POST /v1/auth:switchWorkspace):
BroadcastChannel (all tabs switch together)Finds workspaces where a user is a member — directly, via group, or via allUsers:
SELECT DISTINCT w.resource_id, w.name
FROM workspace w
JOIN policy p ON p.workspace = w.resource_id
WHERE p.resource_type = 'WORKSPACE'
AND p.type = 'IAM'
AND w.deleted = FALSE
AND EXISTS (
SELECT 1
FROM jsonb_array_elements(p.payload->'bindings') AS binding,
jsonb_array_elements_text(binding->'members') AS member
WHERE member = 'users/{email}'
OR (member LIKE 'groups/%' AND EXISTS (
SELECT 1 FROM user_group ug,
jsonb_array_elements(ug.payload->'members') AS gm
WHERE ug.workspace = w.resource_id
AND 'groups/' || ug.email = member
AND gm->>'member' = 'users/{email}'
))
OR member = 'allUsers' -- self-hosted only
)
Workspace-scoped via their own tables (service_account, workload_identity), each with a workspace column. They authenticate directly within workspace context — no workspace picker needed.
Following Google Cloud's approach — no invitation/pending state. Members are managed by updating the workspace IAM policy.
| Action | How |
|---|---|
| Add member | Add email to workspace IAM binding with a role |
| Remove member | Remove email from all workspace IAM bindings |
| Change role | Update the member's IAM bindings |
If the email doesn't have a Bytebase account yet, the IAM binding still exists. When the user signs up later, Signup finds the workspace via IAM membership and auto-joins.
| Behavior | Self-Hosted | SaaS |
|---|---|---|
| Add member | CreateUser (admin creates account) OR add email to IAM | Add email to IAM only (no CreateUser) |
| Principal creation | Admin can create via API | Users self-register via Signup only |
| allUsers binding | Allowed (grants access to all principals) | Blocked |
| Member types in IAM | users/{email}, groups/{email}, allUsers | users/{email}, groups/{email} |
When verifying workspace membership (verifyWorkspaceMembership), the auth interceptor checks:
member == 'users/{email}'member == 'groups/{groupEmail}' → expand via GetGroupMembersSnapshotmember == 'allUsers'Groups are stored in user_group table with members in payload.members[].member (format: users/{email}).
When a group groups/{groupEmail} appears in an IAM binding:
iam/manager.go): expands via GetGroupMembersSnapshot (cached)store/workspace.go): expands via SQL JOIN to user_group tableapi/auth/auth.go): expands via GetGroupMembersSnapshotSaaS mode:
Self-hosted mode: