package middleware import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "log/slog" "github.com/gofiber/fiber/v2" ) // RBACConfig defines the configuration for RBAC middleware type RBACConfig struct { AllowedRoles []string AuthHandler AuthProfileProvider KetoService service.KetoService } // AuthProfileProvider는 미들웨어에서 사용자 정보를 조회하기 위한 최소 인터페이스입니다. type AuthProfileProvider interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) } // RequireKetoPermission enforces permissions using Ory Keto (ReBAC) func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.Handler { return func(c *fiber.Ctx) error { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_keto)") } // Store profile in locals for further use in handlers c.Locals("user_profile", profile) role := domain.NormalizeRole(profile.Role) // Super Admin bypass if role == domain.RoleSuperAdmin { return c.Next() } // Get object ID from path (e.g., tenant ID) // Fix: For Tenant namespace, prioritize tenantId param if available objectID := "" if namespace == "Tenant" { objectID = c.Params("tenantId") } if objectID == "" { objectID = c.Params("id") } if objectID == "" { slog.Error("RBAC Keto check failed: missing object id", "path", c.Path()) return errorJSON(c, fiber.StatusBadRequest, "missing object id for permission check") } slog.Info("Performing Keto permission check", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation) // Set tenant_id for audit logging if namespace is Tenant if namespace == "Tenant" { c.Locals("tenant_id", objectID) } // Check with Keto - add User: prefix to subject allowed, err := config.KetoService.CheckPermission(c.Context(), "User:"+profile.ID, namespace, objectID, relation) if err != nil { slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID) return errorJSON(c, fiber.StatusInternalServerError, "permission check error") } if !allowed { slog.Warn("Keto permission denied", "userID", profile.ID, "userRole", role, "namespace", namespace, "objectID", objectID, "relation", relation, "X-Test-Role", c.Get("X-Test-Role")) return errorJSON(c, fiber.StatusForbidden, "forbidden: keto permission denied for "+namespace+":"+objectID) } return c.Next() } } // RequireRole enforces that the user has one of the allowed roles func RequireRole(config RBACConfig) fiber.Handler { return func(c *fiber.Ctx) error { // Bypass if already authenticated via API Key if c.Locals("apiKeyName") != nil { return c.Next() } profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_role): "+err.Error()) } // Store profile in locals for further use in handlers c.Locals("user_profile", profile) userRole := domain.NormalizeRole(profile.Role) // Super Admin always has access if userRole == domain.RoleSuperAdmin { return c.Next() } // Check if user's role is in allowed roles roleAllowed := false for _, role := range config.AllowedRoles { if userRole == domain.NormalizeRole(role) { roleAllowed = true break } } if !roleAllowed { slog.Warn("RBAC access denied", "userID", profile.ID, "userRole", userRole, "allowedRoles", config.AllowedRoles, "path", c.Path(), "X-Test-Role", c.Get("X-Test-Role"), ) return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions") } return c.Next() } } // RequireTenantMatch enforces that a Tenant Admin can only access their own tenant's data func RequireTenantMatch(config RBACConfig) fiber.Handler { return func(c *fiber.Ctx) error { // Bypass if already authenticated via API Key (System-wide for now) if c.Locals("apiKeyName") != nil { return c.Next() } profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized (trace:rbac_match)") } // Store profile in locals for further use in handlers c.Locals("user_profile", profile) userRole := domain.NormalizeRole(profile.Role) // Super Admin bypass if userRole == domain.RoleSuperAdmin { return c.Next() } // Tenant Admin check if userRole == domain.RoleTenantAdmin { targetTenantID := c.Params("tenantId") if targetTenantID == "" { targetTenantID = c.Params("id") // common for /tenants/:id } if targetTenantID == "" { return c.Next() // No target specified, let Keto or next handler decide } // Check primary tenant match if profile.TenantID != nil && *profile.TenantID == targetTenantID { return c.Next() } // Check inherited manageable tenants isAllowed := false for _, t := range profile.ManageableTenants { if t.ID == targetTenantID { isAllowed = true break } } if !isAllowed { slog.Warn("Tenant match failed", "userID", profile.ID, "targetTenantID", targetTenantID) return errorJSON(c, fiber.StatusForbidden, "forbidden: you do not have access to this tenant") } return c.Next() } return errorJSON(c, fiber.StatusForbidden, "forbidden") } }