Fix RBAC assignment migration and loading

This commit is contained in:
rcourtman 2026-07-23 21:55:13 +01:00
parent 8c341753ae
commit d235aab3cf
26 changed files with 1770 additions and 117 deletions

View file

@ -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.

View file

@ -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:<id>` 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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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', () => {

View file

@ -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())}
>
<Plus class="w-4 h-4" />
New Role
@ -48,7 +50,23 @@ export const RolesPanel: Component = () => {
</div>
</Show>
<Show when={!state.loading() && state.featureGate.rbacEnabled()}>
<Show when={!state.loading() && state.featureGate.rbacEnabled() && state.loadError()}>
{(message) => (
<InlineNotice
role="alert"
aria-live="polite"
tone="danger"
layout="banner"
icon={<TriangleAlert />}
actionLabel="Retry"
actionOnClick={() => void state.loadRoles()}
>
{message()}
</InlineNotice>
)}
</Show>
<Show when={!state.loading() && state.featureGate.rbacEnabled() && !state.loadError()}>
<PulseDataGrid
data={state.roles()}
columns={[

View file

@ -11,6 +11,8 @@ import Pencil from 'lucide-solid/icons/pencil';
import { SearchField } from '@/components/shared/SearchField';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
import { getUserAssignmentsEmptyStateCopy } from '@/utils/rbacPresentation';
import { InlineNotice } from '@/components/shared/InlineNotice';
import TriangleAlert from 'lucide-solid/icons/triangle-alert';
export const UserAssignmentsPanel: Component = () => {
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 = () => {
</div>
</Show>
<Show when={!state.loading() && state.featureGate.rbacEnabled() && state.loadError()}>
{(message) => (
<InlineNotice
role="alert"
aria-live="polite"
tone="danger"
layout="banner"
icon={<TriangleAlert />}
actionLabel="Retry"
actionOnClick={() => void state.loadData()}
>
{message()}
</InlineNotice>
)}
</Show>
<Show
when={
!state.loading() &&
state.featureGate.rbacEnabled() &&
!state.loadError() &&
state.filteredAssignments().length === 0
}
>
@ -73,6 +92,7 @@ export const UserAssignmentsPanel: Component = () => {
when={
!state.loading() &&
state.featureGate.rbacEnabled() &&
!state.loadError() &&
state.filteredAssignments().length > 0
}
>

View file

@ -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(() => <RolesPanel />);
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(() => <UserAssignmentsPanel />);
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);

View file

@ -22,6 +22,7 @@ export function useRolesPanelState() {
const [formName, setFormName] = createSignal('');
const [formDescription, setFormDescription] = createSignal('');
const [formPermissions, setFormPermissions] = createSignal<Permission[]>([]);
const [loadError, setLoadError] = createSignal<string | null>(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,

View file

@ -20,6 +20,7 @@ export function useUserAssignmentsPanelState() {
const [userPermissions, setUserPermissions] = createSignal<Permission[]>([]);
const [loadingPermissions, setLoadingPermissions] = createSignal(false);
const [formRoleIds, setFormRoleIds] = createSignal<string[]>([]);
const [loadError, setLoadError] = createSignal<string | null>(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,

View file

@ -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.

View file

@ -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)

View file

@ -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 {

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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"

View file

@ -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)
}
}

View file

@ -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"

View file

@ -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()

View file

@ -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

View file

@ -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
}

View file

@ -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 {

View file

@ -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()