Files
HealthLog/src/lib/__tests__/require-record-auth-scope.test.ts
T
Antiheld86 cba7a0fce5 Add a self-service measurements:write API token scope
Since v1.30.17 there has been no self-service path to a token that can
write a measurement. That release made the Bearer scope default
fail-closed, which closed a real privilege-escalation hole, and removed
the generic mint at POST /api/tokens along with it. The generic mint had
been the only way to get a token that could reach /api/measurements, so
a local sensor pushed through Home Assistant or Node-RED lost its door
while the OAuth integrations kept theirs.

Add a narrow scope accepted by exactly two routes, both writes, both on
the holder's own record.

The scope constant is a leaf module beside the surface that consumes it,
matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an
options object rather than a sibling function, because the sharing
surface guard keys every leg on that one identifier and a second name
would mean two copies of the delegable declaration. AuthContext carries
the admitting scope as a single nullable string rather than the token's
permission array, so nothing downstream can re-derive an authorisation
decision from it.

A scoped credential is refused a record selector before any grant is
read. Placing the refusal above the sharing check rather than beside it
means no future widening of what a grant permits can reach one of these
tokens.

Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH
is refused 422 rather than relabelled: that source is half the
(userId, type, source, externalId) dedup key the iOS client writes into,
it participates in the cross-source merge, and it gates the HealthKit
sync fields. For the same reason a scoped write no longer advances
User.lastSyncedAt, which the phone reads to size its undelivered window.

Also closes a narrower gap found while building this. The idempotency
wrapper resolved a replayed delegated response against a live grant
without considering which credential was presenting it, so a cached
response about a shared record could be returned to a token the handler
itself would refuse. No write was reachable that way; the response body
was.

Guards: T3 gains the mint site, T4's vocabulary gains the scope and
learns to read the requireRecordAuth form (without which this change
would open a way to widen a route that the closed vocabulary could not
see), and a new T5 freezes the set of delegable routes naming a scope.
The sharing surface guard's AST matcher reads only the first two
arguments, so it needed prose but no code.
2026-08-31 08:46:16 +00:00

229 lines
7.7 KiB
TypeScript

