From 771ddf6090b8a06c7500b45015057a234ed82c38 Mon Sep 17 00:00:00 2001 From: jbob0 Date: Fri, 25 Sep 2026 22:46:47 -0400 Subject: [PATCH] Audit: an API key over every server of its org sees the org's list changes An org ban or reserve list change is logged on the org with no server (serverId null), and auditVisibility gives an API key no owned orgs, so no key sees one, whatever its scope. A key that reads the audit trail of every server in the org, which is what an integration watching the whole org holds, missed exactly the changes those servers all enforce: an org-wide ban reached every server and never reached the key's audit read. A key with audit.read held to every server of its org (serverIds null) now also sees that org's list-entry changes: list.add, list.remove, list.update, list.import and list.expire (ORG_LIST_ROWS). Nothing else logged on the org: member grants, roles, keys, webhooks and list.members stay the owners'. A key held to some servers, a key without audit.read, a key of a suspended org, and every person's visibility are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011wkaN3kHsCQGhxCxeAqERQ --- src/lib/server/access.ts | 18 ++++++++++---- src/lib/server/audit-rows.ts | 14 +++++++++++ src/lib/server/audit.ts | 15 +++++++++++- src/test/access.test.ts | 46 ++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 5 deletions(-) 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]', {