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
+21
View File
@@ -0,0 +1,21 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Request-ID")
c.Header("Access-Control-Expose-Headers", "X-Request-ID")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
@@ -0,0 +1,23 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func Logger(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
logger.Info("request",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.Duration("latency", time.Since(start)),
zap.String("request_id", RequestIDFromGin(c)),
zap.String("client_ip", c.ClientIP()),
)
}
}
@@ -0,0 +1,30 @@
package middleware
import (
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func Recovery(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
logger.Error("panic recovered",
zap.Any("error", r),
zap.String("stack", string(debug.Stack())),
zap.String("request_id", RequestIDFromGin(c)),
)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"code": 50000,
"message": "internal_error",
"detail": "An unexpected error occurred",
"request_id": RequestIDFromGin(c),
})
}
}()
c.Next()
}
}
@@ -0,0 +1,28 @@
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const RequestIDHeader = "X-Request-ID"
const requestIDKey = "request_id"
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
rid := c.GetHeader(RequestIDHeader)
if rid == "" {
rid = uuid.New().String()
}
c.Set(requestIDKey, rid)
c.Header(RequestIDHeader, rid)
c.Next()
}
}
func RequestIDFromGin(c *gin.Context) string {
if rid, ok := c.Get(requestIDKey); ok {
return rid.(string)
}
return ""
}
@@ -0,0 +1,43 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestRequestID_GeneratesNew(t *testing.T) {
r := gin.New()
r.Use(RequestID())
r.GET("/test", func(c *gin.Context) {
rid := RequestIDFromGin(c)
assert.NotEmpty(t, rid)
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.NotEmpty(t, w.Header().Get(RequestIDHeader))
}
func TestRequestID_UsesExisting(t *testing.T) {
r := gin.New()
r.Use(RequestID())
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set(RequestIDHeader, "my-custom-rid")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "my-custom-rid", w.Header().Get(RequestIDHeader))
}
@@ -0,0 +1,37 @@
package middleware
import (
"context"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type tenantIDKey struct{}
func TenantScope() gin.HandlerFunc {
return func(c *gin.Context) {
actor, ok := auth.ActorFromCtx(c.Request.Context())
if !ok || actor.TenantID == 0 {
response.Error(c, response.ErrUnauthorized(40104, "tenant_scope_missing", "tenant context is required"))
c.Abort()
return
}
ctx := WithTenantID(c.Request.Context(), actor.TenantID)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func WithTenantID(ctx context.Context, tenantID int64) context.Context {
return context.WithValue(ctx, tenantIDKey{}, tenantID)
}
func TenantIDFromCtx(ctx context.Context) int64 {
if v, ok := ctx.Value(tenantIDKey{}).(int64); ok {
return v
}
return 0
}
@@ -0,0 +1,78 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/geo-platform/tenant-api/internal/shared/auth"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestTenantScope_ValidActor(t *testing.T) {
r := gin.New()
r.Use(func(c *gin.Context) {
actor := auth.Actor{UserID: 1, TenantID: 10, Role: "editor"}
c.Request = c.Request.WithContext(auth.WithActor(c.Request.Context(), actor))
c.Next()
})
r.Use(TenantScope())
var gotTenantID int64
r.GET("/test", func(c *gin.Context) {
gotTenantID = TenantIDFromCtx(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(10), gotTenantID)
}
func TestTenantScope_MissingActor(t *testing.T) {
r := gin.New()
r.Use(TenantScope())
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 TestTenantScope_ZeroTenantID(t *testing.T) {
r := gin.New()
r.Use(func(c *gin.Context) {
actor := auth.Actor{UserID: 1, TenantID: 0, Role: "editor"}
c.Request = c.Request.WithContext(auth.WithActor(c.Request.Context(), actor))
c.Next()
})
r.Use(TenantScope())
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 TestTenantIDFromCtx_RoundTrip(t *testing.T) {
ctx := WithTenantID(context.Background(), 42)
assert.Equal(t, int64(42), TenantIDFromCtx(ctx))
}
func TestTenantIDFromCtx_Missing(t *testing.T) {
assert.Equal(t, int64(0), TenantIDFromCtx(context.Background()))
}