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 c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_keto)"}) } // Store profile in locals for further use in handlers c.Locals("user_profile", profile) // Super Admin bypass if profile.Role == domain.RoleSuperAdmin { return c.Next() } // Get object ID from path (e.g., tenant ID) objectID := c.Params("id") if objectID == "" { objectID = c.Params("tenantId") } if objectID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"}) } // Check with Keto allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation) if err != nil || !allowed { slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied"}) } 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 c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ "error": "unauthorized (trace:rbac_role): " + err.Error(), }) } // Store profile in locals for further use in handlers c.Locals("user_profile", profile) // Super Admin always has access if profile.Role == domain.RoleSuperAdmin { return c.Next() } // Check if user's role is in allowed roles roleAllowed := false for _, role := range config.AllowedRoles { if profile.Role == role { roleAllowed = true break } } if !roleAllowed { slog.Warn("RBAC access denied", "userID", profile.ID, "userRole", profile.Role, "allowedRoles", config.AllowedRoles, "path", c.Path(), ) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ "error": "forbidden: insufficient permissions", }) } // Store profile in locals for further use in handlers c.Locals("user_profile", profile) 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 c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_match)"}) } // Store profile in locals for further use in handlers c.Locals("user_profile", profile) // Super Admin bypass if profile.Role == domain.RoleSuperAdmin { return c.Next() } // Tenant Admin check if profile.Role == domain.RoleTenantAdmin { targetTenantID := c.Params("tenantId") if targetTenantID == "" { targetTenantID = c.Params("id") // common for /tenants/:id } if profile.TenantID == nil || *profile.TenantID != targetTenantID { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ "error": "forbidden: you do not have access to this tenant", }) } return c.Next() } return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) } }