feat: scope articles by brand
This commit is contained in:
@@ -11,6 +11,7 @@ type Actor struct {
|
||||
|
||||
type actorKey struct{}
|
||||
type currentWorkspaceIDKey struct{}
|
||||
type currentBrandIDKey struct{}
|
||||
|
||||
func WithActor(ctx context.Context, actor Actor) context.Context {
|
||||
return context.WithValue(ctx, actorKey{}, actor)
|
||||
@@ -51,3 +52,22 @@ func CurrentWorkspaceID(ctx context.Context) int64 {
|
||||
}
|
||||
return actor.PrimaryWorkspaceID
|
||||
}
|
||||
|
||||
func WithCurrentBrandID(ctx context.Context, brandID int64) context.Context {
|
||||
return context.WithValue(ctx, currentBrandIDKey{}, brandID)
|
||||
}
|
||||
|
||||
func CurrentBrandIDFromCtx(ctx context.Context) (int64, bool) {
|
||||
brandID, ok := ctx.Value(currentBrandIDKey{}).(int64)
|
||||
if !ok || brandID <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return brandID, true
|
||||
}
|
||||
|
||||
func CurrentBrandID(ctx context.Context) int64 {
|
||||
if brandID, ok := CurrentBrandIDFromCtx(ctx); ok {
|
||||
return brandID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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 BrandIDHeader = "X-Brand-ID"
|
||||
|
||||
func BrandScope(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 {
|
||||
response.Error(c, response.ErrUnauthorized(40100, "not_authenticated", "not authenticated"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
brandID, hasHeader, err := requestedBrandID(c.GetHeader(BrandIDHeader))
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40033, "invalid_brand_id", "brand id must be a positive number"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !hasHeader {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if pool == nil {
|
||||
response.Error(c, response.ErrInternal(50012, "brand_scope_unavailable", "brand scope validation is unavailable"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if err := validateBrandScope(c.Request.Context(), pool, actor.TenantID, brandID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
response.Error(c, response.ErrForbidden(40313, "brand_forbidden", "brand is not available for current tenant"))
|
||||
} else {
|
||||
response.Error(c, response.ErrInternal(50012, "brand_scope_lookup_failed", "failed to validate brand scope"))
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Request = c.Request.WithContext(auth.WithCurrentBrandID(c.Request.Context(), brandID))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireCurrentBrand() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, ok := auth.CurrentBrandIDFromCtx(c.Request.Context()); !ok {
|
||||
response.Error(c, response.ErrBadRequest(40033, "brand_context_required", "current brand is required"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ArticleBrandScope(pool *pgxpool.Pool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rawArticleID := strings.TrimSpace(c.Param("id"))
|
||||
if rawArticleID == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
articleID, err := strconv.ParseInt(rawArticleID, 10, 64)
|
||||
if err != nil || articleID <= 0 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
actor, ok := auth.ActorFromCtx(c.Request.Context())
|
||||
if !ok || actor.TenantID == 0 {
|
||||
response.Error(c, response.ErrUnauthorized(40100, "not_authenticated", "not authenticated"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
brandID, ok := auth.CurrentBrandIDFromCtx(c.Request.Context())
|
||||
if !ok {
|
||||
response.Error(c, response.ErrBadRequest(40033, "brand_context_required", "current brand is required"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if pool == nil {
|
||||
response.Error(c, response.ErrInternal(50012, "brand_scope_unavailable", "brand scope validation is unavailable"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := pool.QueryRow(c.Request.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM articles
|
||||
WHERE id = $1
|
||||
AND tenant_id = $2
|
||||
AND brand_id = $3
|
||||
AND deleted_at IS NULL
|
||||
)
|
||||
`, articleID, actor.TenantID, brandID).Scan(&exists); err != nil {
|
||||
response.Error(c, response.ErrInternal(50012, "brand_scope_lookup_failed", "failed to validate article brand scope"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
response.Error(c, response.ErrNotFound(40411, "article_not_found", "article not found"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func requestedBrandID(raw string) (int64, bool, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
brandID, err := strconv.ParseInt(trimmed, 10, 64)
|
||||
if err != nil {
|
||||
return 0, true, err
|
||||
}
|
||||
if brandID <= 0 {
|
||||
return 0, true, fmt.Errorf("brand id must be positive")
|
||||
}
|
||||
return brandID, true, nil
|
||||
}
|
||||
|
||||
func validateBrandScope(ctx context.Context, pool *pgxpool.Pool, tenantID, brandID int64) error {
|
||||
var exists int
|
||||
return pool.QueryRow(ctx, `
|
||||
SELECT 1
|
||||
FROM brands
|
||||
WHERE tenant_id = $1
|
||||
AND id = $2
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`, tenantID, brandID).Scan(&exists)
|
||||
}
|
||||
Reference in New Issue
Block a user