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.
|
* Whether an individual record passes a filter's `abodes`/`users`/`apikeys`
|
||||||
* `abodes` scopes abode/resident/note (by aid); `users` scopes user/apikey
|
* allowlists. `abodes` scopes abode/resident/note (by aid); `users` scopes user
|
||||||
* (by uid). An absent allowlist means "unrestricted".
|
* (by uid); `apikey` records are scoped by `apikeys` when present, else by
|
||||||
|
* `users`. An absent allowlist means "unrestricted".
|
||||||
*/
|
*/
|
||||||
export function recordAllowed(
|
export function recordAllowed(
|
||||||
filter: ExportFilter | undefined,
|
filter: ExportFilter | undefined,
|
||||||
@@ -36,8 +37,11 @@ export function recordAllowed(
|
|||||||
if (!filter) return true;
|
if (!filter) return true;
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "user":
|
case "user":
|
||||||
case "apikey":
|
|
||||||
return !filter.users || filter.users.includes(record.uid as string);
|
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 "abode":
|
||||||
case "resident":
|
case "resident":
|
||||||
case "note":
|
case "note":
|
||||||
@@ -75,5 +79,6 @@ export function intersectExportFilters(
|
|||||||
excludeKinds: unionList(a.excludeKinds, b.excludeKinds),
|
excludeKinds: unionList(a.excludeKinds, b.excludeKinds),
|
||||||
abodes: intersectList(a.abodes, b.abodes),
|
abodes: intersectList(a.abodes, b.abodes),
|
||||||
users: intersectList(a.users, b.users),
|
users: intersectList(a.users, b.users),
|
||||||
|
apikeys: intersectList(a.apikeys, b.apikeys),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,16 @@ export type ExportFilter = {
|
|||||||
excludeKinds?: ExportKind[];
|
excludeKinds?: ExportKind[];
|
||||||
/** aid allowlist — scopes abode/resident/note. */
|
/** aid allowlist — scopes abode/resident/note. */
|
||||||
abodes?: string[];
|
abodes?: string[];
|
||||||
/** uid allowlist — scopes user/apikey. */
|
/** uid allowlist — scopes user (and apikey, unless `apikeys` is set). */
|
||||||
users?: string[];
|
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 {
|
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
|
* global admin whose credential imposes no narrowing) — their own filter, if
|
||||||
* any, is then honored verbatim as a voluntary narrowing.
|
* any, is then honored verbatim as a voluntary narrowing.
|
||||||
*
|
*
|
||||||
* Otherwise returns `{ abodes, users }`: the abodes the caller resides in, and
|
* Otherwise returns `{ abodes, users, apikeys }`: the abodes the caller resides
|
||||||
* the users needed to keep that data referentially whole (the caller plus every
|
* in, the users needed to keep that data referentially whole (the caller plus
|
||||||
* co-resident of those abodes). This is the maximum a non-admin may export; the
|
* every co-resident of those abodes), and — scoped tighter than `users` —
|
||||||
* route intersects it with any caller-supplied filter (never a union).
|
* 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(
|
export async function computeForcedExportFilter(
|
||||||
db: BackendDbInterface,
|
db: BackendDbInterface,
|
||||||
@@ -40,6 +43,8 @@ export async function computeForcedExportFilter(
|
|||||||
|
|
||||||
let abodes = [...abodeSet];
|
let abodes = [...abodeSet];
|
||||||
let users = [...userSet];
|
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.
|
// An apikey can only narrow what its owning user could otherwise export.
|
||||||
if (session.source === "apikey") {
|
if (session.source === "apikey") {
|
||||||
@@ -51,8 +56,9 @@ export async function computeForcedExportFilter(
|
|||||||
if (p.restrict_users?.length) {
|
if (p.restrict_users?.length) {
|
||||||
const allow = new Set(p.restrict_users);
|
const allow = new Set(p.restrict_users);
|
||||||
users = users.filter((u) => allow.has(u));
|
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: {},
|
permissions: {},
|
||||||
expires_at: null,
|
expires_at: null,
|
||||||
});
|
});
|
||||||
|
await db.createApikey({
|
||||||
|
uid: co.uid,
|
||||||
|
name: "co key",
|
||||||
|
permissions: {},
|
||||||
|
expires_at: null,
|
||||||
|
});
|
||||||
const note1 = await db.createNote(
|
const note1 = await db.createNote(
|
||||||
{
|
{
|
||||||
aid: abode1.aid,
|
aid: abode1.aid,
|
||||||
@@ -312,6 +318,9 @@ describe("exportScope: computeForcedExportFilter", () => {
|
|||||||
new Set([s.normal.uid, s.co.uid]),
|
new Set([s.normal.uid, s.co.uid]),
|
||||||
);
|
);
|
||||||
assert.ok(!forced!.users!.includes(s.admin.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.
|
// A caller requesting a wider abode never gets it: intersection, not union.
|
||||||
const effective = intersectExportFilters(
|
const effective = intersectExportFilters(
|
||||||
@@ -557,6 +566,17 @@ describe("GET /export endpoint", () => {
|
|||||||
assert.ok(userUids.has(s.co.uid));
|
assert.ok(userUids.has(s.co.uid));
|
||||||
assert.ok(!userUids.has(s.admin.uid), "admin not a co-resident of aid1");
|
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.
|
// The meta line records the *effective* (narrowed) filter.
|
||||||
const meta = lines.find((l) => l.kind === "meta");
|
const meta = lines.find((l) => l.kind === "meta");
|
||||||
assert.ok(meta);
|
assert.ok(meta);
|
||||||
|
|||||||
Reference in New Issue
Block a user