diff --git a/docs/analytics/ARCHITECTURE.md b/docs/analytics/ARCHITECTURE.md index efc7ad26..0db1d483 100644 --- a/docs/analytics/ARCHITECTURE.md +++ b/docs/analytics/ARCHITECTURE.md @@ -17,9 +17,10 @@ would create two browser pipelines and competing session definitions. The collec single strict contract, stores raw facts append-only and remains smaller than the Umami tracker. Metabase OSS cannot reuse the existing Account/Staff authorization or provide the required -per-person access/export audit without a second identity system or paid SSO. The Admin surface in -Live queries only the analytics mart through a read-only role, reuses Staff `ADMIN` plus explicit -`ANALYTICS_VIEWER` / `ANALYTICS_EXPORTER` grants, and records every detail view and export. It therefore has a narrower +per-person access/export audit without a second identity system or paid SSO. The Staff surface in +Live queries only the analytics mart through a read-only role, grants view access to every authenticated +Staff role, restricts exports to `ADMIN` or an explicit `ANALYTICS_EXPORTER` grant, and records every +detail view and export. It therefore has a narrower attack surface and clearer ownership than an embedded BI instance. ## Topology diff --git a/docs/analytics/DATA_GOVERNANCE.md b/docs/analytics/DATA_GOVERNANCE.md index ad3b2b92..31410dae 100644 --- a/docs/analytics/DATA_GOVERNANCE.md +++ b/docs/analytics/DATA_GOVERNANCE.md @@ -47,7 +47,7 @@ browser analytics in a consent-required territory. ## Access and data-subject operations -- Live `ADMIN` and explicit `ANALYTICS_VIEWER` roles may view the dashboard. +- Every authenticated Live staff role may view the dashboard. - `ADMIN` and `ANALYTICS_EXPORTER` may export. Every view/export records actor, role, filter digest and exported row count; the audit never stores the result set. - A correction or access request resolves the person in Account, derives the privileged HMAC diff --git a/docs/analytics/RUNBOOK.md b/docs/analytics/RUNBOOK.md index 3988a1d8..866a18d4 100644 --- a/docs/analytics/RUNBOOK.md +++ b/docs/analytics/RUNBOOK.md @@ -21,7 +21,8 @@ The authoritative data-class, purpose, jurisdiction and owner matrix is - Network digests: 30 days; advertising click IDs: 90 days; raw browser events: 180 days. - Canonical account, listening, attendance, membership and payment facts: retained while needed for operations, financial/legal obligations and longitudinal product analysis. -- Dashboard: Live `ADMIN` or an explicit `AnalyticsRoleGrant`; CSV requires EXPORTER or ADMIN. +- Dashboard: every authenticated Live staff role has view access; CSV requires an active + `EXPORTER` grant or `ADMIN`. - Every view/export appends an opaque actor, role, filters digest and row count to `audit`. - Passwords, auth tokens, secrets, signed URLs, payment numbers, chat/media content and form values are prohibited by the contract and rejected before storage. diff --git a/src/lib/__tests__/analytics-access.test.ts b/src/lib/__tests__/analytics-access.test.ts new file mode 100644 index 00000000..93a0b482 --- /dev/null +++ b/src/lib/__tests__/analytics-access.test.ts @@ -0,0 +1,57 @@ +import type { StaffRole } from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + findUnique: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: { + analyticsRoleGrant: { + findUnique: mocks.findUnique, + }, + }, +})); + +import { effectiveAnalyticsRole } from '@/lib/analytics-access'; +import type { StaffPrincipal } from '@/lib/ops-auth'; + +function staff(role: StaffRole): StaffPrincipal { + return { + id: `staff-${role.toLowerCase()}`, + email: `${role.toLowerCase()}@harmonicbeacon.com`, + name: role, + role, + }; +} + +describe('effectiveAnalyticsRole', () => { + beforeEach(() => { + mocks.findUnique.mockReset(); + mocks.findUnique.mockResolvedValue(null); + }); + + it.each(['FACILITATOR', 'FACILITATOR_OP', 'OPERATOR'] as const)( + 'allows %s staff to view analytics without an explicit grant', + async role => { + await expect(effectiveAnalyticsRole(staff(role))).resolves.toBe('ANALYTICS_VIEWER'); + }, + ); + + it('keeps ADMIN access and export authority without querying grants', async () => { + await expect(effectiveAnalyticsRole(staff('ADMIN'))).resolves.toBe('ADMIN'); + expect(mocks.findUnique).not.toHaveBeenCalled(); + }); + + it('upgrades staff with an active EXPORTER grant', async () => { + mocks.findUnique.mockResolvedValue({ role: 'EXPORTER', revokedAt: null }); + + await expect(effectiveAnalyticsRole(staff('OPERATOR'))).resolves.toBe('ANALYTICS_EXPORTER'); + }); + + it('keeps staff view-only when an EXPORTER grant is revoked', async () => { + mocks.findUnique.mockResolvedValue({ role: 'EXPORTER', revokedAt: new Date() }); + + await expect(effectiveAnalyticsRole(staff('FACILITATOR'))).resolves.toBe('ANALYTICS_VIEWER'); + }); +}); diff --git a/src/lib/analytics-access.ts b/src/lib/analytics-access.ts index 2bdda889..7394875b 100644 --- a/src/lib/analytics-access.ts +++ b/src/lib/analytics-access.ts @@ -10,10 +10,13 @@ export async function effectiveAnalyticsRole(staff: StaffPrincipal): Promise; } }).analyticsRoleGrant; - if (!delegate) return null; - const grant = await delegate.findUnique({ where: { staffUserId: staff.id } }); - if (!grant || grant.revokedAt) return null; - return grant.role === 'EXPORTER' ? 'ANALYTICS_EXPORTER' : 'ANALYTICS_VIEWER'; + const grant = delegate + ? await delegate.findUnique({ where: { staffUserId: staff.id } }) + : null; + if (grant && !grant.revokedAt && grant.role === 'EXPORTER') { + return 'ANALYTICS_EXPORTER'; + } + return 'ANALYTICS_VIEWER'; } function required(name: string): string {