From 533400ce60d4cc058c4441f19f2cbac23fd25c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 10:12:08 +0200 Subject: [PATCH] Let an invitation carry the level it offers A grant has always had an access column and `inviteGrant` has always written READ into it. That was the right way to ship the first version: "this release never grants write" was a property of the file rather than a promise in a comment. Remove it deliberately now, so somebody sharing their record can offer reading or reading plus adding. The level is a required argument to `inviteGrant` and is defaulted in exactly one place, the invite schema, where an omitted field becomes READ. An older client's payload therefore stays valid and stays narrow, and the domain module never guesses a level nobody named. There is no upgrade path and that is the decision, not a gap. A grant is READ or WRITE from the moment it is offered and stays that way until somebody ends it. Widening a live one would change what the delegate agreed to without asking them again, and their consent is half of what makes a write grant legitimate. The way up is a new invitation, accepted again. The audit row and the wide event carry the level: "who was given what, and by whom" is the question that row exists to answer. --- docs/api/openapi.yaml | 28 +++- .../__tests__/invite-access-level.test.ts | 155 ++++++++++++++++++ src/app/api/account/grants/route.ts | 26 ++- src/lib/openapi/routes/account-sharing.ts | 8 +- src/lib/sharing/__tests__/grants.test.ts | 81 +++++++++ src/lib/sharing/account-access.ts | 11 +- src/lib/sharing/grants.ts | 47 ++++-- src/lib/validations/account-sharing.ts | 21 ++- .../account-grant-lifecycle.test.ts | 68 +++++++- .../acting-account-resolver.test.ts | 8 +- tests/integration/sharing-audit-actor.test.ts | 2 + 11 files changed, 408 insertions(+), 47 deletions(-) create mode 100644 src/app/api/account/grants/__tests__/invite-access-level.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index dd594f976..aa2d46dc1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12507,12 +12507,12 @@ paths: post: tags: - Account sharing - summary: Invite an account to read this record - description: Offers read access to somebody already registered on this instance; the grant confers nothing until they - accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller - on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a - mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor - re-delegate. + summary: Invite an account to this record + description: Offers access to somebody already registered on this instance, at READ or WRITE; the grant confers nothing + until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an + authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a + silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can + neither invite nor re-delegate. requestBody: required: true content: @@ -12530,6 +12530,17 @@ paths: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + access: + default: READ + description: "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, + observations, a medication, a marked dose) and can still edit or delete nothing, including its own. + Omitted means READ, so a client that predates the field keeps working. The level is fixed when the + invitation is written: no endpoint raises a live grant, because that would widen what the delegate + accepted without asking them again. The way up is a new invitation the delegate accepts." + type: string + enum: + - READ + - WRITE required: - identifier responses: @@ -36689,8 +36700,9 @@ components: - write canWrite: type: boolean - description: Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it; - never derive it. + description: "Whether this caller may ADD to that record: true for an accepted WRITE grant, false for a READ one. It + never means edit or delete, which stay with the owner at both levels. Resolved server-side. Render it; never + derive it." required: - accountId - username diff --git a/src/app/api/account/grants/__tests__/invite-access-level.test.ts b/src/app/api/account/grants/__tests__/invite-access-level.test.ts new file mode 100644 index 000000000..2507d194e --- /dev/null +++ b/src/app/api/account/grants/__tests__/invite-access-level.test.ts @@ -0,0 +1,155 @@ +/** + * `POST /api/account/grants` — the level a request carries is the level the row + * gets. + * + * The assertion is deliberately on the WRITE the route performs rather than on + * the JSON it answers with: a response echoing the request would pass either + * way, and the failure this pins is the one where a field is validated, + * annotated on, and then dropped on the way to the database. So every case + * reads the `data` object handed to `accountGrant.create`. + * + * Where the default lives matters and is asserted here rather than assumed: an + * omitted `access` becomes READ in the invite schema and nowhere else — + * `inviteGrant` takes the level as a required argument. Move the default and + * the third case below goes red. + * + * What this file cannot see: the browser. That the level a person picks on the + * invitation form reaches this route at all is proven in + * `e2e/account-sharing.spec.ts`, which reads the posted body — the component + * test renders the control and stays green with the control wired to a + * constant. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>(h: T): T => + h, + requireAuth: vi.fn(), + }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + accountGrant: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { POST } from "../route"; +import { requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; + +const OWNER = { id: "owner-1" }; +const INVITEE = { id: "invitee-1", username: "housemate", displayName: null }; + +function request(body: Record): NextRequest { + return new NextRequest("http://localhost/api/account/grants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** The `data` object the route's write reached the client with. */ +function writtenRow(): Record { + const calls = vi.mocked(prisma.accountGrant.create).mock.calls; + expect(calls).toHaveLength(1); + return (calls[0][0] as { data: Record }).data; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAuth).mockResolvedValue({ + user: OWNER, + } as unknown as Awaited>); + vi.mocked(prisma.user.findFirst).mockResolvedValue( + INVITEE as never as Awaited>, + ); + vi.mocked(prisma.accountGrant.create).mockImplementation((async (args: { + data: Record; + }) => ({ + id: "grant-1", + grantorId: OWNER.id, + granteeId: INVITEE.id, + acceptedAt: null, + revokedAt: null, + revokedBy: null, + lastUsedAt: null, + expiresAt: null, + ...args.data, + })) as never); +}); + +describe("invitation access level", () => { + it("stores WRITE when the invitation offers it", async () => { + const res = await POST( + request({ identifier: "housemate", access: "WRITE" }), + ); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("WRITE"); + const body = (await res.json()) as { data: { access: string } }; + expect(body.data.access).toBe("WRITE"); + }); + + it("stores READ when the invitation offers it", async () => { + const res = await POST( + request({ identifier: "housemate", access: "READ" }), + ); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("READ"); + }); + + it("mints READ for a client that does not send the field", async () => { + // An older client's payload stays valid, and it stays NARROW. This is the + // case that must never widen by accident. + const res = await POST(request({ identifier: "housemate" })); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("READ"); + }); + + it("refuses a level that is not one of the two, without writing a row", async () => { + const res = await POST( + request({ identifier: "housemate", access: "ADMIN" }), + ); + + expect(res.status).toBe(422); + expect(prisma.accountGrant.create).not.toHaveBeenCalled(); + }); + + it("records the level on the audit row", async () => { + // "Who was given what, and by whom" is the question this row answers. An + // entry naming the invitation without its level answers half of it. + await POST(request({ identifier: "housemate", access: "WRITE" })); + + expect(auditLog).toHaveBeenCalledWith( + "sharing.grant.invited", + expect.objectContaining({ + userId: OWNER.id, + details: expect.objectContaining({ access: "WRITE" }), + }), + ); + }); +}); diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 8d5c785a8..8e92d7c15 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -6,7 +6,10 @@ * one question — "who can see my record, and whose can I see" — and because a * client that has to call twice will eventually render half an answer. * - * `POST` offers a grant to somebody already registered on this instance. + * `POST` offers a grant to somebody already registered on this instance, at + * the level the owner chose. There is no route that raises a live grant: the + * level is settled when the invitation is written and the only way to a wider + * one is a fresh invitation the delegate accepts again. * * **Neither is delegable, and that is structural.** Both resolve through bare * `requireAuth()`, which refuses outright while the caller is acting on @@ -111,7 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const { identifier, expiresAt } = parsed.data; + const { identifier, expiresAt, access } = parsed.data; // Either column, case-insensitively — the same lookup the login form does, // so the name somebody signs in with is the name they can be invited by. @@ -129,18 +132,30 @@ export const POST = apiHandler(async (request: NextRequest) => { } try { - // `inviteGrant` decides everything about the row: READ and only READ, the + // `inviteGrant` decides everything about the row except the level: the // self-grant refusal, and the one-live-grant-per-pair rule the partial - // unique index backs. None of it is re-decided here. + // unique index backs. Neither is re-decided here. The level travels from + // the parsed body, where the schema is the one place an omitted field + // becomes READ, and it is fixed for the life of the row — the domain + // module has no transition that raises it. const grant = await inviteGrant({ grantorId: user.id, granteeId: invitee.id, + access, expiresAt: expiresAt ? new Date(expiresAt) : null, }); + // The level is on the audit row, not only in the response: "who was given + // what, and by whom" is the question this row exists to answer, and an + // entry that recorded the invitation without its level would answer half + // of it. await auditLog("sharing.grant.invited", { userId: user.id, - details: { grantId: grant.id, granteeId: invitee.id }, + details: { + grantId: grant.id, + granteeId: invitee.id, + access: grant.access, + }, ipAddress: getClientIp(request), }).catch(() => {}); @@ -148,6 +163,7 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "sharing.grant.invited" }, meta: { grant_id: grant.id, + access: grant.access, expires: expiresAt !== null && expiresAt !== undefined, }, }); diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index d8224ce36..7ec3bb253 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -32,7 +32,7 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; inviteGrantSchema.meta({ id: "AccountGrantInvite", description: - "Offer read access to another account on this instance. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. The access level is not a parameter: v1 grants READ only.", + "Offer access to another account on this instance. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. `access` is READ when omitted, so a client that does not know about the field keeps working: READ can read the record and change nothing, WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. The level is fixed when the invitation is written — no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts.", }); switchAccountSchema.meta({ @@ -205,7 +205,7 @@ const accountAccessEntry = z canWrite: z .boolean() .describe( - "Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it; never derive it.", + "Whether this caller may ADD to that record: true for an accepted WRITE grant, false for a READ one. It never means edit or delete, which stay with the owner at both levels. Resolved server-side. Render it; never derive it.", ), }) .meta({ @@ -327,9 +327,9 @@ export const accountSharingPaths: NonNullable = { }, post: { tags: ["Account sharing"], - summary: "Invite an account to read this record", + summary: "Invite an account to this record", description: - "Offers read access to somebody already registered on this instance; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate.", + "Offers access to somebody already registered on this instance, at READ or WRITE; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate.", requestBody: { required: true, content: { "application/json": { schema: inviteGrantSchema } }, diff --git a/src/lib/sharing/__tests__/grants.test.ts b/src/lib/sharing/__tests__/grants.test.ts index f0708338d..86153cf7a 100644 --- a/src/lib/sharing/__tests__/grants.test.ts +++ b/src/lib/sharing/__tests__/grants.test.ts @@ -16,9 +16,11 @@ import { describe, expect, it } from "vitest"; import { grantAllows, grantState, + inviteGrant, isGrantActive, type GrantLifecycle, } from "@/lib/sharing/grants"; +import type { AccountGrant, Prisma } from "@/generated/prisma/client"; const NOW = new Date("2026-08-02T12:00:00.000Z"); const EARLIER = new Date("2026-08-01T12:00:00.000Z"); @@ -131,3 +133,82 @@ describe("grantAllows", () => { } }); }); + +/** + * The level the caller offered is the level the row is written with. + * + * A capture client rather than a database: what is asserted here is the `data` + * object this module hands the client, which is the one thing a fake can see + * honestly. That the column then holds the value, that the enum accepts it and + * that the partial unique index still applies are properties of Postgres and + * live in `tests/integration/account-grant-lifecycle.test.ts` — a fake that + * returned whatever it was given would report all three as working. + * + * There is no upgrade test because there is no upgrade: no function in this + * module raises the level of a row that exists, and the way to a wider grant + * is a new invitation the delegate accepts again. + */ +describe("inviteGrant", () => { + function captureDb(): { + db: Pick; + writes: Array<{ access: string; grantorId: string; granteeId: string }>; + } { + const writes: Array<{ + access: string; + grantorId: string; + granteeId: string; + }> = []; + const db = { + accountGrant: { + create: async (args: { + data: { access: string; grantorId: string; granteeId: string }; + }) => { + writes.push(args.data); + return { id: "grant-1", ...args.data } as unknown as AccountGrant; + }, + }, + } as unknown as Pick; + return { db, writes }; + } + + it("writes the level it was given", async () => { + for (const access of ["READ", "WRITE"] as const) { + const { db, writes } = captureDb(); + const row = await inviteGrant( + { grantorId: "owner", granteeId: "delegate", access }, + db, + ); + expect(writes).toHaveLength(1); + expect(writes[0].access).toBe(access); + expect(row.access).toBe(access); + } + }); + + it("still refuses a self-grant before anything is written", async () => { + const { db, writes } = captureDb(); + await expect( + inviteGrant( + { grantorId: "solo", granteeId: "solo", access: "WRITE" }, + db, + ), + ).rejects.toMatchObject({ code: "self_grant" }); + expect(writes).toHaveLength(0); + }); + + it("confers the offered level only after acceptance", () => { + // The two halves of the write consent, as the state machine sees them: the + // owner's is the row, the delegate's is `acceptedAt`. Neither alone is + // enough, and the pending row proves it. + const offered = { + revokedAt: null, + expiresAt: null, + access: "WRITE" as const, + }; + expect(grantAllows({ ...offered, acceptedAt: null }, "write", NOW)).toBe( + false, + ); + expect(grantAllows({ ...offered, acceptedAt: EARLIER }, "write", NOW)).toBe( + true, + ); + }); +}); diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts index 1a0fe3f54..74793b625 100644 --- a/src/lib/sharing/account-access.ts +++ b/src/lib/sharing/account-access.ts @@ -14,11 +14,12 @@ * `canSwitch` and `canWrite`; it never computes them, and there is nothing in * the payload it could compute them from. * - * `canWrite` is `false` for every entry in v1, because `inviteGrant` writes - * READ and only READ. It is resolved here through {@link grantAllows} against - * the real row rather than hardcoded, so the day a WRITE grant exists the - * answer changes without this file being edited — and, more importantly, so - * that nobody has to remember that it should. + * `canWrite` is resolved here through {@link grantAllows} against the real + * row rather than stored or hardcoded, which is why the level becoming an + * invitation choice needed no edit to this file: an accepted WRITE grant + * started answering true the moment one could exist. It answers "may add to + * this record" and never "may change it" — editing and deleting stay with the + * owner at both levels, and no field here offers them. * * What is deliberately NOT published: the other account's avatar. The avatar * bytes are owner-scoped (`/api/user/avatar/{id}` refuses any id but the diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index fb387b469..c3765a3be 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -2,10 +2,11 @@ * v1.36.0 — the account-grant state machine. One file, one set of rules. * * A grant is one account's standing permission to read another account's - * record, and the row is also the durable record of the consent that created - * it (there is no second receipt table — see the model docblock in - * `prisma/schema.prisma`). Everything that decides what a grant currently - * MEANS lives here, and nothing else in the tree is allowed to decide it. + * record, and since v1.36.x to add to it as well, and the row is also the + * durable record of the consent that created it (there is no second receipt + * table — see the model docblock in `prisma/schema.prisma`). Everything that + * decides what a grant currently MEANS lives here, and nothing else in the + * tree is allowed to decide it. * * Why one file rather than the rules at the call sites: two consumers read * these rows for different reasons — the acting-account resolver on every @@ -20,7 +21,7 @@ * Bearer perimeter that shouts at a mention is the right direction to be * wrong in.) * - * Three properties are the whole point, and each is a decision made HERE and + * Four properties are the whole point, and each is a decision made HERE and * nowhere else: * * * **Pending confers nothing.** An invitation is not access. A row with @@ -36,6 +37,11 @@ * the shape it does. Re-inviting the same person mints a NEW row; the * partial unique index in migration 0292 keeps at most one LIVE row per * pair while the history piles up underneath. + * * **The level never widens.** A row is READ or WRITE from the moment it is + * offered, and no transition here raises it. Widening a grant somebody has + * already accepted would change what they agreed to without asking them + * again, and the delegate's consent is half of what makes a write grant + * legitimate. {@link inviteGrant} carries the reasoning. * * Every write below is a CONDITIONAL update — the state the transition * requires is in the `where`, not in a read the caller performed a moment @@ -145,10 +151,8 @@ export function isGrantActive( * * Active first, then the level. A READ grant permits reads only; a WRITE grant * permits both, because write access to a record without read access describes - * nothing anyone asked for. v1 never writes a WRITE row (see - * {@link inviteGrant}), so in v1 this answers `false` for every write — which - * is the fail-closed arm the resolver wants, reached by the data rather than - * by a special case. + * nothing anyone asked for. The state outranks the level in both directions: a + * revoked WRITE grant permits nothing at all, and no level survives an expiry. */ export function grantAllows( grant: GrantLifecycle & { access: AccountGrantAccess }, @@ -166,6 +170,14 @@ export interface InviteGrantInput { grantorId: string; /** The account being invited to read it. */ granteeId: string; + /** + * What the invitation offers. Required, with no default in this module: a + * level nobody named is a level nobody chose, and the one place an omitted + * request field becomes READ is the invite schema + * (`src/lib/validations/account-sharing.ts`). Two defaults agreeing today is + * how they disagree later. + */ + access: AccountGrantAccess; /** Optional lapse date. Null = the grant runs until somebody ends it. */ expiresAt?: Date | null; } @@ -173,11 +185,16 @@ export interface InviteGrantInput { /** * Offer a grant. The delegate has access only once they accept it. * - * `access` is not a parameter. v1 grants READ and only READ (design §14): the - * column exists from day one so that delegated writes become enforcement work - * instead of migration work, and leaving the level out of the input is what - * makes "v1 never grants write" a property of this file rather than a promise - * in a comment. + * The level is fixed here and never moves. There is no widen, no PATCH, no + * upgrade endpoint, and the absence is the design rather than a gap: write + * access needs two consents, the owner's because it is their record and the + * delegate's because they are being asked to put something into somebody's + * health record under their own name. Acceptance is the single moment those + * two meet, and it happens once. Raising a live grant afterwards would carry + * one consent, and it would not be the delegate's. The way up is a new + * invitation at the level meant, accepted again — the partial unique index + * permits it as soon as the old row is ended, and the cost is one revoke and + * one accept. * * Self-grants are refused here rather than by a database CHECK — migration * 0292 carries the reason (the schema-driven wipe seeder plants a row with @@ -198,7 +215,7 @@ export async function inviteGrant( data: { grantorId: input.grantorId, granteeId: input.granteeId, - access: "READ", + access: input.access, invitedAt: now, expiresAt: input.expiresAt ?? null, }, diff --git a/src/lib/validations/account-sharing.ts b/src/lib/validations/account-sharing.ts index 293291bcd..d2ae406cb 100644 --- a/src/lib/validations/account-sharing.ts +++ b/src/lib/validations/account-sharing.ts @@ -5,10 +5,6 @@ * and only two of them carry a body worth validating. What is NOT here is as * deliberate as what is: * - * * No `access` level. v1 grants READ and only READ, and the level is not a - * parameter anywhere in the stack — `inviteGrant` hardcodes it. A field - * here would be an input the domain module then ignores, which is the - * shape of a promise nobody kept. * * No `userId`, `grantorId` or `granteeId` on any body. Both sides of a * grant come from the authenticated session and the row itself. A body * field naming a party would be an authorization decision made by the @@ -43,6 +39,23 @@ export const inviteGrantSchema = z.object({ * remember to renew is worse than one that does not exist. */ expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + /** + * What the invitation offers, and the ONLY place the level is defaulted. + * + * `inviteGrant` takes the level as a required argument, so the domain module + * never guesses; this line is the single answer to "what does an omitted + * field mean", which keeps an older client's payload valid without a second + * copy of the same decision further down. A grant is READ or WRITE from the + * moment it is offered and stays that way: there is no endpoint that widens + * a live grant, because widening it would carry one consent and it would not + * be the delegate's. The way up is a new invitation, accepted again. + */ + access: z + .enum(["READ", "WRITE"]) + .default("READ") + .describe( + "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. Omitted means READ, so a client that predates the field keeps working. The level is fixed when the invitation is written: no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts.", + ), }); export type InviteGrantBody = z.infer; diff --git a/tests/integration/account-grant-lifecycle.test.ts b/tests/integration/account-grant-lifecycle.test.ts index 908c240f4..a1afdb53f 100644 --- a/tests/integration/account-grant-lifecycle.test.ts +++ b/tests/integration/account-grant-lifecycle.test.ts @@ -32,6 +32,7 @@ import { GrantError, acceptGrant, findActiveGrant, + grantAllows, inviteGrant, renounceGrant, revokeGrant, @@ -98,6 +99,7 @@ async function acceptedGrant() { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); const grant = await acceptGrant({ grantId: invited.id, @@ -120,6 +122,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); expect(invited.acceptedAt).toBeNull(); expect(invited.access).toBe("READ"); @@ -147,10 +150,42 @@ describe("account grant lifecycle", () => { expect(active?.id).toBe(invited.id); }); + it("stores the level the invitation offered, and confers it only once accepted", async () => { + // The column, not the argument: a write grant is the one thing in this + // feature that lets somebody put data into another person's record, so the + // proof that WRITE survives the round trip belongs against real Postgres + // rather than against a client fake. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "WRITE", + }); + const stored = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invited.id }, + }); + expect(stored.access).toBe("WRITE"); + // Offered is not accepted. Both consents or nothing. + expect(grantAllows(stored, "write")).toBe(false); + + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + const live = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invited.id }, + }); + expect(grantAllows(live, "write")).toBe(true); + expect(grantAllows(live, "read")).toBe(true); + }); + it("refuses an account sharing its record with itself", async () => { const solo = await makeUser("solo"); await expect( - inviteGrant({ grantorId: solo.id, granteeId: solo.id }), + inviteGrant({ + grantorId: solo.id, + granteeId: solo.id, + access: "READ", + }), ).rejects.toMatchObject({ code: "self_grant" }); expect(await getPrismaClient().accountGrant.count()).toBe(0); }); @@ -163,6 +198,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); await expect( @@ -201,6 +237,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", expiresAt: new Date(Date.now() - 1000), }); @@ -215,6 +252,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", expiresAt: new Date(Date.now() + 60_000), }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); @@ -331,13 +369,25 @@ describe("account grant lifecycle", () => { it("refuses a second live grant for the same pair", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); - await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }); await expect( - inviteGrant({ grantorId: owner.id, granteeId: delegate.id }), + inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }), ).rejects.toBeInstanceOf(GrantError); await expect( - inviteGrant({ grantorId: owner.id, granteeId: delegate.id }), + inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }), ).rejects.toMatchObject({ code: "duplicate_live_grant" }); expect(await getPrismaClient().accountGrant.count()).toBe(1); }); @@ -352,6 +402,7 @@ describe("account grant lifecycle", () => { const second = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); expect(second.id).not.toBe(first.id); expect(second.acceptedAt).toBeNull(); @@ -384,10 +435,15 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); await revokeGrant({ grantId: invited.id, grantorId: owner.id }); } - await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }); expect( await getPrismaClient().accountGrant.count({ where: { grantorId: owner.id, granteeId: delegate.id }, @@ -484,6 +540,7 @@ describe("account grant lifecycle paths", () => { const liveInvite = await inviteGrant({ grantorId: admin.id, granteeId: live.id, + access: "READ", }); const liveGrant = await acceptGrant({ grantId: liveInvite.id, @@ -492,6 +549,7 @@ describe("account grant lifecycle paths", () => { const droppedInvite = await inviteGrant({ grantorId: admin.id, granteeId: dropped.id, + access: "READ", }); await acceptGrant({ grantId: droppedInvite.id, granteeId: dropped.id }); const revoked = await revokeGrant({ diff --git a/tests/integration/acting-account-resolver.test.ts b/tests/integration/acting-account-resolver.test.ts index 37ce752e3..819afec3f 100644 --- a/tests/integration/acting-account-resolver.test.ts +++ b/tests/integration/acting-account-resolver.test.ts @@ -162,6 +162,7 @@ async function household() { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); const grant = await acceptGrant({ grantId: invited.id, @@ -260,6 +261,7 @@ describe("acting-account resolver — the decision is never cached", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", expiresAt: new Date(Date.now() + 400), }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); @@ -275,7 +277,11 @@ describe("acting-account resolver — the decision is never cached", () => { 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 }); + await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }); const session = await signIn(delegate.id); await switchTo(session.id, owner.id); diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts index 422e87c25..a304b7ab1 100644 --- a/tests/integration/sharing-audit-actor.test.ts +++ b/tests/integration/sharing-audit-actor.test.ts @@ -98,6 +98,7 @@ async function household() { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); const session = await signIn(delegate.id); @@ -223,6 +224,7 @@ describe("delegated reads coalesce to one row a day", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: second.id, + access: "READ", }); await acceptGrant({ grantId: invited.id, granteeId: second.id });