Back to Bytebase

Step 03: Application Layer Changes

docs/plans/saas/03.application-layer.md

3.17.16.7 KB
Original Source

Step 03: Application Layer Changes

Update middleware, store, and service layers to enforce workspace scoping on all API requests. This step depends on Step 02 (database migration) being completed.


1. Workspace Context Middleware

Extract workspace from the JWT and inject it into the request context. Every authenticated request carries workspace context.

go
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 ""
}

2. Store Layer: Root Table Queries

All root table queries (12 tables) add WHERE workspace = $1. This is the primary isolation boundary.

Pattern for Root Tables

go
// 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, ...)"
    // ...
}

Tables to Update

TableStore fileKey functions
projectproject.goGetProject, ListProjects, CreateProject
instanceinstance.goGetInstance, ListInstances, CreateInstance
settingsetting.goGetSetting, ListSettings, UpsertSetting
policypolicy.goGetWorkspaceIamPolicy, GetPolicy, ListPolicies, UpsertPolicy
rolerole.goGetRole, ListRoles, CreateRole
idpidp.goGetIdentityProvider, ListIdentityProviders, CreateIdentityProvider
review_configreview_config.goGetReviewConfig, ListReviewConfigs, CreateReviewConfig
user_groupuser_group.goGetGroup, ListGroups, CreateGroup
export_archiveexport_archive.goGetExportArchive, ListExportArchives, CreateExportArchive
audit_logaudit_log.goCreateAuditLog, SearchAuditLogs
service_accountprincipal_service_account.goListServiceAccounts, CreateServiceAccount
workload_identityprincipal_workload_identity.goListWorkloadIdentities, CreateWorkloadIdentity

principal Table: NO Workspace Filter

principal is the global identity table. Queries like GetUserByEmail, GetUserByID do NOT add workspace -- a user identity is cross-workspace.


3. Store Layer: Project-Child Table Queries

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).


4. Service Layer: Workspace Scoping

Root Table Services

Extract workspace from context, pass to store:

go
func (s *ProjectService) GetProject(ctx context.Context, req *v1pb.GetProjectRequest) (*v1pb.Project, error) {
    workspaceID := WorkspaceIDFromContext(ctx)
    project, err := s.store.GetProject(ctx, workspaceID, resourceID)
    // ...
}

Project-Child Services

Validate project ownership first, then query children:

go
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})
    // ...
}

Grandchild Services

Accessed through validated parent chain:

go
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})
    // ...
}

5. Security Checklist

Critical: every API endpoint that returns or mutates workspace-scoped data MUST enforce workspace isolation. Audit every endpoint against this checklist:

  • Root table endpoints: store query includes WHERE workspace = $1
  • Project-child endpoints: project is validated against workspace before querying children
  • Grandchild endpoints: parent chain is validated (project -> plan/issue/db -> child)
  • Cross-table joins: any JOIN that touches root tables includes workspace condition
  • Batch operations: bulk queries (e.g., BatchGetUsersByEmails) handle cross-workspace correctly
  • Cache invalidation: caches keyed by resource name include workspace context to prevent cross-workspace leaks
  • No direct PK lookups without parent validation: queries like GetPlan(ctx, &FindPlanMessage{UID: &planID}) must not be reachable from an API handler without first validating the parent project belongs to the workspace

Cache Considerations

Current 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 key
  • groupCache -- needs workspace in cache key (groups are per-workspace)