feat(auth): add workspace scope middleware and context
Introduces current-workspace context helpers plus a WorkspaceScope middleware that validates X-Workspace-ID against workspace memberships, defaulting to the actor's primary workspace. Prepares monitoring paths to resolve quota/access-state per request workspace instead of per tenant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ type Actor struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type actorKey struct{}
|
type actorKey struct{}
|
||||||
|
type currentWorkspaceIDKey struct{}
|
||||||
|
|
||||||
func WithActor(ctx context.Context, actor Actor) context.Context {
|
func WithActor(ctx context.Context, actor Actor) context.Context {
|
||||||
return context.WithValue(ctx, actorKey{}, actor)
|
return context.WithValue(ctx, actorKey{}, actor)
|
||||||
@@ -27,3 +28,26 @@ func MustActor(ctx context.Context) Actor {
|
|||||||
}
|
}
|
||||||
return 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,3 +47,31 @@ func TestActorCarriesPrimaryWorkspaceID(t *testing.T) {
|
|||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Equal(t, int64(30), actor.PrimaryWorkspaceID)
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,3 +76,48 @@ func TestTenantIDFromCtx_RoundTrip(t *testing.T) {
|
|||||||
func TestTenantIDFromCtx_Missing(t *testing.T) {
|
func TestTenantIDFromCtx_Missing(t *testing.T) {
|
||||||
assert.Equal(t, int64(0), TenantIDFromCtx(context.Background()))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user