diff --git a/docs/advanced-guide/rbac/page.md b/docs/advanced-guide/rbac/page.md new file mode 100644 index 000000000..21b27b99b --- /dev/null +++ b/docs/advanced-guide/rbac/page.md @@ -0,0 +1,565 @@ +# Role-Based Access Control (RBAC) in GoFr + +Role-Based Access Control (RBAC) is a security mechanism that restricts access to resources based on user roles and permissions. GoFr provides a pure config-based RBAC middleware that supports multiple authentication methods, fine-grained permissions, and role inheritance. + +## Overview + +- ✅ **Pure Config-Based** - All authorization rules in JSON/YAML files +- ✅ **Two-Level Authorization Model** - Roles define permissions, endpoints require permissions (no direct role-to-route mapping) +- ✅ **Multiple Auth Methods** - Header-based and JWT-based role extraction +- ✅ **Permission-Based** - Fine-grained permissions +- ✅ **Role Inheritance** - Roles inherit permissions from other roles + +## Quick Start + +```go +package main + +import ( + "gofr.dev/pkg/gofr" + "gofr.dev/pkg/gofr/rbac" +) + +func main() { + app := gofr.New() + + provider := rbac.NewProvider("configs/rbac.json") + app.EnableRBAC(provider) // Custom path + // Or use default paths (empty string): + // provider := rbac.NewProvider(rbac.DefaultRBACConfig) // Tries configs/rbac.json, configs/rbac.yaml, configs/rbac.yml + // app.EnableRBAC(provider) + + app.GET("/api/users", handler) + app.Run() +} +``` + +**Configuration** (`configs/rbac.json`): + +```json +{ + "roleHeader": "X-User-Role", + "roles": [ + { + "name": "admin", + "permissions": ["users:read", "users:write", "users:delete", "posts:read", "posts:write"] + }, + { + "name": "editor", + "permissions": ["users:write", "posts:write"], + "inheritsFrom": ["viewer"] + }, + { + "name": "viewer", + "permissions": ["users:read", "posts:read"] + } + ], + "endpoints": [ + { + "path": "/health", + "methods": ["GET"], + "public": true + }, + { + "path": "/api/users", + "methods": ["GET"], + "requiredPermissions": ["users:read"] + }, + { + "path": "/api/users", + "methods": ["POST"], + "requiredPermissions": ["users:write"] + } + ] +} +``` + +> **💡 Best Practice**: For production/public APIs, use JWT-based RBAC instead of header-based RBAC for better security. + +## Configuration + +### Role Extraction + +**Header-Based** (for internal/trusted networks): +```json +{ + "roleHeader": "X-User-Role" +} +``` + +**JWT-Based** (for production/public APIs): +```json +{ + "jwtClaimPath": "role" // or "roles[0]", "permissions.role", etc. +} +``` + +**Precedence**: If both are set, **only JWT is considered**. The header is not checked when `jwtClaimPath` is configured, even if JWT extraction fails. + +**JWT Claim Path Formats**: +- `"role"` → `{"role": "admin"}` +- `"roles[0]"` → `{"roles": ["admin", "user"]}` (first element) +- `"permissions.role"` → `{"permissions": {"role": "admin"}}` + +### Roles and Permissions + +```json +{ + "roles": [ + { + "name": "admin", + "permissions": ["users:read", "users:write", "users:delete", "posts:read", "posts:write"] // Explicit permissions (wildcards not supported) + }, + { + "name": "editor", + "permissions": ["users:write", "posts:write"], // Only additional permissions + "inheritsFrom": ["viewer"] // Inherits viewer's permissions + }, + { + "name": "viewer", + "permissions": ["users:read", "posts:read"] + } + ] +} +``` + +**Note**: When using `inheritsFrom`, only specify additional permissions - inherited ones are automatically included. + +### Endpoint Mapping + +```json +{ + "endpoints": [ + { + "path": "/health", + "methods": ["GET"], + "public": true // Bypasses authorization + }, + { + "path": "/api/users", + "methods": ["GET"], + "requiredPermissions": ["users:read"] + }, + { + "path": "^/api/users/\\d+$", // Regex pattern for strict validation (numeric IDs only) - automatically detected + "methods": ["DELETE"], + "requiredPermissions": ["users:delete"] + }, + { + "path": "/api/admin/*", // Wildcard pattern - matches all sub-paths + "methods": ["*"], // All methods + "requiredPermissions": ["admin:read", "admin:write"] // Multiple permissions (OR logic) + } + ] +} +``` + +**Route Patterns**: +- **Exact**: `"/api/users"` matches exactly `/api/users` +- **Wildcard**: `"/api/*"` matches `/api/users`, `/api/posts`, etc. +- **Regex**: `"^/api/users/\\d+$"` matches `/api/users/123`, etc. + +### Path Parameters + +For endpoints with path parameters (e.g., `/api/users/{id}`), the `{id}` syntax in the `path` field is **documentation only** and does not work for matching. You must use either a **wildcard** or **regex** pattern. + +**Use Wildcard (`/*`) for Simple Cases**: +```json +{ + "path": "/api/users/*", + "methods": ["DELETE"], + "requiredPermissions": ["users:delete"] +} +``` +- ✅ Matches: `/api/users/123`, `/api/users/abc`, `/api/users/anything` +- Simple and flexible, but accepts any value + +**Use Regex for Strict Validation**: +```json +{ + "path": "^/api/users/\\d+$", // Regex pattern - automatically detected when starts with ^ or contains regex special chars + "methods": ["DELETE"], + "requiredPermissions": ["users:delete"] +} +``` +- ✅ Matches: `/api/users/123`, `/api/users/456` +- ❌ Does not match: `/api/users/abc`, `/api/users/123abc` +- Provides strict validation (numeric IDs, UUIDs, etc.) +- Regex patterns are automatically detected and precompiled for better performance + +**Common Regex Patterns**: +- Numeric IDs: `"^/api/users/\\d+$"` (matches `/api/users/123`) +- UUIDs: `"^/api/users/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"` (matches `/api/users/550e8400-e29b-41d4-a716-446655440000`) +- Alphanumeric: `"^/api/users/[a-zA-Z0-9]+$"` (matches `/api/users/user123`) + +**Note**: Regex patterns are automatically detected when the `path` field starts with `^`, ends with `$`, or contains regex special characters like `\d`, `\w`, `[`, `(`, `?`. + +## JWT-Based RBAC + +For production/public APIs, use JWT-based role extraction: + +```go +app := gofr.New() + +// Enable OAuth middleware first (required for JWT validation) +app.EnableOAuth("https://auth.example.com/.well-known/jwks.json", 10) + +provider := rbac.NewProvider() +app.EnableRBAC(provider, "configs/rbac.json") +``` + +**Configuration** (`configs/rbac.json`): + +```json +{ + "jwtClaimPath": "role", // or "roles[0]", "permissions.role", etc. + "roles": [...], + "endpoints": [...] +} +``` + +## Accessing Role in Handlers + +For business logic, you can access the user's role from the request context: + +**JWT-Based RBAC** (when using JWT role extraction): + +```go +func handler(ctx *gofr.Context) (interface{}, error) { + // Get JWT claims from context + claims := ctx.GetAuthInfo().GetClaims() + if claims == nil { + return nil, errors.New("JWT claims not found") + } + + // Extract role using the same claim path as configured in rbac.json + // Example: if jwtClaimPath is "role" + role, ok := claims["role"].(string) + if !ok { + return nil, errors.New("role not found in JWT claims") + } + + // Use role for business logic (e.g., personalize UI, filter data) + return map[string]string{"userRole": role}, nil +} +``` + +**Note**: All authorization is handled automatically by the middleware. Accessing the role in handlers is only for business logic purposes (e.g., personalizing UI, filtering data). + +## Permission Naming Conventions + +### Recommended Format + +Use the format: `resource:action` + +- **Resource**: The entity being accessed (e.g., `users`, `posts`, `orders`) +- **Action**: The operation being performed (e.g., `read`, `write`, `delete`, `update`) + +### Examples + +``` +"users:read" // Read users +"users:write" // Create/update users +"users:delete" // Delete users +"posts:read" // Read posts +"posts:write" // Create/update posts +"orders:approve" // Approve orders +"reports:export" // Export reports +``` + +**Avoid inconsistent formats**: +- ❌ `"read_users"`, `"writeUsers"`, `"DELETE_POSTS"` +- ✅ `"users:read"`, `"users:write"`, `"posts:delete"` + +### Wildcards Not Supported + +**Important**: Wildcards are **NOT supported** in permissions. Only exact matches are allowed. + +- ❌ `"*:*"` - Does not match all permissions +- ❌ `"users:*"` - Does not match all user permissions +- ✅ `"users:read"` - Exact match only +- ✅ `"users:write"` - Exact match only + +If you need multiple permissions, specify them explicitly: +```json +{ + "name": "admin", + "permissions": ["users:read", "users:write", "users:delete", "posts:read", "posts:write"] +} +``` + +Or use role inheritance to avoid duplication: +```json +{ + "name": "editor", + "permissions": ["users:write", "posts:write"], + "inheritsFrom": ["viewer"] // Inherits viewer's permissions +} +``` + +## Common Patterns + +### CRUD Permissions + +```json +{ + "roles": [ + { + "name": "admin", + "permissions": ["users:create", "users:read", "users:update", "users:delete"] + }, + { + "name": "editor", + "permissions": ["users:create", "users:read", "users:update"], + "inheritsFrom": ["viewer"] + }, + { + "name": "viewer", + "permissions": ["users:read"] + } + ], + "endpoints": [ + { + "path": "/api/users", + "methods": ["POST"], + "requiredPermissions": ["users:create"] + }, + { + "path": "/api/users", + "methods": ["GET"], + "requiredPermissions": ["users:read"] + }, + { + "path": "^/api/users/\\d+$", + "methods": ["PUT", "PATCH"], + "requiredPermissions": ["users:update"] + }, + { + "path": "^/api/users/\\d+$", + "methods": ["DELETE"], + "requiredPermissions": ["users:delete"] + } + ] +} +``` + +### Resource-Specific Permissions + +```json +{ + "roles": [ + { + "name": "admin", + "permissions": ["own:posts:read", "own:posts:write", "all:posts:read", "all:posts:write"] + }, + { + "name": "author", + "permissions": ["own:posts:read", "own:posts:write"] + }, + { + "name": "viewer", + "permissions": ["own:posts:read", "all:posts:read"] + } + ], + "endpoints": [ + { + "path": "/api/posts/my-posts", + "methods": ["GET"], + "requiredPermissions": ["own:posts:read"] + }, + { + "path": "/api/posts", + "methods": ["GET"], + "requiredPermissions": ["all:posts:read"] + } + ] +} +``` + +## Best Practices + +### Security +- **Never use header-based RBAC for public APIs** - Use JWT-based RBAC +- **Always validate JWT tokens** - Use proper JWKS endpoints with HTTPS +- **Use HTTPS in production** - Protect tokens and headers +- **Monitor audit logs** - Track authorization decisions + +### Configuration +- **Use role inheritance** - Avoid duplicating permissions (only specify additional ones) +- **Use consistent naming** - Follow `resource:action` format (e.g., `users:read`, `posts:write`) +- **Group related permissions** - Organize by resource type +- **Version control configs** - Track RBAC changes in git + +## Troubleshooting + +**Role not being extracted** +- Ensure `roleHeader` or `jwtClaimPath` is set in config file +- For header-based: check that the header is present in requests +- For JWT-based: ensure OAuth middleware is enabled before RBAC + +**Permission checks failing** +- Verify `roles[].permissions` is properly configured +- Check that `endpoints[].requiredPermissions` matches your routes correctly +- Ensure role has the required permission (check inherited permissions too) +- Verify route pattern/regex matches exactly +- Check role inheritance - ensure inherited permissions are included + +**Permission always denied** +- Check role assignment - verify user's role has the required permission +- Review role permissions - ensure `roles[].permissions` includes the required permission +- Enable debug logging - check audit logs for authorization decisions + +**Permission always allowed** +- Check public endpoints - verify endpoint is not marked as `public: true` +- Review endpoint configuration - ensure `endpoints[].requiredPermissions` is set correctly +- Verify permission check - check audit logs to see if permission check is being performed + +**JWT role extraction failing** +- Ensure OAuth middleware is enabled before RBAC +- Verify JWT claim path is correct + +**Config file not found** +- Ensure config file exists at the specified path +- Or use default paths (`configs/rbac.json`, `configs/rbac.yaml`, `configs/rbac.yml`) + +## Implementing Custom RBAC Providers + +GoFr's RBAC system is extensible - you can implement your own RBAC provider by implementing the `gofr.RBACProvider` interface. This allows you to: + +- Load RBAC configuration from custom sources (database, API, environment variables, etc.) +- Implement custom authorization logic +- Integrate with external authorization systems +- Add custom middleware behavior + +### RBACProvider Interface + +To create a custom RBAC provider, implement the `gofr.RBACProvider` interface: + +```go +type RBACProvider interface { + // UseLogger sets the logger for the provider + UseLogger(logger any) + + // UseMetrics sets the metrics for the provider + UseMetrics(metrics any) + + // UseTracer sets the tracer for the provider + UseTracer(tracer any) + + // LoadPermissions loads RBAC configuration from the stored config path + LoadPermissions() error + + // RBACMiddleware returns the middleware function using the stored config + // The returned function should be compatible with http.Handler middleware pattern + RBACMiddleware() func(http.Handler) http.Handler +} +``` + +### Example: Custom RBAC Provider + +Here's an example of implementing a custom RBAC provider that loads configuration from a database: + +```go +package main + +import ( + "net/http" + "gofr.dev/pkg/gofr" + "gofr.dev/pkg/gofr/logging" +) + +type CustomRBACProvider struct { + configPath string + config *CustomConfig + logger any + metrics any + tracer any +} + +func NewCustomRBACProvider(configPath string) *CustomRBACProvider { + return &CustomRBACProvider{ + configPath: configPath, + } +} + +func (p *CustomRBACProvider) UseLogger(logger any) { + p.logger = logger +} + +func (p *CustomRBACProvider) UseMetrics(metrics any) { + p.metrics = metrics +} + +func (p *CustomRBACProvider) UseTracer(tracer any) { + p.tracer = tracer +} + +func (p *CustomRBACProvider) LoadPermissions() error { + // Load configuration from your custom source (database, API, etc.) + // For example, load from database: + // config, err := p.loadFromDatabase(p.configPath) + + // Store the loaded config + // p.config = config + + return nil +} + +func (p *CustomRBACProvider) RBACMiddleware() func(http.Handler) http.Handler { + if p.config == nil { + // Return passthrough middleware if config not loaded + return func(handler http.Handler) http.Handler { + return handler + } + } + + // Return your custom middleware implementation + return func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Your custom authorization logic here + // ... + handler.ServeHTTP(w, r) + }) + } +} + +func main() { + app := gofr.New() + + // Use your custom provider + provider := NewCustomRBACProvider("custom-config-path") + app.EnableRBAC(provider) + + app.Run() +} +``` + +### Integration with GoFr + +Once you implement the `RBACProvider` interface, you can use it with GoFr's `EnableRBAC` method: + +```go +app := gofr.New() +customProvider := NewCustomRBACProvider("config-path") +app.EnableRBAC(customProvider) +``` + +GoFr will automatically: +- Call `UseLogger`, `UseMetrics`, and `UseTracer` with the app's logger, metrics, and tracer +- Call `LoadPermissions()` to load your configuration +- Call `RBACMiddleware()` to get the middleware and register it + +## How It Works + +1. **Role Extraction**: Extracts user role from header (`X-User-Role`) or JWT claims +2. **Endpoint Matching**: Matches request method + path to endpoint configuration +3. **Permission Check**: Verifies role has required permission for the endpoint +4. **Authorization**: Allows or denies request based on permission check + +The middleware automatically handles all authorization - you just define routes normally. + +## Related Documentation + +- [HTTP Authentication](https://gofr.dev/docs/advanced-guide/http-authentication) - Basic Auth, API Keys, OAuth 2.0 +- [HTTP Communication](https://gofr.dev/docs/advanced-guide/http-communication) - Inter-service HTTP calls +- [Middlewares](https://gofr.dev/docs/advanced-guide/middlewares) - Custom middleware implementation diff --git a/docs/navigation.js b/docs/navigation.js index 8969a97ea..95f980ac6 100644 --- a/docs/navigation.js +++ b/docs/navigation.js @@ -83,6 +83,11 @@ export const navigation = [ href: '/docs/advanced-guide/http-authentication', desc: "Implement various HTTP authentication methods to secure your GoFR application and protect sensitive endpoints." }, + { + title: 'Role-Based Access Control (RBAC)', + href: '/docs/advanced-guide/rbac', + desc: "Implement comprehensive Role-Based Access Control with support for roles, permissions, hierarchy, JWT integration, hot reloading, and fine-grained permission-based authorization." + }, { title: 'Circuit Breaker Support', href: '/docs/advanced-guide/circuit-breaker', diff --git a/go.work b/go.work index 8baff656a..0d24beb89 100644 --- a/go.work +++ b/go.work @@ -12,8 +12,8 @@ use ( ./pkg/gofr/datasource/elasticsearch ./pkg/gofr/datasource/file/azure ./pkg/gofr/datasource/file/ftp - ./pkg/gofr/datasource/file/s3 ./pkg/gofr/datasource/file/gcs + ./pkg/gofr/datasource/file/s3 ./pkg/gofr/datasource/file/sftp ./pkg/gofr/datasource/influxdb ./pkg/gofr/datasource/kv-store/badger @@ -27,4 +27,5 @@ use ( ./pkg/gofr/datasource/scylladb ./pkg/gofr/datasource/solr ./pkg/gofr/datasource/surrealdb + ./pkg/gofr/rbac ) diff --git a/go.work.sum b/go.work.sum index 72bab5be4..c127a09c5 100644 --- a/go.work.sum +++ b/go.work.sum @@ -345,6 +345,7 @@ github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= @@ -736,8 +737,6 @@ golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= diff --git a/pkg/gofr/rbac.go b/pkg/gofr/rbac.go new file mode 100644 index 000000000..b0a7d0e98 --- /dev/null +++ b/pkg/gofr/rbac.go @@ -0,0 +1,80 @@ +package gofr + +import ( + "net/http" + + "go.opentelemetry.io/otel" +) + +// RBACProvider is the interface for RBAC implementations. +// External RBAC modules (like gofr.dev/pkg/gofr/rbac) implement this interface. +type RBACProvider interface { + // UseLogger sets the logger for the provider + UseLogger(logger any) + + // UseMetrics sets the metrics for the provider + UseMetrics(metrics any) + + // UseTracer sets the tracer for the provider + UseTracer(tracer any) + + // LoadPermissions loads RBAC configuration from the stored config path + LoadPermissions() error + + // RBACMiddleware returns the middleware function using the stored config + // The returned function should be compatible with http.Handler middleware pattern + RBACMiddleware() func(http.Handler) http.Handler +} + +// DefaultRBACConfig is a constant that can be passed to NewProvider to use default config paths. +// When passed, NewProvider will try: configs/rbac.json, configs/rbac.yaml, configs/rbac.yml. +const DefaultRBACConfig = "" + +// EnableRBAC enables RBAC by loading configuration from a JSON or YAML file. +// This is a factory function that registers RBAC implementations and sets up the middleware. +// The config file path is stored in the provider (set via NewProvider). +// +// Pure config-based: All authorization rules are defined in the config file using: +// - Roles: role → permission mapping (format: "resource:action") +// - Endpoints: route & method → permission mapping +// +// Example: +// +// import ( +// "gofr.dev/pkg/gofr" +// "gofr.dev/pkg/gofr/rbac" +// ) +// +// app := gofr.New() +// provider := rbac.NewProvider("configs/rbac.json") // Store config path +// app.EnableRBAC(provider) // Uses stored path +// +// Role extraction is configured in the config file: +// - Set "roleHeader" for header-based extraction (e.g., "X-User-Role") +// - Set "jwtClaimPath" for JWT-based extraction (e.g., "role", "roles[0]"). +func (a *App) EnableRBAC(provider RBACProvider) { + if provider == nil { + a.Logger().Error("RBAC provider is required. Create one using: provider := rbac.NewProvider(\"configs/rbac.json\")") + return + } + + // Set logger, metrics, and tracer automatically + provider.UseLogger(a.Logger()) + provider.UseMetrics(a.Metrics()) + + tracer := otel.GetTracerProvider().Tracer("gofr-rbac") + provider.UseTracer(tracer) + + // Load configuration from file using the provider + // Logger is automatically set on config during LoadPermissions + if err := provider.LoadPermissions(); err != nil { + a.Logger().Errorf("Failed to load RBAC config: %v", err) + return + } + + a.Logger().Infof("Loaded RBAC config successfully") + + // Apply middleware using the provider + middlewareFunc := provider.RBACMiddleware() + a.httpServer.router.Use(middlewareFunc) +} diff --git a/pkg/gofr/rbac/config.go b/pkg/gofr/rbac/config.go new file mode 100644 index 000000000..7f4bbc69b --- /dev/null +++ b/pkg/gofr/rbac/config.go @@ -0,0 +1,432 @@ +package rbac + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + + "go.opentelemetry.io/otel/trace" + "gopkg.in/yaml.v3" +) + +var ( + // errUnsupportedFormat is returned when the config file format is not supported. + errUnsupportedFormat = errors.New("unsupported config file format") + + // ErrEndpointMissingPermissions is returned when an endpoint doesn't specify requiredPermissions and is not public. + ErrEndpointMissingPermissions = errors.New("endpoint must specify requiredPermissions (or be public)") +) + +// RoleDefinition defines a role with its permissions and inheritance. +// Pure config-based: only role->permission mapping is supported. +type RoleDefinition struct { + // Name is the role name (required) + Name string `json:"name" yaml:"name"` + + // Permissions is a list of permissions for this role (format: "resource:action") + // Example: ["users:read", "users:write"] + Permissions []string `json:"permissions,omitempty" yaml:"permissions,omitempty"` + + // InheritsFrom lists roles this role inherits permissions from + // Example: ["viewer"] - editor inherits all viewer permissions + InheritsFrom []string `json:"inheritsFrom,omitempty" yaml:"inheritsFrom,omitempty"` +} + +// EndpointMapping defines authorization requirements for an API endpoint. +// Pure config-based: only route&method->permission mapping is supported. +// No direct route to role mapping - all authorization is permission-based. +type EndpointMapping struct { + // Path is the route path pattern (supports wildcards like /api/* or regex patterns) + // Examples: + // - "/api/users" (exact match) + // - "/api/users/*" (wildcard pattern) + // - "^/api/users/\\d+$" (regex pattern - automatically detected) + Path string `json:"path,omitempty" yaml:"path,omitempty"` + + // Methods is a list of HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.) + // Use ["*"] to match all methods + // Example: ["GET", "POST"] + Methods []string `json:"methods" yaml:"methods"` + + // RequiredPermissions is a list of permissions required to access this endpoint (format: "resource:action") + // User needs to have ANY of these permissions (OR logic) + // Example: ["users:read"] or ["users:read", "users:admin"] + // This is checked against the role's permissions + // REQUIRED: All endpoints must specify requiredPermissions (except public endpoints) + RequiredPermissions []string `json:"requiredPermissions,omitempty" yaml:"requiredPermissions,omitempty"` + + // Public indicates this endpoint is publicly accessible (bypasses authorization) + // Example: true for /health, /metrics endpoints + Public bool `json:"public,omitempty" yaml:"public,omitempty"` +} + +// Config represents the unified RBAC configuration structure. +type Config struct { + // Roles defines all roles with their permissions and inheritance + // This is the unified way to define roles (replaces RouteWithPermissions, RoleHierarchy) + Roles []RoleDefinition `json:"roles,omitempty" yaml:"roles,omitempty"` + + // Endpoints maps API endpoints to authorization requirements + // This is the unified way to define endpoint access (replaces RouteWithPermissions, OverRides) + Endpoints []EndpointMapping `json:"endpoints,omitempty" yaml:"endpoints,omitempty"` + + // RoleHeader specifies the HTTP header key for header-based role extraction + // Example: "X-User-Role" + // If set, role is extracted from this header + RoleHeader string `json:"roleHeader,omitempty" yaml:"roleHeader,omitempty"` + + // JWTClaimPath specifies the JWT claim path for JWT-based role extraction + // Examples: "role", "roles[0]", "permissions.role" + // If set, role is extracted from JWT claims in request context + JWTClaimPath string `json:"jwtClaimPath,omitempty" yaml:"jwtClaimPath,omitempty"` + + // ErrorHandler is called when authorization fails + // If nil, default error response is sent + ErrorHandler func(w http.ResponseWriter, r *http.Request, role, route string, err error) + + // Logger is the logger instance for audit logging + // Set automatically by EnableRBAC - users don't need to configure this + // Audit logging is automatically performed when RBAC is enabled + Logger Logger `json:"-" yaml:"-"` + + // Metrics is the metrics instance for RBAC metrics + // Set automatically by EnableRBAC + Metrics Metrics `json:"-" yaml:"-"` + + // Tracer is the tracer instance for RBAC tracing + // Set automatically by EnableRBAC + Tracer trace.Tracer `json:"-" yaml:"-"` + + // Internal maps built from unified config (not in JSON/YAML) + // These are populated by processUnifiedConfig() + rolePermissionsMap map[string][]string `json:"-" yaml:"-"` + endpointPermissionMap map[string][]string `json:"-" yaml:"-"` // Key: "METHOD:/path", Value: []permissions + publicEndpointsMap map[string]bool `json:"-" yaml:"-"` // Key: "METHOD:/path", Value: true if public + compiledRegexMap map[string]*regexp.Regexp `json:"-" yaml:"-"` // Key: "METHOD:/path", Value: compiled regex + + // Mutex for thread-safe access to maps (for future hot-reload support) + mu sync.RWMutex `json:"-" yaml:"-"` +} + +// LoadPermissions loads RBAC configuration from a JSON or YAML file. +// The file format is automatically detected based on the file extension. +// Supported formats: .json, .yaml, .yml. +func LoadPermissions(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read RBAC config file %s: %w", path, err) + } + + var config Config + + // Detect file format by extension + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".yaml", ".yml": + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse YAML config file %s: %w", path, err) + } + case ".json", "": + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse JSON config file %s: %w", path, err) + } + default: + return nil, fmt.Errorf("unsupported config file format: %s (supported: .json, .yaml, .yml): %w", ext, errUnsupportedFormat) + } + + // Validate config before processing + if err := config.validate(); err != nil { + return nil, fmt.Errorf("invalid RBAC config: %w", err) + } + + // Process unified config to build internal maps + if err := config.processUnifiedConfig(); err != nil { + return nil, fmt.Errorf("failed to process unified config: %w", err) + } + + return &config, nil +} + +// validate validates the RBAC configuration. +func (c *Config) validate() error { + // Validate endpoints: non-public endpoints must have RequiredPermissions + for i, endpoint := range c.Endpoints { + if !endpoint.Public && len(endpoint.RequiredPermissions) == 0 { + return fmt.Errorf("endpoint[%d]: %w: %s", i, ErrEndpointMissingPermissions, endpoint.Path) + } + } + + return nil +} + +// processUnifiedConfig processes the unified Roles and Endpoints config +// and builds internal maps for efficient lookup. +func (c *Config) processUnifiedConfig() error { + c.mu.Lock() + defer c.mu.Unlock() + + c.initializeMaps() + c.buildRolePermissionsMap() + + return c.buildEndpointPermissionMap() +} + +// initializeMaps initializes internal maps. +func (c *Config) initializeMaps() { + c.rolePermissionsMap = make(map[string][]string) + c.endpointPermissionMap = make(map[string][]string) + c.publicEndpointsMap = make(map[string]bool) + c.compiledRegexMap = make(map[string]*regexp.Regexp) +} + +// buildRolePermissionsMap builds the role permissions map from Roles. +// Uses getEffectivePermissions() for consistent inheritance logic. +func (c *Config) buildRolePermissionsMap() { + for _, roleDef := range c.Roles { + // Use getEffectivePermissions() for consistent inheritance handling + permissions := c.getEffectivePermissions(roleDef.Name) + c.rolePermissionsMap[roleDef.Name] = permissions + } +} + +// buildEndpointPermissionMap builds the endpoint permission map from Endpoints. +func (c *Config) buildEndpointPermissionMap() error { + for _, endpoint := range c.Endpoints { + methods := endpoint.Methods + if len(methods) == 0 { + methods = []string{"*"} + } + + if err := c.processEndpointMethods(&endpoint, methods); err != nil { + return err + } + } + + return nil +} + +// processEndpointMethods processes methods for an endpoint. +func (c *Config) processEndpointMethods(endpoint *EndpointMapping, methods []string) error { + for _, method := range methods { + methodUpper := strings.ToUpper(method) + key := c.buildEndpointKey(endpoint, methodUpper) + + if err := c.storeEndpointMapping(endpoint, key, methodUpper); err != nil { + return err + } + } + + return nil +} + +// buildEndpointKey builds the key for an endpoint. +// Uses Path field which may contain either a path pattern or a regex pattern. +// Note: This is called during processUnifiedConfig which already holds a lock, +// so we can directly access compiledRegexMap without acquiring another lock. +func (c *Config) buildEndpointKey(endpoint *EndpointMapping, methodUpper string) string { + pattern := endpoint.Path + + // If pattern looks like a regex, precompile it for performance + // Store with pattern as key (not full METHOD:pattern) since matchesKey + // uses pattern directly for lookup + if isRegexPattern(pattern) { + // We're already holding a lock from processUnifiedConfig, so access map directly + if _, exists := c.compiledRegexMap[pattern]; !exists { + if compiled, err := regexp.Compile(pattern); err == nil { + c.compiledRegexMap[pattern] = compiled + } + } + } + + return fmt.Sprintf("%s:%s", methodUpper, pattern) +} + +// storeEndpointMapping stores an endpoint mapping. +func (c *Config) storeEndpointMapping(endpoint *EndpointMapping, key, methodUpper string) error { + if endpoint.Public { + c.publicEndpointsMap[key] = true + return nil + } + + if len(endpoint.RequiredPermissions) == 0 { + return fmt.Errorf("%w: %s %s", ErrEndpointMissingPermissions, methodUpper, endpoint.Path) + } + + // Store all required permissions (not just the first one) + permissions := make([]string, len(endpoint.RequiredPermissions)) + copy(permissions, endpoint.RequiredPermissions) + c.endpointPermissionMap[key] = permissions + + return nil +} + +// getEffectivePermissions recursively gets all permissions for a role including inherited ones. +func (c *Config) getEffectivePermissions(roleName string) []string { + var permissions []string + + visited := make(map[string]bool) + + var collectPermissions func(string) + + collectPermissions = func(name string) { + if visited[name] { + return + } + + visited[name] = true + + // Find role definition + for _, roleDef := range c.Roles { + if roleDef.Name == name { + permissions = append(permissions, roleDef.Permissions...) + // Recursively collect from inherited roles + for _, inheritedName := range roleDef.InheritsFrom { + collectPermissions(inheritedName) + } + + break + } + } + } + + collectPermissions(roleName) + + return permissions +} + +// GetRolePermissions returns the permissions for a role (thread-safe). +func (c *Config) GetRolePermissions(role string) []string { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.rolePermissionsMap[role] +} + +// GetEndpointPermission returns the required permissions for an endpoint (thread-safe). +// Returns empty slice if endpoint is public or not found. +// Returns all required permissions (user needs ANY of them - OR logic). +func (c *Config) GetEndpointPermission(method, path string) ([]string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + methodUpper := strings.ToUpper(method) + key := fmt.Sprintf("%s:%s", methodUpper, path) + + // Try exact match first + if perms, isPublic := c.checkExactMatch(key); isPublic || len(perms) > 0 { + return perms, isPublic + } + + // Try pattern and regex matching + return c.checkPatternMatch(methodUpper, path) +} + +// checkExactMatch checks for an exact endpoint match. +func (c *Config) checkExactMatch(key string) (permissions []string, isPublic bool) { + if public, ok := c.publicEndpointsMap[key]; ok && public { + return nil, true + } + + if perms, ok := c.endpointPermissionMap[key]; ok { + return perms, false + } + + return nil, false +} + +// checkPatternMatch checks for pattern and regex matches. +// Note: This is called while already holding RLock from GetEndpointPermission. +// matchesKey will acquire another RLock which is safe (read locks can be nested). +func (c *Config) checkPatternMatch(methodUpper, path string) (permissions []string, isPublic bool) { + // Try pattern matching for wildcards + // Note: We iterate over the map while holding RLock, which is safe for read-only operations + for key, perms := range c.endpointPermissionMap { + if c.matchesKey(key, methodUpper, path) { + return perms, false + } + } + + // Check public endpoints with pattern/regex + for key := range c.publicEndpointsMap { + if c.matchesKey(key, methodUpper, path) { + return nil, true + } + } + + return nil, false +} + +// isRegexPattern detects if a pattern is likely a regex. +// Checks for common regex indicators: starts with ^, ends with $, or contains regex special chars. +func isRegexPattern(pattern string) bool { + return strings.HasPrefix(pattern, "^") || strings.HasSuffix(pattern, "$") || + strings.Contains(pattern, "\\d") || strings.Contains(pattern, "\\w") || + strings.Contains(pattern, "\\s") || strings.Contains(pattern, "[") || + strings.Contains(pattern, "(") || strings.Contains(pattern, "?") +} + +// matchesKey checks if a key matches the given method and path. +// Keys are built by buildEndpointKey which uses Path (may contain regex patterns). +// If pattern looks like a regex (starts with ^ or contains regex special chars), use regex matching exclusively. +// Otherwise, use path pattern matching exclusively (no fallback to regex). +func (c *Config) matchesKey(key, methodUpper, path string) bool { + if !strings.HasPrefix(key, methodUpper+":") { + return false + } + + pattern := strings.TrimPrefix(key, methodUpper+":") + + if isRegexPattern(pattern) { + // Try to use precompiled regex for better performance + // Note: We may already be holding RLock from checkPatternMatch, but nested read locks are safe + c.mu.RLock() + compiled, exists := c.compiledRegexMap[pattern] + c.mu.RUnlock() + + if exists { + return compiled.MatchString(path) + } + + // Fallback to runtime compilation if not precompiled (shouldn't happen if config was processed correctly) + // Compile with a timeout-safe approach - use MustCompile in a recover block or just MatchString + matched, err := regexp.MatchString(pattern, path) + if err != nil { + // Invalid regex - no match + return false + } + + return matched + } + + // For path patterns, only try path matching + // Since buildEndpointKey uses Path (which may contain regex patterns), + // if it's not detected as regex, it's a path pattern from buildEndpointKey + return matchesPathPattern(pattern, path) +} + +// matchesPathPattern checks if path matches pattern (supports wildcards). +func matchesPathPattern(pattern, path string) bool { + if pattern == "" { + return false + } + + // Use path/filepath.Match for pattern matching + matched, _ := filepath.Match(pattern, path) + if matched { + return true + } + + // Check prefix match for patterns ending with /* + if strings.HasSuffix(pattern, "/*") { + prefix := strings.TrimSuffix(pattern, "/*") + return path == prefix || strings.HasPrefix(path, prefix+"/") + } + + return false +} diff --git a/pkg/gofr/rbac/config_test.go b/pkg/gofr/rbac/config_test.go new file mode 100644 index 000000000..e54c48db8 --- /dev/null +++ b/pkg/gofr/rbac/config_test.go @@ -0,0 +1,634 @@ +package rbac + +import ( + "os" + "path/filepath" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadPermissions_ValidConfigs(t *testing.T) { + t.Run("loads valid json config", func(t *testing.T) { + fileContent := `{ + "roles": [{"name": "admin", "permissions": ["admin:read", "admin:write"]}], + "endpoints": [{"path": "/api", "methods": ["GET"], "requiredPermissions": ["admin:read"]}] + }` + path, err := createTestConfigFile("test_config.json", fileContent) + require.NoError(t, err) + + defer os.Remove(path) + + config, err := LoadPermissions("test_config.json") + require.NoError(t, err) + require.NotNil(t, config) + assert.NotNil(t, config.rolePermissionsMap) + }) + + t.Run("loads valid yaml config", func(t *testing.T) { + fileContent := `roles: + - name: admin + permissions: ["admin:read", "admin:write"] +endpoints: + - path: /api + methods: ["GET"] + requiredPermissions: ["admin:read"]` + path, err := createTestConfigFile("test_config.yaml", fileContent) + require.NoError(t, err) + + defer os.Remove(path) + + config, err := LoadPermissions("test_config.yaml") + require.NoError(t, err) + require.NotNil(t, config) + assert.NotNil(t, config.rolePermissionsMap) + }) + + t.Run("loads valid yml config", func(t *testing.T) { + fileContent := `roles: + - name: viewer + permissions: ["users:read"]` + path, err := createTestConfigFile("test_config.yml", fileContent) + require.NoError(t, err) + + defer os.Remove(path) + + config, err := LoadPermissions("test_config.yml") + require.NoError(t, err) + require.NotNil(t, config) + assert.NotNil(t, config.rolePermissionsMap) + }) +} + +func TestLoadPermissions_ErrorCases(t *testing.T) { + t.Run("returns error for non-existent file", func(t *testing.T) { + config, err := LoadPermissions("nonexistent.json") + require.Error(t, err) + require.Nil(t, config) + }) + + t.Run("returns error for invalid json", func(t *testing.T) { + path, err := createTestConfigFile("test_invalid.json", `invalid json{`) + require.NoError(t, err) + + defer os.Remove(path) + + config, err := LoadPermissions("test_invalid.json") + require.Error(t, err) + require.Nil(t, config) + }) + + t.Run("returns error for invalid yaml", func(t *testing.T) { + path, err := createTestConfigFile("test_invalid.yaml", `invalid: yaml: [`) + require.NoError(t, err) + + defer os.Remove(path) + + config, err := LoadPermissions("test_invalid.yaml") + require.Error(t, err) + require.Nil(t, config) + }) + + t.Run("returns error for unsupported format", func(t *testing.T) { + path, err := createTestConfigFile("test.txt", `some content`) + require.NoError(t, err) + + defer os.Remove(path) + + config, err := LoadPermissions("test.txt") + require.Error(t, err) + require.Nil(t, config) + }) + + t.Run("returns error for endpoint without requiredPermissions", func(t *testing.T) { + fileContent := `{ + "roles": [{"name": "admin", "permissions": ["*:*"]}], + "endpoints": [{"path": "/api", "methods": ["GET"]}] + }` + path, err := createTestConfigFile("test_missing_perm.json", fileContent) + require.NoError(t, err) + + defer os.Remove(path) + + config, err := LoadPermissions("test_missing_perm.json") + require.Error(t, err) + require.Nil(t, config) + }) +} + +func TestConfig_GetRolePermissions(t *testing.T) { + testCases := []struct { + desc string + config *Config + role string + expectedPerms []string + }{ + { + desc: "returns permissions for existing role", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"*:*"}}, + {Name: "viewer", Permissions: []string{"users:read"}}, + }, + }, + role: "admin", + expectedPerms: []string{"*:*"}, + }, + { + desc: "returns empty for non-existent role", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"*:*"}}, + }, + }, + role: "nonexistent", + expectedPerms: nil, + }, + { + desc: "returns permissions with inheritance", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "viewer", Permissions: []string{"users:read"}}, + {Name: "editor", Permissions: []string{"users:write"}, InheritsFrom: []string{"viewer"}}, + }, + }, + role: "editor", + expectedPerms: []string{"users:write", "users:read"}, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + err := tc.config.processUnifiedConfig() + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + + result := tc.config.GetRolePermissions(tc.role) + + assert.Equal(t, tc.expectedPerms, result, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestConfig_GetEndpointPermission_ExactMatch(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api/users", Methods: []string{"GET"}, RequiredPermissions: []string{"users:read"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + perms, isPublic := config.GetEndpointPermission("GET", "/api/users") + assert.Equal(t, []string{"users:read"}, perms) + assert.False(t, isPublic) +} + +func TestConfig_GetEndpointPermission_PublicEndpoint(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/health", Methods: []string{"GET"}, Public: true}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + perms, isPublic := config.GetEndpointPermission("GET", "/health") + assert.Nil(t, perms) + assert.True(t, isPublic) +} + +func TestConfig_GetEndpointPermission_NotFound(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api/users", Methods: []string{"GET"}, RequiredPermissions: []string{"users:read"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + perms, isPublic := config.GetEndpointPermission("POST", "/api/posts") + assert.Nil(t, perms) + assert.False(t, isPublic) +} + +func TestConfig_GetEndpointPermission_WildcardPattern(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api/*", Methods: []string{"GET"}, RequiredPermissions: []string{"api:read"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + perms, isPublic := config.GetEndpointPermission("GET", "/api/users") + assert.Equal(t, []string{"api:read"}, perms) + assert.False(t, isPublic) +} + +func TestConfig_GetEndpointPermission_RegexPattern(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "^/api/users/\\d+$", Methods: []string{"GET"}, RequiredPermissions: []string{"users:read"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + perms, isPublic := config.GetEndpointPermission("GET", "/api/users/123") + assert.Equal(t, []string{"users:read"}, perms) + assert.False(t, isPublic) +} + +func TestConfig_GetEndpointPermission_CaseInsensitive(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api", Methods: []string{"get"}, RequiredPermissions: []string{"api:read"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + perms, isPublic := config.GetEndpointPermission("GET", "/api") + assert.Equal(t, []string{"api:read"}, perms) + assert.False(t, isPublic) +} + +func TestConfig_GetEndpointPermission_MultiplePermissions(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + { + Path: "/api/users", + Methods: []string{"GET"}, + RequiredPermissions: []string{"users:read", "users:admin"}, + }, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + perms, isPublic := config.GetEndpointPermission("GET", "/api/users") + assert.Equal(t, []string{"users:read", "users:admin"}, perms) + assert.False(t, isPublic) +} + +func TestConfig_processUnifiedConfig(t *testing.T) { + testCases := []struct { + desc string + config *Config + expectError bool + }{ + { + desc: "processes config with roles and endpoints", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"*:*"}}, + }, + Endpoints: []EndpointMapping{ + {Path: "/api", Methods: []string{"GET"}, RequiredPermissions: []string{"admin:*"}}, + }, + }, + expectError: false, + }, + { + desc: "processes config with role inheritance", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "viewer", Permissions: []string{"users:read"}}, + {Name: "editor", Permissions: []string{"users:write"}, InheritsFrom: []string{"viewer"}}, + }, + Endpoints: []EndpointMapping{ + {Path: "/api/users", Methods: []string{"GET"}, RequiredPermissions: []string{"users:read"}}, + }, + }, + expectError: false, + }, + { + desc: "returns error for endpoint without requiredPermissions", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"*:*"}}, + }, + Endpoints: []EndpointMapping{ + {Path: "/api", Methods: []string{"GET"}}, + }, + }, + expectError: true, + }, + { + desc: "processes config with public endpoints", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"*:*"}}, + }, + Endpoints: []EndpointMapping{ + {Path: "/health", Methods: []string{"GET"}, Public: true}, + }, + }, + expectError: false, + }, + { + desc: "processes config with empty methods", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"*:*"}}, + }, + Endpoints: []EndpointMapping{ + {Path: "/api", Methods: []string{}, RequiredPermissions: []string{"admin:*"}}, + }, + }, + expectError: false, + }, + { + desc: "processes config with regex endpoints", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"*:*"}}, + }, + Endpoints: []EndpointMapping{ + {Path: "^/api/users/\\d+$", Methods: []string{"GET"}, RequiredPermissions: []string{"admin:*"}}, + }, + }, + expectError: false, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + err := tc.config.processUnifiedConfig() + + if tc.expectError { + require.Error(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + return + } + + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.NotNil(t, tc.config.rolePermissionsMap, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.NotNil(t, tc.config.endpointPermissionMap, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestMatchesPathPattern(t *testing.T) { + testCases := []struct { + desc string + pattern string + path string + expected bool + }{ + { + desc: "matches exact path", + pattern: "/api/users", + path: "/api/users", + expected: true, + }, + { + desc: "does not match different path", + pattern: "/api/users", + path: "/api/posts", + expected: false, + }, + { + desc: "matches wildcard pattern", + pattern: "/api/*", + path: "/api/users", + expected: true, + }, + { + desc: "matches wildcard pattern with exact prefix", + pattern: "/api/*", + path: "/api", + expected: true, + }, + { + desc: "does not match wildcard pattern with different prefix", + pattern: "/api/*", + path: "/v1/users", + expected: false, + }, + { + desc: "returns false for empty pattern", + pattern: "", + path: "/api/users", + expected: false, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + result := matchesPathPattern(tc.pattern, tc.path) + + assert.Equal(t, tc.expected, result, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func createTestConfigFile(filename, content string) (string, error) { + dir := filepath.Dir(filename) + if dir != "." && dir != "" { + err := os.MkdirAll(dir, 0755) + if err != nil { + return "", err + } + } + + err := os.WriteFile(filename, []byte(content), 0600) + + return filename, err +} + +func TestConfig_getEffectivePermissions(t *testing.T) { + testCases := []struct { + desc string + config *Config + roleName string + expectedPerms []string + }{ + { + desc: "returns permissions for role without inheritance", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "viewer", Permissions: []string{"users:read"}}, + }, + }, + roleName: "viewer", + expectedPerms: []string{"users:read"}, + }, + { + desc: "returns permissions with single level inheritance", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "viewer", Permissions: []string{"users:read"}}, + {Name: "editor", Permissions: []string{"users:write"}, InheritsFrom: []string{"viewer"}}, + }, + }, + roleName: "editor", + expectedPerms: []string{"users:write", "users:read"}, + }, + { + desc: "returns permissions with multi-level inheritance", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "viewer", Permissions: []string{"users:read"}}, + {Name: "editor", Permissions: []string{"users:write"}, InheritsFrom: []string{"viewer"}}, + {Name: "admin", Permissions: []string{"users:delete"}, InheritsFrom: []string{"editor"}}, + }, + }, + roleName: "admin", + expectedPerms: []string{"users:delete", "users:write", "users:read"}, + }, + { + desc: "handles circular inheritance gracefully", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "role1", Permissions: []string{"perm1"}, InheritsFrom: []string{"role2"}}, + {Name: "role2", Permissions: []string{"perm2"}, InheritsFrom: []string{"role1"}}, + }, + }, + roleName: "role1", + expectedPerms: []string{"perm1", "perm2"}, + }, + { + desc: "returns empty for non-existent role", + config: &Config{ + Roles: []RoleDefinition{ + {Name: "viewer", Permissions: []string{"users:read"}}, + }, + }, + roleName: "nonexistent", + expectedPerms: nil, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + result := tc.config.getEffectivePermissions(tc.roleName) + + assert.Equal(t, tc.expectedPerms, result, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestExtractNestedClaim_Additional(t *testing.T) { + testCases := []struct { + desc string + claims jwt.MapClaims + path string + expected any + expectError bool + }{ + { + desc: "extracts claim with jwt.MapClaims nested", + claims: jwt.MapClaims{ + "user": jwt.MapClaims{ + "role": "admin", + }, + }, + path: "user.role", + expected: "admin", + expectError: false, + }, + { + desc: "returns error when intermediate value is not map", + claims: jwt.MapClaims{ + "user": "not a map", + }, + path: "user.role", + expected: nil, + expectError: true, + }, + { + desc: "extracts from mixed map types", + claims: jwt.MapClaims{ + "level1": map[string]any{ + "level2": jwt.MapClaims{ + "value": "test", + }, + }, + }, + path: "level1.level2.value", + expected: "test", + expectError: false, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + result, err := extractNestedClaim(tc.claims, tc.path) + + if tc.expectError { + require.Error(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Nil(t, result, "TEST[%d], Failed.\n%s", i, tc.desc) + + return + } + + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, tc.expected, result, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestExtractArrayClaim_Additional(t *testing.T) { + testCases := []struct { + desc string + claims jwt.MapClaims + path string + expected any + expectError bool + }{ + { + desc: "extracts from array with valid index", + claims: jwt.MapClaims{ + "roles": []any{"admin", "user", "guest"}, + }, + path: "roles[2]", + expected: "guest", + expectError: false, + }, + { + desc: "returns error for invalid array notation format", + claims: jwt.MapClaims{ + "roles": []any{"admin"}, + }, + path: "roles]0[", + expected: nil, + expectError: true, + }, + { + desc: "returns error for non-numeric index", + claims: jwt.MapClaims{ + "roles": []any{"admin"}, + }, + path: "roles[abc]", + expected: nil, + expectError: true, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + idx := 0 + + for j, c := range tc.path { + if c == '[' { + idx = j + break + } + } + + result, err := extractArrayClaim(tc.claims, tc.path, idx) + + if tc.expectError { + require.Error(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Nil(t, result, "TEST[%d], Failed.\n%s", i, tc.desc) + + return + } + + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, tc.expected, result, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} diff --git a/pkg/gofr/rbac/endpoint_matcher.go b/pkg/gofr/rbac/endpoint_matcher.go new file mode 100644 index 000000000..a2e699e06 --- /dev/null +++ b/pkg/gofr/rbac/endpoint_matcher.go @@ -0,0 +1,152 @@ +package rbac + +import ( + "net/http" + "regexp" + "strings" +) + +// matchEndpoint checks if the request matches an endpoint configuration. +// This is the primary authorization check using the unified Endpoints configuration. +// Returns the matched endpoint and whether it's public. +func matchEndpoint(method, route string, endpoints []EndpointMapping, config *Config) (*EndpointMapping, bool) { + for i := range endpoints { + endpoint := &endpoints[i] + + // Check if endpoint is public + if endpoint.Public { + if matchesEndpointPattern(endpoint, route, config) { + return endpoint, true + } + + continue + } + + // Check method match + if !matchesHTTPMethod(method, endpoint.Methods) { + continue + } + + // Check route match + if matchesEndpointPattern(endpoint, route, config) { + return endpoint, false + } + } + + return nil, false +} + +// matchesHTTPMethod checks if the HTTP method matches the endpoint's allowed methods. +func matchesHTTPMethod(method string, allowedMethods []string) bool { + // Empty methods or "*" means all methods + if len(allowedMethods) == 0 { + return true + } + + for _, m := range allowedMethods { + if m == "*" || strings.EqualFold(m, method) { + return true + } + } + + return false +} + +// matchesRegexPattern matches a route against a regex pattern using precompiled regex if available. +func matchesRegexPattern(pattern, route string, config *Config) bool { + if config == nil { + // Fallback to runtime compilation if config is not available + matched, err := regexp.MatchString(pattern, route) + return err == nil && matched + } + + // Look up precompiled regex (stored with pattern as key during config processing) + config.mu.RLock() + compiled, exists := config.compiledRegexMap[pattern] + config.mu.RUnlock() + + if exists { + return compiled.MatchString(route) + } + + // Compile and cache if not found (fallback for runtime compilation) + compiled, err := regexp.Compile(pattern) + if err != nil { + return false // Invalid regex = no match + } + + config.mu.Lock() + config.compiledRegexMap[pattern] = compiled + config.mu.Unlock() + + return compiled.MatchString(route) +} + +// matchesEndpointPattern checks if the route matches the endpoint pattern. +// Method matching is handled separately in matchEndpoint before this function is called. +// Automatically detects if Path contains a regex pattern and uses appropriate matching. +func matchesEndpointPattern(endpoint *EndpointMapping, route string, config *Config) bool { + if endpoint.Path == "" { + return false + } + + pattern := endpoint.Path + + // Check if pattern is a regex (starts with ^, ends with $, or contains regex special chars) + if isRegexPattern(pattern) { + return matchesRegexPattern(pattern, route, config) + } + + // Check path pattern matching + return matchesPathPattern(pattern, route) +} + +// checkEndpointAuthorization checks if the user's role is authorized for the endpoint. +// Pure permission-based: checks if role has ANY of the required permissions (OR logic). +// Uses the endpoint parameter directly instead of re-looking it up. +func checkEndpointAuthorization(role string, endpoint *EndpointMapping, config *Config) (allowed bool, reason string) { + // Public endpoints are always allowed + if endpoint.Public { + return true, "public-endpoint" + } + + // Get required permissions + requiredPerms := endpoint.RequiredPermissions + + // If no permission requirement found, deny (fail secure) + if len(requiredPerms) == 0 { + return false, "" + } + + // Get role's permissions (thread-safe) + rolePerms := config.GetRolePermissions(role) + if len(rolePerms) == 0 { + return false, "" + } + + // Check if role has ANY of the required permissions (OR logic) + // Only exact matches are supported - wildcards are NOT supported in permissions + for _, requiredPerm := range requiredPerms { + for _, perm := range rolePerms { + // Exact match only - no wildcard support + if perm == requiredPerm { + return true, "permission-based" + } + } + } + + return false, "" +} + +// getEndpointForRequest finds the matching endpoint configuration for a request. +// This is the primary function used by the middleware to determine authorization requirements. +func getEndpointForRequest(r *http.Request, config *Config) (*EndpointMapping, bool) { + if len(config.Endpoints) == 0 { + return nil, false + } + + method := r.Method + route := r.URL.Path + + return matchEndpoint(method, route, config.Endpoints, config) +} diff --git a/pkg/gofr/rbac/endpoint_matcher_test.go b/pkg/gofr/rbac/endpoint_matcher_test.go new file mode 100644 index 000000000..e8dc8d5db --- /dev/null +++ b/pkg/gofr/rbac/endpoint_matcher_test.go @@ -0,0 +1,475 @@ +package rbac + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMatchEndpoint_ExactMatch(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api/users", Methods: []string{"GET"}, RequiredPermissions: []string{"users:read"}}, + }, + } + _ = config.processUnifiedConfig() + endpoints := config.Endpoints + endpoint, isPublic := matchEndpoint("GET", "/api/users", endpoints, config) + require.NotNil(t, endpoint) + assert.False(t, isPublic) +} + +func TestMatchEndpoint_PublicEndpoint(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/health", Methods: []string{"GET"}, Public: true}, + }, + } + _ = config.processUnifiedConfig() + endpoints := config.Endpoints + endpoint, isPublic := matchEndpoint("GET", "/health", endpoints, config) + require.NotNil(t, endpoint) + assert.True(t, isPublic) +} + +func TestMatchEndpoint_DifferentMethod(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api/users", Methods: []string{"GET"}, RequiredPermissions: []string{"users:read"}}, + }, + } + _ = config.processUnifiedConfig() + endpoints := config.Endpoints + endpoint, isPublic := matchEndpoint("POST", "/api/users", endpoints, config) + require.Nil(t, endpoint) + assert.False(t, isPublic) +} + +func TestMatchEndpoint_WildcardMethod(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api", Methods: []string{"*"}, RequiredPermissions: []string{"api:*"}}, + }, + } + _ = config.processUnifiedConfig() + endpoints := config.Endpoints + endpoint, isPublic := matchEndpoint("POST", "/api", endpoints, config) + require.NotNil(t, endpoint) + assert.False(t, isPublic) +} + +func TestMatchEndpoint_WildcardPath(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api/*", Methods: []string{"GET"}, RequiredPermissions: []string{"api:read"}}, + }, + } + _ = config.processUnifiedConfig() + endpoints := config.Endpoints + endpoint, isPublic := matchEndpoint("GET", "/api/users", endpoints, config) + require.NotNil(t, endpoint) + assert.False(t, isPublic) +} + +func TestMatchEndpoint_RegexPattern(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "^/api/users/\\d+$", Methods: []string{"GET"}, RequiredPermissions: []string{"users:read"}}, + }, + } + _ = config.processUnifiedConfig() + endpoints := config.Endpoints + endpoint, isPublic := matchEndpoint("GET", "/api/users/123", endpoints, config) + require.NotNil(t, endpoint) + assert.False(t, isPublic) +} + +func TestMatchEndpoint_NotFound(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api/users", Methods: []string{"GET"}, RequiredPermissions: []string{"users:read"}}, + }, + } + _ = config.processUnifiedConfig() + endpoints := config.Endpoints + endpoint, isPublic := matchEndpoint("GET", "/api/posts", endpoints, config) + require.Nil(t, endpoint) + assert.False(t, isPublic) +} + +func TestMatchEndpoint_EmptyMethods(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api", Methods: []string{}, RequiredPermissions: []string{"api:*"}}, + }, + } + _ = config.processUnifiedConfig() + endpoints := config.Endpoints + endpoint, isPublic := matchEndpoint("POST", "/api", endpoints, config) + require.NotNil(t, endpoint) + assert.False(t, isPublic) +} + +func TestMatchesHTTPMethod(t *testing.T) { + testCases := []struct { + desc string + method string + allowedMethods []string + expected bool + }{ + { + desc: "matches exact method", + method: "GET", + allowedMethods: []string{"GET"}, + expected: true, + }, + { + desc: "matches case-insensitive method", + method: "get", + allowedMethods: []string{"GET"}, + expected: true, + }, + { + desc: "matches wildcard method", + method: "POST", + allowedMethods: []string{"*"}, + expected: true, + }, + { + desc: "matches empty methods as all", + method: "DELETE", + allowedMethods: []string{}, + expected: true, + }, + { + desc: "does not match different method", + method: "POST", + allowedMethods: []string{"GET"}, + expected: false, + }, + { + desc: "matches one of multiple methods", + method: "PUT", + allowedMethods: []string{"GET", "PUT", "POST"}, + expected: true, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + result := matchesHTTPMethod(tc.method, tc.allowedMethods) + + assert.Equal(t, tc.expected, result, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestMatchesEndpointPattern(t *testing.T) { + testCases := []struct { + desc string + endpoint *EndpointMapping + route string + expected bool + }{ + { + desc: "matches exact path", + endpoint: &EndpointMapping{ + Path: "/api/users", + }, + route: "/api/users", + expected: true, + }, + { + desc: "matches regex pattern", + endpoint: &EndpointMapping{ + Path: "^/api/users/\\d+$", + }, + route: "/api/users/123", + expected: true, + }, + { + desc: "matches wildcard pattern", + endpoint: &EndpointMapping{ + Path: "/api/*", + }, + route: "/api/users", + expected: true, + }, + { + desc: "matches wildcard pattern with exact prefix", + endpoint: &EndpointMapping{ + Path: "/api/*", + }, + route: "/api", + expected: true, + }, + { + desc: "does not match different path", + endpoint: &EndpointMapping{ + Path: "/api/users", + }, + route: "/api/posts", + expected: false, + }, + { + desc: "does not match invalid regex", + endpoint: &EndpointMapping{ + Path: "[invalid", + }, + route: "/api/users", + expected: false, + }, + } + + config := &Config{} + _ = config.processUnifiedConfig() + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + result := matchesEndpointPattern(tc.endpoint, tc.route, config) + + assert.Equal(t, tc.expected, result, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestCheckEndpointAuthorization_PublicEndpoint(t *testing.T) { + config := &Config{ + Roles: []RoleDefinition{ + {Name: "any", Permissions: []string{}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + endpoint := &EndpointMapping{Public: true} + authorized, reason := checkEndpointAuthorization("any", endpoint, config) + assert.True(t, authorized) + assert.Equal(t, "public-endpoint", reason) +} + +func TestCheckEndpointAuthorization_ExactPermission(t *testing.T) { + config := &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"users:read"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + endpoint := &EndpointMapping{RequiredPermissions: []string{"users:read"}} + authorized, reason := checkEndpointAuthorization("admin", endpoint, config) + assert.True(t, authorized) + assert.Equal(t, "permission-based", reason) +} + +func TestCheckEndpointAuthorization_WildcardsNotSupported(t *testing.T) { + config := &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"*:*"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + endpoint := &EndpointMapping{RequiredPermissions: []string{"users:read"}} + authorized, reason := checkEndpointAuthorization("admin", endpoint, config) + assert.False(t, authorized) + assert.Empty(t, reason) +} + +func TestCheckEndpointAuthorization_ResourceWildcardNotSupported(t *testing.T) { + config := &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"users:*"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + endpoint := &EndpointMapping{RequiredPermissions: []string{"users:read"}} + authorized, reason := checkEndpointAuthorization("admin", endpoint, config) + assert.False(t, authorized) + assert.Empty(t, reason) +} + +func TestCheckEndpointAuthorization_NoPermission(t *testing.T) { + config := &Config{ + Roles: []RoleDefinition{ + {Name: "viewer", Permissions: []string{"users:read"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + endpoint := &EndpointMapping{RequiredPermissions: []string{"users:write"}} + authorized, reason := checkEndpointAuthorization("viewer", endpoint, config) + assert.False(t, authorized) + assert.Empty(t, reason) +} + +func TestCheckEndpointAuthorization_EmptyRequiredPermissions(t *testing.T) { + config := &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"*:*"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + endpoint := &EndpointMapping{RequiredPermissions: []string{}} + authorized, reason := checkEndpointAuthorization("admin", endpoint, config) + assert.False(t, authorized) + assert.Empty(t, reason) +} + +func TestCheckEndpointAuthorization_NoRolePermissions(t *testing.T) { + config := &Config{ + Roles: []RoleDefinition{ + {Name: "guest", Permissions: []string{}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + endpoint := &EndpointMapping{RequiredPermissions: []string{"users:read"}} + authorized, reason := checkEndpointAuthorization("guest", endpoint, config) + assert.False(t, authorized) + assert.Empty(t, reason) +} + +func TestCheckEndpointAuthorization_InheritedPermissions(t *testing.T) { + config := &Config{ + Roles: []RoleDefinition{ + {Name: "viewer", Permissions: []string{"users:read"}}, + {Name: "editor", Permissions: []string{"users:write"}, InheritsFrom: []string{"viewer"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + endpoint := &EndpointMapping{RequiredPermissions: []string{"users:read"}} + authorized, reason := checkEndpointAuthorization("editor", endpoint, config) + assert.True(t, authorized) + assert.Equal(t, "permission-based", reason) +} + +func TestCheckEndpointAuthorization_MultiplePermissions_OR_First(t *testing.T) { + config := &Config{ + Roles: []RoleDefinition{ + {Name: "viewer", Permissions: []string{"users:read"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + endpoint := &EndpointMapping{RequiredPermissions: []string{"users:read", "users:admin"}} + authorized, reason := checkEndpointAuthorization("viewer", endpoint, config) + assert.True(t, authorized) + assert.Equal(t, "permission-based", reason) +} + +func TestCheckEndpointAuthorization_MultiplePermissions_OR_Second(t *testing.T) { + config := &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"users:admin"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + endpoint := &EndpointMapping{RequiredPermissions: []string{"users:read", "users:admin"}} + authorized, reason := checkEndpointAuthorization("admin", endpoint, config) + assert.True(t, authorized) + assert.Equal(t, "permission-based", reason) +} + +func TestCheckEndpointAuthorization_MultiplePermissions_None(t *testing.T) { + config := &Config{ + Roles: []RoleDefinition{ + {Name: "guest", Permissions: []string{"posts:read"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + endpoint := &EndpointMapping{RequiredPermissions: []string{"users:read", "users:write"}} + authorized, reason := checkEndpointAuthorization("guest", endpoint, config) + assert.False(t, authorized) + assert.Empty(t, reason) +} + +func TestGetEndpointForRequest(t *testing.T) { + testCases := []struct { + desc string + request *http.Request + config *Config + expectedMatch bool + expectedPublic bool + }{ + { + desc: "matches endpoint for request", + request: httptest.NewRequest(http.MethodGet, "/api/users", http.NoBody), + config: &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api/users", Methods: []string{"GET"}, RequiredPermissions: []string{"users:read"}}, + }, + }, + expectedMatch: true, + expectedPublic: false, + }, + { + desc: "matches public endpoint", + request: httptest.NewRequest(http.MethodGet, "/health", http.NoBody), + config: &Config{ + Endpoints: []EndpointMapping{ + {Path: "/health", Methods: []string{"GET"}, Public: true}, + }, + }, + expectedMatch: true, + expectedPublic: true, + }, + { + desc: "returns nil for empty endpoints", + request: httptest.NewRequest(http.MethodGet, "/api/users", http.NoBody), + config: &Config{ + Endpoints: []EndpointMapping{}, + }, + expectedMatch: false, + expectedPublic: false, + }, + { + desc: "returns nil for non-matching request", + request: httptest.NewRequest(http.MethodPost, "/api/posts", http.NoBody), + config: &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api/users", Methods: []string{"GET"}, RequiredPermissions: []string{"users:read"}}, + }, + }, + expectedMatch: false, + expectedPublic: false, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + err := tc.config.processUnifiedConfig() + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + + endpoint, isPublic := getEndpointForRequest(tc.request, tc.config) + + if tc.expectedMatch { + require.NotNil(t, endpoint, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, tc.expectedPublic, isPublic, "TEST[%d], Failed.\n%s", i, tc.desc) + + return + } + + require.Nil(t, endpoint, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.False(t, isPublic, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} diff --git a/pkg/gofr/rbac/go.mod b/pkg/gofr/rbac/go.mod new file mode 100644 index 000000000..e0ca375d7 --- /dev/null +++ b/pkg/gofr/rbac/go.mod @@ -0,0 +1,102 @@ +module gofr.dev/pkg/gofr/rbac + +go 1.25 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/stretchr/testify v1.11.1 + gofr.dev v1.48.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + cloud.google.com/go v0.121.6 // indirect + cloud.google.com/go/auth v0.17.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.2 // indirect + cloud.google.com/go/pubsub v1.49.0 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect + github.com/XSAM/otelsql v0.40.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgraph-io/dgo/v210 v210.0.0-20230328113526-b66f8ae53a2d // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/openzipkin/zipkin-go v0.4.3 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.17.0 // indirect + github.com/redis/go-redis/extra/rediscmd/v9 v9.17.0 // indirect + github.com/redis/go-redis/extra/redisotel/v9 v9.17.0 // indirect + github.com/redis/go-redis/v9 v9.17.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/segmentio/kafka-go v0.4.49 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.60.0 // indirect + go.opentelemetry.io/otel/exporters/zipkin v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.uber.org/mock v0.6.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/api v0.256.0 // indirect + google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect + google.golang.org/grpc v1.77.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.40.1 // indirect +) diff --git a/pkg/gofr/rbac/go.sum b/pkg/gofr/rbac/go.sum new file mode 100644 index 000000000..73146e8c1 --- /dev/null +++ b/pkg/gofr/rbac/go.sum @@ -0,0 +1,380 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= +cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= +cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= +cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/kms v1.22.0 h1:dBRIj7+GDeeEvatJeTB19oYZNV0aj6wEqSIT/7gLqtk= +cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= +cloud.google.com/go/pubsub v1.49.0 h1:5054IkbslnrMCgA2MAEPcsN3Ky+AyMpEZcii/DoySPo= +cloud.google.com/go/pubsub v1.49.0/go.mod h1:K1FswTWP+C1tI/nfi3HQecoVeFvL4HUOB1tdaNXKhUY= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/XSAM/otelsql v0.40.0 h1:8jaiQ6KcoEXF46fBmPEqb+pp29w2xjWfuXjZXTXBjaA= +github.com/XSAM/otelsql v0.40.0/go.mod h1:/7F+1XKt3/sTlYtwKtkHQ5Gzoom+EerXmD1VdnTqfB4= +github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/dgo/v210 v210.0.0-20230328113526-b66f8ae53a2d h1:abDbP7XBVgwda+h0J5Qra5p2OQpidU2FdkXvzCKL+H8= +github.com/dgraph-io/dgo/v210 v210.0.0-20230328113526-b66f8ae53a2d/go.mod h1:wKFzULXAPj3U2BDAPWXhSbQQNC6FU1+1/5iika6IY7g= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= +github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/redis/go-redis/extra/rediscmd/v9 v9.17.0 h1:ZOh9XWr5CFKfLcxnboJv76e8IbZJUPk6vPqKi604PBg= +github.com/redis/go-redis/extra/rediscmd/v9 v9.17.0/go.mod h1:wUvaymPZe9f81/s7OfUP7yzZSkWldJZRtcxLFHZVQho= +github.com/redis/go-redis/extra/redisotel/v9 v9.17.0 h1:4THYns6jRztgNk3+qtthK/wDs7eAMjxNk8AZEygfIi8= +github.com/redis/go-redis/extra/redisotel/v9 v9.17.0/go.mod h1:ZGbqRWgfv2ze3EIWPe7gTp6YcKHiVk8QZzEA4nlmvys= +github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM= +github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk= +github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.einride.tech/aip v0.68.1 h1:16/AfSxcQISGN5z9C5lM+0mLYXihrHbQ1onvYTr93aQ= +go.einride.tech/aip v0.68.1/go.mod h1:XaFtaj4HuA3Zwk9xoBtTWgNubZ0ZZXv9BZJCkuKuWbg= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 h1:2pn7OzMewmYRiNtv1doZnLo3gONcnMHlFnmOR8Vgt+8= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0/go.mod h1:rjbQTDEPQymPE0YnRQp9/NuPwwtL0sesz/fnqRW/v84= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= +go.opentelemetry.io/otel/exporters/prometheus v0.60.0 h1:cGtQxGvZbnrWdC2GyjZi0PDKVSLWP/Jocix3QWfXtbo= +go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4OWFGZcKQiIqg0RfuWExcPKFvepk= +go.opentelemetry.io/otel/exporters/zipkin v1.38.0 h1:0rJ2TmzpHDG+Ib9gPmu3J3cE0zXirumQcKS4wCoZUa0= +go.opentelemetry.io/otel/exporters/zipkin v1.38.0/go.mod h1:Su/nq/K5zRjDKKC3Il0xbViE3juWgG3JDoqLumFx5G0= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +gofr.dev v1.48.0 h1:pm8S3zemYlVCOuxc+eDXskjZmh9wLNdLD+5ZN/Q2Erg= +gofr.dev v1.48.0/go.mod h1:vZBvtpICtT5b+YWdpolPPR+x2H9Ft1NIAi1FjOXrwKk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI= +google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= +google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= +modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/pkg/gofr/rbac/logger.go b/pkg/gofr/rbac/logger.go new file mode 100644 index 000000000..36b234355 --- /dev/null +++ b/pkg/gofr/rbac/logger.go @@ -0,0 +1,14 @@ +package rbac + +// Logger defines the logging interface for RBAC. +// It matches GoFr's logging.Logger interface methods used by RBAC. +type Logger interface { + Debug(args ...any) + Debugf(format string, args ...any) + Info(args ...any) + Infof(format string, args ...any) + Error(args ...any) + Errorf(format string, args ...any) + Warn(args ...any) + Warnf(format string, args ...any) +} diff --git a/pkg/gofr/rbac/metrics.go b/pkg/gofr/rbac/metrics.go new file mode 100644 index 000000000..55f67243e --- /dev/null +++ b/pkg/gofr/rbac/metrics.go @@ -0,0 +1,17 @@ +package rbac + +import "context" + +// Metrics defines methods for recording metrics that RBAC uses. +// It matches GoFr's container.Metrics interface. +type Metrics interface { + NewCounter(name, desc string) + NewUpDownCounter(name, desc string) + NewHistogram(name, desc string, buckets ...float64) + NewGauge(name, desc string) + + IncrementCounter(ctx context.Context, name string, labels ...string) + DeltaUpDownCounter(ctx context.Context, name string, value float64, labels ...string) + RecordHistogram(ctx context.Context, name string, value float64, labels ...string) + SetGauge(name string, value float64, labels ...string) +} diff --git a/pkg/gofr/rbac/middleware.go b/pkg/gofr/rbac/middleware.go new file mode 100644 index 000000000..ad75d942c --- /dev/null +++ b/pkg/gofr/rbac/middleware.go @@ -0,0 +1,415 @@ +package rbac + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "gofr.dev/pkg/gofr/http/middleware" +) + +type authMethod int + +const userRole authMethod = 4 + +var ( + // ErrAccessDenied is returned when a user doesn't have required role/permission. + ErrAccessDenied = errors.New("forbidden: access denied") + + // ErrRoleNotFound is returned when role cannot be extracted from request. + ErrRoleNotFound = errors.New("unauthorized: role not found") + + // errJWTClaimsNotFound is returned when JWT claims are not found in request context. + errJWTClaimsNotFound = errors.New("JWT claims not found in request context") + + // errEmptyClaimPath is returned when claim path is empty. + errEmptyClaimPath = errors.New("empty claim path") + + // errClaimPathNotFound is returned when a claim path is not found in JWT claims. + errClaimPathNotFound = errors.New("claim path not found") + + // errInvalidArrayNotation is returned when array notation is invalid. + errInvalidArrayNotation = errors.New("invalid array notation") + + // errInvalidArrayIndex is returned when array index is invalid. + errInvalidArrayIndex = errors.New("invalid array index") + + // errClaimKeyNotFound is returned when a claim key is not found. + errClaimKeyNotFound = errors.New("claim key not found") + + // errClaimValueNotArray is returned when a claim value is not an array. + errClaimValueNotArray = errors.New("claim value is not an array") + + // errArrayIndexOutOfBounds is returned when array index is out of bounds. + errArrayIndexOutOfBounds = errors.New("array index out of bounds") + + // errInvalidClaimStructure is returned when claim structure is invalid. + errInvalidClaimStructure = errors.New("invalid claim structure") +) + +// Middleware creates an HTTP middleware function that enforces RBAC authorization. +// It extracts the user's role and checks if the role is allowed for the requested route. +func Middleware(config *Config) func(handler http.Handler) http.Handler { + return func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // If config is nil, allow all requests (fail open) + if config == nil { + handler.ServeHTTP(w, r) + + return + } + + route := r.URL.Path + r = startTracing(r, config, route) + + // End span at the end of the middleware function (covers all code paths) + // If tracing was started, the span will be in the context + if config.Tracer != nil { + span := trace.SpanFromContext(r.Context()) + if span != nil { + defer span.End() + } + } + + // Check if endpoint is public using unified Endpoints config + endpoint, isPublic := getEndpointForRequest(r, config) + if isPublic { + handler.ServeHTTP(w, r) + + return + } + + // If no endpoint match found, deny by default (fail secure) + if endpoint == nil { + recordMetrics(config, r, "denied", "endpoint_not_found", "") + handleAuthError(w, r, config, "", route, ErrAccessDenied) + + return + } + + // Extract role using header-based or JWT-based extraction + role, err := extractRole(r, config) + if err != nil { + recordRoleExtractionFailure(config, r) + handleAuthError(w, r, config, "", route, err) + + return + } + + // Update span with role + updateSpanWithRole(config, r, role) + + // Check authorization using unified endpoint-based authorization + authorized, authReason := checkEndpointAuthorization(role, endpoint, config) + if !authorized { + recordMetrics(config, r, "denied", "", role) + handleAuthError(w, r, config, role, route, ErrAccessDenied) + + return + } + + recordMetrics(config, r, "allowed", "", role) + updateSpanWithAuthStatus(config, r) + logAuditEventIfEnabled(config, r, role, route, authReason) + + // Store role in context and continue + ctx := context.WithValue(r.Context(), userRole, role) + handler.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// startTracing starts tracing for the request if tracer is available. +// The span is stored in the context and should be ended at the end of the middleware function. +func startTracing(r *http.Request, config *Config, route string) *http.Request { + if config.Tracer == nil { + return r + } + + ctx, span := config.Tracer.Start(r.Context(), "rbac.authorize") + + span.SetAttributes( + attribute.String("http.method", r.Method), + attribute.String("http.route", route), + ) + + return r.WithContext(ctx) +} + +// recordMetrics records authorization decision metrics. +func recordMetrics(config *Config, r *http.Request, status, reason, role string) { + if config.Metrics == nil { + return + } + + labels := []string{"status", status} + if reason != "" { + labels = append(labels, "reason", reason) + } + + if role != "" { + labels = append(labels, "role", role) + } + + config.Metrics.IncrementCounter(r.Context(), "rbac_authorization_decisions", labels...) +} + +// recordRoleExtractionFailure records role extraction failure metrics. +func recordRoleExtractionFailure(config *Config, r *http.Request) { + if config.Metrics != nil { + config.Metrics.IncrementCounter(r.Context(), "rbac_role_extraction_failures") + } +} + +// updateSpanWithRole updates the span with the extracted role. +func updateSpanWithRole(config *Config, r *http.Request, role string) { + if config.Tracer != nil { + trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("rbac.role", role)) + } +} + +// updateSpanWithAuthStatus updates the span with authorization status. +func updateSpanWithAuthStatus(config *Config, r *http.Request) { + if config.Tracer != nil { + trace.SpanFromContext(r.Context()).SetAttributes(attribute.Bool("rbac.authorized", true)) + } +} + +// logAuditEventIfEnabled logs audit event if logger is available. +func logAuditEventIfEnabled(config *Config, r *http.Request, role, route, authReason string) { + if config.Logger != nil { + logAuditEvent(config.Logger, r, role, route, true, authReason) + } +} + +// handleAuthError handles authorization errors with custom error handler or default response. +func handleAuthError(w http.ResponseWriter, r *http.Request, config *Config, role, route string, err error) { + // Record error in span if tracing is enabled + if config.Tracer != nil { + span := trace.SpanFromContext(r.Context()) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + // Log audit event (always enabled when Logger is available) + // Audit logging is automatically performed using GoFr's logger + if config.Logger != nil { + reason := "access denied" + + if errors.Is(err, ErrRoleNotFound) { + reason = "role not found" + } + + logAuditEvent(config.Logger, r, role, route, false, reason) + } + + // Use custom error handler if provided + if config.ErrorHandler != nil { + config.ErrorHandler(w, r, role, route, err) + return + } + + // Default error handling + if errors.Is(err, ErrRoleNotFound) { + http.Error(w, "Unauthorized: Missing or invalid role", http.StatusUnauthorized) + return + } + + http.Error(w, "Forbidden: Access denied", http.StatusForbidden) +} + +// extractRole extracts the user's role from the request. +// Supports header-based extraction (via RoleHeader) or JWT-based extraction (via JWTClaimPath). +// Precedence: JWT takes precedence over header (JWT is more secure). +// No default role is supported - role must be explicitly provided. +func extractRole(r *http.Request, config *Config) (string, error) { + // Try JWT-based extraction first (takes precedence - more secure) + if config.JWTClaimPath != "" { + role, err := extractRoleFromJWT(r, config.JWTClaimPath) + if err == nil && role != "" { + return role, nil + } + // If JWT extraction fails but JWTClaimPath is set, don't fall back to header + // This ensures JWT is the only method when configured + return "", ErrRoleNotFound + } + + // Try header-based extraction (only if JWT is not configured) + if config.RoleHeader != "" { + role := r.Header.Get(config.RoleHeader) + if role != "" { + return role, nil + } + } + + // No role found - no default role supported + return "", ErrRoleNotFound +} + +// extractRoleFromJWT extracts the role from JWT claims in the request context. +// It uses the JWTClaimPath from config to navigate the claim structure. +func extractRoleFromJWT(r *http.Request, claimPath string) (string, error) { + // Get JWT claims from context (set by OAuth middleware) + claims, ok := r.Context().Value(middleware.JWTClaim).(jwt.MapClaims) + if !ok || claims == nil { + return "", fmt.Errorf("%w", errJWTClaimsNotFound) + } + + // Extract role using the configured claim path + role, err := extractClaimValue(claims, claimPath) + if err != nil { + return "", fmt.Errorf("failed to extract role from JWT: %w", err) + } + + // Convert to string + roleStr, ok := role.(string) + if !ok { + // Try to convert if it's not a string + return fmt.Sprintf("%v", role), nil + } + + return roleStr, nil +} + +// extractClaimValue extracts a value from JWT claims using a dot-notation or array notation path. +// Examples: +// - "role" -> claims["role"] +// - "roles[0]" -> claims["roles"].([]any)[0] +// - "permissions.role" -> claims["permissions"].(map[string]any)["role"] +func extractClaimValue(claims jwt.MapClaims, path string) (any, error) { + if path == "" { + return nil, fmt.Errorf("%w", errEmptyClaimPath) + } + + // Handle array notation: "roles[0]" + if idx := strings.Index(path, "["); idx != -1 { + return extractArrayClaim(claims, path, idx) + } + + // Handle dot notation: "permissions.role" + if strings.Contains(path, ".") { + return extractNestedClaim(claims, path) + } + + // Simple key lookup + value, ok := claims[path] + if !ok { + return nil, fmt.Errorf("%w: %s", errClaimPathNotFound, path) + } + + return value, nil +} + +// extractArrayClaim extracts a value from an array in JWT claims. +func extractArrayClaim(claims jwt.MapClaims, path string, idx int) (any, error) { + key := path[:idx] + arrayPath := path[idx:] + + // Extract array index + if !strings.HasPrefix(arrayPath, "[") || !strings.HasSuffix(arrayPath, "]") { + return nil, fmt.Errorf("%w: %s", errInvalidArrayNotation, path) + } + + indexStr := strings.Trim(arrayPath, "[]") + + var index int + if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil { + return nil, fmt.Errorf("%w: %s", errInvalidArrayIndex, indexStr) + } + + value, ok := claims[key] + if !ok { + return nil, fmt.Errorf("%w: %s", errClaimKeyNotFound, key) + } + + arr, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("%w: %s", errClaimValueNotArray, key) + } + + if index < 0 || index >= len(arr) { + return nil, fmt.Errorf("%w: %d (length: %d)", errArrayIndexOutOfBounds, index, len(arr)) + } + + return arr[index], nil +} + +// extractNestedClaim extracts a value from nested structure in JWT claims. +func extractNestedClaim(claims jwt.MapClaims, path string) (any, error) { + parts := strings.Split(path, ".") + + var current any = claims + + for i, part := range parts { + isLast := i == len(parts)-1 + if isLast { + return extractFinalClaimValue(current, part, path, parts, i) + } + + // Navigate through nested structure + current = navigateNestedClaim(current, part) + if current == nil { + return nil, fmt.Errorf("%w: %s", errClaimPathNotFound, strings.Join(parts[:i+1], ".")) + } + } + + return nil, fmt.Errorf("%w: %s", errClaimPathNotFound, path) +} + +// extractFinalClaimValue extracts the final value from a claim path. +func extractFinalClaimValue(current any, part, path string, parts []string, i int) (any, error) { + if m, ok := current.(map[string]any); ok { + value, exists := m[part] + if !exists { + return nil, fmt.Errorf("%w: %s", errClaimPathNotFound, path) + } + + return value, nil + } + + if m, ok := current.(jwt.MapClaims); ok { + value, exists := m[part] + if !exists { + return nil, fmt.Errorf("%w: %s", errClaimPathNotFound, path) + } + + return value, nil + } + + return nil, fmt.Errorf("%w: %s", errInvalidClaimStructure, strings.Join(parts[:i+1], ".")) +} + +// navigateNestedClaim navigates through nested claim structures. +func navigateNestedClaim(current any, part string) any { + switch v := current.(type) { + case map[string]any: + return v[part] + case jwt.MapClaims: + return v[part] + default: + return nil + } +} + +// logAuditEvent logs authorization decisions for audit purposes. +// This is called automatically by the middleware when Logger is set. +// Users don't need to configure this - it uses the provided logger automatically. +func logAuditEvent(logger Logger, r *http.Request, role, route string, allowed bool, reason string) { + if logger == nil { + return // Skip logging if no logger provided + } + + status := "denied" + if allowed { + status = "allowed" + } + + logger.Infof("[RBAC Audit] %s %s - Role: %s - Route: %s - %s - Reason: %s", + r.Method, r.URL.Path, role, route, status, reason) +} diff --git a/pkg/gofr/rbac/middleware_test.go b/pkg/gofr/rbac/middleware_test.go new file mode 100644 index 000000000..8502e1784 --- /dev/null +++ b/pkg/gofr/rbac/middleware_test.go @@ -0,0 +1,850 @@ +package rbac + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gofr.dev/pkg/gofr/http/middleware" + "gofr.dev/pkg/gofr/logging" +) + +func TestMiddleware_NilConfig(t *testing.T) { + middlewareFunc := Middleware(nil) + require.NotNil(t, middlewareFunc) + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + + wrapped := middlewareFunc(handler) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api", http.NoBody) + wrapped.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "OK") +} + +func TestMiddleware_PublicEndpoint(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/health", Methods: []string{"GET"}, Public: true}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + middlewareFunc := Middleware(config) + require.NotNil(t, middlewareFunc) + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + + wrapped := middlewareFunc(handler) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/health", http.NoBody) + wrapped.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "OK") +} + +func TestMiddleware_NoEndpointMatch(t *testing.T) { + config := &Config{ + Endpoints: []EndpointMapping{ + {Path: "/api/users", Methods: []string{"GET"}, RequiredPermissions: []string{"users:read"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + middlewareFunc := Middleware(config) + require.NotNil(t, middlewareFunc) + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + + wrapped := middlewareFunc(handler) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/posts", http.NoBody) + wrapped.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Forbidden: Access denied") +} + +func TestMiddleware_RoleNotFound(t *testing.T) { + config := &Config{ + RoleHeader: "X-User-Role", + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"admin:read", "admin:write"}}, + }, + Endpoints: []EndpointMapping{ + {Path: "/api", Methods: []string{"GET"}, RequiredPermissions: []string{"admin:read"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + middlewareFunc := Middleware(config) + require.NotNil(t, middlewareFunc) + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + + wrapped := middlewareFunc(handler) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api", http.NoBody) + wrapped.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "Unauthorized: Missing or invalid role") +} + +func TestMiddleware_ValidRoleAndPermission(t *testing.T) { + config := &Config{ + RoleHeader: "X-User-Role", + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"admin:read", "admin:write"}}, + }, + Endpoints: []EndpointMapping{ + {Path: "/api", Methods: []string{"GET"}, RequiredPermissions: []string{"admin:read"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + middlewareFunc := Middleware(config) + require.NotNil(t, middlewareFunc) + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + + wrapped := middlewareFunc(handler) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api", http.NoBody) + req.Header.Set("X-User-Role", "admin") + wrapped.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "OK") +} + +func TestMiddleware_InvalidPermission(t *testing.T) { + config := &Config{ + RoleHeader: "X-User-Role", + Roles: []RoleDefinition{ + {Name: "viewer", Permissions: []string{"users:read"}}, + }, + Endpoints: []EndpointMapping{ + {Path: "/api", Methods: []string{"GET"}, RequiredPermissions: []string{"users:write"}}, + }, + } + err := config.processUnifiedConfig() + require.NoError(t, err) + + middlewareFunc := Middleware(config) + require.NotNil(t, middlewareFunc) + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + }) + + wrapped := middlewareFunc(handler) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api", http.NoBody) + req.Header.Set("X-User-Role", "viewer") + wrapped.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Forbidden: Access denied") +} + +func TestExtractRole(t *testing.T) { + testCases := []struct { + desc string + config *Config + request *http.Request + expectedRole string + expectError bool + }{ + { + desc: "extracts role from header", + config: &Config{ + RoleHeader: "X-User-Role", + }, + request: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/api", http.NoBody) + req.Header.Set("X-User-Role", "admin") + return req + }(), + expectedRole: "admin", + expectError: false, + }, + { + desc: "extracts role from JWT when both configured", + config: &Config{ + RoleHeader: "X-User-Role", + JWTClaimPath: "role", + }, + request: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/api", http.NoBody) + req.Header.Set("X-User-Role", "viewer") + claims := jwt.MapClaims{"role": "admin"} + ctx := context.WithValue(req.Context(), middleware.JWTClaim, claims) + return req.WithContext(ctx) + }(), + expectedRole: "admin", + expectError: false, + }, + { + desc: "returns error when JWT configured but claims not found", + config: &Config{ + JWTClaimPath: "role", + }, + request: httptest.NewRequest(http.MethodGet, "/api", http.NoBody), + expectedRole: "", + expectError: true, + }, + { + desc: "returns error when header configured but not present", + config: &Config{ + RoleHeader: "X-User-Role", + }, + request: httptest.NewRequest(http.MethodGet, "/api", http.NoBody), + expectedRole: "", + expectError: true, + }, + { + desc: "returns error when no role extraction configured", + config: &Config{}, + request: httptest.NewRequest(http.MethodGet, "/api", http.NoBody), + expectedRole: "", + expectError: true, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + role, err := extractRole(tc.request, tc.config) + + if tc.expectError { + require.Error(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Empty(t, role, "TEST[%d], Failed.\n%s", i, tc.desc) + + return + } + + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, tc.expectedRole, role, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestExtractRoleFromJWT(t *testing.T) { + testCases := []struct { + desc string + claimPath string + claims jwt.MapClaims + expectedRole string + expectError bool + }{ + { + desc: "extracts role from simple claim", + claimPath: "role", + claims: jwt.MapClaims{ + "role": "admin", + }, + expectedRole: "admin", + expectError: false, + }, + { + desc: "extracts role from array claim", + claimPath: "roles[0]", + claims: jwt.MapClaims{ + "roles": []any{"admin", "user"}, + }, + expectedRole: "admin", + expectError: false, + }, + { + desc: "extracts role from nested claim", + claimPath: "permissions.role", + claims: jwt.MapClaims{ + "permissions": map[string]any{ + "role": "admin", + }, + }, + expectedRole: "admin", + expectError: false, + }, + { + desc: "returns error when claims not in context", + claimPath: "role", + claims: nil, + expectedRole: "", + expectError: true, + }, + { + desc: "returns error when claim path not found", + claimPath: "nonexistent", + claims: jwt.MapClaims{ + "role": "admin", + }, + expectedRole: "", + expectError: true, + }, + { + desc: "converts non-string role to string", + claimPath: "role", + claims: jwt.MapClaims{ + "role": 123, + }, + expectedRole: "123", + expectError: false, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api", http.NoBody) + + if tc.claims != nil { + ctx := context.WithValue(req.Context(), middleware.JWTClaim, tc.claims) + req = req.WithContext(ctx) + } + + role, err := extractRoleFromJWT(req, tc.claimPath) + + if tc.expectError { + require.Error(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Empty(t, role, "TEST[%d], Failed.\n%s", i, tc.desc) + + return + } + + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, tc.expectedRole, role, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestExtractClaimValue(t *testing.T) { + testCases := []struct { + desc string + claims jwt.MapClaims + path string + expected any + expectError bool + }{ + { + desc: "extracts simple claim", + claims: jwt.MapClaims{ + "role": "admin", + }, + path: "role", + expected: "admin", + expectError: false, + }, + { + desc: "extracts array claim", + claims: jwt.MapClaims{ + "roles": []any{"admin", "user"}, + }, + path: "roles[0]", + expected: "admin", + expectError: false, + }, + { + desc: "extracts nested claim", + claims: jwt.MapClaims{ + "permissions": map[string]any{ + "role": "admin", + }, + }, + path: "permissions.role", + expected: "admin", + expectError: false, + }, + { + desc: "returns error for empty path", + claims: jwt.MapClaims{ + "role": "admin", + }, + path: "", + expected: nil, + expectError: true, + }, + { + desc: "returns error for non-existent claim", + claims: jwt.MapClaims{ + "role": "admin", + }, + path: "nonexistent", + expected: nil, + expectError: true, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + result, err := extractClaimValue(tc.claims, tc.path) + + if tc.expectError { + require.Error(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Nil(t, result, "TEST[%d], Failed.\n%s", i, tc.desc) + + return + } + + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, tc.expected, result, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestExtractArrayClaim_Basic(t *testing.T) { + testCases := []struct { + desc string + claims jwt.MapClaims + path string + expected any + expectError bool + }{ + { + desc: "extracts first element from array", + claims: jwt.MapClaims{ + "roles": []any{"admin", "user"}, + }, + path: "roles[0]", + expected: "admin", + expectError: false, + }, + { + desc: "extracts second element from array", + claims: jwt.MapClaims{ + "roles": []any{"admin", "user"}, + }, + path: "roles[1]", + expected: "user", + expectError: false, + }, + { + desc: "handles array with mixed types", + claims: jwt.MapClaims{ + "roles": []any{123, "admin", true}, + }, + path: "roles[0]", + expected: 123, + expectError: false, + }, + } + + runExtractArrayClaimTests(t, testCases) +} + +func TestExtractArrayClaim_Errors(t *testing.T) { + testCases := []struct { + desc string + claims jwt.MapClaims + path string + expected any + expectError bool + }{ + { + desc: "returns error for invalid array notation", + claims: jwt.MapClaims{ + "roles": []any{"admin"}, + }, + path: "roles[", + expected: nil, + expectError: true, + }, + { + desc: "returns error for non-existent key", + claims: jwt.MapClaims{ + "other": []any{"value"}, + }, + path: "roles[0]", + expected: nil, + expectError: true, + }, + { + desc: "returns error when value is not array", + claims: jwt.MapClaims{ + "roles": "not an array", + }, + path: "roles[0]", + expected: nil, + expectError: true, + }, + { + desc: "returns error for out of bounds index", + claims: jwt.MapClaims{ + "roles": []any{"admin"}, + }, + path: "roles[5]", + expected: nil, + expectError: true, + }, + { + desc: "returns error for negative index", + claims: jwt.MapClaims{ + "roles": []any{"admin"}, + }, + path: "roles[-1]", + expected: nil, + expectError: true, + }, + { + desc: "handles empty array", + claims: jwt.MapClaims{ + "roles": []any{}, + }, + path: "roles[0]", + expected: nil, + expectError: true, + }, + { + desc: "handles nil value in claims", + claims: jwt.MapClaims{ + "roles": nil, + }, + path: "roles[0]", + expected: nil, + expectError: true, + }, + } + + runExtractArrayClaimTests(t, testCases) +} + +func TestExtractArrayClaim_EdgeCases(t *testing.T) { + testCases := []struct { + desc string + claims jwt.MapClaims + path string + expected any + expectError bool + }{ + { + desc: "handles array with nil elements", + claims: jwt.MapClaims{ + "roles": []any{nil, "admin"}, + }, + path: "roles[0]", + expected: nil, + expectError: false, + }, + } + + runExtractArrayClaimTests(t, testCases) +} + +func runExtractArrayClaimTests(t *testing.T, testCases []struct { + desc string + claims jwt.MapClaims + path string + expected any + expectError bool +}) { + t.Helper() + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + idx := 0 + + for j, c := range tc.path { + if c == '[' { + idx = j + break + } + } + + result, err := extractArrayClaim(tc.claims, tc.path, idx) + + if tc.expectError { + require.Error(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Nil(t, result, "TEST[%d], Failed.\n%s", i, tc.desc) + + return + } + + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, tc.expected, result, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestExtractNestedClaim_Basic(t *testing.T) { + testCases := []struct { + desc string + claims jwt.MapClaims + path string + expected any + expectError bool + }{ + { + desc: "extracts nested claim", + claims: jwt.MapClaims{ + "permissions": map[string]any{ + "role": "admin", + }, + }, + path: "permissions.role", + expected: "admin", + expectError: false, + }, + { + desc: "extracts deeply nested claim", + claims: jwt.MapClaims{ + "user": map[string]any{ + "profile": map[string]any{ + "role": "admin", + }, + }, + }, + path: "user.profile.role", + expected: "admin", + expectError: false, + }, + { + desc: "handles array in nested structure", + claims: jwt.MapClaims{ + "data": map[string]any{ + "roles": []any{"admin", "user"}, + }, + }, + path: "data.roles", + expected: []any{"admin", "user"}, + expectError: false, + }, + } + + runExtractNestedClaimTests(t, testCases) +} + +func TestExtractNestedClaim_Errors(t *testing.T) { + testCases := []struct { + desc string + claims jwt.MapClaims + path string + expected any + expectError bool + }{ + { + desc: "returns error for non-existent path", + claims: jwt.MapClaims{ + "permissions": map[string]any{ + "role": "admin", + }, + }, + path: "permissions.nonexistent", + expected: nil, + expectError: true, + }, + { + desc: "returns error for invalid structure", + claims: jwt.MapClaims{ + "permissions": "not a map", + }, + path: "permissions.role", + expected: nil, + expectError: true, + }, + { + desc: "returns error when intermediate path is nil", + claims: jwt.MapClaims{ + "permissions": nil, + }, + path: "permissions.role", + expected: nil, + expectError: true, + }, + { + desc: "handles empty nested map", + claims: jwt.MapClaims{ + "permissions": map[string]any{}, + }, + path: "permissions.role", + expected: nil, + expectError: true, + }, + { + desc: "handles deeply nested nil intermediate value", + claims: jwt.MapClaims{ + "user": map[string]any{ + "profile": nil, + }, + }, + path: "user.profile.role", + expected: nil, + expectError: true, + }, + } + + runExtractNestedClaimTests(t, testCases) +} + +func TestExtractNestedClaim_EdgeCases(t *testing.T) { + testCases := []struct { + desc string + claims jwt.MapClaims + path string + expected any + expectError bool + }{ + { + desc: "handles nil value in nested map", + claims: jwt.MapClaims{ + "permissions": map[string]any{ + "role": nil, + }, + }, + path: "permissions.role", + expected: nil, + expectError: false, + }, + } + + runExtractNestedClaimTests(t, testCases) +} + +func runExtractNestedClaimTests(t *testing.T, testCases []struct { + desc string + claims jwt.MapClaims + path string + expected any + expectError bool +}) { + t.Helper() + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + result, err := extractNestedClaim(tc.claims, tc.path) + + if tc.expectError { + require.Error(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Nil(t, result, "TEST[%d], Failed.\n%s", i, tc.desc) + + return + } + + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, tc.expected, result, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestLogAuditEvent(t *testing.T) { + testCases := []struct { + desc string + logger logging.Logger + allowed bool + expected int + }{ + { + desc: "logs allowed event", + logger: &mockLogger{logs: []string{}}, + allowed: true, + expected: 1, + }, + { + desc: "logs denied event", + logger: &mockLogger{logs: []string{}}, + allowed: false, + expected: 1, + }, + { + desc: "does not log when logger is nil", + logger: nil, + allowed: true, + expected: 0, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api", http.NoBody) + logAuditEvent(tc.logger, req, "admin", "/api", tc.allowed, "test-reason") + + if tc.logger != nil { + mockLog := tc.logger.(*mockLogger) + assert.GreaterOrEqual(t, len(mockLog.logs), tc.expected, "TEST[%d], Failed.\n%s", i, tc.desc) + } + }) + } +} + +func TestHandleAuthError(t *testing.T) { + testCases := []struct { + desc string + config *Config + err error + expectedStatus int + expectedBody string + customHandler bool + }{ + { + desc: "handles ErrRoleNotFound with default handler", + config: &Config{ + Logger: &mockLogger{logs: []string{}}, + }, + err: ErrRoleNotFound, + expectedStatus: http.StatusUnauthorized, + expectedBody: "Unauthorized: Missing or invalid role", + customHandler: false, + }, + { + desc: "handles ErrAccessDenied with default handler", + config: &Config{ + Logger: &mockLogger{logs: []string{}}, + }, + err: ErrAccessDenied, + expectedStatus: http.StatusForbidden, + expectedBody: "Forbidden: Access denied", + customHandler: false, + }, + { + desc: "uses custom error handler when provided", + config: &Config{ + Logger: &mockLogger{logs: []string{}}, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _, _ string, _ error) { + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte("Custom error")) + }, + }, + err: ErrAccessDenied, + expectedStatus: http.StatusTeapot, + expectedBody: "Custom error", + customHandler: true, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api", http.NoBody) + + handleAuthError(w, req, tc.config, "admin", "/api", tc.err) + + assert.Equal(t, tc.expectedStatus, w.Code, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Contains(t, w.Body.String(), tc.expectedBody, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} diff --git a/pkg/gofr/rbac/provider.go b/pkg/gofr/rbac/provider.go new file mode 100644 index 000000000..f1d0b0f91 --- /dev/null +++ b/pkg/gofr/rbac/provider.go @@ -0,0 +1,152 @@ +package rbac + +import ( + "errors" + "net/http" + "os" + + "go.opentelemetry.io/otel/trace" +) + +var ( + // ErrConfigPathNotSet is returned when config path is not set. + ErrConfigPathNotSet = errors.New("config path not set") +) + +const ( + // Default RBAC config paths (tried in order). + defaultRBACJSONPath = "configs/rbac.json" + defaultRBACYAMLPath = "configs/rbac.yaml" + defaultRBACYMLPath = "configs/rbac.yml" +) + +// Provider is the RBAC provider implementation. +// Provider implements gofr.RBACProvider interface. +type Provider struct { + configPath string // Store the config file path + config *Config // Store the loaded config + logger Logger // Store the logger (set via UseLogger) + metrics Metrics // Store the metrics (set via UseMetrics) + tracer trace.Tracer // Store the tracer (set via UseTracer) +} + +// NewProvider creates a new RBAC provider with the config file path. +// If configPath is empty, it will try default paths: configs/rbac.json, configs/rbac.yaml, configs/rbac.yml +// +// Example: +// +// provider := rbac.NewProvider("configs/rbac.json") +// app.EnableRBAC(provider) +func NewProvider(configPath string) *Provider { + // If empty, resolve default paths + if configPath == "" { + configPath = resolveRBACConfigPath("") + } + + return &Provider{ + configPath: configPath, + } +} + +// resolveRBACConfigPath resolves the RBAC config file path. +func resolveRBACConfigPath(configFile string) string { + // If custom path provided, use it + if configFile != "" { + return configFile + } + + // Try default paths in order + defaultPaths := []string{ + defaultRBACJSONPath, + defaultRBACYAMLPath, + defaultRBACYMLPath, + } + + for _, path := range defaultPaths { + if _, err := os.Stat(path); err == nil { + return path + } + } + + return "" +} + +// UseLogger sets the logger for the provider which asserts the Logger interface. +// This is called automatically by EnableRBAC - users don't need to configure this. +func (p *Provider) UseLogger(logger any) { + if l, ok := logger.(Logger); ok { + p.logger = l + } +} + +// UseMetrics sets the metrics for the provider which asserts the Metrics interface. +// This is called automatically by EnableRBAC - users don't need to configure this. +func (p *Provider) UseMetrics(metrics any) { + if m, ok := metrics.(Metrics); ok { + p.metrics = m + p.registerMetrics() + } +} + +func (p *Provider) registerMetrics() { + buckets := []float64{0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1} + p.metrics.NewHistogram( + "rbac_authorization_duration", + "Duration of RBAC authorization checks", + buckets..., + ) + p.metrics.NewCounter("rbac_authorization_decisions", "Number of RBAC authorization decisions") + p.metrics.NewCounter("rbac_role_extraction_failures", "Number of failed role extractions") +} + +// UseTracer sets the tracer for the provider. +// This is called automatically by EnableRBAC - users don't need to configure this. +func (p *Provider) UseTracer(tracer any) { + if t, ok := tracer.(trace.Tracer); ok { + p.tracer = t + } +} + +// LoadPermissions loads RBAC configuration from the stored config path and stores it in the provider. +func (p *Provider) LoadPermissions() error { + if p.configPath == "" { + return ErrConfigPathNotSet + } + + config, err := LoadPermissions(p.configPath) + if err != nil { + return err + } + + p.config = config + + // Set logger on config if available (automatic audit logging) + if p.logger != nil { + config.Logger = p.logger + } + + // Set metrics on config if available + if p.metrics != nil { + config.Metrics = p.metrics + } + + // Set tracer on config if available + if p.tracer != nil { + config.Tracer = p.tracer + } + + return nil +} + +// RBACMiddleware returns the middleware function using the stored config. +// All authorization is handled via unified config (Roles and Endpoints). +func (p *Provider) RBACMiddleware() func(http.Handler) http.Handler { + if p.config == nil { + // If config is not loaded, return passthrough middleware + return func(handler http.Handler) http.Handler { + return handler + } + } + + return Middleware(p.config) +} diff --git a/pkg/gofr/rbac/provider_test.go b/pkg/gofr/rbac/provider_test.go new file mode 100644 index 000000000..681bf00ee --- /dev/null +++ b/pkg/gofr/rbac/provider_test.go @@ -0,0 +1,401 @@ +package rbac + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gofr.dev/pkg/gofr/logging" +) + +func TestNewProvider(t *testing.T) { + testCases := []struct { + desc string + configPath string + expected *Provider + }{ + { + desc: "creates new provider with config path", + configPath: "configs/rbac.json", + expected: &Provider{configPath: "configs/rbac.json"}, + }, + { + desc: "creates new provider with empty path", + configPath: "", + expected: &Provider{configPath: ""}, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + result := NewProvider(tc.configPath) + + require.NotNil(t, result, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, tc.expected.configPath, result.configPath, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestProvider_UseLogger(t *testing.T) { + testCases := []struct { + desc string + logger any + valid bool + }{ + { + desc: "sets logger", + logger: &mockLogger{}, + valid: true, + }, + { + desc: "sets nil logger", + logger: nil, + valid: false, + }, + { + desc: "sets invalid logger type", + logger: "invalid", + valid: false, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + p := NewProvider("configs/rbac.json") + p.UseLogger(tc.logger) + + if tc.valid { + assert.NotNil(t, p.logger, "TEST[%d], Failed.\n%s", i, tc.desc) + } else { + assert.Nil(t, p.logger, "TEST[%d], Failed.\n%s", i, tc.desc) + } + }) + } +} + +func TestProvider_LoadPermissions(t *testing.T) { + testCases := []struct { + desc string + fileContent string + fileName string + expectError bool + expectConfig bool + }{ + { + desc: "loads valid json config", + fileContent: `{ + "roles": [{"name": "admin", "permissions": ["admin:read", "admin:write"]}], + "endpoints": [{"path": "/api", "methods": ["GET"], "requiredPermissions": ["admin:read"]}] + }`, + fileName: "test_load.json", + expectError: false, + expectConfig: true, + }, + { + desc: "loads valid yaml config", + fileContent: `roles: + - name: admin + permissions: ["admin:read", "admin:write"] +endpoints: + - path: /api + methods: ["GET"] + requiredPermissions: ["admin:read"]`, + fileName: "test_load.yaml", + expectError: false, + expectConfig: true, + }, + { + desc: "returns error for non-existent file", + fileContent: "", + fileName: "nonexistent.json", + expectError: true, + expectConfig: false, + }, + { + desc: "returns error for invalid json", + fileContent: `invalid json{`, + fileName: "test_invalid.json", + expectError: true, + expectConfig: false, + }, + { + desc: "returns error for invalid yaml", + fileContent: `invalid: yaml: [`, + fileName: "test_invalid.yaml", + expectError: true, + expectConfig: false, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + var filePath string + + if tc.fileContent != "" { + path, err := createTestFile(tc.fileName, tc.fileContent) + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + + filePath = path + defer os.Remove(filePath) + } else { + filePath = tc.fileName + } + + p := NewProvider(filePath) + + err := p.LoadPermissions() + + if tc.expectError { + require.Error(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + require.Nil(t, p.config, "TEST[%d], Failed.\n%s", i, tc.desc) + + return + } + + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + require.NotNil(t, p.config, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.NotNil(t, p.config, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestProvider_LoadPermissions_WithLogger(t *testing.T) { + testCases := []struct { + desc string + logger logging.Logger + }{ + { + desc: "sets logger on config when logger provided", + logger: &mockLogger{}, + }, + { + desc: "does not set logger when nil", + logger: nil, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + fileContent := `{ + "roles": [{"name": "admin", "permissions": ["admin:read", "admin:write"]}], + "endpoints": [{"path": "/api", "methods": ["GET"], "requiredPermissions": ["admin:read"]}] + }` + + path, err := createTestFile("test_logger.json", fileContent) + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + + defer os.Remove(path) + + p := NewProvider("test_logger.json") + p.UseLogger(tc.logger) + + err = p.LoadPermissions() + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + + require.NotNil(t, p.config, "TEST[%d], Failed.\n%s", i, tc.desc) + assert.Equal(t, tc.logger, p.config.Logger, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func TestProvider_UseMetrics(t *testing.T) { + testCases := []struct { + desc string + metrics any + valid bool + }{ + { + desc: "sets valid metrics", + metrics: &mockMetrics{}, + valid: true, + }, + { + desc: "does not set invalid metrics", + metrics: map[string]int{"test": 1}, + valid: false, + }, + { + desc: "sets nil metrics", + metrics: nil, + valid: false, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + p := NewProvider("configs/rbac.json") + p.UseMetrics(tc.metrics) + + if tc.valid { + assert.Equal(t, tc.metrics, p.metrics, "TEST[%d], Failed.\n%s", i, tc.desc) + // Check if metrics were registered + m, ok := tc.metrics.(*mockMetrics) + require.True(t, ok) + assert.True(t, m.histogramCreated, "NewHistogram should be called") + assert.True(t, m.counterCreated, "NewCounter should be called") + } else { + assert.Nil(t, p.metrics, "TEST[%d], Failed.\n%s", i, tc.desc) + } + }) + } +} + +func TestProvider_UseTracer(t *testing.T) { + testCases := []struct { + desc string + tracer any + valid bool + }{ + { + desc: "sets valid tracer", + tracer: nil, // In real usage, this would be trace.Tracer + valid: false, + }, + { + desc: "sets invalid tracer type", + tracer: "invalid", + valid: false, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + p := NewProvider("configs/rbac.json") + p.UseTracer(tc.tracer) + + // Tracer is only set if it's a valid trace.Tracer type + if tc.valid { + assert.NotNil(t, p.tracer, "TEST[%d], Failed.\n%s", i, tc.desc) + } else { + // For invalid types, tracer should remain nil + assert.Nil(t, p.tracer, "TEST[%d], Failed.\n%s", i, tc.desc) + } + }) + } +} + +func TestProvider_RBACMiddleware(t *testing.T) { + testCases := []struct { + desc string + setupConfig func() *Config + expectPassthrough bool + }{ + { + desc: "returns middleware for valid config", + setupConfig: func() *Config { + config := &Config{ + Roles: []RoleDefinition{ + {Name: "admin", Permissions: []string{"*:*"}}, + }, + Endpoints: []EndpointMapping{ + {Path: "/api", Methods: []string{"GET"}, RequiredPermissions: []string{"admin:read", "admin:write"}}, + }, + } + _ = config.processUnifiedConfig() + return config + }, + expectPassthrough: false, + }, + { + desc: "returns passthrough for nil config", + setupConfig: func() *Config { + return nil + }, + expectPassthrough: true, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + p := NewProvider("configs/rbac.json") + p.config = tc.setupConfig() + + middlewareFunc := p.RBACMiddleware() + + require.NotNil(t, middlewareFunc, "TEST[%d], Failed.\n%s", i, tc.desc) + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + wrapped := middlewareFunc(handler) + + require.NotNil(t, wrapped, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +} + +func createTestFile(filename, content string) (string, error) { + dir := filepath.Dir(filename) + if dir != "." && dir != "" { + err := os.MkdirAll(dir, 0755) + if err != nil { + return "", err + } + } + + err := os.WriteFile(filename, []byte(content), 0600) + + return filename, err +} + +type mockLogger struct { + logs []string +} + +func (m *mockLogger) Debug(_ ...any) { m.logs = append(m.logs, "DEBUG") } +func (m *mockLogger) Debugf(_ string, _ ...any) { m.logs = append(m.logs, "DEBUGF") } +func (m *mockLogger) Log(_ ...any) { m.logs = append(m.logs, "LOG") } +func (m *mockLogger) Logf(_ string, _ ...any) { m.logs = append(m.logs, "LOGF") } +func (m *mockLogger) Info(_ ...any) { m.logs = append(m.logs, "INFO") } +func (m *mockLogger) Infof(_ string, _ ...any) { m.logs = append(m.logs, "INFOF") } +func (m *mockLogger) Notice(_ ...any) { m.logs = append(m.logs, "NOTICE") } +func (m *mockLogger) Noticef(_ string, _ ...any) { m.logs = append(m.logs, "NOTICEF") } +func (m *mockLogger) Error(_ ...any) { m.logs = append(m.logs, "ERROR") } +func (m *mockLogger) Errorf(_ string, _ ...any) { m.logs = append(m.logs, "ERRORF") } +func (m *mockLogger) Warn(_ ...any) { m.logs = append(m.logs, "WARN") } +func (m *mockLogger) Warnf(_ string, _ ...any) { m.logs = append(m.logs, "WARNF") } +func (m *mockLogger) Fatal(_ ...any) { m.logs = append(m.logs, "FATAL") } +func (m *mockLogger) Fatalf(_ string, _ ...any) { m.logs = append(m.logs, "FATALF") } +func (*mockLogger) ChangeLevel(logging.Level) {} + +type mockMetrics struct { + histogramCreated bool + counterCreated bool +} + +func (m *mockMetrics) NewHistogram(_, _ string, _ ...float64) { + m.histogramCreated = true +} + +func (*mockMetrics) RecordHistogram(_ context.Context, _ string, _ float64, _ ...string) { + // Mock implementation +} + +func (m *mockMetrics) NewCounter(_, _ string) { + m.counterCreated = true +} + +func (*mockMetrics) IncrementCounter(_ context.Context, _ string, _ ...string) { + // Mock implementation +} + +func (*mockMetrics) NewUpDownCounter(_, _ string) { + // Mock implementation +} + +func (*mockMetrics) NewGauge(_, _ string) { + // Mock implementation +} + +func (*mockMetrics) DeltaUpDownCounter(_ context.Context, _ string, _ float64, _ ...string) { + // Mock implementation +} + +func (*mockMetrics) SetGauge(_ string, _ float64, _ ...string) { + // Mock implementation +} diff --git a/pkg/gofr/rbac_test.go b/pkg/gofr/rbac_test.go new file mode 100644 index 000000000..400946bfa --- /dev/null +++ b/pkg/gofr/rbac_test.go @@ -0,0 +1,229 @@ +package gofr + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +var ( + errConfigPathNotSet = errors.New("config path not set") + errFailedToParse = errors.New("failed to parse") +) + +func TestEnableRBAC(t *testing.T) { + testCases := []struct { + desc string + provider RBACProvider + setupFiles func() (string, error) + cleanupFiles func(string) + expectedLogs []string + expectedError bool + middlewareSet bool + }{ + { + desc: "nil provider should log error", + provider: nil, + setupFiles: func() (string, error) { + return "", nil + }, + cleanupFiles: func(string) {}, + expectedLogs: []string{"RBAC provider is required"}, + expectedError: false, + middlewareSet: false, + }, + { + desc: "valid provider with custom config file", + provider: &mockRBACProvider{configPath: "test_rbac.json"}, + setupFiles: func() (string, error) { + content := `{"roles":[{"name":"admin","permissions":["admin:read"]}],` + + `"endpoints":[{"path":"/api","methods":["GET"],"requiredPermissions":["admin:read"]}]}` + return createTestConfigFile("test_rbac.json", content) + }, + cleanupFiles: func(path string) { + os.Remove(path) + }, + expectedLogs: []string{"Loaded RBAC config"}, + expectedError: false, + middlewareSet: true, + }, + { + desc: "valid provider with default config path", + provider: &mockRBACProvider{configPath: ""}, + setupFiles: func() (string, error) { + content := `{"roles":[{"name":"viewer","permissions":["users:read"]}],"endpoints":[{"path":"/health","methods":["GET"],"public":true}]}` + return createTestConfigFile("configs/rbac.json", content) + }, + cleanupFiles: func(path string) { + os.Remove(path) + os.Remove("configs") + }, + expectedLogs: []string{"Loaded RBAC config"}, + expectedError: false, + middlewareSet: true, + }, + { + desc: "config file not found", + provider: &mockRBACProvider{configPath: "nonexistent.json", loadErr: errConfigPathNotSet}, + setupFiles: func() (string, error) { + return "", nil + }, + cleanupFiles: func(string) {}, + expectedLogs: []string{"Failed to load RBAC config"}, + expectedError: false, + middlewareSet: false, + }, + { + desc: "invalid config file format", + provider: &mockRBACProvider{configPath: "invalid.json", loadErr: errFailedToParse}, + setupFiles: func() (string, error) { + content := `invalid json content{` + return createTestConfigFile("invalid.json", content) + }, + cleanupFiles: func(path string) { + os.Remove(path) + }, + expectedLogs: []string{"Failed to load RBAC config"}, + expectedError: false, + middlewareSet: false, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + filePath, err := tc.setupFiles() + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + + defer tc.cleanupFiles(filePath) + + app := New() + app.EnableRBAC(tc.provider) + + // Check if middleware was actually called (which means it was added to router) + mockProvider, ok := tc.provider.(*mockRBACProvider) + if ok { + require.Equal(t, tc.middlewareSet, mockProvider.middlewareCalled, + "TEST[%d], Failed.\n%s", i, tc.desc) + } else { + // For nil provider case, just check that httpServer exists (it always does after New()) + require.NotNil(t, app.httpServer, "TEST[%d], Failed.\n%s", i, tc.desc) + require.NotNil(t, app.httpServer.router, "TEST[%d], Failed.\n%s", i, tc.desc) + } + }) + } +} + +func createTestConfigFile(filename, content string) (string, error) { + dir := filepath.Dir(filename) + if dir != "." && dir != "" { + err := os.MkdirAll(dir, 0755) + if err != nil { + return "", err + } + } + + err := os.WriteFile(filename, []byte(content), 0600) + + return filename, err +} + +// mockRBACProvider is a mock implementation of RBACProvider for testing. +// This avoids import cycle by not importing rbac package. +type mockRBACProvider struct { + configPath string + loadErr error + middlewareFn func(http.Handler) http.Handler + middlewareCalled bool // Track if RBACMiddleware was called +} + +func (*mockRBACProvider) UseLogger(_ any) { + // Mock implementation +} + +func (*mockRBACProvider) UseMetrics(_ any) { + // Mock implementation +} + +func (*mockRBACProvider) UseTracer(_ any) { + // Mock implementation +} + +func (m *mockRBACProvider) LoadPermissions() error { + if m.loadErr != nil { + return m.loadErr + } + + return nil +} + +func (m *mockRBACProvider) RBACMiddleware() func(http.Handler) http.Handler { + m.middlewareCalled = true + if m.middlewareFn != nil { + return m.middlewareFn + } + + return func(handler http.Handler) http.Handler { + return handler + } +} + +func TestApp_EnableRBAC_Integration(t *testing.T) { + testCases := []struct { + desc string + configContent string + provider RBACProvider + configFile string + expectError bool + }{ + { + desc: "valid config with roles and endpoints", + configContent: `{ + "roles": [ + {"name": "admin", "permissions": ["*:*"]}, + {"name": "viewer", "permissions": ["users:read"]} + ], + "endpoints": [ + {"path": "/health", "methods": ["GET"], "public": true}, + {"path": "/api/users", "methods": ["GET"], "requiredPermissions": ["users:read"]} + ] + }`, + provider: &mockRBACProvider{configPath: "test_integration.json"}, + configFile: "test_integration.json", + expectError: false, + }, + { + desc: "config with role inheritance", + configContent: `{ + "roles": [ + {"name": "viewer", "permissions": ["users:read"]}, + {"name": "editor", "permissions": ["users:write"], "inheritsFrom": ["viewer"]} + ], + "endpoints": [ + {"path": "/api/users", "methods": ["GET"], "requiredPermissions": ["users:read"]} + ] + }`, + provider: &mockRBACProvider{configPath: "test_inheritance.json"}, + configFile: "test_inheritance.json", + expectError: false, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + path, err := createTestConfigFile(tc.configFile, tc.configContent) + require.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc) + + defer os.Remove(path) + + app := New() + app.EnableRBAC(tc.provider) + + require.NotNil(t, app.httpServer, "TEST[%d], Failed.\n%s", i, tc.desc) + require.NotNil(t, app.httpServer.router, "TEST[%d], Failed.\n%s", i, tc.desc) + }) + } +}