mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user