fix: restrict user email visibility

Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
This commit was merged in pull request #17.
This commit is contained in:
2026-07-23 23:18:59 +02:00
committed by codinget
co-authored by Codex
parent 1e10391e20
commit 0355bd0b2e
4 changed files with 232 additions and 5 deletions
+45 -4
View File
@@ -15,11 +15,20 @@ import {
updateuser, updateuser,
} from "../schema/validators.js"; } from "../schema/validators.js";
import { authenticate } from "./middleware/authenticate.js"; import { authenticate } from "./middleware/authenticate.js";
import { InvalidAbodeError, NotFoundAbodeError } from "../db/types/errors.js"; import {
InvalidAbodeError,
NotAuthorizedAbodeError,
NotFoundAbodeError,
} from "../db/types/errors.js";
import { isExportable } from "../db/types/ExportImport.js"; import { isExportable } from "../db/types/ExportImport.js";
import type { ExportFilter, ExportKind } from "../db/types/ExportImport.js"; import type { ExportFilter, ExportKind } from "../db/types/ExportImport.js";
import { intersectExportFilters, isExportKind } from "../db/export/filter.js"; import { intersectExportFilters, isExportKind } from "../db/export/filter.js";
import { computeForcedExportFilter } from "./exportScope.js"; import { computeForcedExportFilter } from "./exportScope.js";
import {
hasGlobalUserVisibility,
hideUserEmail,
userForCaller,
} from "./userVisibility.js";
function parseExportFilter(query: Record<string, unknown>): ExportFilter { function parseExportFilter(query: Record<string, unknown>): ExportFilter {
const list = (v: unknown): string[] | undefined => { const list = (v: unknown): string[] | undefined => {
@@ -92,17 +101,32 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
router.use("/users", authenticate(db)); router.use("/users", authenticate(db));
router.get("/users", async (ctx) => { router.get("/users", async (ctx) => {
ctx.body = await db.listUsers(); const users = await db.listUsers();
ctx.body = users.map((user) =>
userForCaller(user, { user: ctx.user!, session: ctx.session! }),
);
}); });
router.post("/users", jsonBody({ validate: createuser }), async (ctx) => { router.post("/users", jsonBody({ validate: createuser }), async (ctx) => {
ctx.body = await db.createUser(ctx.request.body); ctx.body = await db.createUser(ctx.request.body);
}); });
router.get("/users/by-email", async (ctx) => { router.get("/users/by-email", async (ctx) => {
if (typeof ctx.query.email !== "string") throw new InvalidAbodeError(); if (typeof ctx.query.email !== "string") throw new InvalidAbodeError();
if (
!hasGlobalUserVisibility({
user: ctx.user!,
session: ctx.session!,
})
) {
throw new NotAuthorizedAbodeError();
}
ctx.body = await db.getUserByEmail(ctx.query.email); ctx.body = await db.getUserByEmail(ctx.query.email);
}); });
router.get("/users/:uid", async (ctx) => { router.get("/users/:uid", async (ctx) => {
ctx.body = await db.getUserById(ctx.params.uid); const user = await db.getUserById(ctx.params.uid);
ctx.body = userForCaller(user, {
user: ctx.user!,
session: ctx.session!,
});
}); });
router.patch( router.patch(
"/users/:uid", "/users/:uid",
@@ -176,7 +200,24 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
ctx.body = await db.listResidentsByAbodeId(ctx.params.aid); ctx.body = await db.listResidentsByAbodeId(ctx.params.aid);
}); });
router.get("/abodes/:aid/users", async (ctx) => { router.get("/abodes/:aid/users", async (ctx) => {
ctx.body = await db.listUsersByAbodeId(ctx.params.aid); const users = await db.listUsersByAbodeId(ctx.params.aid);
const globalVisibility = hasGlobalUserVisibility({
user: ctx.user!,
session: ctx.session!,
});
const residents = globalVisibility
? []
: await db.listResidentsByAbodeId(ctx.params.aid);
const abodeAdmin = residents.some(
(resident) =>
resident.uid === ctx.user!.uid && resident.flags.admin === true,
);
ctx.body =
globalVisibility || abodeAdmin
? users
: users.map((user) =>
user.uid === ctx.user!.uid ? user : hideUserEmail(user),
);
}); });
router.get("/abodes/:aid/notes", async (ctx) => { router.get("/abodes/:aid/notes", async (ctx) => {
ctx.body = await db.listNotesByAbodeId(ctx.params.aid); ctx.body = await db.listNotesByAbodeId(ctx.params.aid);
+36
View File
@@ -0,0 +1,36 @@
import type { Context } from "koa";
import type { ClientUser, PartialUser } from "../db/types/User.js";
type AuthContext = {
user: ClientUser;
session: NonNullable<Context["session"]>;
};
export function hasGlobalUserVisibility(ctx: AuthContext): boolean {
if (!ctx.user.flags.admin) return false;
if (ctx.session.source !== "apikey") return true;
const permissions = ctx.session.key.permissions;
return (
!!permissions.admin &&
!!permissions.all &&
!permissions.restrict_users?.length &&
!permissions.restrict_abodes?.length
);
}
export function hideUserEmail(user: PartialUser | ClientUser): PartialUser {
if (!("email" in user)) return user;
const { email: _email, ...partial } = user;
return partial;
}
export function userForCaller(
user: PartialUser | ClientUser,
ctx: AuthContext,
): PartialUser | ClientUser {
if (user.uid === ctx.user.uid || hasGlobalUserVisibility(ctx)) {
return user;
}
return hideUserEmail(user);
}
+3 -1
View File
@@ -17,7 +17,9 @@ async function getApiDb() {
email: AUTH_EMAIL, email: AUTH_EMAIL,
name: "API Auth User", name: "API Auth User",
password: pw, password: pw,
flags: {}, // The shared backend contract suite exercises unrestricted user lookup,
// which the HTTP API now reserves for global administrators.
flags: { admin: true },
}); });
const server = await createTestServer(sqliteDb); const server = await createTestServer(sqliteDb);
const authHeader = "Basic " + btoa(`${AUTH_EMAIL}:${AUTH_PASSWORD}`); const authHeader = "Basic " + btoa(`${AUTH_EMAIL}:${AUTH_PASSWORD}`);
+148
View File
@@ -0,0 +1,148 @@
import { after, before, describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestDb, type TestDb } from "../helpers/sqlite.js";
import { createTestServer, type TestServer } from "../helpers/koa.js";
import { hashPassword } from "../../src/util/hash.js";
import type { ClientUser } from "../../src/db/types/User.js";
const PASSWORD = "user-visibility-password";
function basic(email: string): string {
return `Basic ${Buffer.from(`${email}:${PASSWORD}`).toString("base64")}`;
}
describe("API user email visibility", () => {
let testDb: TestDb;
let server: TestServer;
let admin: ClientUser;
let normal: ClientUser;
let coResident: ClientUser;
let abodeAdmin: ClientUser;
let aid: string;
before(async () => {
testDb = await createTestDb();
server = await createTestServer(testDb.db);
const password = await hashPassword(PASSWORD);
admin = await testDb.db.createUser({
email: "global-admin@test.example",
name: "Global Admin",
password,
flags: { admin: true },
});
normal = await testDb.db.createUser({
email: "normal@test.example",
name: "Normal",
password,
flags: {},
});
coResident = await testDb.db.createUser({
email: "co-resident@test.example",
name: "Co-resident",
password,
flags: {},
});
abodeAdmin = await testDb.db.createUser({
email: "abode-admin@test.example",
name: "Abode Admin",
password,
flags: {},
});
const abode = await testDb.db.createAbode(
{ name: "Shared abode" },
{ uid: admin.uid },
);
aid = abode.aid;
for (const user of [normal, coResident]) {
await testDb.db.createResident(
{ uid: user.uid, aid, flags: {} },
{ uid: admin.uid },
);
}
await testDb.db.createResident(
{ uid: abodeAdmin.uid, aid, flags: { admin: true } },
{ uid: admin.uid },
);
});
after(async () => {
await server.close();
testDb.close();
});
it("shows a normal caller only their own email in GET /users", async () => {
const response = await fetch(`${server.url}/users`, {
headers: { Authorization: basic(normal.email) },
});
assert.equal(response.status, 200);
const users = (await response.json()) as ClientUser[];
assert.equal(
users.find((user) => user.uid === normal.uid)?.email,
normal.email,
);
assert.ok(!("email" in users.find((user) => user.uid === coResident.uid)!));
});
it("hides another user's email in GET /users/:uid", async () => {
const response = await fetch(`${server.url}/users/${coResident.uid}`, {
headers: { Authorization: basic(normal.email) },
});
assert.equal(response.status, 200);
assert.ok(!("email" in ((await response.json()) as object)));
});
it("allows global admins to see user emails", async () => {
const response = await fetch(`${server.url}/users`, {
headers: { Authorization: basic(admin.email) },
});
assert.equal(response.status, 200);
const users = (await response.json()) as ClientUser[];
assert.equal(
users.find((user) => user.uid === coResident.uid)?.email,
coResident.email,
);
});
it("makes lookup by email global-admin-only", async () => {
const denied = await fetch(
`${server.url}/users/by-email?email=${encodeURIComponent(coResident.email)}`,
{ headers: { Authorization: basic(normal.email) } },
);
assert.equal(denied.status, 401);
const allowed = await fetch(
`${server.url}/users/by-email?email=${encodeURIComponent(coResident.email)}`,
{ headers: { Authorization: basic(admin.email) } },
);
assert.equal(allowed.status, 200);
assert.equal(
((await allowed.json()) as ClientUser).email,
coResident.email,
);
});
it("shows co-resident emails to an abode admin", async () => {
const response = await fetch(`${server.url}/abodes/${aid}/users`, {
headers: { Authorization: basic(abodeAdmin.email) },
});
assert.equal(response.status, 200);
const users = (await response.json()) as ClientUser[];
assert.equal(
users.find((user) => user.uid === coResident.uid)?.email,
coResident.email,
);
});
it("hides co-resident emails from a non-admin resident", async () => {
const response = await fetch(`${server.url}/abodes/${aid}/users`, {
headers: { Authorization: basic(normal.email) },
});
assert.equal(response.status, 200);
const users = (await response.json()) as ClientUser[];
assert.equal(
users.find((user) => user.uid === normal.uid)?.email,
normal.email,
);
assert.ok(!("email" in users.find((user) => user.uid === coResident.uid)!));
});
});