diff --git a/src/lib/server/access.ts b/src/lib/server/access.ts index a18bdc19..eb804218 100644 --- a/src/lib/server/access.ts +++ b/src/lib/server/access.ts @@ -16,6 +16,7 @@ import { type Capability } from '../capabilities'; import type { ListKind } from '../types'; +import type { AuditVisibility } from './audit'; import { accessFromCaps, resolveAccess, type ServerAccess } from './access-resolve'; import { keyActorId, keyActorName, keyCoversServer, type ApiKeyPrincipal } from './apikeys-core'; import { @@ -546,14 +547,23 @@ export async function accessibleServers( * What a non-site-owner may see in the audit log: their own rows, rows on servers where their * role includes audit.read (plus everything in orgs they own), and rows of orgs they own. */ -export async function auditVisibility( - env: Env, - user: SessionUser -): Promise<{ userId: string; adminServerIds: string[]; ownedOrgIds: string[] } | null> { +export async function auditVisibility(env: Env, user: SessionUser): Promise { if (user.apiKey) { const covered = user.apiKey.capabilities.includes('audit.read') ? (await accessibleServers(env, user)).map((s) => s.id) : []; + // A key held to every server of its org (serverIds null) also sees that org's list-entry + // changes: they are logged on the org with no server, and every one of those servers + // enforces them, so without this a key watching the whole org misses its org-wide bans. + // Nothing else logged on the org: member, role, key and webhook rows stay the owners'. + // `covered` is empty for a suspended org, which closes this with the rest. + if (user.apiKey.serverIds === null && covered.length) + return { + userId: user.id, + adminServerIds: covered, + ownedOrgIds: [], + listOrgIds: [user.apiKey.orgId] + }; return { userId: user.id, adminServerIds: covered, ownedOrgIds: [] }; } if (user.role === 'owner') return null; diff --git a/src/lib/server/audit-rows.ts b/src/lib/server/audit-rows.ts index 7f8d6c16..8d11a38d 100644 --- a/src/lib/server/audit-rows.ts +++ b/src/lib/server/audit-rows.ts @@ -4,3 +4,17 @@ * Discord channel gets the action and the server's name only. */ export const OWNERS_ROWS = ['server.create', 'server.update', 'server.delete']; + +/** + * Changes to an org's ban or reserve list entries. An org list is enforced on every server of the + * org, so an API key that reads the audit trail of every one of them sees these too (see + * access.ts auditVisibility). Who may edit the lists (`list.members`) is the owners' business and + * stays out. + */ +export const ORG_LIST_ROWS = [ + 'list.add', + 'list.remove', + 'list.update', + 'list.import', + 'list.expire' +]; diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index 1e279102..4ec9f733 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -5,6 +5,7 @@ import { gte, inArray, isNotNull, + isNull, like, lt, lte, @@ -16,7 +17,7 @@ import type { Env } from './env'; import { userAgent, int, str } from './http'; import { auditLog, user, type AuditRow } from './db/schema'; import { notifyWebhooks } from './webhook-delivery'; -import { OWNERS_ROWS } from './audit-rows'; +import { ORG_LIST_ROWS, OWNERS_ROWS } from './audit-rows'; export type { AuditRow }; export type Outcome = 'ok' | 'error' | 'denied'; @@ -105,6 +106,9 @@ export type AuditVisibility = { userId: string; adminServerIds: string[]; ownedOrgIds: string[]; + /** orgs whose list-entry changes (ORG_LIST_ROWS) the caller also sees: an API key's own org, + * when it reads the audit trail of every server in it */ + listOrgIds?: string[]; } | null; /** @@ -125,6 +129,15 @@ function visibleWhere(v: AuditVisibility | undefined): SQL | undefined { and(inArray(auditLog.serverId, v.adminServerIds), notInArray(auditLog.action, OWNERS_ROWS))! ); if (v.ownedOrgIds.length) any.push(inArray(auditLog.orgId, v.ownedOrgIds)); + // An org list change is logged on the org, with no server, though every server enforces it. + if (v.listOrgIds?.length) + any.push( + and( + inArray(auditLog.orgId, v.listOrgIds), + isNull(auditLog.serverId), + inArray(auditLog.action, ORG_LIST_ROWS) + )! + ); return or(...any)!; } diff --git a/src/test/access.test.ts b/src/test/access.test.ts index 3206235f..7b9005a3 100644 --- a/src/test/access.test.ts +++ b/src/test/access.test.ts @@ -119,6 +119,52 @@ describe.skipIf(!hasTestDb)('access', () => { ]); }); + test("a key over every server sees its org's list changes, and nothing else logged on the org", async () => { + const w = await seedWorld(env); + const row = (orgId: string, action: string, serverId: string | null = null) => ({ + actorId: w.users.admin!.id, + actorName: 'admin', + serverId, + orgId, + category: serverId ? ('server' as const) : ('org' as const), + action, + target: '76561198000000099', + outcome: 'ok' as const + }); + await env.db + .insert(auditLog) + .values([ + row(w.org.id, 'list.add'), + row(w.org.id, 'list.expire'), + row(w.org.id, 'list.members'), + row(w.org.id, 'org.member.grants'), + row(w.org.id, 'rcon.kick', w.server.id), + row(w.otherOrg.id, 'list.add') + ]); + const seenBy = async (who: PrincipalName) => + ( + await queryAudit(env, { + target: '76561198000000099', + visibleTo: await auditVisibility(env, w.users[who]!) + }) + ).entries + .map((e) => `${e.orgId === w.org.id ? 'ours' : 'theirs'}:${e.action}`) + .sort(); + // every server, audit.read: the org's list changes and the server's own rows + expect(await seenBy('keyAll')).toEqual([ + 'ours:list.add', + 'ours:list.expire', + 'ours:rcon.kick' + ]); + // one server only: that server's rows, never the org's (keyElsewhere holds otherServer) + expect(await seenBy('keyElsewhere')).toEqual([]); + // no audit.read: nothing + expect(await seenBy('keyView')).toEqual([]); + // people are unchanged: an admin of the server sees its rows, the owner the whole org + expect(await seenBy('admin')).toContain('ours:org.member.grants'); // their own row + expect((await auditVisibility(env, w.users.admin!))?.listOrgIds).toBeUndefined(); + }); + test('Audit trail on a server leaves out the owners editing it: those rows say where RCON listens', async () => { const w = await seedWorld(env); const edited = await api(w, 'owner', 'PATCH api/servers/[id]', {