feat: expose Community audit log via system:audit navigation (#1740)

* feat(rbac): make Settings authorization permission-aware

Align Settings visibility and mutations with the existing permission matrix so Node Admin can edit node-scoped operational settings while system and credential surfaces stay Admin-protected.

* fix(rbac): tighten settings permission buckets and tests

Collapse settings key permission maps into one source of truth, and cover mixed PATCH atomicity plus image-update enabled writes.

* fix(rbac): tighten Settings scoped grants and CI assertions

Empty settings PATCH fails closed, node:manage is scoped to the active
node, system-only Settings stay hidden without system:settings, and
Check updates / webhooks mutate gates follow the permission matrix.

* fix(rbac): defer Settings section fallback until authz is ready

Keep deep links to permission-gated sections (e.g. license) intact while
can() is still fail-closed during permission metadata load.

* feat: expose Community audit log via system:audit navigation

Gate the Audit view on the system:audit permission instead of paid tier,
so Community admins can open the existing 14-day recent-activity window.
Export, anomaly flags, and stats remain Admiral-only.

* test: clarify synthetic Community admin mock lacks system:audit

Document that mockCommunityAdmin is a gate-isolation helper, not the
real Admin permission matrix where system:audit is always present.
This commit is contained in:
Anso
2026-07-30 12:50:55 -04:00
committed by GitHub
parent a3026f47a8
commit a1e2846d7d
9 changed files with 256 additions and 134 deletions
@@ -21,55 +21,58 @@ function mockActiveNode(type: 'local' | 'remote' | null) {
} as unknown as ReturnType<typeof NodeContext.useNodes>);
}
// A community non-admin user with node:read (e.g. a viewer): sees Fleet, no
// admin-only items.
function mockCommunityUser() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: false,
can: (p: string) => p === 'node:read',
permissionsStatus: 'ready',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
function mockLicense(isPaid: boolean, licenseStatus: 'ready' | 'loading' | 'error' = 'ready') {
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
licenseStatus: 'ready',
isPaid,
licenseStatus,
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
// A deployer: stack permissions but no node:read, so no Fleet affordance.
function mockDeployer() {
function mockAuth(
isAdmin: boolean,
can: (p: string) => boolean,
permissionsStatus: 'ready' | 'loading' | 'error' = 'ready',
) {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: false,
can: (p: string) => p === 'stack:read' || p === 'stack:deploy',
permissionsStatus: 'ready',
isAdmin,
can,
permissionsStatus,
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
licenseStatus: 'ready',
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
}
// Community non-admin with node:read (viewer): Fleet yes, admin-only no.
function mockCommunityUser() {
mockAuth(false, (p) => p === 'node:read');
mockLicense(false);
}
// Deployer: stack permissions but no node:read, so no Fleet affordance.
function mockDeployer() {
mockAuth(false, (p) => p === 'stack:read' || p === 'stack:deploy');
mockLicense(false);
}
function mockPaidAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: (p: string) => p === 'system:audit' || p === 'system:console' || p === 'node:read',
permissionsStatus: 'ready',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: true,
licenseStatus: 'ready',
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
mockAuth(
true,
(p) => p === 'system:audit' || p === 'system:console' || p === 'node:read',
);
mockLicense(true);
}
// Synthetic gate-isolation helper: omits system:audit so tests can assert the
// Audit hide path. Real Admin always includes system:audit in the permission matrix.
function mockCommunityAdmin() {
vi.mocked(AuthContext.useAuth).mockReturnValue({
isAdmin: true,
can: (p: string) => p === 'system:console' || p === 'node:read',
permissionsStatus: 'ready',
} as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(LicenseContext.useLicense).mockReturnValue({
isPaid: false,
licenseStatus: 'ready',
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
mockAuth(true, (p) => p === 'system:console' || p === 'node:read');
mockLicense(false);
}
function mockCommunityAdminWithAudit() {
mockAuth(
true,
(p) => p === 'system:audit' || p === 'system:console' || p === 'node:read',
);
mockLicense(false);
}
describe('useViewNavigationState', () => {
@@ -269,7 +272,7 @@ describe('useViewNavigationState', () => {
expect(result.current.navItems.map(i => i.value)).toContain('global-observability');
});
it('shows Update, Schedules, and Console for a community admin; Audit stays paid', () => {
it('hides Audit for a community admin without system:audit', () => {
mockCommunityAdmin();
const { result } = renderHook(() => useViewNavigationState());
const values = result.current.navItems.map(i => i.value);
@@ -281,6 +284,55 @@ describe('useViewNavigationState', () => {
expect(result.current.navItems.find(i => i.value === 'auto-updates')?.label).toBe('Update');
});
it('shows Audit for a community admin with system:audit and keeps deep-links', () => {
mockCommunityAdminWithAudit();
const onNavigateToDashboard = vi.fn();
const { result } = renderHook(() =>
useViewNavigationState({ onNavigateToDashboard }),
);
expect(result.current.navItems.map((i) => i.value)).toContain('audit-log');
act(() => {
window.dispatchEvent(
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'audit-log' } }),
);
});
expect(result.current.activeView).toBe('audit-log');
expect(onNavigateToDashboard).not.toHaveBeenCalled();
});
it('does not normalize audit-log away while permissions are still loading', () => {
mockAuth(true, () => false, 'loading');
mockLicense(false);
const onNavigateToDashboard = vi.fn();
const { result } = renderHook(() =>
useViewNavigationState({ onNavigateToDashboard }),
);
act(() => {
window.dispatchEvent(
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'audit-log' } }),
);
});
expect(result.current.activeView).toBe('audit-log');
expect(onNavigateToDashboard).not.toHaveBeenCalled();
});
it('redirects a user without system:audit off the Audit view reached via a deep-link event', () => {
const onNavigateToDashboard = vi.fn();
mockCommunityAdmin();
const { result } = renderHook(() =>
useViewNavigationState({ onNavigateToDashboard }),
);
act(() => {
window.dispatchEvent(
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'audit-log' } }),
);
});
expect(result.current.activeView).toBe('dashboard');
expect(onNavigateToDashboard).toHaveBeenCalled();
});
it('redirects a non-admin off the Logs view when reached via a deep-link event', () => {
const onNavigateToDashboard = vi.fn();
// Community (non-admin) is the beforeEach default.
@@ -78,30 +78,34 @@ describe('buildNavigationModel', () => {
});
it('includes Console for system:console regardless of experimental discovery', () => {
expect(
buildNavigationModel(makeCtx({
experimentalReady: true,
experimental: false,
isPaid: false,
can: (a) => a === 'system:console' || a === 'node:read',
}))
.allPageItems.map((i) => i.value),
).toContain('host-console');
expect(
buildNavigationModel(makeCtx({
experimentalReady: false,
experimental: false,
can: (a) => a === 'system:console' || a === 'node:read',
}))
.allPageItems.map((i) => i.value),
).toContain('host-console');
const canConsole = (a: string) => a === 'system:console' || a === 'node:read';
for (const experimentalReady of [true, false]) {
const values = buildNavigationModel(
makeCtx({ experimentalReady, experimental: false, isPaid: false, can: canConsole }),
).allPageItems.map((i) => i.value);
expect(values).toContain('host-console');
}
});
it('includes Audit for system:audit on Community', () => {
const values = buildNavigationModel(
makeCtx({ isPaid: false, can: (a) => a === 'system:audit' || a === 'node:read' }),
).allPageItems.map((i) => i.value);
expect(values).toContain('audit-log');
});
it('omits Audit without system:audit', () => {
const values = buildNavigationModel(
makeCtx({ isPaid: true, can: (a) => a === 'node:read' }),
).allPageItems.map((i) => i.value);
expect(values).not.toContain('audit-log');
});
it('omits Console without system:console', () => {
expect(
buildNavigationModel(makeCtx({ can: () => false, isAdmin: false }))
.allPageItems.map((i) => i.value),
).not.toContain('host-console');
const values = buildNavigationModel(
makeCtx({ can: () => false, isAdmin: false }),
).allPageItems.map((i) => i.value);
expect(values).not.toContain('host-console');
});
it('excludes hidden views from quick-link candidates', () => {
+23 -13
View File
@@ -25,13 +25,15 @@ function ctx(over: Partial<ReachabilityContext> = {}): ReachabilityContext {
}
describe('reachability', () => {
it('does not hide views while authz is loading', () => {
const loading = ctx({ permissionsStatus: 'loading' });
it('does not hide views while authz is loading or failed', () => {
const loading = ctx({
permissionsStatus: 'loading',
can: () => false,
isPaid: false,
});
expect(authzReady(loading)).toBe(false);
expect(isViewHidden('audit-log', loading)).toBe(false);
});
it('keeps deep links stable when permission metadata fails', () => {
const failed = ctx({ permissionsStatus: 'error', can: () => false, isAdmin: false });
expect(authzReady(failed)).toBe(false);
expect(isViewHidden('fleet', failed)).toBe(false);
@@ -51,24 +53,20 @@ describe('reachability', () => {
expect(isViewHidden('scheduled-ops', viewer)).toBe(true);
});
it('hides fleet without node:read when ready', () => {
const noFleet = ctx({ can: () => false });
expect(isViewHidden('fleet', noFleet)).toBe(true);
expect(isViewHidden('networking', noFleet)).toBe(true);
it('hides fleet and networking without node:read when ready', () => {
const noNodeRead = ctx({ can: () => false });
expect(isViewHidden('fleet', noNodeRead)).toBe(true);
expect(isViewHidden('networking', noNodeRead)).toBe(true);
});
it('preserves host-console when authz is not ready', () => {
it('gates host-console on system:console only (any tier, any experimental state)', () => {
const licenseError = ctx({ licenseStatus: 'error', can: (a) => a === 'system:console' });
expect(isViewHidden('host-console', licenseError)).toBe(false);
});
it('hides host-console without system:console when ready', () => {
const noConsole = ctx({ can: () => false, isPaid: false, experimental: false });
expect(isViewHidden('host-console', noConsole)).toBe(true);
expect(normalizeHiddenView('host-console', noConsole)).toBe('dashboard');
});
it('keeps host-console for system:console regardless of tier or experimental', () => {
const community = ctx({
isPaid: false,
experimental: false,
@@ -78,6 +76,18 @@ describe('reachability', () => {
expect(isViewHidden('host-console', community)).toBe(false);
});
it('gates audit-log on system:audit only (Community and paid)', () => {
expect(
isViewHidden('audit-log', ctx({ isPaid: false, can: (a) => a === 'system:audit' })),
).toBe(false);
const noAuditCommunity = ctx({ isPaid: false, can: () => false });
expect(isViewHidden('audit-log', noAuditCommunity)).toBe(true);
expect(normalizeHiddenView('audit-log', noAuditCommunity)).toBe('dashboard');
expect(isViewHidden('audit-log', ctx({ isPaid: true, can: () => false }))).toBe(true);
});
it('hides routing and secrets fleet tabs only after experimentalReady when off', () => {
const loading = ctx({ experimental: false, experimentalReady: false });
expect(isFleetTabHidden('routing', loading)).toBe(false);
+9 -11
View File
@@ -39,18 +39,16 @@ export function experimentalDiscoveryReady(ctx: ReachabilityContext): boolean {
export function isViewHidden(view: ActiveView, ctx: ReachabilityContext): boolean {
if (!authzReady(ctx)) return false;
if (ctx.isRemote && HUB_ONLY_VIEWS.has(view)) return true;
if (!ctx.isAdmin && view === 'global-observability') return true;
if (!ctx.isAdmin && (view === 'auto-updates' || view === 'scheduled-ops')) return true;
if (!ctx.can('node:read') && view === 'fleet') return true;
if (!ctx.can('node:read') && view === 'networking') return true;
if (view === 'host-console') {
return !ctx.can('system:console');
}
if (!ctx.isPaid) {
if (view === 'audit-log') return true;
} else {
if (view === 'audit-log' && !ctx.can('system:audit')) return true;
if (
!ctx.isAdmin &&
(view === 'global-observability' || view === 'auto-updates' || view === 'scheduled-ops')
) {
return true;
}
if (!ctx.can('node:read') && (view === 'fleet' || view === 'networking')) return true;
if (view === 'host-console') return !ctx.can('system:console');
// Permission-driven on Community and Admiral (14-day window vs paid depth is in-view).
if (view === 'audit-log') return !ctx.can('system:audit');
return false;
}