Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/analytics/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/analytics/DATA_GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/analytics/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions src/lib/__tests__/analytics-access.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
11 changes: 7 additions & 4 deletions src/lib/analytics-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ export async function effectiveAnalyticsRole(staff: StaffPrincipal): Promise<Eff
const delegate = (prisma as unknown as { analyticsRoleGrant?: {
findUnique(args: unknown): Promise<{ role: 'VIEWER' | 'EXPORTER'; revokedAt: Date | null } | null>;
} }).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 {
Expand Down
Loading