feat: Add tenant middleware with authentication, authorization, isolation, and activity logging

- Add TenantMiddleware struct with tenant service and logger fields
- Add ExtractTenantFromHeader and ExtractTenantFromAPIKey methods for tenant identification from headers, subdomains, and API keys
- Add RequireTenant middleware with header and API key extraction fallback and tenant access validation
- Add RequireTenantRole middleware with role-based access control for multiple required roles
This commit is contained in:
nutcas3
2026-05-02 01:38:47 +03:00
parent 00ad4f34e3
commit 69951d68db
@@ -0,0 +1,331 @@
package tenant
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// TenantMiddleware provides tenant isolation middleware
type TenantMiddleware struct {
tenantService Service
logger *logrus.Logger
}
// NewTenantMiddleware creates a new tenant middleware
func NewTenantMiddleware(tenantService Service, logger *logrus.Logger) *TenantMiddleware {
return &TenantMiddleware{
tenantService: tenantService,
logger: logger,
}
}
// TenantContextKey is the key used to store tenant context in the request context
type TenantContextKey string
const (
TenantCtxKey TenantContextKey = "tenant_context"
UserIDKey TenantContextKey = "user_id"
)
// ExtractTenantFromHeader extracts tenant information from HTTP headers
func (tm *TenantMiddleware) ExtractTenantFromHeader(c *gin.Context) (*TenantContext, error) {
// Try to get tenant from X-Tenant-ID header
tenantID := c.GetHeader("X-Tenant-ID")
if tenantID == "" {
// Try to get tenant from subdomain
host := c.Request.Host
parts := strings.Split(host, ".")
if len(parts) > 2 {
tenantID = parts[0]
}
}
if tenantID == "" {
return nil, errors.New("tenant ID not found in request")
}
// Get tenant context
tenantCtx, err := tm.tenantService.GetTenantContext(c.Request.Context(), tenantID)
if err != nil {
return nil, err
}
return tenantCtx, nil
}
// ExtractTenantFromAPIKey extracts tenant information from API key
func (tm *TenantMiddleware) ExtractTenantFromAPIKey(c *gin.Context) (*TenantContext, error) {
apiKey := c.GetHeader("X-API-Key")
if apiKey == "" {
// Try Authorization header with Bearer token
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
apiKey = strings.TrimPrefix(authHeader, "Bearer ")
}
}
if apiKey == "" {
return nil, errors.New("API key not found in request")
}
// Validate API key
apiKeyData, err := tm.tenantService.ValidateAPIKey(c.Request.Context(), apiKey)
if err != nil {
return nil, err
}
// Get tenant context
tenantCtx, err := tm.tenantService.GetTenantContext(c.Request.Context(), apiKeyData.TenantID)
if err != nil {
return nil, err
}
return tenantCtx, nil
}
// RequireTenant middleware ensures a valid tenant is present
func (tm *TenantMiddleware) RequireTenant() gin.HandlerFunc {
return func(c *gin.Context) {
tenantCtx, err := tm.ExtractTenantFromHeader(c)
if err != nil {
// Try API key extraction
tenantCtx, err = tm.ExtractTenantFromAPIKey(c)
if err != nil {
tm.logger.WithError(err).Error("Failed to extract tenant context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid tenant or API key"})
c.Abort()
return
}
}
// Validate tenant access
_, err = tm.tenantService.ValidateTenantAccess(c.Request.Context(), tenantCtx.TenantID, "")
if err != nil {
tm.logger.WithError(err).Error("Tenant access validation failed")
c.JSON(http.StatusForbidden, gin.H{"error": "Tenant access denied"})
c.Abort()
return
}
// Inject tenant context
ctx := context.WithValue(c.Request.Context(), TenantCtxKey, tenantCtx)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
// RequireTenantRole middleware ensures user has required role
func (tm *TenantMiddleware) RequireTenantRole(requiredRoles ...TenantRole) gin.HandlerFunc {
return func(c *gin.Context) {
tenantCtx, err := tm.GetTenantContext(c)
if err != nil {
tm.logger.WithError(err).Error("Failed to get tenant context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid tenant context"})
c.Abort()
return
}
// Check if user has required role
hasRole := false
for _, requiredRole := range requiredRoles {
if tenantCtx.UserRole == requiredRole {
hasRole = true
break
}
}
if !hasRole {
tm.logger.WithFields(logrus.Fields{
"tenant_id": tenantCtx.TenantID,
"user_role": tenantCtx.UserRole,
"required_roles": requiredRoles,
}).Error("Insufficient tenant role")
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
c.Abort()
return
}
c.Next()
}
}
// RequirePermission middleware ensures user has specific permission
func (tm *TenantMiddleware) RequirePermission(permission string) gin.HandlerFunc {
return func(c *gin.Context) {
tenantCtx, err := tm.GetTenantContext(c)
if err != nil {
tm.logger.WithError(err).Error("Failed to get tenant context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid tenant context"})
c.Abort()
return
}
userID := tm.GetUserID(c)
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User ID not found"})
c.Abort()
return
}
// Check permission
hasPermission, err := tm.tenantService.HasPermission(c.Request.Context(), tenantCtx.TenantID, userID, permission)
if err != nil {
tm.logger.WithError(err).Error("Permission check failed")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Permission check failed"})
c.Abort()
return
}
if !hasPermission {
tm.logger.WithFields(logrus.Fields{
"tenant_id": tenantCtx.TenantID,
"user_id": userID,
"permission": permission,
}).Error("Permission denied")
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied"})
c.Abort()
return
}
c.Next()
}
}
// RateLimit middleware applies rate limiting per tenant
func (tm *TenantMiddleware) RateLimit(endpoint string) gin.HandlerFunc {
return func(c *gin.Context) {
// TODO: Implement rate limiting per tenant
c.Next()
}
}
// GetTenantContext retrieves tenant context from gin context
func (tm *TenantMiddleware) GetTenantContext(c *gin.Context) (*TenantContext, error) {
tenantCtx, exists := c.Get(string(TenantCtxKey))
if !exists {
return nil, errors.New("tenant context not found")
}
ctx, ok := tenantCtx.(*TenantContext)
if !ok {
return nil, errors.New("invalid tenant context type")
}
return ctx, nil
}
// GetUserID retrieves user ID from gin context
func (tm *TenantMiddleware) GetUserID(c *gin.Context) string {
userID, exists := c.Get(string(UserIDKey))
if !exists {
return ""
}
id, ok := userID.(string)
if !ok {
return ""
}
return id
}
// InjectTenantContext injects tenant context into gin context
func (tm *TenantMiddleware) InjectTenantContext(c *gin.Context, tenantCtx *TenantContext) {
c.Set(string(TenantCtxKey), tenantCtx)
}
// TenantIsolation middleware ensures data isolation between tenants
func (tm *TenantMiddleware) TenantIsolation() gin.HandlerFunc {
return func(c *gin.Context) {
tenantCtx, err := tm.GetTenantContext(c)
if err != nil {
tm.logger.WithError(err).Error("Failed to get tenant context for isolation")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid tenant context"})
c.Abort()
return
}
// Inject tenant ID into request context for repository layer
ctx := context.WithValue(c.Request.Context(), "tenant_id", tenantCtx.TenantID)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
// ValidateResourceAccess validates that tenant can access specific resource
func (tm *TenantMiddleware) ValidateResourceAccess(resource string) gin.HandlerFunc {
return func(c *gin.Context) {
// Get resource ID from URL parameters
resourceID := c.Param("id")
if resourceID == "" {
resourceID = c.Param("resource_id")
}
// Validate resource access - TODO: Implement resource access validation
// For now, allow all resource access within tenant
c.Next()
}
}
// LogTenantActivity logs tenant activity for audit purposes
func (tm *TenantMiddleware) LogTenantActivity(activity string) gin.HandlerFunc {
return func(c *gin.Context) {
tenantCtx, err := tm.GetTenantContext(c)
if err != nil {
// If we can't get tenant context, skip logging
c.Next()
return
}
userID := tm.GetUserID(c)
// Log tenant event
event := &TenantEvent{
ID: generateEventID(),
TenantID: tenantCtx.TenantID,
UserID: userID,
EventType: TenantEventType(activity),
EventData: map[string]interface{}{
"method": c.Request.Method,
"path": c.Request.URL.Path,
"user_agent": c.Request.UserAgent(),
"ip_address": c.ClientIP(),
},
Timestamp: getCurrentTimestamp(),
}
if err := tm.tenantService.LogTenantEvent(c.Request.Context(), event); err != nil {
tm.logger.WithError(err).Error("Failed to log tenant event")
}
c.Next()
}
}
// Helper functions
func generateEventID() string {
// Generate unique event ID (implementation depends on your ID generation strategy)
return "evt_" + generateRandomString(16)
}
func generateRandomString(length int) string {
// Generate random string (implementation depends on your random string generation)
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
b[i] = charset[i%len(charset)]
}
return string(b)
}
func getCurrentTimestamp() time.Time {
return time.Now()
}