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
18 changes: 14 additions & 4 deletions src/lib/server/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<AuditVisibility> {
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;
Expand Down
14 changes: 14 additions & 0 deletions src/lib/server/audit-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
];
15 changes: 14 additions & 1 deletion src/lib/server/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
gte,
inArray,
isNotNull,
isNull,
like,
lt,
lte,
Expand All @@ -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';
Expand Down Expand Up @@ -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;

/**
Expand All @@ -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)!;
}

Expand Down
46 changes: 46 additions & 0 deletions src/test/access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]', {
Expand Down
Loading