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>
65 lines
2.5 KiB
TypeScript
65 lines
2.5 KiB
TypeScript
import type { Context } from "koa";
|
|
import type { BackendDbInterface } from "../db/types/DbInterface.js";
|
|
import type { ClientUser } from "../db/types/User.js";
|
|
import type { ExportFilter } from "../db/types/ExportImport.js";
|
|
|
|
/**
|
|
* Compute the export scope that must be *forced* on a caller, independent of
|
|
* anything they requested. Returns `null` when the caller is unrestricted (a
|
|
* global admin whose credential imposes no narrowing) — their own filter, if
|
|
* any, is then honored verbatim as a voluntary narrowing.
|
|
*
|
|
* 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,
|
|
ctx: { user: ClientUser; session: NonNullable<Context["session"]> },
|
|
): Promise<ExportFilter | null> {
|
|
const { user, session } = ctx;
|
|
|
|
if (user.flags.admin) {
|
|
if (session.source !== "apikey") return null;
|
|
const p = session.key.permissions;
|
|
const unrestricted =
|
|
!!p.admin &&
|
|
!!p.all &&
|
|
!p.restrict_users?.length &&
|
|
!p.restrict_abodes?.length;
|
|
if (unrestricted) return null;
|
|
}
|
|
|
|
const residencies = await db.listResidentsByUserId(user.uid);
|
|
const abodeSet = new Set(residencies.map((r) => r.aid));
|
|
const userSet = new Set<string>([user.uid]);
|
|
for (const aid of abodeSet) {
|
|
for (const u of await db.listUsersByAbodeId(aid)) userSet.add(u.uid);
|
|
}
|
|
|
|
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") {
|
|
const p = session.key.permissions;
|
|
if (p.restrict_abodes?.length) {
|
|
const allow = new Set(p.restrict_abodes);
|
|
abodes = abodes.filter((a) => allow.has(a));
|
|
}
|
|
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, apikeys };
|
|
}
|