diff --git a/_examples/auth/main.go b/_examples/auth/main.go index b9ff5ea..34dee9f 100644 --- a/_examples/auth/main.go +++ b/_examples/auth/main.go @@ -329,29 +329,14 @@ func main() { Tags: []string{"user", "status"}, }) - // ====== ROUTES AVEC CONTRÔLE DE RÔLES ====== + // ====== ROUTES AVEC CONTRÔLE DE RÔLES (déclaratif via RequiredRoles) ====== // Route pour les utilisateurs (rôle minimum) + // RequiredRoles: vérifié automatiquement avant le handler + // RequiredPermissions: documenté dans la spec OpenAPI fiberoapi.Get(oapi, "/documents/:documentId", func(c *fiber.Ctx, input DocumentRequest) (DocumentResponse, *fiberoapi.ErrorResponse) { authCtx, _ := fiberoapi.GetAuthContext(c) - - // Vérification manuelle des rôles et scopes - if !authService.HasRole(authCtx, "user") { - return DocumentResponse{}, &fiberoapi.ErrorResponse{ - Code: 403, - Details: "Access denied: requires 'user' role", - Type: "authorization_error", - } - } - if !authService.HasScope(authCtx, "read") { - return DocumentResponse{}, &fiberoapi.ErrorResponse{ - Code: 403, - Details: "Access denied: requires 'read' scope", - Type: "authorization_error", - } - } - fmt.Printf("📖 User %s (roles: %v) accessing document %s\n", authCtx.UserID, authCtx.Roles, input.DocumentID) return DocumentResponse{ @@ -362,32 +347,17 @@ func main() { }, nil }, fiberoapi.OpenAPIOptions{ - Summary: "Get document", - Description: "Récupère un document. Nécessite le rôle 'user' et scope 'read'", - Tags: []string{"documents"}, + Summary: "Get document", + Description: "Récupère un document. Nécessite le rôle 'user'", + Tags: []string{"documents"}, + RequiredRoles: []string{"user"}, + RequiredPermissions: []string{"document:read"}, }) // Route pour les éditeurs (peuvent modifier) fiberoapi.Put(oapi, "/documents/:documentId", func(c *fiber.Ctx, input UpdateDocumentRequest) (DocumentResponse, *fiberoapi.ErrorResponse) { authCtx, _ := fiberoapi.GetAuthContext(c) - - // Vérification manuelle des rôles et scopes - if !authService.HasRole(authCtx, "user") { - return DocumentResponse{}, &fiberoapi.ErrorResponse{ - Code: 403, - Details: "Access denied: requires 'user' role", - Type: "authorization_error", - } - } - if !authService.HasScope(authCtx, "write") { - return DocumentResponse{}, &fiberoapi.ErrorResponse{ - Code: 403, - Details: "Access denied: requires 'write' scope", - Type: "authorization_error", - } - } - fmt.Printf("✏️ User %s (scopes: %v) updating document %s\n", authCtx.UserID, authCtx.Scopes, input.DocumentID) return DocumentResponse{ @@ -398,25 +368,17 @@ func main() { }, nil }, fiberoapi.OpenAPIOptions{ - Summary: "Update document", - Description: "Met à jour un document. Nécessite le rôle 'user' et scope 'write'", - Tags: []string{"documents"}, + Summary: "Update document", + Description: "Met à jour un document. Nécessite le rôle 'editor'", + Tags: []string{"documents"}, + RequiredRoles: []string{"editor"}, + RequiredPermissions: []string{"document:write"}, }) - // Route pour partager (éditeurs et admins) + // Route pour partager (éditeurs seulement) fiberoapi.Post(oapi, "/documents/:documentId/share", func(c *fiber.Ctx, input DocumentRequest) (DocumentShareResponse, *fiberoapi.ErrorResponse) { authCtx, _ := fiberoapi.GetAuthContext(c) - - // Vérification du scope share - if !authService.HasScope(authCtx, "share") { - return DocumentShareResponse{}, &fiberoapi.ErrorResponse{ - Code: 403, - Details: "Access denied: requires 'share' scope", - Type: "authorization_error", - } - } - fmt.Printf("🔗 User %s sharing document %s\n", authCtx.UserID, input.DocumentID) return DocumentShareResponse{ @@ -424,32 +386,17 @@ func main() { }, nil }, fiberoapi.OpenAPIOptions{ - Summary: "Share document", - Description: "Partage un document. Nécessite le scope 'share'", - Tags: []string{"documents", "sharing"}, + Summary: "Share document", + Description: "Partage un document. Nécessite le rôle 'editor'", + Tags: []string{"documents", "sharing"}, + RequiredRoles: []string{"editor"}, + RequiredPermissions: []string{"document:share"}, }) // Route réservée aux administrateurs fiberoapi.Delete(oapi, "/documents/:documentId", func(c *fiber.Ctx, input DocumentRequest) (DocumentDeleteResponse, *fiberoapi.ErrorResponse) { authCtx, _ := fiberoapi.GetAuthContext(c) - - // Vérification du rôle admin et scope delete - if !authService.HasRole(authCtx, "admin") { - return DocumentDeleteResponse{}, &fiberoapi.ErrorResponse{ - Code: 403, - Details: "Access denied: requires 'admin' role", - Type: "authorization_error", - } - } - if !authService.HasScope(authCtx, "delete") { - return DocumentDeleteResponse{}, &fiberoapi.ErrorResponse{ - Code: 403, - Details: "Access denied: requires 'delete' scope", - Type: "authorization_error", - } - } - fmt.Printf("🗑️ Admin %s deleting document %s\n", authCtx.UserID, input.DocumentID) return DocumentDeleteResponse{ @@ -457,32 +404,17 @@ func main() { }, nil }, fiberoapi.OpenAPIOptions{ - Summary: "Delete document", - Description: "Supprime un document. Réservé aux administrateurs", - Tags: []string{"documents", "admin"}, + Summary: "Delete document", + Description: "Supprime un document. Réservé aux administrateurs", + Tags: []string{"documents", "admin"}, + RequiredRoles: []string{"admin"}, + RequiredPermissions: []string{"document:delete"}, }) // Route de création d'utilisateur (admin seulement) fiberoapi.Post(oapi, "/users", func(c *fiber.Ctx, input CreateUserRequest) (CreateUserResponse, *fiberoapi.ErrorResponse) { authCtx, _ := fiberoapi.GetAuthContext(c) - - // Vérification du rôle admin et scope write - if !authService.HasRole(authCtx, "admin") { - return CreateUserResponse{}, &fiberoapi.ErrorResponse{ - Code: 403, - Details: "Access denied: requires 'admin' role", - Type: "authorization_error", - } - } - if !authService.HasScope(authCtx, "write") { - return CreateUserResponse{}, &fiberoapi.ErrorResponse{ - Code: 403, - Details: "Access denied: requires 'write' scope", - Type: "authorization_error", - } - } - fmt.Printf("👤 Admin %s creating user: %s\n", authCtx.UserID, input.Name) return CreateUserResponse{ @@ -492,9 +424,11 @@ func main() { }, nil }, fiberoapi.OpenAPIOptions{ - Summary: "Create user", - Description: "Crée un nouvel utilisateur. Réservé aux administrateurs", - Tags: []string{"users", "admin"}, + Summary: "Create user", + Description: "Crée un nouvel utilisateur. Réservé aux administrateurs", + Tags: []string{"users", "admin"}, + RequiredRoles: []string{"admin"}, + RequiredPermissions: []string{"user:create"}, }) fmt.Println("🚀 Serveur avec authentification et rôles démarré sur port 3002") diff --git a/auth.go b/auth.go index 3e6717b..f443744 100644 --- a/auth.go +++ b/auth.go @@ -142,8 +142,11 @@ func RoleGuard(validator AuthorizationService, requiredRoles ...string) fiber.Ha // validateAuthorization validates permissions based on configured security schemes. // When SecuritySchemes is empty, it falls back to Bearer-only validation for backward compatibility. -func validateAuthorization(c *fiber.Ctx, input interface{}, authService AuthorizationService, config *Config) error { +func validateAuthorization(c *fiber.Ctx, input interface{}, authService AuthorizationService, config *Config, requiredRoles []string) error { if authService == nil { + if len(requiredRoles) > 0 { + return &AuthError{StatusCode: 500, Message: "authorization service not configured"} + } return nil } @@ -165,6 +168,11 @@ func validateAuthorization(c *fiber.Ctx, input interface{}, authService Authoriz return &AuthError{StatusCode: 401, Message: err.Error()} } c.Locals("auth", authCtx) + + // Check roles before resource access + if err := checkRequiredRoles(authCtx, authService, requiredRoles); err != nil { + return err + } return validateResourceAccess(c, authCtx, input, authService) } @@ -182,6 +190,11 @@ func validateAuthorization(c *fiber.Ctx, input interface{}, authService Authoriz authCtx, err := validateSecurityRequirement(c, requirement, config.SecuritySchemes, authService) if err == nil { c.Locals("auth", authCtx) + + // Check roles before resource access + if err := checkRequiredRoles(authCtx, authService, requiredRoles); err != nil { + return err + } return validateResourceAccess(c, authCtx, input, authService) } var authErr *AuthError @@ -203,6 +216,17 @@ func validateAuthorization(c *fiber.Ctx, input interface{}, authService Authoriz return &AuthError{StatusCode: 401, Message: lastErr.Error()} } +// checkRequiredRoles checks that the authenticated user has all required roles. +// Called inside validateAuthorization after auth context is established, before resource access checks. +func checkRequiredRoles(authCtx *AuthContext, authService AuthorizationService, requiredRoles []string) error { + for _, role := range requiredRoles { + if !authService.HasRole(authCtx, role) { + return &AuthError{StatusCode: 403, Message: fmt.Sprintf("required role missing: %s", role)} + } + } + return nil +} + // validateResourceAccess validates resource access based on tags func validateResourceAccess(c *fiber.Ctx, authCtx *AuthContext, input interface{}, authService AuthorizationService) error { inputValue := reflect.ValueOf(input) diff --git a/auth_helpers.go b/auth_helpers.go index eff2f01..34944b0 100644 --- a/auth_helpers.go +++ b/auth_helpers.go @@ -12,6 +12,12 @@ func WithSecurityDisabled(options OpenAPIOptions) OpenAPIOptions { return options } +// WithRoles adds required roles to a route (checked automatically during auth) +func WithRoles(options OpenAPIOptions, roles ...string) OpenAPIOptions { + options.RequiredRoles = append(options.RequiredRoles, roles...) + return options +} + // WithPermissions adds required permissions for documentation func WithPermissions(options OpenAPIOptions, permissions ...string) OpenAPIOptions { options.RequiredPermissions = append(options.RequiredPermissions, permissions...) diff --git a/auth_test.go b/auth_test.go index ee30043..e72020a 100644 --- a/auth_test.go +++ b/auth_test.go @@ -564,3 +564,147 @@ func TestAuthServiceFailure(t *testing.T) { } }) } + +// TestRequiredRoles tests the declarative role checking via OpenAPIOptions.RequiredRoles +func TestRequiredRoles(t *testing.T) { + mockAuth := NewMockAuthService() + + app := fiber.New() + oapi := New(app, Config{ + EnableOpenAPIDocs: true, + EnableValidation: true, + EnableAuthorization: true, + AuthService: mockAuth, + SecuritySchemes: map[string]SecurityScheme{ + "bearerAuth": {Type: "http", Scheme: "bearer", BearerFormat: "JWT"}, + }, + DefaultSecurity: []map[string][]string{ + {"bearerAuth": {}}, + }, + }) + + // Route requiring "admin" role + Get(oapi, "/admin/users", func(c *fiber.Ctx, input struct{}) (fiber.Map, *ErrorResponse) { + return fiber.Map{"ok": true}, nil + }, WithRoles(OpenAPIOptions{Summary: "Admin only"}, "admin")) + + // Route requiring "user" role (both tokens have this) + Get(oapi, "/user/profile", func(c *fiber.Ctx, input struct{}) (fiber.Map, *ErrorResponse) { + return fiber.Map{"ok": true}, nil + }, WithRoles(OpenAPIOptions{Summary: "User profile"}, "user")) + + // Route requiring multiple roles (AND semantics): "admin" AND "user" + Get(oapi, "/admin/settings", func(c *fiber.Ctx, input struct{}) (fiber.Map, *ErrorResponse) { + return fiber.Map{"ok": true}, nil + }, WithRoles(OpenAPIOptions{Summary: "Admin settings"}, "admin", "user")) + + // Route with no required roles + Get(oapi, "/public/info", func(c *fiber.Ctx, input struct{}) (fiber.Map, *ErrorResponse) { + return fiber.Map{"ok": true}, nil + }, OpenAPIOptions{Summary: "Public info"}) + + t.Run("admin token accesses admin route", func(t *testing.T) { + req := httptest.NewRequest("GET", "/admin/users", nil) + req.Header.Set("Authorization", "Bearer admin-token") + resp, _ := app.Test(req) + if resp.StatusCode != 200 { + t.Errorf("Expected 200, got %d", resp.StatusCode) + } + }) + + t.Run("user token rejected from admin route", func(t *testing.T) { + req := httptest.NewRequest("GET", "/admin/users", nil) + req.Header.Set("Authorization", "Bearer valid-token") + resp, _ := app.Test(req) + if resp.StatusCode != 403 { + t.Errorf("Expected 403, got %d", resp.StatusCode) + } + }) + + t.Run("user token accesses user route", func(t *testing.T) { + req := httptest.NewRequest("GET", "/user/profile", nil) + req.Header.Set("Authorization", "Bearer valid-token") + resp, _ := app.Test(req) + if resp.StatusCode != 200 { + t.Errorf("Expected 200, got %d", resp.StatusCode) + } + }) + + t.Run("admin token accesses user route", func(t *testing.T) { + req := httptest.NewRequest("GET", "/user/profile", nil) + req.Header.Set("Authorization", "Bearer admin-token") + resp, _ := app.Test(req) + if resp.StatusCode != 200 { + t.Errorf("Expected 200, got %d", resp.StatusCode) + } + }) + + t.Run("no token rejected from role-protected route", func(t *testing.T) { + req := httptest.NewRequest("GET", "/admin/users", nil) + resp, _ := app.Test(req) + if resp.StatusCode != 401 { + t.Errorf("Expected 401, got %d", resp.StatusCode) + } + }) + + t.Run("no roles required still needs auth", func(t *testing.T) { + req := httptest.NewRequest("GET", "/public/info", nil) + req.Header.Set("Authorization", "Bearer valid-token") + resp, _ := app.Test(req) + if resp.StatusCode != 200 { + t.Errorf("Expected 200, got %d", resp.StatusCode) + } + }) + + t.Run("x-required-roles in OpenAPI spec", func(t *testing.T) { + spec := oapi.GenerateOpenAPISpec() + paths := spec["paths"].(map[string]interface{}) + adminPath := paths["/admin/users"].(map[string]interface{}) + getOp := adminPath["get"].(map[string]interface{}) + roles, ok := getOp["x-required-roles"].([]string) + if !ok { + t.Fatal("Expected x-required-roles in spec") + } + if len(roles) != 1 || roles[0] != "admin" { + t.Errorf("Expected [admin], got %v", roles) + } + }) + + // Multi-role AND semantics tests + // admin-token has roles ["admin", "user"] -> should pass + t.Run("multi-role: token with all roles accepted", func(t *testing.T) { + req := httptest.NewRequest("GET", "/admin/settings", nil) + req.Header.Set("Authorization", "Bearer admin-token") + resp, _ := app.Test(req) + if resp.StatusCode != 200 { + t.Errorf("Expected 200, got %d", resp.StatusCode) + } + }) + + // valid-token has roles ["user"] -> missing "admin", should be rejected + t.Run("multi-role: token missing one role rejected", func(t *testing.T) { + req := httptest.NewRequest("GET", "/admin/settings", nil) + req.Header.Set("Authorization", "Bearer valid-token") + resp, _ := app.Test(req) + if resp.StatusCode != 403 { + t.Errorf("Expected 403, got %d", resp.StatusCode) + } + }) +} + +// TestWithRolesHelper tests the WithRoles helper function +func TestWithRolesHelper(t *testing.T) { + opts := WithRoles(OpenAPIOptions{Summary: "test"}, "admin", "editor") + if len(opts.RequiredRoles) != 2 { + t.Fatalf("Expected 2 roles, got %d", len(opts.RequiredRoles)) + } + if opts.RequiredRoles[0] != "admin" || opts.RequiredRoles[1] != "editor" { + t.Errorf("Expected [admin, editor], got %v", opts.RequiredRoles) + } + + // Chaining + opts = WithRoles(opts, "superadmin") + if len(opts.RequiredRoles) != 3 { + t.Fatalf("Expected 3 roles after chaining, got %d", len(opts.RequiredRoles)) + } +} diff --git a/common.go b/common.go index 1f99bd7..cf7c423 100644 --- a/common.go +++ b/common.go @@ -123,7 +123,7 @@ func parseInput[TInput any](app *OApiApp, c *fiber.Ctx, path string, options *Op if routeSecurity, ok := options.Security.([]map[string][]string); ok && len(routeSecurity) > 0 { cfg.DefaultSecurity = routeSecurity } - err = validateAuthorization(c, input, cfg.AuthService, &cfg) + err = validateAuthorization(c, input, cfg.AuthService, &cfg, options.RequiredRoles) if err != nil { return input, err } diff --git a/fiberoapi.go b/fiberoapi.go index a2fd49d..df24a37 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -251,6 +251,11 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { } } + // Add required roles as OpenAPI extension + if len(op.Options.RequiredRoles) > 0 { + enhancedOptions["x-required-roles"] = op.Options.RequiredRoles + } + // Add descriptions for required permissions if len(op.Options.RequiredPermissions) > 0 { desc := "" diff --git a/types.go b/types.go index c4ac3fe..1cc6826 100644 --- a/types.go +++ b/types.go @@ -73,14 +73,15 @@ type Config struct { // OpenAPIOptions represents options for OpenAPI operations type OpenAPIOptions struct { - OperationID string `json:"operationId,omitempty"` - Tags []string `json:"tags,omitempty"` - Summary string `json:"summary,omitempty"` - Description string `json:"description,omitempty"` - Parameters []map[string]interface{} `json:"parameters,omitempty"` - Security interface{} `json:"security,omitempty"` // Can be []map[string][]string or "disabled" - RequiredPermissions []string `json:"-"` // Ex: ["document:read", "workspace:admin"] - ResourceType string `json:"-"` // Type de ressource concernée + OperationID string `json:"operationId,omitempty"` + Tags []string `json:"tags,omitempty"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + Parameters []map[string]any `json:"parameters,omitempty"` + Security any `json:"security,omitempty"` // Can be []map[string][]string or "disabled" + RequiredRoles []string `json:"-"` // Roles required to access this route (checked automatically) + RequiredPermissions []string `json:"-"` // Ex: ["document:read", "workspace:admin"] + ResourceType string `json:"-"` // Type de ressource concernée } // OpenAPIOperation represents a registered operation @@ -94,22 +95,22 @@ type OpenAPIOperation struct { } type OpenAPIParameter struct { - Name string `json:"name"` - In string `json:"in"` // "path", "query", "header", "cookie" - Required bool `json:"required,omitempty"` - Description string `json:"description,omitempty"` - Schema map[string]interface{} `json:"schema"` + Name string `json:"name"` + In string `json:"in"` // "path", "query", "header", "cookie" + Required bool `json:"required,omitempty"` + Description string `json:"description,omitempty"` + Schema map[string]any `json:"schema"` } type OpenAPIResponse struct { - Description string `json:"description"` - Content map[string]interface{} `json:"content,omitempty"` + Description string `json:"description"` + Content map[string]any `json:"content,omitempty"` } type OpenAPIRequestBody struct { - Description string `json:"description,omitempty"` - Required bool `json:"required,omitempty"` - Content map[string]interface{} `json:"content"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` + Content map[string]any `json:"content"` } type ErrorResponse struct {