refactor(store): add QueryScope abstraction for composable multi-level isolation

Introduce store.QueryScope struct that generates WHERE clauses for
tenant + optional project scoping. Add scopeClause/scopeClauseAlias
wrappers in pg/helpers.go as drop-in replacements for tenantClauseN.

Old tenant-only helpers remain for existing callers (76 occurrences).
Migrate providers.go (4 calls) as proof-of-concept. Future callers
should use scopeClause for project-scope readiness.
This commit is contained in:
viettranx committed 2026-03-25 08:20:10 +07:00
1 parent 379da91a68
commit deb821063a
3 files changed
+107 -4

No files matched your search

+25
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+78
View File
@@ -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
}