/**
* `requireRecordAuth`'s scope option, and the refusal that comes with it.
*
* A route can now declare a narrow Bearer scope on a DELEGABLE surface, which
* puts two admissions on one call: the route may act on somebody else's record,
* and it accepts a credential minted for one job. The interesting cases are all
* about the pair, not either half — a scoped credential must reach its own
* record and be refused any other, while a session and a wildcard token keep
* the delegation behaviour they already had.
*
* The load-bearing assertion is not the 403. It is that the grant lookup never
* runs: the refusal has to sit above the sharing check rather than beside it,
* or a future change to what a grant permits would start deciding what a
* single-purpose token can reach.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// --- Mocks must be hoisted before importing the module under test. ---
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/sharing/grants", () => ({
findActiveGrant: vi.fn(),
grantAllows: vi.fn(() => true),
grantCoversDomain: vi.fn(() => true),
touchGrantUsage: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/sharing/record-session-fence", () => ({
assertRecordSessionFence: vi.fn().mockResolvedValue(undefined),
attachRecordContextEcho: vi.fn((res: unknown) => res),
}));
const headersGet = vi.fn<(name: string) => string | null>();
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: headersGet })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// --- Imports use the mocked modules above. ---
import { requireRecordAuth, isScopedCredential } from "../api-handler";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { hashToken } from "@/lib/auth/hmac";
import { auditLog } from "@/lib/auth/audit";
import { findActiveGrant } from "@/lib/sharing/grants";
import { ACCOUNT_SELECTOR_HEADER } from "@/lib/auth/acting-carrier";
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
const FAKE_HASH = "deadbeefcafef00d";
const RAW_TOKEN = "hlk_" + "a".repeat(64);
const OWNER_ID = "user-owner";
const HOLDER_ID = "user-holder";
const HOLDER = {
id: HOLDER_ID,
role: "USER" as const,
username: "holder",
email: "holder@example.test",
};
/** Present a Bearer with these scopes, and optionally a record selector. */
function armBearer(permissions: string[], selector?: string): void {
headersGet.mockReset();
headersGet.mockImplementation((name: string) => {
const key = name.toLowerCase();
if (key === "authorization") return `Bearer ${RAW_TOKEN}`;
if (key === ACCOUNT_SELECTOR_HEADER) return selector ?? null;
return null;
});
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-1",
userId: HOLDER_ID,
permissions,
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(HOLDER as never);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(hashToken).mockReturnValue(FAKE_HASH);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(getSession).mockResolvedValue(null as never);
});
describe("a scoped credential on its own record", () => {
it("is admitted, and looks exactly like the un-switched request it is", async () => {
armBearer([MEASUREMENTS_WRITE_SCOPE]);
const auth = await requireRecordAuth("write", "measurements", {
scope: MEASUREMENTS_WRITE_SCOPE,
});
expect(auth.user.id).toBe(HOLDER_ID);
expect(auth.actor.id).toBe(HOLDER_ID);
expect(auth.grantId).toBeNull();
expect(findActiveGrant).not.toHaveBeenCalled();
});
it("reports itself as scoped, and names the scope that admitted it", async () => {
armBearer([MEASUREMENTS_WRITE_SCOPE]);
const auth = await requireRecordAuth("write", "measurements", {
scope: MEASUREMENTS_WRITE_SCOPE,
});
expect(auth.bearerScope).toBe(MEASUREMENTS_WRITE_SCOPE);
expect(isScopedCredential(auth)).toBe(true);
});
});
describe("a scoped credential naming another record", () => {
it("is refused, and the grant is never consulted", async () => {
armBearer([MEASUREMENTS_WRITE_SCOPE], OWNER_ID);
await expect(
requireRecordAuth("write", "measurements", {
scope: MEASUREMENTS_WRITE_SCOPE,
}),
).rejects.toThrow();
// The point of the whole arm: refused ABOVE the sharing check, so no
// future reading of what a grant permits can reach a scoped credential.
expect(findActiveGrant).not.toHaveBeenCalled();
});
it("is refused even when a live grant would have allowed it", async () => {
armBearer([MEASUREMENTS_WRITE_SCOPE], OWNER_ID);
vi.mocked(findActiveGrant).mockResolvedValue({
id: "grant-1",
grantorId: OWNER_ID,
granteeId: HOLDER_ID,
} as never);
await expect(
requireRecordAuth("write", "measurements", {
scope: MEASUREMENTS_WRITE_SCOPE,
}),
).rejects.toThrow();
});
it("writes a durable refusal row naming the reason", async () => {
armBearer([MEASUREMENTS_WRITE_SCOPE], OWNER_ID);
await expect(
requireRecordAuth("write", "measurements", {
scope: MEASUREMENTS_WRITE_SCOPE,
}),
).rejects.toThrow();
expect(auditLog).toHaveBeenCalledWith(
"sharing.access.denied",
expect.objectContaining({
details: expect.objectContaining({ reason: "narrow_scope_selector" }),
}),
);
});
});
describe("the credentials this arm must not touch", () => {
it("a wildcard token with a selector still reaches the grant lookup", async () => {
// The control. Without it, a refusal that swallowed every delegated
// Bearer would pass the tests above for entirely the wrong reason.
armBearer(["*"], OWNER_ID);
vi.mocked(findActiveGrant).mockResolvedValue(null as never);
await expect(
requireRecordAuth("write", "measurements", {
scope: MEASUREMENTS_WRITE_SCOPE,
}),
).rejects.toThrow();
expect(findActiveGrant).toHaveBeenCalled();
});
it("a wildcard token reads as unscoped however the route was declared", async () => {
armBearer(["*"]);
const auth = await requireRecordAuth("write", "measurements", {
scope: MEASUREMENTS_WRITE_SCOPE,
});
expect(auth.bearerScope).toBeNull();
expect(isScopedCredential(auth)).toBe(false);
});
it("a cookie session reads as unscoped", async () => {
headersGet.mockReset();
headersGet.mockReturnValue(null);
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 60_000) },
user: HOLDER,
} as never);
const auth = await requireRecordAuth("write", "measurements", {
scope: MEASUREMENTS_WRITE_SCOPE,
});
expect(isScopedCredential(auth)).toBe(false);
expect(auth.grantId).toBeNull();
});
});
describe("a route that declares no scope", () => {
it("still refuses a narrow token outright", async () => {
// The read legs on the measurements module are exactly this shape, and it
// is what makes the new scope write-only rather than merely write-named.
armBearer([MEASUREMENTS_WRITE_SCOPE]);
await expect(requireRecordAuth("read", "measurements")).rejects.toThrow();
expect(findActiveGrant).not.toHaveBeenCalled();
});
});