fix: scope apikey export to the caller for non-admins
CI / format (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 23s
CI / install-and-build (pull_request) Successful in 46s
CI / typecheck-tests (pull_request) Successful in 30s
CI / typecheck-source (pull_request) Successful in 31s
CI / test (pull_request) Successful in 40s
CI / format (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 23s
CI / install-and-build (pull_request) Successful in 46s
CI / typecheck-tests (pull_request) Successful in 30s
CI / typecheck-source (pull_request) Successful in 31s
CI / test (pull_request) Successful in 40s
A non-admin's forced export filter includes co-resident *user* records so abode/resident data isn't left with dangling references, but the same `users` allowlist was also governing `apikey` records — leaking co-residents' apikey metadata (name/permissions/expiry, though not the secret token). Add a dedicated `apikeys` uid-allowlist to ExportFilter that scopes apikey records specifically, falling back to `users` when absent (so existing unfiltered/voluntary-narrowing behaviour and the round-trip are unchanged). computeForcedExportFilter now sets it to the caller alone (intersected with an apikey credential's restrict_users), so a non-admin can only ever export their own keys. Global admins (forced filter null) are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -109,6 +109,12 @@ async function seed(db: TestDb["db"]): Promise<Seed> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user