diff --git a/src/db/export/filter.ts b/src/db/export/filter.ts index 9f78890..23876bd 100644 --- a/src/db/export/filter.ts +++ b/src/db/export/filter.ts @@ -24,9 +24,10 @@ export function kindAllowed( } /** - * Whether an individual record passes a filter's `abodes`/`users` allowlists. - * `abodes` scopes abode/resident/note (by aid); `users` scopes user/apikey - * (by uid). An absent allowlist means "unrestricted". + * Whether an individual record passes a filter's `abodes`/`users`/`apikeys` + * allowlists. `abodes` scopes abode/resident/note (by aid); `users` scopes user + * (by uid); `apikey` records are scoped by `apikeys` when present, else by + * `users`. An absent allowlist means "unrestricted". */ export function recordAllowed( filter: ExportFilter | undefined, @@ -36,8 +37,11 @@ export function recordAllowed( if (!filter) return true; switch (kind) { case "user": - case "apikey": return !filter.users || filter.users.includes(record.uid as string); + case "apikey": { + const allow = filter.apikeys ?? filter.users; + return !allow || allow.includes(record.uid as string); + } case "abode": case "resident": case "note": @@ -75,5 +79,6 @@ export function intersectExportFilters( excludeKinds: unionList(a.excludeKinds, b.excludeKinds), abodes: intersectList(a.abodes, b.abodes), users: intersectList(a.users, b.users), + apikeys: intersectList(a.apikeys, b.apikeys), }; } diff --git a/src/db/types/ExportImport.ts b/src/db/types/ExportImport.ts index 9c600b2..e1afa39 100644 --- a/src/db/types/ExportImport.ts +++ b/src/db/types/ExportImport.ts @@ -15,8 +15,16 @@ export type ExportFilter = { excludeKinds?: ExportKind[]; /** aid allowlist — scopes abode/resident/note. */ abodes?: string[]; - /** uid allowlist — scopes user/apikey. */ + /** uid allowlist — scopes user (and apikey, unless `apikeys` is set). */ users?: string[]; + /** + * uid allowlist scoping apikey records specifically. When set it takes + * precedence over `users` for the `apikey` kind — used to export a + * non-admin's own keys while still exporting co-residents' *user* records + * for referential integrity, without leaking their apikey metadata. Absent = + * fall back to `users`. + */ + apikeys?: string[]; }; export interface ExportOptions { diff --git a/src/webapi/exportScope.ts b/src/webapi/exportScope.ts index aa79bc8..12e0b97 100644 --- a/src/webapi/exportScope.ts +++ b/src/webapi/exportScope.ts @@ -9,10 +9,13 @@ import type { ExportFilter } from "../db/types/ExportImport.js"; * global admin whose credential imposes no narrowing) — their own filter, if * any, is then honored verbatim as a voluntary narrowing. * - * Otherwise returns `{ abodes, users }`: the abodes the caller resides in, and - * the users needed to keep that data referentially whole (the caller plus every - * co-resident of those abodes). This is the maximum a non-admin may export; the - * route intersects it with any caller-supplied filter (never a union). + * Otherwise returns `{ abodes, users, apikeys }`: the abodes the caller resides + * in, the users needed to keep that data referentially whole (the caller plus + * every co-resident of those abodes), and — scoped tighter than `users` — + * apikeys limited to the caller alone, so a non-admin never exports another + * user's apikey metadata even though that user's record is included. This is + * the maximum a non-admin may export; the route intersects it with any + * caller-supplied filter (never a union). */ export async function computeForcedExportFilter( db: BackendDbInterface, @@ -40,6 +43,8 @@ export async function computeForcedExportFilter( let abodes = [...abodeSet]; let users = [...userSet]; + // apikeys are self-only for non-admins, regardless of co-residency. + let apikeys = [user.uid]; // An apikey can only narrow what its owning user could otherwise export. if (session.source === "apikey") { @@ -51,8 +56,9 @@ export async function computeForcedExportFilter( if (p.restrict_users?.length) { const allow = new Set(p.restrict_users); users = users.filter((u) => allow.has(u)); + apikeys = apikeys.filter((u) => allow.has(u)); } } - return { abodes, users }; + return { abodes, users, apikeys }; } diff --git a/test/tools/export-import.test.ts b/test/tools/export-import.test.ts index 359c529..c8bdaca 100644 --- a/test/tools/export-import.test.ts +++ b/test/tools/export-import.test.ts @@ -109,6 +109,12 @@ async function seed(db: TestDb["db"]): Promise { permissions: {}, expires_at: null, }); + await db.createApikey({ + uid: co.uid, + name: "co key", + permissions: {}, + expires_at: null, + }); const note1 = await db.createNote( { aid: abode1.aid, @@ -312,6 +318,9 @@ describe("exportScope: computeForcedExportFilter", () => { new Set([s.normal.uid, s.co.uid]), ); assert.ok(!forced!.users!.includes(s.admin.uid)); + // apikeys are self-only, even though co is a co-resident whose user + // record is exported for referential integrity. + assert.deepEqual(forced!.apikeys, [s.normal.uid]); // A caller requesting a wider abode never gets it: intersection, not union. const effective = intersectExportFilters( @@ -557,6 +566,17 @@ describe("GET /export endpoint", () => { assert.ok(userUids.has(s.co.uid)); assert.ok(!userUids.has(s.admin.uid), "admin not a co-resident of aid1"); + // apikeys are self-only: the caller's own key is exported, but a + // co-resident's key metadata is NOT, even though their user record is. + const apikeyUids = lines + .filter((l) => l.kind === "apikey") + .map((l) => l.data.uid); + assert.deepEqual(new Set(apikeyUids), new Set([s.normal.uid])); + assert.ok( + !apikeyUids.includes(s.co.uid), + "co-resident apikey metadata must not leak", + ); + // The meta line records the *effective* (narrowed) filter. const meta = lines.find((l) => l.kind === "meta"); assert.ok(meta);