diff --git a/docs/advanced-guide/rbac-permissions/page.md b/docs/advanced-guide/rbac-permissions/page.md new file mode 100644 index 0000000000..9b133a4081 --- /dev/null +++ b/docs/advanced-guide/rbac-permissions/page.md @@ -0,0 +1,484 @@ +# Permission-Based Access Control in GoFr + +Permission-Based Access Control (PBAC) extends Role-Based Access Control (RBAC) by providing fine-grained authorization at the permission level. Instead of just checking if a user has a specific role, you can check if they have a specific permission to perform an action. + +## Overview + +GoFr's permission-based RBAC provides: + +- ✅ **Factory Function Pattern** - `app.EnableRBAC()` follows the same pattern as `app.AddMongo()`, `app.AddPostgres()` - automatically registers RBAC when called +- ✅ **Fine-Grained Control** - Define permissions like `users:read`, `users:write`, `users:delete` +- ✅ **Structured Route Rules** - Map HTTP methods and routes to specific permissions using flexible regex patterns +- ✅ **Role-Centric Permissions** - Intuitive model where roles define what they can do +- ✅ **Automatic Route Protection** - Middleware automatically checks permissions - no route-level wrappers needed +- ✅ **Fallback to Role-Based** - Automatically falls back to role-based checks when permission mapping is missing + +> **Note**: `app.EnableRBAC()` follows the same factory function pattern used throughout GoFr for datasource registration. Just like you use `app.AddMongo(db)` to register MongoDB, you use `app.EnableRBAC()` to register and configure RBAC. When using RBAC options (e.g., `&rbac.JWTExtractor{}`), you must import the rbac package: `import "gofr.dev/pkg/gofr/rbac"`. + +## Quick Start + +### Basic Permission Configuration + +```go +package main + +import ( + "gofr.dev/pkg/gofr" +) + +func main() { + app := gofr.New() + + // Enable RBAC with permissions + // Config file defines all permissions and route mappings + // EnableRBAC is a factory function that registers RBAC automatically + app.EnableRBAC() // Uses configs/rbac.json by default + + // Example routes - permissions checked automatically by middleware + app.GET("/api/users", getAllUsers) // Auto-checked: users:read + app.POST("/api/users", createUser) // Auto-checked: users:write + app.DELETE("/api/users/:id", deleteUser) // Auto-checked: users:delete + + app.Run() +} +``` + +**Configuration** (`configs/rbac.json`): + +```json +{ + "roleHeader": "X-User-Role", + "route": { + "/api/*": ["admin", "editor"] + }, + "permissions": { + "rolePermissions": { + "admin": ["users:read", "users:write", "users:delete"], + "editor": ["users:read", "users:write"], + "viewer": ["users:read"] + }, + "routePermissionRules": [ + { + "methods": ["GET"], + "regex": "^/api/users(/.*)?$", + "permission": "users:read" + }, + { + "methods": ["POST", "PUT"], + "regex": "^/api/users(/.*)?$", + "permission": "users:write" + }, + { + "methods": ["DELETE"], + "regex": "^/api/users/\\d+$", + "permission": "users:delete" + } + ] + } +} +``` + +## How Permissions Work + +### Two-Tier Authorization System + +GoFr's RBAC middleware implements a **two-tier authorization system**: + +1. **Permission-Based Check** (Primary) - Checks if user's role has the required permission +2. **Role-Based Check** (Fallback) - Falls back to route-based role checking if permission check fails + +```go +// Check permission-based access if enabled +if config.EnablePermissions && config.PermissionConfig != nil { + if err := CheckPermission(reqWithRole, config.PermissionConfig); err == nil { + authorized = true + authReason = "permission-based" + } +} + +// Check role-based access (if not already authorized by permissions) +if !authorized { + if isRoleAllowed(role, route, config) { + authorized = true + authReason = "role-based" + } +} +``` + +**Benefits of Fallback**: +- Provides safety net when permission mappings are missing +- Allows gradual migration from role-based to permission-based +- Ensures routes are always protected even if permission config is incomplete + +### Permission Flow + +1. **Request arrives** → Middleware extracts user role +2. **Permission check** → Checks if route matches a permission rule +3. **Role validation** → Verifies if user's role has the required permission +4. **Fallback** → If no permission mapping exists, falls back to role-based check +5. **Authorization decision** → Allow or deny based on the checks + +## Configuration + +### Role-Centric Permission Model + +GoFr uses a **role-centric** permission model, which is more intuitive than the traditional permission-centric model: + +```json +{ + "rolePermissions": { + "admin": ["users:read", "users:write", "users:delete"], + "editor": ["users:read", "users:write"], + "viewer": ["users:read"] + } +} +``` + +**Why Role-Centric?** +- ✅ Easy to see what a role can do (all permissions in one place) +- ✅ Better for understanding role capabilities +- ✅ More maintainable as roles change +- ✅ Aligns with how developers think about access control + +### Structured Route Permission Rules + +The new `routePermissionRules` format provides flexible, structured route-to-permission mapping: + +```json +{ + "routePermissionRules": [ + { + "methods": ["GET"], + "regex": "^/api/users(/.*)?$", + "permission": "users:read" + }, + { + "methods": ["POST", "PUT"], + "path": "/api/users", + "permission": "users:write" + }, + { + "methods": ["DELETE"], + "regex": "^/api/users/\\d+$", + "permission": "users:delete" + } + ] +} +``` + +**Fields**: +- `methods` (array): HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.). Empty or `["*"]` matches all methods +- `path` (string): Path pattern (supports wildcards). Used when `regex` is not provided +- `regex` (string): Regular expression pattern. Takes precedence over `path` if both are provided +- `permission` (string): Required permission for matching routes + +**Benefits**: +- ✅ Supports multiple HTTP methods per rule +- ✅ Flexible regex patterns for complex route matching +- ✅ More readable than string-based `"METHOD /path"` format +- ✅ Easier to maintain and extend + +### Legacy Format Support + +For backward compatibility, the legacy `routePermissions` format is still supported: + +```json +{ + "routePermissions": { + "GET /api/users": "users:read", + "POST /api/users": "users:write", + "DELETE /api/users": "users:delete" + } +} +``` + +> **Note**: `routePermissionRules` takes precedence over `routePermissions` if both are provided. + +## 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 + +```go +"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 +``` + +## Automatic Route Protection + +With the new API, middleware automatically checks permissions based on `routePermissionRules`. You **don't need** to add `RequirePermission()` at the route level: + +```go +// ✅ Good: Middleware automatically checks permissions +app.GET("/api/users", getAllUsers) +app.POST("/api/users", createUser) +app.DELETE("/api/users/:id", deleteUser) + +// ❌ Not needed: Middleware already checks permissions +// app.DELETE("/api/users/:id", gofr.RequirePermission("users:delete", config, deleteUser)) +``` + +**When to use `RequirePermission()`**: +- For programmatic checks within handlers +- For dynamic, conditional permissions +- For fine-grained checks that can't be expressed in route rules + +## Implementation Patterns + +### 1. Permission-Based RBAC (Header) + +**Best for**: Fine-grained access control with header-based roles + +**Example**: [Permission-Based RBAC (Header) Example](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/permissions-header) + +```go +app := gofr.New() + +// Enable RBAC - config file defines roleHeader and permissions +app.EnableRBAC() // Uses configs/rbac.json by default +``` + +### 2. Permission-Based RBAC (JWT) + +**Best for**: Public APIs requiring fine-grained permissions + +**Example**: [Permission-Based RBAC (JWT) Example](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/permissions-jwt) + +```go +app := gofr.New() + +// Enable OAuth middleware +app.EnableOAuth("https://auth.example.com/.well-known/jwks.json", 10) + +// Enable RBAC with JWT and permissions +app.EnableRBAC("", &rbac.JWTExtractor{Claim: "role"}) +``` + +## Common Patterns + +### Pattern 1: CRUD Permissions + +```json +{ + "rolePermissions": { + "admin": ["users:create", "users:read", "users:update", "users:delete"], + "editor": ["users:create", "users:read", "users:update"], + "viewer": ["users:read"] + }, + "routePermissionRules": [ + { + "methods": ["POST"], + "regex": "^/api/users$", + "permission": "users:create" + }, + { + "methods": ["GET"], + "regex": "^/api/users(/.*)?$", + "permission": "users:read" + }, + { + "methods": ["PUT", "PATCH"], + "regex": "^/api/users/\\d+$", + "permission": "users:update" + }, + { + "methods": ["DELETE"], + "regex": "^/api/users/\\d+$", + "permission": "users:delete" + } + ] +} +``` + +### Pattern 2: Resource-Specific Permissions + +```json +{ + "rolePermissions": { + "admin": ["own:posts:read", "own:posts:write", "all:posts:read", "all:posts:write"], + "author": ["own:posts:read", "own:posts:write"], + "viewer": ["own:posts:read", "all:posts:read"] + }, + "routePermissionRules": [ + { + "methods": ["GET"], + "regex": "^/api/posts/my-posts$", + "permission": "own:posts:read" + }, + { + "methods": ["GET"], + "regex": "^/api/posts(/.*)?$", + "permission": "all:posts:read" + } + ] +} +``` + +## Best Practices + +### 1. Use Role-Centric Permissions + +```json +{ + "rolePermissions": { + "admin": ["users:read", "users:write", "users:delete"], + "editor": ["users:read", "users:write"], + "viewer": ["users:read"] + } +} +``` + +**Benefits**: +- Easy to see what each role can do +- Better for understanding role capabilities +- More maintainable + +### 2. Use Structured Route Rules + +```json +{ + "routePermissionRules": [ + { + "methods": ["GET"], + "regex": "^/api/users(/.*)?$", + "permission": "users:read" + } + ] +} +``` + +**Benefits**: +- Supports multiple HTTP methods +- Flexible regex patterns +- More readable and maintainable + +### 3. Use Consistent Permission Naming + +```go +// Good: Consistent format +"users:read" +"users:write" +"users:delete" +"posts:read" +"posts:write" + +// Avoid: Inconsistent formats +"read_users" +"writeUsers" +"DELETE_POSTS" +``` + +### 4. Group Related Permissions + +```json +{ + "rolePermissions": { + "admin": [ + "users:read", "users:write", "users:delete", + "posts:read", "posts:write", "posts:delete", + "orders:read", "orders:approve", "orders:cancel" + ] + } +} +``` + +### 5. Use Fallback Routes + +Always define fallback routes in your config file: + +```json +{ + "route": { + "/api/*": ["admin", "editor"], + "*": ["viewer"] + } +} +``` + +This ensures routes without explicit permission mappings are still protected. + +### 6. Document Permission Requirements + +Document which permissions are required for each endpoint: + +```go +// GET /api/users - Requires: users:read +// POST /api/users - Requires: users:write +// DELETE /api/users/:id - Requires: users:delete +``` + +### 7. Test Permission Checks + +Write integration tests to verify permission checks: + +```go +func TestPermissionChecks(t *testing.T) { + // Test that admin can delete users + // Test that editor cannot delete users + // Test that viewer cannot write users +} +``` + +### 8. Let Middleware Handle Checks + +Don't add `RequirePermission()` at route level unless needed for programmatic checks: + +```go +// ✅ Good: Middleware automatically checks +app.DELETE("/api/users/:id", deleteUser) + +// ❌ Not needed: Middleware already checks +// app.DELETE("/api/users/:id", gofr.RequirePermission("users:delete", config, deleteUser)) +``` + +## Troubleshooting + +### Permission Check Not Working + +1. **Verify `rolePermissions` is configured** - Check that roles have permissions assigned +2. **Check route rule format** - Ensure `routePermissionRules` match your routes correctly +3. **Verify role extraction** - Ensure role is being extracted correctly +4. **Check permission mapping** - Ensure route matches a permission rule +5. **Review fallback** - Check if role-based fallback is allowing access + +### Permission Always Denied + +1. **Check role assignment** - Verify user's role has the required permission +2. **Review permission config** - Ensure `rolePermissions` is properly set +3. **Check route matching** - Verify route pattern/regex matches exactly +4. **Enable debug logging** - Check audit logs for authorization decisions + +### Permission Always Allowed + +1. **Check fallback routes** - Fallback might be too permissive +2. **Verify permission check** - Ensure `PermissionConfig` is set +3. **Review route mapping** - Ensure route matches a permission rule + +## Related Documentation + +- [Role-Based Access Control (RBAC)](./rbac/page.md) - Complete RBAC guide +- [HTTP Authentication](https://gofr.dev/docs/advanced-guide/http-authentication) - Authentication methods +- [Permission-Based RBAC Examples](https://github.com/gofr-dev/gofr/tree/main/examples/rbac) - Working examples +- [RBAC Architecture](./rbac/ARCHITECTURE.md) - Code execution flow + +## Examples + +- [Permission-Based RBAC (Header)](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/permissions-header) - Header-based role extraction with permissions +- [Permission-Based RBAC (JWT)](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/permissions-jwt) - JWT-based role extraction with permissions + +Each example includes: +- Complete working code +- Configuration files +- Integration tests +- Detailed README with setup instructions diff --git a/docs/advanced-guide/rbac/page.md b/docs/advanced-guide/rbac/page.md new file mode 100644 index 0000000000..31cb03d14a --- /dev/null +++ b/docs/advanced-guide/rbac/page.md @@ -0,0 +1,783 @@ +# 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 comprehensive RBAC middleware that supports multiple authentication methods, fine-grained permissions, role hierarchies, and audit logging. + +## Overview + +GoFr's RBAC middleware provides: + +- ✅ **Multiple Authentication Methods** - Header-based and JWT-based role extraction +- ✅ **Permission-Based Access Control** - Fine-grained permissions beyond simple roles +- ✅ **Role Hierarchy** - Inherited roles (admin > editor > author > viewer) +- ✅ **Audit Logging** - Comprehensive authorization logging using GoFr's logger +- ✅ **Framework Integration** - Simple API consistent with other GoFr features +- ✅ **Modular Design** - RBAC is an external module, keeping the core framework lightweight +- ✅ **Role-Centric Permissions** - Intuitive permission model where roles define what they can do + +> **Important**: `app.EnableRBAC()` follows the same factory function pattern used throughout GoFr for datasources (e.g., `app.AddMongo()`, `app.AddPostgres()`). It automatically registers RBAC implementations when called. Simply call `app.EnableRBAC()` to enable RBAC features. When using RBAC options (e.g., `&rbac.JWTExtractor{}`), you must import the rbac package: `import "gofr.dev/pkg/gofr/rbac"`. + +## Quick Start + +GoFr's RBAC follows the same factory function pattern as datasource registration. Just like you use `app.AddMongo(db)` to register MongoDB, you use `app.EnableRBAC()` to register and configure RBAC. + +### Basic RBAC with Header-Based Roles + +The simplest way to implement RBAC is using header-based role extraction: + +```go +package main + +import ( + "gofr.dev/pkg/gofr" +) + +func main() { + app := gofr.New() + + // Enable RBAC - uses default config path (configs/rbac.json) + // Config file defines roleHeader: "X-User-Role" for automatic header extraction + // EnableRBAC is a factory function that registers RBAC automatically + app.EnableRBAC() + + app.GET("/api/users", handler) + app.Run() +} +``` + +**Configuration** (`configs/rbac.json`): + +```json +{ + "roleHeader": "X-User-Role", + "route": { + "/api/users": ["admin", "editor", "viewer"], + "/api/admin/*": ["admin"], + "*": ["viewer"] + }, + "overrides": { + "/health": true, + "/metrics": true + }, + "defaultRole": "viewer", + "roleHierarchy": { + "admin": ["editor", "author", "viewer"], + "editor": ["author", "viewer"], + "author": ["viewer"] + } +} +``` + +> **⚠️ Security Note**: Header-based RBAC is **not secure** for public APIs. Use JWT-based RBAC for production applications. + +## RBAC Implementation Patterns + +GoFr supports four main RBAC patterns, each suited for different use cases: + +### 1. Simple RBAC (Header-Based) + +**Best for**: Internal APIs, trusted networks, development environments + +**Example**: [Simple RBAC Example](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/simple) + +```go +import ( + "gofr.dev/pkg/gofr" +) + +app := gofr.New() + +// Enable RBAC with default config path +// Config file defines roleHeader for automatic header extraction +// EnableRBAC is a factory function that registers RBAC automatically +app.EnableRBAC() // Uses configs/rbac.json by default +``` + +**Configuration** (`configs/rbac.json`): + +```json +{ + "roleHeader": "X-User-Role", + "route": { + "/api/users": ["admin", "editor", "viewer"], + "/api/admin/*": ["admin"], + "*": ["viewer"] + }, + "defaultRole": "viewer", + "roleHierarchy": { + "admin": ["editor", "author", "viewer"] + } +} +``` + +**Example**: [Simple RBAC Example](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/simple) + +### 2. JWT-Based RBAC + +**Best for**: Public APIs, microservices, OAuth2/OIDC integration + +**Example**: [JWT RBAC Example](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/jwt) + +```go +import ( + "gofr.dev/pkg/gofr" + "gofr.dev/pkg/gofr/rbac" // Import for JWTExtractor type +) + +app := gofr.New() + +// Enable OAuth middleware first (required for JWT validation) +app.EnableOAuth("https://auth.example.com/.well-known/jwks.json", 10) + +// Enable RBAC with JWT role extraction +// EnableRBAC is a factory function that registers RBAC automatically +app.EnableRBAC("configs/rbac.json", &rbac.JWTExtractor{Claim: "role"}) +``` + +**JWT Role Claim Parameter (`roleClaim`)**: + +The `roleClaim` parameter in `JWTExtractor` specifies the path to the role in JWT claims. It supports multiple formats: + +| Format | Example | JWT Claim Structure | +|--------|---------|---------------------| +| **Simple Key** | `"role"` | `{"role": "admin"}` | +| **Array Notation** | `"roles[0]"` | `{"roles": ["admin", "user"]}` - extracts first element | +| **Array Notation** | `"roles[1]"` | `{"roles": ["admin", "user"]}` - extracts second element | +| **Dot Notation** | `"permissions.role"` | `{"permissions": {"role": "admin"}}` | +| **Deeply Nested** | `"user.permissions.role"` | `{"user": {"permissions": {"role": "admin"}}}` | + +**Examples**: + +```go +// Simple claim +app.EnableRBAC("configs/rbac.json", &rbac.JWTExtractor{Claim: "role"}) +// JWT: {"role": "admin", "sub": "user123"} + +// Array notation - extract first role +app.EnableRBAC("configs/rbac.json", &rbac.JWTExtractor{Claim: "roles[0]"}) +// JWT: {"roles": ["admin", "editor"], "sub": "user123"} + +// Nested claim +app.EnableRBAC("configs/rbac.json", &rbac.JWTExtractor{Claim: "permissions.role"}) +// JWT: {"permissions": {"role": "admin"}, "sub": "user123"} +``` + +**Note**: +- If `roleClaim` is empty (`""`), it defaults to `"role"` +- The extracted value is converted to string automatically +- Array indices must be valid integers (e.g., `[0]`, `[1]`, not `[invalid]`) +- Array indices must be within bounds (e.g., `roles[5]` fails if array has only 2 elements) + +**Example**: [JWT RBAC Example](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/jwt) + +**Related**: [HTTP Authentication - OAuth 2.0](https://gofr.dev/docs/advanced-guide/http-authentication#3-oauth-20) + +### 3. Permission-Based RBAC (Header) + +**Best for**: Fine-grained access control with header-based roles + +**Example**: [Permission-Based RBAC (Header) Example](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/permissions-header) + +```go +import ( + "gofr.dev/pkg/gofr" +) + +app := gofr.New() + +// Enable RBAC with permissions +// Config file defines roleHeader and all permissions +// EnableRBAC is a factory function that registers RBAC automatically +app.EnableRBAC() // Uses configs/rbac.json by default +``` + +**Configuration** (`configs/rbac.json`): + +```json +{ + "roleHeader": "X-User-Role", + "route": { + "/api/*": ["admin", "editor"] + }, + "permissions": { + "rolePermissions": { + "admin": ["users:read", "users:write", "users:delete", "posts:read", "posts:write"], + "editor": ["users:read", "users:write", "posts:read"], + "viewer": ["users:read", "posts:read"] + }, + "routePermissionRules": [ + { + "methods": ["GET"], + "regex": "^/api/users(/.*)?$", + "permission": "users:read" + }, + { + "methods": ["POST", "PUT"], + "regex": "^/api/users(/.*)?$", + "permission": "users:write" + }, + { + "methods": ["DELETE"], + "regex": "^/api/users/\\d+$", + "permission": "users:delete" + } + ] + } +} +``` + +**Example**: [Permission-Based RBAC (Header) Example](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/permissions-header) + +### 4. Permission-Based RBAC (JWT) + +**Best for**: Public APIs requiring fine-grained permissions + +**Example**: [Permission-Based RBAC (JWT) Example](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/permissions-jwt) + +```go +app := gofr.New() + +// Enable OAuth middleware +app.EnableOAuth("https://auth.example.com/.well-known/jwks.json", 10) + +// Enable RBAC with JWT and permissions +app.EnableRBAC("", &rbac.JWTExtractor{Claim: "role"}) +// Uses default config path: configs/rbac.json +``` + +**Example**: [Permission-Based RBAC (JWT) Example](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/permissions-jwt) + +**Related**: [HTTP Authentication - OAuth 2.0](https://gofr.dev/docs/advanced-guide/http-authentication#3-oauth-20) + +## Factory Function Pattern + +GoFr's RBAC follows the same factory function pattern used throughout the framework for datasource registration. This provides a consistent API and user experience. + +### Options Pattern (Same as HTTP Service) + +RBAC options follow the exact same pattern as HTTP service options (`service.Options`): + +| Aspect | HTTP Service | RBAC | +|--------|--------------|------| +| **Interface** | `service.Options` | `rbac.Options` (internal) / `gofr.RBACOption` (public) | +| **Method** | `AddOption(h HTTP) HTTP` | `AddOption(config RBACConfig) RBACConfig` | +| **Usage** | `app.AddHTTPService(name, addr, options...)` | `app.EnableRBAC(configFile, options...)` | +| **Composable** | ✅ Yes - options can be chained | ✅ Yes - options can be chained | + +**Example Comparison**: + +```go +// HTTP Service Options +app.AddHTTPService("payment", "http://localhost:9000", + &service.RateLimiterConfig{...}, + &service.CircuitBreakerConfig{...}, +) + +// RBAC Options (same pattern) +app.EnableRBAC("configs/rbac.json", + &rbac.JWTExtractor{Claim: "role"}, + &rbac.HeaderRoleExtractor{HeaderKey: "X-User-Role"}, +) +``` + +Both patterns use the same interface design where each option implements `AddOption` method, making them composable and order-agnostic. + +### Comparison with Datasource Registration + +Just like datasources, RBAC uses a factory function pattern: + +| Feature | Datasources | RBAC | +|---------|-------------|------| +| **Factory Function** | `app.AddMongo(db)` | `app.EnableRBAC(configFile, options...)` | +| **Pattern** | Single entry point | Single entry point | +| **Setup** | Logger, Metrics, Tracer | Logger, Config, Middleware | +| **User Import** | Import datasource package | Import rbac package (when using options) | +| **Registration** | Direct assignment | Interface-based registration | + +### Example Comparison + +**Datasource Registration:** +```go +import ( + "gofr.dev/pkg/gofr" + "gofr.dev/pkg/gofr/datasource/mongo" +) + +app := gofr.New() +mongoDB := mongo.New(...) +app.AddMongo(mongoDB) // Factory function - sets up logger, metrics, tracer, connects +``` + +**RBAC Registration:** +```go +import ( + "gofr.dev/pkg/gofr" + "gofr.dev/pkg/gofr/rbac" // Import when using options +) + +app := gofr.New() +app.EnableRBAC("configs/rbac.json", &rbac.JWTExtractor{Claim: "role"}) // Factory function - registers, loads config, sets up middleware +``` + +Both follow the same pattern: +1. **Import the package** when using specific features/options +2. **Call the factory function** to register and configure +3. **Framework handles setup** automatically (logger, metrics, connection/setup) + +## Configuration + +### EnableRBAC API + +The `EnableRBAC` function accepts an optional config file path and variadic options: + +```go +func (a *App) EnableRBAC(configFile string, options ...RBACOption) +``` + +**Parameters**: +- `configFile` (string): Path to RBAC config file (JSON or YAML). If empty, tries default paths: + - `configs/rbac.json` + - `configs/rbac.yaml` + - `configs/rbac.yml` +- `options` (...RBACOption): Optional interface-based options (follows same pattern as `service.Options`): + - `&rbac.HeaderRoleExtractor{HeaderKey: "X-User-Role"}` - Header-based extraction + - `&rbac.JWTExtractor{Claim: "role"}` - JWT-based extraction + +**Options Pattern**: RBAC options follow the same pattern as HTTP service options (`service.Options`). Each option implements the `AddOption(config RBACConfig) RBACConfig` method, allowing for composable configuration similar to how HTTP services use `AddOption(h HTTP) HTTP`. + +**Examples**: + +```go +// Use default config path +app.EnableRBAC() + +// Use custom config path +app.EnableRBAC("configs/custom-rbac.json") + +// Use default path with JWT option +app.EnableRBAC("", &rbac.JWTExtractor{Claim: "role"}) + +// Use custom path with header extractor +app.EnableRBAC("configs/rbac.json", &rbac.HeaderRoleExtractor{HeaderKey: "X-User-Role"}) +``` + +### JSON Configuration + +```json +{ + "roleHeader": "X-User-Role", + "route": { + "/api/users": ["admin", "editor", "viewer"], + "/api/posts": ["admin", "editor", "author"], + "/api/admin/*": ["admin"], + "*": ["viewer"] + }, + "overrides": { + "/health": true, + "/metrics": true + }, + "defaultRole": "viewer", + "roleHierarchy": { + "admin": ["editor", "author", "viewer"], + "editor": ["author", "viewer"], + "author": ["viewer"] + }, + "permissions": { + "rolePermissions": { + "admin": ["users:read", "users:write", "users:delete"], + "editor": ["users:read", "users:write"], + "viewer": ["users:read"] + }, + "routePermissionRules": [ + { + "methods": ["GET"], + "regex": "^/api/users(/.*)?$", + "permission": "users:read" + }, + { + "methods": ["POST", "PUT"], + "regex": "^/api/users(/.*)?$", + "permission": "users:write" + } + ] + } +} +``` + +### YAML Configuration + +```yaml +roleHeader: X-User-Role + +route: + /api/users: + - admin + - editor + - viewer + /api/posts: + - admin + - editor + - author + +overrides: + /health: true + /metrics: true + +defaultRole: viewer + +roleHierarchy: + admin: + - editor + - author + - viewer + editor: + - author + - viewer + +permissions: + rolePermissions: + admin: + - users:read + - users:write + - users:delete + editor: + - users:read + - users:write + viewer: + - users:read + routePermissionRules: + - methods: [GET] + regex: "^/api/users(/.*)?$" + permission: users:read + - methods: [POST, PUT] + regex: "^/api/users(/.*)?$" + permission: users:write +``` + +## Configuration Fields + +| Field | Type | Description | +|-------|------|-------------| +| `roleHeader` | `string` | HTTP header key for role extraction (e.g., "X-User-Role"). Auto-configures header extractor | +| `route` | `map[string][]string` | Maps route patterns to allowed roles. Supports wildcards (`*`, `/api/*`) | +| `overrides` | `map[string]bool` | Routes that bypass RBAC (public access) | +| `defaultRole` | `string` | ⚠️ Role used when no role can be extracted. **Security Warning**: Can be a security flaw if not carefully considered | +| `roleHierarchy` | `map[string][]string` | Defines role inheritance relationships | +| `permissions` | `object` | Permission-based access control configuration | +| `permissions.rolePermissions` | `map[string][]string` | **Role-centric**: Maps roles to their permissions (e.g., `"admin": ["users:read", "users:write"]`) | +| `permissions.routePermissionRules` | `array` | Structured route-to-permission mapping with regex support | + +## Route Patterns + +GoFr supports flexible route pattern matching: + +- **Exact Match**: `"/api/users"` matches exactly `/api/users` +- **Wildcard**: `"/api/*"` matches `/api/users`, `/api/posts`, etc. +- **Global Fallback**: `"*"` matches all routes not explicitly defined + +## Route Permission Rules + +The new `routePermissionRules` format provides structured, flexible route-to-permission mapping: + +```json +{ + "routePermissionRules": [ + { + "methods": ["GET"], + "regex": "^/api/users(/.*)?$", + "permission": "users:read" + }, + { + "methods": ["POST", "PUT"], + "path": "/api/users", + "permission": "users:write" + }, + { + "methods": ["DELETE"], + "regex": "^/api/users/\\d+$", + "permission": "users:delete" + } + ] +} +``` + +**Fields**: +- `methods` (array): HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.). Empty or `["*"]` matches all methods +- `path` (string): Path pattern (supports wildcards). Used when `regex` is not provided +- `regex` (string): Regular expression pattern. Takes precedence over `path` if both are provided +- `permission` (string): Required permission for matching routes + +## Handler-Level Authorization + +GoFr provides helper functions for handler-level authorization checks: + +### Require Specific Role + +```go +app.GET("/admin", gofr.RequireRole("admin", adminHandler)) +``` + +### Require Any of Multiple Roles + +```go +app.GET("/dashboard", gofr.RequireAnyRole([]string{"admin", "editor"}, dashboardHandler)) +``` + +### Require Permission + +```go +// Note: Middleware automatically checks permissions, but you can use this for programmatic checks +app.DELETE("/api/users/:id", gofr.RequirePermission("users:delete", config.PermissionConfig, deleteUser)) +``` + +> **Note**: With the new API, middleware automatically checks permissions based on `routePermissionRules`. You typically don't need `RequirePermission()` at the route level unless you need programmatic checks within handlers. + +## Context Helpers + +Access role and permission information in your handlers: + +```go +import "gofr.dev/pkg/gofr/rbac" + +func handler(ctx *gofr.Context) (interface{}, error) { + // Check if user has specific role + if rbac.HasRole(ctx, "admin") { + // Admin-only logic + } + + // Get user's role + role := rbac.GetUserRole(ctx) + + // Check permission + if rbac.HasPermission(ctx.Context, "users:write", config.PermissionConfig) { + // Permission-based logic + } + + return nil, nil +} +``` + +## Advanced Features + +### Custom Error Handler + +Customize error responses for authorization failures: + +```go +config.ErrorHandler = func(w http.ResponseWriter, r *http.Request, role, route string, err error) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(fmt.Sprintf("Access denied for role %s on %s", role, route))) +} +``` + +### Audit Logging + +RBAC automatically logs all authorization decisions using GoFr's logger when `config.Logger` is set (which is done automatically by `app.EnableRBAC()`). + +**Audit logs include:** +- Request method and path +- User role +- Route being accessed +- Authorization decision (allowed/denied) +- Reason for decision + +**Example log output:** +``` +[RBAC Audit] GET /api/users - Role: admin - Route: /api/users - allowed - Reason: permission-based +[RBAC Audit] GET /api/admin - Role: viewer - Route: /api/admin - denied - Reason: access denied +``` + +**No configuration needed** - audit logging works automatically when you enable RBAC. GoFr's logger is used internally to log all authorization decisions. + +## Environment Variable Overrides + +Override configuration at runtime using environment variables: + +```bash +# Override default role +RBAC_DEFAULT_ROLE=viewer + +# Override route permissions +RBAC_ROUTE_/api/users=admin,editor + +# Override specific routes (public access) +RBAC_OVERRIDE_/health=true +``` + +## Comparison Matrix + +| Feature | Simple | JWT | Permissions-Header | Permissions-JWT | +|---------|--------|-----|-------------------|----------------| +| **Security** | ⚠️ Low | ✅ High | ⚠️ Low | ✅ High | +| **Flexibility** | ⚠️ Low | ⚠️ Low | ✅ High | ✅ High | +| **Performance** | ✅ Fast | ✅ Fast | ✅ Fast | ✅ Fast | +| **Production Ready** | ❌ No | ✅ Yes | ❌ No | ✅ Yes | +| **Setup Complexity** | ✅ Simple | ⚠️ Medium | ⚠️ Medium | ⚠️ Medium | + +## Migration Path + +**Development → Production:** +1. Start with **Simple RBAC** for development +2. Move to **JWT RBAC** for production +3. Add **Permissions** when you need fine-grained control + +## Complete Examples + +All examples are available in the GoFr repository: + +- [Simple RBAC Example](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/simple) - Header-based role extraction +- [JWT RBAC Example](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/jwt) - JWT-based role extraction +- [Permission-Based RBAC (Header)](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/permissions-header) - Permissions with header roles +- [Permission-Based RBAC (JWT)](https://github.com/gofr-dev/gofr/tree/main/examples/rbac/permissions-jwt) - Permissions with JWT roles + +Each example includes: +- Complete working code +- Configuration files +- Integration tests +- Setup instructions in code comments + +## Best Practices + +### 1. Security + +1. **Never use header-based RBAC for public APIs** - Use JWT-based RBAC instead +2. **Always validate JWT tokens** - Use proper JWKS endpoints with HTTPS +3. **Use HTTPS in production** - Protect tokens and headers from interception +4. **Implement rate limiting** - Prevent abuse and brute force attacks +5. **Monitor audit logs** - Track authorization decisions for security analysis +6. **Avoid defaultRole in production** - Can be a security flaw if not carefully considered + +### 2. Configuration + +1. **Use role-centric permissions** - More intuitive than permission-centric model + ```json + { + "rolePermissions": { + "admin": ["users:read", "users:write", "users:delete"], + "editor": ["users:read", "users:write"] + } + } + ``` +2. **Use structured route rules** - More flexible than string-based mapping + ```json + { + "routePermissionRules": [ + { + "methods": ["GET"], + "regex": "^/api/users(/.*)?$", + "permission": "users:read" + } + ] + } + ``` +3. **Set roleHeader in config** - Auto-configures header extraction +4. **Use default config paths** - Simplifies configuration management + +### 3. Permission Design + +1. **Use consistent naming** - Follow `resource:action` format (e.g., `users:read`, `posts:write`) +2. **Group related permissions** - Organize by resource type +3. **Document permission requirements** - Comment which permissions are needed for each endpoint +4. **Test permission checks** - Write integration tests to verify authorization + +### 4. Code Organization + +1. **Let middleware handle checks** - Don't add `RequirePermission()` at route level unless needed for programmatic checks +2. **Use context helpers** - Access role/permission info in handlers when needed +3. **Keep configs in files** - Use JSON/YAML files for route-level config, code for fine-grained permissions +4. **Version control configs** - Track RBAC configuration changes separately from code + +### 5. Performance + +1. **Cache role lookups** - For high-traffic applications, consider caching roles +2. **Optimize route rules** - Use specific patterns before generic ones +3. **Monitor performance** - Track authorization decision times + +### 6. Maintenance + +1. **Regular security audits** - Review RBAC configurations periodically +2. **Use role hierarchy wisely** - Don't create overly complex hierarchies +3. **Document role meanings** - Clearly define what each role can do +4. **Keep examples updated** - Maintain working examples for reference + +## Related Documentation + +- [Permission-Based Access Control](./rbac-permissions/page.md) - Detailed permission 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 +- [Configuration](https://gofr.dev/docs/quick-start/configuration) - Environment variables and configuration management + +## API Reference + +### Framework Methods + +- `app.EnableRBAC(configFile string, options ...RBACOption)` - Factory function that enables RBAC with config file and options (follows same pattern as `app.AddHTTPService()`) + + **Factory Function Behavior:** + - Automatically registers RBAC implementations on first call + - No need to import the RBAC module separately + - Handles registration internally + + **Parameters:** + - `configFile` (string): Optional path to RBAC config file. If empty, tries default paths: + - `configs/rbac.json` + - `configs/rbac.yaml` + - `configs/rbac.yml` + - `options` (...RBACOption): Optional interface-based options (follows same pattern as `service.Options`): + - `&rbac.HeaderRoleExtractor{HeaderKey: "X-User-Role"}` - Header-based role extraction + - `&rbac.JWTExtractor{Claim: "role"}` - JWT-based role extraction + + **Examples:** + ```go + // Use default config path + app.EnableRBAC() + + // Use custom config path + app.EnableRBAC("configs/custom-rbac.json") + + // Use default path with JWT option + app.EnableRBAC("", &rbac.JWTExtractor{Claim: "role"}) + + // Use custom path with header extractor + app.EnableRBAC("configs/rbac.json", &rbac.HeaderRoleExtractor{HeaderKey: "X-User-Role"}) + ``` + +### Handler Helpers + +- `gofr.RequireRole(role, handler)` - Require specific role +- `gofr.RequireAnyRole(roles, handler)` - Require any of multiple roles +- `gofr.RequirePermission(permission, config, handler)` - Require permission (for programmatic checks) + +### Context Helpers + +Access role and permission information in your handlers: + +- `rbac.HasRole(ctx, role)` - Check if context has specific role +- `rbac.GetUserRole(ctx)` - Get user role from context +- `rbac.HasPermission(ctx, permission, config)` - Check if user has permission + +## Troubleshooting + +### Common Issues + +**Issue**: Role not being extracted +- **Solution**: Ensure `roleHeader` is set in config or use `HeaderRoleExtractor` option. Check that the header is present in requests. + +**Issue**: Permission checks failing +- **Solution**: Verify `rolePermissions` is properly configured and `routePermissionRules` match your routes correctly. + +**Issue**: JWT role extraction failing +- **Solution**: Ensure OAuth middleware is enabled before RBAC, and JWT claim path is correct. + +**Issue**: Config file not found +- **Solution**: Ensure config file exists at the specified path, or use default paths (`configs/rbac.json`, `configs/rbac.yaml`, `configs/rbac.yml`). + +**Issue**: RBAC not working / "RBAC module not imported" error +- **Solution**: This should not happen with the factory function pattern. If it does, ensure you're calling `app.EnableRBAC()` and that the rbac package is available in your dependencies. The factory function handles registration automatically. + +## Need Help? + +- Check [RBAC Examples](https://github.com/gofr-dev/gofr/tree/main/examples/rbac) for complete working code +- See [GoFr Documentation](https://gofr.dev/docs) for general framework documentation +- Review [Permission-Based Access Control](./rbac-permissions/page.md) for detailed permission documentation +- See [RBAC Architecture](./rbac/ARCHITECTURE.md) for code execution flow diff --git a/docs/navigation.js b/docs/navigation.js index 8969a97ea8..6c2f570db5 100644 --- a/docs/navigation.js +++ b/docs/navigation.js @@ -83,6 +83,16 @@ 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, and database-based role extraction." + }, + { + title: 'Permission-Based Access Control', + href: '/docs/advanced-guide/rbac-permissions', + desc: "Learn how to implement fine-grained permission-based access control with route-to-permission mapping, role-to-permission assignment, and handler-level permission checks." + }, { title: 'Circuit Breaker Support', href: '/docs/advanced-guide/circuit-breaker', diff --git a/go.work b/go.work index 75ef79f107..67e789f977 100644 --- a/go.work +++ b/go.work @@ -11,8 +11,8 @@ use ( ./pkg/gofr/datasource/dgraph ./pkg/gofr/datasource/elasticsearch ./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 @@ -26,4 +26,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 c6767d926d..18623568cf 100644 --- a/go.work.sum +++ b/go.work.sum @@ -4,6 +4,7 @@ buf.build/go/protovalidate v0.12.0 h1:4GKJotbspQjRCcqZMGVSuC8SjwZ/FmgtSuKDpKUTZe buf.build/go/protovalidate v0.12.0/go.mod h1:q3PFfbzI05LeqxSwq+begW2syjy2Z6hLxZSkP1OH/D0= cel.dev/expr v0.15.0/go.mod h1:TRSuuV7DlVCE/uwv5QbAiW/v8l5O8C4eEPHeu7gf7Sg= cel.dev/expr v0.20.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= @@ -279,8 +280,11 @@ codeberg.org/go-latex/latex v0.1.0 h1:hoGO86rIbWVyjtlDLzCqZPjNykpWQ9YuTZqAzPcfL3 codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw= codeberg.org/go-pdf/fpdf v0.10.0 h1:u+w669foDDx5Ds43mpiiayp40Ov6sZalgcPMDBcZRd4= codeberg.org/go-pdf/fpdf v0.10.0/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= +contrib.go.opencensus.io/exporter/stackdriver v0.13.15-0.20230702191903-2de6d2748484 h1:xRc46S76eyn4ZF3jWX8I+aUSKVLw5EQ1aDvHwfV5W1o= +contrib.go.opencensus.io/exporter/stackdriver v0.13.15-0.20230702191903-2de6d2748484/go.mod h1:uxw+4/0SiKbbVSD/F2tk5pJTdVcfIBBcsQ8gwcu4X+E= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUuKccphEpTJnWhS2jkQyMt6Y7pJCD7fY= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= git.sr.ht/~sbinet/gg v0.6.0 h1:RIzgkizAk+9r7uPzf/VfbJHBMKUr0F5hRFxTUGMnt38= git.sr.ht/~sbinet/gg v0.6.0/go.mod h1:uucygbfC9wVPQIfrmwM2et0imr8L7KQWywX0xpFMm94= @@ -288,12 +292,15 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c= github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= github.com/CloudyKit/jet/v6 v6.2.0 h1:EpcZ6SR9n28BUGtNJSvlBqf90IpjeFr36Tizxhn/oME= github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/IBM/sarama v1.43.1 h1:Z5uz65Px7f4DhI/jQqEm/tV9t8aU+JUdTyW/K/fCXpA= github.com/IBM/sarama v1.43.1/go.mod h1:GG5q1RURtDNPz8xxJs3mgX6Ytak8Z9eLhAkJPObe2xE= github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk= @@ -313,6 +320,8 @@ github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwc github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 h1:BDgIUYGEo5TkayOWv/oBLPphWwNm/A91AebUjAu5L5g= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.1/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= @@ -324,12 +333,15 @@ github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= +github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= +github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= 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= @@ -338,8 +350,10 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f h1:WBZRG4aNOuI15bLRrCgN8fCq8E5Xuty6jGbmSNEvSsU= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -372,7 +386,9 @@ github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0o github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= +github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/flosch/pongo2/v4 v4.0.2 h1:gv+5Pe3vaSVmiJvh/BZa82b7/00YUGm0PIyVVLop0Hw= @@ -383,12 +399,17 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/log v0.1.0 h1:DGJh0Sm43HbOeYDNnVZFl8BvcYVvjD5bqYJvp0REbwQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= @@ -428,6 +449,7 @@ github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12 h1:uK3X/2mt4tbSGoHvbLBHUny7CKiuwUip3MArtukol4E= github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY= github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI= @@ -438,6 +460,7 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-pkcs11 v0.3.0 h1:PVRnTgtArZ3QQqTGtbtjtnIkzl2iY2kt24yqbrf7td8= github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= +github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -451,20 +474,24 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8 h1:tlyzajkF3030q6M8SvmJSemC9DTHL/xaMa18b65+JM4= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= @@ -495,6 +522,7 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d h1:c93kUJDtVAXFEhsCh5jSxyOJmFHuzcihnslQiX8Urwo= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= @@ -585,6 +613,7 @@ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqn github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -602,6 +631,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad h1:fiWzISvDn0Csy5H0iwgAuJGQTUpVfEMJJd4nRFXogbc= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -646,22 +677,29 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/zpages v0.62.0 h1:9fUYTLmrK0x/lweM2uM+BOx069jLx8PxVqWhegGJ9Bo= go.opentelemetry.io/contrib/zpages v0.62.0/go.mod h1:C8kXoiC1Ytvereztus2R+kqdSa6W/MZ8FfS8Zwj+LiM= +go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= @@ -673,11 +711,14 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= @@ -688,8 +729,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= @@ -701,8 +740,10 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= @@ -712,6 +753,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -736,8 +778,10 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= @@ -760,6 +804,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -790,7 +836,9 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -802,6 +850,7 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= @@ -856,7 +905,9 @@ golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -932,11 +983,13 @@ google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk= +google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo= google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A= google.golang.org/genproto/googleapis/api v0.0.0-20250425173222-7b384671a197/go.mod h1:Cd8IzgPo5Akum2c9R6FsXNaZbH3Jpa2gpHlW89FqlyQ= google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:W3S/3np0/dPWsWLi1h/UymYctGXaGBM2StwzD0y140U= google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074/go.mod h1:vYFwMYFbmA8vl6Z/krj/h7+U/AqpHknwJX4Uqgfyc7I= +google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs= google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250929231259-57b25ae835d4/go.mod h1:YUQUKndxDbAanQC0ln4pZ3Sis3N5sqgDte2XQqufkJc= @@ -949,7 +1002,11 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= @@ -961,18 +1018,31 @@ google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s= google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/grpc v1.74.3/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20 h1:MLBCGN1O7GzIx+cBiwfYPwtmZ41U3Mn/cotLJciaArI= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0= +google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6 h1:ExN12ndbJ608cboPYflpTny6mXSzPrDLh0iTaVrRrds= +google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8= +google.golang.org/grpc/gcp/observability v1.0.1 h1:2IQ7szW1gobfZaS/sDSAu2uxO0V/aTryMZvlcyqKqQA= +google.golang.org/grpc/gcp/observability v1.0.1/go.mod h1:yM0UcrYRMe/B+Nu0mDXeTJNDyIMJRJnzuxqnJMz7Ewk= +google.golang.org/grpc/security/advancedtls v1.0.0 h1:/KQ7VP/1bs53/aopk9QhuPyFAp9Dm9Ejix3lzYkCrDA= +google.golang.org/grpc/security/advancedtls v1.0.0/go.mod h1:o+s4go+e1PJ2AjuQMY5hU82W7lDlefjJA6FqEHRVHWk= +google.golang.org/grpc/stats/opencensus v1.0.0 h1:evSYcRZaSToQp+borzWE52+03joezZeXcKJvZDfkUJA= +google.golang.org/grpc/stats/opencensus v1.0.0/go.mod h1:FhdkeYvN43wLYUnapVuRJJ9JXkNwe403iLUW2LKSnjs= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= @@ -982,9 +1052,13 @@ honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc h1:/hemPrYIhOhy8zYrNj+069zDB68us2sMGsfkFJO0iZs= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/pkg/gofr/auth_test.go b/pkg/gofr/auth_test.go new file mode 100644 index 0000000000..505b6e6c53 --- /dev/null +++ b/pkg/gofr/auth_test.go @@ -0,0 +1,126 @@ +package gofr + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gofr.dev/pkg/gofr/config" + "gofr.dev/pkg/gofr/container" + gofrHTTP "gofr.dev/pkg/gofr/http" +) + +func TestEnableRBAC_WithoutProvider(t *testing.T) { + app := &App{ + container: container.NewContainer(config.NewMockConfig(nil)), + httpServer: &httpServer{ + router: gofrHTTP.NewRouter(), + }, + } + + // Test new API - EnableRBAC requires provider + app.EnableRBAC(nil, "test.json") + // Should log error about nil provider +} + +func TestEnableRBAC_NoConfigProvided(_ *testing.T) { + app := &App{ + container: container.NewContainer(config.NewMockConfig(nil)), + httpServer: &httpServer{ + router: gofrHTTP.NewRouter(), + }, + } + + // Create a mock provider + mockProvider := &mockRBACProvider{} + app.EnableRBAC(mockProvider, "") // Empty string uses default paths +} + +// mockRBACProvider is a minimal mock for testing +type mockRBACProvider struct{} + +func (m *mockRBACProvider) LoadPermissions(file string) (any, error) { + return nil, nil +} + +func (m *mockRBACProvider) GetMiddleware(config any) func(http.Handler) http.Handler { + return func(handler http.Handler) http.Handler { + return handler + } +} + +func (m *mockRBACProvider) RequireRole(allowedRole string, handlerFunc func(any) (any, error)) func(any) (any, error) { + return handlerFunc +} + +func (m *mockRBACProvider) RequireAnyRole(allowedRoles []string, handlerFunc func(any) (any, error)) func(any) (any, error) { + return handlerFunc +} + +func (m *mockRBACProvider) RequirePermission(requiredPermission string, permissionConfig any, handlerFunc func(any) (any, error)) func(any) (any, error) { + return handlerFunc +} + +func (m *mockRBACProvider) ErrAccessDenied() error { + return nil +} + +func (m *mockRBACProvider) ErrPermissionDenied() error { + return nil +} + +// Old functional options tests removed - new API uses interface-based options +// See examples/rbac for usage of new API with HeaderRoleExtractor, JWTExtractor, etc. + +func TestRequireRole_WithoutModule(t *testing.T) { + handler := func(_ *Context) (any, error) { + return "success", nil + } + + wrapped := RequireRole("admin", handler) + require.NotNil(t, wrapped) + + ctx := &Context{ + Container: container.NewContainer(config.NewMockConfig(nil)), + } + result, err := wrapped(ctx) + assert.Nil(t, result) + require.Error(t, err) + assert.Contains(t, err.Error(), "RBAC module not imported") +} + +func TestRequireAnyRole_WithoutModule(t *testing.T) { + handler := func(_ *Context) (any, error) { + return "success", nil + } + + wrapped := RequireAnyRole([]string{"admin", "editor"}, handler) + require.NotNil(t, wrapped) + + ctx := &Context{ + Container: container.NewContainer(config.NewMockConfig(nil)), + } + result, err := wrapped(ctx) + assert.Nil(t, result) + require.Error(t, err) + assert.Contains(t, err.Error(), "RBAC module not imported") +} + +func TestRequirePermission_WithoutModule(t *testing.T) { + handler := func(_ *Context) (any, error) { + return "success", nil + } + + wrapped := RequirePermission("users:read", nil, handler) + require.NotNil(t, wrapped) + + ctx := &Context{ + Container: container.NewContainer(config.NewMockConfig(nil)), + } + result, err := wrapped(ctx) + assert.Nil(t, result) + require.Error(t, err) + assert.Contains(t, err.Error(), "RBAC module not imported") +} diff --git a/pkg/gofr/container/container.go b/pkg/gofr/container/container.go index 4e8713e3a0..a3b56d7163 100644 --- a/pkg/gofr/container/container.go +++ b/pkg/gofr/container/container.go @@ -72,6 +72,8 @@ type Container struct { KVStore KVStore File file.FileSystem + + RBAC RBACProvider } func NewContainer(conf config.Config) *Container { diff --git a/pkg/gofr/container/datasources.go b/pkg/gofr/container/datasources.go index 4e69fa2388..46da8c3cdd 100644 --- a/pkg/gofr/container/datasources.go +++ b/pkg/gofr/container/datasources.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "database/sql" + "net/http" "time" "github.com/redis/go-redis/v9" @@ -837,3 +838,31 @@ type InfluxDBProvider interface { provider } + +// RBACProvider is the interface for RBAC implementations. +// External RBAC modules (like gofr.dev/pkg/gofr/rbac) implement this interface. +// This follows the same pattern as datasource providers (e.g., MongoProvider, PostgresProvider). +// Note: This interface uses `any` for types to avoid cyclic imports with gofr package. +type RBACProvider interface { + // LoadPermissions loads RBAC configuration from a file + LoadPermissions(file string) (any, error) + + // GetMiddleware returns the middleware function for the given config + // The returned function should be compatible with http.Handler middleware pattern + GetMiddleware(config any) func(http.Handler) http.Handler + + // RequireRole wraps a handler to require a specific role + RequireRole(allowedRole string, handlerFunc func(any) (any, error)) func(any) (any, error) + + // RequireAnyRole wraps a handler to require any of the specified roles + RequireAnyRole(allowedRoles []string, handlerFunc func(any) (any, error)) func(any) (any, error) + + // RequirePermission wraps a handler to require a specific permission + RequirePermission(requiredPermission string, permissionConfig any, handlerFunc func(any) (any, error)) func(any) (any, error) + + // ErrAccessDenied returns the error used when access is denied + ErrAccessDenied() error + + // ErrPermissionDenied returns the error used when permission is denied + ErrPermissionDenied() error +} diff --git a/pkg/gofr/rbac.go b/pkg/gofr/rbac.go new file mode 100644 index 0000000000..2521a8913f --- /dev/null +++ b/pkg/gofr/rbac.go @@ -0,0 +1,237 @@ +package gofr + +import ( + "errors" + "fmt" + "net/http" + "os" + + "gofr.dev/pkg/gofr/container" +) + +var ( + errRBACModuleNotImportedAccess = errors.New("forbidden: access denied - RBAC module not imported") + errRBACModuleNotImportedPermission = errors.New("forbidden: permission denied - RBAC module not imported") + errRoleHeaderNotFound = errors.New("role header not found") +) + +const ( + // Default RBAC config paths (tried in order). + defaultRBACJSONPath = "configs/rbac.json" + defaultRBACYAMLPath = "configs/rbac.yaml" + defaultRBACYMLPath = "configs/rbac.yml" +) + +// 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. +// If configFile is empty, tries default paths: configs/rbac.json, configs/rbac.yaml, configs/rbac.yml +// +// Example: +// +// // Use default path (configs/rbac.json or configs/rbac.yaml) +// app.EnableRBAC() +// +// // Use custom path +// app.EnableRBAC("configs/custom-rbac.json") +// +// // Use default path with JWT option +// app.EnableRBAC("", &rbac.JWTExtractor{Claim: "role"}) +// +// // Use custom path with options +// app.EnableRBAC("configs/rbac.json", &rbac.HeaderRoleExtractor{HeaderKey: "X-User-Role"}) +// +// Note: When using RBAC options (e.g., &rbac.JWTExtractor{}), you must import the rbac package. +// The rbac package's init() function will automatically register itself when imported. +// Example: import "gofr.dev/pkg/gofr/rbac" +// +// Options follow the same pattern as service.Options - each option implements AddOption method. +// The provider parameter follows the same pattern as datasources (e.g., app.AddMongo(mongoProvider)). +// Users create the provider from the rbac package: provider := rbac.NewProvider() +// Options can be either gofr.RBACOption or rbac.Options (both are accepted). +func (a *App) EnableRBAC(provider container.RBACProvider, configFile string, options ...any) { + if provider == nil { + a.container.Error("RBAC provider is required. Create one using: provider := rbac.NewProvider()") + return + } + + // Resolve config file path + filePath := resolveRBACConfigPath(configFile) + if filePath == "" { + a.container.Warn("RBAC config file not found. Tried: configs/rbac.json, configs/rbac.yaml, configs/rbac.yml") + return + } + + // Load configuration from file using the provider + configAny, err := provider.LoadPermissions(filePath) + if err != nil { + a.container.Errorf("Failed to load RBAC config from %s: %v", filePath, err) + return + } + + // Type assert to RBACConfig + // The provider returns *rbac.Config which implements both rbac.RBACConfig and gofr.RBACConfig + var config RBACConfig + if gofrConfig, ok := configAny.(RBACConfig); ok { + config = gofrConfig + } else { + a.container.Error("RBAC provider returned invalid config type") + return + } + + a.container.Infof("Loaded RBAC config from %s", filePath) + + // Auto-configure header extractor if RoleHeader is in config + autoConfigureHeaderExtractor(config) + + // Apply user-provided options (following service.Options pattern) + // Options can be either gofr.RBACOption or rbac.Options + // Since *rbac.Config implements both rbac.RBACConfig and gofr.RBACConfig + // (they have identical method signatures), we can pass the config directly + for _, opt := range options { + // Try gofr.RBACOption first + if gofrOpt, ok := opt.(RBACOption); ok { + config = gofrOpt.AddOption(config) + } else if rbacOpt, ok := opt.(interface { + AddOption(interface{}) interface{} + }); ok { + // Handle rbac.Options (from rbac package) + // Since *rbac.Config implements both interfaces, we can pass it + result := rbacOpt.AddOption(config) + if resultConfig, ok := result.(RBACConfig); ok { + config = resultConfig + } + } + } + + // Setup logger + if config.GetLogger() == nil { + config.SetLogger(a.container.Logger) + } + + // Initialize maps + config.InitializeMaps() + + // Auto-detect permissions if PermissionConfig is set + if config.GetPermissionConfig() != nil { + config.SetEnablePermissions(true) + } + + // Apply middleware using the provider + middlewareFunc := provider.GetMiddleware(config) + a.httpServer.router.Use(middlewareFunc) + + // Store provider for RequireRole, RequireAnyRole, RequirePermission functions + a.container.RBAC = provider +} + +// 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 "" +} + +// autoConfigureHeaderExtractor automatically configures header-based role extraction +// if RoleHeader is set in the config file. +func autoConfigureHeaderExtractor(config RBACConfig) { + if roleHeader := config.GetRoleHeader(); roleHeader != "" { + // Create header extractor and apply it + config.SetRoleExtractorFunc(func(req *http.Request, _ ...any) (string, error) { + role := req.Header.Get(roleHeader) + if role == "" { + return "", fmt.Errorf("%w: %q", errRoleHeaderNotFound, roleHeader) + } + + return role, nil + }) + } +} + + +// RequireRole wraps a handler to require a specific role. +// This is a convenience wrapper that works with GoFr's Handler type. +// +// Note: RBAC must be enabled via app.EnableRBAC() before using this function. +func RequireRole(allowedRole string, handlerFunc Handler) Handler { + // Get RBAC provider from context (set by EnableRBAC) + // This follows the same pattern as accessing datasources from context + return func(ctx *Context) (any, error) { + provider := ctx.RBAC + if provider == nil { + return nil, errRBACModuleNotImportedAccess + } + + rbacHandler := provider.RequireRole(allowedRole, func(ctx any) (any, error) { + if gofrCtx, ok := ctx.(*Context); ok { + return handlerFunc(gofrCtx) + } + + return nil, provider.ErrAccessDenied() + }) + + return rbacHandler(ctx) + } +} + +// RequireAnyRole wraps a handler to require any of the specified roles. +// This is a convenience wrapper that works with GoFr's Handler type. +// +// Note: RBAC must be enabled via app.EnableRBAC() before using this function. +func RequireAnyRole(allowedRoles []string, handlerFunc Handler) Handler { + return func(ctx *Context) (any, error) { + provider := ctx.RBAC + if provider == nil { + return nil, errRBACModuleNotImportedAccess + } + + rbacHandler := provider.RequireAnyRole(allowedRoles, func(ctx any) (any, error) { + if gofrCtx, ok := ctx.(*Context); ok { + return handlerFunc(gofrCtx) + } + + return nil, provider.ErrAccessDenied() + }) + + return rbacHandler(ctx) + } +} + +// RequirePermission wraps a handler to require a specific permission. +// This works with permission-based access control. +// The permissionConfig must be set in the RBAC config. +// +// Note: RBAC must be enabled via app.EnableRBAC() before using this function. +func RequirePermission(requiredPermission string, permissionConfig PermissionConfig, handlerFunc Handler) Handler { + return func(ctx *Context) (any, error) { + provider := ctx.RBAC + if provider == nil { + return nil, errRBACModuleNotImportedPermission + } + + rbacHandler := provider.RequirePermission(requiredPermission, permissionConfig, func(ctx any) (any, error) { + if gofrCtx, ok := ctx.(*Context); ok { + return handlerFunc(gofrCtx) + } + + return nil, provider.ErrPermissionDenied() + }) + + return rbacHandler(ctx) + } +} diff --git a/pkg/gofr/rbac/config.go b/pkg/gofr/rbac/config.go new file mode 100644 index 0000000000..c026ec8104 --- /dev/null +++ b/pkg/gofr/rbac/config.go @@ -0,0 +1,275 @@ +package rbac + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + + "gopkg.in/yaml.v3" +) + +var ( + // errUnsupportedFormat is returned when the config file format is not supported. + errUnsupportedFormat = errors.New("unsupported config file format") +) + +// Config represents the RBAC configuration structure. +type Config struct { + // RouteWithPermissions maps route patterns to allowed roles + // Example: "/api/users": ["admin", "editor"] + RouteWithPermissions map[string][]string `json:"route" yaml:"route"` + + // RoleHeader specifies the HTTP header key for role extraction + // Example: "X-User-Role" + // If set, automatically creates a header-based role extractor + RoleHeader string `json:"roleHeader,omitempty" yaml:"roleHeader,omitempty"` + + // RoleExtractorFunc extracts the user's role from the HTTP request + // This function is called for each request to determine the user's role + // Args will be empty for header/JWT-based extraction + RoleExtractorFunc func(req *http.Request, args ...any) (string, error) + + // OverRides allows bypassing authorization for specific routes + // Example: "/health": true (allows access without role check) + OverRides map[string]bool `json:"overrides,omitempty" yaml:"overrides,omitempty"` + + // DefaultRole is used when no role can be extracted + // If empty, missing role results in unauthorized error + // ⚠️ Security Warning: Using defaultRole can be a security flaw if not carefully considered. + // Only use for internal services in trusted networks or development/testing environments. + DefaultRole string `json:"defaultRole,omitempty" yaml:"defaultRole,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) + + // PermissionConfig enables permission-based access control + // If set, permissions are checked instead of (or in addition to) roles + PermissionConfig *PermissionConfig `json:"permissions,omitempty" yaml:"permissions,omitempty"` + + // EnablePermissions enables permission-based checks + // Auto-detected from PermissionConfig presence, but can be set explicitly + EnablePermissions bool `json:"-" yaml:"-"` + + // RoleHierarchy defines role inheritance relationships + // Example: "admin": ["editor", "author", "viewer"] + RoleHierarchy map[string][]string `json:"roleHierarchy,omitempty" yaml:"roleHierarchy,omitempty"` + + // Logger is the logger instance for audit logging + // If nil, audit logging will be skipped + // Audit logging is automatically performed when Logger is set + Logger Logger `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) + } + + // Apply environment variable overrides + applyEnvOverrides(&config) + + // Initialize empty maps if not present + if config.RouteWithPermissions == nil { + config.RouteWithPermissions = make(map[string][]string) + } + + if config.OverRides == nil { + config.OverRides = make(map[string]bool) + } + + // Auto-detect permissions if PermissionConfig is set + if config.PermissionConfig != nil { + config.EnablePermissions = true + // Compile regex patterns in route rules + if err := config.PermissionConfig.CompileRoutePermissionRules(); err != nil { + return nil, fmt.Errorf("failed to compile route permission rules: %w", err) + } + } + + return &config, nil +} + +// applyEnvOverrides applies environment variable overrides to the config. +// Environment variables take precedence over file-based configuration. +func applyEnvOverrides(config *Config) { + // Override default role from environment + if defaultRole := os.Getenv("RBAC_DEFAULT_ROLE"); defaultRole != "" { + config.DefaultRole = defaultRole + } + + // Override specific routes from environment + // Format: RBAC_ROUTE_=role1,role2,role3 + // Example: RBAC_ROUTE_/api/users=admin,editor + for _, env := range os.Environ() { + key, value, found := strings.Cut(env, "=") + if !found { + continue + } + + if strings.HasPrefix(key, "RBAC_ROUTE_") { + applyRouteOverride(config, key, value) + } + + if strings.HasPrefix(key, "RBAC_OVERRIDE_") { + applyOverrideFlag(config, key, value) + } + } +} + +// applyRouteOverride applies a route override from environment variable. +func applyRouteOverride(config *Config, key, value string) { + route := strings.TrimPrefix(key, "RBAC_ROUTE_") + route = strings.ReplaceAll(route, "_", "/") + roles := strings.Split(value, ",") + + // Trim whitespace from roles + for i, role := range roles { + roles[i] = strings.TrimSpace(role) + } + + if config.RouteWithPermissions == nil { + config.RouteWithPermissions = make(map[string][]string) + } + + config.RouteWithPermissions[route] = roles +} + +// applyOverrideFlag applies an override flag from environment variable. +func applyOverrideFlag(config *Config, key, value string) { + route := strings.TrimPrefix(key, "RBAC_OVERRIDE_") + route = strings.ReplaceAll(route, "_", "/") + + if strings.EqualFold(value, "true") || value == "1" { + if config.OverRides == nil { + config.OverRides = make(map[string]bool) + } + + config.OverRides[route] = true + } +} + +// ConfigLoader manages loading of RBAC configuration. +type ConfigLoader struct { + config *Config + mu sync.RWMutex +} + +// NewConfigLoaderWithLogger creates a new ConfigLoader with a logger for error reporting. +func NewConfigLoaderWithLogger(path string, logger Logger) (*ConfigLoader, error) { + config, err := LoadPermissions(path) + if err != nil { + return nil, err + } + + if logger != nil { + config.Logger = logger + } + + loader := &ConfigLoader{ + config: config, + } + + return loader, nil +} + +// GetConfig returns the current configuration (thread-safe). +func (l *ConfigLoader) GetConfig() RBACConfig { + l.mu.RLock() + defer l.mu.RUnlock() + + return l.config +} + +// Implement RBACConfig interface methods + +// GetRouteWithPermissions returns the route-to-roles mapping. +func (c *Config) GetRouteWithPermissions() map[string][]string { + return c.RouteWithPermissions +} + +// GetRoleExtractorFunc returns the role extractor function. +func (c *Config) GetRoleExtractorFunc() RoleExtractor { + return c.RoleExtractorFunc +} + +// GetPermissionConfig returns permission configuration if enabled. +func (c *Config) GetPermissionConfig() any { + // Return as any to match interface, will be type-asserted by callers + return c.PermissionConfig +} + +// GetOverRides returns route overrides. +func (c *Config) GetOverRides() map[string]bool { + return c.OverRides +} + +// GetLogger returns the logger instance. +func (c *Config) GetLogger() any { + return c.Logger +} + + +// GetRoleHeader returns the role header key if configured. +func (c *Config) GetRoleHeader() string { + return c.RoleHeader +} + +// SetRoleExtractorFunc sets the role extractor function. +func (c *Config) SetRoleExtractorFunc(extractor RoleExtractor) { + c.RoleExtractorFunc = extractor +} + +// SetLogger sets the logger instance. +func (c *Config) SetLogger(logger any) { + if l, ok := logger.(Logger); ok { + c.Logger = l + } +} + + +// SetEnablePermissions enables permission-based access control. +func (c *Config) SetEnablePermissions(enabled bool) { + c.EnablePermissions = enabled +} + +// InitializeMaps initializes empty maps if not present. +func (c *Config) InitializeMaps() { + if c.RouteWithPermissions == nil { + c.RouteWithPermissions = make(map[string][]string) + } + + if c.OverRides == nil { + c.OverRides = make(map[string]bool) + } + + if c.RoleHierarchy == nil { + c.RoleHierarchy = make(map[string][]string) + } +} diff --git a/pkg/gofr/rbac/config_test.go b/pkg/gofr/rbac/config_test.go new file mode 100644 index 0000000000..1e42763052 --- /dev/null +++ b/pkg/gofr/rbac/config_test.go @@ -0,0 +1,736 @@ +package rbac + +import ( + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadPermissions_Success(t *testing.T) { + jsonContent := `{ + "route": {"admin":["read", "write"], "user":["read"]}, + "overrides": {"admin":true, "user":false} + }` + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*.json") + require.NoError(t, err) + + _, err = tempFile.WriteString(jsonContent) + require.NoError(t, err) + tempFile.Close() + + cfg, err := LoadPermissions(tempFile.Name()) + require.NoError(t, err) + assert.Equal(t, map[string][]string{"admin": {"read", "write"}, "user": {"read"}}, cfg.RouteWithPermissions) + assert.Equal(t, map[string]bool{"admin": true, "user": false}, cfg.OverRides) +} + +func TestLoadPermissions_FileNotFound(t *testing.T) { + cfg, err := LoadPermissions("non_existent_file.json") + assert.Nil(t, cfg) + assert.Error(t, err) +} + +func TestLoadPermissions_InvalidJSON(t *testing.T) { + tempFile, err := os.CreateTemp(t.TempDir(), "badjson_*.json") + require.NoError(t, err) + + defer os.Remove(tempFile.Name()) + + _, err = tempFile.WriteString(`{"route": [INVALID JSON}`) + require.NoError(t, err) + tempFile.Close() + + cfg, err := LoadPermissions(tempFile.Name()) + assert.Nil(t, cfg) + assert.Error(t, err) +} + +func TestLoadPermissions_YAML(t *testing.T) { + yamlContent := `route: + /api/users: + - admin + - editor + /api/posts: + - admin + - author +overrides: + /health: true +defaultRole: viewer +` + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*.yaml") + require.NoError(t, err) + + _, err = tempFile.WriteString(yamlContent) + require.NoError(t, err) + tempFile.Close() + + cfg, err := LoadPermissions(tempFile.Name()) + require.NoError(t, err) + assert.Equal(t, []string{"admin", "editor"}, cfg.RouteWithPermissions["/api/users"]) + assert.Equal(t, []string{"admin", "author"}, cfg.RouteWithPermissions["/api/posts"]) + assert.True(t, cfg.OverRides["/health"]) + assert.Equal(t, "viewer", cfg.DefaultRole) +} + +func TestLoadPermissions_YML(t *testing.T) { + yamlContent := `route: + /api/users: + - admin +` + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*.yml") + require.NoError(t, err) + + _, err = tempFile.WriteString(yamlContent) + require.NoError(t, err) + tempFile.Close() + + cfg, err := LoadPermissions(tempFile.Name()) + require.NoError(t, err) + assert.Equal(t, []string{"admin"}, cfg.RouteWithPermissions["/api/users"]) +} + +func TestLoadPermissions_UnsupportedFormat(t *testing.T) { + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*.xml") + require.NoError(t, err) + + _, err = tempFile.WriteString(``) + require.NoError(t, err) + tempFile.Close() + + cfg, err := LoadPermissions(tempFile.Name()) + assert.Nil(t, cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported config file format") +} + +func TestLoadPermissions_InvalidYAML(t *testing.T) { + tempFile, err := os.CreateTemp(t.TempDir(), "badyaml_*.yaml") + require.NoError(t, err) + + _, err = tempFile.WriteString(`route: [invalid: yaml`) + require.NoError(t, err) + tempFile.Close() + + cfg, err := LoadPermissions(tempFile.Name()) + assert.Nil(t, cfg) + assert.Error(t, err) +} + +func TestLoadPermissions_EnvOverrides(t *testing.T) { + // Set environment variables + t.Setenv("RBAC_DEFAULT_ROLE", "test-role") + t.Setenv("RBAC_ROUTE_/api/test", "admin,editor") + t.Setenv("RBAC_OVERRIDE_/public", "true") + + defer func() { + os.Unsetenv("RBAC_DEFAULT_ROLE") + os.Unsetenv("RBAC_ROUTE_/api/test") + os.Unsetenv("RBAC_OVERRIDE_/public") + }() + + jsonContent := `{ + "route": {"admin":["read"]}, + "overrides": {} + }` + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*.json") + require.NoError(t, err) + + _, err = tempFile.WriteString(jsonContent) + require.NoError(t, err) + tempFile.Close() + + cfg, err := LoadPermissions(tempFile.Name()) + require.NoError(t, err) + + // Check environment variable overrides + assert.Equal(t, "test-role", cfg.DefaultRole) + assert.Equal(t, []string{"admin", "editor"}, cfg.RouteWithPermissions["/api/test"]) + assert.True(t, cfg.OverRides["/public"]) +} + +func TestLoadPermissions_InitializesEmptyMaps(t *testing.T) { + jsonContent := `{}` + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*.json") + require.NoError(t, err) + + _, err = tempFile.WriteString(jsonContent) + require.NoError(t, err) + tempFile.Close() + + cfg, err := LoadPermissions(tempFile.Name()) + require.NoError(t, err) + assert.NotNil(t, cfg.RouteWithPermissions) + assert.NotNil(t, cfg.OverRides) +} + +func TestNewConfigLoaderWithLogger_NilLogger(t *testing.T) { + jsonContent := `{ + "route": {"admin":["read"]} + }` + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*.json") + require.NoError(t, err) + + _, err = tempFile.WriteString(jsonContent) + require.NoError(t, err) + tempFile.Close() + + // Test config loader + loader, err := NewConfigLoaderWithLogger(tempFile.Name(), nil) + require.NoError(t, err) + assert.NotNil(t, loader) + + config := loader.GetConfig() + assert.NotNil(t, config) + assert.Equal(t, []string{"read"}, config.GetRouteWithPermissions()["admin"]) +} + +func TestConfigLoader_GetConfig_ThreadSafe(t *testing.T) { + jsonContent := `{ + "route": {"admin":["read"]} + }` + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*.json") + require.NoError(t, err) + + _, err = tempFile.WriteString(jsonContent) + require.NoError(t, err) + tempFile.Close() + + loader, err := NewConfigLoaderWithLogger(tempFile.Name(), nil) + require.NoError(t, err) + + // Concurrent reads + done := make(chan bool, 10) + + for i := 0; i < 10; i++ { + go func() { + config := loader.GetConfig() + assert.NotNil(t, config) + + done <- true + }() + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } +} + +func TestLoadPermissions_WithPermissionConfig(t *testing.T) { + jsonContent := `{ + "route": {"admin":["read"]}, + "permissions": { + "permissions": { + "users:read": ["admin", "editor"], + "users:write": ["admin"] + }, + "routePermissions": { + "GET /api/users": "users:read", + "POST /api/users": "users:write" + } + }, + "enablePermissions": true + }` + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*.json") + require.NoError(t, err) + + _, err = tempFile.WriteString(jsonContent) + require.NoError(t, err) + tempFile.Close() + + cfg, err := LoadPermissions(tempFile.Name()) + require.NoError(t, err) + assert.NotNil(t, cfg.PermissionConfig) + assert.Equal(t, []string{"admin", "editor"}, cfg.PermissionConfig.Permissions["users:read"]) + assert.Equal(t, "users:read", cfg.PermissionConfig.RoutePermissionMap["GET /api/users"]) + assert.True(t, cfg.EnablePermissions) +} + +func TestLoadPermissions_WithRoleHierarchy(t *testing.T) { + jsonContent := `{ + "route": {"admin":["read"]}, + "roleHierarchy": { + "admin": ["editor", "author", "viewer"], + "editor": ["author", "viewer"] + } + }` + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*.json") + require.NoError(t, err) + + _, err = tempFile.WriteString(jsonContent) + require.NoError(t, err) + tempFile.Close() + + cfg, err := LoadPermissions(tempFile.Name()) + require.NoError(t, err) + assert.Equal(t, []string{"editor", "author", "viewer"}, cfg.RoleHierarchy["admin"]) + assert.Equal(t, []string{"author", "viewer"}, cfg.RoleHierarchy["editor"]) +} + +func TestLoadPermissions_FileExtensionDetection(t *testing.T) { + tests := []struct { + name string + ext string + content string + wantErr bool + checkErr func(*testing.T, error) + }{ + { + name: "JSON file", + ext: ".json", + content: `{"route": {"admin":["read"]}}`, + wantErr: false, + }, + { + name: "YAML file", + ext: ".yaml", + content: `route:\n admin: [read]`, + wantErr: false, + }, + { + name: "YML file", + ext: ".yml", + content: `route:\n admin: [read]`, + wantErr: false, + }, + { + name: "No extension defaults to JSON", + ext: "", + content: `{"route": {"admin":["read"]}}`, + wantErr: false, + }, + { + name: "Unsupported format", + ext: ".xml", + content: ``, + wantErr: true, + checkErr: func(t *testing.T, err error) { + t.Helper() + assert.Contains(t, err.Error(), "unsupported config file format") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*"+tt.ext) + require.NoError(t, err) + + defer os.Remove(tempFile.Name()) + + _, err = tempFile.WriteString(tt.content) + require.NoError(t, err) + tempFile.Close() + + cfg, err := LoadPermissions(tempFile.Name()) + assertLoadPermissionsResult(t, cfg, err, tt.wantErr, tt.checkErr) + }) + } +} + +func TestLoadPermissions_NoExtensionFile(t *testing.T) { + jsonContent := `{"route": {"admin":["read"]}}` + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions") + require.NoError(t, err) + + _, err = tempFile.WriteString(jsonContent) + require.NoError(t, err) + tempFile.Close() + + cfg, err := LoadPermissions(tempFile.Name()) + require.NoError(t, err) + assert.Equal(t, []string{"read"}, cfg.RouteWithPermissions["admin"]) +} + +func TestConfigLoader_GetConfig_ConcurrentAccess(t *testing.T) { + jsonContent := `{ + "route": {"admin":["read"]} + }` + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*.json") + require.NoError(t, err) + + _, err = tempFile.WriteString(jsonContent) + require.NoError(t, err) + tempFile.Close() + + loader, err := NewConfigLoaderWithLogger(tempFile.Name(), nil) + require.NoError(t, err) + + // Concurrent reads and writes + done := make(chan bool, 20) + + for i := 0; i < 10; i++ { + go func() { + config := loader.GetConfig() + assert.NotNil(t, config) + + done <- true + }() + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } +} + +func TestConfig_GetPermissionConfig_Nil(t *testing.T) { + config := &Config{} + assert.Nil(t, config.GetPermissionConfig()) +} + +func TestConfig_GetLogger_Nil(t *testing.T) { + config := &Config{} + assert.Nil(t, config.GetLogger()) +} + +func TestLoadPermissions_ErrorMessages(t *testing.T) { + tests := []struct { + name string + setup func() string + wantErrMsg string + cleanup func(string) + }{ + { + name: "File not found", + setup: func() string { + return "nonexistent_file.json" + }, + wantErrMsg: "failed to read RBAC config file", + cleanup: func(string) {}, + }, + { + name: "Invalid JSON", + setup: func() string { + tempFile, _ := os.CreateTemp(t.TempDir(), "bad_*.json") + _, _ = tempFile.WriteString(`{invalid json}`) + tempFile.Close() + return tempFile.Name() + }, + wantErrMsg: "failed to parse JSON config file", + cleanup: func(path string) { + os.Remove(path) + }, + }, + { + name: "Invalid YAML", + setup: func() string { + tempFile, _ := os.CreateTemp(t.TempDir(), "bad_*.yaml") + _, _ = tempFile.WriteString(`invalid: yaml: [`) + tempFile.Close() + return tempFile.Name() + }, + wantErrMsg: "failed to parse YAML config file", + cleanup: func(path string) { + os.Remove(path) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup() + defer tt.cleanup(path) + + cfg, err := LoadPermissions(path) + assert.Nil(t, cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrMsg) + }) + } +} + +// assertLoadPermissionsResult asserts LoadPermissions results without nested if-else. +func assertLoadPermissionsResult(t *testing.T, cfg *Config, err error, wantErr bool, checkErr func(*testing.T, error)) { + t.Helper() + + if wantErr { + require.Error(t, err) + + if checkErr != nil { + checkErr(t, err) + } + + return + } + + require.NoError(t, err) + assert.NotNil(t, cfg) +} + +func TestConfig_GetterMethods(t *testing.T) { + extractor := func(_ *http.Request, _ ...any) (string, error) { + return "admin", nil + } + permissionConfig := &PermissionConfig{ + Permissions: map[string][]string{ + "users:read": {"admin"}, + }, + } + mockLogger := &mockLogger{} + + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/users": {"admin"}, + }, + RoleExtractorFunc: extractor, + PermissionConfig: permissionConfig, + OverRides: map[string]bool{"/health": true}, + Logger: mockLogger, + EnablePermissions: true, + } + + assert.Equal(t, map[string][]string{"/api/users": {"admin"}}, config.GetRouteWithPermissions()) + assert.NotNil(t, config.GetRoleExtractorFunc()) + assert.Equal(t, permissionConfig, config.GetPermissionConfig()) + assert.Equal(t, map[string]bool{"/health": true}, config.GetOverRides()) + assert.Equal(t, mockLogger, config.GetLogger()) +} + +func TestConfig_SetterMethods(t *testing.T) { + config := &Config{} + extractor := func(_ *http.Request, _ ...any) (string, error) { + return "admin", nil + } + mockLogger := &mockLogger{} + + config.SetRoleExtractorFunc(extractor) + assert.NotNil(t, config.RoleExtractorFunc) + + config.SetLogger(mockLogger) + assert.Equal(t, mockLogger, config.Logger) + + config.SetLogger("not-a-logger") + assert.Equal(t, mockLogger, config.Logger) // Should remain unchanged + + config.SetEnablePermissions(true) + assert.True(t, config.EnablePermissions) + + config.SetEnablePermissions(false) + assert.False(t, config.EnablePermissions) +} + +func TestConfig_InitializeMaps(t *testing.T) { + t.Run("AllMapsNil", func(t *testing.T) { + config := &Config{} + config.InitializeMaps() + + assert.NotNil(t, config.RouteWithPermissions) + assert.NotNil(t, config.OverRides) + assert.NotNil(t, config.RoleHierarchy) + }) + + t.Run("SomeMapsNil", func(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{"/test": {"admin"}}, + } + config.InitializeMaps() + + assert.NotNil(t, config.RouteWithPermissions) + assert.Equal(t, []string{"admin"}, config.RouteWithPermissions["/test"]) + assert.NotNil(t, config.OverRides) + assert.NotNil(t, config.RoleHierarchy) + }) + + t.Run("AllMapsInitialized", func(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{"/test": {"admin"}}, + OverRides: map[string]bool{"/health": true}, + RoleHierarchy: map[string][]string{"admin": {"editor"}}, + } + config.InitializeMaps() + + assert.Equal(t, []string{"admin"}, config.RouteWithPermissions["/test"]) + assert.True(t, config.OverRides["/health"]) + assert.Equal(t, []string{"editor"}, config.RoleHierarchy["admin"]) + }) +} + +func TestNewConfigLoaderWithLogger(t *testing.T) { + jsonContent := `{ + "route": {"admin":["read"]} + }` + tempFile, err := os.CreateTemp(t.TempDir(), "test_permissions_*.json") + require.NoError(t, err) + + _, err = tempFile.WriteString(jsonContent) + require.NoError(t, err) + tempFile.Close() + + mockLogger := &mockLogger{} + + loader, err := NewConfigLoaderWithLogger(tempFile.Name(), mockLogger) + require.NoError(t, err) + assert.NotNil(t, loader) + + config := loader.GetConfig() + assert.NotNil(t, config) + assert.Equal(t, mockLogger, config.GetLogger()) +} + +func TestApplyEnvOverrides_EdgeCases(t *testing.T) { + t.Run("RouteOverrideWithUnderscores", func(t *testing.T) { + t.Setenv("RBAC_ROUTE_/api_test_users", "admin,editor") + + defer os.Unsetenv("RBAC_ROUTE_/api_test_users") + + config := &Config{} + applyEnvOverrides(config) + + assert.Equal(t, []string{"admin", "editor"}, config.RouteWithPermissions["/api/test/users"]) + }) + + t.Run("RouteOverrideWithSpaces", func(t *testing.T) { + t.Setenv("RBAC_ROUTE_/api/users", "admin, editor, viewer") + + defer os.Unsetenv("RBAC_ROUTE_/api/users") + + config := &Config{} + applyEnvOverrides(config) + + assert.Equal(t, []string{"admin", "editor", "viewer"}, config.RouteWithPermissions["/api/users"]) + }) + + t.Run("OverrideFlagFalse", func(t *testing.T) { + t.Setenv("RBAC_OVERRIDE_/public", "false") + + defer os.Unsetenv("RBAC_OVERRIDE_/public") + + config := &Config{} + applyEnvOverrides(config) + + assert.Nil(t, config.OverRides) + }) + + t.Run("OverrideFlagZero", func(t *testing.T) { + t.Setenv("RBAC_OVERRIDE_/public", "0") + + defer os.Unsetenv("RBAC_OVERRIDE_/public") + + config := &Config{} + applyEnvOverrides(config) + + assert.Nil(t, config.OverRides) + }) + + t.Run("OverrideFlagOne", func(t *testing.T) { + t.Setenv("RBAC_OVERRIDE_/public", "1") + + defer os.Unsetenv("RBAC_OVERRIDE_/public") + + config := &Config{} + applyEnvOverrides(config) + + assert.True(t, config.OverRides["/public"]) + }) + + t.Run("OverrideFlagCaseInsensitive", func(t *testing.T) { + t.Setenv("RBAC_OVERRIDE_/public", "TRUE") + + defer os.Unsetenv("RBAC_OVERRIDE_/public") + + config := &Config{} + applyEnvOverrides(config) + + assert.True(t, config.OverRides["/public"]) + }) + + t.Run("RouteOverrideSingleRole", func(t *testing.T) { + t.Setenv("RBAC_ROUTE_/api/test", "admin") + + defer os.Unsetenv("RBAC_ROUTE_/api/test") + + config := &Config{} + applyEnvOverrides(config) + + assert.Equal(t, []string{"admin"}, config.RouteWithPermissions["/api/test"]) + }) + + t.Run("RouteOverrideEmptyValue", func(t *testing.T) { + t.Setenv("RBAC_ROUTE_/api/test", "") + + defer os.Unsetenv("RBAC_ROUTE_/api/test") + + config := &Config{} + applyEnvOverrides(config) + + assert.Equal(t, []string{""}, config.RouteWithPermissions["/api/test"]) + }) +} + +func TestApplyRouteOverride(t *testing.T) { + t.Run("WithNilRouteWithPermissions", func(t *testing.T) { + config := &Config{} + applyRouteOverride(config, "RBAC_ROUTE_/api/test", "admin,editor") + + assert.NotNil(t, config.RouteWithPermissions) + assert.Equal(t, []string{"admin", "editor"}, config.RouteWithPermissions["/api/test"]) + }) + + t.Run("WithExistingRouteWithPermissions", func(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/other": {"viewer"}, + }, + } + applyRouteOverride(config, "RBAC_ROUTE_/api/test", "admin,editor") + + assert.Equal(t, []string{"admin", "editor"}, config.RouteWithPermissions["/api/test"]) + assert.Equal(t, []string{"viewer"}, config.RouteWithPermissions["/other"]) + }) + + t.Run("WithMultipleUnderscores", func(t *testing.T) { + config := &Config{} + applyRouteOverride(config, "RBAC_ROUTE_/api_test_users", "admin") + + assert.Equal(t, []string{"admin"}, config.RouteWithPermissions["/api/test/users"]) + }) +} + +func TestApplyOverrideFlag(t *testing.T) { + t.Run("WithTrueValue", func(t *testing.T) { + config := &Config{} + applyOverrideFlag(config, "RBAC_OVERRIDE_/public", "true") + + assert.NotNil(t, config.OverRides) + assert.True(t, config.OverRides["/public"]) + }) + + t.Run("WithOneValue", func(t *testing.T) { + config := &Config{} + applyOverrideFlag(config, "RBAC_OVERRIDE_/public", "1") + + assert.NotNil(t, config.OverRides) + assert.True(t, config.OverRides["/public"]) + }) + + t.Run("WithFalseValue", func(t *testing.T) { + config := &Config{} + applyOverrideFlag(config, "RBAC_OVERRIDE_/public", "false") + + assert.Nil(t, config.OverRides) + }) + + t.Run("WithNilOverRides", func(t *testing.T) { + config := &Config{} + applyOverrideFlag(config, "RBAC_OVERRIDE_/public", "true") + + assert.NotNil(t, config.OverRides) + assert.True(t, config.OverRides["/public"]) + }) + + t.Run("WithExistingOverRides", func(t *testing.T) { + config := &Config{ + OverRides: map[string]bool{ + "/other": true, + }, + } + applyOverrideFlag(config, "RBAC_OVERRIDE_/public", "true") + + assert.True(t, config.OverRides["/public"]) + assert.True(t, config.OverRides["/other"]) + }) +} diff --git a/pkg/gofr/rbac/go.mod b/pkg/gofr/rbac/go.mod new file mode 100644 index 0000000000..8d214e01fe --- /dev/null +++ b/pkg/gofr/rbac/go.mod @@ -0,0 +1,104 @@ +module gofr.dev/pkg/gofr/rbac + +go 1.25 + +replace gofr.dev => ../../.. + +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 0000000000..f5daf8831d --- /dev/null +++ b/pkg/gofr/rbac/go.sum @@ -0,0 +1,377 @@ +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.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= +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= +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/helper.go b/pkg/gofr/rbac/helper.go new file mode 100644 index 0000000000..307fb85747 --- /dev/null +++ b/pkg/gofr/rbac/helper.go @@ -0,0 +1,45 @@ +package rbac + +import "context" + +// ContextValueGetter is an interface for accessing context values. +// This avoids import cycle with gofr package. +type ContextValueGetter interface { + Value(key any) any +} + +// HasRole checks if the context contains the specified role. +// ctx should be a *gofr.Context or any type that implements Value(key any) any. +func HasRole(ctx ContextValueGetter, role string) bool { + if ctx == nil { + return false + } + + expRole, _ := ctx.Value(userRole).(string) + + return expRole == role +} + +// GetUserRole extracts the user role from the context. +// ctx should be a *gofr.Context or any type that implements Value(key any) any. +func GetUserRole(ctx ContextValueGetter) string { + if ctx == nil { + return "" + } + + role, _ := ctx.Value(userRole).(string) + + return role +} + +// HasRoleFromContext is a convenience function that works with standard context.Context. +func HasRoleFromContext(ctx context.Context, role string) bool { + expRole, _ := ctx.Value(userRole).(string) + return expRole == role +} + +// GetUserRoleFromContext is a convenience function that works with standard context.Context. +func GetUserRoleFromContext(ctx context.Context) string { + role, _ := ctx.Value(userRole).(string) + return role +} diff --git a/pkg/gofr/rbac/helper_test.go b/pkg/gofr/rbac/helper_test.go new file mode 100644 index 0000000000..5a450cab59 --- /dev/null +++ b/pkg/gofr/rbac/helper_test.go @@ -0,0 +1,131 @@ +package rbac + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +// mockContextValueGetter implements ContextValueGetter interface for testing. +type mockContextValueGetter struct { + value func(key any) any +} + +func (m *mockContextValueGetter) Value(key any) any { + if m.value != nil { + return m.value(key) + } + + return nil +} + +func TestHasRole(t *testing.T) { + tests := []struct { + name string + ctxRoleVal string + checkRole string + expectedRes bool + }{ + {"matching role", "admin", "admin", true}, + {"non-matching role", "viewer", "admin", false}, + {"empty role in context", "", "admin", false}, + {"nil role in context", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &mockContextValueGetter{ + value: func(key any) any { + if key == userRole { + return tt.ctxRoleVal + } + return nil + }, + } + + got := HasRole(ctx, tt.checkRole) + assert.Equal(t, tt.expectedRes, got) + }) + } +} + +func TestGetUserRole(t *testing.T) { + expectedRole := "editor" + ctx := &mockContextValueGetter{ + value: func(key any) any { + if key == userRole { + return expectedRole + } + return nil + }, + } + + role := GetUserRole(ctx) + assert.Equal(t, expectedRole, role) + + // Test no role set should return "" + emptyCtx := &mockContextValueGetter{ + value: func(_ any) any { + return nil + }, + } + role = GetUserRole(emptyCtx) + assert.Empty(t, role) +} + +func TestHasRoleFromContext(t *testing.T) { + tests := []struct { + name string + ctxRoleVal string + checkRole string + expectedRes bool + }{ + {"matching role", "admin", "admin", true}, + {"non-matching role", "viewer", "admin", false}, + {"empty role in context", "", "admin", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.WithValue(context.Background(), userRole, tt.ctxRoleVal) + got := HasRoleFromContext(ctx, tt.checkRole) + assert.Equal(t, tt.expectedRes, got) + }) + } +} + +func TestGetUserRoleFromContext(t *testing.T) { + expectedRole := "editor" + ctx := context.WithValue(context.Background(), userRole, expectedRole) + + role := GetUserRoleFromContext(ctx) + assert.Equal(t, expectedRole, role) + + // Test no role set should return "" + emptyCtx := context.Background() + role = GetUserRoleFromContext(emptyCtx) + assert.Empty(t, role) +} + +func TestHasRole_NilContext(t *testing.T) { + got := HasRole(nil, "admin") + assert.False(t, got) +} + +func TestGetUserRole_NilContext(t *testing.T) { + role := GetUserRole(nil) + assert.Empty(t, role) +} + +// mockLogger is a simple mock implementation of the Logger interface for testing. +type mockLogger struct{} + +func (*mockLogger) Debug(_ ...any) {} +func (*mockLogger) Debugf(_ string, _ ...any) {} +func (*mockLogger) Info(_ ...any) {} +func (*mockLogger) Infof(_ string, _ ...any) {} +func (*mockLogger) Error(_ ...any) {} +func (*mockLogger) Errorf(_ string, _ ...any) {} +func (*mockLogger) Warn(_ ...any) {} +func (*mockLogger) Warnf(_ string, _ ...any) {} diff --git a/pkg/gofr/rbac/hierarchy.go b/pkg/gofr/rbac/hierarchy.go new file mode 100644 index 0000000000..ed0ab174c2 --- /dev/null +++ b/pkg/gofr/rbac/hierarchy.go @@ -0,0 +1,134 @@ +package rbac + +import ( + "context" + "sync" +) + +// RoleHierarchy manages role inheritance relationships. +// Example: admin > editor > author > viewer. +type RoleHierarchy struct { + // hierarchy maps roles to their inherited roles + // Example: "admin": ["editor", "author", "viewer"] + hierarchy map[string][]string + mu sync.RWMutex +} + +// NewRoleHierarchy creates a new role hierarchy from a map. +func NewRoleHierarchy(hierarchy map[string][]string) *RoleHierarchy { + if hierarchy == nil { + hierarchy = make(map[string][]string) + } + + return &RoleHierarchy{ + hierarchy: hierarchy, + } +} + +// GetEffectiveRoles returns all roles that the given role inherits. +// This includes the role itself and all inherited roles. +func (h *RoleHierarchy) GetEffectiveRoles(role string) []string { + h.mu.RLock() + defer h.mu.RUnlock() + + if role == "" { + return []string{} + } + + // Start with the role itself + effectiveRoles := []string{role} + visited := make(map[string]bool) + visited[role] = true + + // Get inherited roles recursively + h.getInheritedRoles(role, &effectiveRoles, visited) + + return effectiveRoles +} + +// getInheritedRoles recursively collects all inherited roles. +func (h *RoleHierarchy) getInheritedRoles(role string, effectiveRoles *[]string, visited map[string]bool) { + inherited, exists := h.hierarchy[role] + if !exists { + return + } + + for _, inheritedRole := range inherited { + if !visited[inheritedRole] { + visited[inheritedRole] = true + *effectiveRoles = append(*effectiveRoles, inheritedRole) + // Recursively get roles inherited by this role + h.getInheritedRoles(inheritedRole, effectiveRoles, visited) + } + } +} + +// HasRole checks if the user's role (or any inherited role) matches the required role. +func (h *RoleHierarchy) HasRole(ctx context.Context, requiredRole string) bool { + role, _ := ctx.Value(userRole).(string) + if role == "" { + return false + } + + // Direct match + if role == requiredRole { + return true + } + + // Check if required role is in the effective roles (inherited) + effectiveRoles := h.GetEffectiveRoles(role) + for _, effectiveRole := range effectiveRoles { + if effectiveRole == requiredRole { + return true + } + } + + return false +} + +// HasAnyRole checks if the user's role (or any inherited role) matches any of the required roles. +func (h *RoleHierarchy) HasAnyRole(ctx context.Context, requiredRoles []string) bool { + role, _ := ctx.Value(userRole).(string) + if role == "" { + return false + } + + effectiveRoles := h.GetEffectiveRoles(role) + + // Check if any required role matches + for _, requiredRole := range requiredRoles { + // Direct match + if role == requiredRole { + return true + } + + // Check inherited roles + for _, effectiveRole := range effectiveRoles { + if effectiveRole == requiredRole { + return true + } + } + } + + return false +} + +// IsRoleAllowedWithHierarchy checks if a role is allowed, considering hierarchy. +func IsRoleAllowedWithHierarchy(role, route string, config *Config, hierarchy *RoleHierarchy) bool { + if hierarchy == nil { + // Fallback to regular role check + return isRoleAllowed(role, route, config) + } + + // Get effective roles (including inherited) + effectiveRoles := hierarchy.GetEffectiveRoles(role) + + // Check if any effective role is allowed + for _, effectiveRole := range effectiveRoles { + if isRoleAllowed(effectiveRole, route, config) { + return true + } + } + + return false +} diff --git a/pkg/gofr/rbac/hierarchy_test.go b/pkg/gofr/rbac/hierarchy_test.go new file mode 100644 index 0000000000..4d6c520363 --- /dev/null +++ b/pkg/gofr/rbac/hierarchy_test.go @@ -0,0 +1,364 @@ +package rbac + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewRoleHierarchy(t *testing.T) { + hierarchy := map[string][]string{ + "admin": {"editor", "author", "viewer"}, + "editor": {"author", "viewer"}, + "author": {"viewer"}, + } + + rh := NewRoleHierarchy(hierarchy) + assert.NotNil(t, rh) + + // Test nil hierarchy + rh2 := NewRoleHierarchy(nil) + assert.NotNil(t, rh2) +} + +func TestRoleHierarchy_GetEffectiveRoles(t *testing.T) { + hierarchy := map[string][]string{ + "admin": {"editor", "author", "viewer"}, + "editor": {"author", "viewer"}, + "author": {"viewer"}, + } + + rh := NewRoleHierarchy(hierarchy) + + tests := []struct { + name string + role string + want []string + contains []string // Roles that should be in the result + }{ + { + name: "Admin role", + role: "admin", + contains: []string{"admin", "editor", "author", "viewer"}, + }, + { + name: "Editor role", + role: "editor", + contains: []string{"editor", "author", "viewer"}, + }, + { + name: "Author role", + role: "author", + contains: []string{"author", "viewer"}, + }, + { + name: "Viewer role", + role: "viewer", + contains: []string{"viewer"}, + }, + { + name: "Unknown role", + role: "unknown", + contains: []string{"unknown"}, + }, + { + name: "Empty role", + role: "", + contains: []string{}, // Empty role returns empty slice + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + effectiveRoles := rh.GetEffectiveRoles(tt.role) + // Empty role returns empty slice, so skip the contains check + if tt.role != "" { + assert.Contains(t, effectiveRoles, tt.role) // Should always contain itself + } + + for _, expectedRole := range tt.contains { + assert.Contains(t, effectiveRoles, expectedRole, "Effective roles should contain %s", expectedRole) + } + }) + } +} + +func TestRoleHierarchy_GetEffectiveRoles_Circular(t *testing.T) { + // Test that circular references don't cause infinite loops + hierarchy := map[string][]string{ + "admin": {"editor"}, + "editor": {"admin"}, // Circular reference + } + + rh := NewRoleHierarchy(hierarchy) + + effectiveRoles := rh.GetEffectiveRoles("admin") + assert.Contains(t, effectiveRoles, "admin") + assert.Contains(t, effectiveRoles, "editor") + // Should not have duplicates + roleCount := make(map[string]int) + for _, role := range effectiveRoles { + roleCount[role]++ + } + + for _, count := range roleCount { + assert.Equal(t, 1, count, "No role should appear more than once") + } +} + +func TestRoleHierarchy_HasRole(t *testing.T) { + hierarchy := map[string][]string{ + "admin": {"editor", "author", "viewer"}, + "editor": {"author", "viewer"}, + } + + rh := NewRoleHierarchy(hierarchy) + + tests := []struct { + name string + ctxRole string + requiredRole string + want bool + }{ + { + name: "Direct match", + ctxRole: "admin", + requiredRole: "admin", + want: true, + }, + { + name: "Inherited role match", + ctxRole: "admin", + requiredRole: "editor", + want: true, + }, + { + name: "Deep inheritance", + ctxRole: "admin", + requiredRole: "viewer", + want: true, + }, + { + name: "No match", + ctxRole: "viewer", + requiredRole: "admin", + want: false, + }, + { + name: "No role in context", + ctxRole: "", + requiredRole: "admin", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.WithValue(context.Background(), userRole, tt.ctxRole) + got := rh.HasRole(ctx, tt.requiredRole) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRoleHierarchy_HasAnyRole(t *testing.T) { + hierarchy := map[string][]string{ + "admin": {"editor", "author", "viewer"}, + "editor": {"author", "viewer"}, + } + + rh := NewRoleHierarchy(hierarchy) + + tests := []struct { + name string + ctxRole string + requiredRoles []string + want bool + }{ + { + name: "Direct match", + ctxRole: "admin", + requiredRoles: []string{"admin", "editor"}, + want: true, + }, + { + name: "Inherited role match", + ctxRole: "admin", + requiredRoles: []string{"editor", "author"}, + want: true, + }, + { + name: "No match", + ctxRole: "viewer", + requiredRoles: []string{"admin", "editor"}, + want: false, + }, + { + name: "Empty required roles", + ctxRole: "admin", + requiredRoles: []string{}, + want: false, + }, + { + name: "No role in context", + ctxRole: "", + requiredRoles: []string{"admin", "editor"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.WithValue(context.Background(), userRole, tt.ctxRole) + got := rh.HasAnyRole(ctx, tt.requiredRoles) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsRoleAllowedWithHierarchy(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/users": {"editor", "viewer"}, + "/api/admin": {"admin"}, + }, + } + + hierarchy := map[string][]string{ + "admin": {"editor", "viewer"}, + } + + rh := NewRoleHierarchy(hierarchy) + + tests := []struct { + name string + role string + route string + want bool + useHier bool + }{ + { + name: "With hierarchy - admin can access editor route", + role: "admin", + route: "/api/users", + want: true, + useHier: true, + }, + { + name: "With hierarchy - editor can access editor route", + role: "editor", + route: "/api/users", + want: true, + useHier: true, + }, + { + name: "With hierarchy - viewer cannot access admin route", + role: "viewer", + route: "/api/admin", + want: false, + useHier: true, + }, + { + name: "Without hierarchy - admin cannot access editor route", + role: "admin", + route: "/api/users", + want: false, + useHier: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getIsRoleAllowedWithHierarchy(tt.role, tt.route, config, rh, tt.useHier) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsRoleAllowedWithHierarchy_NilHierarchy(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/users": {"admin"}, + }, + } + + // Should fallback to regular role check + got := IsRoleAllowedWithHierarchy("admin", "/api/users", config, nil) + assert.True(t, got) + + got = IsRoleAllowedWithHierarchy("user", "/api/users", config, nil) + assert.False(t, got) +} + +func TestRoleHierarchy_ThreadSafety(t *testing.T) { + hierarchy := map[string][]string{ + "admin": {"editor", "viewer"}, + } + + rh := NewRoleHierarchy(hierarchy) + + // Concurrent reads + done := make(chan bool, 10) + + for i := 0; i < 10; i++ { + go func() { + roles := rh.GetEffectiveRoles("admin") + + assert.NotEmpty(t, roles) + + done <- true + }() + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } +} + +func TestRoleHierarchy_ComplexInheritance(t *testing.T) { + hierarchy := map[string][]string{ + "superadmin": {"admin", "editor", "author", "viewer"}, + "admin": {"editor", "author", "viewer"}, + "editor": {"author", "viewer"}, + "author": {"viewer"}, + } + + rh := NewRoleHierarchy(hierarchy) + + // Test deep inheritance + effectiveRoles := rh.GetEffectiveRoles("superadmin") + assert.Contains(t, effectiveRoles, "superadmin") + assert.Contains(t, effectiveRoles, "admin") + assert.Contains(t, effectiveRoles, "editor") + assert.Contains(t, effectiveRoles, "author") + assert.Contains(t, effectiveRoles, "viewer") + + // Test that superadmin has all roles + ctx := context.WithValue(context.Background(), userRole, "superadmin") + assert.True(t, rh.HasRole(ctx, "viewer")) + assert.True(t, rh.HasRole(ctx, "author")) + assert.True(t, rh.HasRole(ctx, "editor")) + assert.True(t, rh.HasRole(ctx, "admin")) + assert.True(t, rh.HasRole(ctx, "superadmin")) +} + +func TestRoleHierarchy_EmptyHierarchy(t *testing.T) { + rh := NewRoleHierarchy(map[string][]string{}) + + effectiveRoles := rh.GetEffectiveRoles("admin") + assert.Equal(t, []string{"admin"}, effectiveRoles) + + ctx := context.WithValue(context.Background(), userRole, "admin") + assert.True(t, rh.HasRole(ctx, "admin")) + assert.False(t, rh.HasRole(ctx, "editor")) +} + +func getIsRoleAllowedWithHierarchy(role, route string, config *Config, hierarchy *RoleHierarchy, useHier bool) bool { + if !useHier { + return IsRoleAllowedWithHierarchy(role, route, config, nil) + } + + return IsRoleAllowedWithHierarchy(role, route, config, hierarchy) +} diff --git a/pkg/gofr/rbac/interface.go b/pkg/gofr/rbac/interface.go new file mode 100644 index 0000000000..1f0960550c --- /dev/null +++ b/pkg/gofr/rbac/interface.go @@ -0,0 +1,13 @@ +package rbac + +// Logger interface is used by RBAC package to log information. +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/match.go b/pkg/gofr/rbac/match.go new file mode 100644 index 0000000000..c3c1880b6a --- /dev/null +++ b/pkg/gofr/rbac/match.go @@ -0,0 +1,92 @@ +package rbac + +import ( + "path" + "strings" +) + +func isRoleAllowed(role, apiroute string, config *Config) bool { + if config == nil { + return false + } + + // Check if route is overridden (public access) + if config.OverRides != nil && config.OverRides[apiroute] { + return true + } + + routePermissions := findRoutePermissions(apiroute, config) + + return isRoleInPermissions(role, routePermissions) +} + +// findRoutePermissions finds the permissions for a given route. +func findRoutePermissions(apiroute string, config *Config) []string { + if config == nil || config.RouteWithPermissions == nil { + return nil + } + + routePermissions, matchedSpecificRoute := findSpecificRouteMatch(apiroute, config) + + // Only append global permissions if no specific route matched + // This ensures specific routes take precedence over global wildcard + if !matchedSpecificRoute { + routePermissions = appendGlobalPermissions(routePermissions, config) + } + + return routePermissions +} + +// findSpecificRouteMatch finds if the route matches any specific route pattern. +func findSpecificRouteMatch(apiroute string, config *Config) ([]string, bool) { + for route, allowedRoles := range config.RouteWithPermissions { + // Skip global wildcard for now - we'll handle it separately + if route == "*" { + continue + } + + if matchesRoute(route, apiroute) { + return allowedRoles, true + } + } + + return nil, false +} + +// matchesRoute checks if a route pattern matches the given API route. +func matchesRoute(route, apiroute string) bool { + // Check if route pattern matches using path.Match + if isMatched, _ := path.Match(route, apiroute); isMatched && route != "" { + return true + } + + // Also check if route is a prefix match for patterns ending with /* + // e.g., /api/admin should match /api/admin/* + if strings.HasSuffix(route, "/*") { + prefix := strings.TrimSuffix(route, "/*") + + return apiroute == prefix || strings.HasPrefix(apiroute, prefix+"/") + } + + return false +} + +// appendGlobalPermissions appends global wildcard permissions if they exist. +func appendGlobalPermissions(routePermissions []string, config *Config) []string { + if globalRoles, exists := config.RouteWithPermissions["*"]; exists { + routePermissions = append(routePermissions, globalRoles...) + } + + return routePermissions +} + +// isRoleInPermissions checks if the role is in the allowed permissions. +func isRoleInPermissions(role string, permissions []string) bool { + for _, allowedRole := range permissions { + if allowedRole == role || allowedRole == "*" { + return true + } + } + + return false +} diff --git a/pkg/gofr/rbac/match_test.go b/pkg/gofr/rbac/match_test.go new file mode 100644 index 0000000000..0d33a23dc0 --- /dev/null +++ b/pkg/gofr/rbac/match_test.go @@ -0,0 +1,194 @@ +package rbac + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsRoleAllowed(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/admin/*": {"admin"}, + "/user/*": {"user", "admin"}, + "*": {"guest"}, + }, + OverRides: map[string]bool{ + "/admin/home": true, + }, + } + + tests := []struct { + name string + role string + route string + expected bool + }{ + {"Override true", "anyone", "/admin/home", true}, + {"Pattern match /admin/*", "admin", "/admin/dashboard", true}, + {"Pattern match negative", "user", "/admin/dashboard", false}, + {"Non-pattern route", "user", "/user/profile", true}, + {"Wildcard permission", "guest", "/anything", true}, + {"No route or global match", "unknown", "/private", false}, + {"Not matched or globally allowed", "nobody", "/wildcard", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := isRoleAllowed(tc.role, tc.route, config) + assert.Equal(t, tc.expected, got, tc.name) + }) + } +} + +func TestIsRoleAllowed_NilConfig(t *testing.T) { + got := isRoleAllowed("admin", "/test", nil) + assert.False(t, got) +} + +func TestIsRoleAllowed_OverrideFalse(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/test": {"admin"}, + }, + OverRides: map[string]bool{ + "/test": false, + }, + } + + got := isRoleAllowed("admin", "/test", config) + assert.True(t, got) // Override false still allows access (override means bypass) +} + +func TestFindRoutePermissions_ExactMatch(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/users": {"admin", "editor"}, + }, + } + + perms := findRoutePermissions("/api/users", config) + assert.Equal(t, []string{"admin", "editor"}, perms) +} + +func TestFindRoutePermissions_PatternMatch(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/*": {"admin"}, + }, + } + + perms := findRoutePermissions("/api/users", config) + assert.Equal(t, []string{"admin"}, perms) +} + +func TestFindRoutePermissions_WithGlobalWildcard(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/users": {"admin"}, + "*": {"guest"}, + }, + } + + // Specific routes take precedence - global wildcard is not appended + perms := findRoutePermissions("/api/users", config) + assert.Equal(t, []string{"admin"}, perms) +} + +func TestFindRoutePermissions_OnlyGlobalWildcard(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "*": {"guest"}, + }, + } + + perms := findRoutePermissions("/any/route", config) + assert.Equal(t, []string{"guest"}, perms) +} + +func TestFindRoutePermissions_NoMatch(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/other": {"admin"}, + }, + } + + perms := findRoutePermissions("/api/users", config) + assert.Nil(t, perms) +} + +func TestFindRoutePermissions_NilConfig(t *testing.T) { + perms := findRoutePermissions("/api/users", nil) + assert.Nil(t, perms) +} + +func TestFindRoutePermissions_NilRouteWithPermissions(t *testing.T) { + config := &Config{} + perms := findRoutePermissions("/api/users", config) + assert.Nil(t, perms) +} + +func TestFindRoutePermissions_EmptyRoute(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "": {"admin"}, + }, + } + + perms := findRoutePermissions("/api/users", config) + assert.Nil(t, perms) // Empty route should not match +} + +func TestFindRoutePermissions_PrefixMatchForWildcardPattern(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/admin/*": {"admin"}, + "*": {"viewer"}, + }, + } + + // /api/admin should match /api/admin/* pattern (prefix match) + perms := findRoutePermissions("/api/admin", config) + assert.Equal(t, []string{"admin"}, perms) // Should only have admin, not viewer + + // /api/admin/something should also match + perms2 := findRoutePermissions("/api/admin/something", config) + assert.Equal(t, []string{"admin"}, perms2) +} + +func TestFindRoutePermissions_SpecificRouteTakesPrecedenceOverGlobal(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/users": {"admin", "editor"}, + "*": {"viewer"}, + }, + } + + perms := findRoutePermissions("/api/users", config) + assert.Equal(t, []string{"admin", "editor"}, perms) // Should not include viewer +} + +func TestIsRoleInPermissions(t *testing.T) { + tests := []struct { + name string + role string + permissions []string + expected bool + }{ + {"ExactMatch", "admin", []string{"admin", "editor"}, true}, + {"NoMatch", "viewer", []string{"admin", "editor"}, false}, + {"WildcardRole", "anyone", []string{"*"}, true}, + {"WildcardWithOthers", "admin", []string{"*", "editor"}, true}, + {"EmptyPermissions", "admin", []string{}, false}, + {"NilPermissions", "admin", nil, false}, + {"EmptyRole", "", []string{"admin"}, false}, + {"EmptyRoleWithWildcard", "", []string{"*"}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := isRoleInPermissions(tc.role, tc.permissions) + assert.Equal(t, tc.expected, got, tc.name) + }) + } +} diff --git a/pkg/gofr/rbac/middleware.go b/pkg/gofr/rbac/middleware.go new file mode 100644 index 0000000000..9d3c89df62 --- /dev/null +++ b/pkg/gofr/rbac/middleware.go @@ -0,0 +1,247 @@ +package rbac + +import ( + "context" + "errors" + "net/http" +) + +type authMethod int + +const userRole authMethod = 4 + +const ( + authReasonPermissionBased = "permission-based" + authReasonRoleBasedHierarchy = "role-based (with hierarchy)" + authReasonRoleBased = "role-based" +) + +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") +) + +// auditLogger is an internal logger for authorization decisions. +// Audit logging is automatically performed using GoFr's logger - users don't need to configure this. +type auditLogger struct{} + +// logAccess logs an authorization decision using the logger. +func (*auditLogger) logAccess(logger Logger, req *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", + req.Method, req.URL.Path, role, route, status, reason) +} + +// 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. +// +// Users should use app.EnableRBAC() with options instead of calling this function directly. +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 + + // Check if route is overridden (public access) + if config.OverRides != nil && config.OverRides[route] { + handler.ServeHTTP(w, r) + return + } + + // Extract role + role, err := extractRole(r, config) + if err != nil { + handleAuthError(w, r, config, "", route, err) + return + } + + // Check authorization + authorized, authReason := checkAuthorization(r, role, route, config) + if !authorized { + handleAuthError(w, r, config, role, route, ErrAccessDenied) + return + } + + // Log audit event (always enabled when Logger is available) + // Audit logging is automatically performed using GoFr's logger + if config.Logger != nil { + logAuditEvent(config.Logger, r, role, route, true, authReason) + } + + // Store role in context and continue + ctx := context.WithValue(r.Context(), userRole, role) + handler.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// 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) { + // 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 using the configured extractor. +func extractRole(r *http.Request, config *Config) (string, error) { + if config.RoleExtractorFunc == nil { + if config.DefaultRole != "" { + return config.DefaultRole, nil + } + + return "", ErrRoleNotFound + } + + // Role extractor is called without any args (no container access) + role, err := config.RoleExtractorFunc(r) + if err != nil { + if config.DefaultRole != "" { + return config.DefaultRole, nil + } + + return "", ErrRoleNotFound + } + + // If role is empty, use default role if available + if role == "" && config.DefaultRole != "" { + return config.DefaultRole, nil + } + + // If role is still empty and no default role, return error + if role == "" { + return "", ErrRoleNotFound + } + + return role, nil +} + +// checkAuthorization checks if the user is authorized to access the route. +func checkAuthorization(r *http.Request, role, route string, config *Config) (authorized bool, reason string) { + // Check permission-based access if enabled + if config.EnablePermissions && config.PermissionConfig != nil { + reqCtx := context.WithValue(r.Context(), userRole, role) + reqWithRole := r.WithContext(reqCtx) + + if err := CheckPermission(reqWithRole, config.PermissionConfig); err == nil { + return true, authReasonPermissionBased + } + } + + // Check role-based access + if len(config.RoleHierarchy) > 0 { + hierarchy := NewRoleHierarchy(config.RoleHierarchy) + + if IsRoleAllowedWithHierarchy(role, route, config, hierarchy) { + return true, authReasonRoleBasedHierarchy + } + } + + if isRoleAllowed(role, route, config) { + return true, authReasonRoleBased + } + + return false, "" +} + +// 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) { + auditLogger := &auditLogger{} + auditLogger.logAccess(logger, r, role, route, allowed, reason) +} + +// HandlerFunc is a function type that matches GoFr's handler signature. +// This avoids import cycle with gofr package. +// Users should use gofr.RequireRole() instead for type safety. +type HandlerFunc func(ctx any) (any, error) + +// RequireRole wraps a handler to require a specific role. +// Returns ErrAccessDenied if the user's role doesn't match. +// Note: For GoFr applications, use gofr.RequireRole() instead for better type safety. +func RequireRole(allowedRole string, handlerFunc HandlerFunc) HandlerFunc { + return func(ctx any) (any, error) { + // Type assert to get context value access + type contextValueGetter interface { + Value(key any) any + } + + var role string + + if ctxWithValue, ok := ctx.(contextValueGetter); ok { + if roleVal := ctxWithValue.Value(userRole); roleVal != nil { + role, _ = roleVal.(string) + } + } + + if role == allowedRole { + return handlerFunc(ctx) + } + + return nil, ErrAccessDenied + } +} + +// RequireAnyRole wraps a handler to require any of the specified roles. +// Note: For GoFr applications, use gofr.RequireAnyRole() instead for better type safety. +func RequireAnyRole(allowedRoles []string, handlerFunc HandlerFunc) HandlerFunc { + return func(ctx any) (any, error) { + type contextValueGetter interface { + Value(key any) any + } + + var role string + + if ctxWithValue, ok := ctx.(contextValueGetter); ok { + if roleVal := ctxWithValue.Value(userRole); roleVal != nil { + role, _ = roleVal.(string) + } + } + + for _, allowedRole := range allowedRoles { + if role == allowedRole { + return handlerFunc(ctx) + } + } + + return nil, ErrAccessDenied + } +} diff --git a/pkg/gofr/rbac/middleware_test.go b/pkg/gofr/rbac/middleware_test.go new file mode 100644 index 0000000000..55046a4a79 --- /dev/null +++ b/pkg/gofr/rbac/middleware_test.go @@ -0,0 +1,751 @@ +package rbac + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + errNoRole = errors.New("no role") + errExtractionFailed = errors.New("extraction failed") +) + +// mock role extractor function for testing. +func mockRoleExtractor(r *http.Request, _ ...any) (string, error) { + role := r.Header.Get("Role") + if role == "" { + return "", errNoRole + } + + return role, nil +} + +func TestMiddleware_Authorization(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/allowed": {"admin"}, + }, + OverRides: map[string]bool{}, + RoleExtractorFunc: mockRoleExtractor, + } + + // next handler to confirm request passed through middleware + nextCalled := false + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + + w.WriteHeader(http.StatusOK) + }) + + // Test without container (header-based RBAC doesn't need container) + middleware := Middleware(config) + + // test cases + tests := []struct { + name string + roleHeader string + requestPath string + wantStatus int + wantNextCall bool + }{ + { + name: "No role header", + roleHeader: "", + requestPath: "/allowed", + wantStatus: http.StatusUnauthorized, + wantNextCall: false, + }, + { + name: "Unauthorized role", + roleHeader: "user", + requestPath: "/allowed", + wantStatus: http.StatusForbidden, + wantNextCall: false, + }, + { + name: "Authorized role", + roleHeader: "admin", + requestPath: "/allowed", + wantStatus: http.StatusOK, + wantNextCall: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + nextCalled = false + + req := httptest.NewRequest(http.MethodGet, tc.requestPath, http.NoBody) + + if tc.roleHeader != "" { + req.Header.Set("Role", tc.roleHeader) + } + + w := httptest.NewRecorder() + + handlerToTest := middleware(nextHandler) + handlerToTest.ServeHTTP(w, req) + + assert.Equal(t, tc.wantStatus, w.Code) + assert.Equal(t, tc.wantNextCall, nextCalled) + }) + } +} + +func TestMiddleware_WithOverride(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/public": {"admin"}, + }, + OverRides: map[string]bool{ + "/public": true, + }, + RoleExtractorFunc: mockRoleExtractor, + } + + nextCalled := false + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + + w.WriteHeader(http.StatusOK) + }) + + // Test without container + middleware := Middleware(config) + req := httptest.NewRequest(http.MethodGet, "/public", http.NoBody) + // No role header - should still pass due to override + w := httptest.NewRecorder() + + handlerToTest := middleware(nextHandler) + handlerToTest.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, nextCalled) +} + +func TestMiddleware_WithDefaultRole(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/allowed": {"viewer"}, + }, + DefaultRole: "viewer", + RoleExtractorFunc: func(_ *http.Request, _ ...any) (string, error) { + return "", errNoRole + }, + } + + nextCalled := false + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + + w.WriteHeader(http.StatusOK) + }) + + // Test without container + middleware := Middleware(config) + req := httptest.NewRequest(http.MethodGet, "/allowed", http.NoBody) + w := httptest.NewRecorder() + + handlerToTest := middleware(nextHandler) + handlerToTest.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, nextCalled) +} + +func TestMiddleware_WithPermissionCheck(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/users": {"admin"}, + }, + EnablePermissions: true, + PermissionConfig: &PermissionConfig{ + Permissions: map[string][]string{ + "users:read": {"admin", "editor"}, + }, + RoutePermissionMap: map[string]string{ + "GET /api/users": "users:read", + }, + }, + RoleExtractorFunc: mockRoleExtractor, + } + + nextCalled := false + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + + w.WriteHeader(http.StatusOK) + }) + + // Test without container + middleware := Middleware(config) + + tests := []struct { + name string + role string + wantStatus int + wantNextCall bool + }{ + { + name: "Has permission", + role: "editor", + wantStatus: http.StatusOK, + wantNextCall: true, + }, + { + name: "No permission", + role: "viewer", + wantStatus: http.StatusForbidden, + wantNextCall: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + nextCalled = false + req := httptest.NewRequest(http.MethodGet, "/api/users", http.NoBody) + + req.Header.Set("Role", tc.role) + + w := httptest.NewRecorder() + + handlerToTest := middleware(nextHandler) + handlerToTest.ServeHTTP(w, req) + + assert.Equal(t, tc.wantStatus, w.Code) + assert.Equal(t, tc.wantNextCall, nextCalled) + }) + } +} + +func TestMiddleware_WithHierarchy(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/users": {"editor"}, + }, + RoleHierarchy: map[string][]string{ + "admin": {"editor", "viewer"}, + }, + RoleExtractorFunc: mockRoleExtractor, + } + + nextCalled := false + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + + w.WriteHeader(http.StatusOK) + }) + + // Test without container + middleware := Middleware(config) + req := httptest.NewRequest(http.MethodGet, "/api/users", http.NoBody) + req.Header.Set("Role", "admin") // Admin should have editor permissions + + w := httptest.NewRecorder() + + handlerToTest := middleware(nextHandler) + handlerToTest.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, nextCalled) +} + +func TestMiddleware_WithCustomErrorHandler(t *testing.T) { + errorHandlerCalled := false + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/allowed": {"admin"}, + }, + RoleExtractorFunc: mockRoleExtractor, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _, _ string, _ error) { + errorHandlerCalled = true + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("Custom error")) + }, + } + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Test without container + middleware := Middleware(config) + req := httptest.NewRequest(http.MethodGet, "/allowed", http.NoBody) + req.Header.Set("Role", "user") // Unauthorized + + w := httptest.NewRecorder() + + handlerToTest := middleware(nextHandler) + handlerToTest.ServeHTTP(w, req) + + assert.True(t, errorHandlerCalled) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Custom error") +} + +func TestMiddleware_WithAuditLogging(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/allowed": {"admin"}, + }, + RoleExtractorFunc: mockRoleExtractor, + Logger: &mockLogger{}, + } + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Test without container + // Audit logging is automatically performed when Logger is set + middleware := Middleware(config) + req := httptest.NewRequest(http.MethodGet, "/allowed", http.NoBody) + + req.Header.Set("Role", "admin") + + w := httptest.NewRecorder() + + handlerToTest := middleware(nextHandler) + handlerToTest.ServeHTTP(w, req) + + // Audit logging happens automatically - no need to verify it was called + // The middleware will log using GoFr's logger when Logger is set + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRequireRole_Handler(t *testing.T) { + allowedRole := "admin" + called := false + handlerFunc := func(_ any) (any, error) { + called = true + return "success", nil + } + + wrappedHandler := RequireRole(allowedRole, handlerFunc) + + tests := []struct { + name string + contextRole string + wantErr error + wantCalled bool + }{ + { + name: "Role allowed", + contextRole: "admin", + wantErr: nil, + wantCalled: true, + }, + { + name: "Role denied", + contextRole: "user", + wantErr: ErrAccessDenied, + wantCalled: false, + }, + { + name: "No role in context", + contextRole: "", + wantErr: ErrAccessDenied, + wantCalled: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + called = false + ctx := &mockContextValueGetter{ + value: func(key any) any { + if key == userRole { + return tc.contextRole + } + return nil + }, + } + resp, err := wrappedHandler(ctx) + + assertErrorExpectation(t, err, tc.wantErr) + assertHandlerCallExpectation(t, called, tc.wantCalled, resp) + }) + } +} + +func TestRequireAnyRole(t *testing.T) { + allowedRoles := []string{"admin", "editor"} + called := false + handlerFunc := func(_ any) (any, error) { + called = true + return "success", nil + } + + wrappedHandler := RequireAnyRole(allowedRoles, handlerFunc) + + tests := []struct { + name string + contextRole string + wantErr error + wantCalled bool + }{ + { + name: "First role allowed", + contextRole: "admin", + wantErr: nil, + wantCalled: true, + }, + { + name: "Second role allowed", + contextRole: "editor", + wantErr: nil, + wantCalled: true, + }, + { + name: "Role denied", + contextRole: "viewer", + wantErr: ErrAccessDenied, + wantCalled: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + called = false + ctx := &mockContextValueGetter{ + value: func(key any) any { + if key == userRole { + return tc.contextRole + } + return nil + }, + } + resp, err := wrappedHandler(ctx) + + assertErrorExpectation(t, err, tc.wantErr) + assertHandlerCallExpectation(t, called, tc.wantCalled, resp) + }) + } +} + +// assertErrorExpectation asserts error expectations without nested if-else. +func assertErrorExpectation(t *testing.T, err, wantErr error) { + t.Helper() + + if wantErr != nil { + require.Error(t, err) + require.ErrorIs(t, err, wantErr) + + return + } + + require.NoError(t, err) +} + +// assertHandlerCallExpectation asserts handler call expectations without nested if-else. +func assertHandlerCallExpectation(t *testing.T, called, wantCalled bool, resp any) { + t.Helper() + + if wantCalled { + assert.True(t, called) + assert.Equal(t, "success", resp) + + return + } + + assert.False(t, called) + assert.Nil(t, resp) +} + +func TestExtractRole_EdgeCases(t *testing.T) { + t.Run("NilRoleExtractorWithDefaultRole", func(t *testing.T) { + config := &Config{ + DefaultRole: "guest", + } + + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + role, err := extractRole(req, config) + require.NoError(t, err) + assert.Equal(t, "guest", role) + }) + + t.Run("RoleExtractorErrorWithDefaultRole", func(t *testing.T) { + config := &Config{ + DefaultRole: "guest", + RoleExtractorFunc: func(_ *http.Request, _ ...any) (string, error) { + return "", errExtractionFailed + }, + } + + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + role, err := extractRole(req, config) + require.NoError(t, err) + assert.Equal(t, "guest", role) + }) + + t.Run("RoleExtractorErrorNoDefaultRole", func(t *testing.T) { + config := &Config{ + RoleExtractorFunc: func(_ *http.Request, _ ...any) (string, error) { + return "", errExtractionFailed + }, + } + + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + role, err := extractRole(req, config) + require.Error(t, err) + require.ErrorIs(t, err, ErrRoleNotFound) + assert.Empty(t, role) + }) + + t.Run("RoleExtractorEmptyStringWithDefaultRole", func(t *testing.T) { + config := &Config{ + DefaultRole: "guest", + RoleExtractorFunc: func(_ *http.Request, _ ...any) (string, error) { + return "", nil // Empty string, no error + }, + } + + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + role, err := extractRole(req, config) + require.NoError(t, err) + assert.Equal(t, "guest", role) + }) + + t.Run("RoleExtractorEmptyStringNoDefaultRole", func(t *testing.T) { + config := &Config{ + RoleExtractorFunc: func(_ *http.Request, _ ...any) (string, error) { + return "", nil // Empty string, no error + }, + } + + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + role, err := extractRole(req, config) + require.Error(t, err) + require.ErrorIs(t, err, ErrRoleNotFound) + assert.Empty(t, role) + }) +} + + +func TestCheckAuthorization_EdgeCases(t *testing.T) { + t.Run("PermissionBasedAccess", func(t *testing.T) { + config := &Config{ + EnablePermissions: true, + PermissionConfig: &PermissionConfig{ + Permissions: map[string][]string{ + "users:read": {"admin"}, + }, + RoutePermissionMap: map[string]string{ + "GET /api/users": "users:read", + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/api/users", http.NoBody) + reqCtx := context.WithValue(req.Context(), userRole, "admin") + req = req.WithContext(reqCtx) + + authorized, reason := checkAuthorization(req, "admin", "/api/users", config) + assert.True(t, authorized) + assert.Equal(t, authReasonPermissionBased, reason) + }) + + t.Run("RoleBasedWithHierarchy", func(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/users": {"editor"}, + }, + RoleHierarchy: map[string][]string{ + "admin": {"editor"}, + }, + } + + authorized, reason := checkAuthorization(nil, "admin", "/api/users", config) + assert.True(t, authorized) + assert.Equal(t, authReasonRoleBasedHierarchy, reason) + }) + + t.Run("RoleBasedNoHierarchy", func(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/users": {"admin"}, + }, + } + + authorized, reason := checkAuthorization(nil, "admin", "/api/users", config) + assert.True(t, authorized) + assert.Equal(t, authReasonRoleBased, reason) + }) + + t.Run("NotAuthorized", func(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/users": {"admin"}, + }, + } + + authorized, reason := checkAuthorization(nil, "viewer", "/api/users", config) + assert.False(t, authorized) + assert.Empty(t, reason) + }) +} + +func TestMiddleware_ExtractRoleError(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/test": {"admin"}, + }, + RoleExtractorFunc: func(_ *http.Request, _ ...any) (string, error) { + return "", errExtractionFailed + }, + } + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + middleware := Middleware(config) + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + w := httptest.NewRecorder() + + handlerToTest := middleware(nextHandler) + handlerToTest.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestMiddleware_ExtractRoleWithDefaultRole(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/test": {"guest"}, + }, + DefaultRole: "guest", + RoleExtractorFunc: func(_ *http.Request, _ ...any) (string, error) { + return "", errExtractionFailed + }, + } + + nextCalled := false + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + + w.WriteHeader(http.StatusOK) + }) + + middleware := Middleware(config) + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + w := httptest.NewRecorder() + + handlerToTest := middleware(nextHandler) + handlerToTest.ServeHTTP(w, req) + + assert.True(t, nextCalled) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestHandleAuthError(t *testing.T) { + t.Run("WithRoleNotFoundError", func(t *testing.T) { + config := &Config{ + Logger: &mockLogger{}, + } + + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + w := httptest.NewRecorder() + + handleAuthError(w, req, config, "", "/test", ErrRoleNotFound) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "Unauthorized") + }) + + t.Run("WithAccessDeniedError", func(t *testing.T) { + config := &Config{ + Logger: &mockLogger{}, + } + + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + w := httptest.NewRecorder() + + handleAuthError(w, req, config, "viewer", "/test", ErrAccessDenied) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Forbidden") + }) + + t.Run("WithCustomErrorHandler", func(t *testing.T) { + errorHandlerCalled := false + config := &Config{ + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _, _ string, _ error) { + errorHandlerCalled = true + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("Custom error")) + }, + } + + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + w := httptest.NewRecorder() + + handleAuthError(w, req, config, "viewer", "/test", ErrAccessDenied) + + assert.True(t, errorHandlerCalled) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "Custom error") + }) + + t.Run("WithNilLogger", func(t *testing.T) { + config := &Config{} + + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + w := httptest.NewRecorder() + + handleAuthError(w, req, config, "viewer", "/test", ErrAccessDenied) + + assert.Equal(t, http.StatusForbidden, w.Code) + }) +} + +func TestCheckAuthorization_PermissionBasedDisabled(t *testing.T) { + config := &Config{ + EnablePermissions: false, + PermissionConfig: &PermissionConfig{ + Permissions: map[string][]string{ + "users:read": {"admin"}, + }, + }, + RouteWithPermissions: map[string][]string{ + "/api/users": {"admin"}, + }, + } + + authorized, reason := checkAuthorization(nil, "admin", "/api/users", config) + assert.True(t, authorized) + assert.Equal(t, authReasonRoleBased, reason) // Should use role-based, not permission-based +} + +func TestCheckAuthorization_PermissionBasedNoConfig(t *testing.T) { + config := &Config{ + EnablePermissions: true, + PermissionConfig: nil, // No permission config + RouteWithPermissions: map[string][]string{ + "/api/users": {"admin"}, + }, + } + + authorized, reason := checkAuthorization(nil, "admin", "/api/users", config) + assert.True(t, authorized) + assert.Equal(t, authReasonRoleBased, reason) // Should fallback to role-based +} + +func TestCheckAuthorization_EmptyHierarchy(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/api/users": {"admin"}, + }, + RoleHierarchy: map[string][]string{}, // Empty hierarchy + } + + authorized, reason := checkAuthorization(nil, "admin", "/api/users", config) + assert.True(t, authorized) + assert.Equal(t, authReasonRoleBased, reason) // Should use role-based when hierarchy is empty +} diff --git a/pkg/gofr/rbac/options.go b/pkg/gofr/rbac/options.go new file mode 100644 index 0000000000..ca715df0fb --- /dev/null +++ b/pkg/gofr/rbac/options.go @@ -0,0 +1,53 @@ +package rbac + +import ( + "fmt" + "net/http" + + "gofr.dev/pkg/gofr/rbac/providers" +) + +// HeaderRoleExtractor implements Options for header-based role extraction. +type HeaderRoleExtractor struct { + // HeaderKey is the HTTP header key name (e.g., "X-User-Role") + // Default: "X-User-Role" + HeaderKey string +} + +// AddOption configures the RBAC config with header-based role extraction. +// This follows the same pattern as service.Options for consistency. +func (h *HeaderRoleExtractor) AddOption(config RBACConfig) RBACConfig { + if h.HeaderKey == "" { + h.HeaderKey = "X-User-Role" // Default header key + } + + config.SetRoleExtractorFunc(func(req *http.Request, args ...any) (string, error) { + role := req.Header.Get(h.HeaderKey) + if role == "" { + return "", fmt.Errorf("role header %q not found", h.HeaderKey) + } + return role, nil + }) + return config +} + +// JWTExtractor implements Options for JWT-based role extraction. +type JWTExtractor struct { + // Claim is the JWT claim path (e.g., "role", "roles[0]", "permissions.role") + // Default: "role" + Claim string +} + +// AddOption configures the RBAC config with JWT-based role extraction. +// This follows the same pattern as service.Options for consistency. +func (j *JWTExtractor) AddOption(config RBACConfig) RBACConfig { + if j.Claim == "" { + j.Claim = "role" // Default claim + } + + // Use the provider's JWT extractor + jwtExtractor := providers.NewJWTRoleExtractor(j.Claim) + config.SetRoleExtractorFunc(jwtExtractor.ExtractRole) + return config +} + diff --git a/pkg/gofr/rbac/permissions.go b/pkg/gofr/rbac/permissions.go new file mode 100644 index 0000000000..0f3d9aa934 --- /dev/null +++ b/pkg/gofr/rbac/permissions.go @@ -0,0 +1,333 @@ +package rbac + +import ( + "context" + "errors" + "fmt" + "net/http" + "path" + "regexp" + "strings" +) + +var ( + // ErrPermissionDenied is returned when a user doesn't have required permission. + ErrPermissionDenied = errors.New("forbidden: permission denied") + + // errNoPermissionMapping is returned when no permission mapping is found for a route. + errNoPermissionMapping = errors.New("no permission mapping found") +) + +// RoutePermissionRule defines a rule for mapping routes to permissions. +type RoutePermissionRule struct { + // Methods is a list of HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.) + // If empty or contains "*", matches all methods + Methods []string `json:"methods" yaml:"methods"` + + // Path is a path pattern (supports wildcards like /api/*) + // Used when Regex is empty + Path string `json:"path,omitempty" yaml:"path,omitempty"` + + // Regex is a regular expression pattern for route matching + // Takes precedence over Path if both are provided + Regex string `json:"regex,omitempty" yaml:"regex,omitempty"` + + // Permission is the required permission for matching routes + Permission string `json:"permission" yaml:"permission"` + + // compiledRegex is the compiled regex (internal, not in JSON/YAML) + compiledRegex *regexp.Regexp `json:"-" yaml:"-"` +} + +// PermissionConfig holds permission-based access control configuration. +type PermissionConfig struct { + // RolePermissions maps roles to their permissions (ROLE-CENTRIC) + // Example: "admin": ["users:read", "users:write", "users:delete"] + RolePermissions map[string][]string `json:"rolePermissions" yaml:"rolePermissions"` + + // RoutePermissionRules is a list of rules for mapping routes to permissions + // More flexible than RoutePermissionMap - supports multiple methods, regex, etc. + RoutePermissionRules []RoutePermissionRule `json:"routePermissionRules,omitempty" yaml:"routePermissionRules,omitempty"` + + // RoutePermissionMap is the legacy format (for backward compatibility) + // Format: "METHOD /path": "permission:action" + // Example: "GET /api/users": "users:read" + // Deprecated: Use RoutePermissionRules instead + RoutePermissionMap map[string]string `json:"routePermissions,omitempty" yaml:"routePermissions,omitempty"` + + // Permissions is the legacy permission-centric format (for backward compatibility) + // Example: "users:read": ["admin", "editor", "viewer"] + // Deprecated: Use RolePermissions instead + Permissions map[string][]string `json:"permissions,omitempty" yaml:"permissions,omitempty"` + + // DefaultPermission is used when route doesn't have explicit permission mapping + DefaultPermission string `json:"defaultPermission,omitempty" yaml:"defaultPermission,omitempty"` +} + +// Implement PermissionConfig interface methods + +// GetPermissions returns the legacy permissions map (for backward compatibility). +func (p *PermissionConfig) GetPermissions() map[string][]string { + return p.Permissions +} + +// GetRoutePermissionMap returns the legacy route-to-permission mapping (for backward compatibility). +func (p *PermissionConfig) GetRoutePermissionMap() map[string]string { + return p.RoutePermissionMap +} + +// GetRolePermissions returns the role-centric permissions map. +func (p *PermissionConfig) GetRolePermissions() map[string][]string { + return p.RolePermissions +} + +// GetRoutePermissionRules returns the structured route permission rules. +func (p *PermissionConfig) GetRoutePermissionRules() []RoutePermissionRule { + return p.RoutePermissionRules +} + +// CompileRoutePermissionRules compiles regex patterns in RoutePermissionRules. +func (p *PermissionConfig) CompileRoutePermissionRules() error { + for i := range p.RoutePermissionRules { + rule := &p.RoutePermissionRules[i] + if rule.Regex != "" { + regex, err := regexp.Compile(rule.Regex) + if err != nil { + return fmt.Errorf("invalid regex pattern %q: %w", rule.Regex, err) + } + rule.compiledRegex = regex + } + } + return nil +} + +// HasPermission checks if the user's role has the specified permission. +// Uses role-centric lookup (preferred) or falls back to permission-centric (legacy). +func HasPermission(ctx context.Context, permission string, config *PermissionConfig) bool { + if config == nil { + return false + } + + // Get user role from context + role, _ := ctx.Value(userRole).(string) + if role == "" { + return false + } + + // Try role-centric lookup first (preferred) + if config.RolePermissions != nil { + rolePerms, exists := config.RolePermissions[role] + if exists { + // Check if permission is in role's permissions + for _, rolePerm := range rolePerms { + if rolePerm == permission || rolePerm == "*" { + return true + } + } + } + } + + // Fallback to permission-centric lookup (legacy) + if config.Permissions != nil { + allowedRoles, exists := config.Permissions[permission] + if !exists { + return false + } + + // Check if user's role is in allowed roles + for _, allowedRole := range allowedRoles { + if allowedRole == role || allowedRole == "*" { + return true + } + } + } + + return false +} + +// GetRequiredPermission returns the required permission for a given route and method. +func GetRequiredPermission(method, route string, config *PermissionConfig) (string, error) { + if config == nil { + return "", fmt.Errorf("no permission mapping found for %s %s: %w", method, route, errNoPermissionMapping) + } + + // First, try RoutePermissionRules (new structured format) + if len(config.RoutePermissionRules) > 0 { + if permission := matchRoutePermissionRules(method, route, config.RoutePermissionRules); permission != "" { + return permission, nil + } + } + + // Fallback to RoutePermissionMap (legacy format) + if config.RoutePermissionMap != nil { + // Try exact match: "GET /api/users" + key := fmt.Sprintf("%s %s", method, route) + if permission, exists := config.RoutePermissionMap[key]; exists { + return permission, nil + } + + // Try pattern matching with wildcards + for pattern, permission := range config.RoutePermissionMap { + if matchesRoutePattern(pattern, method, route) { + return permission, nil + } + } + } + + // Use default permission if configured + if config.DefaultPermission != "" { + return config.DefaultPermission, nil + } + + return "", fmt.Errorf("no permission mapping found for %s %s: %w", method, route, errNoPermissionMapping) +} + +// matchRoutePermissionRules matches a route against RoutePermissionRules. +func matchRoutePermissionRules(method, route string, rules []RoutePermissionRule) string { + for i := range rules { + rule := &rules[i] + // Check method match + if !matchesMethod(method, rule.Methods) { + continue + } + + // Check route match (regex takes precedence) + if rule.Regex != "" { + if rule.compiledRegex == nil { + // Compile regex on first use + regex, err := regexp.Compile(rule.Regex) + if err != nil { + continue // Skip invalid regex + } + rule.compiledRegex = regex + } + if rule.compiledRegex.MatchString(route) { + return rule.Permission + } + } else if rule.Path != "" { + // Use path pattern matching (supports wildcards) + if matchesPathPattern(rule.Path, route) { + return rule.Permission + } + } + } + return "" +} + +// matchesMethod checks if the HTTP method matches any of the allowed methods. +func matchesMethod(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 +} + +// matchesPathPattern checks if route matches path pattern (supports wildcards). +func matchesPathPattern(pattern, route string) bool { + // Use path/filepath.Match for pattern matching + matched, _ := path.Match(pattern, route) + if matched { + return true + } + + // Also check prefix match for patterns ending with /* + if strings.HasSuffix(pattern, "/*") { + prefix := strings.TrimSuffix(pattern, "/*") + return route == prefix || strings.HasPrefix(route, prefix+"/") + } + + return false +} + +// matchesRoutePattern checks if a route pattern matches the given method and route. +// Supports wildcards: "GET /api/*" matches "GET /api/users". +func matchesRoutePattern(pattern, method, route string) bool { + // Split pattern into method and path + const expectedParts = 2 + + parts := strings.SplitN(pattern, " ", expectedParts) + + if len(parts) != expectedParts { + return false + } + + patternMethod := parts[0] + patternPath := parts[1] + + // Check method match (supports wildcard) + if patternMethod != "*" && patternMethod != method { + return false + } + + // Use path/filepath.Match for path pattern matching + matched, _ := path.Match(patternPath, route) + + return matched +} + +// CheckPermission checks if the user has the required permission for the route. +func CheckPermission(req *http.Request, config *PermissionConfig) error { + if config == nil { + return ErrPermissionDenied + } + + // Get required permission for this route + permission, err := GetRequiredPermission(req.Method, req.URL.Path, config) + if err != nil { + // If no permission mapping found and no default, deny access + return ErrPermissionDenied + } + + // Check if user has the permission + if !HasPermission(req.Context(), permission, config) { + return ErrPermissionDenied + } + + return nil +} + +// RequirePermission wraps a handler to require a specific permission. +// Note: For GoFr applications, use gofr.RequirePermission() instead for better type safety. +func RequirePermission(requiredPermission string, config *PermissionConfig, handlerFunc HandlerFunc) HandlerFunc { + return func(ctx any) (any, error) { + reqCtx := extractContextFromCtx(ctx) + + if !HasPermission(reqCtx, requiredPermission, config) { + return nil, ErrPermissionDenied + } + + return handlerFunc(ctx) + } +} + +// extractContextFromCtx extracts context.Context from the given context value. +func extractContextFromCtx(ctx any) context.Context { + type contextValueGetter interface { + Value(key any) any + } + + ctxWithValue, ok := ctx.(contextValueGetter) + if !ok { + return context.Background() + } + + // Try to extract context.Context from GoFr context + if gofrCtx, ok := ctx.(interface{ Context() context.Context }); ok { + return gofrCtx.Context() + } + + // Fallback: create a context with role value + roleVal := ctxWithValue.Value(userRole) + if roleVal != nil { + return context.WithValue(context.Background(), userRole, roleVal) + } + + return context.Background() +} diff --git a/pkg/gofr/rbac/permissions_test.go b/pkg/gofr/rbac/permissions_test.go new file mode 100644 index 0000000000..3b5042fae1 --- /dev/null +++ b/pkg/gofr/rbac/permissions_test.go @@ -0,0 +1,443 @@ +package rbac + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHasPermission(t *testing.T) { + config := &PermissionConfig{ + Permissions: map[string][]string{ + "users:read": {"admin", "editor", "viewer"}, + "users:write": {"admin", "editor"}, + "posts:read": {"admin", "author", "viewer"}, + }, + } + + tests := []struct { + name string + ctx context.Context + permission string + want bool + }{ + { + name: "Has permission with admin role", + ctx: context.WithValue(context.Background(), userRole, "admin"), + permission: "users:read", + want: true, + }, + { + name: "Has permission with editor role", + ctx: context.WithValue(context.Background(), userRole, "editor"), + permission: "users:read", + want: true, + }, + { + name: "No permission with viewer role", + ctx: context.WithValue(context.Background(), userRole, "viewer"), + permission: "users:write", + want: false, + }, + { + name: "Permission not found", + ctx: context.WithValue(context.Background(), userRole, "admin"), + permission: "nonexistent:permission", + want: false, + }, + { + name: "No role in context", + ctx: context.Background(), + permission: "users:read", + want: false, + }, + { + name: "Wildcard permission", + ctx: context.WithValue(context.Background(), userRole, "admin"), + permission: "users:read", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HasPermission(tt.ctx, tt.permission, config) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGetRequiredPermission(t *testing.T) { + config := &PermissionConfig{ + RoutePermissionMap: map[string]string{ + "GET /api/users": "users:read", + "POST /api/users": "users:write", + "GET /api/posts/*": "posts:read", + "* /api/public/*": "public:read", + }, + DefaultPermission: "default:read", + } + + tests := []struct { + name string + method string + route string + want string + wantErr bool + checkErr func(*testing.T, error) + }{ + { + name: "Exact match", + method: "GET", + route: "/api/users", + want: "users:read", + wantErr: false, + }, + { + name: "Pattern match with wildcard", + method: "GET", + route: "/api/posts/123", + want: "posts:read", + wantErr: false, + }, + { + name: "Method wildcard", + method: "PUT", + route: "/api/public/test", + want: "public:read", + wantErr: false, + }, + { + name: "Default permission", + method: "GET", + route: "/api/unknown", + want: "default:read", + wantErr: false, + }, + { + name: "No match and no default", + method: "GET", + route: "/api/unknown", + want: "", + wantErr: true, + checkErr: func(t *testing.T, err error) { + t.Helper() + assert.Contains(t, err.Error(), "no permission mapping found") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create config with or without default + testConfig := config + if tt.name == "No match and no default" { + testConfig = &PermissionConfig{ + RoutePermissionMap: config.RoutePermissionMap, + // No DefaultPermission + } + } + + got, err := GetRequiredPermission(tt.method, tt.route, testConfig) + assertGetRequiredPermissionResult(t, got, err, tt.want, tt.wantErr, tt.checkErr) + }) + } +} + +func TestCheckPermission(t *testing.T) { + config := &PermissionConfig{ + Permissions: map[string][]string{ + "users:read": {"admin", "editor", "viewer"}, + "users:write": {"admin", "editor"}, + }, + RoutePermissionMap: map[string]string{ + "GET /api/users": "users:read", + "POST /api/users": "users:write", + }, + } + + tests := []struct { + name string + method string + route string + role string + wantErr bool + wantErrIs error + }{ + { + name: "Authorized - admin with read", + method: "GET", + route: "/api/users", + role: "admin", + wantErr: false, + }, + { + name: "Authorized - editor with read", + method: "GET", + route: "/api/users", + role: "editor", + wantErr: false, + }, + { + name: "Unauthorized - viewer with write", + method: "POST", + route: "/api/users", + role: "viewer", + wantErr: true, + wantErrIs: ErrPermissionDenied, + }, + { + name: "No role in context", + method: "GET", + route: "/api/users", + role: "", + wantErr: true, + wantErrIs: ErrPermissionDenied, + }, + { + name: "No permission mapping", + method: "GET", + route: "/api/unknown", + role: "admin", + wantErr: true, + wantErrIs: ErrPermissionDenied, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.route, http.NoBody) + ctx := context.WithValue(req.Context(), userRole, tt.role) + req = req.WithContext(ctx) + + err := CheckPermission(req, config) + assertCheckPermissionResult(t, err, tt.wantErr, tt.wantErrIs) + }) + } +} + +func TestCheckPermission_NilConfig(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/users", http.NoBody) + err := CheckPermission(req, nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrPermissionDenied) +} + +func TestMatchesRoutePattern(t *testing.T) { + tests := []struct { + name string + pattern string + method string + route string + want bool + }{ + { + name: "Exact match", + pattern: "GET /api/users", + method: "GET", + route: "/api/users", + want: true, + }, + { + name: "Method mismatch", + pattern: "GET /api/users", + method: "POST", + route: "/api/users", + want: false, + }, + { + name: "Route mismatch", + pattern: "GET /api/users", + method: "GET", + route: "/api/posts", + want: false, + }, + { + name: "Wildcard method", + pattern: "* /api/users", + method: "POST", + route: "/api/users", + want: true, + }, + { + name: "Wildcard route", + pattern: "GET /api/*", + method: "GET", + route: "/api/users", + want: true, + }, + { + name: "Invalid pattern format", + pattern: "invalid", + method: "GET", + route: "/api/users", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchesRoutePattern(tt.pattern, tt.method, tt.route) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRequirePermission(t *testing.T) { + config := &PermissionConfig{ + Permissions: map[string][]string{ + "users:read": {"admin", "editor"}, + }, + } + + tests := []struct { + name string + permission string + ctxRole string + wantErr bool + wantHandlerCall bool + }{ + { + name: "Has permission", + permission: "users:read", + ctxRole: "admin", + wantErr: false, + wantHandlerCall: true, + }, + { + name: "No permission", + permission: "users:read", + ctxRole: "viewer", + wantErr: true, + wantHandlerCall: false, + }, + { + name: "No role", + permission: "users:read", + ctxRole: "", + wantErr: true, + wantHandlerCall: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handlerCalled := false + handlerFunc := func(_ any) (any, error) { + handlerCalled = true + return "success", nil + } + + wrapped := RequirePermission(tt.permission, config, handlerFunc) + + // Create mock context that implements ContextValueGetter + ctx := &mockContextValueGetter{ + value: func(key any) any { + if key == userRole { + return tt.ctxRole + } + return nil + }, + } + + result, err := wrapped(ctx) + assertRequirePermissionResult(t, err, handlerCalled, result, tt.wantErr) + }) + } +} + +func TestRequirePermission_NilConfig(t *testing.T) { + handlerFunc := func(_ any) (any, error) { + return "success", nil + } + + wrapped := RequirePermission("users:read", nil, handlerFunc) + + ctx := &mockContextValueGetter{ + value: func(key any) any { + if key == userRole { + return "admin" + } + return nil + }, + } + + result, err := wrapped(ctx) + require.Error(t, err) + require.ErrorIs(t, err, ErrPermissionDenied) + assert.Nil(t, result) +} + +func TestPermissionConfig_WithDefaultPermission(t *testing.T) { + config := &PermissionConfig{ + Permissions: map[string][]string{ + "default:read": {"admin", "viewer"}, + }, + DefaultPermission: "default:read", + RoutePermissionMap: map[string]string{ + "GET /api/specific": "specific:read", + }, + } + + // Test default permission is used when route not in map + permission, err := GetRequiredPermission("GET", "/api/unknown", config) + require.NoError(t, err) + assert.Equal(t, "default:read", permission) + + permission, err = GetRequiredPermission("GET", "/api/specific", config) + require.NoError(t, err) + assert.Equal(t, "specific:read", permission) +} + +func assertGetRequiredPermissionResult(t *testing.T, got string, err error, want string, wantErr bool, checkErr func(*testing.T, error)) { + t.Helper() + + if wantErr { + require.Error(t, err) + + if checkErr != nil { + checkErr(t, err) + } + + return + } + + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func assertCheckPermissionResult(t *testing.T, err error, wantErr bool, wantErrIs error) { + t.Helper() + + if wantErr { + require.Error(t, err) + + if wantErrIs != nil { + require.ErrorIs(t, err, wantErrIs) + } + + return + } + + require.NoError(t, err) +} + +func assertRequirePermissionResult(t *testing.T, err error, handlerCalled bool, result any, wantErr bool) { + t.Helper() + + if wantErr { + require.Error(t, err) + assert.False(t, handlerCalled) + assert.Nil(t, result) + + return + } + + require.NoError(t, err) + assert.True(t, handlerCalled) + assert.Equal(t, "success", result) +} diff --git a/pkg/gofr/rbac/provider.go b/pkg/gofr/rbac/provider.go new file mode 100644 index 0000000000..bd04929560 --- /dev/null +++ b/pkg/gofr/rbac/provider.go @@ -0,0 +1,123 @@ +package rbac + +import ( + "net/http" + + "gofr.dev/pkg/gofr/container" +) + +// Provider implements container.RBACProvider interface. +// This follows the same pattern as datasource providers (e.g., mongo.Client). +type Provider struct{} + +// NewProvider creates a new RBAC provider. +// This follows the same pattern as datasource providers (e.g., mongo.New()). +// +// Example: +// +// provider := rbac.NewProvider() +// app.EnableRBAC(provider, "configs/rbac.json") +func NewProvider() container.RBACProvider { + return &Provider{} +} + +// LoadPermissions loads RBAC configuration from a file. +func (p *Provider) LoadPermissions(file string) (any, error) { + return LoadPermissions(file) +} + +// GetMiddleware returns the middleware function for the given config. +func (p *Provider) GetMiddleware(config any) func(http.Handler) http.Handler { + rbacConfig, ok := config.(*Config) + if !ok { + // If config is not *Config, return passthrough middleware + return func(handler http.Handler) http.Handler { + return handler + } + } + + return Middleware(rbacConfig) +} + +// RequireRole wraps a handler to require a specific role. +func (p *Provider) RequireRole(allowedRole string, handlerFunc func(any) (any, error)) func(any) (any, error) { + hf := HandlerFunc(handlerFunc) + wrapped := RequireRole(allowedRole, hf) + + return func(ctx any) (any, error) { + return wrapped(ctx) + } +} + +// RequireAnyRole wraps a handler to require any of the specified roles. +func (p *Provider) RequireAnyRole(allowedRoles []string, handlerFunc func(any) (any, error)) func(any) (any, error) { + hf := HandlerFunc(handlerFunc) + wrapped := RequireAnyRole(allowedRoles, hf) + + return func(ctx any) (any, error) { + return wrapped(ctx) + } +} + +// RequirePermission wraps a handler to require a specific permission. +func (p *Provider) RequirePermission(requiredPermission string, permissionConfig any, handlerFunc func(any) (any, error)) func(any) (any, error) { + var rbacPermConfig *PermissionConfig + + // Convert permissionConfig to *PermissionConfig + if permissionConfig == nil { + rbacPermConfig = &PermissionConfig{} + } else { + // Try to convert from gofr.PermissionConfig interface + if pc, ok := permissionConfig.(interface { + GetRolePermissions() map[string][]string + GetRoutePermissionMap() map[string]string + GetPermissions() map[string][]string + GetRoutePermissionRules() []RoutePermissionRule + }); ok { + rules := pc.GetRoutePermissionRules() + if len(rules) > 0 { + rbacRules := make([]RoutePermissionRule, len(rules)) + for i, rule := range rules { + rbacRules[i] = RoutePermissionRule{ + Methods: rule.Methods, + Path: rule.Path, + Regex: rule.Regex, + Permission: rule.Permission, + } + } + rbacPermConfig = &PermissionConfig{ + RoutePermissionRules: rbacRules, + RolePermissions: pc.GetRolePermissions(), + } + } else { + rbacPermConfig = &PermissionConfig{ + RolePermissions: pc.GetRolePermissions(), + RoutePermissionMap: pc.GetRoutePermissionMap(), + Permissions: pc.GetPermissions(), + } + } + } else if pc, ok := permissionConfig.(*PermissionConfig); ok { + rbacPermConfig = pc + } else { + rbacPermConfig = &PermissionConfig{} + } + } + + hf := HandlerFunc(handlerFunc) + wrapped := RequirePermission(requiredPermission, rbacPermConfig, hf) + + return func(ctx any) (any, error) { + return wrapped(ctx) + } +} + +// ErrAccessDenied returns the error used when access is denied. +func (p *Provider) ErrAccessDenied() error { + return ErrAccessDenied +} + +// ErrPermissionDenied returns the error used when permission is denied. +func (p *Provider) ErrPermissionDenied() error { + return ErrPermissionDenied +} + diff --git a/pkg/gofr/rbac/provider_test.go b/pkg/gofr/rbac/provider_test.go new file mode 100644 index 0000000000..92ba673cbe --- /dev/null +++ b/pkg/gofr/rbac/provider_test.go @@ -0,0 +1,336 @@ +package rbac + +import ( + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gofr.dev/pkg/gofr/container" +) + +func TestNewProvider(t *testing.T) { + provider := NewProvider() + assert.NotNil(t, provider) + + // Verify it implements the interface + var _ container.RBACProvider = provider +} + +func TestProvider_LoadPermissions(t *testing.T) { + provider := NewProvider() + + t.Run("Success", func(t *testing.T) { + jsonContent := `{"route": {"/admin": ["admin"]}}` + + tempFile, err := os.CreateTemp(t.TempDir(), "test_*.json") + require.NoError(t, err) + + _, err = tempFile.WriteString(jsonContent) + require.NoError(t, err) + require.NoError(t, tempFile.Close()) + + config, err := provider.LoadPermissions(tempFile.Name()) + require.NoError(t, err) + assert.NotNil(t, config) + + // Verify it's a *Config + rbacConfig, ok := config.(*Config) + require.True(t, ok) + assert.NotNil(t, rbacConfig) + }) + + t.Run("FileNotFound", func(t *testing.T) { + config, err := provider.LoadPermissions("nonexistent.json") + require.Error(t, err) + assert.Nil(t, config) + }) +} + +func TestProvider_GetMiddleware(t *testing.T) { + provider := NewProvider() + + t.Run("WithValidConfig", func(t *testing.T) { + config := &Config{ + RouteWithPermissions: map[string][]string{ + "/test": {"admin"}, + }, + } + + mwFunc := provider.GetMiddleware(config) + assert.NotNil(t, mwFunc) + + // Verify it returns a middleware function + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + wrapped := mwFunc(handler) + assert.NotNil(t, wrapped) + }) + + t.Run("WithInvalidConfigType", func(t *testing.T) { + // Pass a config that doesn't implement *Config + mwFunc := provider.GetMiddleware(nil) + assert.NotNil(t, mwFunc) + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + wrapped := mwFunc(handler) + assert.NotNil(t, wrapped) + }) + + t.Run("WithNonConfigType", func(t *testing.T) { + // Pass a non-Config type + mwFunc := provider.GetMiddleware("not-a-config") + assert.NotNil(t, mwFunc) + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + wrapped := mwFunc(handler) + assert.NotNil(t, wrapped) + }) +} + +func TestProvider_RequireRole(t *testing.T) { + provider := NewProvider() + + t.Run("Success", func(t *testing.T) { + handlerFunc := func(_ any) (any, error) { + return "success", nil + } + + wrapped := provider.RequireRole("admin", handlerFunc) + assert.NotNil(t, wrapped) + }) + + t.Run("WithContextValueGetter", func(t *testing.T) { + handlerCalled := false + handlerFunc := func(_ any) (any, error) { + handlerCalled = true + return "success", nil + } + + wrapped := provider.RequireRole("admin", handlerFunc) + + ctx := &mockContextValueGetter{ + value: func(key any) any { + if key == userRole { + return "admin" + } + return nil + }, + } + + result, err := wrapped(ctx) + require.NoError(t, err) + assert.True(t, handlerCalled) + assert.Equal(t, "success", result) + }) + + t.Run("WithWrongRole", func(t *testing.T) { + handlerCalled := false + handlerFunc := func(_ any) (any, error) { + handlerCalled = true + return "success", nil + } + + wrapped := provider.RequireRole("admin", handlerFunc) + + ctx := &mockContextValueGetter{ + value: func(key any) any { + if key == userRole { + return "user" + } + return nil + }, + } + + result, err := wrapped(ctx) + require.Error(t, err) + require.ErrorIs(t, err, ErrAccessDenied) + assert.False(t, handlerCalled) + assert.Nil(t, result) + }) +} + +func TestProvider_RequireAnyRole(t *testing.T) { + provider := NewProvider() + + t.Run("Success", func(t *testing.T) { + handlerFunc := func(_ any) (any, error) { + return "success", nil + } + + wrapped := provider.RequireAnyRole([]string{"admin", "editor"}, handlerFunc) + assert.NotNil(t, wrapped) + }) + + t.Run("WithMatchingRole", func(t *testing.T) { + handlerCalled := false + handlerFunc := func(_ any) (any, error) { + handlerCalled = true + return "success", nil + } + + wrapped := provider.RequireAnyRole([]string{"admin", "editor"}, handlerFunc) + + ctx := &mockContextValueGetter{ + value: func(key any) any { + if key == userRole { + return "admin" + } + return nil + }, + } + + result, err := wrapped(ctx) + require.NoError(t, err) + assert.True(t, handlerCalled) + assert.Equal(t, "success", result) + }) + + t.Run("WithNonMatchingRole", func(t *testing.T) { + handlerCalled := false + handlerFunc := func(_ any) (any, error) { + handlerCalled = true + return "success", nil + } + + wrapped := provider.RequireAnyRole([]string{"admin", "editor"}, handlerFunc) + + ctx := &mockContextValueGetter{ + value: func(key any) any { + if key == userRole { + return "viewer" + } + return nil + }, + } + + result, err := wrapped(ctx) + require.Error(t, err) + require.ErrorIs(t, err, ErrAccessDenied) + assert.False(t, handlerCalled) + assert.Nil(t, result) + }) +} + +func TestProvider_RequirePermission(t *testing.T) { + provider := NewProvider() + + t.Run("Success", func(t *testing.T) { + permissionConfig := &PermissionConfig{ + Permissions: map[string][]string{ + "users:read": {"admin"}, + }, + } + + handlerFunc := func(_ any) (any, error) { + return "success", nil + } + + wrapped := provider.RequirePermission("users:read", permissionConfig, handlerFunc) + assert.NotNil(t, wrapped) + }) + + t.Run("WithInvalidPermissionConfigType", func(t *testing.T) { + handlerFunc := func(_ any) (any, error) { + return "success", nil + } + + wrapped := provider.RequirePermission("users:read", nil, handlerFunc) + assert.NotNil(t, wrapped) + + ctx := &mockContextValueGetter{ + value: func(_ any) any { + return nil + }, + } + + result, err := wrapped(ctx) + require.Error(t, err) + require.ErrorIs(t, err, ErrPermissionDenied) + assert.Nil(t, result) + }) + + t.Run("WithMatchingPermission", func(t *testing.T) { + permissionConfig := &PermissionConfig{ + Permissions: map[string][]string{ + "users:read": {"admin"}, + }, + } + + handlerCalled := false + handlerFunc := func(_ any) (any, error) { + handlerCalled = true + return "success", nil + } + + wrapped := provider.RequirePermission("users:read", permissionConfig, handlerFunc) + + ctx := &mockContextValueGetter{ + value: func(key any) any { + if key == userRole { + return "admin" + } + return nil + }, + } + + result, err := wrapped(ctx) + require.NoError(t, err) + assert.True(t, handlerCalled) + assert.Equal(t, "success", result) + }) + + t.Run("WithNonMatchingPermission", func(t *testing.T) { + permissionConfig := &PermissionConfig{ + Permissions: map[string][]string{ + "users:read": {"admin"}, + }, + } + + handlerCalled := false + handlerFunc := func(_ any) (any, error) { + handlerCalled = true + return "success", nil + } + + wrapped := provider.RequirePermission("users:read", permissionConfig, handlerFunc) + + ctx := &mockContextValueGetter{ + value: func(key any) any { + if key == userRole { + return "viewer" + } + return nil + }, + } + + result, err := wrapped(ctx) + require.Error(t, err) + require.ErrorIs(t, err, ErrPermissionDenied) + assert.False(t, handlerCalled) + assert.Nil(t, result) + }) +} + +func TestProvider_ErrAccessDenied(t *testing.T) { + provider := NewProvider() + err := provider.ErrAccessDenied() + assert.Equal(t, ErrAccessDenied, err) +} + +func TestProvider_ErrPermissionDenied(t *testing.T) { + provider := NewProvider() + err := provider.ErrPermissionDenied() + assert.Equal(t, ErrPermissionDenied, err) +} + diff --git a/pkg/gofr/rbac/providers/jwt.go b/pkg/gofr/rbac/providers/jwt.go new file mode 100644 index 0000000000..4bdd54ca0e --- /dev/null +++ b/pkg/gofr/rbac/providers/jwt.go @@ -0,0 +1,197 @@ +package providers + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" + "gofr.dev/pkg/gofr/http/middleware" +) + +var ( + // ErrJWTNotEnabled is returned when JWT role extraction is attempted but OAuth is not enabled. + // This error is exported for users who want to check if JWT/OAuth middleware is properly configured. + ErrJWTNotEnabled = errors.New("JWT/OAuth middleware not enabled") + + // ErrRoleClaimNotFound is returned when the specified role claim is not found in JWT. + // This error is exported for users who want to handle missing role claims in their error handling logic. + ErrRoleClaimNotFound = errors.New("role claim not found in JWT") + + // Internal errors - not exported as they are implementation details. + errEmptyClaimPath = errors.New("empty claim path") + errInvalidArrayNotation = errors.New("invalid array notation") + errInvalidArrayIndex = errors.New("invalid array index") + errClaimKeyNotFound = errors.New("claim key not found") + errClaimNotArray = errors.New("claim value is not an array") + errArrayIndexOutOfBounds = errors.New("array index out of bounds") + errClaimPathNotFound = errors.New("claim path not found") + errInvalidClaimStructure = errors.New("invalid claim structure") +) + +// JWTRoleExtractor extracts role from JWT claims stored in request context. +// It works with GoFr's OAuth middleware which stores JWT claims in context. +type JWTRoleExtractor struct { + // RoleClaim is the path to the role in JWT claims + // Examples: + // - "role" for simple claim: {"role": "admin"} + // - "roles[0]" for array: {"roles": ["admin", "user"]} + // - "permissions.role" for nested: {"permissions": {"role": "admin"}} + RoleClaim string +} + +// NewJWTRoleExtractor creates a new JWT role extractor. +func NewJWTRoleExtractor(roleClaim string) *JWTRoleExtractor { + if roleClaim == "" { + roleClaim = "role" // Default claim name + } + + return &JWTRoleExtractor{ + RoleClaim: roleClaim, + } +} + +// ExtractRole extracts the role from JWT claims in the request context. +// It expects the OAuth middleware to have already validated the JWT and stored claims. +func (e *JWTRoleExtractor) ExtractRole(req *http.Request, _ ...any) (string, error) { + // Get JWT claims from context (set by OAuth middleware) + // We use the same context key that GoFr's OAuth middleware uses + claims, ok := req.Context().Value(middleware.JWTClaim).(jwt.MapClaims) + if !ok || claims == nil { + return "", ErrJWTNotEnabled + } + + // Extract role using the configured claim path + role, err := extractClaimValue(claims, e.RoleClaim) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrRoleClaimNotFound, 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, 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 +} + +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", errClaimNotArray, key) + } + + if index < 0 || index >= len(arr) { + return nil, fmt.Errorf("%w: %d (length: %d)", errArrayIndexOutOfBounds, index, len(arr)) + } + + return arr[index], nil +} + +func extractNestedClaim(claims jwt.MapClaims, path string) (any, error) { + parts := strings.Split(path, ".") + + var current any = claims + + for i, part := range parts { + if i == len(parts)-1 { + return extractFinalPart(current, part, path, parts, i) + } + + // Navigate through nested structure + current = navigateNestedStructure(current, part, path, parts, i) + if current == nil { + return nil, fmt.Errorf("%w: %s", errInvalidClaimStructure, strings.Join(parts[:i+1], ".")) + } + } + + return nil, fmt.Errorf("%w: %s", errClaimPathNotFound, path) +} + +func extractFinalPart(current any, part, path string, parts []string, index int) (any, error) { + // Last part - return the value + 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 + } + + // jwt.MapClaims is a type alias for map[string]any + 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[:index+1], ".")) +} + +func navigateNestedStructure(current any, part, _ string, _ []string, _ int) any { + if m, ok := current.(map[string]any); ok { + return m[part] + } + + if m, ok := current.(jwt.MapClaims); ok { + return m[part] + } + + return nil +} diff --git a/pkg/gofr/rbac/providers/jwt_test.go b/pkg/gofr/rbac/providers/jwt_test.go new file mode 100644 index 0000000000..6dd5798dd9 --- /dev/null +++ b/pkg/gofr/rbac/providers/jwt_test.go @@ -0,0 +1,218 @@ +package providers + +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" +) + +func TestNewJWTRoleExtractor(t *testing.T) { + extractor := NewJWTRoleExtractor("role") + assert.NotNil(t, extractor) + assert.Equal(t, "role", extractor.RoleClaim) + + // Test default claim name + extractor2 := NewJWTRoleExtractor("") + assert.NotNil(t, extractor2) + assert.Equal(t, "role", extractor2.RoleClaim) +} + +func TestJWTRoleExtractor_ExtractRole_SimpleClaim(t *testing.T) { + extractor := NewJWTRoleExtractor("role") + + claims := jwt.MapClaims{ + "role": "admin", + "sub": "user123", + } + + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req = req.WithContext(context.WithValue(req.Context(), middleware.JWTClaim, claims)) + + role, err := extractor.ExtractRole(req) + require.NoError(t, err) + assert.Equal(t, "admin", role) +} + +func TestJWTRoleExtractor_ExtractRole_ArrayNotation(t *testing.T) { + extractor := NewJWTRoleExtractor("roles[0]") + + claims := jwt.MapClaims{ + "roles": []any{"admin", "editor"}, + "sub": "user123", + } + + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req = req.WithContext(context.WithValue(req.Context(), middleware.JWTClaim, claims)) + + role, err := extractor.ExtractRole(req) + require.NoError(t, err) + assert.Equal(t, "admin", role) +} + +func TestJWTRoleExtractor_ExtractRole_NestedClaim(t *testing.T) { + extractor := NewJWTRoleExtractor("permissions.role") + + claims := jwt.MapClaims{ + "permissions": map[string]any{ + "role": "admin", + }, + "sub": "user123", + } + + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req = req.WithContext(context.WithValue(req.Context(), middleware.JWTClaim, claims)) + + role, err := extractor.ExtractRole(req) + require.NoError(t, err) + assert.Equal(t, "admin", role) +} + +func TestJWTRoleExtractor_ExtractRole_NoJWT(t *testing.T) { + extractor := NewJWTRoleExtractor("role") + + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + // No JWT claims in context + + role, err := extractor.ExtractRole(req) + require.Error(t, err) + require.ErrorIs(t, err, ErrJWTNotEnabled) + assert.Empty(t, role) +} + +func TestJWTRoleExtractor_ExtractRole_ClaimNotFound(t *testing.T) { + extractor := NewJWTRoleExtractor("nonexistent") + + claims := jwt.MapClaims{ + "role": "admin", + } + + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req = req.WithContext(context.WithValue(req.Context(), middleware.JWTClaim, claims)) + + role, err := extractor.ExtractRole(req) + require.Error(t, err) + require.ErrorIs(t, err, ErrRoleClaimNotFound) + assert.Empty(t, role) +} + +func TestJWTRoleExtractor_ExtractRole_NonStringValue(t *testing.T) { + extractor := NewJWTRoleExtractor("role") + + claims := jwt.MapClaims{ + "role": 123, // Non-string value + } + + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req = req.WithContext(context.WithValue(req.Context(), middleware.JWTClaim, claims)) + + role, err := extractor.ExtractRole(req) + require.NoError(t, err) // Should convert to string + assert.Equal(t, "123", role) +} + +func TestJWTRoleExtractor_ExtractRole_ArrayIndexOutOfBounds(t *testing.T) { + extractor := NewJWTRoleExtractor("roles[5]") + + claims := jwt.MapClaims{ + "roles": []any{"admin", "editor"}, + } + + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req = req.WithContext(context.WithValue(req.Context(), middleware.JWTClaim, claims)) + + role, err := extractor.ExtractRole(req) + require.Error(t, err) + assert.Empty(t, role) +} + +func TestJWTRoleExtractor_ExtractRole_InvalidArrayIndex(t *testing.T) { + extractor := NewJWTRoleExtractor("roles[invalid]") + + claims := jwt.MapClaims{ + "roles": []any{"admin", "editor"}, + } + + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req = req.WithContext(context.WithValue(req.Context(), middleware.JWTClaim, claims)) + + role, err := extractor.ExtractRole(req) + require.Error(t, err) + assert.Empty(t, role) +} + +func TestJWTRoleExtractor_ExtractRole_DeeplyNested(t *testing.T) { + extractor := NewJWTRoleExtractor("user.permissions.role") + + claims := jwt.MapClaims{ + "user": map[string]any{ + "permissions": map[string]any{ + "role": "admin", + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) + req = req.WithContext(context.WithValue(req.Context(), middleware.JWTClaim, claims)) + + role, err := extractor.ExtractRole(req) + require.NoError(t, err) + assert.Equal(t, "admin", role) +} + +func TestExtractClaimValue_SimpleKey(t *testing.T) { + claims := jwt.MapClaims{ + "role": "admin", + } + + value, err := extractClaimValue(claims, "role") + require.NoError(t, err) + assert.Equal(t, "admin", value) +} + +func TestExtractClaimValue_ArrayNotation(t *testing.T) { + claims := jwt.MapClaims{ + "roles": []any{"admin", "editor", "viewer"}, + } + + value, err := extractClaimValue(claims, "roles[1]") + require.NoError(t, err) + assert.Equal(t, "editor", value) +} + +func TestExtractClaimValue_DotNotation(t *testing.T) { + claims := jwt.MapClaims{ + "user": map[string]any{ + "role": "admin", + }, + } + + value, err := extractClaimValue(claims, "user.role") + require.NoError(t, err) + assert.Equal(t, "admin", value) +} + +func TestExtractClaimValue_NotFound(t *testing.T) { + claims := jwt.MapClaims{ + "other": "value", + } + + value, err := extractClaimValue(claims, "nonexistent") + require.Error(t, err) + assert.Nil(t, value) +} + +func TestExtractClaimValue_EmptyPath(t *testing.T) { + claims := jwt.MapClaims{ + "role": "admin", + } + + value, err := extractClaimValue(claims, "") + require.Error(t, err) + assert.Nil(t, value) +} diff --git a/pkg/gofr/rbac/types.go b/pkg/gofr/rbac/types.go new file mode 100644 index 0000000000..10bdf265c1 --- /dev/null +++ b/pkg/gofr/rbac/types.go @@ -0,0 +1,49 @@ +package rbac + +import "net/http" + +// RBACConfig is the minimal interface for RBAC configuration. +// This interface is defined in the rbac package to avoid cyclic imports. +type RBACConfig interface { + // GetRouteWithPermissions returns the route-to-roles mapping + GetRouteWithPermissions() map[string][]string + + // GetRoleExtractorFunc returns the role extractor function + GetRoleExtractorFunc() RoleExtractor + + // GetPermissionConfig returns permission configuration if enabled + // Returns nil if permissions are not configured + // The returned value should implement PermissionConfig interface + GetPermissionConfig() any + + // GetOverRides returns route overrides + GetOverRides() map[string]bool + + // GetLogger returns the logger instance + GetLogger() any + + // GetRoleHeader returns the role header key if configured + GetRoleHeader() string + + // SetRoleExtractorFunc sets the role extractor function + SetRoleExtractorFunc(RoleExtractor) + + // SetLogger sets the logger instance + SetLogger(any) + + // SetEnablePermissions enables permission-based access control + SetEnablePermissions(bool) + + // InitializeMaps initializes empty maps if not present + InitializeMaps() +} + +// RoleExtractor extracts the user's role from the HTTP request. +type RoleExtractor func(req *http.Request, args ...any) (string, error) + +// Options is an interface for RBAC configuration options. +// This follows the same pattern as service.Options for consistency. +type Options interface { + AddOption(config RBACConfig) RBACConfig +} + diff --git a/pkg/gofr/rbac_interface.go b/pkg/gofr/rbac_interface.go new file mode 100644 index 0000000000..ae1128f22f --- /dev/null +++ b/pkg/gofr/rbac_interface.go @@ -0,0 +1,71 @@ +package gofr + +import ( + "net/http" +) + +// RBACConfig is the minimal interface for RBAC configuration. +// This interface matches rbac.RBACConfig to allow rbac.Config to implement it. +// The actual interface is defined in rbac package to avoid cyclic imports. +type RBACConfig interface { + // GetRouteWithPermissions returns the route-to-roles mapping + GetRouteWithPermissions() map[string][]string + + // GetRoleExtractorFunc returns the role extractor function + GetRoleExtractorFunc() RoleExtractor + + // GetPermissionConfig returns permission configuration if enabled + // Returns nil if permissions are not configured + // The returned value should implement PermissionConfig interface + GetPermissionConfig() any + + // GetOverRides returns route overrides + GetOverRides() map[string]bool + + // GetLogger returns the logger instance + GetLogger() any + + // GetRoleHeader returns the role header key if configured + GetRoleHeader() string + + // SetRoleExtractorFunc sets the role extractor function + SetRoleExtractorFunc(RoleExtractor) + + // SetLogger sets the logger instance + SetLogger(any) + + // SetEnablePermissions enables permission-based access control + SetEnablePermissions(bool) + + // InitializeMaps initializes empty maps if not present + InitializeMaps() +} + +// RoleExtractor extracts the user's role from the HTTP request. +type RoleExtractor func(req *http.Request, args ...any) (string, error) + +// PermissionConfig is the interface for permission-based access control. +type PermissionConfig interface { + GetPermissions() map[string][]string + GetRoutePermissionMap() map[string]string + GetRolePermissions() map[string][]string + GetRoutePermissionRules() []RoutePermissionRule +} + +// RoutePermissionRule defines a rule for mapping routes to permissions. +type RoutePermissionRule struct { + Methods []string `json:"methods" yaml:"methods"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Regex string `json:"regex,omitempty" yaml:"regex,omitempty"` + Permission string `json:"permission" yaml:"permission"` +} + +// RBACOption is an interface for RBAC configuration options. +// This follows the same pattern as service.Options for consistency. +// The method name is AddOption to match service.Options pattern. +type RBACOption interface { + AddOption(config RBACConfig) RBACConfig +} + +// RBACHandlerFunc is a function type for RBAC handler wrappers. +type RBACHandlerFunc func(ctx any) (any, error)