diff --git a/src/webapi/apirouter.ts b/src/webapi/apirouter.ts index ccf55ac..fe9d44d 100644 --- a/src/webapi/apirouter.ts +++ b/src/webapi/apirouter.ts @@ -15,11 +15,20 @@ import { updateuser, } from "../schema/validators.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 type { ExportFilter, ExportKind } from "../db/types/ExportImport.js"; import { intersectExportFilters, isExportKind } from "../db/export/filter.js"; import { computeForcedExportFilter } from "./exportScope.js"; +import { + hasGlobalUserVisibility, + hideUserEmail, + userForCaller, +} from "./userVisibility.js"; function parseExportFilter(query: Record): ExportFilter { const list = (v: unknown): string[] | undefined => { @@ -92,17 +101,32 @@ export function apirouter(db: BackendDbInterface): KoaRouter { router.use("/users", authenticate(db)); 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) => { ctx.body = await db.createUser(ctx.request.body); }); router.get("/users/by-email", async (ctx) => { 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); }); 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( "/users/:uid", @@ -176,7 +200,24 @@ export function apirouter(db: BackendDbInterface): KoaRouter { ctx.body = await db.listResidentsByAbodeId(ctx.params.aid); }); 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) => { ctx.body = await db.listNotesByAbodeId(ctx.params.aid); diff --git a/src/webapi/userVisibility.ts b/src/webapi/userVisibility.ts new file mode 100644 index 0000000..1e418cc --- /dev/null +++ b/src/webapi/userVisibility.ts @@ -0,0 +1,36 @@ +import type { Context } from "koa"; +import type { ClientUser, PartialUser } from "../db/types/User.js"; + +type AuthContext = { + user: ClientUser; + session: NonNullable; +}; + +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); +} diff --git a/test/backends/api/index.test.ts b/test/backends/api/index.test.ts index c05ee64..1ead0ec 100644 --- a/test/backends/api/index.test.ts +++ b/test/backends/api/index.test.ts @@ -17,7 +17,9 @@ async function getApiDb() { email: AUTH_EMAIL, name: "API Auth User", 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 authHeader = "Basic " + btoa(`${AUTH_EMAIL}:${AUTH_PASSWORD}`); diff --git a/test/tools/user-visibility.test.ts b/test/tools/user-visibility.test.ts new file mode 100644 index 0000000..33f3b9a --- /dev/null +++ b/test/tools/user-visibility.test.ts @@ -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)!)); + }); +});