diff --git a/server/internal/shared/auth/context.go b/server/internal/shared/auth/context.go index d7f8621..4529e4d 100644 --- a/server/internal/shared/auth/context.go +++ b/server/internal/shared/auth/context.go @@ -10,6 +10,7 @@ type Actor struct { } type actorKey struct{} +type currentWorkspaceIDKey struct{} func WithActor(ctx context.Context, actor Actor) context.Context { return context.WithValue(ctx, actorKey{}, actor) @@ -27,3 +28,26 @@ func MustActor(ctx context.Context) Actor { } return actor } + +func WithCurrentWorkspaceID(ctx context.Context, workspaceID int64) context.Context { + return context.WithValue(ctx, currentWorkspaceIDKey{}, workspaceID) +} + +func CurrentWorkspaceIDFromCtx(ctx context.Context) (int64, bool) { + workspaceID, ok := ctx.Value(currentWorkspaceIDKey{}).(int64) + if !ok || workspaceID <= 0 { + return 0, false + } + return workspaceID, true +} + +func CurrentWorkspaceID(ctx context.Context) int64 { + if workspaceID, ok := CurrentWorkspaceIDFromCtx(ctx); ok { + return workspaceID + } + actor, ok := ActorFromCtx(ctx) + if !ok { + return 0 + } + return actor.PrimaryWorkspaceID +} diff --git a/server/internal/shared/auth/context_test.go b/server/internal/shared/auth/context_test.go index d10c1ab..f54939b 100644 --- a/server/internal/shared/auth/context_test.go +++ b/server/internal/shared/auth/context_test.go @@ -47,3 +47,31 @@ func TestActorCarriesPrimaryWorkspaceID(t *testing.T) { assert.True(t, ok) assert.Equal(t, int64(30), actor.PrimaryWorkspaceID) } + +func TestCurrentWorkspaceIDFallsBackToPrimaryWorkspace(t *testing.T) { + ctx := WithActor(context.Background(), Actor{ + UserID: 10, + TenantID: 20, + PrimaryWorkspaceID: 30, + Role: "member", + }) + + assert.Equal(t, int64(30), CurrentWorkspaceID(ctx)) + _, ok := CurrentWorkspaceIDFromCtx(ctx) + assert.False(t, ok) +} + +func TestCurrentWorkspaceIDUsesScopedWorkspace(t *testing.T) { + ctx := WithActor(context.Background(), Actor{ + UserID: 10, + TenantID: 20, + PrimaryWorkspaceID: 30, + Role: "member", + }) + ctx = WithCurrentWorkspaceID(ctx, 40) + + got, ok := CurrentWorkspaceIDFromCtx(ctx) + assert.True(t, ok) + assert.Equal(t, int64(40), got) + assert.Equal(t, int64(40), CurrentWorkspaceID(ctx)) +} diff --git a/server/internal/shared/middleware/tenant_scope_test.go b/server/internal/shared/middleware/tenant_scope_test.go index f6eab98..ec22967 100644 --- a/server/internal/shared/middleware/tenant_scope_test.go +++ b/server/internal/shared/middleware/tenant_scope_test.go @@ -76,3 +76,48 @@ func TestTenantIDFromCtx_RoundTrip(t *testing.T) { func TestTenantIDFromCtx_Missing(t *testing.T) { assert.Equal(t, int64(0), TenantIDFromCtx(context.Background())) } + +func TestWorkspaceScopeDefaultsToActorPrimaryWorkspace(t *testing.T) { + r := gin.New() + r.Use(func(c *gin.Context) { + actor := auth.Actor{UserID: 1, TenantID: 10, PrimaryWorkspaceID: 30, Role: "editor"} + c.Request = c.Request.WithContext(auth.WithActor(c.Request.Context(), actor)) + c.Next() + }) + r.Use(WorkspaceScope(nil)) + + var gotWorkspaceID int64 + r.GET("/test", func(c *gin.Context) { + gotWorkspaceID = auth.CurrentWorkspaceID(c.Request.Context()) + c.Status(http.StatusOK) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, int64(30), gotWorkspaceID) +} + +func TestRequestedWorkspaceIDDefaultsToPrimary(t *testing.T) { + got, err := requestedWorkspaceID("", 42) + assert.NoError(t, err) + assert.Equal(t, int64(42), got) +} + +func TestRequestedWorkspaceIDParsesHeader(t *testing.T) { + got, err := requestedWorkspaceID("84", 42) + assert.NoError(t, err) + assert.Equal(t, int64(84), got) +} + +func TestRequestedWorkspaceIDRejectsInvalidHeader(t *testing.T) { + _, err := requestedWorkspaceID("nope", 42) + assert.Error(t, err) +} + +func TestRequestedWorkspaceIDRejectsNonPositiveHeader(t *testing.T) { + _, err := requestedWorkspaceID("0", 42) + assert.Error(t, err) +} diff --git a/server/internal/shared/middleware/workspace_scope.go b/server/internal/shared/middleware/workspace_scope.go new file mode 100644 index 0000000..7774780 --- /dev/null +++ b/server/internal/shared/middleware/workspace_scope.go @@ -0,0 +1,83 @@ +package middleware + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +const WorkspaceIDHeader = "X-Workspace-ID" + +func WorkspaceScope(pool *pgxpool.Pool) gin.HandlerFunc { + return func(c *gin.Context) { + actor, ok := auth.ActorFromCtx(c.Request.Context()) + if !ok || actor.TenantID == 0 || actor.UserID == 0 || actor.PrimaryWorkspaceID == 0 { + response.Error(c, response.ErrUnauthorized(40105, "workspace_scope_missing", "workspace context is required")) + c.Abort() + return + } + + workspaceID, err := requestedWorkspaceID(c.GetHeader(WorkspaceIDHeader), actor.PrimaryWorkspaceID) + if err != nil { + response.Error(c, response.ErrBadRequest(40032, "invalid_workspace_id", "workspace id must be a positive number")) + c.Abort() + return + } + + if workspaceID != actor.PrimaryWorkspaceID { + if pool == nil { + response.Error(c, response.ErrInternal(50012, "workspace_scope_unavailable", "workspace scope validation is unavailable")) + c.Abort() + return + } + if err := validateWorkspaceMembership(c.Request.Context(), pool, actor.TenantID, workspaceID, actor.UserID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + response.Error(c, response.ErrForbidden(40312, "workspace_forbidden", "workspace is not available for current user")) + } else { + response.Error(c, response.ErrInternal(50012, "workspace_scope_lookup_failed", "failed to validate workspace scope")) + } + c.Abort() + return + } + } + + c.Request = c.Request.WithContext(auth.WithCurrentWorkspaceID(c.Request.Context(), workspaceID)) + c.Next() + } +} + +func requestedWorkspaceID(raw string, fallback int64) (int64, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return fallback, nil + } + workspaceID, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil { + return 0, err + } + if workspaceID <= 0 { + return 0, fmt.Errorf("workspace id must be positive") + } + return workspaceID, nil +} + +func validateWorkspaceMembership(ctx context.Context, pool *pgxpool.Pool, tenantID, workspaceID, userID int64) error { + var exists int + return pool.QueryRow(ctx, ` + SELECT 1 + FROM workspace_memberships + WHERE tenant_id = $1 + AND workspace_id = $2 + AND user_id = $3 + LIMIT 1 + `, tenantID, workspaceID, userID).Scan(&exists) +}