diff --git a/README.md b/README.md index d231657..1f16a4e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ CloudPAM is a modern IPAM solution designed for hybrid and multi-cloud environme - **Schema Wizard** - Design IP schemas with conflict detection before deploying - **AI Planning** - Optional OpenAI-compatible conversational planner with SSE streaming and plan apply - **CIDR Search** - Unified search with containment queries across pools and accounts -- **Auth & RBAC** - Local users, session cookies, API keys, and optional OIDC/SSO +- **Auth & RBAC** - Local users, session cookies, API keys, custom roles, and optional OIDC/SSO - **Release Notes & Updates** - Embedded changelog plus release-check and host-managed upgrade endpoints - **Audit Logging** - Full activity tracking with filterable event log - **Observability** - Structured logging (slog), Prometheus metrics, Sentry integration @@ -60,7 +60,7 @@ See the [Deployment Guide](docs/DEPLOYMENT.md) for production setup. | **Database** | PostgreSQL 15+ (production) / SQLite (development) / In-memory (demo) | | **Frontend** | React 18 + Vite + TypeScript + Tailwind CSS | | **API** | OpenAPI 3.1 | -| **Auth** | Local sessions + API keys + OIDC + RBAC | +| **Auth** | Local sessions + API keys + OIDC + custom-role RBAC | | **Logging** | slog (Go std lib) | | **Metrics** | Prometheus | | **Error Tracking** | Sentry (backend + frontend) | diff --git a/cmd/cloudpam/main.go b/cmd/cloudpam/main.go index f775f98..4dd1c76 100644 --- a/cmd/cloudpam/main.go +++ b/cmd/cloudpam/main.go @@ -154,12 +154,15 @@ func main() { auditLogger := selectAuditLogger(logger) keyStore := selectKeyStore(logger) userStore := selectUserStore(logger) + roleStore := selectRoleStore(logger, userStore) + auth.SetRoleStoreProvider(roleStore) sessionStore := selectSessionStore(logger) srv := api.NewServer(mux, store, logger, metrics, auditLogger) srv.SetAppVersion(version) // Check if this is a fresh install (no users exist) for first-boot setup. srv.SetUserStore(userStore) + srv.SetRoleStore(roleStore) existingUsers, _ := userStore.List(context.Background()) if len(existingUsers) == 0 { srv.SetNeedsSetup(true) @@ -239,12 +242,15 @@ func main() { authSrv.SetSettingsStore(settingsStore) authSrv.RegisterProtectedAuthRoutes(logger.Slog()) userSrv := api.NewUserServer(srv, keyStore, userStore, sessionStore, auditLogger) + userSrv.SetRoleStore(roleStore) loginRL := api.LoginRateLimitMiddleware(api.LoginRateLimitConfig{ AttemptsPerMinute: 5, ProxyConfig: proxyConfig, }) userSrv.RegisterProtectedUserRoutes(logger.Slog(), api.WithLoginRateLimit(loginRL)) dualMW := api.DualAuthMiddleware(keyStore, sessionStore, userStore, true, logger.Slog()) + roleSrv := api.NewRoleServer(srv, roleStore) + roleSrv.RegisterProtectedRoleRoutes(dualMW, logger.Slog()) discoverySrv.RegisterProtectedDiscoveryRoutes(dualMW, logger.Slog()) analysisSrv.RegisterProtectedAnalysisRoutes(dualMW, logger.Slog()) recSrv.RegisterProtectedRecommendationRoutes(dualMW, logger.Slog()) @@ -333,6 +339,7 @@ func main() { } else { logger.Info("database connection closed") } + closeIfPossible(logger, roleStore, "role store") // Flush Sentry events if sentryEnabled { diff --git a/cmd/cloudpam/store_both.go b/cmd/cloudpam/store_both.go index a1f259e..6f30efa 100644 --- a/cmd/cloudpam/store_both.go +++ b/cmd/cloudpam/store_both.go @@ -124,6 +124,28 @@ func selectUserStore(logger observability.Logger) auth.UserStore { return us } +func selectRoleStore(logger observability.Logger, userStore auth.UserStore) auth.RoleStore { + if logger == nil { + logger = observability.NewLogger(observability.DefaultConfig()) + } + if usePostgres() { + rs, err := auth.NewPostgresRoleStore(databaseURL(), userStore) + if err != nil { + logger.Error("postgres role store init failed; falling back to sqlite", "error", err) + } else { + logger.Info("using postgres role store") + return rs + } + } + rs, err := auth.NewSQLiteRoleStore(sqliteDSN(), userStore) + if err != nil { + logger.Error("sqlite role store init failed; falling back to memory", "error", err) + return auth.NewMemoryRoleStore(userStore) + } + logger.Info("using sqlite role store") + return rs +} + func selectSessionStore(logger observability.Logger) auth.SessionStore { if logger == nil { logger = observability.NewLogger(observability.DefaultConfig()) diff --git a/cmd/cloudpam/store_default.go b/cmd/cloudpam/store_default.go index e7787e5..ce5bc7f 100644 --- a/cmd/cloudpam/store_default.go +++ b/cmd/cloudpam/store_default.go @@ -41,6 +41,11 @@ func selectUserStore(_ observability.Logger) auth.UserStore { return auth.NewMemoryUserStore() } +// selectRoleStore returns an in-memory role store. +func selectRoleStore(_ observability.Logger, userStore auth.UserStore) auth.RoleStore { + return auth.NewMemoryRoleStore(userStore) +} + // selectSessionStore returns an in-memory session store. func selectSessionStore(_ observability.Logger) auth.SessionStore { return auth.NewMemorySessionStore() diff --git a/cmd/cloudpam/store_postgres.go b/cmd/cloudpam/store_postgres.go index 552dccf..fb3d706 100644 --- a/cmd/cloudpam/store_postgres.go +++ b/cmd/cloudpam/store_postgres.go @@ -81,6 +81,21 @@ func selectUserStore(logger observability.Logger) auth.UserStore { return us } +// selectRoleStore returns a PostgreSQL-backed role store. +func selectRoleStore(logger observability.Logger, userStore auth.UserStore) auth.RoleStore { + if logger == nil { + logger = observability.NewLogger(observability.DefaultConfig()) + } + url := databaseURL() + rs, err := auth.NewPostgresRoleStore(url, userStore) + if err != nil { + logger.Error("postgres role store init failed; falling back to memory", "error", err) + return auth.NewMemoryRoleStore(userStore) + } + logger.Info("using postgres role store") + return rs +} + // selectSessionStore returns a PostgreSQL-backed session store. func selectSessionStore(logger observability.Logger) auth.SessionStore { if logger == nil { diff --git a/cmd/cloudpam/store_sqlite.go b/cmd/cloudpam/store_sqlite.go index 9a8c1f2..8b32125 100644 --- a/cmd/cloudpam/store_sqlite.go +++ b/cmd/cloudpam/store_sqlite.go @@ -81,6 +81,21 @@ func selectUserStore(logger observability.Logger) auth.UserStore { return us } +// selectRoleStore returns a SQLite-backed role store. +func selectRoleStore(logger observability.Logger, userStore auth.UserStore) auth.RoleStore { + if logger == nil { + logger = observability.NewLogger(observability.DefaultConfig()) + } + dsn := sqliteDSN() + rs, err := auth.NewSQLiteRoleStore(dsn, userStore) + if err != nil { + logger.Error("sqlite role store init failed; falling back to memory", "error", err) + return auth.NewMemoryRoleStore(userStore) + } + logger.Info("using sqlite role store") + return rs +} + // selectSessionStore returns a SQLite-backed session store. func selectSessionStore(logger observability.Logger) auth.SessionStore { if logger == nil { diff --git a/docs/AUTH_FLOWS.md b/docs/AUTH_FLOWS.md index 6a6e71a..9a035e0 100644 --- a/docs/AUTH_FLOWS.md +++ b/docs/AUTH_FLOWS.md @@ -290,69 +290,81 @@ rate_limit: |------|-------------|-----------------| | **Admin** | Full access | All permissions | | **Operator** | Manage IPAM and discovery resources | pools:*, accounts:*, discovery:* | -| **Viewer** | Read-only access | pools:read, accounts:read, discovery:read | -| **Auditor** | Audit-only access | audit:read | +| **Viewer** | Read-only access | pools:read/list, accounts:read/list, discovery:read/list | +| **Auditor** | Audit-only access | audit:read/list | ### Permission Structure -Permissions follow the pattern: `resource:action` +Permissions follow the pattern: `resource:action`. The catalog below mirrors the API permission checks and the **Identity > RBAC** permission matrix. ``` -pools:read - View pools -pools:write - Create/update pools -pools:delete - Delete pools -pools:allocate - Allocate from pools +pools:create - Create address pools and planned allocations +pools:read - View pool details, blocks, utilization, and schema checks +pools:update - Edit pool metadata, hierarchy, and assignment +pools:delete - Delete pools and planned allocations +pools:list - Browse pool lists and tree views -accounts:read - View cloud accounts -accounts:write - Create/update accounts -accounts:delete - Remove accounts -accounts:sync - Trigger syncs +accounts:create - Create cloud account records +accounts:read - View account details and account-linked resources +accounts:update - Edit account metadata +accounts:delete - Delete account records +accounts:list - Browse account lists -schema:read - View plans/templates -schema:write - Create/update plans -schema:apply - Apply schema plans +apikeys:create - Create API tokens within the caller's permission envelope +apikeys:read - View API key metadata +apikeys:update - Reserved for future API key metadata updates +apikeys:delete - Revoke API keys +apikeys:list - Browse API key metadata -discovery:read - View discovered resources -discovery:link - Link resources to pools -discovery:sync - Trigger discovery +audit:read - View audit event details +audit:list - Browse audit events -audit:read - View audit logs -audit:export - Export audit data +users:create - Create local user accounts +users:read - View user account details +users:update - Edit users, roles, password state, and active status +users:delete - Deactivate user accounts +users:list - Browse user accounts -users:read - View users -users:invite - Invite new users -users:write - Update users -users:delete - Remove users +discovery:create - Start discovery syncs and register agents +discovery:read - View discovered resources, agents, drift, and recommendations +discovery:update - Apply discovery results and reconcile drift +discovery:delete - Reserved for future discovery cleanup operations +discovery:list - Browse discovery resources, jobs, agents, drift, and recommendations -roles:read - View roles -roles:write - Manage custom roles - -org:read - View org settings -org:write - Update org settings - -teams:read - View teams -teams:write - Manage teams +settings:read - View security, OIDC, update, and system configuration +settings:write - Change security, OIDC, update, and system configuration ``` ### Custom Roles -Admins can create custom roles with specific permission sets: +Admins can create custom roles from **Identity > RBAC** or through the role API. Built-in roles are immutable. Custom roles can be assigned to users from **Identity > Users**, and a custom role cannot be deleted while it is assigned to any active user. ```json { - "name": "Network Engineer", + "name": "network-engineer", "description": "Can manage pools and view discovery", "permissions": [ "pools:read", - "pools:write", - "pools:allocate", + "pools:list", + "pools:update", "discovery:read", - "accounts:read", - "schema:read" + "discovery:list", + "accounts:read" ] } ``` +Role administration endpoints require `settings:read` for list/read operations and `settings:write` for create/update/delete operations: + +``` +GET /api/v1/auth/permissions +GET /api/v1/auth/roles +POST /api/v1/auth/roles +GET /api/v1/auth/roles/{name} +PATCH /api/v1/auth/roles/{name} +DELETE /api/v1/auth/roles/{name} +``` + ### Team-Based Access (Pool Scoping) Teams can have access scoped to specific pools: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4f90818..51a1454 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.0] - 2026-05-08 + +### Added +- Custom RBAC roles with app-wide permission catalog endpoints, persisted role/permission storage, and immutable built-in role handling +- Identity RBAC UI for creating, editing, deleting, and reviewing roles against the same permission matrix enforced by the API +- User management role selectors now include custom roles, and role deletion is blocked while assigned to active users + +### Changed +- Session metadata now includes effective permissions so the frontend can gate administration pages by permission instead of admin-only role checks +- API key scope creation now validates requested scopes against the caller's effective role permissions, including custom roles + ## [0.12.0] - 2026-05-07 ### Added diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 64b13c8..cf86696 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -26,7 +26,7 @@ This summary reflects what is implemented in the current repository. The deeper **Authentication & Authorization:** - API key authentication with Argon2id hashing -- RBAC with 4 roles: admin, operator, viewer, auditor +- RBAC with immutable built-ins (admin, operator, viewer, auditor) plus admin-managed custom roles built from app-wide `resource:action` permissions - Local user management with password authentication - Session management (HttpOnly + Secure cookies) - Dual auth: session cookies (browser) + Bearer tokens (API) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 00e3ddd..709b624 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -803,6 +803,114 @@ paths: $ref: "#/components/schemas/Error" default: $ref: "#/components/responses/Error" + /api/v1/auth/permissions: + get: + summary: List RBAC permissions + tags: [Auth] + description: Requires `settings:read`. + responses: + "200": + description: Permission catalog listed + content: + application/json: + schema: + type: object + properties: + permissions: + type: array + items: + $ref: "#/components/schemas/PermissionInfo" + required: [permissions] + default: + $ref: "#/components/responses/Error" + /api/v1/auth/roles: + get: + summary: List RBAC roles + tags: [Auth] + description: Requires `settings:read`. + responses: + "200": + description: Roles listed + content: + application/json: + schema: + type: object + properties: + roles: + type: array + items: + $ref: "#/components/schemas/RoleInfo" + required: [roles] + default: + $ref: "#/components/responses/Error" + post: + summary: Create custom RBAC role + tags: [Auth] + description: Requires `settings:write`. Built-in role names cannot be created. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoleSaveRequest" + responses: + "201": + description: Role created + content: + application/json: + schema: + $ref: "#/components/schemas/RoleInfo" + default: + $ref: "#/components/responses/Error" + /api/v1/auth/roles/{roleName}: + parameters: + - name: roleName + in: path + required: true + schema: + type: string + description: Role slug/name + get: + summary: Get RBAC role + tags: [Auth] + description: Requires `settings:read`. + responses: + "200": + description: Role found + content: + application/json: + schema: + $ref: "#/components/schemas/RoleInfo" + default: + $ref: "#/components/responses/Error" + patch: + summary: Update custom RBAC role + tags: [Auth] + description: Requires `settings:write`. Built-in roles are immutable. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoleSaveRequest" + responses: + "200": + description: Role updated + content: + application/json: + schema: + $ref: "#/components/schemas/RoleInfo" + default: + $ref: "#/components/responses/Error" + delete: + summary: Delete custom RBAC role + tags: [Auth] + description: Requires `settings:write`. Fails when the role is built-in or assigned to an active user. + responses: + "204": + description: Role deleted + default: + $ref: "#/components/responses/Error" /api/v1/audit: get: summary: Query audit log @@ -1458,6 +1566,71 @@ components: minimum: 0 description: Key expires after this many days. Omit to use the configured default; 0 requests no expiration unless a maximum lifetime is enforced. required: [name] + Permission: + type: object + properties: + resource: + type: string + action: + type: string + required: [resource, action] + PermissionInfo: + type: object + properties: + id: + type: string + example: pools:read + resource: + type: string + example: pools + action: + type: string + example: read + name: + type: string + description: + type: string + category: + type: string + required: [id, resource, action, name, description, category] + RoleInfo: + type: object + properties: + id: + type: string + name: + type: string + example: network-operator + description: + type: string + is_builtin: + type: boolean + permissions: + type: array + items: + $ref: "#/components/schemas/Permission" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: [id, name, is_builtin, permissions] + RoleSaveRequest: + type: object + properties: + name: + type: string + description: Required for creates; ignored on updates. + example: network-operator + description: + type: string + permissions: + type: array + items: + type: string + example: pools:read + required: [description, permissions] SecuritySettings: type: object properties: diff --git a/internal/api/account_handlers.go b/internal/api/account_handlers.go index 5e76f04..a0e859d 100644 --- a/internal/api/account_handlers.go +++ b/internal/api/account_handlers.go @@ -22,14 +22,14 @@ func (s *Server) protectedAccountsHandler(logger *slog.Logger) http.Handler { switch r.Method { case http.MethodGet: - if !auth.HasPermission(role, auth.ResourceAccounts, auth.ActionList) && - !auth.HasPermission(role, auth.ResourceAccounts, auth.ActionRead) { + if !auth.HasPermissionContext(ctx, role, auth.ResourceAccounts, auth.ActionList) && + !auth.HasPermissionContext(ctx, role, auth.ResourceAccounts, auth.ActionRead) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } s.listAccounts(w, r) case http.MethodPost: - if !auth.HasPermission(role, auth.ResourceAccounts, auth.ActionCreate) { + if !auth.HasPermissionContext(ctx, role, auth.ResourceAccounts, auth.ActionCreate) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } @@ -61,7 +61,7 @@ func (s *Server) protectedAccountsSubroutesHandler(logger *slog.Logger) http.Han switch r.Method { case http.MethodGet: - if !auth.HasPermission(role, auth.ResourceAccounts, auth.ActionRead) { + if !auth.HasPermissionContext(ctx, role, auth.ResourceAccounts, auth.ActionRead) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } @@ -77,7 +77,7 @@ func (s *Server) protectedAccountsSubroutesHandler(logger *slog.Logger) http.Han writeJSON(w, http.StatusOK, a) case http.MethodPatch: - if !auth.HasPermission(role, auth.ResourceAccounts, auth.ActionUpdate) { + if !auth.HasPermissionContext(ctx, role, auth.ResourceAccounts, auth.ActionUpdate) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } @@ -105,7 +105,7 @@ func (s *Server) protectedAccountsSubroutesHandler(logger *slog.Logger) http.Han writeJSON(w, http.StatusOK, a) case http.MethodDelete: - if !auth.HasPermission(role, auth.ResourceAccounts, auth.ActionDelete) { + if !auth.HasPermissionContext(ctx, role, auth.ResourceAccounts, auth.ActionDelete) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } diff --git a/internal/api/auth_handlers.go b/internal/api/auth_handlers.go index a121b97..cae28c0 100644 --- a/internal/api/auth_handlers.go +++ b/internal/api/auth_handlers.go @@ -95,15 +95,15 @@ func (as *AuthServer) protectedAPIKeysHandler(logger *slog.Logger) http.Handler switch r.Method { case http.MethodPost: // Create API key requires apikeys:create - if !auth.HasPermission(role, auth.ResourceAPIKeys, auth.ActionCreate) { + if !auth.HasPermissionContext(ctx, role, auth.ResourceAPIKeys, auth.ActionCreate) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } as.createAPIKey(w, r) case http.MethodGet: // List API keys requires apikeys:list or apikeys:read - if !auth.HasPermission(role, auth.ResourceAPIKeys, auth.ActionList) && - !auth.HasPermission(role, auth.ResourceAPIKeys, auth.ActionRead) { + if !auth.HasPermissionContext(ctx, role, auth.ResourceAPIKeys, auth.ActionList) && + !auth.HasPermissionContext(ctx, role, auth.ResourceAPIKeys, auth.ActionRead) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } @@ -132,7 +132,7 @@ func (as *AuthServer) protectedAPIKeyByIDHandler(logger *slog.Logger) http.Handl switch r.Method { case http.MethodDelete: // Revoke API key requires apikeys:delete - if !auth.HasPermission(role, auth.ResourceAPIKeys, auth.ActionDelete) { + if !auth.HasPermissionContext(ctx, role, auth.ResourceAPIKeys, auth.ActionDelete) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } @@ -201,14 +201,23 @@ func (as *AuthServer) createAPIKey(w http.ResponseWriter, r *http.Request) { callerRole := auth.GetEffectiveRole(r.Context()) if callerRole != auth.RoleNone { requestedRole := auth.GetRoleFromScopes(input.Scopes) - if auth.RoleLevel(requestedRole) > auth.RoleLevel(callerRole) { + if auth.IsBuiltinRole(callerRole) && auth.RoleLevel(requestedRole) > auth.RoleLevel(callerRole) { as.writeErr(r.Context(), w, http.StatusForbidden, "scope elevation denied", "requested scopes require a higher privilege level than your current role") return } - if deniedScope := deniedAPIKeyScope(settings, callerRole, input.Scopes); deniedScope != "" { - as.writeErr(r.Context(), w, http.StatusForbidden, "scope denied by API key policy", "requested scope is not allowed for your role") - return + for _, scope := range input.Scopes { + if !auth.ScopeAllowedByRolePermissions(ctx, callerRole, scope) { + as.writeErr(r.Context(), w, http.StatusForbidden, "scope elevation denied", + "requested scope exceeds your current permissions") + return + } + } + if auth.IsBuiltinRole(callerRole) { + if deniedScope := deniedAPIKeyScope(settings, callerRole, input.Scopes); deniedScope != "" { + as.writeErr(r.Context(), w, http.StatusForbidden, "scope denied by API key policy", "requested scope is not allowed for your role") + return + } } } diff --git a/internal/api/middleware.go b/internal/api/middleware.go index dfcf3d6..0359cb0 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -1013,7 +1013,7 @@ func RequirePermissionMiddleware(resource, action string, logger *slog.Logger) M } // Check permission - if !auth.HasPermission(role, resource, action) { + if !auth.HasPermissionContext(ctx, role, resource, action) { // Log authorization failure attrs := appendRequestID(ctx, []any{ "method", r.Method, @@ -1062,7 +1062,7 @@ func RequireAnyPermissionMiddleware(permissions []auth.Permission, logger *slog. // Check if any permission is granted for _, perm := range permissions { - if auth.HasPermission(role, perm.Resource, perm.Action) { + if auth.HasPermissionContext(ctx, role, perm.Resource, perm.Action) { next.ServeHTTP(w, r) return } diff --git a/internal/api/pool_handlers.go b/internal/api/pool_handlers.go index d6a21d2..28f9395 100644 --- a/internal/api/pool_handlers.go +++ b/internal/api/pool_handlers.go @@ -26,15 +26,15 @@ func (s *Server) protectedPoolsHandler(logger *slog.Logger) http.Handler { switch r.Method { case http.MethodGet: // List pools requires pools:list or pools:read - if !auth.HasPermission(role, auth.ResourcePools, auth.ActionList) && - !auth.HasPermission(role, auth.ResourcePools, auth.ActionRead) { + if !auth.HasPermissionContext(ctx, role, auth.ResourcePools, auth.ActionList) && + !auth.HasPermissionContext(ctx, role, auth.ResourcePools, auth.ActionRead) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } s.listPools(w, r) case http.MethodPost: // Create pool requires pools:create - if !auth.HasPermission(role, auth.ResourcePools, auth.ActionCreate) { + if !auth.HasPermissionContext(ctx, role, auth.ResourcePools, auth.ActionCreate) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } @@ -66,7 +66,7 @@ func (s *Server) protectedPoolsSubroutesHandler(logger *slog.Logger) http.Handle return } // Requires pools:read - if !auth.HasPermission(role, auth.ResourcePools, auth.ActionRead) { + if !auth.HasPermissionContext(ctx, role, auth.ResourcePools, auth.ActionRead) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } @@ -87,7 +87,7 @@ func (s *Server) protectedPoolsSubroutesHandler(logger *slog.Logger) http.Handle return } // Requires pools:read - if !auth.HasPermission(role, auth.ResourcePools, auth.ActionRead) { + if !auth.HasPermissionContext(ctx, role, auth.ResourcePools, auth.ActionRead) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } @@ -102,7 +102,7 @@ func (s *Server) protectedPoolsSubroutesHandler(logger *slog.Logger) http.Handle return } // Requires pools:read - if !auth.HasPermission(role, auth.ResourcePools, auth.ActionRead) { + if !auth.HasPermissionContext(ctx, role, auth.ResourcePools, auth.ActionRead) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } @@ -113,7 +113,7 @@ func (s *Server) protectedPoolsSubroutesHandler(logger *slog.Logger) http.Handle // Handle /pools/{id} switch r.Method { case http.MethodGet: - if !auth.HasPermission(role, auth.ResourcePools, auth.ActionRead) { + if !auth.HasPermissionContext(ctx, role, auth.ResourcePools, auth.ActionRead) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } @@ -129,14 +129,14 @@ func (s *Server) protectedPoolsSubroutesHandler(logger *slog.Logger) http.Handle writeJSON(w, http.StatusOK, p) case http.MethodPatch: - if !auth.HasPermission(role, auth.ResourcePools, auth.ActionUpdate) { + if !auth.HasPermissionContext(ctx, role, auth.ResourcePools, auth.ActionUpdate) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } s.updatePool(w, r, id64) case http.MethodDelete: - if !auth.HasPermission(role, auth.ResourcePools, auth.ActionDelete) { + if !auth.HasPermissionContext(ctx, role, auth.ResourcePools, auth.ActionDelete) { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) return } diff --git a/internal/api/role_handlers.go b/internal/api/role_handlers.go new file mode 100644 index 0000000..adbea07 --- /dev/null +++ b/internal/api/role_handlers.go @@ -0,0 +1,194 @@ +package api + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + + "cloudpam/internal/audit" + "cloudpam/internal/auth" +) + +type RoleServer struct { + *Server + roleStore auth.RoleStore +} + +func NewRoleServer(s *Server, roleStore auth.RoleStore) *RoleServer { + return &RoleServer{Server: s, roleStore: roleStore} +} + +func (rs *RoleServer) RegisterProtectedRoleRoutes(authMW Middleware, logger *slog.Logger) { + if logger == nil { + logger = slog.Default() + } + readMW := RequirePermissionMiddleware(auth.ResourceSettings, auth.ActionRead, logger) + writeMW := RequirePermissionMiddleware(auth.ResourceSettings, auth.ActionWrite, logger) + + rs.mux.Handle("/api/v1/auth/permissions", authMW(readMW(http.HandlerFunc(rs.listPermissions)))) + rs.mux.Handle("/api/v1/auth/roles", authMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + readMW(http.HandlerFunc(rs.listRoles)).ServeHTTP(w, r) + case http.MethodPost: + writeMW(http.HandlerFunc(rs.createRole)).ServeHTTP(w, r) + default: + w.Header().Set("Allow", strings.Join([]string{http.MethodGet, http.MethodPost}, ", ")) + rs.writeErr(r.Context(), w, http.StatusMethodNotAllowed, "method not allowed", "") + } + }))) + rs.mux.Handle("/api/v1/auth/roles/", authMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := auth.NormalizeRoleName(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/v1/auth/roles/"), "/")) + if name == auth.RoleNone { + rs.writeErr(r.Context(), w, http.StatusNotFound, "not found", "") + return + } + switch r.Method { + case http.MethodGet: + readMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rs.getRole(w, r, name) })).ServeHTTP(w, r) + case http.MethodPatch: + writeMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rs.updateRole(w, r, name) })).ServeHTTP(w, r) + case http.MethodDelete: + writeMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rs.deleteRole(w, r, name) })).ServeHTTP(w, r) + default: + w.Header().Set("Allow", strings.Join([]string{http.MethodGet, http.MethodPatch, http.MethodDelete}, ", ")) + rs.writeErr(r.Context(), w, http.StatusMethodNotAllowed, "method not allowed", "") + } + }))) +} + +func (rs *RoleServer) listPermissions(w http.ResponseWriter, r *http.Request) { + perms, err := rs.roleStore.ListPermissions(r.Context()) + if err != nil { + rs.writeErr(r.Context(), w, http.StatusInternalServerError, "failed to list permissions", err.Error()) + return + } + writeJSON(w, http.StatusOK, struct { + Permissions []auth.PermissionDefinition `json:"permissions"` + }{Permissions: perms}) +} + +func (rs *RoleServer) listRoles(w http.ResponseWriter, r *http.Request) { + roles, err := rs.roleStore.ListRoles(r.Context()) + if err != nil { + rs.writeErr(r.Context(), w, http.StatusInternalServerError, "failed to list roles", err.Error()) + return + } + writeJSON(w, http.StatusOK, struct { + Roles []*auth.RoleDefinition `json:"roles"` + }{Roles: roles}) +} + +func (rs *RoleServer) getRole(w http.ResponseWriter, r *http.Request, name auth.Role) { + role, err := rs.roleStore.GetRole(r.Context(), name) + if err != nil { + rs.writeRoleErr(w, r, err) + return + } + writeJSON(w, http.StatusOK, role) +} + +func (rs *RoleServer) createRole(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + input, ok := rs.decodeRoleInput(w, r, true) + if !ok { + return + } + role := &auth.RoleDefinition{ + Name: auth.NormalizeRoleName(input.Name), + Description: input.Description, + Permissions: input.Permissions, + } + if err := rs.roleStore.CreateRole(ctx, role); err != nil { + rs.writeRoleErr(w, r, err) + return + } + created, err := rs.roleStore.GetRole(ctx, role.Name) + if err != nil { + rs.writeErr(ctx, w, http.StatusInternalServerError, "failed to read created role", err.Error()) + return + } + rs.logAudit(ctx, audit.ActionCreate, "role", string(created.Name), string(created.Name), http.StatusCreated) + writeJSON(w, http.StatusCreated, created) +} + +func (rs *RoleServer) updateRole(w http.ResponseWriter, r *http.Request, name auth.Role) { + ctx := r.Context() + input, ok := rs.decodeRoleInput(w, r, false) + if !ok { + return + } + role, err := rs.roleStore.UpdateRole(ctx, name, input.Description, input.Permissions) + if err != nil { + rs.writeRoleErr(w, r, err) + return + } + rs.logAudit(ctx, audit.ActionUpdate, "role", string(role.Name), string(role.Name), http.StatusOK) + writeJSON(w, http.StatusOK, role) +} + +func (rs *RoleServer) deleteRole(w http.ResponseWriter, r *http.Request, name auth.Role) { + ctx := r.Context() + if err := rs.roleStore.DeleteRole(ctx, name); err != nil { + rs.writeRoleErr(w, r, err) + return + } + rs.logAudit(ctx, audit.ActionDelete, "role", string(name), string(name), http.StatusNoContent) + w.WriteHeader(http.StatusNoContent) +} + +type roleInput struct { + Name string + Description string + Permissions []auth.Permission +} + +func (rs *RoleServer) decodeRoleInput(w http.ResponseWriter, r *http.Request, requireName bool) (roleInput, bool) { + var raw struct { + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` + } + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + rs.writeErr(r.Context(), w, http.StatusBadRequest, "invalid json", "") + return roleInput{}, false + } + raw.Name = strings.TrimSpace(raw.Name) + if requireName && raw.Name == "" { + rs.writeErr(r.Context(), w, http.StatusBadRequest, "name is required", "") + return roleInput{}, false + } + perms := make([]auth.Permission, 0, len(raw.Permissions)) + seen := make(map[string]bool, len(raw.Permissions)) + for _, id := range raw.Permissions { + perm, ok := auth.PermissionFromID(id) + if !ok { + rs.writeErr(r.Context(), w, http.StatusBadRequest, "invalid permission", id) + return roleInput{}, false + } + if seen[perm.String()] { + continue + } + seen[perm.String()] = true + perms = append(perms, perm) + } + return roleInput{Name: raw.Name, Description: strings.TrimSpace(raw.Description), Permissions: perms}, true +} + +func (rs *RoleServer) writeRoleErr(w http.ResponseWriter, r *http.Request, err error) { + ctx := r.Context() + switch { + case errors.Is(err, auth.ErrRoleNotFound): + rs.writeErr(ctx, w, http.StatusNotFound, err.Error(), "") + case errors.Is(err, auth.ErrRoleExists): + rs.writeErr(ctx, w, http.StatusConflict, err.Error(), "") + case errors.Is(err, auth.ErrBuiltinRole), errors.Is(err, auth.ErrRoleInUse): + rs.writeErr(ctx, w, http.StatusConflict, err.Error(), "") + case errors.Is(err, auth.ErrInvalidRole), errors.Is(err, auth.ErrInvalidPermission): + rs.writeErr(ctx, w, http.StatusBadRequest, err.Error(), "") + default: + rs.writeErr(ctx, w, http.StatusInternalServerError, "internal error", err.Error()) + } +} diff --git a/internal/api/role_handlers_test.go b/internal/api/role_handlers_test.go new file mode 100644 index 0000000..844881e --- /dev/null +++ b/internal/api/role_handlers_test.go @@ -0,0 +1,131 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "cloudpam/internal/auth" + "cloudpam/internal/observability" + "cloudpam/internal/storage" +) + +func setupRoleTestServer(t *testing.T) (*Server, auth.RoleStore, auth.UserStore) { + t.Helper() + mux := http.NewServeMux() + logger := observability.NewLogger(observability.Config{ + Level: "info", + Format: "json", + Output: io.Discard, + }) + srv := NewServer(mux, storage.NewMemoryStore(), logger, nil, nil) + userStore := auth.NewMemoryUserStore() + roleStore := auth.NewMemoryRoleStore(userStore) + auth.SetRoleStoreProvider(roleStore) + t.Cleanup(func() { auth.SetRoleStoreProvider(nil) }) + + roleSrv := NewRoleServer(srv, roleStore) + authMW := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := auth.ContextWithRole(r.Context(), auth.RoleAdmin) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } + roleSrv.RegisterProtectedRoleRoutes(authMW, logger.Slog()) + return srv, roleStore, userStore +} + +func TestRoleHandlers_CreateUpdateAndDeleteCustomRole(t *testing.T) { + srv, roleStore, _ := setupRoleTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/roles", strings.NewReader(`{ + "name": "network-operator", + "description": "Network operator", + "permissions": ["pools:read", "pools:list", "accounts:read"] + }`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.mux.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + + var created auth.RoleDefinition + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatalf("unmarshal role: %v", err) + } + if created.Name != "network-operator" || created.IsBuiltin { + t.Fatalf("unexpected role response: %+v", created) + } + if !auth.HasPermissionContext(context.Background(), created.Name, auth.ResourcePools, auth.ActionRead) { + t.Fatalf("created role should resolve through dynamic permission provider") + } + + req = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/roles/network-operator", strings.NewReader(`{ + "description": "Read only", + "permissions": ["pools:read"] + }`)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + srv.mux.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + role, err := roleStore.GetRole(context.Background(), "network-operator") + if err != nil { + t.Fatalf("get role: %v", err) + } + if len(role.Permissions) != 1 || role.Permissions[0].String() != "pools:read" { + t.Fatalf("unexpected permissions after update: %+v", role.Permissions) + } + + req = httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/network-operator", nil) + rr = httptest.NewRecorder() + srv.mux.ServeHTTP(rr, req) + if rr.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestRoleHandlers_PreventBuiltinAndAssignedRoleDeletion(t *testing.T) { + srv, roleStore, userStore := setupRoleTestServer(t) + if err := roleStore.CreateRole(context.Background(), &auth.RoleDefinition{ + Name: "assigned-role", + Description: "Assigned", + Permissions: []auth.Permission{ + {Resource: auth.ResourcePools, Action: auth.ActionRead}, + }, + }); err != nil { + t.Fatalf("create role: %v", err) + } + if err := userStore.Create(context.Background(), &auth.User{ + ID: "user-1", + Username: "assigned-user", + Role: "assigned-role", + PasswordHash: []byte("hash"), + IsActive: true, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("create user: %v", err) + } + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/admin", nil) + rr := httptest.NewRecorder() + srv.mux.ServeHTTP(rr, req) + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409 for built-in role delete, got %d: %s", rr.Code, rr.Body.String()) + } + + req = httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/assigned-role", nil) + rr = httptest.NewRecorder() + srv.mux.ServeHTTP(rr, req) + if rr.Code != http.StatusConflict { + t.Fatalf("expected 409 for assigned role delete, got %d: %s", rr.Code, rr.Body.String()) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 0a8b4a0..49e0029 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -37,6 +37,7 @@ type Server struct { localAuthEnabled bool needsSetup bool userStore auth.UserStore + roleStore auth.RoleStore settingsStore storage.SettingsStore appVersion string } @@ -58,6 +59,9 @@ func NewServer(mux *http.ServeMux, store storage.Store, logger observability.Log // SetUserStore sets the user store for first-boot setup. func (s *Server) SetUserStore(us auth.UserStore) { s.userStore = us } +// SetRoleStore sets the role store for custom RBAC lookups. +func (s *Server) SetRoleStore(rs auth.RoleStore) { s.roleStore = rs } + // SetSettingsStore sets the settings store for runtime configuration lookups. func (s *Server) SetSettingsStore(ss storage.SettingsStore) { s.settingsStore = ss } diff --git a/internal/api/user_handlers.go b/internal/api/user_handlers.go index 05e8dc8..faab0c0 100644 --- a/internal/api/user_handlers.go +++ b/internal/api/user_handlers.go @@ -22,6 +22,7 @@ type UserServer struct { *Server keyStore auth.KeyStore userStore auth.UserStore + roleStore auth.RoleStore sessionStore auth.SessionStore auditLogger audit.AuditLogger settingsStore storage.SettingsStore @@ -43,6 +44,11 @@ func (us *UserServer) SetSettingsStore(ss storage.SettingsStore) { us.settingsStore = ss } +// SetRoleStore sets the role store used to validate custom role assignments. +func (us *UserServer) SetRoleStore(rs auth.RoleStore) { + us.roleStore = rs +} + // userRouteConfig holds optional configuration for user route registration. type userRouteConfig struct { loginRateLimit func(http.Handler) http.Handler @@ -309,11 +315,13 @@ func (us *UserServer) handleLogin(w http.ResponseWriter, r *http.Request) { us.logAuditEvent(ctx, audit.ActionLogin, audit.ResourceSession, session.ID, user.Username, http.StatusOK) writeJSON(w, http.StatusOK, struct { - User *auth.User `json:"user"` - ExpiresAt time.Time `json:"expires_at"` + User *auth.User `json:"user"` + ExpiresAt time.Time `json:"expires_at"` + Permissions []string `json:"permissions"` }{ - User: user, - ExpiresAt: session.ExpiresAt, + User: user, + ExpiresAt: session.ExpiresAt, + Permissions: permissionStrings(auth.GetPermissionsContext(ctx, user.Role)), }) } @@ -376,6 +384,7 @@ func (us *UserServer) handleMe(w http.ResponseWriter, r *http.Request) { KeyName string `json:"key_name,omitempty"` AuthProvider string `json:"auth_provider,omitempty"` SessionExpiresAt string `json:"session_expires_at,omitempty"` + Permissions []string `json:"permissions,omitempty"` } if user := auth.UserFromContext(ctx); user != nil { @@ -384,6 +393,7 @@ func (us *UserServer) handleMe(w http.ResponseWriter, r *http.Request) { Role: role, User: user, AuthProvider: user.AuthProvider, + Permissions: permissionStrings(auth.GetPermissionsContext(ctx, role)), } if resp.AuthProvider == "" { resp.AuthProvider = "local" @@ -397,10 +407,11 @@ func (us *UserServer) handleMe(w http.ResponseWriter, r *http.Request) { if key := auth.APIKeyFromContext(ctx); key != nil { writeJSON(w, http.StatusOK, meResponse{ - AuthType: "api_key", - Role: role, - KeyID: key.ID, - KeyName: key.Name, + AuthType: "api_key", + Role: role, + KeyID: key.ID, + KeyName: key.Name, + Permissions: permissionStrings(auth.GetPermissionsContext(ctx, role)), }) return } @@ -529,9 +540,10 @@ func (us *UserServer) createUser(w http.ResponseWriter, r *http.Request) { return } - role := auth.ParseRole(input.Role) - if role == auth.RoleNone { - role = auth.RoleViewer // default + role, ok := us.normalizeAndValidateRole(ctx, input.Role, true) + if !ok { + us.writeErr(ctx, w, http.StatusBadRequest, "invalid role", input.Role) + return } hash, err := auth.HashPassword(input.Password) @@ -618,8 +630,8 @@ func (us *UserServer) updateUser(w http.ResponseWriter, r *http.Request, id stri user.DisplayName = strings.TrimSpace(*input.DisplayName) } if input.Role != nil { - role := auth.ParseRole(*input.Role) - if role == auth.RoleNone { + role, ok := us.normalizeAndValidateRole(ctx, *input.Role, false) + if !ok { us.writeErr(ctx, w, http.StatusBadRequest, "invalid role", *input.Role) return } @@ -645,6 +657,32 @@ func (us *UserServer) updateUser(w http.ResponseWriter, r *http.Request, id stri writeJSON(w, http.StatusOK, user) } +func (us *UserServer) normalizeAndValidateRole(ctx context.Context, value string, defaultViewer bool) (auth.Role, bool) { + role := auth.NormalizeRoleName(value) + if role == auth.RoleNone && defaultViewer { + role = auth.RoleViewer + } + if role == auth.RoleNone { + return auth.RoleNone, false + } + if us.roleStore != nil { + if _, err := us.roleStore.GetRole(ctx, role); err == nil { + return role, true + } + return auth.RoleNone, false + } + return role, auth.RoleExists(ctx, role) +} + +func permissionStrings(perms []auth.Permission) []string { + out := make([]string, 0, len(perms)) + for _, perm := range perms { + out = append(out, perm.String()) + } + sort.Strings(out) + return out +} + // deactivateUser soft-deletes a user by setting is_active=false and clearing sessions. // DELETE /api/v1/auth/users/{id} func (us *UserServer) deactivateUser(w http.ResponseWriter, r *http.Request, id string) { @@ -686,7 +724,7 @@ func (us *UserServer) changePassword(w http.ResponseWriter, r *http.Request, id currentUser := auth.UserFromContext(ctx) role := auth.GetEffectiveRole(ctx) isSelf := currentUser != nil && currentUser.ID == id - isAdmin := auth.HasPermission(role, auth.ResourceUsers, auth.ActionUpdate) + isAdmin := auth.HasPermissionContext(ctx, role, auth.ResourceUsers, auth.ActionUpdate) if !isSelf && !isAdmin { writeJSON(w, http.StatusForbidden, apiError{Error: "forbidden"}) diff --git a/internal/auth/context.go b/internal/auth/context.go index 0e055f6..64bbdd5 100644 --- a/internal/auth/context.go +++ b/internal/auth/context.go @@ -134,7 +134,7 @@ func GetEffectiveRole(ctx context.Context) Role { // Returns nil if permitted, or ErrInsufficientScopes if not. func RequirePermission(ctx context.Context, resource, action string) error { role := GetEffectiveRole(ctx) - if !HasPermission(role, resource, action) { + if !HasPermissionContext(ctx, role, resource, action) { return ErrInsufficientScopes } return nil diff --git a/internal/auth/rbac.go b/internal/auth/rbac.go index ade0de9..8a969ed 100644 --- a/internal/auth/rbac.go +++ b/internal/auth/rbac.go @@ -2,7 +2,9 @@ package auth import ( + "context" "strings" + "sync" ) // Role represents a user role in the RBAC system. @@ -57,6 +59,77 @@ func (p Permission) String() string { return p.Resource + ":" + p.Action } +// PermissionCatalog returns the complete app-wide permission catalog. +func PermissionCatalog() []PermissionDefinition { + defs := []PermissionDefinition{ + {ID: "pools:create", Resource: ResourcePools, Action: ActionCreate, Name: "Create pools", Description: "Create address pools and planned allocations.", Category: "IPAM"}, + {ID: "pools:read", Resource: ResourcePools, Action: ActionRead, Name: "Read pools", Description: "View pool details, blocks, utilization, and schema checks.", Category: "IPAM"}, + {ID: "pools:update", Resource: ResourcePools, Action: ActionUpdate, Name: "Update pools", Description: "Edit pool metadata, hierarchy, and assignment.", Category: "IPAM"}, + {ID: "pools:delete", Resource: ResourcePools, Action: ActionDelete, Name: "Delete pools", Description: "Delete pools and planned allocations.", Category: "IPAM"}, + {ID: "pools:list", Resource: ResourcePools, Action: ActionList, Name: "List pools", Description: "Browse pool lists and tree views.", Category: "IPAM"}, + {ID: "accounts:create", Resource: ResourceAccounts, Action: ActionCreate, Name: "Create accounts", Description: "Create cloud account records.", Category: "Accounts"}, + {ID: "accounts:read", Resource: ResourceAccounts, Action: ActionRead, Name: "Read accounts", Description: "View account details and account-linked resources.", Category: "Accounts"}, + {ID: "accounts:update", Resource: ResourceAccounts, Action: ActionUpdate, Name: "Update accounts", Description: "Edit account metadata.", Category: "Accounts"}, + {ID: "accounts:delete", Resource: ResourceAccounts, Action: ActionDelete, Name: "Delete accounts", Description: "Delete account records.", Category: "Accounts"}, + {ID: "accounts:list", Resource: ResourceAccounts, Action: ActionList, Name: "List accounts", Description: "Browse account lists.", Category: "Accounts"}, + {ID: "apikeys:create", Resource: ResourceAPIKeys, Action: ActionCreate, Name: "Create API keys", Description: "Create API tokens within the caller's permission envelope.", Category: "Identity"}, + {ID: "apikeys:read", Resource: ResourceAPIKeys, Action: ActionRead, Name: "Read API keys", Description: "View API key metadata.", Category: "Identity"}, + {ID: "apikeys:update", Resource: ResourceAPIKeys, Action: ActionUpdate, Name: "Update API keys", Description: "Reserved for future API key metadata updates.", Category: "Identity"}, + {ID: "apikeys:delete", Resource: ResourceAPIKeys, Action: ActionDelete, Name: "Delete API keys", Description: "Revoke API keys.", Category: "Identity"}, + {ID: "apikeys:list", Resource: ResourceAPIKeys, Action: ActionList, Name: "List API keys", Description: "Browse API key metadata.", Category: "Identity"}, + {ID: "audit:read", Resource: ResourceAudit, Action: ActionRead, Name: "Read audit logs", Description: "View audit event details.", Category: "Audit"}, + {ID: "audit:list", Resource: ResourceAudit, Action: ActionList, Name: "List audit logs", Description: "Browse audit events.", Category: "Audit"}, + {ID: "users:create", Resource: ResourceUsers, Action: ActionCreate, Name: "Create users", Description: "Create local user accounts.", Category: "Identity"}, + {ID: "users:read", Resource: ResourceUsers, Action: ActionRead, Name: "Read users", Description: "View user account details.", Category: "Identity"}, + {ID: "users:update", Resource: ResourceUsers, Action: ActionUpdate, Name: "Update users", Description: "Edit users, roles, password state, and active status.", Category: "Identity"}, + {ID: "users:delete", Resource: ResourceUsers, Action: ActionDelete, Name: "Delete users", Description: "Deactivate user accounts.", Category: "Identity"}, + {ID: "users:list", Resource: ResourceUsers, Action: ActionList, Name: "List users", Description: "Browse user accounts.", Category: "Identity"}, + {ID: "discovery:create", Resource: ResourceDiscovery, Action: ActionCreate, Name: "Start discovery", Description: "Start discovery syncs and register agents.", Category: "Discovery"}, + {ID: "discovery:read", Resource: ResourceDiscovery, Action: ActionRead, Name: "Read discovery", Description: "View discovered resources, agents, drift, and recommendations.", Category: "Discovery"}, + {ID: "discovery:update", Resource: ResourceDiscovery, Action: ActionUpdate, Name: "Update discovery", Description: "Apply discovery results and reconcile drift.", Category: "Discovery"}, + {ID: "discovery:delete", Resource: ResourceDiscovery, Action: ActionDelete, Name: "Delete discovery", Description: "Reserved for future discovery cleanup operations.", Category: "Discovery"}, + {ID: "discovery:list", Resource: ResourceDiscovery, Action: ActionList, Name: "List discovery", Description: "Browse discovery resources, jobs, agents, drift, and recommendations.", Category: "Discovery"}, + {ID: "settings:read", Resource: ResourceSettings, Action: ActionRead, Name: "Read settings", Description: "View security, OIDC, update, and system configuration.", Category: "Settings"}, + {ID: "settings:write", Resource: ResourceSettings, Action: ActionWrite, Name: "Write settings", Description: "Change security, OIDC, update, and system configuration.", Category: "Settings"}, + } + result := make([]PermissionDefinition, len(defs)) + copy(result, defs) + return result +} + +func PermissionFromID(id string) (Permission, bool) { + parts := strings.SplitN(strings.TrimSpace(id), ":", 2) + if len(parts) != 2 { + return Permission{}, false + } + perm := Permission{Resource: parts[0], Action: parts[1]} + return perm, IsValidPermission(perm) +} + +func IsValidPermission(perm Permission) bool { + id := perm.String() + for _, def := range PermissionCatalog() { + if def.ID == id { + return true + } + } + return false +} + +func ValidatePermissions(perms []Permission) error { + seen := make(map[string]bool, len(perms)) + for _, perm := range perms { + if !IsValidPermission(perm) { + return ErrInvalidPermission + } + if seen[perm.String()] { + continue + } + seen[perm.String()] = true + } + return nil +} + // RolePermissions maps roles to their allowed permissions. // This is the authoritative source of what each role can do. var RolePermissions = map[Role][]Permission{ @@ -127,7 +200,11 @@ var RolePermissions = map[Role][]Permission{ // rolePermissionCache is a pre-computed lookup table for faster permission checks. // Map format: role -> resource -> action -> bool -var rolePermissionCache map[Role]map[string]map[string]bool +var ( + rolePermissionCache map[Role]map[string]map[string]bool + roleProviderMu sync.RWMutex + roleProvider RoleStore +) func init() { rolePermissionCache = make(map[Role]map[string]map[string]bool) @@ -145,9 +222,27 @@ func init() { // HasPermission checks if a role has permission for a specific resource and action. // Returns false for unknown roles or permissions (default deny). func HasPermission(role Role, resource, action string) bool { + return HasPermissionContext(context.Background(), role, resource, action) +} + +func HasPermissionContext(ctx context.Context, role Role, resource, action string) bool { + if key := APIKeyFromContext(ctx); key != nil && key.IsValid() { + return ScopesAllowPermission(key.Scopes, resource, action) + } if role == RoleNone { return false } + if provider := currentRoleProvider(); provider != nil { + def, err := provider.GetRole(ctx, role) + if err == nil && def != nil { + for _, perm := range def.Permissions { + if perm.Resource == resource && perm.Action == action { + return true + } + } + return false + } + } resourcePerms, ok := rolePermissionCache[role] if !ok { @@ -162,9 +257,85 @@ func HasPermission(role Role, resource, action string) bool { return actionPerms[action] // Unknown action returns false (deny) } +func SetRoleStoreProvider(provider RoleStore) { + roleProviderMu.Lock() + defer roleProviderMu.Unlock() + roleProvider = provider +} + +func currentRoleProvider() RoleStore { + roleProviderMu.RLock() + defer roleProviderMu.RUnlock() + return roleProvider +} + // GetPermissions returns all permissions for a given role. // Returns nil for unknown roles. func GetPermissions(role Role) []Permission { + return GetPermissionsContext(context.Background(), role) +} + +func GetPermissionsContext(ctx context.Context, role Role) []Permission { + if key := APIKeyFromContext(ctx); key != nil && key.IsValid() { + return PermissionsFromScopes(key.Scopes) + } + if provider := currentRoleProvider(); provider != nil { + def, err := provider.GetRole(ctx, role) + if err == nil && def != nil { + return copyPermissions(def.Permissions) + } + } + return GetStaticPermissions(role) +} + +func ScopesAllowPermission(scopes []string, resource, action string) bool { + for _, scope := range scopes { + if ScopeAllowsPermission(scope, resource, action) { + return true + } + } + return false +} + +func ScopeAllowsPermission(scope, resource, action string) bool { + scope = strings.TrimSpace(scope) + if scope == "*" { + return true + } + parts := strings.SplitN(scope, ":", 2) + if len(parts) != 2 { + return false + } + scopeResource := parts[0] + if scopeResource == "keys" { + scopeResource = ResourceAPIKeys + } + if scopeResource != resource { + return false + } + switch parts[1] { + case "*": + return true + case "read": + return action == ActionRead || action == ActionList + case "write": + return action == ActionCreate || action == ActionRead || action == ActionUpdate || action == ActionDelete || action == ActionList || action == ActionWrite + default: + return parts[1] == action + } +} + +func PermissionsFromScopes(scopes []string) []Permission { + var perms []Permission + for _, def := range PermissionCatalog() { + if ScopesAllowPermission(scopes, def.Resource, def.Action) { + perms = append(perms, Permission{Resource: def.Resource, Action: def.Action}) + } + } + return perms +} + +func GetStaticPermissions(role Role) []Permission { perms, ok := RolePermissions[role] if !ok { return nil @@ -175,6 +346,55 @@ func GetPermissions(role Role) []Permission { return result } +func copyPermissions(perms []Permission) []Permission { + result := make([]Permission, len(perms)) + copy(result, perms) + return result +} + +func RoleExists(ctx context.Context, role Role) bool { + if IsBuiltinRole(role) { + return true + } + if provider := currentRoleProvider(); provider != nil { + def, err := provider.GetRole(ctx, role) + return err == nil && def != nil + } + return false +} + +func ScopeAllowedByRolePermissions(ctx context.Context, role Role, scope string) bool { + scope = strings.TrimSpace(scope) + if scope == "*" { + for _, def := range PermissionCatalog() { + if !HasPermissionContext(ctx, role, def.Resource, def.Action) { + return false + } + } + return true + } + parts := strings.SplitN(scope, ":", 2) + if len(parts) != 2 { + return false + } + resource := parts[0] + action := parts[1] + if resource == "keys" { + resource = ResourceAPIKeys + } + if action == "read" { + return HasPermissionContext(ctx, role, resource, ActionRead) || HasPermissionContext(ctx, role, resource, ActionList) + } + if action == "write" || action == "*" { + return HasPermissionContext(ctx, role, resource, ActionCreate) && + HasPermissionContext(ctx, role, resource, ActionRead) && + HasPermissionContext(ctx, role, resource, ActionUpdate) && + HasPermissionContext(ctx, role, resource, ActionDelete) && + HasPermissionContext(ctx, role, resource, ActionList) + } + return HasPermissionContext(ctx, role, resource, action) +} + // GetRoleFromScopes determines the effective role based on API key scopes. // The role is determined by the highest privilege level implied by the scopes. // diff --git a/internal/auth/rbac_test.go b/internal/auth/rbac_test.go index b41ede3..21589a2 100644 --- a/internal/auth/rbac_test.go +++ b/internal/auth/rbac_test.go @@ -286,6 +286,52 @@ func BenchmarkHasPermission(b *testing.B) { } } +func TestScopesAllowPermission(t *testing.T) { + tests := []struct { + name string + scopes []string + resource string + action string + want bool + }{ + { + name: "read scope grants read and list", + scopes: []string{"pools:read"}, + resource: ResourcePools, + action: ActionList, + want: true, + }, + { + name: "read scope does not grant other resources", + scopes: []string{"pools:read"}, + resource: ResourceAccounts, + action: ActionRead, + want: false, + }, + { + name: "write scope grants mutation", + scopes: []string{"keys:write"}, + resource: ResourceAPIKeys, + action: ActionDelete, + want: true, + }, + { + name: "wildcard grants all", + scopes: []string{"*"}, + resource: ResourceSettings, + action: ActionWrite, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ScopesAllowPermission(tt.scopes, tt.resource, tt.action); got != tt.want { + t.Fatalf("ScopesAllowPermission() = %v, want %v", got, tt.want) + } + }) + } +} + func BenchmarkGetRoleFromScopes(b *testing.B) { scopes := []string{"pools:read", "pools:write", "accounts:read"} for i := 0; i < b.N; i++ { diff --git a/internal/auth/role_postgres.go b/internal/auth/role_postgres.go new file mode 100644 index 0000000..a204587 --- /dev/null +++ b/internal/auth/role_postgres.go @@ -0,0 +1,258 @@ +//go:build postgres + +package auth + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type PostgresRoleStore struct { + pool *pgxpool.Pool + ownPool bool + users UserStore +} + +func NewPostgresRoleStore(connStr string, users UserStore) (*PostgresRoleStore, error) { + pool, err := pgxpool.New(context.Background(), connStr) + if err != nil { + return nil, err + } + return &PostgresRoleStore{pool: pool, ownPool: true, users: users}, nil +} + +func (s *PostgresRoleStore) Close() error { + if s.ownPool { + s.pool.Close() + } + return nil +} + +func (s *PostgresRoleStore) ListPermissions(ctx context.Context) ([]PermissionDefinition, error) { + rows, err := s.pool.Query(ctx, `SELECT id, name, COALESCE(description, ''), category FROM permissions ORDER BY category, id`) + if err != nil { + return nil, err + } + defer rows.Close() + var defs []PermissionDefinition + for rows.Next() { + var def PermissionDefinition + if err := rows.Scan(&def.ID, &def.Name, &def.Description, &def.Category); err != nil { + return nil, err + } + if perm, ok := PermissionFromID(def.ID); ok { + def.Resource = perm.Resource + def.Action = perm.Action + } + defs = append(defs, def) + } + return defs, rows.Err() +} + +func (s *PostgresRoleStore) ListRoles(ctx context.Context) ([]*RoleDefinition, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, name, COALESCE(description, ''), is_builtin, created_at, updated_at + FROM roles + WHERE organization_id IS NULL + ORDER BY is_builtin DESC, name`) + if err != nil { + return nil, err + } + defer rows.Close() + var roles []*RoleDefinition + for rows.Next() { + role, err := scanPostgresRoleMetadata(rows) + if err != nil { + return nil, err + } + role.Permissions, err = s.rolePermissionsByID(ctx, role.ID) + if err != nil { + return nil, err + } + roles = append(roles, role) + } + return roles, rows.Err() +} + +func (s *PostgresRoleStore) GetRole(ctx context.Context, name Role) (*RoleDefinition, error) { + name = NormalizeRoleName(string(name)) + role, err := scanPostgresRoleMetadata(s.pool.QueryRow(ctx, ` + SELECT id, name, COALESCE(description, ''), is_builtin, created_at, updated_at + FROM roles WHERE organization_id IS NULL AND name = $1`, string(name))) + if err != nil { + return nil, err + } + role.Permissions, err = s.rolePermissionsByID(ctx, role.ID) + if err != nil { + return nil, err + } + return role, nil +} + +func (s *PostgresRoleStore) CreateRole(ctx context.Context, role *RoleDefinition) error { + if role == nil { + return ErrInvalidRole + } + role.Name = NormalizeRoleName(string(role.Name)) + if err := ValidateCustomRoleName(role.Name); err != nil { + return err + } + if err := ValidatePermissions(role.Permissions); err != nil { + return err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + var id uuid.UUID + err = tx.QueryRow(ctx, ` + INSERT INTO roles (organization_id, name, description, is_builtin) + VALUES (NULL, $1, $2, FALSE) + RETURNING id`, string(role.Name), strings.TrimSpace(role.Description)).Scan(&id) + if err != nil { + if isUniqueViolation(err) { + return ErrRoleExists + } + return err + } + if err := insertPostgresRolePermissions(ctx, tx, id.String(), role.Permissions); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *PostgresRoleStore) UpdateRole(ctx context.Context, name Role, description string, permissions []Permission) (*RoleDefinition, error) { + name = NormalizeRoleName(string(name)) + if IsBuiltinRole(name) { + return nil, ErrBuiltinRole + } + if err := ValidatePermissions(permissions); err != nil { + return nil, err + } + role, err := s.GetRole(ctx, name) + if err != nil { + return nil, err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + tag, err := tx.Exec(ctx, `UPDATE roles SET description = $2 WHERE id = $1 AND is_builtin = FALSE`, role.ID, strings.TrimSpace(description)) + if err != nil { + return nil, err + } + if tag.RowsAffected() == 0 { + return nil, ErrRoleNotFound + } + if _, err := tx.Exec(ctx, `DELETE FROM role_permissions WHERE role_id = $1`, role.ID); err != nil { + return nil, err + } + if err := insertPostgresRolePermissions(ctx, tx, role.ID, permissions); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return s.GetRole(ctx, name) +} + +func (s *PostgresRoleStore) DeleteRole(ctx context.Context, name Role) error { + name = NormalizeRoleName(string(name)) + if IsBuiltinRole(name) { + return ErrBuiltinRole + } + inUse, err := s.RoleAssignedToActiveUsers(ctx, name) + if err != nil { + return err + } + if inUse { + return ErrRoleInUse + } + tag, err := s.pool.Exec(ctx, `DELETE FROM roles WHERE organization_id IS NULL AND name = $1 AND is_builtin = FALSE`, string(name)) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrRoleNotFound + } + return nil +} + +func (s *PostgresRoleStore) RoleAssignedToActiveUsers(ctx context.Context, name Role) (bool, error) { + if s.users != nil { + users, err := s.users.List(ctx) + if err != nil { + return false, err + } + for _, user := range users { + if user.IsActive && user.Role == name { + return true, nil + } + } + return false, nil + } + var count int + if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE role = $1 AND is_active = TRUE`, string(name)).Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} + +type postgresRoleScanner interface { + Scan(dest ...any) error +} + +func scanPostgresRoleMetadata(row postgresRoleScanner) (*RoleDefinition, error) { + var id uuid.UUID + var name string + var createdAt, updatedAt time.Time + role := RoleDefinition{} + if err := row.Scan(&id, &name, &role.Description, &role.IsBuiltin, &createdAt, &updatedAt); err != nil { + if err == pgx.ErrNoRows { + return nil, ErrRoleNotFound + } + return nil, err + } + role.ID = id.String() + role.Name = Role(name) + role.CreatedAt = createdAt + role.UpdatedAt = updatedAt + return &role, nil +} + +func (s *PostgresRoleStore) rolePermissionsByID(ctx context.Context, roleID string) ([]Permission, error) { + rows, err := s.pool.Query(ctx, `SELECT permission_id FROM role_permissions WHERE role_id = $1 ORDER BY permission_id`, roleID) + if err != nil { + return nil, err + } + defer rows.Close() + var perms []Permission + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + if perm, ok := PermissionFromID(id); ok { + perms = append(perms, perm) + } + } + sort.Slice(perms, func(i, j int) bool { return perms[i].String() < perms[j].String() }) + return perms, rows.Err() +} + +func insertPostgresRolePermissions(ctx context.Context, tx pgx.Tx, roleID string, permissions []Permission) error { + for _, perm := range permissions { + if _, err := tx.Exec(ctx, `INSERT INTO role_permissions (role_id, permission_id) VALUES ($1::uuid, $2) ON CONFLICT DO NOTHING`, roleID, perm.String()); err != nil { + return err + } + } + return nil +} diff --git a/internal/auth/role_sqlite.go b/internal/auth/role_sqlite.go new file mode 100644 index 0000000..8ae548f --- /dev/null +++ b/internal/auth/role_sqlite.go @@ -0,0 +1,259 @@ +//go:build sqlite + +package auth + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strconv" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +type SQLiteRoleStore struct { + db *sql.DB + users UserStore +} + +func NewSQLiteRoleStore(dsn string, users UserStore) (*SQLiteRoleStore, error) { + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;`); err != nil { + _ = db.Close() + return nil, fmt.Errorf("set pragmas: %w", err) + } + return &SQLiteRoleStore{db: db, users: users}, nil +} + +func (s *SQLiteRoleStore) Close() error { return s.db.Close() } + +func (s *SQLiteRoleStore) ListPermissions(ctx context.Context) ([]PermissionDefinition, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, name, COALESCE(description, ''), category FROM permissions ORDER BY category, id`) + if err != nil { + return nil, fmt.Errorf("query permissions: %w", err) + } + defer rows.Close() + var defs []PermissionDefinition + for rows.Next() { + var def PermissionDefinition + if err := rows.Scan(&def.ID, &def.Name, &def.Description, &def.Category); err != nil { + return nil, fmt.Errorf("scan permission: %w", err) + } + if perm, ok := PermissionFromID(def.ID); ok { + def.Resource = perm.Resource + def.Action = perm.Action + } + defs = append(defs, def) + } + return defs, rows.Err() +} + +func (s *SQLiteRoleStore) ListRoles(ctx context.Context) ([]*RoleDefinition, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, name, COALESCE(description, ''), is_builtin, created_at, updated_at + FROM roles + WHERE organization_id IS NULL + ORDER BY is_builtin DESC, name`) + if err != nil { + return nil, fmt.Errorf("query roles: %w", err) + } + defer rows.Close() + + var roles []*RoleDefinition + for rows.Next() { + role, err := s.scanRoleMetadata(rows) + if err != nil { + return nil, err + } + perms, err := s.rolePermissionsByID(ctx, role.ID) + if err != nil { + return nil, err + } + role.Permissions = perms + roles = append(roles, role) + } + return roles, rows.Err() +} + +func (s *SQLiteRoleStore) GetRole(ctx context.Context, name Role) (*RoleDefinition, error) { + name = NormalizeRoleName(string(name)) + row := s.db.QueryRowContext(ctx, ` + SELECT id, name, COALESCE(description, ''), is_builtin, created_at, updated_at + FROM roles WHERE name = ? AND organization_id IS NULL`, string(name)) + role, err := s.scanRoleMetadata(row) + if err != nil { + return nil, err + } + role.Permissions, err = s.rolePermissionsByID(ctx, role.ID) + if err != nil { + return nil, err + } + return role, nil +} + +func (s *SQLiteRoleStore) CreateRole(ctx context.Context, role *RoleDefinition) error { + if role == nil { + return ErrInvalidRole + } + role.Name = NormalizeRoleName(string(role.Name)) + if err := ValidateCustomRoleName(role.Name); err != nil { + return err + } + if err := ValidatePermissions(role.Permissions); err != nil { + return err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := tx.ExecContext(ctx, ` + INSERT INTO roles (organization_id, name, description, is_builtin, created_at, updated_at) + VALUES (NULL, ?, ?, 0, ?, ?)`, string(role.Name), strings.TrimSpace(role.Description), now, now) + if err != nil { + if isUniqueViolation(err) { + return ErrRoleExists + } + return fmt.Errorf("insert role: %w", err) + } + roleID, _ := res.LastInsertId() + if err := insertSQLiteRolePermissions(ctx, tx, strconv.FormatInt(roleID, 10), role.Permissions); err != nil { + return err + } + return tx.Commit() +} + +func (s *SQLiteRoleStore) UpdateRole(ctx context.Context, name Role, description string, permissions []Permission) (*RoleDefinition, error) { + name = NormalizeRoleName(string(name)) + if IsBuiltinRole(name) { + return nil, ErrBuiltinRole + } + if err := ValidatePermissions(permissions); err != nil { + return nil, err + } + role, err := s.GetRole(ctx, name) + if err != nil { + return nil, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `UPDATE roles SET description = ?, updated_at = ? WHERE id = ? AND is_builtin = 0`, + strings.TrimSpace(description), time.Now().UTC().Format(time.RFC3339Nano), role.ID); err != nil { + return nil, fmt.Errorf("update role: %w", err) + } + if _, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = ?`, role.ID); err != nil { + return nil, fmt.Errorf("delete role permissions: %w", err) + } + if err := insertSQLiteRolePermissions(ctx, tx, role.ID, permissions); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetRole(ctx, name) +} + +func (s *SQLiteRoleStore) DeleteRole(ctx context.Context, name Role) error { + name = NormalizeRoleName(string(name)) + if IsBuiltinRole(name) { + return ErrBuiltinRole + } + inUse, err := s.RoleAssignedToActiveUsers(ctx, name) + if err != nil { + return err + } + if inUse { + return ErrRoleInUse + } + res, err := s.db.ExecContext(ctx, `DELETE FROM roles WHERE name = ? AND organization_id IS NULL AND is_builtin = 0`, string(name)) + if err != nil { + return fmt.Errorf("delete role: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrRoleNotFound + } + return nil +} + +func (s *SQLiteRoleStore) RoleAssignedToActiveUsers(ctx context.Context, name Role) (bool, error) { + if s.users != nil { + users, err := s.users.List(ctx) + if err != nil { + return false, err + } + for _, user := range users { + if user.IsActive && user.Role == name { + return true, nil + } + } + return false, nil + } + var count int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = ? AND is_active = 1`, string(name)).Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} + +type roleScanner interface { + Scan(dest ...any) error +} + +func (s *SQLiteRoleStore) scanRoleMetadata(row roleScanner) (*RoleDefinition, error) { + var id int64 + var role RoleDefinition + var name string + var isBuiltin int + var createdAt, updatedAt string + if err := row.Scan(&id, &name, &role.Description, &isBuiltin, &createdAt, &updatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, ErrRoleNotFound + } + return nil, fmt.Errorf("scan role: %w", err) + } + role.ID = strconv.FormatInt(id, 10) + role.Name = Role(name) + role.IsBuiltin = isBuiltin != 0 + role.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) + role.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) + return &role, nil +} + +func (s *SQLiteRoleStore) rolePermissionsByID(ctx context.Context, roleID string) ([]Permission, error) { + rows, err := s.db.QueryContext(ctx, `SELECT permission_id FROM role_permissions WHERE role_id = ? ORDER BY permission_id`, roleID) + if err != nil { + return nil, fmt.Errorf("query role permissions: %w", err) + } + defer rows.Close() + var perms []Permission + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan role permission: %w", err) + } + if perm, ok := PermissionFromID(id); ok { + perms = append(perms, perm) + } + } + sort.Slice(perms, func(i, j int) bool { return perms[i].String() < perms[j].String() }) + return perms, rows.Err() +} + +func insertSQLiteRolePermissions(ctx context.Context, tx *sql.Tx, roleID string, permissions []Permission) error { + for _, perm := range permissions { + if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)`, roleID, perm.String()); err != nil { + return fmt.Errorf("insert role permission: %w", err) + } + } + return nil +} diff --git a/internal/auth/role_store.go b/internal/auth/role_store.go new file mode 100644 index 0000000..7c8ef52 --- /dev/null +++ b/internal/auth/role_store.go @@ -0,0 +1,244 @@ +package auth + +import ( + "context" + "errors" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +var ( + ErrRoleNotFound = errors.New("role not found") + ErrRoleExists = errors.New("role already exists") + ErrBuiltinRole = errors.New("built-in roles cannot be modified") + ErrRoleInUse = errors.New("role is assigned to active users") + ErrInvalidRole = errors.New("invalid role") + ErrInvalidPermission = errors.New("invalid permission") +) + +type PermissionDefinition struct { + ID string `json:"id"` + Resource string `json:"resource"` + Action string `json:"action"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` +} + +type RoleDefinition struct { + ID string `json:"id"` + Name Role `json:"name"` + Description string `json:"description"` + IsBuiltin bool `json:"is_builtin"` + Permissions []Permission `json:"permissions"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +type RoleStore interface { + ListPermissions(ctx context.Context) ([]PermissionDefinition, error) + ListRoles(ctx context.Context) ([]*RoleDefinition, error) + GetRole(ctx context.Context, name Role) (*RoleDefinition, error) + CreateRole(ctx context.Context, role *RoleDefinition) error + UpdateRole(ctx context.Context, name Role, description string, permissions []Permission) (*RoleDefinition, error) + DeleteRole(ctx context.Context, name Role) error + RoleAssignedToActiveUsers(ctx context.Context, name Role) (bool, error) +} + +var roleNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,62}$`) + +func NormalizeRoleName(name string) Role { + return Role(strings.ToLower(strings.TrimSpace(name))) +} + +func ValidateCustomRoleName(name Role) error { + if IsBuiltinRole(name) { + return ErrBuiltinRole + } + if name == RoleNone || !roleNamePattern.MatchString(string(name)) { + return ErrInvalidRole + } + return nil +} + +func IsBuiltinRole(role Role) bool { + switch role { + case RoleAdmin, RoleOperator, RoleViewer, RoleAuditor: + return true + default: + return false + } +} + +func BuiltinRoleDefinition(role Role) *RoleDefinition { + desc := map[Role]string{ + RoleAdmin: "Full application administration", + RoleOperator: "Manage pools, accounts, and discovery workflows", + RoleViewer: "Read-only access to pools, accounts, and discovery", + RoleAuditor: "Read-only audit log access", + } + perms := GetStaticPermissions(role) + if perms == nil { + return nil + } + return &RoleDefinition{ + ID: string(role), + Name: role, + Description: desc[role], + IsBuiltin: true, + Permissions: perms, + } +} + +type MemoryRoleStore struct { + mu sync.RWMutex + roles map[Role]*RoleDefinition + users UserStore +} + +func NewMemoryRoleStore(users ...UserStore) *MemoryRoleStore { + s := &MemoryRoleStore{roles: make(map[Role]*RoleDefinition)} + if len(users) > 0 { + s.users = users[0] + } + for _, role := range ValidRoles() { + if def := BuiltinRoleDefinition(role); def != nil { + s.roles[role] = copyRoleDefinition(def) + } + } + return s +} + +func (s *MemoryRoleStore) ListPermissions(context.Context) ([]PermissionDefinition, error) { + return PermissionCatalog(), nil +} + +func (s *MemoryRoleStore) ListRoles(context.Context) ([]*RoleDefinition, error) { + s.mu.RLock() + defer s.mu.RUnlock() + roles := make([]*RoleDefinition, 0, len(s.roles)) + for _, role := range s.roles { + roles = append(roles, copyRoleDefinition(role)) + } + sort.Slice(roles, func(i, j int) bool { + if roles[i].IsBuiltin != roles[j].IsBuiltin { + return roles[i].IsBuiltin + } + return roles[i].Name < roles[j].Name + }) + return roles, nil +} + +func (s *MemoryRoleStore) GetRole(_ context.Context, name Role) (*RoleDefinition, error) { + name = NormalizeRoleName(string(name)) + s.mu.RLock() + defer s.mu.RUnlock() + role := s.roles[name] + if role == nil { + return nil, ErrRoleNotFound + } + return copyRoleDefinition(role), nil +} + +func (s *MemoryRoleStore) CreateRole(_ context.Context, role *RoleDefinition) error { + if role == nil { + return ErrInvalidRole + } + role.Name = NormalizeRoleName(string(role.Name)) + if err := ValidateCustomRoleName(role.Name); err != nil { + return err + } + if err := ValidatePermissions(role.Permissions); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.roles[role.Name]; exists { + return ErrRoleExists + } + now := time.Now().UTC() + role.ID = string(role.Name) + role.IsBuiltin = false + role.CreatedAt = now + role.UpdatedAt = now + s.roles[role.Name] = copyRoleDefinition(role) + return nil +} + +func (s *MemoryRoleStore) UpdateRole(_ context.Context, name Role, description string, permissions []Permission) (*RoleDefinition, error) { + name = NormalizeRoleName(string(name)) + if IsBuiltinRole(name) { + return nil, ErrBuiltinRole + } + if err := ValidatePermissions(permissions); err != nil { + return nil, err + } + + s.mu.Lock() + defer s.mu.Unlock() + role := s.roles[name] + if role == nil { + return nil, ErrRoleNotFound + } + role.Description = strings.TrimSpace(description) + role.Permissions = copyPermissions(permissions) + role.UpdatedAt = time.Now().UTC() + return copyRoleDefinition(role), nil +} + +func (s *MemoryRoleStore) DeleteRole(ctx context.Context, name Role) error { + name = NormalizeRoleName(string(name)) + if IsBuiltinRole(name) { + return ErrBuiltinRole + } + inUse, err := s.RoleAssignedToActiveUsers(ctx, name) + if err != nil { + return err + } + if inUse { + return ErrRoleInUse + } + + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.roles[name]; !exists { + return ErrRoleNotFound + } + delete(s.roles, name) + return nil +} + +func (s *MemoryRoleStore) RoleAssignedToActiveUsers(ctx context.Context, name Role) (bool, error) { + if s.users == nil { + return false, nil + } + users, err := s.users.List(ctx) + if err != nil { + return false, err + } + for _, user := range users { + if user.IsActive && user.Role == name { + return true, nil + } + } + return false, nil +} + +func copyRoleDefinition(role *RoleDefinition) *RoleDefinition { + if role == nil { + return nil + } + return &RoleDefinition{ + ID: role.ID, + Name: role.Name, + Description: role.Description, + IsBuiltin: role.IsBuiltin, + Permissions: copyPermissions(role.Permissions), + CreatedAt: role.CreatedAt, + UpdatedAt: role.UpdatedAt, + } +} diff --git a/migrations/0020_rbac_permission_catalog.sql b/migrations/0020_rbac_permission_catalog.sql new file mode 100644 index 0000000..955e0e5 --- /dev/null +++ b/migrations/0020_rbac_permission_catalog.sql @@ -0,0 +1,57 @@ +-- Expand RBAC permissions to cover all app surfaces used by runtime checks. + +INSERT INTO permissions (id, name, description, category) VALUES + ('pools:create', 'Create pools', 'Create address pools and planned allocations', 'IPAM'), + ('pools:read', 'Read pools', 'View pool details, blocks, utilization, and schema checks', 'IPAM'), + ('pools:update', 'Update pools', 'Edit pool metadata, hierarchy, and assignment', 'IPAM'), + ('pools:delete', 'Delete pools', 'Delete pools and planned allocations', 'IPAM'), + ('pools:list', 'List pools', 'Browse pool lists and tree views', 'IPAM'), + ('accounts:create', 'Create accounts', 'Create cloud account records', 'Accounts'), + ('accounts:read', 'Read accounts', 'View account details and account-linked resources', 'Accounts'), + ('accounts:update', 'Update accounts', 'Edit account metadata', 'Accounts'), + ('accounts:delete', 'Delete accounts', 'Delete account records', 'Accounts'), + ('accounts:list', 'List accounts', 'Browse account lists', 'Accounts'), + ('apikeys:create', 'Create API keys', 'Create API tokens within the caller permission envelope', 'Identity'), + ('apikeys:read', 'Read API keys', 'View API key metadata', 'Identity'), + ('apikeys:update', 'Update API keys', 'Reserved for future API key metadata updates', 'Identity'), + ('apikeys:delete', 'Delete API keys', 'Revoke API keys', 'Identity'), + ('apikeys:list', 'List API keys', 'Browse API key metadata', 'Identity'), + ('audit:read', 'Read audit logs', 'View audit event details', 'Audit'), + ('audit:list', 'List audit logs', 'Browse audit events', 'Audit'), + ('users:create', 'Create users', 'Create local user accounts', 'Identity'), + ('users:read', 'Read users', 'View user account details', 'Identity'), + ('users:update', 'Update users', 'Edit users, roles, password state, and active status', 'Identity'), + ('users:delete', 'Delete users', 'Deactivate user accounts', 'Identity'), + ('users:list', 'List users', 'Browse user accounts', 'Identity'), + ('discovery:create', 'Start discovery', 'Start discovery syncs and register agents', 'Discovery'), + ('discovery:read', 'Read discovery', 'View discovered resources, agents, drift, and recommendations', 'Discovery'), + ('discovery:update', 'Update discovery', 'Apply discovery results and reconcile drift', 'Discovery'), + ('discovery:delete', 'Delete discovery', 'Reserved for future discovery cleanup operations', 'Discovery'), + ('discovery:list', 'List discovery', 'Browse discovery resources, jobs, agents, drift, and recommendations', 'Discovery'), + ('settings:read', 'Read settings', 'View security, OIDC, update, and system configuration', 'Settings'), + ('settings:write', 'Write settings', 'Change security, OIDC, update, and system configuration', 'Settings') +ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + category = excluded.category; + +DELETE FROM role_permissions WHERE role_id IN (10, 20, 30, 40); + +INSERT OR IGNORE INTO role_permissions (role_id, permission_id) +SELECT 10, id FROM permissions; + +INSERT OR IGNORE INTO role_permissions (role_id, permission_id) +SELECT 20, id FROM permissions +WHERE id IN ( + 'pools:create', 'pools:read', 'pools:update', 'pools:delete', 'pools:list', + 'accounts:create', 'accounts:read', 'accounts:update', 'accounts:delete', 'accounts:list', + 'discovery:create', 'discovery:read', 'discovery:update', 'discovery:list' +); + +INSERT OR IGNORE INTO role_permissions (role_id, permission_id) +SELECT 30, id FROM permissions +WHERE id IN ('pools:read', 'pools:list', 'accounts:read', 'accounts:list', 'discovery:read', 'discovery:list'); + +INSERT OR IGNORE INTO role_permissions (role_id, permission_id) +SELECT 40, id FROM permissions +WHERE id IN ('audit:read', 'audit:list'); diff --git a/migrations/postgres/0019_rbac_permission_catalog.up.sql b/migrations/postgres/0019_rbac_permission_catalog.up.sql new file mode 100644 index 0000000..03283a9 --- /dev/null +++ b/migrations/postgres/0019_rbac_permission_catalog.up.sql @@ -0,0 +1,67 @@ +-- Expand RBAC permissions to cover all app surfaces used by runtime checks. + +INSERT INTO permissions (id, name, description, category) VALUES + ('pools:create', 'Create pools', 'Create address pools and planned allocations', 'IPAM'), + ('pools:read', 'Read pools', 'View pool details, blocks, utilization, and schema checks', 'IPAM'), + ('pools:update', 'Update pools', 'Edit pool metadata, hierarchy, and assignment', 'IPAM'), + ('pools:delete', 'Delete pools', 'Delete pools and planned allocations', 'IPAM'), + ('pools:list', 'List pools', 'Browse pool lists and tree views', 'IPAM'), + ('accounts:create', 'Create accounts', 'Create cloud account records', 'Accounts'), + ('accounts:read', 'Read accounts', 'View account details and account-linked resources', 'Accounts'), + ('accounts:update', 'Update accounts', 'Edit account metadata', 'Accounts'), + ('accounts:delete', 'Delete accounts', 'Delete account records', 'Accounts'), + ('accounts:list', 'List accounts', 'Browse account lists', 'Accounts'), + ('apikeys:create', 'Create API keys', 'Create API tokens within the caller permission envelope', 'Identity'), + ('apikeys:read', 'Read API keys', 'View API key metadata', 'Identity'), + ('apikeys:update', 'Update API keys', 'Reserved for future API key metadata updates', 'Identity'), + ('apikeys:delete', 'Delete API keys', 'Revoke API keys', 'Identity'), + ('apikeys:list', 'List API keys', 'Browse API key metadata', 'Identity'), + ('audit:read', 'Read audit logs', 'View audit event details', 'Audit'), + ('audit:list', 'List audit logs', 'Browse audit events', 'Audit'), + ('users:create', 'Create users', 'Create local user accounts', 'Identity'), + ('users:read', 'Read users', 'View user account details', 'Identity'), + ('users:update', 'Update users', 'Edit users, roles, password state, and active status', 'Identity'), + ('users:delete', 'Delete users', 'Deactivate user accounts', 'Identity'), + ('users:list', 'List users', 'Browse user accounts', 'Identity'), + ('discovery:create', 'Start discovery', 'Start discovery syncs and register agents', 'Discovery'), + ('discovery:read', 'Read discovery', 'View discovered resources, agents, drift, and recommendations', 'Discovery'), + ('discovery:update', 'Update discovery', 'Apply discovery results and reconcile drift', 'Discovery'), + ('discovery:delete', 'Delete discovery', 'Reserved for future discovery cleanup operations', 'Discovery'), + ('discovery:list', 'List discovery', 'Browse discovery resources, jobs, agents, drift, and recommendations', 'Discovery'), + ('settings:read', 'Read settings', 'View security, OIDC, update, and system configuration', 'Settings'), + ('settings:write', 'Write settings', 'Change security, OIDC, update, and system configuration', 'Settings') +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + category = EXCLUDED.category; + +DELETE FROM role_permissions +WHERE role_id IN ( + '00000000-0000-0000-0000-000000000010', + '00000000-0000-0000-0000-000000000020', + '00000000-0000-0000-0000-000000000030', + '00000000-0000-0000-0000-000000000040' +); + +INSERT INTO role_permissions (role_id, permission_id) +SELECT '00000000-0000-0000-0000-000000000010'::uuid, id FROM permissions +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT '00000000-0000-0000-0000-000000000020'::uuid, id FROM permissions +WHERE id IN ( + 'pools:create', 'pools:read', 'pools:update', 'pools:delete', 'pools:list', + 'accounts:create', 'accounts:read', 'accounts:update', 'accounts:delete', 'accounts:list', + 'discovery:create', 'discovery:read', 'discovery:update', 'discovery:list' +) +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT '00000000-0000-0000-0000-000000000030'::uuid, id FROM permissions +WHERE id IN ('pools:read', 'pools:list', 'accounts:read', 'accounts:list', 'discovery:read', 'discovery:list') +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT '00000000-0000-0000-0000-000000000040'::uuid, id FROM permissions +WHERE id IN ('audit:read', 'audit:list') +ON CONFLICT DO NOTHING; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index bb25dc2..149616a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -51,14 +51,18 @@ export default function App() { } /> } /> } /> - }> + }> } /> + + }> } /> } /> } /> - } /> } /> + }> + } /> + diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index a526512..b7be467 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -329,6 +329,7 @@ export interface LoginRequest { export interface LoginResponse { user: UserInfo expires_at: string + permissions?: string[] } export interface MeResponse { @@ -339,6 +340,7 @@ export interface MeResponse { key_name?: string auth_provider?: 'local' | 'oidc' session_expires_at?: string + permissions?: string[] } export interface CreateUserRequest { @@ -365,6 +367,39 @@ export interface UsersListResponse { users: UserInfo[] } +export interface PermissionInfo { + id: string + resource: string + action: string + name: string + description: string + category: string +} + +export interface RoleInfo { + id: string + name: string + description: string + is_builtin: boolean + permissions: Array<{ resource: string; action: string }> + created_at?: string + updated_at?: string +} + +export interface RolesListResponse { + roles: RoleInfo[] +} + +export interface PermissionsListResponse { + permissions: PermissionInfo[] +} + +export interface RoleSaveRequest { + name?: string + description: string + permissions: string[] +} + // --- Discovery types --- export type CloudResourceType = 'vpc' | 'subnet' | 'network_interface' | 'elastic_ip' diff --git a/ui/src/components/ProtectedRoute.tsx b/ui/src/components/ProtectedRoute.tsx index dcee90b..4233be8 100644 --- a/ui/src/components/ProtectedRoute.tsx +++ b/ui/src/components/ProtectedRoute.tsx @@ -3,10 +3,12 @@ import { useAuth } from '../hooks/useAuth' interface ProtectedRouteProps { requiredRole?: string + requiredPermission?: string + requiredAnyPermissions?: string[] } -export default function ProtectedRoute({ requiredRole }: ProtectedRouteProps) { - const { isAuthenticated, authEnabled, localAuthEnabled, needsSetup, authChecked, role } = useAuth() +export default function ProtectedRoute({ requiredRole, requiredPermission, requiredAnyPermissions }: ProtectedRouteProps) { + const { isAuthenticated, authEnabled, localAuthEnabled, needsSetup, authChecked, role, hasPermission } = useAuth() // Wait for the healthz check to finish before deciding if (!authChecked) { @@ -26,6 +28,12 @@ export default function ProtectedRoute({ requiredRole }: ProtectedRouteProps) { if (requiredRole && role !== requiredRole) { return } + if (requiredPermission && !hasPermission(requiredPermission)) { + return + } + if (requiredAnyPermissions?.length && !requiredAnyPermissions.some(hasPermission)) { + return + } return } diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 9058d5a..e2b7218 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -32,8 +32,11 @@ interface SidebarProps { } export default function Sidebar({ onImportExport }: SidebarProps) { - const { isAuthenticated, role } = useAuth() + const { isAuthenticated, hasPermission } = useAuth() const navigate = useNavigate() + const canSettings = hasPermission('settings:read') + const canUsers = hasPermission('users:list') + const canAudit = hasPermission('audit:read') || hasPermission('audit:list') return (