Give a grant a section set and a third level

A grant has meant one thing since it shipped: the whole record, to read or to
add to. This lays the words for the two shapes the rest of the sharing work
needs — part of a record, and management of one — while changing what no
existing grant does.

`ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`,
clustered from the delegable read surfaces rather than invented, plus the
distinguished `record` for the routes that read across them. It never converts
to a `ModuleKey` and never imports one: two masks, two questions, no shared
spelling, because the day they share a name is the day somebody makes one
imply the other, and a module the owner enabled is not a section the owner
shared.

`account_grants.scope_json` is NULL on every row that exists, and NULL is the
entire record — the answer those rows were consented as, so no backfill and no
re-consent. A non-NULL value is read through one fail-closed normaliser: a
blob it cannot parse opens nothing, including a section the blob happened to
name. That is the deliberate inverse of the module preference normaliser,
which fails open on purpose; both do the safe thing with a value nobody can
read, and the safe thing differs because one answers an authorization question
and the other a presentation question.

`MANAGE` joins the level enum, ordered above WRITE so a write grant can never
satisfy it, and carries no scope at all: management of part of a record
promises a boundary that cannot survive an edit, so the invite path refuses to
write one.

`requireRecordAuth` takes the section as a second required argument. The
requirement is the lever — a delegable route without a classification does not
typecheck, so the set of classified routes cannot fall behind the set of
delegable ones. All fifty-seven modules carry theirs. The structural guard
freezes each against the production vocabulary, fails when a module's own arms
disagree, and pins a non-zero count of resolved sections so a matcher that
stopped reading the argument fails instead of agreeing with everything.

`requireGuardianAuth` is exported and reaches nothing yet. It gates on the
managed-profile marker rather than on the grant, so an adult who granted
management of their own record cannot arrive at that record's settings by any
argument about what MANAGE means: widening MANAGE would widen the record
resolver and would not touch this one. Its frozen list lands empty, with the
declaration site as the positive control that keeps "nothing reaches it" a
measurement rather than a renamed symbol.

`users.managed_profile_at` says since when a record is one somebody else runs.
A timestamp and not a boolean, for a reason the schema has to keep possible:
clearing it is the handover, and the grants and the activity trail span it
untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-08-04 17:11:40 +02:00
1 parent 03877a0971
commit da3cabb55f
77 files changed
+2305 -266

No files matched your search

