From d235aab3cfcaae4ad1efed41d495413f79e936d6 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 23 Jul 2026 21:55:13 +0100 Subject: [PATCH] Fix RBAC assignment migration and loading --- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/api-contracts.md | 15 + .../subsystems/organization-settings.md | 14 + .../subsystems/performance-and-scalability.md | 4 + .../v6/internal/subsystems/registry.json | 22 + .../internal/subsystems/security-privacy.md | 21 + .../internal/subsystems/storage-recovery.md | 7 + .../src/api/__tests__/rbac.test.ts | 14 + .../src/components/Settings/RolesPanel.tsx | 22 +- .../Settings/UserAssignmentsPanel.tsx | 22 +- .../__tests__/RBACPaywallPanels.test.tsx | 47 ++ .../components/Settings/useRolesPanelState.ts | 9 + .../Settings/useUserAssignmentsPanelState.ts | 10 + internal/api/access_admin_recovery.go | 25 +- internal/api/access_control_handlers.go | 130 ++- internal/api/access_tenant_provider.go | 17 +- internal/api/auth_principal_identity.go | 14 +- internal/api/auth_principal_identity_test.go | 66 ++ internal/api/rbac_admin_recovery_test.go | 24 + internal/api/rbac_handlers_test.go | 137 +++ internal/api/rbac_tenant_provider_test.go | 61 ++ internal/api/router.go | 31 +- pkg/auth/rbac.go | 16 + pkg/auth/sqlite_manager.go | 781 ++++++++++++++++-- pkg/auth/sqlite_manager_test.go | 360 ++++++++ pkg/server/server.go | 13 +- 26 files changed, 1770 insertions(+), 117 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 6a147e6b5..98585e611 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1775,6 +1775,11 @@ the intentionally sparse public response. ## Completion Obligations +The shared router's canonical RBAC initialization is an adjacent +security/API-contract boundary. It must remain a one-time startup and shutdown +dependency and must not alter agent registration, install tokens, profiles, +command transport, update policy, or fleet lifecycle authority. + 1. Update this contract when agent lifecycle ownership changes. Routes added under the shared `internal/api/` extension point that are clearly outside lifecycle ownership (for example `POST /api/ai/patrol/preflight`, the `patrol_preflight` snapshot field added to `/api/settings/ai`, the auto-trigger preflight dispatch on settings save, the startup-seed dispatch in `NewAISettingsHandler`, and the cached-preflight integration into the Patrol `tools` readiness check — all owned by ai-runtime) do not extend this subsystem's contract; they live in their owning subsystem. Canonical scoped Patrol resolution on `POST /api/ai/patrol/run` and structured `patrol_assess_finding` lifecycle outcomes are likewise adjacent AI/API contracts: they may consume agent-reported identities and evidence, but they do not change agent registration, install, token, profile, command transport, update, or fleet-lifecycle authority. 2. Keep shared API proof routing aligned whenever install, register, or profile payloads change. 3. Update runtime and settings tests in the same slice when lifecycle behavior changes. Shell installer lifecycle changes must keep `scripts/installtests/install_sh_test.go` covering explicit flags, persisted connection state, legacy running-process/service recovery, legacy single-dash v5 agent flag recovery, and secure token-file service argument rendering for update re-entry. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index c474f698c..22cd35af4 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -2973,6 +2973,12 @@ a new API state machine, queue contract, or verification-accounting field. ## Completion Obligations +RBAC transport changes must prove that settings, SSO role mapping, and +authorization share one default-organization manager; that healthy empty +collections serialize as arrays; and that provider, migration, or read errors +retain the stable `503 rbac_store_unavailable` envelope instead of becoming +successful empty payloads. + Manual Patrol execution is a canonical identity-bearing API contract. A scoped `POST /api/ai/patrol/run` request accepts only the declared resource and alert fields, rejects client-authored prompt context and trailing or unknown JSON, @@ -7260,6 +7266,15 @@ principal on governed API routes: when token metadata carries `ownerUserId`, RBAC and audit-facing auth resolution must use that bound user identity rather than a detached synthetic `token:` subject, while still preserving token scope and org enforcement. +RBAC administration routes now also have an explicit store-availability +contract. `internal/api/router.go` and +`internal/api/access_tenant_provider.go` must resolve the default organization, +SSO role mapping, authorization checks, and Settings transport through one +canonical manager. Healthy empty role-assignment collections serialize as +`[]`; provider initialization, legacy migration, or SQLite read failures +return `503 rbac_store_unavailable` and must not be normalized into a +successful empty response. Stable colon-delimited SSO principals remain valid +assignment targets, while slashes and path-traversal input remain rejected. The onboarding QR payload flow now also carries explicit token-bound auth semantics: when the frontend requests `/api/onboarding/qr` with a pairing token, the API client must send that token explicitly so the returned payload diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md index a32b067ec..5fc307187 100644 --- a/docs/release-control/v6/internal/subsystems/organization-settings.md +++ b/docs/release-control/v6/internal/subsystems/organization-settings.md @@ -131,6 +131,9 @@ create, review, and approve cross-organization shares. behavior; frontend-primitives owns primary, danger-outline, success-ghost, danger-ghost, ghost, accent, danger, focus, disabled, and settings-action chrome. +10. Keep role and assignment panels fail closed on store errors: clear stale + rows, disable mutations, retain a visible retry notice, and do not render + the normal empty state until a healthy response has been received. ## Current State @@ -357,3 +360,14 @@ self-digesting foreign revocation or activation cannot be attributed to or activate the victim tenant. Each tenant's atomic AI config owns its history, while provider/MSP inheritance, delegated acknowledgement, tenant-default UI, and multi-actor administration remain explicit residual product work. + +The RBAC settings transport consumes the security-owned canonical persistence +boundary rather than constructing a settings-local manager. +`internal/api/access_tenant_provider.go` isolates organization databases and +only the default organization may consume v5 file-based migration input. +Role and assignment panels must receive healthy empty collections as `[]`, but +store initialization, migration, and read failures stay visible as +`rbac_store_unavailable`; the settings layer must not reinterpret corruption +as an organization with no roles or users. Local identities and opaque stable +SSO principals are both valid assignment subjects, including principals with +an intentionally empty role set. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 0311f70d8..1571d5c00 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -837,6 +837,10 @@ shell clickable behind another overlay. `internal/api/router.go` may consult the already-wired runtime entitlement service, but it must not add broad persistence scans, metrics fan-out, or external network calls to protected settings or chat request paths. +7. Initialize and cache each organization RBAC manager once per router + lifecycle. Request handlers may reuse the cached manager but must not reopen + or remigrate SQLite on each role or assignment request, and shutdown must + close the owned manager set. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 0de34996d..663c7e08a 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -6382,6 +6382,9 @@ "pkg/audit/async_logger.go", "pkg/audit/audit.go", "pkg/audit/sqlite_logger.go", + "pkg/auth/rbac.go", + "pkg/auth/sqlite_manager.go", + "pkg/server/server.go", "pkg/server/telemetry_pulse_intelligence.go", "pkg/tlsutil/fingerprint.go", "scripts/telemetry_adoption_report.py", @@ -6591,6 +6594,25 @@ "internal/api/security_tokens_test.go", "internal/api/system_settings_telemetry_test.go" ] + }, + { + "id": "rbac-runtime-persistence", + "label": "RBAC runtime persistence and upgrade proof", + "match_prefixes": [], + "match_files": [ + "pkg/auth/rbac.go", + "pkg/auth/sqlite_manager.go", + "pkg/server/server.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "internal/api/rbac_handlers_test.go", + "internal/api/rbac_tenant_provider_test.go", + "internal/api/security_regression_test.go", + "pkg/auth/rbac_manager_test.go", + "pkg/auth/sqlite_manager_test.go" + ] } ], "match_files": null diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index a5d2aeaed..c20957a88 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -69,6 +69,9 @@ controls as normal product settings. 41. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` 42. `internal/api/agent_exec_token_binding.go` 43. `internal/logging/logging.go` +44. `pkg/auth/rbac.go` +45. `pkg/auth/sqlite_manager.go` +46. `pkg/server/server.go` ## Shared Boundaries @@ -481,6 +484,10 @@ the `white_label` branding entitlement. hosted entitlement refresh origin handling, or pinned-certificate transport behavior changes. Hosted commercial URL overrides must remain absolute HTTP(S) URLs, with plain HTTP limited to loopback development origins. +7a. Keep v5 RBAC file import transactional and fail closed, retain source files + on validation or persistence failure, keep SSO and settings on one + canonical manager, and prove that moving a legacy identity alias cannot + union conflicting grants. 8. Keep the Resource Privacy/Data Handling settings surface neutral and non-commercial: it may show resource policy posture, local-only counts, and redaction coverage, but it must not advertise trials, upgrades, paid plans, or monitoring limits, and it must remain route-backed rather than promoted in the normal Settings sidebar while it is informational only. 9. Keep operator-facing Resource Privacy/Data Handling posture aligned with runtime AI/context enforcement: `local-only` resource details must not be sent to external model prompts, and sensitive free-form alert, tool-result, investigation, handoff context, and any retained legacy managed-model compatibility text must use the shared resource-policy redaction helper before leaving the local trust boundary. Assistant handoffs may surface canonical policy handling guidance and current resource-state summaries for product-originated resources, but that guidance and state are model-only context and must not become disclosure authority. Product-originated Assistant handoff text must also be policy-cleaned before prompt injection, including operator briefings and finding/action context, so raw governed resource identity cannot leak through local-model briefing prose while non-local transport still receives the final provider-bound sanitizer. All provider-bound AI requests to non-local models must use the shared resource-policy sanitizer immediately before transport so later agentic turns cannot bypass the advertised handling posture. Native Pulse Assistant provider seams and native tool-adapter names in the @@ -1238,6 +1245,20 @@ That same fail-closed rule also applies to persisted OIDC refresh tokens in the session store: if session-store crypto is unavailable or a stored refresh token cannot be decrypted canonically, the runtime must drop that token instead of accepting or writing plaintext-at-rest refresh-token state. +RBAC persistence follows the same single-owner and fail-closed posture. +`internal/api/access_tenant_provider.go` owns per-organization manager +selection, while `pkg/auth/sqlite_manager.go` is the canonical v6 store and +`internal/api/router.go` binds its default-organization instance to the global +SSO and authorization boundary. `pkg/server/server.go` must not initialize a +parallel file-backed manager. Legacy `rbac_roles.json` and +`rbac_assignments.json` files are migration inputs only: the complete role and +assignment graph must validate and commit transactionally before either source +is archived. Corrupt JSON, missing role references, inheritance cycles, and +conflicts with newer v6 state must preserve the source files and make RBAC +unavailable with an explicit error rather than silently yielding empty data. +The canonical identity table must retain known local and stable SSO principals +when their role set is empty or a custom role is deleted, without retaining a +permission grant to the deleted role. That same rule also applies to hosted entitlement lease secrets in `internal/config/billing_state.go`: `billing.json` may not keep `entitlement_jwt` or `entitlement_refresh_token` as plaintext-at-rest billing diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 89ff344b4..5e70e7b14 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1581,6 +1581,13 @@ recovery scope, or a storage/recovery-owned secret source. ## Completion Obligations +Legacy RBAC JSON import is an adjacent security-owned migration, not recovery +inventory or restore evidence. When shared `internal/api/` construction +triggers that import, validation and SQLite writes must complete atomically, +corrupt or stale inputs must keep their source files, and non-default +organization stores must never consume the default organization's legacy +files. + 1. Update this contract when canonical storage or recovery entry points move. Routes added under the shared `internal/api/` extension point that are clearly outside storage/recovery ownership (for example `POST /api/ai/patrol/preflight`, the `patrol_preflight` snapshot field added to `/api/settings/ai`, the auto-trigger preflight dispatch on settings save, the startup-seed dispatch in `NewAISettingsHandler`, and the cached-preflight integration into the Patrol `tools` readiness check — all owned by ai-runtime) do not extend this subsystem's contract; they live in their owning subsystem. Exact scoped-run identity resolution and structured `present`, `resolved`, or `uncertain` Patrol finding assessments on that shared boundary are also adjacent AI/API state. Storage and recovery may supply evidence or observe the resulting finding, but an assessment is not backup success, restore verification, recovery freshness, storage health, or permission to mutate a storage system. Content-free Pulse Intelligence telemetry rollups under shared `internal/api/` are also adjacent-only. Storage and recovery may consume diff --git a/frontend-modern/src/api/__tests__/rbac.test.ts b/frontend-modern/src/api/__tests__/rbac.test.ts index 3dd168734..07c6a7499 100644 --- a/frontend-modern/src/api/__tests__/rbac.test.ts +++ b/frontend-modern/src/api/__tests__/rbac.test.ts @@ -118,6 +118,20 @@ describe('RBACAPI', () => { }), ); }); + + it('encodes stable SSO principals without changing their identity', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ success: true }); + + await RBACAPI.updateUserRoles('sso:oidc:okta:opaque_subject', ['viewer']); + + expect(apiFetchJSON).toHaveBeenCalledWith( + '/api/admin/users/sso%3Aoidc%3Aokta%3Aopaque_subject/roles', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ roleIds: ['viewer'] }), + }), + ); + }); }); describe('getUserPermissions', () => { diff --git a/frontend-modern/src/components/Settings/RolesPanel.tsx b/frontend-modern/src/components/Settings/RolesPanel.tsx index 1ac3982c0..2e3b63437 100644 --- a/frontend-modern/src/components/Settings/RolesPanel.tsx +++ b/frontend-modern/src/components/Settings/RolesPanel.tsx @@ -11,6 +11,8 @@ import Pencil from 'lucide-solid/icons/pencil'; import Trash2 from 'lucide-solid/icons/trash-2'; import BadgeCheck from 'lucide-solid/icons/badge-check'; import { PulseDataGrid } from '@/components/shared/PulseDataGrid'; +import { InlineNotice } from '@/components/shared/InlineNotice'; +import TriangleAlert from 'lucide-solid/icons/triangle-alert'; export const RolesPanel: Component = () => { const state = useRolesPanelState(); @@ -25,7 +27,7 @@ export const RolesPanel: Component = () => { size="settingsAction" class="w-full gap-2 sm:w-auto" onClick={state.openCreateRole} - disabled={!state.featureGate.rbacEnabled()} + disabled={!state.featureGate.rbacEnabled() || Boolean(state.loadError())} > New Role @@ -48,7 +50,23 @@ export const RolesPanel: Component = () => { - + + {(message) => ( + } + actionLabel="Retry" + actionOnClick={() => void state.loadRoles()} + > + {message()} + + )} + + + { const state = useUserAssignmentsPanelState(); @@ -25,7 +27,7 @@ export const UserAssignmentsPanel: Component = () => { placeholder="Search users..." value={state.searchQuery()} onChange={state.setSearchQuery} - disabled={!state.featureGate.rbacEnabled()} + disabled={!state.featureGate.rbacEnabled() || Boolean(state.loadError())} class="min-w-[15rem]" inputClass="min-h-10 sm:min-h-9 py-2.5" /> @@ -47,10 +49,27 @@ export const UserAssignmentsPanel: Component = () => { + + {(message) => ( + } + actionLabel="Retry" + actionOnClick={() => void state.loadData()} + > + {message()} + + )} + + @@ -73,6 +92,7 @@ export const UserAssignmentsPanel: Component = () => { when={ !state.loading() && state.featureGate.rbacEnabled() && + !state.loadError() && state.filteredAssignments().length > 0 } > diff --git a/frontend-modern/src/components/Settings/__tests__/RBACPaywallPanels.test.tsx b/frontend-modern/src/components/Settings/__tests__/RBACPaywallPanels.test.tsx index a24eec576..c3c6e8678 100644 --- a/frontend-modern/src/components/Settings/__tests__/RBACPaywallPanels.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/RBACPaywallPanels.test.tsx @@ -161,6 +161,29 @@ describe('RBAC paywall settings panels', () => { expect(screen.getByRole('button', { name: 'New Role' })).not.toBeDisabled(); }); + it('fails closed with a persistent retry notice when roles cannot be read', async () => { + getRolesMock.mockRejectedValueOnce( + Object.assign(new Error('RBAC data could not be loaded'), { + status: 503, + code: 'rbac_store_unavailable', + }), + ); + + render(() => ); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Failed to load roles'); + }); + expect(screen.getByRole('button', { name: 'New Role' })).toBeDisabled(); + expect(notificationErrorMock).toHaveBeenCalledWith('Failed to load roles'); + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await waitFor(() => { + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + it('shows the user assignments paywall for free entitlements and does not load users', async () => { hasFeatureMock.mockImplementation((feature: string) => feature !== 'rbac'); @@ -192,6 +215,30 @@ describe('RBAC paywall settings panels', () => { expect(screen.getByPlaceholderText('Search users...')).not.toBeDisabled(); }); + it('does not render stale user data when assignment storage is unavailable', async () => { + getUsersMock.mockRejectedValueOnce( + Object.assign(new Error('RBAC data could not be loaded'), { + status: 503, + code: 'rbac_store_unavailable', + }), + ); + + render(() => ); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Failed to load user assignments'); + }); + expect(screen.queryByText('alice')).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText('Search users...')).toBeDisabled(); + expect(notificationErrorMock).toHaveBeenCalledWith('Failed to load user assignments'); + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await waitFor(() => { + expect(screen.getByText('alice')).toBeInTheDocument(); + }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + it('keeps user assignment upgrade actions quiet when self-hosted upgrade prompts are hidden', async () => { hasFeatureMock.mockImplementation((feature: string) => feature !== 'rbac'); presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true); diff --git a/frontend-modern/src/components/Settings/useRolesPanelState.ts b/frontend-modern/src/components/Settings/useRolesPanelState.ts index 964c2ac98..3e92fac5d 100644 --- a/frontend-modern/src/components/Settings/useRolesPanelState.ts +++ b/frontend-modern/src/components/Settings/useRolesPanelState.ts @@ -22,6 +22,7 @@ export function useRolesPanelState() { const [formName, setFormName] = createSignal(''); const [formDescription, setFormDescription] = createSignal(''); const [formPermissions, setFormPermissions] = createSignal([]); + const [loadError, setLoadError] = createSignal(null); const featureGate = useRBACFeatureGateState({ kind: 'roles', @@ -32,19 +33,24 @@ export function useRolesPanelState() { const loadRoles = async () => { if (!featureGate.rbacEnabled()) { setRoles([]); + setLoadError(null); setLoading(false); return; } setLoading(true); + setLoadError(null); try { const data = await RBACAPI.getRoles(); setRoles(data || []); } catch (err) { if (err instanceof Error && /feature not included in license/i.test(err.message)) { setRoles([]); + setLoadError(null); return; } + setRoles([]); + setLoadError(getRolesLoadErrorMessage()); logger.error('Failed to load roles', err); notificationStore.error(getRolesLoadErrorMessage()); } finally { @@ -59,6 +65,7 @@ export function useRolesPanelState() { } if (!featureGate.rbacEnabled()) { setRoles([]); + setLoadError(null); setLoading(false); return; } @@ -159,6 +166,8 @@ export function useRolesPanelState() { handleDeleteRole, handleSaveRole, loading, + loadError, + loadRoles, openCreateRole, openEditRole, removePermission, diff --git a/frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts b/frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts index a2ef5d287..839fa55f7 100644 --- a/frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts +++ b/frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts @@ -20,6 +20,7 @@ export function useUserAssignmentsPanelState() { const [userPermissions, setUserPermissions] = createSignal([]); const [loadingPermissions, setLoadingPermissions] = createSignal(false); const [formRoleIds, setFormRoleIds] = createSignal([]); + const [loadError, setLoadError] = createSignal(null); const featureGate = useRBACFeatureGateState({ kind: 'user-assignments', @@ -31,11 +32,13 @@ export function useUserAssignmentsPanelState() { if (!featureGate.rbacEnabled()) { setAssignments([]); setRoles([]); + setLoadError(null); setLoading(false); return; } setLoading(true); + setLoadError(null); try { const [usersData, rolesData] = await Promise.all([RBACAPI.getUsers(), RBACAPI.getRoles()]); setAssignments(usersData || []); @@ -44,8 +47,12 @@ export function useUserAssignmentsPanelState() { if (err instanceof Error && /feature not included in license/i.test(err.message)) { setAssignments([]); setRoles([]); + setLoadError(null); return; } + setAssignments([]); + setRoles([]); + setLoadError(getUserAssignmentsLoadErrorMessage()); logger.error('Failed to load user assignments', err); notificationStore.error(getUserAssignmentsLoadErrorMessage()); } finally { @@ -61,6 +68,7 @@ export function useUserAssignmentsPanelState() { if (!featureGate.rbacEnabled()) { setAssignments([]); setRoles([]); + setLoadError(null); setLoading(false); return; } @@ -143,6 +151,8 @@ export function useUserAssignmentsPanelState() { getRoleName, handleSaveAssignments, loading, + loadData, + loadError, loadingPermissions, openManageAccess, roles, diff --git a/internal/api/access_admin_recovery.go b/internal/api/access_admin_recovery.go index f3dc900bf..894ffd882 100644 --- a/internal/api/access_admin_recovery.go +++ b/internal/api/access_admin_recovery.go @@ -34,7 +34,17 @@ func VerifyRBACIntegrity(provider *TenantRBACProvider, orgID string) RBACIntegri result.DBAccessible = true // If manager access succeeds, schema queries are available through manager methods. - roles := manager.GetRoles() + var roles []auth.Role + if errorAware, ok := manager.(auth.ErrorAwareManager); ok { + roles, err = errorAware.GetRolesWithError() + if err != nil { + result.Error = fmt.Sprintf("failed to read roles: %v", err) + RecordRBACIntegrityCheck("unhealthy") + return result + } + } else { + roles = manager.GetRoles() + } result.TablesPresent = true result.TotalRoles = len(roles) @@ -44,7 +54,18 @@ func VerifyRBACIntegrity(provider *TenantRBACProvider, orgID string) RBACIntegri } } - assignments := manager.GetUserAssignments() + var assignments []auth.UserRoleAssignment + if errorAware, ok := manager.(auth.ErrorAwareManager); ok { + assignments, err = errorAware.GetUserAssignmentsWithError() + if err != nil { + result.Error = fmt.Sprintf("failed to read assignments: %v", err) + result.TablesPresent = false + RecordRBACIntegrityCheck("unhealthy") + return result + } + } else { + assignments = manager.GetUserAssignments() + } result.TotalAssignments = len(assignments) // Healthy if db accessible, tables present, and at least 4 built-in roles exist. diff --git a/internal/api/access_control_handlers.go b/internal/api/access_control_handlers.go index cc7591033..43e9ea7ac 100644 --- a/internal/api/access_control_handlers.go +++ b/internal/api/access_control_handlers.go @@ -10,13 +10,16 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/pkg/auth" + "github.com/rs/zerolog/log" ) // validRoleID matches alphanumeric IDs with hyphens and underscores (1-64 chars) var validRoleID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) -// validUsername matches reasonable username formats (1-128 chars, alphanumeric, plus common chars) -var validUsername = regexp.MustCompile(`^[a-zA-Z0-9._@+-]{1,128}$`) +// validUsername accepts local identities plus the colon-delimited opaque SSO +// principals emitted by stableSSOPrincipal. Slashes and path traversal +// characters remain excluded. +var validUsername = regexp.MustCompile(`^[a-zA-Z0-9._@+:-]{1,256}$`) // RBACHandlers provides HTTP handlers for RBAC management. type RBACHandlers struct { @@ -39,34 +42,49 @@ func NewRBACHandlers(cfg *config.Config, rbacProvider ...*TenantRBACProvider) *R // getManager returns the RBAC Manager for the org in the request context. // Falls back to global manager if no provider is set (backward compat). -func (h *RBACHandlers) getManager(ctx context.Context) auth.Manager { +func (h *RBACHandlers) getManager(ctx context.Context) (auth.Manager, error) { if h.rbacProvider != nil { orgID := GetOrgID(ctx) manager, err := h.rbacProvider.GetManager(orgID) if err != nil { - return nil + return nil, err } - return manager + return manager, nil } - return auth.GetManager() + return auth.GetManager(), nil } // getExtendedManager returns the ExtendedManager for the org in the request context. -func (h *RBACHandlers) getExtendedManager(ctx context.Context) auth.ExtendedManager { +func (h *RBACHandlers) getExtendedManager(ctx context.Context) (auth.ExtendedManager, error) { if h.rbacProvider != nil { orgID := GetOrgID(ctx) manager, err := h.rbacProvider.GetManager(orgID) if err != nil { - return nil + return nil, err } - return manager + return manager, nil } - return auth.GetExtendedManager() + return auth.GetExtendedManager(), nil +} + +func writeRBACStoreError(w http.ResponseWriter, err error) { + log.Error().Err(err).Msg("RBAC store is unavailable") + writeErrorResponse( + w, + http.StatusServiceUnavailable, + "rbac_store_unavailable", + "RBAC data could not be loaded; check the server logs before retrying", + nil, + ) } // HandleRoles handles list, create, update, and delete actions for roles. func (h *RBACHandlers) HandleRoles(w http.ResponseWriter, r *http.Request) { - manager := h.getManager(r.Context()) + manager, err := h.getManager(r.Context()) + if err != nil { + writeRBACStoreError(w, err) + return + } if manager == nil { writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil) return @@ -86,7 +104,19 @@ func (h *RBACHandlers) HandleRoles(w http.ResponseWriter, r *http.Request) { case http.MethodGet: if roleID == "" { // List all roles - roles := manager.GetRoles() + var roles []auth.Role + if errorAware, ok := manager.(auth.ErrorAwareManager); ok { + roles, err = errorAware.GetRolesWithError() + if err != nil { + writeRBACStoreError(w, err) + return + } + } else { + roles = manager.GetRoles() + } + if roles == nil { + roles = []auth.Role{} + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(roles) } else { @@ -191,20 +221,40 @@ func (h *RBACHandlers) HandleGetUsers(w http.ResponseWriter, r *http.Request) { return } - manager := h.getManager(r.Context()) + manager, err := h.getManager(r.Context()) + if err != nil { + writeRBACStoreError(w, err) + return + } if manager == nil { writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil) return } - assignments := manager.GetUserAssignments() + var assignments []auth.UserRoleAssignment + if errorAware, ok := manager.(auth.ErrorAwareManager); ok { + assignments, err = errorAware.GetUserAssignmentsWithError() + if err != nil { + writeRBACStoreError(w, err) + return + } + } else { + assignments = manager.GetUserAssignments() + } + if assignments == nil { + assignments = []auth.UserRoleAssignment{} + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(assignments) } // HandleUserRoleActions handles assigning/updating roles for a user. func (h *RBACHandlers) HandleUserRoleActions(w http.ResponseWriter, r *http.Request) { - manager := h.getManager(r.Context()) + manager, err := h.getManager(r.Context()) + if err != nil { + writeRBACStoreError(w, err) + return + } if manager == nil { writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil) return @@ -269,14 +319,36 @@ func (h *RBACHandlers) HandleUserRoleActions(w http.ResponseWriter, r *http.Requ case http.MethodGet: // Get effective permissions if len(parts) > 1 && parts[1] == "permissions" { - perms := manager.GetUserPermissions(username) + var perms []auth.Permission + if errorAware, ok := manager.(auth.ErrorAwareManager); ok { + perms, err = errorAware.GetUserPermissionsWithError(username) + if err != nil { + writeRBACStoreError(w, err) + return + } + } else { + perms = manager.GetUserPermissions(username) + } + if perms == nil { + perms = []auth.Permission{} + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(perms) return } // Get specific assignment - assignment, ok := manager.GetUserAssignment(username) + var assignment auth.UserRoleAssignment + var ok bool + if errorAware, supportsErrors := manager.(auth.ErrorAwareManager); supportsErrors { + assignment, ok, err = errorAware.GetUserAssignmentWithError(username) + if err != nil { + writeRBACStoreError(w, err) + return + } + } else { + assignment, ok = manager.GetUserAssignment(username) + } if !ok { writeErrorResponse(w, http.StatusNotFound, "not_found", "User assignment not found", nil) return @@ -296,7 +368,11 @@ func (h *RBACHandlers) HandleRBACChangelog(w http.ResponseWriter, r *http.Reques return } - em := h.getExtendedManager(r.Context()) + em, err := h.getExtendedManager(r.Context()) + if err != nil { + writeRBACStoreError(w, err) + return + } if em == nil { writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC changelog is not available (requires Pro)", nil) return @@ -347,7 +423,11 @@ func (h *RBACHandlers) HandleRoleEffective(w http.ResponseWriter, r *http.Reques return } - em := h.getExtendedManager(r.Context()) + em, err := h.getExtendedManager(r.Context()) + if err != nil { + writeRBACStoreError(w, err) + return + } if em == nil { writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "Role inheritance is not available (requires Pro)", nil) return @@ -389,7 +469,11 @@ func (h *RBACHandlers) HandleUserEffectivePermissions(w http.ResponseWriter, r * return } - manager := h.getManager(r.Context()) + manager, err := h.getManager(r.Context()) + if err != nil { + writeRBACStoreError(w, err) + return + } if manager == nil { writeErrorResponse(w, http.StatusNotImplemented, "rbac_unavailable", "RBAC management is not available", nil) return @@ -406,7 +490,11 @@ func (h *RBACHandlers) HandleUserEffectivePermissions(w http.ResponseWriter, r * } // Check if we have extended manager for inheritance - em := h.getExtendedManager(r.Context()) + em, err := h.getExtendedManager(r.Context()) + if err != nil { + writeRBACStoreError(w, err) + return + } if em != nil { roles := em.GetRolesWithInheritance(username) diff --git a/internal/api/access_tenant_provider.go b/internal/api/access_tenant_provider.go index 01124728a..6720dcb37 100644 --- a/internal/api/access_tenant_provider.go +++ b/internal/api/access_tenant_provider.go @@ -57,7 +57,8 @@ func (p *TenantRBACProvider) GetManager(orgID string) (auth.ExtendedManager, err } manager, err = auth.NewSQLiteManager(auth.SQLiteManagerConfig{ - DataDir: dataDir, + DataDir: dataDir, + MigrateFromFiles: orgID == "default", }) if err != nil { return nil, fmt.Errorf("failed to initialize RBAC manager for org %s: %w", orgID, err) @@ -119,6 +120,20 @@ func (p *TenantRBACProvider) ManagerCount() int { return len(p.managers) } +func (p *TenantRBACProvider) ownsManager(manager auth.Manager) bool { + if manager == nil { + return false + } + p.mu.RLock() + defer p.mu.RUnlock() + for _, candidate := range p.managers { + if manager == candidate { + return true + } + } + return false +} + func (p *TenantRBACProvider) resolveDataDir(orgID string) (string, error) { baseDataDir, err := securityutil.NormalizeStorageDir(p.baseDataDir) if err != nil { diff --git a/internal/api/auth_principal_identity.go b/internal/api/auth_principal_identity.go index 2bbfe00be..1eaf21e2b 100644 --- a/internal/api/auth_principal_identity.go +++ b/internal/api/auth_principal_identity.go @@ -62,9 +62,8 @@ func applySSORoleAssignments(manager internalauth.Manager, principal string, leg return manager.UpdateUserRoles(principal, mappedRoles) } - if assignment, ok := manager.GetUserAssignment(principal); ok && len(assignment.RoleIDs) > 0 { - return nil - } + principalAssignment, principalExists := manager.GetUserAssignment(principal) + principalHasRoles := principalExists && len(principalAssignment.RoleIDs) > 0 for _, candidate := range legacyCandidates { candidate = strings.TrimSpace(candidate) @@ -75,9 +74,18 @@ func applySSORoleAssignments(manager internalauth.Manager, principal string, leg if !ok || len(assignment.RoleIDs) == 0 { continue } + if migrator, ok := manager.(internalauth.AssignmentMigrator); ok { + return migrator.MigrateUserAssignment(candidate, principal) + } + if principalHasRoles { + return nil + } return manager.UpdateUserRoles(principal, assignment.RoleIDs) } + if principalHasRoles { + return nil + } if ensureListed { return manager.UpdateUserRoles(principal, nil) } diff --git a/internal/api/auth_principal_identity_test.go b/internal/api/auth_principal_identity_test.go index 0dc0fc5c8..097e58ab9 100644 --- a/internal/api/auth_principal_identity_test.go +++ b/internal/api/auth_principal_identity_test.go @@ -108,3 +108,69 @@ func TestApplySSORoleAssignmentsUsesAuthoritativeMapping(t *testing.T) { t.Fatalf("updated roles = %q, want viewer", got) } } + +func TestApplySSORoleAssignmentsMovesLegacySQLiteAlias(t *testing.T) { + manager, err := auth.NewSQLiteManager(auth.SQLiteManagerConfig{DataDir: t.TempDir()}) + if err != nil { + t.Fatalf("NewSQLiteManager: %v", err) + } + defer manager.Close() + if err := manager.UpdateUserRoles("alice@example.com", []string{auth.RoleViewer}); err != nil { + t.Fatalf("seed legacy assignment: %v", err) + } + principal := "sso:oidc:okta:stable" + + if err := applySSORoleAssignments( + manager, + principal, + []string{"alice@example.com"}, + nil, + false, + true, + ); err != nil { + t.Fatalf("applySSORoleAssignments: %v", err) + } + + assignment, ok := manager.GetUserAssignment(principal) + if !ok || len(assignment.RoleIDs) != 1 || assignment.RoleIDs[0] != auth.RoleViewer { + t.Fatalf("canonical assignment = %#v, exists=%v", assignment, ok) + } + if stale, ok := manager.GetUserAssignment("alice@example.com"); ok { + t.Fatalf("legacy alias retained a reusable grant: %#v", stale) + } +} + +func TestApplySSORoleAssignmentsRejectsConflictingLegacySQLiteAlias(t *testing.T) { + manager, err := auth.NewSQLiteManager(auth.SQLiteManagerConfig{DataDir: t.TempDir()}) + if err != nil { + t.Fatalf("NewSQLiteManager: %v", err) + } + defer manager.Close() + if err := manager.UpdateUserRoles("alice@example.com", []string{auth.RoleAdmin}); err != nil { + t.Fatalf("seed legacy assignment: %v", err) + } + principal := "sso:oidc:okta:stable" + if err := manager.UpdateUserRoles(principal, []string{auth.RoleViewer}); err != nil { + t.Fatalf("seed canonical assignment: %v", err) + } + + err = applySSORoleAssignments( + manager, + principal, + []string{"alice@example.com"}, + nil, + false, + true, + ) + if err == nil || !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("error = %v, want a conflicting-grant failure", err) + } + canonical, ok := manager.GetUserAssignment(principal) + if !ok || len(canonical.RoleIDs) != 1 || canonical.RoleIDs[0] != auth.RoleViewer { + t.Fatalf("canonical assignment changed: %#v, exists=%v", canonical, ok) + } + legacy, ok := manager.GetUserAssignment("alice@example.com") + if !ok || len(legacy.RoleIDs) != 1 || legacy.RoleIDs[0] != auth.RoleAdmin { + t.Fatalf("legacy assignment changed before conflict resolution: %#v, exists=%v", legacy, ok) + } +} diff --git a/internal/api/rbac_admin_recovery_test.go b/internal/api/rbac_admin_recovery_test.go index 604ea761e..1ff5bd2e2 100644 --- a/internal/api/rbac_admin_recovery_test.go +++ b/internal/api/rbac_admin_recovery_test.go @@ -102,6 +102,30 @@ func TestRBACIntegrity_DefaultOrg(t *testing.T) { } } +func TestRBACIntegrity_ReportsSQLiteReadFailure(t *testing.T) { + baseDir := t.TempDir() + provider := NewTenantRBACProvider(baseDir) + manager, err := provider.GetManager("default") + if err != nil { + t.Fatalf("GetManager(default): %v", err) + } + sqliteManager, ok := manager.(*auth.SQLiteManager) + if !ok { + t.Fatalf("manager type = %T, want *auth.SQLiteManager", manager) + } + if err := sqliteManager.Close(); err != nil { + t.Fatalf("close manager: %v", err) + } + + result := VerifyRBACIntegrity(provider, "default") + if result.Healthy { + t.Fatalf("closed SQLite store reported healthy: %+v", result) + } + if result.Error == "" || !strings.Contains(result.Error, "failed to read roles") { + t.Fatalf("read failure was not reported explicitly: %+v", result) + } +} + func TestResetAdminRole_RestoresAccess(t *testing.T) { baseDir := t.TempDir() orgID := "ops" diff --git a/internal/api/rbac_handlers_test.go b/internal/api/rbac_handlers_test.go index b53931f70..dd7c6532d 100644 --- a/internal/api/rbac_handlers_test.go +++ b/internal/api/rbac_handlers_test.go @@ -392,3 +392,140 @@ func TestMultiTenantRBACRoleUpdateChangesPermissions(t *testing.T) { t.Fatalf("permissions leaked into %s: %+v", orgB, permsInOrgB) } } + +func TestRBACHandlersReturnEmptyAssignmentsAsArray(t *testing.T) { + baseDir := t.TempDir() + provider := NewTenantRBACProvider(baseDir) + t.Cleanup(func() { _ = provider.Close() }) + handler := NewRBACHandlers(&config.Config{DataPath: baseDir}, provider) + + request := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) + response := httptest.NewRecorder() + handler.HandleGetUsers(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", response.Code, response.Body.String()) + } + if response.Body.String() != "[]\n" { + t.Fatalf("body = %q, want an empty JSON array", response.Body.String()) + } +} + +func TestRBACHandlersReportLegacyMigrationFailure(t *testing.T) { + baseDir := t.TempDir() + if err := os.WriteFile(filepath.Join(baseDir, "rbac_roles.json"), []byte(`[{"id":`), 0600); err != nil { + t.Fatalf("write corrupt legacy roles: %v", err) + } + + provider := NewTenantRBACProvider(baseDir) + t.Cleanup(func() { _ = provider.Close() }) + handler := NewRBACHandlers(&config.Config{DataPath: baseDir}, provider) + request := httptest.NewRequest(http.MethodGet, "/api/admin/roles", nil) + response := httptest.NewRecorder() + handler.HandleRoles(response, request) + + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503; body=%s", response.Code, response.Body.String()) + } + if !bytes.Contains(response.Body.Bytes(), []byte(`"code":"rbac_store_unavailable"`)) { + t.Fatalf("missing explicit RBAC store error: %s", response.Body.String()) + } + if _, err := os.Stat(filepath.Join(baseDir, "rbac_roles.json")); err != nil { + t.Fatalf("corrupt source was not preserved: %v", err) + } +} + +func TestRBACHandlersReportSQLiteReadFailure(t *testing.T) { + baseDir := t.TempDir() + provider := NewTenantRBACProvider(baseDir) + manager, err := provider.GetManager("default") + if err != nil { + t.Fatalf("GetManager(default): %v", err) + } + sqliteManager, ok := manager.(*auth.SQLiteManager) + if !ok { + t.Fatalf("manager type = %T, want *auth.SQLiteManager", manager) + } + if err := sqliteManager.Close(); err != nil { + t.Fatalf("close manager: %v", err) + } + + handler := NewRBACHandlers(&config.Config{DataPath: baseDir}, provider) + request := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) + response := httptest.NewRecorder() + handler.HandleGetUsers(response, request) + + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503; body=%s", response.Code, response.Body.String()) + } + if !bytes.Contains(response.Body.Bytes(), []byte(`"code":"rbac_store_unavailable"`)) { + t.Fatalf("missing explicit RBAC store error: %s", response.Body.String()) + } +} + +func TestRBACHandlersAllowStableSSOPrincipalAssignments(t *testing.T) { + baseDir := t.TempDir() + provider := NewTenantRBACProvider(baseDir) + t.Cleanup(func() { _ = provider.Close() }) + handler := NewRBACHandlers(&config.Config{DataPath: baseDir}, provider) + principal := "sso:oidc:okta:opaque-subject" + + body := bytes.NewBufferString(`{"roleIds":["viewer"]}`) + request := httptest.NewRequest( + http.MethodPut, + "/api/admin/users/"+principal+"/roles", + body, + ) + response := httptest.NewRecorder() + handler.HandleUserRoleActions(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body=%s", response.Code, response.Body.String()) + } + manager, err := provider.GetManager("default") + if err != nil { + t.Fatalf("GetManager(default): %v", err) + } + assignment, ok := manager.GetUserAssignment(principal) + if !ok || len(assignment.RoleIDs) != 1 || assignment.RoleIDs[0] != auth.RoleViewer { + t.Fatalf("stable SSO assignment not saved: %#v, exists=%v", assignment, ok) + } +} + +func TestNewRouterSharesCanonicalRBACManagerWithSSOAndSettings(t *testing.T) { + cfg := newTestConfigWithTokens(t) + legacy, err := auth.NewFileManager(cfg.DataPath) + if err != nil { + t.Fatalf("NewFileManager: %v", err) + } + if err := legacy.SaveRole(auth.Role{ + ID: "legacy-operator", + Name: "Legacy operator", + Permissions: []auth.Permission{{Action: "read", Resource: "nodes"}}, + }); err != nil { + t.Fatalf("save legacy role: %v", err) + } + if err := legacy.UpdateUserRoles("legacy-user", []string{"legacy-operator"}); err != nil { + t.Fatalf("save legacy assignment: %v", err) + } + + originalManager := auth.GetManager() + router := NewRouter(cfg, nil, nil, nil, nil, "test") + t.Cleanup(func() { + router.shutdownBackgroundWorkers() + router.ShutdownRBAC() + auth.SetManager(originalManager) + }) + + settingsManager, err := router.rbacProvider.GetManager("default") + if err != nil { + t.Fatalf("GetManager(default): %v", err) + } + if auth.GetManager() != settingsManager { + t.Fatal("SSO/authorization global manager differs from the settings manager") + } + assignment, ok := auth.GetManager().GetUserAssignment("legacy-user") + if !ok || len(assignment.RoleIDs) != 1 || assignment.RoleIDs[0] != "legacy-operator" { + t.Fatalf("upgraded legacy assignment unavailable to canonical manager: %#v, exists=%v", assignment, ok) + } +} diff --git a/internal/api/rbac_tenant_provider_test.go b/internal/api/rbac_tenant_provider_test.go index 4f08cc07b..2bd4d1576 100644 --- a/internal/api/rbac_tenant_provider_test.go +++ b/internal/api/rbac_tenant_provider_test.go @@ -31,6 +31,38 @@ func TestTenantRBACProvider_DefaultOrg(t *testing.T) { } } +func TestTenantRBACProvider_DefaultOrgMigratesLegacyState(t *testing.T) { + baseDir := t.TempDir() + legacy, err := auth.NewFileManager(baseDir) + if err != nil { + t.Fatalf("NewFileManager: %v", err) + } + if err := legacy.SaveRole(auth.Role{ + ID: "legacy-readonly", + Name: "Legacy read only", + Permissions: []auth.Permission{{Action: "read", Resource: "*"}}, + }); err != nil { + t.Fatalf("save legacy role: %v", err) + } + if err := legacy.UpdateUserRoles("legacy-local", []string{"legacy-readonly"}); err != nil { + t.Fatalf("save legacy assignment: %v", err) + } + + provider := NewTenantRBACProvider(baseDir) + t.Cleanup(func() { _ = provider.Close() }) + manager, err := provider.GetManager("default") + if err != nil { + t.Fatalf("GetManager(default): %v", err) + } + if _, ok := manager.GetRole("legacy-readonly"); !ok { + t.Fatal("default organization did not migrate the legacy role") + } + assignment, ok := manager.GetUserAssignment("legacy-local") + if !ok || len(assignment.RoleIDs) != 1 || assignment.RoleIDs[0] != "legacy-readonly" { + t.Fatalf("default organization did not migrate assignment: %#v, exists=%v", assignment, ok) + } +} + func TestTenantRBACProvider_NonDefaultOrg(t *testing.T) { baseDir := t.TempDir() orgID := "acme" @@ -56,6 +88,35 @@ func TestTenantRBACProvider_NonDefaultOrg(t *testing.T) { } } +func TestTenantRBACProvider_NonDefaultOrgDoesNotConsumeLegacyState(t *testing.T) { + baseDir := t.TempDir() + legacy, err := auth.NewFileManager(baseDir) + if err != nil { + t.Fatalf("NewFileManager: %v", err) + } + if err := legacy.SaveRole(auth.Role{ + ID: "default-only", + Name: "Default only", + Permissions: []auth.Permission{{Action: "read", Resource: "*"}}, + }); err != nil { + t.Fatalf("save legacy role: %v", err) + } + + createOrgDir(t, baseDir, "other-org") + provider := NewTenantRBACProvider(baseDir) + t.Cleanup(func() { _ = provider.Close() }) + manager, err := provider.GetManager("other-org") + if err != nil { + t.Fatalf("GetManager(other-org): %v", err) + } + if _, ok := manager.GetRole("default-only"); ok { + t.Fatal("legacy default-organization role leaked into another organization") + } + if _, err := os.Stat(filepath.Join(baseDir, "rbac_roles.json")); err != nil { + t.Fatalf("non-default organization consumed legacy source: %v", err) + } +} + func TestTenantRBACProvider_Isolation(t *testing.T) { baseDir := t.TempDir() orgA := "org-a" diff --git a/internal/api/router.go b/internal/api/router.go index 1e1eff936..a998f342a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -318,6 +318,19 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, mtMonitor *monit auth.SetAdminUser(cfg.AuthUser) } + // The tenant provider is the sole owner of v6 RBAC persistence. Initialize + // the default manager before SSO services and routes so settings, SSO role + // mapping, and authorization all observe the same SQLite store. + r.rbacProvider = NewTenantRBACProvider(r.config.DataPath) + defaultRBACManager, err := r.rbacProvider.GetManager("default") + if err != nil { + auth.SetManager(nil) + log.Error().Err(err).Msg("Failed to initialize the canonical RBAC store") + } else { + auth.SetManager(defaultRBACManager) + log.Info().Msg("Canonical RBAC store initialized") + } + // Initialize SSO service managers r.oidcManager = NewOIDCServiceManager() r.samlManager = NewSAMLServiceManager("") @@ -595,8 +608,7 @@ func (r *Router) setupRoutes() { InstanceFingerprint: state.InstanceFingerprint, }, true }) - rbacProvider := NewTenantRBACProvider(r.config.DataPath) - r.rbacProvider = rbacProvider + rbacProvider := r.rbacProvider orgHandlers := NewOrgHandlers(r.multiTenant, r.mtMonitor, rbacProvider) orgHandlers.SetHostedMode(r.hostedMode) orgHandlers.SetOnDelete(func(ctx context.Context, orgID string) error { @@ -2787,6 +2799,21 @@ func (r *Router) ShutdownAIIntelligence() { log.Info().Msg("AI Intelligence: Graceful shutdown complete") } +// ShutdownRBAC closes every organization RBAC store owned by this router and +// clears the global manager only when it points at the same provider. +func (r *Router) ShutdownRBAC() { + if r.rbacProvider == nil { + return + } + ownsGlobal := r.rbacProvider.ownsManager(auth.GetManager()) + if err := r.rbacProvider.Close(); err != nil { + log.Error().Err(err).Msg("Failed to close RBAC stores") + } + if ownsGlobal { + auth.SetManager(nil) + } +} + func (r *Router) shutdownBackgroundWorkers() { if r.lifecycleCancel != nil { r.lifecycleCancel() diff --git a/pkg/auth/rbac.go b/pkg/auth/rbac.go index fda0a4369..54a47dd99 100644 --- a/pkg/auth/rbac.go +++ b/pkg/auth/rbac.go @@ -118,6 +118,22 @@ type ExtendedManager interface { UpdateUserRolesWithContext(username string, roleIDs []string, byUser string) error } +// ErrorAwareManager exposes storage failures that the legacy Manager interface +// cannot represent. HTTP handlers use this interface when available so a +// damaged or unavailable RBAC store is never presented as an empty data set. +type ErrorAwareManager interface { + GetRolesWithError() ([]Role, error) + GetUserAssignmentsWithError() ([]UserRoleAssignment, error) + GetUserAssignmentWithError(username string) (UserRoleAssignment, bool, error) + GetUserPermissionsWithError(username string) ([]Permission, error) +} + +// AssignmentMigrator atomically moves a role assignment from a legacy +// identity alias to its canonical principal. +type AssignmentMigrator interface { + MigrateUserAssignment(fromUsername, toUsername string) error +} + var ( globalManager Manager managerMu sync.RWMutex diff --git a/pkg/auth/sqlite_manager.go b/pkg/auth/sqlite_manager.go index b00a8f7f9..9b01074e9 100644 --- a/pkg/auth/sqlite_manager.go +++ b/pkg/auth/sqlite_manager.go @@ -3,8 +3,13 @@ package auth import ( "database/sql" "encoding/json" + "errors" "fmt" "net/url" + "os" + "path/filepath" + "sort" + "strings" "sync" "time" @@ -100,7 +105,8 @@ func NewSQLiteManager(cfg SQLiteManagerConfig) (*SQLiteManager, error) { // Migrate from file-based storage if requested if cfg.MigrateFromFiles { if err := m.migrateFromFiles(dataDir); err != nil { - log.Warn().Err(err).Msg("Failed to migrate RBAC from files (may not exist)") + db.Close() + return nil, fmt.Errorf("migrate legacy RBAC data: %w", err) } } @@ -134,14 +140,27 @@ func (m *SQLiteManager) initSchema() error { ); -- User role assignments + CREATE TABLE IF NOT EXISTS rbac_users ( + username TEXT PRIMARY KEY, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS rbac_user_assignments ( username TEXT NOT NULL, role_id TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (username, role_id), + FOREIGN KEY (username) REFERENCES rbac_users(username) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES rbac_roles(id) ON DELETE CASCADE ); + -- Backfill the identity table for databases created before rbac_users + -- existed. This preserves users when their last role is removed. + INSERT OR IGNORE INTO rbac_users (username, updated_at) + SELECT username, MAX(updated_at) + FROM rbac_user_assignments + GROUP BY username; + -- Change log CREATE TABLE IF NOT EXISTS rbac_changelog ( id TEXT PRIMARY KEY, @@ -254,17 +273,30 @@ func (m *SQLiteManager) Close() error { // GetRoles returns all roles. func (m *SQLiteManager) GetRoles() []Role { + roles, err := m.GetRolesWithError() + if err != nil { + log.Error().Err(err).Msg("Failed to query roles") + return nil + } + return roles +} + +// GetRolesWithError returns all roles and preserves storage errors. +func (m *SQLiteManager) GetRolesWithError() ([]Role, error) { m.mu.RLock() defer m.mu.RUnlock() + return m.getRolesUnsafe() +} + +func (m *SQLiteManager) getRolesUnsafe() ([]Role, error) { rows, err := m.db.Query(` SELECT id, name, description, parent_id, is_built_in, priority, created_at, updated_at FROM rbac_roles ORDER BY name `) if err != nil { - log.Error().Err(err).Msg("Failed to query roles") - return nil + return nil, err } // Collect roles first, then close rows before loading permissions @@ -277,8 +309,8 @@ func (m *SQLiteManager) GetRoles() []Role { var isBuiltIn int if err := rows.Scan(&role.ID, &role.Name, &role.Description, &parentID, &isBuiltIn, &role.Priority, &createdAt, &updatedAt); err != nil { - log.Error().Err(err).Msg("Failed to scan role") - continue + rows.Close() + return nil, err } role.ParentID = parentID.String @@ -288,25 +320,46 @@ func (m *SQLiteManager) GetRoles() []Role { roles = append(roles, role) } - rows.Close() + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } // Load permissions after releasing the connection for i := range roles { - roles[i].Permissions = m.loadRolePermissions(roles[i].ID) + permissions, err := m.loadRolePermissionsWithError(roles[i].ID) + if err != nil { + return nil, err + } + roles[i].Permissions = permissions } - return roles + if roles == nil { + roles = []Role{} + } + return roles, nil } func (m *SQLiteManager) loadRolePermissions(roleID string) []Permission { + permissions, err := m.loadRolePermissionsWithError(roleID) + if err != nil { + log.Error().Err(err).Str("roleId", roleID).Msg("Failed to query permissions") + return nil + } + return permissions +} + +func (m *SQLiteManager) loadRolePermissionsWithError(roleID string) ([]Permission, error) { rows, err := m.db.Query(` SELECT action, resource, effect, conditions FROM rbac_permissions WHERE role_id = ? `, roleID) if err != nil { - log.Error().Err(err).Str("roleId", roleID).Msg("Failed to query permissions") - return nil + return nil, err } defer rows.Close() @@ -316,20 +369,25 @@ func (m *SQLiteManager) loadRolePermissions(roleID string) []Permission { var conditions sql.NullString if err := rows.Scan(&perm.Action, &perm.Resource, &perm.Effect, &conditions); err != nil { - log.Error().Err(err).Msg("Failed to scan permission") - continue + return nil, err } if conditions.Valid && conditions.String != "" { if err := json.Unmarshal([]byte(conditions.String), &perm.Conditions); err != nil { - log.Error().Err(err).Msg("Failed to parse permission conditions") + return nil, fmt.Errorf("parse conditions for role %s: %w", roleID, err) } } perms = append(perms, perm) } - return perms + if err := rows.Err(); err != nil { + return nil, err + } + if perms == nil { + perms = []Permission{} + } + return perms, nil } // GetRole returns a role by ID. @@ -531,77 +589,138 @@ func (m *SQLiteManager) DeleteRoleWithContext(id string, username string) error // GetUserAssignments returns all user role assignments. func (m *SQLiteManager) GetUserAssignments() []UserRoleAssignment { - m.mu.RLock() - defer m.mu.RUnlock() - - // Collect usernames first, then close rows before nested queries - // (avoids holding the connection during nested queries with MaxOpenConns=1) - rows, err := m.db.Query("SELECT DISTINCT username FROM rbac_user_assignments") + assignments, err := m.GetUserAssignmentsWithError() if err != nil { log.Error().Err(err).Msg("Failed to query user assignments") return nil } + return assignments +} + +// GetUserAssignmentsWithError returns every known RBAC identity, including +// identities that currently have no roles, and preserves storage errors. +func (m *SQLiteManager) GetUserAssignmentsWithError() ([]UserRoleAssignment, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + return m.getUserAssignmentsUnsafe() +} + +func (m *SQLiteManager) getUserAssignmentsUnsafe() ([]UserRoleAssignment, error) { + // Collect identities first, then close rows before nested queries + // (avoids holding the connection during nested queries with MaxOpenConns=1) + rows, err := m.db.Query("SELECT username FROM rbac_users ORDER BY username") + if err != nil { + return nil, err + } var usernames []string for rows.Next() { var username string if err := rows.Scan(&username); err != nil { - continue + rows.Close() + return nil, err } usernames = append(usernames, username) } - rows.Close() + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } var assignments []UserRoleAssignment for _, username := range usernames { - assignment := m.getUserAssignmentUnsafe(username) - if len(assignment.RoleIDs) > 0 { - assignments = append(assignments, assignment) + assignment, _, err := m.getUserAssignmentWithErrorUnsafe(username) + if err != nil { + return nil, err } + assignments = append(assignments, assignment) } - return assignments + if assignments == nil { + assignments = []UserRoleAssignment{} + } + return assignments, nil } func (m *SQLiteManager) getUserAssignmentUnsafe(username string) UserRoleAssignment { + assignment, _, err := m.getUserAssignmentWithErrorUnsafe(username) + if err != nil { + log.Error().Err(err).Str("username", username).Msg("Failed to query user assignment") + return UserRoleAssignment{Username: username} + } + return assignment +} + +func (m *SQLiteManager) getUserAssignmentWithErrorUnsafe(username string) (UserRoleAssignment, bool, error) { + var userUpdatedAt int64 + err := m.db.QueryRow(` + SELECT updated_at + FROM rbac_users + WHERE username = ? + `, username).Scan(&userUpdatedAt) + if err == sql.ErrNoRows { + return UserRoleAssignment{Username: username, RoleIDs: []string{}}, false, nil + } + if err != nil { + return UserRoleAssignment{}, false, err + } + rows, err := m.db.Query(` SELECT role_id, updated_at FROM rbac_user_assignments WHERE username = ? + ORDER BY role_id `, username) if err != nil { - return UserRoleAssignment{Username: username} + return UserRoleAssignment{}, false, err } defer rows.Close() - var roleIDs []string - var latestUpdate int64 + roleIDs := []string{} + latestUpdate := userUpdatedAt for rows.Next() { var roleID string var updatedAt int64 if err := rows.Scan(&roleID, &updatedAt); err != nil { - continue + return UserRoleAssignment{}, false, err } roleIDs = append(roleIDs, roleID) if updatedAt > latestUpdate { latestUpdate = updatedAt } } + if err := rows.Err(); err != nil { + return UserRoleAssignment{}, false, err + } return UserRoleAssignment{ Username: username, RoleIDs: roleIDs, UpdatedAt: time.Unix(latestUpdate, 0), - } + }, true, nil } // GetUserAssignment returns the role assignment for a user. func (m *SQLiteManager) GetUserAssignment(username string) (UserRoleAssignment, bool) { + assignment, ok, err := m.GetUserAssignmentWithError(username) + if err != nil { + log.Error().Err(err).Str("username", username).Msg("Failed to query user assignment") + return UserRoleAssignment{}, false + } + return assignment, ok +} + +// GetUserAssignmentWithError returns an assignment and preserves storage +// errors. A known identity with zero roles still exists. +func (m *SQLiteManager) GetUserAssignmentWithError(username string) (UserRoleAssignment, bool, error) { m.mu.RLock() defer m.mu.RUnlock() - assignment := m.getUserAssignmentUnsafe(username) - return assignment, len(assignment.RoleIDs) > 0 + return m.getUserAssignmentWithErrorUnsafe(username) } // AssignRole adds a role to a user. @@ -619,12 +738,26 @@ func (m *SQLiteManager) AssignRole(username string, roleID string) error { } now := time.Now().Unix() - _, err := m.db.Exec(` + tx, err := m.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(` + INSERT INTO rbac_users (username, updated_at) + VALUES (?, ?) + ON CONFLICT(username) DO UPDATE SET updated_at = excluded.updated_at + `, username, now); err != nil { + return err + } + if _, err := tx.Exec(` INSERT OR IGNORE INTO rbac_user_assignments (username, role_id, updated_at) VALUES (?, ?, ?) - `, username, roleID, now) - - return err + `, username, roleID, now); err != nil { + return err + } + return tx.Commit() } // UpdateUserRoles replaces all roles for a user. @@ -658,6 +791,15 @@ func (m *SQLiteManager) UpdateUserRolesWithContext(username string, roleIDs []st } defer func() { _ = tx.Rollback() }() + now := time.Now().Unix() + if _, err = tx.Exec(` + INSERT INTO rbac_users (username, updated_at) + VALUES (?, ?) + ON CONFLICT(username) DO UPDATE SET updated_at = excluded.updated_at + `, username, now); err != nil { + return err + } + // Delete existing assignments _, err = tx.Exec("DELETE FROM rbac_user_assignments WHERE username = ?", username) if err != nil { @@ -665,7 +807,6 @@ func (m *SQLiteManager) UpdateUserRolesWithContext(username string, roleIDs []st } // Insert new assignments - now := time.Now().Unix() for _, roleID := range roleIDs { _, err = tx.Exec(` INSERT INTO rbac_user_assignments (username, role_id, updated_at) @@ -688,6 +829,94 @@ func (m *SQLiteManager) UpdateUserRolesWithContext(username string, roleIDs []st return nil } +// MigrateUserAssignment atomically moves a legacy identity alias to a +// canonical principal. Conflicting canonical roles fail closed rather than +// unioning grants and accidentally escalating access. +func (m *SQLiteManager) MigrateUserAssignment(fromUsername, toUsername string) error { + m.mu.Lock() + defer m.mu.Unlock() + + fromUsername = strings.TrimSpace(fromUsername) + toUsername = strings.TrimSpace(toUsername) + if fromUsername == "" || toUsername == "" { + return fmt.Errorf("source and destination usernames are required") + } + if fromUsername == toUsername { + return nil + } + + source, sourceExists, err := m.getUserAssignmentWithErrorUnsafe(fromUsername) + if err != nil { + return err + } + if !sourceExists { + return nil + } + target, targetExists, err := m.getUserAssignmentWithErrorUnsafe(toUsername) + if err != nil { + return err + } + sourceRoleIDs := append([]string{}, source.RoleIDs...) + targetRoleIDs := append([]string{}, target.RoleIDs...) + sort.Strings(sourceRoleIDs) + sort.Strings(targetRoleIDs) + if targetExists && len(targetRoleIDs) > 0 && + strings.Join(sourceRoleIDs, "\x00") != strings.Join(targetRoleIDs, "\x00") { + return fmt.Errorf("canonical assignment for %q conflicts with legacy identity %q", toUsername, fromUsername) + } + + tx, err := m.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + now := time.Now().Unix() + if _, err := tx.Exec(` + INSERT INTO rbac_users (username, updated_at) + VALUES (?, ?) + ON CONFLICT(username) DO UPDATE SET updated_at = excluded.updated_at + `, toUsername, now); err != nil { + return err + } + if _, err := tx.Exec("DELETE FROM rbac_user_assignments WHERE username = ?", toUsername); err != nil { + return err + } + for _, roleID := range sourceRoleIDs { + if _, err := tx.Exec(` + INSERT INTO rbac_user_assignments (username, role_id, updated_at) + VALUES (?, ?, ?) + `, toUsername, roleID, now); err != nil { + return err + } + } + if _, err := tx.Exec("DELETE FROM rbac_user_assignments WHERE username = ?", fromUsername); err != nil { + return err + } + if _, err := tx.Exec("DELETE FROM rbac_users WHERE username = ?", fromUsername); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + + oldValueJSON, _ := json.Marshal(source) + newValueJSON, _ := json.Marshal(UserRoleAssignment{ + Username: toUsername, + RoleIDs: sourceRoleIDs, + UpdatedAt: time.Unix(now, 0), + }) + m.logChangeUnsafe( + ActionUserRolesUpdate, + "assignment", + toUsername, + string(oldValueJSON), + string(newValueJSON), + "identity-migration", + ) + return nil +} + // RemoveRole removes a role from a user. func (m *SQLiteManager) RemoveRole(username string, roleID string) error { m.mu.Lock() @@ -703,30 +932,47 @@ func (m *SQLiteManager) RemoveRole(username string, roleID string) error { // GetUserPermissions returns the effective permissions for a user. func (m *SQLiteManager) GetUserPermissions(username string) []Permission { + permissions, err := m.GetUserPermissionsWithError(username) + if err != nil { + log.Error().Err(err).Str("username", username).Msg("Failed to query user permissions") + return nil + } + return permissions +} + +// GetUserPermissionsWithError returns effective permissions and preserves +// storage failures. +func (m *SQLiteManager) GetUserPermissionsWithError(username string) ([]Permission, error) { m.mu.RLock() defer m.mu.RUnlock() - assignment := m.getUserAssignmentUnsafe(username) + assignment, _, err := m.getUserAssignmentWithErrorUnsafe(username) + if err != nil { + return nil, err + } if len(assignment.RoleIDs) == 0 { - return nil + return []Permission{}, nil } // Collect unique permissions from all assigned roles permMap := make(map[string]Permission) for _, roleID := range assignment.RoleIDs { - perms := m.loadRolePermissions(roleID) + perms, err := m.loadRolePermissionsWithError(roleID) + if err != nil { + return nil, err + } for _, perm := range perms { key := perm.Action + ":" + perm.Resource + ":" + perm.GetEffect() permMap[key] = perm } } - var perms []Permission + perms := make([]Permission, 0, len(permMap)) for _, perm := range permMap { perms = append(perms, perm) } - return perms + return perms, nil } // GetRoleWithInheritance returns a role and all inherited permissions. @@ -936,53 +1182,438 @@ func (m *SQLiteManager) migrateFromFiles(dataDir string) error { return fmt.Errorf("resolve legacy assignments backup path: %w", err) } - // Check if migration is needed - var roleCount int - if err := m.db.QueryRow("SELECT COUNT(*) FROM rbac_roles WHERE is_built_in = 0").Scan(&roleCount); err != nil { - log.Warn().Err(err).Msg("Failed to count custom roles before migration") + roles, rolesExist, err := readLegacyRBACFile[Role](rolesFile, "roles") + if err != nil { + return err } - if roleCount > 0 { - return nil // Already have custom roles, skip migration + assignments, assignmentsExist, err := readLegacyRBACFile[UserRoleAssignment](assignmentsFile, "assignments") + if err != nil { + return err + } + if !rolesExist && !assignmentsExist { + return nil + } + if rolesExist { + rolesBackup, err = availableLegacyBackupPath(rolesBackup) + if err != nil { + return err + } + } + if assignmentsExist { + assignmentsBackup, err = availableLegacyBackupPath(assignmentsBackup) + if err != nil { + return err + } } - // Migrate roles - if data, err := securityutil.ReadSecureStorageFile(rolesFile, maxLegacyRBACFileSize); err == nil { - var roles []Role - if err := json.Unmarshal(data, &roles); err == nil { - for _, role := range roles { - if !role.IsBuiltIn { - if err := m.SaveRoleWithContext(role, "migration"); err != nil { - log.Warn().Err(err).Str("roleId", role.ID).Msg("Failed to migrate role") - } - } + if err := m.importLegacyRBAC(roles, assignments); err != nil { + return err + } + + // Source files remain untouched until the complete import transaction has + // committed. A rename failure is returned so an operator can resolve it; + // the next start safely verifies the imported records before retrying. + if rolesExist { + if err := securityutil.RenameSecureStorageFile(rolesFile, rolesBackup); err != nil { + return fmt.Errorf("archive migrated legacy roles: %w", err) + } + } + if assignmentsExist { + if err := securityutil.RenameSecureStorageFile(assignmentsFile, assignmentsBackup); err != nil { + return fmt.Errorf("archive migrated legacy assignments: %w", err) + } + } + + log.Info(). + Int("roles", len(roles)). + Int("assignments", len(assignments)). + Msg("Migrated legacy RBAC data to SQLite") + return nil +} + +func readLegacyRBACFile[T any](path, kind string) ([]T, bool, error) { + data, err := securityutil.ReadSecureStorageFile(path, maxLegacyRBACFileSize) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("read legacy RBAC %s: %w", kind, err) + } + + var records []T + if err := json.Unmarshal(data, &records); err != nil { + return nil, true, fmt.Errorf("decode legacy RBAC %s: %w", kind, err) + } + if records == nil { + records = []T{} + } + return records, true, nil +} + +func availableLegacyBackupPath(path string) (string, error) { + for index := 0; index < 1000; index++ { + candidate := path + if index > 0 { + candidate = fmt.Sprintf("%s.%d", path, index) + } + _, err := os.Lstat(candidate) + if errors.Is(err, os.ErrNotExist) { + return candidate, nil + } + if err != nil { + return "", fmt.Errorf("inspect legacy RBAC backup %s: %w", filepath.Base(candidate), err) + } + } + return "", fmt.Errorf("too many legacy RBAC backups for %s", filepath.Base(path)) +} + +func (m *SQLiteManager) importLegacyRBAC(roles []Role, assignments []UserRoleAssignment) error { + tx, err := m.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + existingRoles, err := loadMigrationRoles(tx) + if err != nil { + return fmt.Errorf("load current roles: %w", err) + } + + legacyRoles := make(map[string]Role, len(roles)) + for _, role := range roles { + if strings.TrimSpace(role.ID) == "" { + return fmt.Errorf("legacy role has an empty ID") + } + if previous, duplicate := legacyRoles[role.ID]; duplicate { + if canonicalRole(previous) != canonicalRole(role) { + return fmt.Errorf("conflicting duplicate legacy role %q", role.ID) } - log.Info().Int("count", len(roles)).Msg("Migrated roles from file") + continue + } + legacyRoles[role.ID] = role + } - // Rename old file - if err := securityutil.RenameSecureStorageFile(rolesFile, rolesBackup); err != nil { - log.Warn().Err(err).Msg("Failed to rename migrated roles file") + parentByRole := make(map[string]string, len(existingRoles)+len(legacyRoles)) + for id, role := range existingRoles { + parentByRole[id] = role.ParentID + } + for id, role := range legacyRoles { + if role.IsBuiltIn { + existing, ok := existingRoles[id] + if !ok || !existing.IsBuiltIn { + return fmt.Errorf("legacy built-in role %q is not recognized", id) + } + if canonicalRole(existing) != canonicalRole(role) { + return fmt.Errorf("legacy built-in role %q differs from the canonical definition", id) + } + continue + } + if existing, ok := existingRoles[id]; ok && canonicalRole(existing) != canonicalRole(role) { + return fmt.Errorf("legacy role %q conflicts with current v6 data", id) + } + parentByRole[id] = role.ParentID + } + for id, parentID := range parentByRole { + if parentID != "" { + if _, ok := parentByRole[parentID]; !ok { + return fmt.Errorf("role %q references missing parent %q", id, parentID) + } + } + } + if err := validateRoleParentGraph(parentByRole); err != nil { + return err + } + + newRoleIDs := make([]string, 0, len(legacyRoles)) + for id, role := range legacyRoles { + if role.IsBuiltIn { + continue + } + if _, exists := existingRoles[id]; exists { + continue + } + if strings.TrimSpace(role.Name) == "" { + return fmt.Errorf("legacy role %q has an empty name", id) + } + createdAt := role.CreatedAt.Unix() + updatedAt := role.UpdatedAt.Unix() + now := time.Now().Unix() + if role.CreatedAt.IsZero() { + createdAt = now + } + if role.UpdatedAt.IsZero() { + updatedAt = createdAt + } + if _, err := tx.Exec(` + INSERT INTO rbac_roles + (id, name, description, parent_id, is_built_in, priority, created_at, updated_at) + VALUES (?, ?, ?, NULL, 0, ?, ?, ?) + `, role.ID, role.Name, role.Description, role.Priority, createdAt, updatedAt); err != nil { + return fmt.Errorf("insert legacy role %q: %w", role.ID, err) + } + for _, permission := range role.Permissions { + if strings.TrimSpace(permission.Action) == "" || strings.TrimSpace(permission.Resource) == "" { + return fmt.Errorf("legacy role %q has an invalid permission", role.ID) + } + effect := permission.GetEffect() + if effect != EffectAllow && effect != EffectDeny { + return fmt.Errorf("legacy role %q has invalid permission effect %q", role.ID, effect) + } + var conditions interface{} + if len(permission.Conditions) > 0 { + encoded, err := json.Marshal(permission.Conditions) + if err != nil { + return fmt.Errorf("encode legacy role %q conditions: %w", role.ID, err) + } + conditions = string(encoded) + } + if _, err := tx.Exec(` + INSERT INTO rbac_permissions (role_id, action, resource, effect, conditions) + VALUES (?, ?, ?, ?, ?) + `, role.ID, permission.Action, permission.Resource, effect, conditions); err != nil { + return fmt.Errorf("insert permission for legacy role %q: %w", role.ID, err) + } + } + newRoleIDs = append(newRoleIDs, id) + } + for _, id := range newRoleIDs { + if parentID := legacyRoles[id].ParentID; parentID != "" { + if _, err := tx.Exec("UPDATE rbac_roles SET parent_id = ? WHERE id = ?", parentID, id); err != nil { + return fmt.Errorf("set parent for legacy role %q: %w", id, err) } } } - // Migrate assignments - if data, err := securityutil.ReadSecureStorageFile(assignmentsFile, maxLegacyRBACFileSize); err == nil { - var assignments []UserRoleAssignment - if err := json.Unmarshal(data, &assignments); err == nil { - for _, a := range assignments { - if err := m.UpdateUserRolesWithContext(a.Username, a.RoleIDs, "migration"); err != nil { - log.Warn().Err(err).Str("username", a.Username).Msg("Failed to migrate assignment") - } + knownRoleIDs := make(map[string]struct{}, len(parentByRole)) + for id := range parentByRole { + knownRoleIDs[id] = struct{}{} + } + legacyAssignments := make(map[string][]string, len(assignments)) + for _, assignment := range assignments { + username := strings.TrimSpace(assignment.Username) + if username == "" { + return fmt.Errorf("legacy assignment has an empty username") + } + roleIDs, err := normalizedRoleIDs(assignment.RoleIDs, knownRoleIDs) + if err != nil { + return fmt.Errorf("legacy assignment for %q: %w", username, err) + } + if previous, duplicate := legacyAssignments[username]; duplicate { + if strings.Join(previous, "\x00") != strings.Join(roleIDs, "\x00") { + return fmt.Errorf("conflicting duplicate legacy assignment for %q", username) } - log.Info().Int("count", len(assignments)).Msg("Migrated assignments from file") + continue + } + legacyAssignments[username] = roleIDs + } - // Rename old file - if err := securityutil.RenameSecureStorageFile(assignmentsFile, assignmentsBackup); err != nil { - log.Warn().Err(err).Msg("Failed to rename migrated assignments file") + usernames := make([]string, 0, len(legacyAssignments)) + for username := range legacyAssignments { + usernames = append(usernames, username) + } + sort.Strings(usernames) + for _, username := range usernames { + roleIDs := legacyAssignments[username] + currentRoleIDs, exists, err := loadMigrationAssignment(tx, username) + if err != nil { + return fmt.Errorf("load current assignment for %q: %w", username, err) + } + if exists { + if strings.Join(currentRoleIDs, "\x00") != strings.Join(roleIDs, "\x00") { + return fmt.Errorf("legacy assignment for %q conflicts with current v6 data", username) + } + continue + } + now := time.Now().Unix() + if _, err := tx.Exec( + "INSERT INTO rbac_users (username, updated_at) VALUES (?, ?)", + username, + now, + ); err != nil { + return fmt.Errorf("insert legacy identity %q: %w", username, err) + } + for _, roleID := range roleIDs { + if _, err := tx.Exec(` + INSERT INTO rbac_user_assignments (username, role_id, updated_at) + VALUES (?, ?, ?) + `, username, roleID, now); err != nil { + return fmt.Errorf("insert legacy assignment for %q: %w", username, err) } } } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit legacy RBAC import: %w", err) + } + return nil +} + +func loadMigrationRoles(tx *sql.Tx) (map[string]Role, error) { + rows, err := tx.Query(` + SELECT id, name, description, parent_id, is_built_in, priority + FROM rbac_roles + `) + if err != nil { + return nil, err + } + roles := make(map[string]Role) + for rows.Next() { + var role Role + var parentID sql.NullString + var isBuiltIn int + if err := rows.Scan(&role.ID, &role.Name, &role.Description, &parentID, &isBuiltIn, &role.Priority); err != nil { + rows.Close() + return nil, err + } + role.ParentID = parentID.String + role.IsBuiltIn = isBuiltIn == 1 + roles[role.ID] = role + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + + permissionRows, err := tx.Query(` + SELECT role_id, action, resource, effect, conditions + FROM rbac_permissions + ORDER BY role_id, id + `) + if err != nil { + return nil, err + } + defer permissionRows.Close() + for permissionRows.Next() { + var roleID string + var permission Permission + var conditions sql.NullString + if err := permissionRows.Scan(&roleID, &permission.Action, &permission.Resource, &permission.Effect, &conditions); err != nil { + return nil, err + } + if conditions.Valid && conditions.String != "" { + if err := json.Unmarshal([]byte(conditions.String), &permission.Conditions); err != nil { + return nil, fmt.Errorf("decode conditions for role %q: %w", roleID, err) + } + } + role, ok := roles[roleID] + if !ok { + return nil, fmt.Errorf("permission references missing role %q", roleID) + } + role.Permissions = append(role.Permissions, permission) + roles[roleID] = role + } + if err := permissionRows.Err(); err != nil { + return nil, err + } + return roles, nil +} + +func loadMigrationAssignment(tx *sql.Tx, username string) ([]string, bool, error) { + var count int + if err := tx.QueryRow("SELECT COUNT(*) FROM rbac_users WHERE username = ?", username).Scan(&count); err != nil { + return nil, false, err + } + if count == 0 { + return nil, false, nil + } + rows, err := tx.Query(` + SELECT role_id + FROM rbac_user_assignments + WHERE username = ? + ORDER BY role_id + `, username) + if err != nil { + return nil, false, err + } + defer rows.Close() + roleIDs := []string{} + for rows.Next() { + var roleID string + if err := rows.Scan(&roleID); err != nil { + return nil, false, err + } + roleIDs = append(roleIDs, roleID) + } + if err := rows.Err(); err != nil { + return nil, false, err + } + return roleIDs, true, nil +} + +func normalizedRoleIDs(roleIDs []string, known map[string]struct{}) ([]string, error) { + unique := make(map[string]struct{}, len(roleIDs)) + for _, roleID := range roleIDs { + if _, exists := known[roleID]; !exists { + return nil, fmt.Errorf("references missing role %q", roleID) + } + unique[roleID] = struct{}{} + } + normalized := make([]string, 0, len(unique)) + for roleID := range unique { + normalized = append(normalized, roleID) + } + sort.Strings(normalized) + return normalized, nil +} + +func canonicalRole(role Role) string { + permissions := make([]string, 0, len(role.Permissions)) + for _, permission := range role.Permissions { + var conditions []byte + if len(permission.Conditions) > 0 { + conditions, _ = json.Marshal(permission.Conditions) + } + permissions = append(permissions, strings.Join([]string{ + permission.Action, + permission.Resource, + permission.GetEffect(), + string(conditions), + }, "\x00")) + } + sort.Strings(permissions) + return strings.Join([]string{ + role.ID, + role.Name, + role.Description, + role.ParentID, + fmt.Sprintf("%t", role.IsBuiltIn), + fmt.Sprintf("%d", role.Priority), + strings.Join(permissions, "\x01"), + }, "\x02") +} + +func validateRoleParentGraph(parentByRole map[string]string) error { + const ( + unvisited = iota + visiting + visited + ) + state := make(map[string]int, len(parentByRole)) + var visit func(string) error + visit = func(roleID string) error { + switch state[roleID] { + case visiting: + return fmt.Errorf("role inheritance cycle contains %q", roleID) + case visited: + return nil + } + state[roleID] = visiting + if parentID := parentByRole[roleID]; parentID != "" { + if err := visit(parentID); err != nil { + return err + } + } + state[roleID] = visited + return nil + } + for roleID := range parentByRole { + if err := visit(roleID); err != nil { + return err + } + } return nil } diff --git a/pkg/auth/sqlite_manager_test.go b/pkg/auth/sqlite_manager_test.go index 0655cd3e7..288bf29ef 100644 --- a/pkg/auth/sqlite_manager_test.go +++ b/pkg/auth/sqlite_manager_test.go @@ -1,8 +1,10 @@ package auth import ( + "encoding/json" "os" "path/filepath" + "strings" "testing" "time" ) @@ -318,6 +320,364 @@ func TestSQLiteManagerMigration(t *testing.T) { }) } +func TestSQLiteManagerMigrationPreservesZeroRoleIdentities(t *testing.T) { + tmpDir := t.TempDir() + fileManager, err := NewFileManager(tmpDir) + if err != nil { + t.Fatalf("NewFileManager: %v", err) + } + if err := fileManager.UpdateUserRoles("local-user", nil); err != nil { + t.Fatalf("create empty local assignment: %v", err) + } + if err := fileManager.UpdateUserRoles("sso:oidc:okta:stable", []string{RoleViewer}); err != nil { + t.Fatalf("create SSO assignment: %v", err) + } + + manager, err := NewSQLiteManager(SQLiteManagerConfig{ + DataDir: tmpDir, + MigrateFromFiles: true, + }) + if err != nil { + t.Fatalf("NewSQLiteManager: %v", err) + } + defer manager.Close() + + empty, ok := manager.GetUserAssignment("local-user") + if !ok { + t.Fatal("zero-role local identity was lost during migration") + } + if len(empty.RoleIDs) != 0 { + t.Fatalf("zero-role identity has roles: %v", empty.RoleIDs) + } + sso, ok := manager.GetUserAssignment("sso:oidc:okta:stable") + if !ok || len(sso.RoleIDs) != 1 || sso.RoleIDs[0] != RoleViewer { + t.Fatalf("SSO assignment not migrated: %#v, exists=%v", sso, ok) + } +} + +func TestSQLiteManagerMigrationPreservesExistingBackups(t *testing.T) { + tmpDir := t.TempDir() + fileManager, err := NewFileManager(tmpDir) + if err != nil { + t.Fatalf("NewFileManager: %v", err) + } + if err := fileManager.SaveRole(Role{ + ID: "legacy-role", + Name: "Legacy role", + Permissions: []Permission{{Action: "read", Resource: "*"}}, + }); err != nil { + t.Fatalf("create legacy role: %v", err) + } + if err := fileManager.UpdateUserRoles("legacy-user", []string{RoleViewer}); err != nil { + t.Fatalf("create legacy assignment: %v", err) + } + for _, name := range []string{"rbac_roles.json.bak", "rbac_assignments.json.bak"} { + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte("existing backup"), 0600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + manager, err := NewSQLiteManager(SQLiteManagerConfig{ + DataDir: tmpDir, + MigrateFromFiles: true, + }) + if err != nil { + t.Fatalf("NewSQLiteManager: %v", err) + } + defer manager.Close() + + for _, name := range []string{"rbac_roles.json.bak", "rbac_assignments.json.bak"} { + original, err := os.ReadFile(filepath.Join(tmpDir, name)) + if err != nil { + t.Fatalf("read original %s: %v", name, err) + } + if string(original) != "existing backup" { + t.Fatalf("existing backup %s was replaced", name) + } + if _, err := os.Stat(filepath.Join(tmpDir, name+".1")); err != nil { + t.Fatalf("new migration backup %s.1 missing: %v", name, err) + } + } +} + +func TestSQLiteManagerMigrationRejectsCorruptAndStaleData(t *testing.T) { + tests := []struct { + name string + roles string + assignments string + wantError string + }{ + { + name: "corrupt roles", + roles: `[{"id":`, + assignments: `[]`, + wantError: "decode legacy RBAC roles", + }, + { + name: "corrupt assignments rolls back roles", + roles: mustJSON(t, []Role{{ + ID: "legacy-viewer", + Name: "Legacy Viewer", + Permissions: []Permission{{Action: "read", Resource: "*"}}, + }}), + assignments: `[{"username":`, + wantError: "decode legacy RBAC assignments", + }, + { + name: "stale assignment", + roles: mustJSON(t, []Role{{ + ID: "legacy-viewer", + Name: "Legacy Viewer", + Permissions: []Permission{{Action: "read", Resource: "*"}}, + }}), + assignments: mustJSON(t, []UserRoleAssignment{{ + Username: "stale-user", + RoleIDs: []string{"deleted-role"}, + }}), + wantError: "references missing role", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + rolesPath := filepath.Join(tmpDir, "rbac_roles.json") + assignmentsPath := filepath.Join(tmpDir, "rbac_assignments.json") + if err := os.WriteFile(rolesPath, []byte(tt.roles), 0600); err != nil { + t.Fatalf("write roles: %v", err) + } + if err := os.WriteFile(assignmentsPath, []byte(tt.assignments), 0600); err != nil { + t.Fatalf("write assignments: %v", err) + } + + manager, err := NewSQLiteManager(SQLiteManagerConfig{ + DataDir: tmpDir, + MigrateFromFiles: true, + }) + if manager != nil { + _ = manager.Close() + } + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("error = %v, want substring %q", err, tt.wantError) + } + if _, err := os.Stat(rolesPath); err != nil { + t.Fatalf("legacy roles source was not preserved: %v", err) + } + if _, err := os.Stat(assignmentsPath); err != nil { + t.Fatalf("legacy assignments source was not preserved: %v", err) + } + if _, err := os.Stat(rolesPath + ".bak"); !os.IsNotExist(err) { + t.Fatalf("roles backup must not be created on failed migration: %v", err) + } + + reopened, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir}) + if err != nil { + t.Fatalf("reopen after failed migration: %v", err) + } + defer reopened.Close() + if _, ok := reopened.GetRole("legacy-viewer"); ok { + t.Fatal("failed migration committed a partial role") + } + }) + } +} + +func TestSQLiteManagerMigrationRejectsCurrentStateConflicts(t *testing.T) { + tmpDir := t.TempDir() + current, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir}) + if err != nil { + t.Fatalf("create current manager: %v", err) + } + if err := current.SaveRole(Role{ + ID: "existing", + Name: "Current v6 role", + Permissions: []Permission{{Action: "read", Resource: "nodes"}}, + }); err != nil { + t.Fatalf("save current role: %v", err) + } + if err := current.Close(); err != nil { + t.Fatalf("close current manager: %v", err) + } + + legacyRoles := []Role{{ + ID: "existing", + Name: "Legacy conflicting role", + Permissions: []Permission{{Action: "admin", Resource: "*"}}, + }} + if err := os.WriteFile(filepath.Join(tmpDir, "rbac_roles.json"), []byte(mustJSON(t, legacyRoles)), 0600); err != nil { + t.Fatalf("write legacy roles: %v", err) + } + + manager, err := NewSQLiteManager(SQLiteManagerConfig{ + DataDir: tmpDir, + MigrateFromFiles: true, + }) + if manager != nil { + _ = manager.Close() + } + if err == nil || !strings.Contains(err.Error(), "conflicts with current v6 data") { + t.Fatalf("error = %v, want current-state conflict", err) + } + + reopened, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir}) + if err != nil { + t.Fatalf("reopen current manager: %v", err) + } + defer reopened.Close() + role, ok := reopened.GetRole("existing") + if !ok || role.Name != "Current v6 role" { + t.Fatalf("current v6 role was changed: %#v, exists=%v", role, ok) + } +} + +func TestSQLiteManagerMigrationMergesDistinctLegacyAndV6State(t *testing.T) { + tmpDir := t.TempDir() + current, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: tmpDir}) + if err != nil { + t.Fatalf("create current manager: %v", err) + } + if err := current.SaveRole(Role{ + ID: "v6-role", + Name: "V6 role", + Permissions: []Permission{{Action: "write", Resource: "nodes"}}, + }); err != nil { + t.Fatalf("save v6 role: %v", err) + } + if err := current.UpdateUserRoles("v6-user", []string{"v6-role"}); err != nil { + t.Fatalf("save v6 assignment: %v", err) + } + if err := current.Close(); err != nil { + t.Fatalf("close v6 manager: %v", err) + } + + legacyRoles := []Role{{ + ID: "legacy-role", + Name: "Legacy role", + Permissions: []Permission{{Action: "read", Resource: "nodes"}}, + }} + legacyAssignments := []UserRoleAssignment{{ + Username: "legacy-user", + RoleIDs: []string{"legacy-role"}, + }} + if err := os.WriteFile(filepath.Join(tmpDir, "rbac_roles.json"), []byte(mustJSON(t, legacyRoles)), 0600); err != nil { + t.Fatalf("write legacy roles: %v", err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "rbac_assignments.json"), []byte(mustJSON(t, legacyAssignments)), 0600); err != nil { + t.Fatalf("write legacy assignments: %v", err) + } + + manager, err := NewSQLiteManager(SQLiteManagerConfig{ + DataDir: tmpDir, + MigrateFromFiles: true, + }) + if err != nil { + t.Fatalf("migrate distinct states: %v", err) + } + defer manager.Close() + for _, roleID := range []string{"v6-role", "legacy-role"} { + if _, ok := manager.GetRole(roleID); !ok { + t.Fatalf("role %q missing after merge", roleID) + } + } + for username, roleID := range map[string]string{ + "v6-user": "v6-role", + "legacy-user": "legacy-role", + } { + assignment, ok := manager.GetUserAssignment(username) + if !ok || len(assignment.RoleIDs) != 1 || assignment.RoleIDs[0] != roleID { + t.Fatalf("assignment for %q = %#v, exists=%v", username, assignment, ok) + } + } +} + +func TestSQLiteManagerKeepsIdentityWhenRolesAreEmptyOrDeleted(t *testing.T) { + manager, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: t.TempDir()}) + if err != nil { + t.Fatalf("NewSQLiteManager: %v", err) + } + defer manager.Close() + + if err := manager.UpdateUserRoles("empty-user", nil); err != nil { + t.Fatalf("UpdateUserRoles empty: %v", err) + } + if assignment, ok := manager.GetUserAssignment("empty-user"); !ok || len(assignment.RoleIDs) != 0 { + t.Fatalf("empty identity not retained: %#v, exists=%v", assignment, ok) + } + + role := Role{ + ID: "temporary-role", + Name: "Temporary", + Permissions: []Permission{{Action: "read", Resource: "nodes"}}, + } + if err := manager.SaveRole(role); err != nil { + t.Fatalf("SaveRole: %v", err) + } + if err := manager.AssignRole("renamed-user", role.ID); err != nil { + t.Fatalf("AssignRole: %v", err) + } + if err := manager.DeleteRole(role.ID); err != nil { + t.Fatalf("DeleteRole: %v", err) + } + assignment, ok := manager.GetUserAssignment("renamed-user") + if !ok || len(assignment.RoleIDs) != 0 { + t.Fatalf("role deletion left a stale grant or removed identity: %#v, exists=%v", assignment, ok) + } + if permissions := manager.GetUserPermissions("renamed-user"); len(permissions) != 0 { + t.Fatalf("deleted role still grants permissions: %#v", permissions) + } + + assignments, err := manager.GetUserAssignmentsWithError() + if err != nil { + t.Fatalf("GetUserAssignmentsWithError: %v", err) + } + if len(assignments) != 2 { + t.Fatalf("known identities = %d, want 2: %#v", len(assignments), assignments) + } + + if _, err := manager.db.Exec(` + INSERT INTO rbac_user_assignments (username, role_id, updated_at) + VALUES ('orphaned-user', ?, ?) + `, RoleViewer, time.Now().Unix()); err == nil { + t.Fatal("assignment schema accepted an identity missing from rbac_users") + } +} + +func TestSQLiteManagerIdentityMigrationRejectsConflictingGrant(t *testing.T) { + manager, err := NewSQLiteManager(SQLiteManagerConfig{DataDir: t.TempDir()}) + if err != nil { + t.Fatalf("NewSQLiteManager: %v", err) + } + defer manager.Close() + if err := manager.UpdateUserRoles("legacy@example.com", []string{RoleAdmin}); err != nil { + t.Fatalf("seed legacy assignment: %v", err) + } + if err := manager.UpdateUserRoles("sso:oidc:okta:stable", []string{RoleViewer}); err != nil { + t.Fatalf("seed canonical assignment: %v", err) + } + + err = manager.MigrateUserAssignment("legacy@example.com", "sso:oidc:okta:stable") + if err == nil || !strings.Contains(err.Error(), "conflicts") { + t.Fatalf("error = %v, want conflict", err) + } + legacy, legacyExists := manager.GetUserAssignment("legacy@example.com") + canonical, canonicalExists := manager.GetUserAssignment("sso:oidc:okta:stable") + if !legacyExists || len(legacy.RoleIDs) != 1 || legacy.RoleIDs[0] != RoleAdmin { + t.Fatalf("legacy assignment changed after conflict: %#v, exists=%v", legacy, legacyExists) + } + if !canonicalExists || len(canonical.RoleIDs) != 1 || canonical.RoleIDs[0] != RoleViewer { + t.Fatalf("canonical assignment changed after conflict: %#v, exists=%v", canonical, canonicalExists) + } +} + +func mustJSON(t *testing.T, value interface{}) string { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal test data: %v", err) + } + return string(data) +} + func TestSQLiteManagerCircularInheritance(t *testing.T) { tmpDir, err := os.MkdirTemp("", "rbac-circular-test-*") if err != nil { diff --git a/pkg/server/server.go b/pkg/server/server.go index a16bd47e0..70b7ad7ff 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -32,7 +32,6 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/websocket" "github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts" "github.com/rcourtman/pulse-go-rewrite/pkg/audit" - "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rcourtman/pulse-go-rewrite/pkg/extensions" pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" @@ -201,15 +200,6 @@ func Run(ctx context.Context, version string) error { mtPersistence := config.NewMultiTenantPersistence(cfg.DataPath) baseDataDir := mtPersistence.BaseDataDir() - // Initialize RBAC manager for role-based access control - rbacManager, err := auth.NewFileManager(baseDataDir) - if err != nil { - log.Warn().Err(err).Msg("Failed to initialize RBAC manager, role management will be unavailable") - } else { - auth.SetManager(rbacManager) - log.Info().Msg("RBAC manager initialized") - } - // Run multi-tenant data migration only when the feature is explicitly enabled. // This prevents any on-disk layout changes for default (single-tenant) users. if api.IsMultiTenantEnabled() { @@ -831,6 +821,9 @@ shutdown: // Stop license grant refresh loops router.StopGrantRefresh() + // Close organization RBAC databases and clear the canonical global manager. + router.ShutdownRBAC() + // Gracefully stop AI intelligence services (patrol, investigations, triggers) router.ShutdownAIIntelligence()