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:
+45
-4
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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)!));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user