Skip to content

Commit bb7c4e3

Browse files
authored
Refactor role-based access control (#42)
* Refactor role and permission checks in document routes to use declarative RequiredRoles and RequiredPermissions * Add WithRoles function to append required roles for route authorization * Add tests for required roles and WithRoles helper function * Add validateRequiredRoles function to check user roles for authorization * Add OpenAPI extension for required roles in generated spec * Add required roles validation in parseInput function * Add RequiredRoles field to OpenAPIOptions for route access control * Refactor OpenAPIOptions and related types to use 'any' for parameters and schema * Add multi-role support in TestRequiredRoles for AND semantics * Add required roles check in validateAuthorization function * Refactor parseInput to include required roles validation in validateAuthorization call * Update share route comment to specify access for editors only
1 parent 68c8c5a commit bb7c4e3

File tree

7 files changed

+229
-115
lines changed

7 files changed

+229
-115
lines changed

_examples/auth/main.go

Lines changed: 29 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -329,29 +329,14 @@ func main() {
329329
Tags: []string{"user", "status"},
330330
})
331331

332-
// ====== ROUTES AVEC CONTRÔLE DE RÔLES ======
332+
// ====== ROUTES AVEC CONTRÔLE DE RÔLES (déclaratif via RequiredRoles) ======
333333