+112 -7
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import type { User } from "@/generated/prisma/client";
import type { AccountGrant, User } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { WideEventBuilder } from "./logging/event-builder";
import { annotate, eventStorage, getEvent } from "./logging/context";
@@ -32,9 +32,11 @@ import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
import {
findActiveGrant,
grantAllows,
grantCoversDomain,
touchGrantUsage,
type GrantNeed,
} from "./sharing/grants";
import type { ShareScope } from "./sharing/scope";
/**
* HTTP methods a read-only credential may use on the REST surface. A request
@@ -770,10 +772,20 @@ export interface RecordAuthContext extends AuthContext {
* 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.
* fail-closed posture as the MCP audience guard above. A route declaring
* `"manage"` refuses everything below it regardless of method: the method
* can only escalate what the declaration asked for, never satisfy it.
* @param domain v1.37.0 — which section of the record the route touches, or
* `"record"` when it reads across sections. Required, and the requirement is
* the fail-closed lever: a delegable route without a classification does not
* typecheck, so the set of routes carrying one cannot fall behind the set of
* routes that are delegable. The value is frozen per module by
* `src/__tests__/delegable-surface-guard.test.ts` and reviewed against the
* design's clustering table; this function only enforces what was declared.
*/
export async function requireRecordAuth(
need: GrantNeed,
domain: ShareScope,
): Promise<RecordAuthContext> {
const auth = await authenticateCaller();
const carrier = await readActingCarrier(auth);
@@ -789,6 +801,101 @@ export async function requireRecordAuth(
throw refuseUndeclaredMode(auth, carrier, "misplaced_selector");
}
const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase();
const effectiveNeed: GrantNeed =
need !== "read" || !READ_HTTP_METHODS.has(method) ? escalate(need) : "read";
return resolveSwitchedRecord(auth, carrier, (grant) => {
if (!grantAllows(grant, effectiveNeed)) return "insufficient_access";
// Level first, then scope, and the order is visible on the wire in exactly
// one way: not at all. Both refusals are the same error with the same
// bytes, and only the audit reason differs — so the ordering is a choice
// about what the operator's trail says happened, not about what the caller
// learns. It says the bigger thing: a delegate who was never given this
// level is a different story from one whose sections do not reach here.
if (!grantCoversDomain(grant, domain)) return "out_of_scope";
return null;
});
}
/**
* The need a non-safe method implies, for a route that declared `need`.
*
* A PUT on a route declaring `"read"` needs a write grant, which is what this
* has always done. A PUT on a route declaring `"manage"` needs a MANAGE grant
* and must not be reduced to a write — so the escalation raises the floor to
* `"write"` and never lowers a declaration that already sits above it.
*/
function escalate(need: GrantNeed): GrantNeed {
return need === "manage" ? "manage" : "write";
}
/**
* v1.37.0 — a guardian surface: this route administers a managed profile.
*
* The other declaration, and a separate function rather than a flag on the one
* above, for the reason `requireActorAuth` is also separate: the guard freezes
* each declaration as its own list, so a route moving between them is a diff a
* human reviews rather than an argument about a parameter.
*
* What it admits is narrower than it looks. An active MANAGE grant is
* necessary and NOT sufficient — the record must also carry the managed-profile
* marker. That second condition is the identity fence, and it is the line
* between "manage my data" and "own my account": an adult who granted
* management of their own record reaches none of this, at any level, by any
* argument, because their record has no marker and never will while they hold
* it. A managed profile has no self to reserve these surfaces for, so its
* guardian holds them.
*
* The fence is gated on the marker rather than on the grant precisely so that
* no future change to what MANAGE means can reach through it. Widening MANAGE
* would widen `requireRecordAuth`; it would not touch this function.
*/
export async function requireGuardianAuth(): Promise<RecordAuthContext> {
const auth = await authenticateCaller();
const carrier = await readActingCarrier(auth);
if (carrier.kind === "none") {
// The owner of an ordinary record, reaching their own settings. Same
// do-no-harm posture as the record resolver: without a carrier this is the
// request it always was.
return { ...auth, actor: auth.user, grantId: null };
}
if (carrier.kind === "misplaced-header") {
throw refuseUndeclaredMode(auth, carrier, "misplaced_selector");
}
return resolveSwitchedRecord(auth, carrier, (grant, owner) => {
if (!grantAllows(grant, "manage")) return "insufficient_access";
if (owner.managedProfileAt === null) return "guardian_only";
return null;
});
}
/**
* The shared body of both record resolvers: turn a selector into an owner, or
* refuse.
*
* Everything either resolver does with a carrier lives here except the
* admission rule itself, which arrives as `admit` — a function of the grant
* and the owner returning an audit reason to refuse with, or null to proceed.
* The split is where it is because the two resolvers differ in exactly that
* one decision and in nothing else, and two copies of the grant lookup, the
* refusal shape, the usage stamp and the owner's access row would be two
* copies to keep in step.
*
* The owner row is loaded BEFORE the admission runs, which is a change from
* v1.36.0's ordering and a deliberate one: the guardian rule is a fact about
* the owner, so it cannot be decided before the owner is known. Nothing about
* the refusal moved — every arm below is the same error with the same bytes,
* and an admission that refuses after the load costs one query it does not use
* and tells the caller nothing extra.
*/
async function resolveSwitchedRecord(
auth: AuthContext,
carrier: Extract<ActingCarrier, { accountId: string }>,
admit: (grant: AccountGrant, owner: User) => string | null,
): Promise<RecordAuthContext> {
/** Every refusal below is this one error, with the reason kept off the wire. */
const denied = (reason: string): SharingAccessDeniedError => {
annotate({ meta: { sharing_refusal: reason } });
@@ -800,10 +907,6 @@ export async function requireRecordAuth(
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
@@ -818,7 +921,6 @@ export async function requireRecordAuth(
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 },
@@ -828,6 +930,9 @@ export async function requireRecordAuth(
// to a person is not one to act on.
if (!owner) throw denied("owner_missing");
const refusal = admit(grant, owner);
if (refusal !== null) throw denied(refusal);
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.