mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user