docs/plans/saas/03.application-layer.md
Update middleware, store, and service layers to enforce workspace scoping on all API requests. This step depends on Step 02 (database migration) being completed.
Extract workspace from the JWT and inject it into the request context. Every authenticated request carries workspace context.
type workspaceIDKey struct{}
func WorkspaceMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
workspaceID := extractWorkspaceFromJWT(r)
ctx := context.WithValue(r.Context(), workspaceIDKey{}, workspaceID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func WorkspaceIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(workspaceIDKey{}).(string); ok {
return v
}
return ""
}
All root table queries (12 tables) add WHERE workspace = $1. This is the primary isolation boundary.
// GET: add workspace to WHERE clause
func (s *Store) GetProject(ctx context.Context, workspaceID, resourceID string) (*ProjectMessage, error) {
query := "SELECT ... FROM project WHERE workspace = $1 AND resource_id = $2"
// ...
}
// LIST: add workspace to WHERE clause
func (s *Store) ListInstances(ctx context.Context, workspaceID string, find *FindInstanceMessage) ([]*InstanceMessage, error) {
where := qb.Q().Space("workspace = ?", workspaceID)
// ... existing filters ...
}
// INSERT: include workspace
func (s *Store) CreateProject(ctx context.Context, workspaceID string, create *ProjectMessage) (*ProjectMessage, error) {
query := "INSERT INTO project (workspace, resource_id, name, ...) VALUES ($1, $2, $3, ...)"
// ...
}
| Table | Store file | Key functions |
|---|---|---|
project | project.go | GetProject, ListProjects, CreateProject |
instance | instance.go | GetInstance, ListInstances, CreateInstance |
setting | setting.go | GetSetting, ListSettings, UpsertSetting |
policy | policy.go | GetWorkspaceIamPolicy, GetPolicy, ListPolicies, UpsertPolicy |
role | role.go | GetRole, ListRoles, CreateRole |
idp | idp.go | GetIdentityProvider, ListIdentityProviders, CreateIdentityProvider |
review_config | review_config.go | GetReviewConfig, ListReviewConfigs, CreateReviewConfig |
user_group | user_group.go | GetGroup, ListGroups, CreateGroup |
export_archive | export_archive.go | GetExportArchive, ListExportArchives, CreateExportArchive |
audit_log | audit_log.go | CreateAuditLog, SearchAuditLogs |
service_account | principal_service_account.go | ListServiceAccounts, CreateServiceAccount |
workload_identity | principal_workload_identity.go | ListWorkloadIdentities, CreateWorkloadIdentity |
principal Table: NO Workspace Filterprincipal is the global identity table. Queries like GetUserByEmail, GetUserByID do NOT add workspace -- a user identity is cross-workspace.
These tables (plan, issue, db, project_webhook, worksheet, db_group, release, access_grant, query_history) filter by project FK. No workspace changes needed in the store layer -- they continue to use WHERE project = $1.
The security invariant is: the project value passed to the store has already been validated as belonging to the current workspace (see Service Layer below).
Extract workspace from context, pass to store:
func (s *ProjectService) GetProject(ctx context.Context, req *v1pb.GetProjectRequest) (*v1pb.Project, error) {
workspaceID := WorkspaceIDFromContext(ctx)
project, err := s.store.GetProject(ctx, workspaceID, resourceID)
// ...
}
Validate project ownership first, then query children:
func (s *PlanService) ListPlans(ctx context.Context, req *v1pb.ListPlansRequest) (*v1pb.ListPlansResponse, error) {
workspaceID := WorkspaceIDFromContext(ctx)
projectID := getProjectID(req.Parent)
// Step 1: validate project belongs to this workspace
project, err := s.store.GetProject(ctx, workspaceID, projectID)
if err != nil { return nil, err }
// Step 2: query plans by project (already workspace-scoped)
plans, err := s.store.ListPlans(ctx, &FindPlanMessage{ProjectID: &project.ResourceID})
// ...
}
Accessed through validated parent chain:
func (s *RolloutService) GetTaskRun(ctx context.Context, req *v1pb.GetTaskRunRequest) (*v1pb.TaskRun, error) {
workspaceID := WorkspaceIDFromContext(ctx)
projectID, planUID, _, taskRunUID := parseTaskRunName(req.Name)
// Validate project belongs to workspace
project, err := s.store.GetProject(ctx, workspaceID, projectID)
if err != nil { return nil, err }
// Validate plan belongs to project
plan, err := s.store.GetPlan(ctx, &FindPlanMessage{UID: &planUID, ProjectID: &project.ResourceID})
if err != nil { return nil, err }
// Query task_run (parent chain already validated)
taskRun, err := s.store.GetTaskRun(ctx, &FindTaskRunMessage{UID: &taskRunUID})
// ...
}
Critical: every API endpoint that returns or mutates workspace-scoped data MUST enforce workspace isolation. Audit every endpoint against this checklist:
WHERE workspace = $1BatchGetUsersByEmails) handle cross-workspace correctlyGetPlan(ctx, &FindPlanMessage{UID: &planID}) must not be reachable from an API handler without first validating the parent project belongs to the workspaceCurrent caches (userEmailCache, policyCache, groupCache, iamPolicyCache) are keyed without workspace context. In SaaS mode:
userEmailCache -- OK, principal is global (email is globally unique)policyCache -- needs workspace in cache key (policies are per-workspace)iamPolicyCache -- needs workspace in cache keygroupCache -- needs workspace in cache key (groups are per-workspace)