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:
@@ -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