docs/plans/saas/05.progress.md
Current status of the workspace isolation implementation. 185+ files changed, ~4700 lines modified.
server_config table — Global config (single row), stores auth_secret via payload JSONBworkspace table — Workspace identity with resource_id PKworkspace column added to 13 root tables + oauth2_clientoauth2_authorization_code / oauth2_refresh_token — No workspace column (scoped through client_id FK)SystemSetting JSON to server_config.payloadSystemSetting proto — auth_secret and workspace_id fields reservedplan, task, task_run, plan_check_run, issue changed from serial to bigint NOT NULLworksheet.id column dropped (PK is resource_id)(workspace, created_at DESC) replaces separate indexesserver_config (workspace created by Go signup flow)id column (fixes "null value in column id" error)workspace.go: GetWorkspaceID() returns ("", nil) when no workspace exists, ListWorkspacesByEmail(), FindWorkspace(), CreateWorkspace() with proto-based settings/policyrunner_queries.go: Cross-workspace methods (GetProjectByResourceID, GetInstanceByResourceID, ListAllInstances, DeleteExpiredExportArchivesAll)account.go: AccountMessage + GetAccountByEmail() for auth layerGetServerConfig() / GetAuthSecret() — reads from server_config tableCountActiveEndUsersPerWorkspace — counts via IAM policy membership (explicit members only, not allUsers)CountAllActivePrincipals — counts all principals globally (for allUsers admin check)GetDefaultProjectID(ctx, workspace) — checks new format first, falls back to legacy "default"BatchGetUsersByEmails — workspace-scoped via IAM joinsettingCache, groupCache, groupMembersCache, memberGroupsCache all include workspace in keys. UpdatePolicy sets Workspace on cached entry. DeletePolicy invalidates iamPolicyCache. UpdateUserReferenceInPolicies invalidates iamPolicyCache and purges group caches.SearchAuditLogs — workspace filter is optional (empty = cross-workspace for login lockout)WorkspaceID field)ListWorkspacesByEmail (IAM policy). Workspace resolved early in Login flow, passed as parameter (not via context).Signup API (POST /v1/auth/signup, allow_without_credential) — Creates principal + workspace. Self-hosted: joins existing workspace or creates if none exists. SaaS: creates new workspace per user.CreateUser refactored — Admin-only (bb.users.create permission), self-hosted only. SaaS blocked.countRecentLoginFailures searches audit logs cross-workspace (per-email rate limit)idp.Workspace, passes workspace explicitly (no context injection)GetWorkloadIdentityByEmail (cross-workspace) and wi.Workspaceallow_without_credential APIs audited — no GetWorkspaceIDFromContext in unauthenticated pathsworkspaceID as parameter (not from context): validateLoginPermissions, checkMFARequired, needResetPassword, isUserWorkspaceAdmin, validateEmailWithDomains, syncUserGroups, generateLoginTokenUpdateUser — SaaS: self-updates only. Self-hosted: admin can update others.DeleteUser / UndeleteUser / UpdateEmail — SaaS: blocked.GetUser / BatchGetUsers — workspace-scoped via IAM joinSetIamPolicy — blocks allUsers in SaaS. No member count check (enforced at CreateUser/IDP level).ListIdentityProviders — self-hosted falls back to store.GetWorkspaceID for unauthenticated login pageGetSubscription — falls back to free subscription if no workspace contextrawResource struct, getResourceFromRequest returns []stringCreateProject blocks "default" and "default-*" project IDs as reserved. IsDefaultProject handles both legacy and new formats.userCountGuard — uses explicit workspaceID parameterCheckReplicaLimit skips in SaaS modeCreateDatabaseDefault uses GetDefaultProjectID (handles legacy)oauth2_client workspace-scoped/api/workspaces/:workspaceID/oauth2/...!profile.SaaS)server_config — Only auth_secret (JSONB payload)!profile.SaaSDisallowSignup override removed — SaaS no longer forces it true (signup always allowed)LoadSubscription, StoreLicense, GetUserLimit, GetActivatedInstanceLimit all workspace-scoped with workspace in cache keyisDefaultProject — reads from useActuatorV1Store().serverInfo?.defaultProject internally. Single-arg call.Signup API — frontend uses authServiceClientConnect.signup() instead of CreateUser + LoginSignup methodDEFAULT_PROJECT_PREFIX — unexported, used only internally by getDefaultProjectNameSee 06.saas-vs-selfhosted.md for a comprehensive catalog of every code path difference.
A full code review was conducted to verify workspace isolation across the entire backend. All previously flagged API gaps were investigated and found to be properly isolated.
| # | Fix | Files Changed |
|---|---|---|
| 1 | ACL now validates instance-scoped resources (e.g. instances/{id}, instances/{id}/roles/{role}) by looking up the instance with workspace filter | backend/api/v1/acl.go |
| 2 | Added missing Workspace param to GetDatabase/ListDatabases calls | backend/api/lsp/handler.go, backend/api/lsp/completion.go, backend/component/export/resources.go (4 calls), backend/component/sampleinstance/manager.go |
| 3 | LSP handler refactored: user and workspaceID injected into context at WebSocket connection time, removed from handler struct. Now uses common.GetWorkspaceIDFromContext(ctx) consistently | backend/api/lsp/lsp.go, backend/api/lsp/handler.go, backend/api/lsp/completion.go |
| 4 | Added cross-workspace documentation comments to ACL populateRawResources (resource resolution strategy, per-case comments) | backend/api/v1/acl.go |
| 5 | db_schema.go GetDBSchema now JOINs to instance table and filters by instance.workspace when workspace param is provided | backend/store/db_schema.go |
All previously flagged services were verified to properly enforce workspace isolation:
instance_role_service.go — uses getInstanceMessage helper which filters by workspace. ACL layer now also validates instance ownership.changelog_service.go — ListChangelogs and GetChangelog look up the database with Workspace: common.GetWorkspaceIDFromContext(ctx). ACL's database case also validates.database_group_service.go — all methods call store.GetProject with workspace filter before accessing database groups.database_service.go — GetDatabase, BatchGetDatabases, UpdateDatabase all use workspace-filtered lookups.revision_service.go — validates database with workspace filter before querying revisions.issue_service.go — getIssueFind() sets workspace from context; getIssueMessage() passes workspace.plan_service.go — all GetPlan/ListPlans calls pass workspace.rollout_service.go — all task/plan lookups pass workspace.access_grant_service.go — GetAccessGrant with workspace filter before any update.release_service.go — project lookup with workspace filter before accessing releases.worksheet_service.go — project lookup with workspace filter before accessing worksheets.Every public store method was audited:
project, instance, policy, setting, role, group, etc.): All enforce workspace via direct WHERE workspace = ?.changelog, revision, worksheet, db_schema, etc.): Scoped via globally unique parent PKs. API layer validates workspace on the parent before calling these methods.principal, sheet_blob): Intentionally cross-workspace by design. Principals are global identities. Sheet blobs are content-addressed (SHA256 dedup).common.GetWorkspaceIDFromContext(ctx). Verified for GetDatabase, ListDatabases, GetIssue, ListIssues, GetPlan, ListPlans, ListTasks, ListTaskRuns.workspace_id (login auto-selects the first workspace; users switch after login)InstanceIDs batch filter to FindDatabaseMessageCreateUser (POST /v1/users) now requires bb.users.create permission. Self-service signup moved to Signup (POST /v1/auth/signup). API consumers using CreateUser for registration will break.CreateUser no longer adds to workspace IAM — Admin creates principal only. Must separately add to IAM for the user to login.default-{workspaceID}. Existing default project preserved. Code handles both formats.ADD COLUMN with default. No table rewrite.allUsers in IAM works identically to before.server_config only stores auth_secret — All other settings live in per-workspace WORKSPACE_PROFILE.
Root table PKs (resource_id) are globally unique — No composite PK migration needed.
Three access patterns for store queries:
common.GetWorkspaceIDFromContext(ctx) from JWTstore.GetWorkspaceID(ctx) or ListWorkspacesByEmailOAuth2 child tables don't need workspace — scoped through client_id FK.
Default project naming — New workspaces: default-{workspaceID}. Legacy: default. IsDefaultProject and frontend handle both. No data migration needed.
Signup vs CreateUser — Signup is self-service (both modes). CreateUser is admin-only (self-hosted). SaaS admins add members via workspace IAM policy.
disallow_signup — Self-hosted only. Not applicable in SaaS (admin invites via IAM, signup always allowed for new workspace creation).
allow_without_credential APIs — No GetWorkspaceIDFromContext in unauthenticated paths. Workspace resolved from entity data or store.GetWorkspaceID for self-hosted.