mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-04 16:07: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:
@@ -33,7 +33,7 @@ beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
// Mock LicenseService to return the paid tier for audit log access
|
||||
// Default suite tier is paid so stats/export/anomaly tests pass; Community cases override.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
|
||||
@@ -419,10 +419,14 @@ describe('DatabaseService audit methods', () => {
|
||||
|
||||
// ---- API endpoint tests ----
|
||||
|
||||
async function mockCommunityTier(): Promise<void> {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
}
|
||||
|
||||
describe('GET /api/audit-log', () => {
|
||||
it('returns 200 for a Community admin (recent-activity window, no tier gate)', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
await mockCommunityTier();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log')
|
||||
@@ -431,17 +435,32 @@ describe('GET /api/audit-log', () => {
|
||||
expect(Array.isArray(res.body.entries)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 403 for viewer role (no system:audit permission)', async () => {
|
||||
it('returns 200 for a Community auditor (system:audit without paid tier)', async () => {
|
||||
await mockCommunityTier();
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
db.addUser({ username: 'vieweraudit', password_hash: 'hash', role: 'viewer' });
|
||||
const viewerToken = authToken('vieweraudit', 'viewer');
|
||||
db.addUser({ username: 'communityauditor', password_hash: 'hash', role: 'auditor' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log')
|
||||
.set('Authorization', `Bearer ${viewerToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
.set('Authorization', `Bearer ${authToken('communityauditor', 'auditor')}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.entries)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['viewer', 'deployer', 'node-admin'] as const)(
|
||||
'returns 403 for %s role (no system:audit permission)',
|
||||
async (role) => {
|
||||
const username = `${role.replace(/-/g, '')}audit`;
|
||||
DatabaseService.getInstance().addUser({ username, password_hash: 'hash', role });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log')
|
||||
.set('Authorization', `Bearer ${authToken(username, role)}`);
|
||||
expect(res.status).toBe(403);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns paginated results for admin with correct structure', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log?page=1&limit=10')
|
||||
@@ -575,6 +594,15 @@ describe('GET /api/audit-log', () => {
|
||||
describe('GET /api/audit-log (Community recent-activity window)', () => {
|
||||
const windowUser = 'communitywindowuser';
|
||||
|
||||
async function communityWindowSearch(queryExtra = ''): Promise<string[]> {
|
||||
await mockCommunityTier();
|
||||
const res = await request(app)
|
||||
.get(`/api/audit-log?search=${windowUser}&limit=100${queryExtra}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
return res.body.entries.map((e: { summary: string }) => e.summary);
|
||||
}
|
||||
|
||||
it('clamps Community results to the last 14 days', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const now = Date.now();
|
||||
@@ -589,47 +617,26 @@ describe('GET /api/audit-log (Community recent-activity window)', () => {
|
||||
status_code: 200, node_id: null, ip_address: '127.0.0.1', summary: 'recent windowed entry',
|
||||
});
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
const res = await request(app)
|
||||
.get(`/api/audit-log?search=${windowUser}&limit=100`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const summaries = res.body.entries.map((e: { summary: string }) => e.summary);
|
||||
const summaries = await communityWindowSearch();
|
||||
expect(summaries).toContain('recent windowed entry');
|
||||
expect(summaries).not.toContain('old windowed entry');
|
||||
});
|
||||
|
||||
it('clamps even when a Community caller passes an explicit from older than the window', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
const explicitOldFrom = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
const res = await request(app)
|
||||
.get(`/api/audit-log?search=${windowUser}&from=${explicitOldFrom}&limit=100`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const summaries = res.body.entries.map((e: { summary: string }) => e.summary);
|
||||
const summaries = await communityWindowSearch(`&from=${explicitOldFrom}`);
|
||||
expect(summaries).toContain('recent windowed entry');
|
||||
expect(summaries).not.toContain('old windowed entry');
|
||||
});
|
||||
|
||||
it('does not let a non-numeric from lift the Community window clamp', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
const res = await request(app)
|
||||
.get(`/api/audit-log?search=${windowUser}&from=abc&limit=100`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const summaries = res.body.entries.map((e: { summary: string }) => e.summary);
|
||||
const summaries = await communityWindowSearch('&from=abc');
|
||||
expect(summaries).toContain('recent windowed entry');
|
||||
expect(summaries).not.toContain('old windowed entry');
|
||||
});
|
||||
|
||||
it('paid tier still sees entries older than the Community window', async () => {
|
||||
// The suite default mock is the paid tier (no clamp).
|
||||
// Suite default mock is paid (no clamp).
|
||||
const res = await request(app)
|
||||
.get(`/api/audit-log?search=${windowUser}&limit=100`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
@@ -640,8 +647,7 @@ describe('GET /api/audit-log (Community recent-activity window)', () => {
|
||||
});
|
||||
|
||||
it('does not annotate anomalies for Community even when with_anomalies=1', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
await mockCommunityTier();
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log?with_anomalies=1&limit=5')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
@@ -654,9 +660,20 @@ describe('GET /api/audit-log (Community recent-activity window)', () => {
|
||||
});
|
||||
|
||||
describe('GET /api/audit-log/stats', () => {
|
||||
it('returns 200 for an Admiral auditor (system:audit + paid)', async () => {
|
||||
DatabaseService.getInstance().addUser({
|
||||
username: 'admiralauditorstats', password_hash: 'hash', role: 'auditor',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/stats')
|
||||
.set('Authorization', `Bearer ${authToken('admiralauditorstats', 'auditor')}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('events_24h');
|
||||
});
|
||||
|
||||
it('returns 403 without a paid license', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
await mockCommunityTier();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/stats')
|
||||
@@ -665,6 +682,19 @@ describe('GET /api/audit-log/stats', () => {
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('returns 403 for a Community auditor (permission without paid)', async () => {
|
||||
await mockCommunityTier();
|
||||
DatabaseService.getInstance().addUser({
|
||||
username: 'communityauditorstats', password_hash: 'hash', role: 'auditor',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/stats')
|
||||
.set('Authorization', `Bearer ${authToken('communityauditorstats', 'auditor')}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('returns the four-tile stat structure for admin', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/stats')
|
||||
@@ -682,9 +712,20 @@ describe('GET /api/audit-log/stats', () => {
|
||||
});
|
||||
|
||||
describe('GET /api/audit-log/export', () => {
|
||||
it('returns 200 for an Admiral auditor (system:audit + paid)', async () => {
|
||||
DatabaseService.getInstance().addUser({
|
||||
username: 'admiralauditorexport', password_hash: 'hash', role: 'auditor',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/export?format=json')
|
||||
.set('Authorization', `Bearer ${authToken('admiralauditorexport', 'auditor')}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/json/);
|
||||
});
|
||||
|
||||
it('returns 403 without a paid license', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
await mockCommunityTier();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/export?format=json')
|
||||
@@ -693,6 +734,19 @@ describe('GET /api/audit-log/export', () => {
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('returns 403 for a Community auditor (permission without paid)', async () => {
|
||||
await mockCommunityTier();
|
||||
DatabaseService.getInstance().addUser({
|
||||
username: 'communityauditorexport', password_hash: 'hash', role: 'auditor',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/export?format=json')
|
||||
.set('Authorization', `Bearer ${authToken('communityauditorexport', 'auditor')}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('exports JSON with correct Content-Type', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/export?format=json')
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: Audit Log
|
||||
description: Track every mutating action on your Sencho instance with a searchable, exportable trail for team accountability.
|
||||
description: Track every mutating action on your Sencho instance with a searchable trail for team accountability; export and extended retention on Admiral.
|
||||
---
|
||||
|
||||
<Note>
|
||||
Community keeps a rolling 14-day recent-activity audit API window, but has no **Audit** tab in navigation to browse it. The Audit navigation view, CSV and JSON export, anomaly detection, and configurable retention beyond the recent window all require a Sencho **Admiral** license.
|
||||
Audit requires the `system:audit` permission (Admin, or Auditor on Admiral). Community shows the rolling 14-day recent-activity window. Admiral adds the 24h signal rail, CSV/JSON export, anomaly detection, and configurable retention beyond that window.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
@@ -66,21 +66,25 @@ Expanding a row in the Table view reveals additional detail:
|
||||
|
||||
## Viewing the audit log
|
||||
|
||||
The **Audit** view in navigation is an Admiral governance surface: the tab itself only appears on an Admiral license, and Community accounts have no navigation entry point to Audit at all, though the underlying recent-activity API stays reachable directly. On Admiral, the tab is further limited to users whose role grants the `system:audit` permission, which by default means **Admin** or **Auditor**.
|
||||
The **Audit** tab appears for any signed-in user whose role grants the `system:audit` permission (by default **Admin**, and **Auditor** when that role is available). Community shows the last 14 days of activity. Admiral shows the full retained history; retention defaults to 90 days and is configurable up to 365.
|
||||
|
||||
Navigate to the **Audit** tab in the top navigation when it is available for your role and plan. The feed then shows your full retained history rather than any fixed lookback window; retention defaults to 90 days and is configurable up to 365.
|
||||
Navigate to the **Audit** tab in the top navigation when it is available for your role.
|
||||
|
||||
The page has two views, toggled from the segmented control in the card header: **Stream** (default) and **Table**. The card subtitle reports the total number of entries that match the current filters.
|
||||
|
||||
### Stream view
|
||||
|
||||
Stream gives you an at-a-glance read on activity. A signal rail at the top summarizes the last 24 hours across four tiles, and the feed below groups entries by day with severity dots, relative times, and inline anomaly callouts.
|
||||
Stream gives you an at-a-glance read on activity. The feed groups entries by day with severity dots and relative times.
|
||||
|
||||
<Note>
|
||||
The 24h signal rail and inline anomaly callouts require a Sencho **Admiral** license.
|
||||
</Note>
|
||||
|
||||
<Frame>
|
||||
<img src="/images/audit-log/audit-stream.png" alt="Audit Log Stream view with the four-tile signal rail (Events 53 +218% vs 7d avg, Actors 2, Failure rate 0%, Peak hour 21:00) above a day-banded chronological feed of admin POST and DELETE entries, including a first seen anomaly flag on one entry." />
|
||||
</Frame>
|
||||
|
||||
**Signal rail tiles:**
|
||||
**Signal rail tiles (Admiral):**
|
||||
|
||||
| Tile | What it shows |
|
||||
|------|---------------|
|
||||
@@ -105,7 +109,7 @@ Table keeps the full-featured detail grid for power users: exact timestamps, met
|
||||
<img src="/images/audit-log/audit-log-expanded.png" alt="Audit Log Table view with the second row expanded to reveal Request Path /api/webhooks/4, IP Address ::ffff:203.0.113.10, Node ID 1, and Entry ID #2472 in a four-cell detail strip." />
|
||||
</Frame>
|
||||
|
||||
Both views share the **Refresh** button and the **Export** dropdown in the card header, and both paginate at 50 entries per page with chevron controls at the bottom of the feed or table.
|
||||
Both views share the **Refresh** button in the card header and paginate at 50 entries per page with chevron controls at the bottom of the feed or table. On Admiral, both views also share the **Export** dropdown.
|
||||
|
||||
## Anomaly detection
|
||||
|
||||
@@ -190,7 +194,7 @@ Sensitive database values (such as remote node API tokens) are encrypted at rest
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="The Audit tab is missing from the navigation">
|
||||
The Audit tab is an Admiral governance view: it only appears on an Admiral license, and on Community it is not shown at all, regardless of role. On Admiral, the tab is further limited to users whose role grants the `system:audit` permission, by default **Admin** or **Auditor**. If you are on Admiral but signed in as a Deployer or Viewer, ask an admin to assign you the Auditor role from **Settings · Users**. The recent-activity API stays reachable on Community even without a navigation entry point to it.
|
||||
The Audit tab appears only for users with the `system:audit` permission (by default **Admin**, or **Auditor** when that role is available). If you are signed in as a Viewer, Deployer, or Node Admin, ask an admin to grant you a role that includes audit access. On Community, Admin is the role that can open Audit; the Auditor role requires Admiral to assign.
|
||||
</Accordion>
|
||||
<Accordion title="Stream view shows everything but I want to filter to a specific user, action, or date">
|
||||
Filters live in **Table view only**. Toggle the segmented control in the card header from **Stream** to **Table** and the search box, method dropdown, and From / To date pickers will appear above the grid. Switching back to Stream clears the filter strip but does not remember the last filter.
|
||||
@@ -202,6 +206,6 @@ Sensitive database values (such as remote node API tokens) are encrypted at rest
|
||||
Each export is capped at 10,000 entries. If your filter selects more than that, narrow the date range using the **From** and **To** pickers and download in chunks. The cap protects the API from generating very large CSVs in a single response; for full archives, schedule periodic exports from your own tooling.
|
||||
</Accordion>
|
||||
<Accordion title="Old entries vanished even though I never deleted anything">
|
||||
Cleanup runs automatically against the **Audit log** retention value in **Settings · Operations · Data Retention** (default 90 days). Entries older than the configured window are pruned on the next maintenance tick. Increase the value (up to 365 days) before the next cleanup runs to retain a longer history; the change applies forward only and cannot bring back already-pruned entries.
|
||||
On Community, the list shows only the last 14 days of activity, so older rows are not returned even when they still exist in the database. On Admiral, cleanup also runs against the **Audit log** retention value in **Settings · Operations · Data Retention** (default 90 days). Entries older than the configured window are pruned on the next maintenance tick. Increase the value (up to 365 days) before the next cleanup runs to retain a longer history; the change applies forward only and cannot bring back already-pruned entries.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -26,7 +26,7 @@ The palette groups results into three sections.
|
||||
|
||||
| Group | What it contains | What happens when you pick one |
|
||||
|-------|------------------|--------------------------------|
|
||||
| **Pages** | The reachable page destinations for your tier and role (the same set Classic / Smart / mobile navigation use). **Home**, **Resources**, **Networking**, **Security**, and **App Store** appear for signed-in operators; **Fleet** appears when your role holds the `node:read` permission; **Logs**, **Update**, **Schedules**, and **Console** appear for admins; **Audit** appears on Admiral for any role with the `system:audit` permission. See [RBAC & User Management](/features/rbac) for the full permission matrix. | Navigates to that page |
|
||||
| **Pages** | The reachable page destinations for your tier and role (the same set Classic / Smart / mobile navigation use). **Home**, **Resources**, **Networking**, **Security**, and **App Store** appear for signed-in operators; **Fleet** appears when your role holds the `node:read` permission; **Logs**, **Update**, **Schedules**, and **Console** appear for admins; **Audit** appears for any role with the `system:audit` permission. See [RBAC & User Management](/features/rbac) for the full permission matrix. | Navigates to that page |
|
||||
| **Nodes** | Every node in your fleet, with a green dot for online and a grey dot for offline. The currently active node carries a small **ACTIVE** chip on the right. | Switches the active node without leaving the current page |
|
||||
| **Stacks** | Every compose stack on every online node, matched on the compose filename (extension included). | Switches to the stack's node and opens it in the editor |
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ Configure threshold-based alerts per stack and route notifications to Discord, S
|
||||
|
||||
### Audit log
|
||||
|
||||
Track mutating actions across your Sencho instance with a searchable trail: who deployed, stopped, deleted, or changed settings, with timestamps, user attribution, and node context. Community keeps a rolling 14-day recent-activity audit API window. The Audit navigation view, plus CSV/JSON export, anomaly detection, and configurable retention, is Admiral governance. [Learn more →](/features/audit-log)
|
||||
Track mutating actions across your Sencho instance with a searchable trail: who deployed, stopped, deleted, or changed settings, with timestamps, user attribution, and node context. Users with `system:audit` can open Audit from navigation. Community shows a rolling 14-day window; Admiral adds the 24h signal rail, export, anomaly detection, and configurable retention. [Learn more →](/features/audit-log)
|
||||
|
||||
## Fleet management
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ For user management and scoped permissions, see [RBAC & User Management](/featur
|
||||
|
||||
Every POST, PUT, DELETE, and PATCH request to the API is recorded in the audit log with the acting username, IP address, HTTP method, response status, and an auto-generated summary. GET requests are excluded to keep the log focused on mutations.
|
||||
|
||||
The audit log is searchable by keyword (actions, paths, usernames) and filterable by HTTP method and date range. The recent-activity log, scoped to the last 14 days, is available on every tier. With Admiral, results can be exported as CSV or JSON (up to 10,000 entries per export), entries carry anomaly annotations, and retention defaults to 90 days and is configurable from 1 to 365 days in **Settings · Operations · Data Retention**.
|
||||
The audit log is searchable by keyword (actions, paths, usernames) and filterable by HTTP method and date range. Users with the `system:audit` permission can open **Audit** from navigation. Community shows the last 14 days of activity; with Admiral, results can be exported as CSV or JSON (up to 10,000 entries per export), entries carry anomaly annotations, and retention defaults to 90 days and is configurable from 1 to 365 days in **Settings · Operations · Data Retention**.
|
||||
|
||||
The **Auditor** role provides read-only access to the audit log without any other administrative privileges, making it suitable for compliance reviewers who should not have access to system settings.
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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