feat: add tenant and user management with migrations, handlers, and tests

- Implemented tenant and user management features including:
  - Tenant creation and management with associated migrations.
  - User creation and management with associated migrations.
  - Tenant membership management with associated migrations.
  - Platform user roles management with associated migrations.
  - Quota management with associated migrations.
  - Article and template management with associated migrations.
- Added HTTP handlers for templates and workspaces.
- Created tests for protected and public routes.
- Introduced a script to check tenant scope in SQL queries.
- Documented task plan for backend completion and frontend foundation.
This commit is contained in:
2026-04-01 00:58:42 +08:00
commit de30497f59
210 changed files with 23733 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
package auth
import (
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID int64 `json:"user_id"`
TenantID int64 `json:"tenant_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
+28
View File
@@ -0,0 +1,28 @@
package auth
import "context"
type Actor struct {
UserID int64
TenantID int64
Role string
}
type actorKey struct{}
func WithActor(ctx context.Context, actor Actor) context.Context {
return context.WithValue(ctx, actorKey{}, actor)
}
func ActorFromCtx(ctx context.Context) (Actor, bool) {
actor, ok := ctx.Value(actorKey{}).(Actor)
return actor, ok
}
func MustActor(ctx context.Context) Actor {
actor, ok := ActorFromCtx(ctx)
if !ok {
panic("auth: actor not in context")
}
return actor
}
@@ -0,0 +1,36 @@
package auth
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestWithActorAndFromCtx(t *testing.T) {
actor := Actor{UserID: 1, TenantID: 10, Role: "editor"}
ctx := WithActor(context.Background(), actor)
got, ok := ActorFromCtx(ctx)
assert.True(t, ok)
assert.Equal(t, actor, got)
}
func TestActorFromCtxMissing(t *testing.T) {
_, ok := ActorFromCtx(context.Background())
assert.False(t, ok)
}
func TestMustActorPanics(t *testing.T) {
assert.Panics(t, func() {
MustActor(context.Background())
})
}
func TestMustActorReturns(t *testing.T) {
actor := Actor{UserID: 5, TenantID: 20, Role: "tenant_admin"}
ctx := WithActor(context.Background(), actor)
got := MustActor(ctx)
assert.Equal(t, actor, got)
}
+103
View File
@@ -0,0 +1,103 @@
package auth
import (
"crypto/sha256"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type Manager struct {
secret []byte
accessTTL time.Duration
refreshTTL time.Duration
}
func NewManager(secret string, accessTTL, refreshTTL time.Duration) *Manager {
return &Manager{
secret: []byte(secret),
accessTTL: accessTTL,
refreshTTL: refreshTTL,
}
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
AccessJTI string `json:"-"`
RefreshJTI string `json:"-"`
ExpiresAt int64 `json:"expires_at"`
}
func (m *Manager) Issue(userID, tenantID int64, role string) (*TokenPair, error) {
now := time.Now()
accessJTI := uuid.New().String()
refreshJTI := uuid.New().String()
accessClaims := Claims{
UserID: userID,
TenantID: tenantID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ID: accessJTI,
Subject: "access",
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(m.accessTTL)),
},
}
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(m.secret)
if err != nil {
return nil, fmt.Errorf("sign access token: %w", err)
}
refreshClaims := Claims{
UserID: userID,
TenantID: tenantID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ID: refreshJTI,
Subject: "refresh",
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(m.refreshTTL)),
},
}
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(m.secret)
if err != nil {
return nil, fmt.Errorf("sign refresh token: %w", err)
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
AccessJTI: accessJTI,
RefreshJTI: refreshJTI,
ExpiresAt: now.Add(m.accessTTL).Unix(),
}, nil
}
func (m *Manager) Parse(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return m.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}
func (m *Manager) AccessTTL() time.Duration { return m.accessTTL }
func (m *Manager) RefreshTTL() time.Duration { return m.refreshTTL }
func HashToken(raw string) string {
h := sha256.Sum256([]byte(raw))
return fmt.Sprintf("%x", h)
}
+85
View File
@@ -0,0 +1,85 @@
package auth
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueAndParse(t *testing.T) {
mgr := NewManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
pair, err := mgr.Issue(42, 10, "tenant_admin")
require.NoError(t, err)
assert.NotEmpty(t, pair.AccessToken)
assert.NotEmpty(t, pair.RefreshToken)
assert.NotEmpty(t, pair.AccessJTI)
assert.NotEmpty(t, pair.RefreshJTI)
assert.NotZero(t, pair.ExpiresAt)
claims, err := mgr.Parse(pair.AccessToken)
require.NoError(t, err)
assert.Equal(t, int64(42), claims.UserID)
assert.Equal(t, int64(10), claims.TenantID)
assert.Equal(t, "tenant_admin", claims.Role)
assert.Equal(t, "access", claims.Subject)
assert.Equal(t, pair.AccessJTI, claims.ID)
}
func TestParseRefreshToken(t *testing.T) {
mgr := NewManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
pair, err := mgr.Issue(1, 1, "viewer")
require.NoError(t, err)
claims, err := mgr.Parse(pair.RefreshToken)
require.NoError(t, err)
assert.Equal(t, "refresh", claims.Subject)
assert.Equal(t, pair.RefreshJTI, claims.ID)
}
func TestParseExpired(t *testing.T) {
mgr := NewManager("test-secret-key", -1*time.Second, -1*time.Second)
pair, err := mgr.Issue(1, 1, "viewer")
require.NoError(t, err)
_, err = mgr.Parse(pair.AccessToken)
assert.Error(t, err)
}
func TestParseWrongSecret(t *testing.T) {
mgr1 := NewManager("secret-one", 15*time.Minute, 7*24*time.Hour)
mgr2 := NewManager("secret-two", 15*time.Minute, 7*24*time.Hour)
pair, err := mgr1.Issue(1, 1, "viewer")
require.NoError(t, err)
_, err = mgr2.Parse(pair.AccessToken)
assert.Error(t, err)
}
func TestParseGarbage(t *testing.T) {
mgr := NewManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
_, err := mgr.Parse("not-a-real-token")
assert.Error(t, err)
}
func TestHashToken(t *testing.T) {
h1 := HashToken("some-token")
h2 := HashToken("some-token")
h3 := HashToken("different-token")
assert.Equal(t, h1, h2)
assert.NotEqual(t, h1, h3)
assert.Len(t, h1, 64) // SHA-256 hex
}
func TestAccessAndRefreshTTL(t *testing.T) {
mgr := NewManager("s", 15*time.Minute, 24*time.Hour)
assert.Equal(t, 15*time.Minute, mgr.AccessTTL())
assert.Equal(t, 24*time.Hour, mgr.RefreshTTL())
}
+50
View File
@@ -0,0 +1,50 @@
package auth
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
func Middleware(jwtMgr *Manager, sessions *SessionStore) gin.HandlerFunc {
return func(c *gin.Context) {
raw, err := bearerToken(c.GetHeader("Authorization"))
if err != nil {
response.Error(c, response.ErrUnauthorized(40101, "missing_bearer_token", "Authorization header is required"))
c.Abort()
return
}
claims, err := jwtMgr.Parse(raw)
if err != nil || claims.Subject != "access" {
response.Error(c, response.ErrUnauthorized(40102, "invalid_access_token", "token is invalid or expired"))
c.Abort()
return
}
revoked, _ := sessions.IsBlacklisted(c.Request.Context(), claims.ID)
if revoked {
response.Error(c, response.ErrUnauthorized(40103, "token_revoked", "token has been logged out"))
c.Abort()
return
}
actor := Actor{
UserID: claims.UserID,
TenantID: claims.TenantID,
Role: claims.Role,
}
c.Request = c.Request.WithContext(WithActor(c.Request.Context(), actor))
c.Next()
}
}
func bearerToken(header string) (string, error) {
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return "", response.ErrUnauthorized(40101, "missing_bearer_token", "Authorization header is required")
}
return parts[1], nil
}
@@ -0,0 +1,124 @@
package auth
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
goredis "github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func init() {
gin.SetMode(gin.TestMode)
}
func setupTestMiddleware(t *testing.T) (*Manager, *SessionStore, *miniredis.Miniredis) {
t.Helper()
mr := miniredis.RunT(t)
rdb := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
mgr := NewManager("test-middleware-secret", 15*time.Minute, 7*24*time.Hour)
sessions := NewSessionStore(rdb)
return mgr, sessions, mr
}
func TestAuthMiddleware_ValidToken(t *testing.T) {
mgr, sessions, _ := setupTestMiddleware(t)
pair, err := mgr.Issue(1, 10, "editor")
require.NoError(t, err)
r := gin.New()
r.Use(Middleware(mgr, sessions))
var gotActor Actor
r.GET("/test", func(c *gin.Context) {
a, ok := ActorFromCtx(c.Request.Context())
assert.True(t, ok)
gotActor = a
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, int64(1), gotActor.UserID)
assert.Equal(t, int64(10), gotActor.TenantID)
assert.Equal(t, "editor", gotActor.Role)
}
func TestAuthMiddleware_MissingHeader(t *testing.T) {
mgr, sessions, _ := setupTestMiddleware(t)
r := gin.New()
r.Use(Middleware(mgr, sessions))
r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAuthMiddleware_InvalidToken(t *testing.T) {
mgr, sessions, _ := setupTestMiddleware(t)
r := gin.New()
r.Use(Middleware(mgr, sessions))
r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer bad-token-value")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAuthMiddleware_RefreshTokenRejected(t *testing.T) {
mgr, sessions, _ := setupTestMiddleware(t)
pair, err := mgr.Issue(1, 10, "editor")
require.NoError(t, err)
r := gin.New()
r.Use(Middleware(mgr, sessions))
r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer "+pair.RefreshToken)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAuthMiddleware_BlacklistedToken(t *testing.T) {
mgr, sessions, mr := setupTestMiddleware(t)
pair, err := mgr.Issue(1, 10, "editor")
require.NoError(t, err)
// Blacklist the access token's JTI
require.NoError(t, mr.Set(fmt.Sprintf("blacklist:%s", pair.AccessJTI), "1"))
r := gin.New()
r.Use(Middleware(mgr, sessions))
r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
@@ -0,0 +1,30 @@
package auth
var rolePermissions = map[string][]string{
"tenant_admin": {
"brand:read", "brand:write", "brand:delete",
"article:read", "article:write", "article:delete",
"template:read", "template:generate",
"workspace:read",
"member:read", "member:write",
},
"editor": {
"brand:read", "brand:write",
"article:read", "article:write",
"template:read", "template:generate",
"workspace:read",
},
"viewer": {
"brand:read",
"article:read",
"template:read",
"workspace:read",
},
}
func PermissionsForRole(role string) []string {
if perms, ok := rolePermissions[role]; ok {
return perms
}
return nil
}
@@ -0,0 +1,35 @@
package auth
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPermissionsForRole_Admin(t *testing.T) {
perms := PermissionsForRole("tenant_admin")
assert.Contains(t, perms, "brand:write")
assert.Contains(t, perms, "brand:delete")
assert.Contains(t, perms, "member:write")
}
func TestPermissionsForRole_Editor(t *testing.T) {
perms := PermissionsForRole("editor")
assert.Contains(t, perms, "article:write")
assert.Contains(t, perms, "template:generate")
assert.NotContains(t, perms, "brand:delete")
assert.NotContains(t, perms, "member:write")
}
func TestPermissionsForRole_Viewer(t *testing.T) {
perms := PermissionsForRole("viewer")
assert.Contains(t, perms, "brand:read")
assert.Contains(t, perms, "article:read")
assert.NotContains(t, perms, "brand:write")
assert.NotContains(t, perms, "article:write")
}
func TestPermissionsForRole_Unknown(t *testing.T) {
perms := PermissionsForRole("nonexistent")
assert.Nil(t, perms)
}
@@ -0,0 +1,93 @@
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var (
ErrRefreshSessionNotFound = errors.New("refresh session not found")
ErrRefreshTokenMismatch = errors.New("refresh token mismatch")
)
var rotateRefreshScript = redis.NewScript(`
local current = redis.call("GET", KEYS[1])
if not current then
return 0
end
if current ~= ARGV[1] then
return -1
end
redis.call("DEL", KEYS[1])
redis.call("PSETEX", KEYS[2], ARGV[3], ARGV[2])
return 1
`)
type SessionStore struct {
rdb *redis.Client
}
func NewSessionStore(rdb *redis.Client) *SessionStore {
return &SessionStore{rdb: rdb}
}
func (s *SessionStore) SaveRefresh(ctx context.Context, jti string, tokenHash string, ttl time.Duration) error {
key := fmt.Sprintf("refresh:%s", jti)
return s.rdb.Set(ctx, key, tokenHash, ttl).Err()
}
func (s *SessionStore) GetRefresh(ctx context.Context, jti string) (string, error) {
key := fmt.Sprintf("refresh:%s", jti)
return s.rdb.Get(ctx, key).Result()
}
func (s *SessionStore) DeleteRefresh(ctx context.Context, jti string) error {
key := fmt.Sprintf("refresh:%s", jti)
return s.rdb.Del(ctx, key).Err()
}
func (s *SessionStore) RotateRefresh(ctx context.Context, oldJTI, oldHash, newJTI, newHash string, ttl time.Duration) error {
oldKey := fmt.Sprintf("refresh:%s", oldJTI)
newKey := fmt.Sprintf("refresh:%s", newJTI)
result, err := rotateRefreshScript.Run(
ctx,
s.rdb,
[]string{oldKey, newKey},
oldHash,
newHash,
ttl.Milliseconds(),
).Int()
if err != nil {
return err
}
switch result {
case 1:
return nil
case 0:
return ErrRefreshSessionNotFound
case -1:
return ErrRefreshTokenMismatch
default:
return fmt.Errorf("unexpected rotate refresh result: %d", result)
}
}
func (s *SessionStore) Blacklist(ctx context.Context, accessJTI string, ttl time.Duration) error {
key := fmt.Sprintf("blacklist:%s", accessJTI)
return s.rdb.Set(ctx, key, "1", ttl).Err()
}
func (s *SessionStore) IsBlacklisted(ctx context.Context, accessJTI string) (bool, error) {
key := fmt.Sprintf("blacklist:%s", accessJTI)
n, err := s.rdb.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return n > 0, nil
}
@@ -0,0 +1,52 @@
package auth
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
goredis "github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSessionStoreRotateRefreshSuccess(t *testing.T) {
mr := miniredis.RunT(t)
client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
store := NewSessionStore(client)
ctx := context.Background()
require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute))
require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti", "new-hash", 2*time.Minute))
_, err := store.GetRefresh(ctx, "old-jti")
assert.Error(t, err)
value, err := store.GetRefresh(ctx, "new-jti")
require.NoError(t, err)
assert.Equal(t, "new-hash", value)
}
func TestSessionStoreRotateRefreshRejectsMismatch(t *testing.T) {
mr := miniredis.RunT(t)
client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
store := NewSessionStore(client)
ctx := context.Background()
require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute))
err := store.RotateRefresh(ctx, "old-jti", "wrong-hash", "new-jti", "new-hash", 2*time.Minute)
require.ErrorIs(t, err, ErrRefreshTokenMismatch)
value, getErr := store.GetRefresh(ctx, "old-jti")
require.NoError(t, getErr)
assert.Equal(t, "old-hash", value)
_, getErr = store.GetRefresh(ctx, "new-jti")
assert.Error(t, getErr)
}