mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #687 from MBombeck/work/sharing-resolver
The acting-account resolver and its fail-closed refusal
This commit is contained in:
14 files changed
+1692
-7
No files matched your search
@@ -0,0 +1,36 @@
|
||||
-- v1.36.0 — the cookie transport's acting-account carrier.
|
||||
--
|
||||
-- One nullable column, and the whole reason it is a column rather than
|
||||
-- something the client sends: a browser cannot address this row. The session
|
||||
-- cookie carries a 32-byte secret whose hash is the lookup key, so nothing
|
||||
-- reachable from the client names, reads, or writes `acting_as_user_id`. The
|
||||
-- only writer is the switch endpoint, which validates the grant first.
|
||||
--
|
||||
-- It is still a SELECTOR and not an authorization. The resolver joins it
|
||||
-- against a live row in `account_grants` on every request, so a value stranded
|
||||
-- here by a grant that has since been revoked or expired confers nothing at
|
||||
-- all. Nothing sweeps this column for correctness — sweeping it (on revocation,
|
||||
-- on account deletion) is housekeeping so the delegate's browser lands back in
|
||||
-- its own account instead of on a wall of 403s.
|
||||
--
|
||||
-- ON DELETE SET NULL, not CASCADE. The two ends mean opposite things here:
|
||||
-- deleting the session's OWNER should take the session with it (that is the
|
||||
-- existing `user_id` cascade), while deleting the account being ACTED ON must
|
||||
-- leave the delegate's browser session alive and simply put it back into its
|
||||
-- own record. A CASCADE on this side would sign a delegate out of their own
|
||||
-- account because somebody else deleted theirs.
|
||||
--
|
||||
-- The index is not for the resolver — that reads by primary key. It is for the
|
||||
-- two sweeps that go the other way: revocation clearing every session of the
|
||||
-- delegate pointing at this owner, and the SET NULL above when an account is
|
||||
-- deleted. Both scan on this column.
|
||||
|
||||
ALTER TABLE "sessions" ADD COLUMN "acting_as_user_id" TEXT;
|
||||
|
||||
ALTER TABLE "sessions"
|
||||
ADD CONSTRAINT "sessions_acting_as_user_id_fkey"
|
||||
FOREIGN KEY ("acting_as_user_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "sessions_acting_as_user_id_idx"
|
||||
ON "sessions" ("acting_as_user_id");
|
||||
+26
-2
@@ -695,7 +695,10 @@ model User {
|
||||
|
||||
// Relations
|
||||
passkeys Passkey[]
|
||||
sessions Session[]
|
||||
sessions Session[] @relation("SessionOwner")
|
||||
// v1.36.0 — browser sessions belonging to OTHER accounts that are currently
|
||||
// acting on this one. Named so the delete cascade has somewhere to point.
|
||||
sessionsActingOnMe Session[] @relation("SessionActingAs")
|
||||
challenges AuthChallenge[]
|
||||
// v1.23 — second-factor stores (kept separate from `passkeys`, which is the
|
||||
// passwordless-primary credential set; an MFA credential is meaningless
|
||||
@@ -1132,9 +1135,30 @@ model Session {
|
||||
// v1.23 — sliding "last seen" for the user-facing active-session list.
|
||||
lastActiveAt DateTime? @map("last_active_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// v1.36.0 — the cookie transport's acting-account carrier. NULL (the only
|
||||
/// value any session has had until now) means the browser is acting as the
|
||||
/// account it signed in as. A non-NULL value names the account whose record
|
||||
/// this session is currently reading, and it is a SELECTOR, never an
|
||||
/// authorization: the resolver joins it against a live grant on every single
|
||||
/// request, so a value left here by a grant that has since ended confers
|
||||
/// exactly nothing.
|
||||
///
|
||||
/// Server-side by construction — the column lives on a row the client cannot
|
||||
/// address (the cookie carries a secret, not this id), and the only writer is
|
||||
/// the switch endpoint, which validates the grant before it stamps.
|
||||
///
|
||||
/// The FK is ON DELETE SET NULL rather than CASCADE: deleting the OWNER must
|
||||
/// not delete the delegate's browser session, it must put that browser back
|
||||
/// into its own account.
|
||||
actingAsUserId String? @map("acting_as_user_id")
|
||||
|
||||
user User @relation("SessionOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
actingAs User? @relation("SessionActingAs", fields: [actingAsUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
// Revocation clears every session pointing at the owner, and deleting an
|
||||
// account nulls the same set. Both sweep on this column.
|
||||
@@index([actingAsUserId])
|
||||
// v1.18.7 — the sliding-30-day session reaper sweeps on `expiresAt`; without
|
||||
// this index the cleanup degrades to a sequential scan on a busy instance.
|
||||
@@index([expiresAt])
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Structural guards on the acting-account boundary.
|
||||
*
|
||||
* The behavioural tests in `src/lib/__tests__/acting-account-resolver.test.ts`
|
||||
* prove what today's code does. These prove what tomorrow's may not do, and
|
||||
* they exist because the boundary has one shape that keeps it safe: the acting
|
||||
* account is a value ONE function is allowed to act on, and the helpers that
|
||||
* decide role and step-up never see it.
|
||||
*
|
||||
* Tripwires, not proofs. They cannot show the resolver is correct — only that
|
||||
* the boundary has not moved without somebody editing this file. Every leg
|
||||
* below asserts a non-zero match count first, because a guard whose matcher
|
||||
* finds nothing reports success, and this repository has shipped several of
|
||||
* those.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { globSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return globSync("**/*.{ts,tsx}", { cwd: SRC })
|
||||
.filter(
|
||||
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
|
||||
)
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.map((p) => p.split(sep).join("/"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
function filesMatching(re: RegExp): string[] {
|
||||
return sourceFiles().filter((rel) => re.test(read(rel)));
|
||||
}
|
||||
|
||||
/** The source text of a top-level exported function, braces included. */
|
||||
function functionBody(src: string, name: string): string {
|
||||
const start = src.indexOf(`export async function ${name}(`);
|
||||
if (start === -1) return "";
|
||||
const open = src.indexOf("{", src.indexOf(")", start));
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
if (src[i] === "{") depth++;
|
||||
else if (src[i] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(open, i + 1);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
describe("the acting-account carrier is read in one place", () => {
|
||||
/**
|
||||
* Files allowed to touch `Session.actingAsUserId`.
|
||||
*
|
||||
* The column is a selector — it names an account, it does not authorise one.
|
||||
* Anything that reads it and acts on the value without going through the
|
||||
* resolver has invented a second answer to "whose record is this", and the
|
||||
* copy that drifts is always the one nobody reads.
|
||||
*
|
||||
* Later items in this feature will extend this list, and that is the point:
|
||||
* the switch endpoint (which writes it) and revocation cleanup (which clears
|
||||
* it) arrive as a visible diff here rather than as a quiet new reader.
|
||||
*
|
||||
* The matcher reads source text and does not exempt comments, so a file that
|
||||
* merely NAMES the column in prose trips it. Left that way deliberately: a
|
||||
* guard over an authorization carrier that shouts at a mention is the right
|
||||
* direction to be wrong in.
|
||||
*/
|
||||
const CARRIER_ALLOWLIST = [
|
||||
// Projects it off the session row it already loaded.
|
||||
"lib/auth/session.ts",
|
||||
// The resolver. The only thing that acts on the value.
|
||||
"lib/api-handler.ts",
|
||||
].sort();
|
||||
|
||||
it("no other file reads or writes the carrier column", () => {
|
||||
const readers = filesMatching(/actingAsUserId|acting_as_user_id/);
|
||||
// Non-zero proof: an emptied allowlist must fail rather than agree with an
|
||||
// emptied match set.
|
||||
expect(readers.length).toBeGreaterThan(0);
|
||||
expect(readers).toEqual(CARRIER_ALLOWLIST);
|
||||
});
|
||||
|
||||
it("the selector header is named in one place", () => {
|
||||
// A route that read the header itself would be deciding an authorisation
|
||||
// question the resolver exists to answer once.
|
||||
const namers = filesMatching(/x-healthlog-account/i);
|
||||
expect(namers.length).toBeGreaterThan(0);
|
||||
expect(namers).toEqual(["lib/api-handler.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the cookie-only helpers cannot see the acting account", () => {
|
||||
/**
|
||||
* `requireAdmin`, `requireCookieAuth` and `requireFreshMfa` are safe under a
|
||||
* switch for one reason: they resolve through `getSession()`, which answers
|
||||
* who is CALLING, and they never consult the carrier. That is the property
|
||||
* that keeps role checks and step-up gates structurally out of a delegate's
|
||||
* reach, and it is one careless line away from being untrue.
|
||||
*/
|
||||
const COOKIE_ONLY = ["requireAdmin", "requireCookieAuth", "requireFreshMfa"];
|
||||
|
||||
it.each(COOKIE_ONLY)("%s resolves the actor and nothing else", (name) => {
|
||||
const body = functionBody(read("lib/api-handler.ts"), name);
|
||||
|
||||
// Non-zero proof, twice over: the slice found a function, and the slice is
|
||||
// the function it claims to be. A renamed helper must fail here rather
|
||||
// than silently assert against an empty string.
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
expect(body).toContain("getSession()");
|
||||
|
||||
expect(body).not.toContain("actingAsUserId");
|
||||
expect(body).not.toContain("requireRecordAuth");
|
||||
expect(body).not.toContain("readActingCarrier");
|
||||
});
|
||||
|
||||
it("substitutes a data-scope user in exactly one function", () => {
|
||||
const src = read("lib/api-handler.ts");
|
||||
const record = functionBody(src, "requireRecordAuth");
|
||||
expect(record).toContain("readActingCarrier");
|
||||
|
||||
// `readActingCarrier` is what turns a request into "acting as somebody
|
||||
// else". Two callers: the bare path, which refuses on it, and the record
|
||||
// path, which acts on it. A third would be a mode nobody declared.
|
||||
const callers = (src.match(/await readActingCarrier\(/g) ?? []).length;
|
||||
expect(callers).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,11 @@ import {
|
||||
} from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: {
|
||||
id: "session-1",
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
actingAsUserId: null,
|
||||
},
|
||||
user: {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
|
||||
@@ -98,6 +98,11 @@ import {
|
||||
} from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: {
|
||||
id: "session-1",
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
actingAsUserId: null,
|
||||
},
|
||||
user: { id: "user-1", email: "test@example.com", role: "USER" },
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -96,7 +96,11 @@ const SESSION_USER = {
|
||||
};
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
session: {
|
||||
id: "sess-1",
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
actingAsUserId: null,
|
||||
},
|
||||
user: SESSION_USER as never,
|
||||
};
|
||||
|
||||
|
||||
@@ -77,7 +77,11 @@ function postReq(fields: Record<string, string>): Request {
|
||||
|
||||
function signedIn() {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 1e6) },
|
||||
session: {
|
||||
id: "s1",
|
||||
expiresAt: new Date(Date.now() + 1e6),
|
||||
actingAsUserId: null,
|
||||
},
|
||||
user: { id: "user-1" } as never,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: {
|
||||
id: "session-1",
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
actingAsUserId: null,
|
||||
},
|
||||
user: { id: "user-1", username: "owner", role: "USER", email: null },
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* v1.36.0 — the acting-account resolver, arm by arm.
|
||||
*
|
||||
* Three things are worth more here than anywhere else in the feature, because
|
||||
* everything downstream trusts them:
|
||||
*
|
||||
* 1. **It fails closed.** A bare `requireAuth()` reached under an active
|
||||
* switch refuses. It must never fall back to the caller's own account —
|
||||
* a delegate who believes they are reading the owner's record being handed
|
||||
* their own is the same data-mixing failure, pointed the other way.
|
||||
* 2. **Refusals do not enumerate.** A selector naming an account that does
|
||||
* not exist and one naming an account that granted nothing produce the
|
||||
* same bytes. Asserted as bytes, not as "both were 403".
|
||||
* 3. **Nothing is cached.** The grant is loaded on the request that uses it.
|
||||
* Proven against a real database in
|
||||
* `tests/integration/acting-account-resolver.test.ts`, where revoking
|
||||
* between two requests is a thing that can actually happen.
|
||||
*
|
||||
* The grant module is deliberately NOT mocked. The resolver consuming S1's
|
||||
* `isGrantActive` rather than its own idea of "active" is the property that
|
||||
* keeps the panel and the resolver from disagreeing, and a mocked predicate
|
||||
* would report that property working while it was broken. The Prisma fake below
|
||||
* honours its `where` clause for the same reason: a fake that ignores `where`
|
||||
* turns every scoping assertion into a check that cannot fail.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// --- Mocks must be hoisted before importing the module under test. ---
|
||||
|
||||
interface GrantRow {
|
||||
id: string;
|
||||
grantorId: string;
|
||||
granteeId: string;
|
||||
access: "READ" | "WRITE";
|
||||
acceptedAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
||||
const grantRows: GrantRow[] = [];
|
||||
const userRows = new Map<string, { id: string; role: string }>();
|
||||
const touched: string[] = [];
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
accountGrant: {
|
||||
// Honours the narrowing the real query performs. `findActiveGrant`
|
||||
// narrows on `revokedAt: null` in SQL and leaves accepted/expired to the
|
||||
// state machine; a fake that returned any row for any `where` would make
|
||||
// the cross-user arms below pass no matter what the resolver did.
|
||||
findFirst: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
}: {
|
||||
where: {
|
||||
grantorId: string;
|
||||
granteeId: string;
|
||||
revokedAt: null;
|
||||
};
|
||||
}) =>
|
||||
grantRows.find(
|
||||
(g) =>
|
||||
g.grantorId === where.grantorId &&
|
||||
g.granteeId === where.granteeId &&
|
||||
(where.revokedAt === null ? g.revokedAt === null : true),
|
||||
) ?? null,
|
||||
),
|
||||
update: vi.fn(async ({ where }: { where: { id: string } }) => {
|
||||
touched.push(where.id);
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(
|
||||
async ({ where }: { where: { id: string } }) =>
|
||||
userRows.get(where.id) ?? null,
|
||||
),
|
||||
},
|
||||
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn(async () => 0) },
|
||||
session: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "hash") }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
const headerJar = new Map<string, string>();
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// --- Imports use the mocked modules above. ---
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
ACCOUNT_SELECTOR_HEADER,
|
||||
apiHandler,
|
||||
requireActorAuth,
|
||||
requireAdmin,
|
||||
requireAuth,
|
||||
requireCookieAuth,
|
||||
requireFreshMfa,
|
||||
requireRecordAuth,
|
||||
type RecordAuthContext,
|
||||
} from "../api-handler";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { emitIfSampled } from "@/lib/logging/transports";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { WideEvent } from "@/lib/logging/types";
|
||||
|
||||
const OWNER = "owner-1";
|
||||
const DELEGATE = "delegate-1";
|
||||
|
||||
function user(id: string, role: "USER" | "ADMIN" = "USER") {
|
||||
return { id, role, username: id, email: `${id}@example.test` };
|
||||
}
|
||||
|
||||
/** Sign the delegate in over the cookie transport. */
|
||||
function signedInCookie(
|
||||
actingAsUserId: string | null = null,
|
||||
role: "USER" | "ADMIN" = "USER",
|
||||
): void {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: {
|
||||
id: "session-1",
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
actingAsUserId,
|
||||
},
|
||||
user: user(DELEGATE, role) as never,
|
||||
});
|
||||
}
|
||||
|
||||
/** Sign the delegate in over the Bearer transport. */
|
||||
function signedInBearer(): void {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
headerJar.set("authorization", "Bearer hlk_" + "a".repeat(64));
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "token-1",
|
||||
userId: DELEGATE,
|
||||
permissions: ["*"],
|
||||
revoked: false,
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
} as never);
|
||||
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
|
||||
userRows.set(DELEGATE, user(DELEGATE));
|
||||
}
|
||||
|
||||
function selector(value: string): void {
|
||||
headerJar.set(ACCOUNT_SELECTOR_HEADER, value);
|
||||
}
|
||||
|
||||
function liveGrant(overrides: Partial<GrantRow> = {}): GrantRow {
|
||||
const row: GrantRow = {
|
||||
id: "grant-1",
|
||||
grantorId: OWNER,
|
||||
granteeId: DELEGATE,
|
||||
access: "READ",
|
||||
acceptedAt: new Date(Date.now() - 60_000),
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
...overrides,
|
||||
};
|
||||
grantRows.push(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a resolver call the way a route runs it: inside `apiHandler`, against a
|
||||
* real `NextRequest`, so the HTTP method the resolver reads is the one the
|
||||
* request actually carried and the refusal is a real HTTP response.
|
||||
*/
|
||||
function route(
|
||||
resolve: () => Promise<unknown>,
|
||||
): (method?: string) => Promise<Response> {
|
||||
// A zero-argument handler is assignable to the one-argument shape Next.js
|
||||
// calls; naming the type here keeps the call site honest without an unused
|
||||
// parameter.
|
||||
const handler: (request: NextRequest) => Promise<Response> = apiHandler(
|
||||
async () => {
|
||||
await resolve();
|
||||
return NextResponse.json({ data: null, error: null });
|
||||
},
|
||||
);
|
||||
return (method = "GET") =>
|
||||
handler(new NextRequest("http://localhost/api/test", { method }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve inside a route and hand back what the handler body would have seen.
|
||||
* Every substitution assertion runs through here rather than calling the
|
||||
* resolver bare, because the HTTP method is part of the decision and a bare
|
||||
* call has none.
|
||||
*/
|
||||
async function resolveInRoute<T>(
|
||||
resolve: () => Promise<T>,
|
||||
method = "GET",
|
||||
): Promise<T> {
|
||||
let value: T | undefined;
|
||||
const response = await route(async () => {
|
||||
value = await resolve();
|
||||
})(method);
|
||||
expect(response.status).toBe(200);
|
||||
return value as T;
|
||||
}
|
||||
|
||||
/** The wide event the last run emitted. */
|
||||
function lastEvent(): WideEvent {
|
||||
const calls = vi.mocked(emitIfSampled).mock.calls;
|
||||
return calls[calls.length - 1][0] as WideEvent;
|
||||
}
|
||||
|
||||
function sharingAuditCalls() {
|
||||
return vi
|
||||
.mocked(auditLog)
|
||||
.mock.calls.filter(([action]) => action === "sharing.access.denied");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
grantRows.length = 0;
|
||||
userRows.clear();
|
||||
touched.length = 0;
|
||||
headerJar.clear();
|
||||
userRows.set(OWNER, user(OWNER));
|
||||
userRows.set(DELEGATE, user(DELEGATE));
|
||||
});
|
||||
|
||||
describe("do no harm — nothing is acting as anyone", () => {
|
||||
it("resolves every mode to the caller, and never asks about a grant", async () => {
|
||||
signedInCookie(null);
|
||||
|
||||
const bare = await resolveInRoute(() => requireAuth());
|
||||
expect(bare.user.id).toBe(DELEGATE);
|
||||
|
||||
const actor = await resolveInRoute(() => requireActorAuth());
|
||||
expect(actor.user.id).toBe(DELEGATE);
|
||||
|
||||
const record = await resolveInRoute(() => requireRecordAuth("read"));
|
||||
expect(record.user.id).toBe(DELEGATE);
|
||||
expect(record.actor.id).toBe(DELEGATE);
|
||||
expect(record.grantId).toBeNull();
|
||||
|
||||
// No carrier means no authorization question was ever asked. If this
|
||||
// fires, the resolver is doing work on the un-switched path — which is
|
||||
// where 100% of today's traffic lives.
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
expect(sharingAuditCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("leaves the wide event's actor identity alone", async () => {
|
||||
signedInCookie(null);
|
||||
const call = route(() => requireRecordAuth("read"));
|
||||
await call();
|
||||
|
||||
expect(lastEvent().auth?.user_id).toBe(DELEGATE);
|
||||
expect(lastEvent().auth?.acting_as).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bare requireAuth refuses to run under a switch", () => {
|
||||
it("refuses the session carrier without falling back to the caller", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
|
||||
const response = await route(() => requireAuth())();
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.meta.errorCode).toBe("sharing.not_permitted");
|
||||
expect(body.data).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses even when the grant behind the switch is perfectly valid", async () => {
|
||||
// The point of the fail-closed arm: the grant is not the question. The
|
||||
// route never declared that it can be used on someone else's record.
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
|
||||
await expect(requireAuth()).rejects.toMatchObject({
|
||||
errorCode: "sharing.not_permitted",
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a Bearer selector, and records it", async () => {
|
||||
signedInBearer();
|
||||
selector(OWNER);
|
||||
liveGrant();
|
||||
|
||||
await expect(requireAuth()).rejects.toMatchObject({
|
||||
errorCode: "sharing.not_permitted",
|
||||
});
|
||||
expect(sharingAuditCalls()).toHaveLength(1);
|
||||
expect(sharingAuditCalls()[0][1]).toMatchObject({
|
||||
userId: DELEGATE,
|
||||
details: expect.objectContaining({
|
||||
reason: "undeclared_mode",
|
||||
carrier: "header",
|
||||
target: OWNER,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a selector sent over the cookie transport", async () => {
|
||||
// The header is a Bearer-only carrier. Ignoring it here would serve the
|
||||
// caller's own record to a client that believes it asked for another's.
|
||||
signedInCookie(null);
|
||||
selector(OWNER);
|
||||
|
||||
await expect(requireAuth()).rejects.toMatchObject({
|
||||
errorCode: "sharing.not_permitted",
|
||||
});
|
||||
expect(sharingAuditCalls()[0][1]).toMatchObject({
|
||||
details: expect.objectContaining({ carrier: "misplaced-header" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the ambient web refusal out of the audit table", async () => {
|
||||
// Every navigation and poll that touches a non-delegable route lands here
|
||||
// while a switch is on. A row per request would bury the header refusals,
|
||||
// which are the ones that mean something.
|
||||
signedInCookie(OWNER);
|
||||
await expect(requireAuth()).rejects.toThrow();
|
||||
expect(sharingAuditCalls()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireActorAuth — always the caller's own rows", () => {
|
||||
it("works under a session switch and answers as the caller", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
|
||||
const ctx = await requireActorAuth();
|
||||
expect(ctx.user.id).toBe(DELEGATE);
|
||||
// The switcher, the banner and the "whose records may I open" list all run
|
||||
// while switched, and every one of them is about the delegate.
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a Bearer selector loudly", async () => {
|
||||
signedInBearer();
|
||||
selector(OWNER);
|
||||
liveGrant();
|
||||
|
||||
await expect(requireActorAuth()).rejects.toMatchObject({
|
||||
errorCode: "sharing.not_permitted",
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(sharingAuditCalls()[0][1]).toMatchObject({
|
||||
details: expect.objectContaining({ reason: "actor_surface" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a selector on the cookie transport too", async () => {
|
||||
signedInCookie(null);
|
||||
selector(OWNER);
|
||||
await expect(requireActorAuth()).rejects.toMatchObject({
|
||||
errorCode: "sharing.not_permitted",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireRecordAuth — substitution", () => {
|
||||
it("substitutes the owner as the data scope and keeps the delegate as actor", async () => {
|
||||
signedInCookie(OWNER);
|
||||
const grant = liveGrant();
|
||||
|
||||
const resolved: RecordAuthContext = await resolveInRoute(() =>
|
||||
requireRecordAuth("read"),
|
||||
);
|
||||
expect(resolved.user.id).toBe(OWNER);
|
||||
expect(resolved.actor.id).toBe(DELEGATE);
|
||||
expect(resolved.grantId).toBe(grant.id);
|
||||
});
|
||||
|
||||
it("substitutes over the Bearer selector as well", async () => {
|
||||
signedInBearer();
|
||||
selector(OWNER);
|
||||
liveGrant();
|
||||
|
||||
const resolved = await resolveInRoute(() => requireRecordAuth("read"));
|
||||
expect(resolved.user.id).toBe(OWNER);
|
||||
expect(resolved.actor.id).toBe(DELEGATE);
|
||||
});
|
||||
|
||||
it("names the acting account on the wide event without moving the actor", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
|
||||
await route(() => requireRecordAuth("read"))();
|
||||
expect(lastEvent().auth?.user_id).toBe(DELEGATE);
|
||||
expect(lastEvent().auth?.acting_as).toBe(OWNER);
|
||||
});
|
||||
|
||||
it("stamps the grant as used without making the read wait on it", async () => {
|
||||
signedInCookie(OWNER);
|
||||
const grant = liveGrant();
|
||||
|
||||
await resolveInRoute(() => requireRecordAuth("read"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(touched).toEqual([grant.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireRecordAuth — every way a grant can fail", () => {
|
||||
const refused = async (): Promise<Response> => {
|
||||
const response = await route(() => requireRecordAuth("read"))();
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.clone().json()).meta.errorCode).toBe(
|
||||
"sharing.access.denied",
|
||||
);
|
||||
return response;
|
||||
};
|
||||
|
||||
it("refuses a pending invitation", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ acceptedAt: null });
|
||||
await refused();
|
||||
});
|
||||
|
||||
it("refuses a revoked grant", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ revokedAt: new Date(Date.now() - 1000) });
|
||||
await refused();
|
||||
});
|
||||
|
||||
it("refuses an expired grant", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ expiresAt: new Date(Date.now() - 1000) });
|
||||
await refused();
|
||||
});
|
||||
|
||||
it("refuses a grant made to somebody else", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ granteeId: "other-delegate" });
|
||||
await refused();
|
||||
});
|
||||
|
||||
it("refuses a write method under a read grant", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
|
||||
const response = await route(() => requireRecordAuth("read"))("POST");
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.json()).meta.errorCode).toBe(
|
||||
"sharing.access.denied",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a declared write under a read grant", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
|
||||
const response = await route(() => requireRecordAuth("write"))();
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("admits a write method once the grant says WRITE", async () => {
|
||||
// v1 never writes a WRITE row, so this arm is reached by the data rather
|
||||
// than by a special case — which is what makes v2 enforcement work instead
|
||||
// of a rewrite.
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ access: "WRITE" });
|
||||
|
||||
const response = await route(() => requireRecordAuth("write"))("POST");
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses when the method cannot be known", async () => {
|
||||
// Outside an event context there is no method to check, so we cannot prove
|
||||
// the request is a read. Same fail-closed posture as the MCP audience
|
||||
// guard.
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
await expect(requireRecordAuth("read")).rejects.toMatchObject({
|
||||
errorCode: "sharing.access.denied",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a selector too long to name an account, without querying", async () => {
|
||||
signedInBearer();
|
||||
selector("x".repeat(65));
|
||||
await expect(requireRecordAuth("read")).rejects.toMatchObject({
|
||||
errorCode: "sharing.access.denied",
|
||||
});
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a selector sent over the cookie transport", async () => {
|
||||
signedInCookie(null);
|
||||
selector(OWNER);
|
||||
liveGrant();
|
||||
await expect(requireRecordAuth("read")).rejects.toMatchObject({
|
||||
errorCode: "sharing.not_permitted",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses when the owner row is gone under a surviving grant", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
userRows.delete(OWNER);
|
||||
await refused();
|
||||
expect(sharingAuditCalls()[0][1]).toMatchObject({
|
||||
details: expect.objectContaining({ reason: "owner_missing" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusals do not enumerate", () => {
|
||||
it("answers identically for an account that does not exist and one that granted nothing", async () => {
|
||||
signedInBearer();
|
||||
selector("no-such-account-at-all");
|
||||
const missing = await route(() => requireRecordAuth("read"))();
|
||||
const missingBody = await missing.text();
|
||||
|
||||
vi.clearAllMocks();
|
||||
headerJar.clear();
|
||||
signedInBearer();
|
||||
// An account that exists, has a session, has data, and simply never
|
||||
// granted this caller anything.
|
||||
userRows.set("real-stranger", user("real-stranger"));
|
||||
selector("real-stranger");
|
||||
const present = await route(() => requireRecordAuth("read"))();
|
||||
const presentBody = await present.text();
|
||||
|
||||
expect(missing.status).toBe(present.status);
|
||||
// Bytes, not statuses. A difference of one word here is an account
|
||||
// enumeration oracle for anyone holding a login.
|
||||
expect(missingBody).toBe(presentBody);
|
||||
});
|
||||
|
||||
it("answers identically for a revoked grant and a grant that never existed", async () => {
|
||||
signedInBearer();
|
||||
selector(OWNER);
|
||||
liveGrant({ revokedAt: new Date(Date.now() - 1000) });
|
||||
const revoked = await route(() => requireRecordAuth("read"))();
|
||||
const revokedBody = await revoked.text();
|
||||
|
||||
vi.clearAllMocks();
|
||||
headerJar.clear();
|
||||
grantRows.length = 0;
|
||||
signedInBearer();
|
||||
selector(OWNER);
|
||||
const never = await route(() => requireRecordAuth("read"))();
|
||||
|
||||
expect(revokedBody).toBe(await never.text());
|
||||
});
|
||||
|
||||
it("keeps the reason off the wire and on the audit row", async () => {
|
||||
signedInBearer();
|
||||
selector(OWNER);
|
||||
liveGrant({ expiresAt: new Date(Date.now() - 1000) });
|
||||
|
||||
const body = await (await route(() => requireRecordAuth("read"))()).text();
|
||||
expect(body).not.toContain("expire");
|
||||
expect(body).not.toContain("grant");
|
||||
expect(sharingAuditCalls()[0][1]).toMatchObject({
|
||||
details: expect.objectContaining({ reason: "no_active_grant" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the cookie-only helpers stay out of reach of a switch", () => {
|
||||
it("resolves requireAdmin against the actor, never the account being acted on", async () => {
|
||||
signedInCookie(OWNER, "ADMIN");
|
||||
liveGrant();
|
||||
userRows.set(OWNER, user(OWNER, "USER"));
|
||||
|
||||
const ctx = await requireAdmin();
|
||||
// The role came off the caller's row. Switching into somebody's record
|
||||
// must confer nothing, and switching out of an admin's must take nothing
|
||||
// away.
|
||||
expect(ctx.user.id).toBe(DELEGATE);
|
||||
});
|
||||
|
||||
it("resolves requireCookieAuth against the actor", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
const ctx = await requireCookieAuth();
|
||||
expect(ctx.user.id).toBe(DELEGATE);
|
||||
});
|
||||
|
||||
it("resolves requireFreshMfa against the actor", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: {
|
||||
id: "session-1",
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
actingAsUserId: OWNER,
|
||||
},
|
||||
user: {
|
||||
...user(DELEGATE),
|
||||
totpConfirmedAt: new Date(Date.now() - 86_400_000),
|
||||
} as never,
|
||||
});
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const ctx = await requireFreshMfa(300);
|
||||
expect(ctx.user.id).toBe(DELEGATE);
|
||||
});
|
||||
});
|
||||
+430
-1
@@ -22,6 +22,12 @@ import { hashToken } from "./auth/hmac";
|
||||
import { AssistantDisabledError } from "./feature-flags";
|
||||
import { ConsentRequiredError } from "./ai/consent-guard";
|
||||
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
|
||||
import {
|
||||
findActiveGrant,
|
||||
grantAllows,
|
||||
touchGrantUsage,
|
||||
type GrantNeed,
|
||||
} from "./sharing/grants";
|
||||
|
||||
/**
|
||||
* HTTP methods a read-only credential may use on the REST surface. A request
|
||||
@@ -67,6 +73,63 @@ export class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.36.0 — the two refusals the acting-account resolver can raise.
|
||||
*
|
||||
* Both are 403 with a stable `meta.errorCode`, the same envelope shape the
|
||||
* step-up and consent gates already use, so a client branches on the code
|
||||
* rather than on prose.
|
||||
*/
|
||||
export abstract class SharingAuthError extends HttpError {
|
||||
abstract readonly errorCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "You may not act as that account."
|
||||
*
|
||||
* Deliberately parameterless. The message is a fixed literal and the
|
||||
* constructor takes no arguments, so there is no way for a caller to vary the
|
||||
* response — which is what makes a selector naming an account that does not
|
||||
* exist and one naming an account that granted nothing produce the SAME BYTES.
|
||||
* That is not a formatting nicety: a distinguishable refusal turns this
|
||||
* endpoint into an account-enumeration oracle for anyone with a login.
|
||||
*
|
||||
* The indistinguishability is structural rather than careful, too. Neither
|
||||
* branch ever looks an account up: the resolver asks the grant table for the
|
||||
* (owner, delegate) pair and a missing account and a missing grant are the
|
||||
* same empty result, on the same code path, after the same one query.
|
||||
*
|
||||
* Why the reason is not in the response at all: the caller already knows what
|
||||
* they sent, and the only party who benefits from knowing WHICH condition
|
||||
* failed is someone probing. The reason goes to the audit row and the wide
|
||||
* event, where the owner and the operator can see it.
|
||||
*/
|
||||
export class SharingAccessDeniedError extends SharingAuthError {
|
||||
readonly errorCode = "sharing.access.denied";
|
||||
constructor() {
|
||||
super(403, "Account access denied");
|
||||
this.name = "SharingAccessDeniedError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "This endpoint does not act on another account's record."
|
||||
*
|
||||
* A different fact from the one above and so a different code: nothing here is
|
||||
* about whether a grant exists. The route simply never declared that it can be
|
||||
* used under a switch, so it refuses rather than quietly serving the caller's
|
||||
* OWN record — which is the reverse data-mixing failure, and the reason this
|
||||
* class exists at all. A delegate who believes they are reading the owner's
|
||||
* record must never be handed their own instead.
|
||||
*/
|
||||
export class SharingNotPermittedError extends SharingAuthError {
|
||||
readonly errorCode = "sharing.not_permitted";
|
||||
constructor() {
|
||||
super(403, "This endpoint cannot be used while acting on another account");
|
||||
this.name = "SharingNotPermittedError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an API route handler with Wide Event logging.
|
||||
* Creates a WideEventBuilder, runs the handler inside AsyncLocalStorage,
|
||||
@@ -247,6 +310,21 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
},
|
||||
{ status: error.statusCode },
|
||||
);
|
||||
} else if (error instanceof SharingAuthError) {
|
||||
// v1.36.0 — the acting-account resolver refused. Same
|
||||
// 403 + meta.errorCode envelope as the gates above, so a client
|
||||
// distinguishes "you may not act as that account" from "this
|
||||
// endpoint does not support acting on one" without parsing prose.
|
||||
// Checked before the generic HttpError branch because it extends it.
|
||||
evt.setError(error);
|
||||
response = NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: error.message,
|
||||
meta: { errorCode: error.errorCode },
|
||||
},
|
||||
{ status: error.statusCode },
|
||||
);
|
||||
} else if (error instanceof HttpError) {
|
||||
evt.setError(error);
|
||||
response = NextResponse.json(
|
||||
@@ -292,7 +370,21 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
* to a Session row.
|
||||
*/
|
||||
export type AuthContext = {
|
||||
session: { id: string; expiresAt: Date };
|
||||
session: {
|
||||
id: string;
|
||||
expiresAt: Date;
|
||||
/**
|
||||
* v1.36.0 — the cookie transport's acting-account carrier, straight off
|
||||
* the session row. Absent on the Bearer path, which has no session row and
|
||||
* carries its selector in a per-request header instead.
|
||||
*
|
||||
* Optional so that the ~180 test files which hand `requireAuth` a
|
||||
* hand-built session context keep compiling; an absent field means the
|
||||
* same thing as a null one — no switch. Only the acting-account resolver
|
||||
* below reads it.
|
||||
*/
|
||||
readonly actingAsUserId?: string | null;
|
||||
};
|
||||
user: User;
|
||||
/**
|
||||
* Transport the caller authenticated with, derived from the SAME branch that
|
||||
@@ -313,6 +405,22 @@ export type AuthContext = {
|
||||
* 2. No cookie + `Authorization: Bearer hlk_<...>` → API token path.
|
||||
* 3. Neither → 401.
|
||||
*
|
||||
* v1.36.0 — and it REFUSES to run while the request is acting on another
|
||||
* account. A bare `requireAuth()` is an undeclared route: nobody has decided
|
||||
* whether it is safe under a switch, and the routes that call it include the
|
||||
* password change, the MFA management surface, the token mint, and the
|
||||
* integration connect. So a request carrying an acting-account carrier gets a
|
||||
* 403 `sharing.not_permitted` here, and never a silent fall-back to the
|
||||
* caller's own record.
|
||||
*
|
||||
* The fall-back is the tempting wrong answer and is worth naming: it looks
|
||||
* harmless, it keeps every un-migrated route working, and it is the reverse
|
||||
* data-mixing failure. A delegate reading what they believe is the owner's
|
||||
* record would be shown their own; a delegate writing what they believe is the
|
||||
* owner's measurement would write it into theirs. An inaccessible route is an
|
||||
* inconvenience. A route that answers with the wrong person's health record is
|
||||
* the product being wrong about the only thing it does.
|
||||
*
|
||||
* @param requiredPermission Optional permission scope. Only enforced for Bearer
|
||||
* auth — cookie sessions always pass (full user access). Omitting it is a
|
||||
* positive declaration: the route accepts cookie sessions and cookie-
|
||||
@@ -323,6 +431,25 @@ export type AuthContext = {
|
||||
*/
|
||||
export async function requireAuth(
|
||||
requiredPermission?: string,
|
||||
): Promise<AuthContext> {
|
||||
const auth = await authenticateCaller(requiredPermission);
|
||||
const carrier = await readActingCarrier(auth);
|
||||
if (carrier.kind !== "none") {
|
||||
throw refuseUndeclaredMode(auth, carrier);
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the caller as themselves. The pre-v1.36.0 `requireAuth` body,
|
||||
* unchanged, split out so the three declared modes above it can share it.
|
||||
*
|
||||
* This function knows nothing about acting accounts and must not learn: it is
|
||||
* the thing every mode agrees on before they disagree about what the request
|
||||
* is allowed to reach.
|
||||
*/
|
||||
async function authenticateCaller(
|
||||
requiredPermission?: string,
|
||||
): Promise<AuthContext> {
|
||||
// 1. Session cookie path — unchanged.
|
||||
const sessionData = await getSession();
|
||||
@@ -466,10 +593,312 @@ async function authenticateBearer(
|
||||
};
|
||||
}
|
||||
|
||||
// ── The acting-account resolver ─────────────────────────────────────────────
|
||||
//
|
||||
// One function decides, for every transport, whether this request acts on
|
||||
// somebody else's record — the same argument that put `resolveBearerToken` in
|
||||
// one file for two wires. Two carriers converge here because they are two
|
||||
// answers to one question, and two answers that live apart drift.
|
||||
//
|
||||
// Neither carrier authorises anything. Both merely NAME an account; the grant
|
||||
// table decides, on this request, with no cached verdict anywhere. The
|
||||
// difference between them is who can set them:
|
||||
//
|
||||
// * The cookie transport carries it on the session ROW (`actingAsUserId`).
|
||||
// A browser cannot address that row — the cookie holds a secret whose hash
|
||||
// is the lookup key — so the value is server state, written only by the
|
||||
// switch endpoint after it has validated the grant.
|
||||
// * The Bearer transport carries it in a per-request HEADER. A long-lived
|
||||
// token must not accumulate switch state: stamping it on the ApiToken row
|
||||
// would make the credential itself ambient authority, and the token has to
|
||||
// keep meaning "this person" for as long as it exists.
|
||||
//
|
||||
// The header is a Bearer-only carrier. On a cookie request it is refused, not
|
||||
// ignored — a client that sends a selector over a transport that does not read
|
||||
// one believes it is selecting an account and is not, and the failure of that
|
||||
// belief is a support case about data that looks wrong.
|
||||
|
||||
/**
|
||||
* The per-request account selector, on the Bearer transport only.
|
||||
*
|
||||
* Cross-origin JavaScript cannot set it: a custom request header forces a CORS
|
||||
* preflight and this app sends no CORS headers at all, so the preflight is
|
||||
* never answered. It is a selector regardless — the grant table is what
|
||||
* authorises, and the header is treated as hostile input everywhere below.
|
||||
*/
|
||||
export const ACCOUNT_SELECTOR_HEADER = "x-healthlog-account";
|
||||
|
||||
/**
|
||||
* Longest selector value worth a database round trip. Account ids are 25-char
|
||||
* cuids; anything past this names no account that could exist and is refused
|
||||
* as such, on the same path and with the same bytes as an unknown account.
|
||||
*/
|
||||
const MAX_SELECTOR_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* What, if anything, this request says it is acting as.
|
||||
*
|
||||
* `misplaced-header` is its own case rather than an error thrown at read time
|
||||
* so that every mode below decides for itself — the modes disagree about the
|
||||
* session carrier and must not disagree about this one by accident.
|
||||
*/
|
||||
type ActingCarrier =
|
||||
| { kind: "none" }
|
||||
| { kind: "session"; accountId: string }
|
||||
| { kind: "header"; accountId: string }
|
||||
| { kind: "misplaced-header" };
|
||||
|
||||
/** The selector header, or null when the request did not send one. */
|
||||
async function readSelectorHeader(): Promise<string | null> {
|
||||
try {
|
||||
const headerList = await headers();
|
||||
return headerList.get(ACCOUNT_SELECTOR_HEADER);
|
||||
} catch {
|
||||
// Outside a Next.js request scope (direct unit invocation of a legacy
|
||||
// route) there is no header to read. Same posture as the Bearer branch of
|
||||
// `authenticateCaller`.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The acting-account carrier for this request.
|
||||
*
|
||||
* The session value rides out of `getSession()` on the row that call already
|
||||
* loaded — a second query for a column we have just read would be waste on
|
||||
* every cookie request in the app. What does NOT ride out of `getSession()` is
|
||||
* a substituted user: it keeps answering "who is calling" and nothing else,
|
||||
* which is exactly why `requireAdmin`, `requireCookieAuth` and
|
||||
* `requireFreshMfa` are unreachable by a switch. The carrier is an id to be
|
||||
* checked, not a decision that has been made.
|
||||
*
|
||||
* Fresh on every request, by construction: `getSession()` reads the row from
|
||||
* Postgres each time, so a switch cleared by a revocation is gone from the very
|
||||
* next request rather than from the next login.
|
||||
*/
|
||||
async function readActingCarrier(auth: AuthContext): Promise<ActingCarrier> {
|
||||
const header = await readSelectorHeader();
|
||||
|
||||
if (auth.authMethod === "bearer") {
|
||||
// No session row exists on this transport, so the header is the only
|
||||
// carrier there could be.
|
||||
return header === null
|
||||
? { kind: "none" }
|
||||
: { kind: "header", accountId: header };
|
||||
}
|
||||
|
||||
if (header !== null) return { kind: "misplaced-header" };
|
||||
|
||||
const stamped = auth.session.actingAsUserId ?? null;
|
||||
return stamped === null
|
||||
? { kind: "none" }
|
||||
: { kind: "session", accountId: stamped };
|
||||
}
|
||||
|
||||
/** Could this value name an account at all? */
|
||||
function selectorNamesAnAccount(value: string): boolean {
|
||||
return value.length > 0 && value.length <= MAX_SELECTOR_LENGTH;
|
||||
}
|
||||
|
||||
/** The account a carrier names, or null when it names nothing. */
|
||||
function carrierTarget(carrier: ActingCarrier): string | null {
|
||||
return "accountId" in carrier ? carrier.accountId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a refused delegation.
|
||||
*
|
||||
* Written for the OWNER's benefit as much as the operator's: "someone tried to
|
||||
* open my record and was turned away" is the question the sharing panel exists
|
||||
* to answer. The row names the actor (the caller), the account they aimed at,
|
||||
* and why it failed — none of which reaches the response.
|
||||
*
|
||||
* Fire-and-forget: an audit write that fails must not convert a 403 into a 500,
|
||||
* because the 403 is the part that protects the record.
|
||||
*/
|
||||
function auditRefusal(
|
||||
auth: AuthContext,
|
||||
carrier: ActingCarrier,
|
||||
reason: string,
|
||||
): void {
|
||||
auditLog("sharing.access.denied", {
|
||||
userId: auth.user.id,
|
||||
details: {
|
||||
reason,
|
||||
carrier: carrier.kind,
|
||||
target: carrierTarget(carrier),
|
||||
method: getEvent()?.getHttpMethod() ?? null,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* This request says it is acting on another account and this route will not.
|
||||
*
|
||||
* Two ways to arrive: a route that never declared a mode (`undeclared_mode`),
|
||||
* and a selector sent over the transport that does not carry one
|
||||
* (`misplaced_selector`). One response either way — the caller learns that the
|
||||
* request will not be served, and nothing about grants.
|
||||
*
|
||||
* The audit rule, stated once because it is a judgement call: a HEADER refusal
|
||||
* always writes a row, a SESSION refusal never does. The header is a per-request
|
||||
* assertion by a client — a bug or a probe, and both are worth a durable record.
|
||||
* The session field is ambient browser state: while a switch is on, every
|
||||
* navigation, prefetch and poll that touches a non-delegable route lands here,
|
||||
* and a row per such request would bury the header rows that matter under noise
|
||||
* from the feature working as designed. Both are on the wide event either way.
|
||||
*/
|
||||
function refuseUndeclaredMode(
|
||||
auth: AuthContext,
|
||||
carrier: ActingCarrier,
|
||||
reason: "undeclared_mode" | "misplaced_selector" = "undeclared_mode",
|
||||
): SharingNotPermittedError {
|
||||
annotate({ meta: { sharing_refusal: reason } });
|
||||
if (carrier.kind !== "session") {
|
||||
auditRefusal(auth, carrier, reason);
|
||||
}
|
||||
return new SharingNotPermittedError();
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.36.0 — an actor surface: this route always serves the CALLER's own rows.
|
||||
*
|
||||
* It works under a switch. That is the whole reason the mode exists: the
|
||||
* switcher itself, the banner, and the "which records may I open" list all have
|
||||
* to keep answering while the browser is acting as someone else, and every one
|
||||
* of them is about the delegate. So the session carrier is read and
|
||||
* deliberately ignored — no query, because there is nothing the answer could
|
||||
* change.
|
||||
*
|
||||
* A selector HEADER is refused, loudly. An actor surface is DEFINED as serving
|
||||
* the caller's own rows, so a client that attaches a selector to one has a bug,
|
||||
* and the bug is invisible until somebody files a support ticket about data
|
||||
* that looks wrong. Ignoring the header would serve exactly the right response
|
||||
* to exactly the wrong expectation.
|
||||
*/
|
||||
export async function requireActorAuth(): Promise<AuthContext> {
|
||||
const auth = await authenticateCaller();
|
||||
const header = await readSelectorHeader();
|
||||
if (header !== null) {
|
||||
annotate({ meta: { sharing_refusal: "actor_surface" } });
|
||||
auditRefusal(auth, { kind: "header", accountId: header }, "actor_surface");
|
||||
throw new SharingNotPermittedError();
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* A request resolved against a record that may not be the caller's own.
|
||||
*
|
||||
* `user` is the account whose rows the handler must read and write — so an
|
||||
* existing `where: { userId: user.id }` is correct without being touched, which
|
||||
* is what keeps a route migration a one-line change instead of an audit.
|
||||
* `actor` is who is actually here. They are the same object when no switch is
|
||||
* active.
|
||||
*/
|
||||
export interface RecordAuthContext extends AuthContext {
|
||||
/** The authenticated caller. Equals `user` when the caller acts as themselves. */
|
||||
readonly actor: User;
|
||||
/** The grant being exercised, or null when the caller acts as themselves. */
|
||||
readonly grantId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.36.0 — a delegable surface: this route may act on a shared record.
|
||||
*
|
||||
* Calling it is a DECLARATION, reviewed and frozen by a structural guard, that
|
||||
* the route reads or writes health data and nothing else — no credential, no
|
||||
* integration, no notification channel, no grant management, nothing that would
|
||||
* let a delegate extend their own reach. The declaration is the security
|
||||
* property; this function only enforces what was declared.
|
||||
*
|
||||
* @param need what the route does to the record. `"read"` still refuses a
|
||||
* non-safe HTTP method under a read-only grant: the declaration and the
|
||||
* method must BOTH be satisfiable, so a delegable GET handler that grows a
|
||||
* POST export cannot quietly inherit the read grant's permission. An unknown
|
||||
* method (no event context) counts as a write and is refused, the same
|
||||
* fail-closed posture as the MCP audience guard above.
|
||||
*/
|
||||
export async function requireRecordAuth(
|
||||
need: GrantNeed,
|
||||
): Promise<RecordAuthContext> {
|
||||
const auth = await authenticateCaller();
|
||||
const carrier = await readActingCarrier(auth);
|
||||
|
||||
if (carrier.kind === "none") {
|
||||
// Do no harm: without a carrier this is byte-for-byte the pre-v1.36.0
|
||||
// request, resolved by the same `authenticateCaller` every other mode uses.
|
||||
return { ...auth, actor: auth.user, grantId: null };
|
||||
}
|
||||
if (carrier.kind === "misplaced-header") {
|
||||
// The route DID declare a mode; the client sent the selector over the
|
||||
// cookie transport, where the carrier is the session row.
|
||||
throw refuseUndeclaredMode(auth, carrier, "misplaced_selector");
|
||||
}
|
||||
|
||||
/** Every refusal below is this one error, with the reason kept off the wire. */
|
||||
const denied = (reason: string): SharingAccessDeniedError => {
|
||||
annotate({ meta: { sharing_refusal: reason } });
|
||||
auditRefusal(auth, carrier, reason);
|
||||
return new SharingAccessDeniedError();
|
||||
};
|
||||
|
||||
if (!selectorNamesAnAccount(carrier.accountId)) {
|
||||
throw denied("malformed_selector");
|
||||
}
|
||||
|
||||
const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase();
|
||||
const effectiveNeed: GrantNeed =
|
||||
need === "write" || !READ_HTTP_METHODS.has(method) ? "write" : "read";
|
||||
|
||||
// Loaded here, on this request, every request. Not memoised on the session,
|
||||
// not cached in the process, not carried on the token: a revocation has to
|
||||
// land on the delegate's NEXT request, not on their next login. That is the
|
||||
// one property the owner is actually promised when they press revoke.
|
||||
//
|
||||
// Note what is NOT looked up: the account named by the carrier. An account
|
||||
// that does not exist and an account that granted nothing produce the same
|
||||
// empty row from the same query, which is what makes the two refusals
|
||||
// identical in bytes and in timing rather than identical by careful wording.
|
||||
const grant = await findActiveGrant({
|
||||
grantorId: carrier.accountId,
|
||||
granteeId: auth.user.id,
|
||||
});
|
||||
if (!grant) throw denied("no_active_grant");
|
||||
if (!grantAllows(grant, effectiveNeed)) throw denied("insufficient_access");
|
||||
|
||||
const owner = await prisma.user.findUnique({
|
||||
where: { id: grant.grantorId },
|
||||
});
|
||||
// Unreachable while the FK cascade holds — a grant naming a deleted account
|
||||
// is deleted with it. Fail closed anyway: an authorization we cannot resolve
|
||||
// to a person is not one to act on.
|
||||
if (!owner) throw denied("owner_missing");
|
||||
|
||||
getEvent()?.setActingAs(owner.id);
|
||||
// Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait
|
||||
// on the bookkeeping, and the bookkeeping failing must not fail the read.
|
||||
void touchGrantUsage(grant.id);
|
||||
|
||||
return {
|
||||
session: auth.session,
|
||||
user: owner,
|
||||
authMethod: auth.authMethod,
|
||||
actor: auth.user,
|
||||
grantId: grant.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Require an authenticated admin user. Throws HttpError(401) or HttpError(403).
|
||||
* Cookie-only — Bearer tokens never elevate to admin (security boundary).
|
||||
* Automatically annotates the Wide Event with auth context.
|
||||
*
|
||||
* v1.36.0 — unreachable by a switch, structurally: it resolves through
|
||||
* `getSession()`, which knows only who is calling, and the role is read off
|
||||
* that row. Switching into an ADMIN's record therefore confers nothing, and
|
||||
* making it confer something would take a deliberate edit here rather than an
|
||||
* oversight elsewhere.
|
||||
*/
|
||||
export async function requireAdmin(): Promise<AuthContext> {
|
||||
const sessionData = await getSession();
|
||||
|
||||
+22
-2
@@ -246,7 +246,7 @@ export async function createSession(
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<{
|
||||
session: { id: string; expiresAt: Date };
|
||||
session: { id: string; expiresAt: Date; actingAsUserId: string | null };
|
||||
user: User;
|
||||
} | null> {
|
||||
try {
|
||||
@@ -343,7 +343,27 @@ export async function getSession(): Promise<{
|
||||
}
|
||||
|
||||
return {
|
||||
session: { id: session.id, expiresAt: session.expiresAt },
|
||||
session: {
|
||||
id: session.id,
|
||||
expiresAt: session.expiresAt,
|
||||
// v1.36.0 — the acting-account CARRIER, projected because the row is
|
||||
// already in hand and re-reading it per request would be waste.
|
||||
//
|
||||
// Read this in exactly one place: the acting-account resolver in
|
||||
// `src/lib/api-handler.ts`. It is a selector and not a decision — the
|
||||
// resolver still has to find a live grant for the pair before anything
|
||||
// is substituted, and a value stranded here by a grant that has since
|
||||
// ended confers nothing.
|
||||
//
|
||||
// What this function returns for `user` does NOT change and must not:
|
||||
// `getSession()` answers "who is calling", and `requireAdmin`,
|
||||
// `requireCookieAuth` and `requireFreshMfa` are unreachable by a switch
|
||||
// precisely because that is the only thing it answers. Substituting the
|
||||
// owner here would put a delegate inside the admin check.
|
||||
// `src/__tests__/acting-account-boundary-guard.test.ts` freezes the set
|
||||
// of files allowed to touch this field.
|
||||
actingAsUserId: session.actingAsUserId,
|
||||
},
|
||||
user: session.user,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,6 +90,18 @@ export class WideEventBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.36.0 — record that this request acted on another account's record.
|
||||
*
|
||||
* Merges rather than replaces, because the actor identity is already on the
|
||||
* event by the time the acting account is resolved and overwriting it is the
|
||||
* exact confusion the two fields exist to prevent.
|
||||
*/
|
||||
setActingAs(userId: string): this {
|
||||
this.event.auth = { ...(this.event.auth ?? {}), acting_as: userId };
|
||||
return this;
|
||||
}
|
||||
|
||||
setAction(action: WideEvent["action"]): this {
|
||||
this.event.action = action;
|
||||
return this;
|
||||
|
||||
@@ -58,6 +58,14 @@ export interface WideEvent {
|
||||
| "cron_secret"
|
||||
| "telegram_webhook"
|
||||
| "webhook_secret";
|
||||
/**
|
||||
* v1.36.0 — the account whose record this request read or wrote, when it
|
||||
* is not the caller's own. `user_id` above always stays the ACTOR: the
|
||||
* person who authenticated. Two fields rather than one swapped field,
|
||||
* because "who did it" and "whose record" are different questions and a
|
||||
* dashboard that conflates them cannot answer either.
|
||||
*/
|
||||
acting_as?: string;
|
||||
};
|
||||
|
||||
// Business-Daten (was wurde getan)
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* The acting-account resolver against real Postgres.
|
||||
*
|
||||
* The unit suite proves the arms. This file proves the two things a unit suite
|
||||
* cannot, because both are about which ROWS come back:
|
||||
*
|
||||
* * **Substitution.** Under a valid switch the handler's `where` clause is
|
||||
* built from the owner's id and returns the owner's measurements — not a
|
||||
* mock's idea of them, and demonstrably not the delegate's own, which are
|
||||
* seeded alongside so a resolver that quietly served the caller their own
|
||||
* record would show up as the wrong ids rather than as an empty list.
|
||||
* * **Nothing is cached.** A revocation lands between two requests inside one
|
||||
* test and the second one 403s. Anything memoised — on the session, in the
|
||||
* process, on the token — would let the first request's verdict survive
|
||||
* into the second, and the owner's revoke button would mean "at some point
|
||||
* later" instead of "now".
|
||||
*
|
||||
* The do-no-harm arm runs against a REAL shipped route (`GET /api/measurements`,
|
||||
* a bare-`requireAuth` handler nobody has migrated). A mock cannot fake that,
|
||||
* and the arm is worth nothing unless the route it exercises is one somebody
|
||||
* actually ships.
|
||||
*
|
||||
* The delegable handler is assembled here rather than imported because no route
|
||||
* declares `requireRecordAuth` yet — S3 lands the frozen list empty on purpose,
|
||||
* and S6 migrates the first route into it. Everything under it is real: the
|
||||
* real `apiHandler`, the real resolver, the real grant module, the real
|
||||
* database.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import {
|
||||
ACCOUNT_SELECTOR_HEADER,
|
||||
apiHandler,
|
||||
requireRecordAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { acceptGrant, inviteGrant, revokeGrant } from "@/lib/sharing/grants";
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function makeUser(label: string) {
|
||||
const suffix = `${label}-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `acting-${suffix}`,
|
||||
email: `acting-${suffix}@example.test`,
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function mintToken(userId: string): Promise<string> {
|
||||
const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0");
|
||||
await getPrismaClient().apiToken.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "acting-account-test",
|
||||
tokenHash: hashToken(raw),
|
||||
permissions: ["*"],
|
||||
},
|
||||
});
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function seedWeight(userId: string, value: number) {
|
||||
return getPrismaClient().measurement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "WEIGHT",
|
||||
value,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(),
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A delegable read handler: the real wrapper, the real resolver, and a `where`
|
||||
* clause built from the resolved data-scope user exactly as a migrated route
|
||||
* builds one.
|
||||
*/
|
||||
const delegableRead: (request: NextRequest) => Promise<Response> = apiHandler(
|
||||
async () => {
|
||||
const { user, actor, grantId } = await requireRecordAuth("read");
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { value: "asc" },
|
||||
});
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
scopeUserId: user.id,
|
||||
actorUserId: actor.id,
|
||||
grantId,
|
||||
values: rows.map((r) => r.value),
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
function call(method = "GET"): Promise<Response> {
|
||||
return delegableRead(
|
||||
new NextRequest("http://localhost/api/test/delegable", { method }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Owner and delegate, an accepted grant, and one measurement each. */
|
||||
async function household() {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await seedWeight(owner.id, 81);
|
||||
await seedWeight(delegate.id, 64);
|
||||
const invited = await inviteGrant({
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
});
|
||||
const grant = await acceptGrant({
|
||||
grantId: invited.id,
|
||||
granteeId: delegate.id,
|
||||
ip: "203.0.113.7",
|
||||
});
|
||||
return { owner, delegate, grant };
|
||||
}
|
||||
|
||||
/** Point the delegate's browser session at the owner's record. */
|
||||
async function switchTo(sessionId: string, ownerId: string | null) {
|
||||
await getPrismaClient().session.update({
|
||||
where: { id: sessionId },
|
||||
data: { actingAsUserId: ownerId },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("acting-account resolver — substitution", () => {
|
||||
it("returns the OWNER's rows to a switched delegate, not the delegate's own", async () => {
|
||||
const { owner, delegate, grant } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
await switchTo(session.id, owner.id);
|
||||
|
||||
const body = await (await call()).json();
|
||||
expect(body.data.scopeUserId).toBe(owner.id);
|
||||
expect(body.data.actorUserId).toBe(delegate.id);
|
||||
expect(body.data.grantId).toBe(grant.id);
|
||||
// The delegate's own 64 kg row exists and must not be in this answer. An
|
||||
// empty list would have passed a weaker assertion while hiding the exact
|
||||
// failure this feature has to avoid.
|
||||
expect(body.data.values).toEqual([81]);
|
||||
});
|
||||
|
||||
it("substitutes over the Bearer selector too", async () => {
|
||||
const { owner, delegate } = await household();
|
||||
const raw = await mintToken(delegate.id);
|
||||
headerJar.set("authorization", `Bearer ${raw}`);
|
||||
headerJar.set(ACCOUNT_SELECTOR_HEADER, owner.id);
|
||||
|
||||
const body = await (await call()).json();
|
||||
expect(body.data.scopeUserId).toBe(owner.id);
|
||||
expect(body.data.values).toEqual([81]);
|
||||
});
|
||||
|
||||
it("stamps the grant as used", async () => {
|
||||
const { owner, delegate, grant } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
await switchTo(session.id, owner.id);
|
||||
|
||||
await call();
|
||||
// Fire-and-forget, so give the stamp a tick to land before reading it.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
||||
where: { id: grant.id },
|
||||
});
|
||||
expect(row.lastUsedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("serves the caller their own rows when nothing is switched", async () => {
|
||||
const { delegate } = await household();
|
||||
await signIn(delegate.id);
|
||||
|
||||
const body = await (await call()).json();
|
||||
expect(body.data.scopeUserId).toBe(delegate.id);
|
||||
expect(body.data.grantId).toBeNull();
|
||||
expect(body.data.values).toEqual([64]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("acting-account resolver — the decision is never cached", () => {
|
||||
it("refuses the request after the one that came before it succeeded", async () => {
|
||||
const { owner, delegate, grant } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
await switchTo(session.id, owner.id);
|
||||
|
||||
const first = await call();
|
||||
expect(first.status).toBe(200);
|
||||
expect((await first.json()).data.values).toEqual([81]);
|
||||
|
||||
await revokeGrant({ grantId: grant.id, grantorId: owner.id });
|
||||
|
||||
const second = await call();
|
||||
expect(second.status).toBe(403);
|
||||
expect((await second.json()).meta.errorCode).toBe("sharing.access.denied");
|
||||
});
|
||||
|
||||
it("stops on expiry with nothing having swept anything", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await seedWeight(owner.id, 81);
|
||||
const invited = await inviteGrant({
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
expiresAt: new Date(Date.now() + 400),
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
|
||||
const session = await signIn(delegate.id);
|
||||
await switchTo(session.id, owner.id);
|
||||
|
||||
expect((await call()).status).toBe(200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
// The row did not change. Only the clock the resolver reads moved.
|
||||
expect((await call()).status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses the moment the grant is only pending", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await inviteGrant({ grantorId: owner.id, granteeId: delegate.id });
|
||||
const session = await signIn(delegate.id);
|
||||
await switchTo(session.id, owner.id);
|
||||
|
||||
expect((await call()).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("acting-account resolver — do no harm", () => {
|
||||
it("leaves an un-switched request on a real shipped route exactly as it was", async () => {
|
||||
const user = await makeUser("solo");
|
||||
await seedWeight(user.id, 72);
|
||||
await signIn(user.id);
|
||||
|
||||
const { GET } = await import("@/app/api/measurements/route");
|
||||
const response = await GET(
|
||||
new NextRequest("http://localhost/api/measurements?type=WEIGHT"),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(
|
||||
body.data.measurements.map((m: { value: number }) => m.value),
|
||||
).toEqual([72]);
|
||||
});
|
||||
|
||||
it("refuses that same route while a switch is active, and serves nobody's rows", async () => {
|
||||
const { owner, delegate } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
await switchTo(session.id, owner.id);
|
||||
|
||||
const { GET } = await import("@/app/api/measurements/route");
|
||||
const response = await GET(
|
||||
new NextRequest("http://localhost/api/measurements?type=WEIGHT"),
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.meta.errorCode).toBe("sharing.not_permitted");
|
||||
expect(body.data).toBeNull();
|
||||
// Not the owner's rows, and — the failure that matters — not the
|
||||
// delegate's own either.
|
||||
expect(JSON.stringify(body)).not.toContain("64");
|
||||
});
|
||||
});
|
||||
|
||||
describe("acting-account resolver — refusals do not enumerate", () => {
|
||||
it("answers a nonexistent account and a stranger's account with the same bytes", async () => {
|
||||
const { delegate } = await household();
|
||||
const stranger = await makeUser("stranger");
|
||||
await seedWeight(stranger.id, 99);
|
||||
const raw = await mintToken(delegate.id);
|
||||
headerJar.set("authorization", `Bearer ${raw}`);
|
||||
|
||||
headerJar.set(ACCOUNT_SELECTOR_HEADER, "cmnosuchaccount000000000x");
|
||||
const missing = await call();
|
||||
const missingBody = await missing.text();
|
||||
|
||||
headerJar.set(ACCOUNT_SELECTOR_HEADER, stranger.id);
|
||||
const present = await call();
|
||||
const presentBody = await present.text();
|
||||
|
||||
expect(missing.status).toBe(403);
|
||||
expect(present.status).toBe(403);
|
||||
expect(missingBody).toBe(presentBody);
|
||||
});
|
||||
});
|
||||
|
||||
describe("acting-account resolver — the cookie-only helpers", () => {
|
||||
it("resolves the actor under a live switch, against the real session row", async () => {
|
||||
// The unit suite asserts this with `getSession` mocked, which cannot catch
|
||||
// the one edit that would break it: `getSession` itself substituting the
|
||||
// owner. Here the row is real, the switch is real, and the grant behind it
|
||||
// is valid — so the only reason these helpers answer with the delegate is
|
||||
// that `getSession` still answers "who is calling".
|
||||
const { owner, delegate } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
await switchTo(session.id, owner.id);
|
||||
|
||||
const { requireAdmin, requireCookieAuth } =
|
||||
await import("@/lib/api-handler");
|
||||
const cookieCtx = await requireCookieAuth();
|
||||
expect(cookieCtx.user.id).toBe(delegate.id);
|
||||
|
||||
// The delegate is not an admin; the refusal proves the role was read off
|
||||
// the caller's row and not the record owner's.
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: owner.id },
|
||||
data: { role: "ADMIN" },
|
||||
});
|
||||
await expect(requireAdmin()).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("acting-account resolver — the carrier column", () => {
|
||||
it("puts the delegate's browser back in its own account when the owner is deleted", async () => {
|
||||
const { owner, delegate } = await household();
|
||||
const session = await signIn(delegate.id);
|
||||
await switchTo(session.id, owner.id);
|
||||
|
||||
await getPrismaClient().user.delete({ where: { id: owner.id } });
|
||||
|
||||
const row = await getPrismaClient().session.findUnique({
|
||||
where: { id: session.id },
|
||||
});
|
||||
// The session survives (it belongs to the delegate) with the switch
|
||||
// cleared. A CASCADE here would have signed the delegate out of their own
|
||||
// account because somebody else deleted theirs.
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.actingAsUserId).toBeNull();
|
||||
|
||||
const body = await (await call()).json();
|
||||
expect(body.data.scopeUserId).toBe(delegate.id);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user