334334
// Route pour les utilisateurs (rôle minimum)
335+
// RequiredRoles: vérifié automatiquement avant le handler
336+
// RequiredPermissions: documenté dans la spec OpenAPI
335337
fiberoapi.Get(oapi, "/documents/:documentId",
336338
func(c *fiber.Ctx, input DocumentRequest) (DocumentResponse, *fiberoapi.ErrorResponse) {
337339
authCtx, _ := fiberoapi.GetAuthContext(c)
338-
339-
// Vérification manuelle des rôles et scopes
340-
if !authService.HasRole(authCtx, "user") {
341-
return DocumentResponse{}, &fiberoapi.ErrorResponse{
342-
Code: 403,
343-
Details: "Access denied: requires 'user' role",
344-
Type: "authorization_error",
345-
}
346-
}
347-
if !authService.HasScope(authCtx, "read") {
348-
return DocumentResponse{}, &fiberoapi.ErrorResponse{
349-
Code: 403,
350-
Details: "Access denied: requires 'read' scope",
351-
Type: "authorization_error",
352-
}
353-
}
354-
355340
fmt.Printf("📖 User %s (roles: %v) accessing document %s\n", authCtx.UserID, authCtx.Roles, input.DocumentID)
356341

357342
return DocumentResponse{
@@ -362,32 +347,17 @@ func main() {
362347
}, nil
363348
},
364349
fiberoapi.OpenAPIOptions{
365-
Summary: "Get document",
366-
Description: "Récupère un document. Nécessite le rôle 'user' et scope 'read'",
367-
Tags: []string{"documents"},
350+
Summary: "Get document",
351+
Description: "Récupère un document. Nécessite le rôle 'user'",
352+
Tags: []string{"documents"},
353+
RequiredRoles: []string{"user"},
354+
RequiredPermissions: []string{"document:read"},
368355
})
369356

370357
// Route pour les éditeurs (peuvent modifier)
371358
fiberoapi.Put(oapi, "/documents/:documentId",
372359
func(c *fiber.Ctx, input UpdateDocumentRequest) (DocumentResponse, *fiberoapi.ErrorResponse) {
373360
authCtx, _ := fiberoapi.GetAuthContext(c)
374-
375-
// Vérification manuelle des rôles et scopes
376-
if !authService.HasRole(authCtx, "user") {
377-
return DocumentResponse{}, &fiberoapi.ErrorResponse{
378-
Code: 403,
379-
Details: "Access denied: requires 'user' role",
380-
Type: "authorization_error",
381-
}
382-
}
383-
if !authService.HasScope(authCtx, "write") {
384-
return DocumentResponse{}, &fiberoapi.ErrorResponse{
385-
Code: 403,
386-
Details: "Access denied: requires 'write' scope",
387-
Type: "authorization_error",
388-
}
389-
}
390-
391361
fmt.Printf("✏️ User %s (scopes: %v) updating document %s\n", authCtx.UserID, authCtx.Scopes, input.DocumentID)
392362

393363
return DocumentResponse{
@@ -398,91 +368,53 @@ func main() {
398368
}, nil
399369
},
400370
fiberoapi.OpenAPIOptions{
401-
Summary: "Update document",
402-
Description: "Met à jour un document. Nécessite le rôle 'user' et scope 'write'",
403-
Tags: []string{"documents"},
371+
Summary: "Update document",
372+
Description: "Met à jour un document. Nécessite le rôle 'editor'",
373+
Tags: []string{"documents"},
374+
RequiredRoles: []string{"editor"},
375+
RequiredPermissions: []string{"document:write"},
404376
})
405377

406-
// Route pour partager (éditeurs et admins)
378+
// Route pour partager (éditeurs seulement)
407379
fiberoapi.Post(oapi, "/documents/:documentId/share",
408380
func(c *fiber.Ctx, input DocumentRequest) (DocumentShareResponse, *fiberoapi.ErrorResponse) {
409381
authCtx, _ := fiberoapi.GetAuthContext(c)
410-
411-
// Vérification du scope share
412-
if !authService.HasScope(authCtx, "share") {
413-
return DocumentShareResponse{}, &fiberoapi.ErrorResponse{
414-
Code: 403,
415-
Details: "Access denied: requires 'share' scope",
416-
Type: "authorization_error",
417-
}
418-
}
419-
420382
fmt.Printf("🔗 User %s sharing document %s\n", authCtx.UserID, input.DocumentID)
421383

422384
return DocumentShareResponse{
423385
ShareLink: fmt.Sprintf("https://example.com/shared/%s", input.DocumentID),
424386
}, nil
425387
},
426388
fiberoapi.OpenAPIOptions{
427-
Summary: "Share document",
428-
Description: "Partage un document. Nécessite le scope 'share'",
429-
Tags: []string{"documents", "sharing"},
389+
Summary: "Share document",
390+
Description: "Partage un document. Nécessite le rôle 'editor'",
391+
Tags: []string{"documents", "sharing"},
392+
RequiredRoles: []string{"editor"},
393+
RequiredPermissions: []string{"document:share"},
430394
})
431395

432396
// Route réservée aux administrateurs
433397
fiberoapi.Delete(oapi, "/documents/:documentId",
434398
func(c *fiber.Ctx, input DocumentRequest) (DocumentDeleteResponse, *fiberoapi.ErrorResponse) {
435399
authCtx, _ := fiberoapi.GetAuthContext(c)
436-
437-
// Vérification du rôle admin et scope delete
438-
if !authService.HasRole(authCtx, "admin") {
439-
return DocumentDeleteResponse{}, &fiberoapi.ErrorResponse{
440-
Code: 403,
441-
Details: "Access denied: requires 'admin' role",
442-
Type: "authorization_error",
443-
}
444-
}
445-
if !authService.HasScope(authCtx, "delete") {
446-
return DocumentDeleteResponse{}, &fiberoapi.ErrorResponse{
447-
Code: 403,
448-
Details: "Access denied: requires 'delete' scope",
449-
Type: "authorization_error",
450-
}
451-
}
452-
453400
fmt.Printf("🗑️ Admin %s deleting document %s\n", authCtx.UserID, input.DocumentID)
454401

455402
return DocumentDeleteResponse{
456403
Success: true,
457404
}, nil
458405
},
459406
fiberoapi.OpenAPIOptions{
460-
Summary: "Delete document",
461-
Description: "Supprime un document. Réservé aux administrateurs",
462-
Tags: []string{"documents", "admin"},
407+
Summary: "Delete document",
408+
Description: "Supprime un document. Réservé aux administrateurs",
409+
Tags: []string{"documents", "admin"},
410+
RequiredRoles: []string{"admin"},
411+
RequiredPermissions: []string{"document:delete"},
463412
})
464413

465414
// Route de création d'utilisateur (admin seulement)
466415
fiberoapi.Post(oapi, "/users",
467416
func(c *fiber.Ctx, input CreateUserRequest) (CreateUserResponse, *fiberoapi.ErrorResponse) {
468417
authCtx, _ := fiberoapi.GetAuthContext(c)
469-
470-
// Vérification du rôle admin et scope write
471-
if !authService.HasRole(authCtx, "admin") {
472-
return CreateUserResponse{}, &fiberoapi.ErrorResponse{
473-
Code: 403,
474-
Details: "Access denied: requires 'admin' role",
475-
Type: "authorization_error",
476-
}
477-
}
478-
if !authService.HasScope(authCtx, "write") {
479-
return CreateUserResponse{}, &fiberoapi.ErrorResponse{
480-
Code: 403,
481-
Details: "Access denied: requires 'write' scope",
482-
Type: "authorization_error",
483-
}
484-
}
485-
486418
fmt.Printf("👤 Admin %s creating user: %s\n", authCtx.UserID, input.Name)
487419

488420
return CreateUserResponse{
@@ -492,9 +424,11 @@ func main() {
492424
}, nil
493425
},
494426
fiberoapi.OpenAPIOptions{
495-
Summary: "Create user",
496-
Description: "Crée un nouvel utilisateur. Réservé aux administrateurs",
497-
Tags: []string{"users", "admin"},
427+
Summary: "Create user",
428+
Description: "Crée un nouvel utilisateur. Réservé aux administrateurs",
429+
Tags: []string{"users", "admin"},
430+
RequiredRoles: []string{"admin"},
431+
RequiredPermissions: []string{"user:create"},
498432
})
499433

500434
fmt.Println("🚀 Serveur avec authentification et rôles démarré sur port 3002")

auth.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,11 @@ func RoleGuard(validator AuthorizationService, requiredRoles ...string) fiber.Ha
142142

143143
// validateAuthorization validates permissions based on configured security schemes.
144144
// When SecuritySchemes is empty, it falls back to Bearer-only validation for backward compatibility.
145-
func validateAuthorization(c *fiber.Ctx, input interface{}, authService AuthorizationService, config *Config) error {
145+
func validateAuthorization(c *fiber.Ctx, input interface{}, authService AuthorizationService, config *Config, requiredRoles []string) error {
146146
if authService == nil {
147+
if len(requiredRoles) > 0 {
148+
return &AuthError{StatusCode: 500, Message: "authorization service not configured"}
149+
}
147150
return nil
148151
}
149152

@@ -165,6 +168,11 @@ func validateAuthorization(c *fiber.Ctx, input interface{}, authService Authoriz
165168
return &AuthError{StatusCode: 401, Message: err.Error()}
166169
}
167170
c.Locals("auth", authCtx)
171+
172+
// Check roles before resource access
173+
if err := checkRequiredRoles(authCtx, authService, requiredRoles); err != nil {
174+
return err
175+
}
168176
return validateResourceAccess(c, authCtx, input, authService)
169177
}
170178

@@ -182,6 +190,11 @@ func validateAuthorization(c *fiber.Ctx, input interface{}, authService Authoriz
182190
authCtx, err := validateSecurityRequirement(c, requirement, config.SecuritySchemes, authService)
183191
if err == nil {
184192
c.Locals("auth", authCtx)
193+
194+
// Check roles before resource access
195+
if err := checkRequiredRoles(authCtx, authService, requiredRoles); err != nil {
196+
return err
197+
}
185198
return validateResourceAccess(c, authCtx, input, authService)
186199
}
187200
var authErr *AuthError
@@ -203,6 +216,17 @@ func validateAuthorization(c *fiber.Ctx, input interface{}, authService Authoriz
203216
return &AuthError{StatusCode: 401, Message: lastErr.Error()}
204217
}
205218

219+
// checkRequiredRoles checks that the authenticated user has all required roles.
220+
// Called inside validateAuthorization after auth context is established, before resource access checks.
221+
func checkRequiredRoles(authCtx *AuthContext, authService AuthorizationService, requiredRoles []string) error {
222+
for _, role := range requiredRoles {
223+
if !authService.HasRole(authCtx, role) {
224+
return &AuthError{StatusCode: 403, Message: fmt.Sprintf("required role missing: %s", role)}
225+
}
226+
}
227+
return nil
228+
}
229+
206230
// validateResourceAccess validates resource access based on tags
207231
func validateResourceAccess(c *fiber.Ctx, authCtx *AuthContext, input interface{}, authService AuthorizationService) error {
208232
inputValue := reflect.ValueOf(input)

auth_helpers.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ func WithSecurityDisabled(options OpenAPIOptions) OpenAPIOptions {
1212
return options
1313
}
1414

15+
// WithRoles adds required roles to a route (checked automatically during auth)
16+
func WithRoles(options OpenAPIOptions, roles ...string) OpenAPIOptions {
17+
options.RequiredRoles = append(options.RequiredRoles, roles...)
18+
return options
19+
}
20+
1521
// WithPermissions adds required permissions for documentation
1622
func WithPermissions(options OpenAPIOptions, permissions ...string) OpenAPIOptions {
1723
options.RequiredPermissions = append(options.RequiredPermissions, permissions...)

0 commit comments

Comments
 (0)