mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-02 11:43:33 +00:00
Fix RBAC assignment migration and loading
This commit is contained in:
parent
8c341753ae
commit
d235aab3cf
26 changed files with 1770 additions and 117 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue