diff --git a/internal/store/pg/helpers.go b/internal/store/pg/helpers.go index 3c46bc3a..c4b6c84e 100644 --- a/internal/store/pg/helpers.go +++ b/internal/store/pg/helpers.go @@ -257,3 +257,28 @@ func requireTenantID(ctx context.Context) (uuid.UUID, error) { } return tid, nil } + +// --- Scope-based helpers (composable alternative to tenantClauseN) --- +// Use these for new code. Old tenantClauseN/tenantClauseNAlias remain for existing callers. + +// scopeClause extracts QueryScope from context and generates WHERE conditions. +// Drop-in replacement for tenantClauseN that supports future project-level scoping. +func scopeClause(ctx context.Context, startParam int) (clause string, args []any, nextParam int, err error) { + scope, err := store.ScopeFromContext(ctx) + if err != nil { + return "", nil, startParam, err + } + clause, args, nextParam = scope.WhereClause(startParam) + return clause, args, nextParam, nil +} + +// scopeClauseAlias is like scopeClause but qualifies columns with a table alias. +// SECURITY: alias is interpolated into SQL — callers MUST pass hardcoded string literals only. +func scopeClauseAlias(ctx context.Context, startParam int, alias string) (clause string, args []any, nextParam int, err error) { + scope, err := store.ScopeFromContext(ctx) + if err != nil { + return "", nil, startParam, err + } + clause, args, nextParam = scope.WhereClauseAlias(startParam, alias) + return clause, args, nextParam, nil +} diff --git a/internal/store/pg/providers.go b/internal/store/pg/providers.go index fdb40731..1c42b1f3 100644 --- a/internal/store/pg/providers.go +++ b/internal/store/pg/providers.go @@ -61,7 +61,7 @@ func (s *PGProviderStore) CreateProvider(ctx context.Context, p *store.LLMProvid } func (s *PGProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store.LLMProviderData, error) { - tClause, tArgs, err := tenantClauseN(ctx, 2) + tClause, tArgs, _, err := scopeClause(ctx, 2) if err != nil { return nil, err } @@ -80,7 +80,7 @@ func (s *PGProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store } func (s *PGProviderStore) GetProviderByName(ctx context.Context, name string) (*store.LLMProviderData, error) { - tClause, tArgs, err := tenantClauseN(ctx, 2) + tClause, tArgs, _, err := scopeClause(ctx, 2) if err != nil { return nil, err } @@ -99,7 +99,7 @@ func (s *PGProviderStore) GetProviderByName(ctx context.Context, name string) (* } func (s *PGProviderStore) ListProviders(ctx context.Context) ([]store.LLMProviderData, error) { - tClause, tArgs, err := tenantClauseN(ctx, 1) + tClause, tArgs, _, err := scopeClause(ctx, 1) if err != nil { return nil, err } @@ -168,7 +168,7 @@ func (s *PGProviderStore) UpdateProvider(ctx context.Context, id uuid.UUID, upda } func (s *PGProviderStore) DeleteProvider(ctx context.Context, id uuid.UUID) error { - tClause, tArgs, err := tenantClauseN(ctx, 2) + tClause, tArgs, _, err := scopeClause(ctx, 2) if err != nil { return err } diff --git a/internal/store/scope.go b/internal/store/scope.go new file mode 100644 index 00000000..6dce46d7 --- /dev/null +++ b/internal/store/scope.go @@ -0,0 +1,78 @@ +package store + +import ( + "context" + "fmt" + + "github.com/google/uuid" +) + +// QueryScope represents the multi-level isolation scope for database queries. +// Currently supports tenant-level scoping; designed to be extended with +// project-level scoping when the Project feature is implemented. +type QueryScope struct { + TenantID uuid.UUID + ProjectID *uuid.UUID // nil = no project filter (tenant-only) +} + +// ScopeFromContext extracts the query scope from context. +// Returns error if tenant ID is missing (fail-closed). +func ScopeFromContext(ctx context.Context) (QueryScope, error) { + tid := TenantIDFromContext(ctx) + if tid == uuid.Nil { + return QueryScope{}, fmt.Errorf("tenant_id required") + } + return QueryScope{TenantID: tid}, nil +} + +// WhereClause generates SQL WHERE conditions for the scope. +// Returns the clause string (e.g. " AND tenant_id = $3"), args, and the next +// available parameter index. Callers chain: startParam → nextParam. +func (s QueryScope) WhereClause(startParam int) (clause string, args []any, nextParam int) { + clause = fmt.Sprintf(" AND tenant_id = $%d", startParam) + args = []any{s.TenantID} + nextParam = startParam + 1 + + if s.ProjectID != nil { + clause += fmt.Sprintf(" AND project_id = $%d", nextParam) + args = append(args, *s.ProjectID) + nextParam++ + } + + return clause, args, nextParam +} + +// WhereClauseAlias generates SQL WHERE conditions qualified with a table alias. +// Used in JOIN queries to avoid column ambiguity. +// SECURITY: alias is interpolated into SQL — callers MUST pass hardcoded string literals only. +func (s QueryScope) WhereClauseAlias(startParam int, alias string) (clause string, args []any, nextParam int) { + clause = fmt.Sprintf(" AND %s.tenant_id = $%d", alias, startParam) + args = []any{s.TenantID} + nextParam = startParam + 1 + + if s.ProjectID != nil { + clause += fmt.Sprintf(" AND %s.project_id = $%d", alias, nextParam) + args = append(args, *s.ProjectID) + nextParam++ + } + + return clause, args, nextParam +} + +// InsertValues returns column names and values for INSERT operations. +// Falls back to MasterTenantID when TenantID is nil. +func (s QueryScope) InsertValues() (columns []string, values []any) { + tid := s.TenantID + if tid == uuid.Nil { + tid = MasterTenantID + } + columns = []string{"tenant_id"} + values = []any{tid} + + if s.ProjectID != nil { + columns = append(columns, "project_id") + values = append(values, *s.ProjectID) + } + + return columns, values +}