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:
@@ -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()))
|
||||
}
|
||||
Reference in New Issue
Block a user