mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
refactor(sharing): read and judge grants from a leaf module
lib/sharing/grants.ts held both the grant transitions and the predicates every reader asks (state, level, scope, the live grant for a pair). The transitions reach into the managed-profile lifecycle and the OAuth connect target, and both of those read a live grant, so grants, lifecycle and connect-target formed an import cycle. The predicates, findActiveGrant and touchGrantUsage move verbatim to lib/sharing/grant-access.ts, which imports only the database client and the scope vocabulary. Importers and the mocks that replaced those functions point at the new module. Runtime import cycles fall from 7 to 6.
This commit is contained in:
1 parent
e84b80ee3c
commit
78ac2c5aeb
27 files changed
+315
-296
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$comment": "Runtime import cycles under src/: the back-edge count and the strongly connected components. Maintained by src/__tests__/import-cycle-guard.test.ts.",
|
||||
"cycles": 7,
|
||||
"cycles": 6,
|
||||
"components": [
|
||||
[
|
||||
"components/command-palette/command-palette.tsx",
|
||||
@@ -18,11 +18,6 @@
|
||||
"lib/medications/scheduling/compliance.ts",
|
||||
"lib/medications/scheduling/schedule-eras.ts"
|
||||
],
|
||||
[
|
||||
"lib/integrations/oauth-kit/connect-target.ts",
|
||||
"lib/managed-profiles/lifecycle.ts",
|
||||
"lib/sharing/grants.ts"
|
||||
],
|
||||
[
|
||||
"lib/cycle/gate.ts",
|
||||
"lib/modules/gate.ts"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 120,
|
||||
"total": 141792,
|
||||
"total": 141553,
|
||||
"entries": {
|
||||
"app/api/dashboard/summary/route.ts": 1032,
|
||||
"app/api/documents/inbound/route.ts": 811,
|
||||
@@ -115,7 +115,7 @@
|
||||
"lib/polar/client.ts": 1010,
|
||||
"lib/rollups/measurement-rollups.ts": 1496,
|
||||
"lib/search/search-records.ts": 996,
|
||||
"lib/sharing/grants.ts": 1046,
|
||||
"lib/sharing/grants.ts": 807,
|
||||
"lib/support/report.ts": 905,
|
||||
"lib/telegram-webhook-handlers.ts": 1341,
|
||||
"lib/validations/inbound-documents.ts": 1130,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2954,
|
||||
"total": 10481,
|
||||
"total": 10480,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -2819,7 +2819,7 @@
|
||||
"lib/sharing/audit-details.ts": 1,
|
||||
"lib/sharing/delegated-generation.ts": 1,
|
||||
"lib/sharing/grant-view.ts": 2,
|
||||
"lib/sharing/grants.ts": 4,
|
||||
"lib/sharing/grants.ts": 3,
|
||||
"lib/sharing/module-disclosure.ts": 1,
|
||||
"lib/sharing/record-session-fence-contract.ts": 1,
|
||||
"lib/sharing/record-session-fence.ts": 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { grantCoversDomain, normaliseScope } from "@/lib/sharing/grants";
|
||||
import { grantCoversDomain, normaliseScope } from "@/lib/sharing/grant-access";
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ import {
|
||||
pointSessionAt,
|
||||
pointSessionAtIfUnchanged,
|
||||
} from "@/lib/sharing/acting-session";
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { findActiveGrant } from "@/lib/sharing/grant-access";
|
||||
import { switchAccountSchema } from "@/lib/validations/account-sharing";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
} from "@/lib/auth/session";
|
||||
import { lookupIpLocation } from "@/lib/geo";
|
||||
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
|
||||
import { isGrantActive } from "@/lib/sharing/grants";
|
||||
import { isGrantActive } from "@/lib/sharing/grant-access";
|
||||
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -54,13 +54,13 @@ import {
|
||||
} from "../idempotency";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
vi.mock("@/lib/sharing/grants", () => ({
|
||||
vi.mock("@/lib/sharing/grant-access", () => ({
|
||||
findActiveGrant: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { headers } from "next/headers";
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { findActiveGrant } from "@/lib/sharing/grant-access";
|
||||
|
||||
/** A live WRITE grant, whole record, as `delegatedGrantFacet` reads it. */
|
||||
const GRANT = { id: "grant-1", access: "WRITE", scopeJson: null };
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/sharing/grants", () => ({
|
||||
vi.mock("@/lib/sharing/grant-access", () => ({
|
||||
findActiveGrant: vi.fn(),
|
||||
grantAllows: vi.fn(() => true),
|
||||
grantCoversDomain: vi.fn(() => true),
|
||||
@@ -60,7 +60,7 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { findActiveGrant } from "@/lib/sharing/grant-access";
|
||||
import { ACCOUNT_SELECTOR_HEADER } from "@/lib/auth/acting-carrier";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ let auth: Record<string, unknown> | undefined;
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ getAuth: () => auth, addWarning: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/sharing/grants", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/sharing/grants")>()),
|
||||
vi.mock("@/lib/sharing/grant-access", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/sharing/grant-access")>()),
|
||||
findActiveGrant: vi.fn(),
|
||||
}));
|
||||
vi.mock("../load", () => ({ loadAiCapabilityInputs: vi.fn() }));
|
||||
|
||||
@@ -9,14 +9,14 @@ let auth: Record<string, unknown> | undefined;
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ getAuth: () => auth, addWarning: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/sharing/grants", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/sharing/grants")>()),
|
||||
vi.mock("@/lib/sharing/grant-access", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/sharing/grant-access")>()),
|
||||
findActiveGrant: vi.fn(),
|
||||
}));
|
||||
vi.mock("../load", () => ({ loadAiCapabilityInputs: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { findActiveGrant } from "@/lib/sharing/grant-access";
|
||||
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
||||
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
* consent_required → `consent.ai.required`
|
||||
* check_failed → `ai.unavailable`; 503
|
||||
*/
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { findActiveGrant } from "@/lib/sharing/grant-access";
|
||||
import { resolveGrantSections } from "@/lib/sharing/grant-view";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { providerWorkAuthorityForRecord } from "@/lib/sharing/provider-work-authority";
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
grantCoversDomain,
|
||||
touchGrantUsage,
|
||||
type GrantNeed,
|
||||
} from "@/lib/sharing/grants";
|
||||
} from "@/lib/sharing/grant-access";
|
||||
import type { ShareScope } from "@/lib/sharing/scope";
|
||||
import { assertRecordSessionFence } from "@/lib/sharing/record-session-fence";
|
||||
import {
|
||||
|
||||
@@ -38,7 +38,7 @@ import { refuseStaleRecordSessionClaim } from "@/lib/sharing/record-session-fenc
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { findActiveGrant } from "@/lib/sharing/grant-access";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { createHash } from "node:crypto";
|
||||
import { looksSecretShaped } from "@/lib/secret-shape";
|
||||
|
||||
@@ -9,7 +9,7 @@ const { findUnique, update, findActiveGrant, auditLog } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique, update } } }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog }));
|
||||
vi.mock("@/lib/sharing/grants", () => ({
|
||||
vi.mock("@/lib/sharing/grant-access", () => ({
|
||||
findActiveGrant,
|
||||
grantAllows: (
|
||||
grant: { access: string; revokedAt: Date | null },
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { findActiveGrant, grantAllows } from "@/lib/sharing/grants";
|
||||
import { findActiveGrant, grantAllows } from "@/lib/sharing/grant-access";
|
||||
import type { OAuthKitProvider } from "./token";
|
||||
|
||||
export type CallbackActorVerdict =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { activeGuardianWhere } from "@/lib/managed-profiles/lifecycle";
|
||||
import { grantState } from "@/lib/sharing/grants";
|
||||
import { grantState } from "@/lib/sharing/grant-access";
|
||||
import { GRANT_PARTY_SELECT, type GrantParty } from "@/lib/sharing/grant-view";
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,10 +51,10 @@ import {
|
||||
type HandoverAccess,
|
||||
} from "@/lib/managed-profiles/handover-access";
|
||||
import {
|
||||
isGrantActive,
|
||||
settleHandoverGuardianGrant,
|
||||
withdrawPendingGrantsOfRecord,
|
||||
} from "@/lib/sharing/grants";
|
||||
import { isGrantActive } from "@/lib/sharing/grant-access";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
|
||||
@@ -13,15 +13,15 @@
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { inviteGrant } from "@/lib/sharing/grants";
|
||||
import {
|
||||
grantAllows,
|
||||
grantCoversDomain,
|
||||
grantState,
|
||||
inviteGrant,
|
||||
isGrantActive,
|
||||
normaliseScope,
|
||||
type GrantLifecycle,
|
||||
} from "@/lib/sharing/grants";
|
||||
} from "@/lib/sharing/grant-access";
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { AccountGrant } from "@/generated/prisma/client";
|
||||
|
||||
@@ -44,7 +44,7 @@ import type {
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
import { resolveGrantSections } from "@/lib/sharing/grant-view";
|
||||
import { grantAllows, isGrantActive } from "@/lib/sharing/grants";
|
||||
import { grantAllows, isGrantActive } from "@/lib/sharing/grant-access";
|
||||
import type { AccountGrantAccess } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { grantCoversDomain } from "@/lib/sharing/grants";
|
||||
import { grantCoversDomain } from "@/lib/sharing/grant-access";
|
||||
import type { ShareScope } from "@/lib/sharing/scope";
|
||||
|
||||
export async function actingDomainVisibility(
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* What an account grant MEANS at a given instant: its state, whether it
|
||||
* permits a need, whether it covers a section of the record, and the live
|
||||
* grant for a pair. Pure predicates plus one read and one fire-and-forget
|
||||
* stamp, and nothing that changes a grant.
|
||||
*
|
||||
* The transitions that DO change a grant (invite, accept, revoke, renounce,
|
||||
* the managed-profile handover) are in `./grants.ts` and decide through the
|
||||
* predicates here, so the resolver and the CRUD surface cannot carry two
|
||||
* ideas of "active". They live apart because the transitions reach into the
|
||||
* managed-profile lifecycle and the OAuth connect target, and those two read
|
||||
* a live grant themselves: with the predicates in the same module, every
|
||||
* reader of a grant was part of one import cycle.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ENTIRE_RECORD, isShareDomain } from "@/lib/sharing/scope";
|
||||
import type { ShareDomain, ShareScope } from "@/lib/sharing/scope";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import type {
|
||||
AccountGrant,
|
||||
AccountGrantAccess,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
/** The slice of a grant row the pure predicates read. */
|
||||
export interface GrantLifecycle {
|
||||
acceptedAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a grant currently is.
|
||||
*
|
||||
* Ordering matters and is stated rather than left to the reader: a revoked
|
||||
* grant reads REVOKED even if it also sat past its expiry, because somebody
|
||||
* ended it and the record should say so. An invitation past its expiry reads
|
||||
* EXPIRED rather than PENDING, because it can no longer be accepted. Only
|
||||
* ACTIVE confers anything.
|
||||
*/
|
||||
export type GrantState = "REVOKED" | "EXPIRED" | "PENDING" | "ACTIVE";
|
||||
|
||||
/** What a caller needs the grant to permit. */
|
||||
export type GrantNeed = "read" | "write" | "manage";
|
||||
|
||||
/**
|
||||
* The narrow client slice these functions need, so a caller can pass a
|
||||
* transaction handle. Same shape as `issue-token.ts`.
|
||||
*/
|
||||
export type GrantDb = Pick<
|
||||
Prisma.TransactionClient,
|
||||
"accountGrant" | "auditLog"
|
||||
>;
|
||||
|
||||
// ── The state machine, pure ─────────────────────────────────────────────────
|
||||
|
||||
/** What this grant is, at `now`. */
|
||||
export function grantState(
|
||||
grant: GrantLifecycle,
|
||||
now: Date = new Date(),
|
||||
): GrantState {
|
||||
if (grant.revokedAt !== null) return "REVOKED";
|
||||
// `<=` matches the Bearer-token expiry comparison: the instant named is the
|
||||
// first instant the grant no longer holds.
|
||||
if (grant.expiresAt !== null && grant.expiresAt <= now) return "EXPIRED";
|
||||
if (grant.acceptedAt === null) return "PENDING";
|
||||
return "ACTIVE";
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this grant confer anything right now?
|
||||
*
|
||||
* The one predicate the resolver asks. Deliberately derived from
|
||||
* {@link grantState} rather than re-listing the conditions, so a fourth
|
||||
* terminal state can never be added without this answering for it.
|
||||
*/
|
||||
export function isGrantActive(
|
||||
grant: GrantLifecycle,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
return grantState(grant, now) === "ACTIVE";
|
||||
}
|
||||
|
||||
/**
|
||||
* The three levels, ordered. Higher reaches everything lower.
|
||||
*
|
||||
* A total order rather than a set of capabilities per level, because that is
|
||||
* what the consent screen says out loud: view, view and add, manage. Written
|
||||
* as two tables rather than as a chain of comparisons so that a fourth level
|
||||
* cannot be added without deciding where it sits — an unlisted key does not
|
||||
* typecheck, and the exhaustiveness is the point of the `Record` type.
|
||||
*/
|
||||
const ACCESS_RANK: Record<AccountGrantAccess, number> = {
|
||||
READ: 0,
|
||||
WRITE: 1,
|
||||
MANAGE: 2,
|
||||
};
|
||||
|
||||
const NEED_RANK: Record<GrantNeed, number> = {
|
||||
read: 0,
|
||||
write: 1,
|
||||
manage: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Does this grant permit what the caller is about to do?
|
||||
*
|
||||
* 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; a MANAGE grant permits all three, because managing
|
||||
* a record it cannot read or add to describes nothing either. The state
|
||||
* outranks the level in both directions: a revoked MANAGE grant permits
|
||||
* nothing at all, and no level survives an expiry.
|
||||
*
|
||||
* The comparison is `>=` on the ranks above and not equality, and the
|
||||
* difference matters in exactly one direction: a WRITE grant must never
|
||||
* satisfy `"manage"`. That is the whole of the third level's meaning — an
|
||||
* accepted WRITE grant carries a consent to add, and nothing about it carries
|
||||
* a consent to rewrite or remove.
|
||||
*/
|
||||
export function grantAllows(
|
||||
grant: GrantLifecycle & { access: AccountGrantAccess },
|
||||
need: GrantNeed,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!isGrantActive(grant, now)) return false;
|
||||
return ACCESS_RANK[grant.access] >= NEED_RANK[need];
|
||||
}
|
||||
|
||||
// ── Scope ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The slice of a grant row the scope predicate reads. */
|
||||
export interface GrantScope {
|
||||
scopeJson: Prisma.JsonValue | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a stored scope actually opens.
|
||||
*
|
||||
* `null` — the whole record. Not a default standing in for a missing answer:
|
||||
* it is the answer every grant written before the column existed was consented
|
||||
* as, and the answer an owner who ticks "entire record" gives today. It is a
|
||||
* first-class value everywhere, never a legacy badge.
|
||||
*
|
||||
* A set — those sections and nothing else, `record` included (see
|
||||
* {@link grantCoversDomain}).
|
||||
*/
|
||||
export type ResolvedScope = ReadonlySet<ShareDomain> | null;
|
||||
|
||||
const EMPTY_SCOPE: ReadonlySet<ShareDomain> = new Set<ShareDomain>();
|
||||
|
||||
/**
|
||||
* Turn the stored blob into what it opens, refusing anything it cannot read.
|
||||
*
|
||||
* Fail-closed, and this is the deliberate inverse of `normalisePrefs` in the
|
||||
* module gate: that one resolves a malformed preference blob to "everything
|
||||
* on", because a presentation preference nobody can read should not hide a
|
||||
* person's own data from them. This one answers an authorization question, so
|
||||
* a value it cannot read resolves to the EMPTY set — every section refused,
|
||||
* including any the garbage might have happened to name. Both directions are
|
||||
* "do the safe thing with a value we do not understand"; they only look
|
||||
* opposite because the safe thing differs.
|
||||
*
|
||||
* What counts as unreadable is deliberately wide: anything that is not an
|
||||
* array, an empty array, an array holding a key outside the vocabulary, an
|
||||
* array holding a non-string. There is no partial credit — a set that is half
|
||||
* recognisable is a set somebody wrote in a shape this file does not
|
||||
* understand, and honouring the half we recognise would be guessing at
|
||||
* consent. `record` is not a member of the vocabulary, so a stored `["record"]`
|
||||
* is unreadable too, which is the correct reading: a scope that means "no
|
||||
* scope" is a thing the invite path refuses to write, and one that reached
|
||||
* the column by any other route opens nothing.
|
||||
*/
|
||||
export function normaliseScope(stored: Prisma.JsonValue | null): ResolvedScope {
|
||||
if (stored === null || stored === undefined) return null;
|
||||
if (!Array.isArray(stored) || stored.length === 0) return EMPTY_SCOPE;
|
||||
|
||||
const domains = new Set<ShareDomain>();
|
||||
for (const entry of stored) {
|
||||
if (!isShareDomain(entry)) return EMPTY_SCOPE;
|
||||
// A scope is a subset, not merely an array of recognisable values. A
|
||||
// duplicated key is malformed rather than a second consent to the same
|
||||
// domain, so it receives no partial credit.
|
||||
if (domains.has(entry)) return EMPTY_SCOPE;
|
||||
domains.add(entry);
|
||||
}
|
||||
return domains;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this grant open the section the route declared?
|
||||
*
|
||||
* Three rules, and the second is the one that carries the invariant:
|
||||
*
|
||||
* * A NULL scope covers everything, `record` included. That is what every
|
||||
* grant in the product was until this release and what most will stay.
|
||||
* * A non-NULL scope NEVER covers `record`. A route that declares `record`
|
||||
* reads across sections — a health score, a digest, an achievement set —
|
||||
* and there is no honest way to answer one of those for a delegate who was
|
||||
* given part of a record. A score that says 70 to the owner and 64 to the
|
||||
* delegate is a support case and a clinical hazard, so the scoped delegate
|
||||
* does not get a filtered answer, they get no answer.
|
||||
* * Otherwise, membership. A key that postdates the invitation is not in the
|
||||
* stored set and so is not covered, which is why a new section is a
|
||||
* consent question rather than a schema question.
|
||||
*
|
||||
* The level is a separate question, asked by {@link grantAllows}. This one
|
||||
* knows nothing about READ, WRITE or MANAGE — and MANAGE rows carry a NULL
|
||||
* scope by construction, so it answers true for them by the first rule rather
|
||||
* than by a special case.
|
||||
*/
|
||||
export function grantCoversDomain(
|
||||
grant: GrantScope,
|
||||
domain: ShareScope,
|
||||
): boolean {
|
||||
const scope = normaliseScope(grant.scopeJson);
|
||||
if (scope === null) return true;
|
||||
if (domain === ENTIRE_RECORD) return false;
|
||||
return scope.has(domain);
|
||||
}
|
||||
|
||||
// ── Resolution ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The grant that lets `granteeId` act on `grantorId`'s record right now, or
|
||||
* null.
|
||||
*
|
||||
* The query narrows on `revokedAt: null` only — which the partial unique index
|
||||
* makes at most one row — and every other condition is decided by
|
||||
* {@link isGrantActive}. That split is deliberate: a SQL predicate spelling out
|
||||
* "accepted and not expired" beside a TypeScript predicate spelling out the
|
||||
* same thing is two places deciding one question, and the copy that drifts is
|
||||
* the one nobody reads. Here the database narrows and the state machine
|
||||
* decides.
|
||||
*/
|
||||
export async function findActiveGrant(
|
||||
pair: { grantorId: string; granteeId: string },
|
||||
db: GrantDb = prisma,
|
||||
now: Date = new Date(),
|
||||
): Promise<AccountGrant | null> {
|
||||
const live = await db.accountGrant.findFirst({
|
||||
where: {
|
||||
grantorId: pair.grantorId,
|
||||
granteeId: pair.granteeId,
|
||||
revokedAt: null,
|
||||
},
|
||||
});
|
||||
if (!live) return null;
|
||||
return isGrantActive(live, now) ? live : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp `lastUsedAt` on a grant that was just exercised.
|
||||
*
|
||||
* Fire-and-forget, the `ApiToken.lastUsedAt` posture (`bearer.ts`): the request
|
||||
* that triggered it must not wait, and a failure to record the use must not
|
||||
* fail the read. The promise is returned so a test can await it; production
|
||||
* callers deliberately do not.
|
||||
*/
|
||||
export function touchGrantUsage(
|
||||
grantId: string,
|
||||
db: GrantDb = prisma,
|
||||
): Promise<void> {
|
||||
return db.accountGrant
|
||||
.update({ where: { id: grantId }, data: { lastUsedAt: new Date() } })
|
||||
.then(() => undefined)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
grantState,
|
||||
normaliseScope,
|
||||
type GrantState,
|
||||
} from "@/lib/sharing/grants";
|
||||
} from "@/lib/sharing/grant-access";
|
||||
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
|
||||
import type { AccountGrant } from "@/generated/prisma/client";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
+9
-248
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* v1.36.0 — the account-grant state machine. One file, one set of rules.
|
||||
* The account-grant state machine: the transitions here, the predicates they
|
||||
* decide by (state, level, scope, the live grant for a pair) in
|
||||
* `./grant-access.ts`. Two modules, one set of rules.
|
||||
*
|
||||
* A grant is one account's standing permission to open another account's
|
||||
* record — to read it, and where the grant says so to add to it. 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.
|
||||
* Everything that decides what a grant currently MEANS lives in these two
|
||||
* modules, 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
|
||||
* Why one place rather than the rules at the call sites: two consumers read
|
||||
* these rows for different reasons — the acting-account resolver on every
|
||||
* request, and the grant CRUD surface when a person invites, accepts, revokes
|
||||
* or renounces. If each carried its own idea of "active", the two would drift,
|
||||
@@ -67,35 +69,15 @@ import {
|
||||
grantAccessFor,
|
||||
type HandoverAccess,
|
||||
} from "@/lib/managed-profiles/handover-access";
|
||||
import { ENTIRE_RECORD, isShareDomain } from "@/lib/sharing/scope";
|
||||
import type { ShareDomain, ShareScope } from "@/lib/sharing/scope";
|
||||
import { isShareDomain } from "@/lib/sharing/scope";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
import { grantState, type GrantDb } from "@/lib/sharing/grant-access";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type {
|
||||
AccountGrant,
|
||||
AccountGrantAccess,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
/** The slice of a grant row the pure predicates read. */
|
||||
export interface GrantLifecycle {
|
||||
acceptedAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a grant currently is.
|
||||
*
|
||||
* Ordering matters and is stated rather than left to the reader: a revoked
|
||||
* grant reads REVOKED even if it also sat past its expiry, because somebody
|
||||
* ended it and the record should say so. An invitation past its expiry reads
|
||||
* EXPIRED rather than PENDING, because it can no longer be accepted. Only
|
||||
* ACTIVE confers anything.
|
||||
*/
|
||||
export type GrantState = "REVOKED" | "EXPIRED" | "PENDING" | "ACTIVE";
|
||||
|
||||
/** What a caller needs the grant to permit. */
|
||||
export type GrantNeed = "read" | "write" | "manage";
|
||||
|
||||
export type GrantErrorCode =
|
||||
/** An account cannot share its record with itself. */
|
||||
| "self_grant"
|
||||
@@ -134,179 +116,6 @@ export class GrantError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrow client slice these functions need, so a caller can pass a
|
||||
* transaction handle. Same shape as `issue-token.ts`.
|
||||
*/
|
||||
type GrantDb = Pick<Prisma.TransactionClient, "accountGrant" | "auditLog">;
|
||||
|
||||
// ── The state machine, pure ─────────────────────────────────────────────────
|
||||
|
||||
/** What this grant is, at `now`. */
|
||||
export function grantState(
|
||||
grant: GrantLifecycle,
|
||||
now: Date = new Date(),
|
||||
): GrantState {
|
||||
if (grant.revokedAt !== null) return "REVOKED";
|
||||
// `<=` matches the Bearer-token expiry comparison: the instant named is the
|
||||
// first instant the grant no longer holds.
|
||||
if (grant.expiresAt !== null && grant.expiresAt <= now) return "EXPIRED";
|
||||
if (grant.acceptedAt === null) return "PENDING";
|
||||
return "ACTIVE";
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this grant confer anything right now?
|
||||
*
|
||||
* The one predicate the resolver asks. Deliberately derived from
|
||||
* {@link grantState} rather than re-listing the conditions, so a fourth
|
||||
* terminal state can never be added without this answering for it.
|
||||
*/
|
||||
export function isGrantActive(
|
||||
grant: GrantLifecycle,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
return grantState(grant, now) === "ACTIVE";
|
||||
}
|
||||
|
||||
/**
|
||||
* The three levels, ordered. Higher reaches everything lower.
|
||||
*
|
||||
* A total order rather than a set of capabilities per level, because that is
|
||||
* what the consent screen says out loud: view, view and add, manage. Written
|
||||
* as two tables rather than as a chain of comparisons so that a fourth level
|
||||
* cannot be added without deciding where it sits — an unlisted key does not
|
||||
* typecheck, and the exhaustiveness is the point of the `Record` type.
|
||||
*/
|
||||
const ACCESS_RANK: Record<AccountGrantAccess, number> = {
|
||||
READ: 0,
|
||||
WRITE: 1,
|
||||
MANAGE: 2,
|
||||
};
|
||||
|
||||
const NEED_RANK: Record<GrantNeed, number> = {
|
||||
read: 0,
|
||||
write: 1,
|
||||
manage: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Does this grant permit what the caller is about to do?
|
||||
*
|
||||
* 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; a MANAGE grant permits all three, because managing
|
||||
* a record it cannot read or add to describes nothing either. The state
|
||||
* outranks the level in both directions: a revoked MANAGE grant permits
|
||||
* nothing at all, and no level survives an expiry.
|
||||
*
|
||||
* The comparison is `>=` on the ranks above and not equality, and the
|
||||
* difference matters in exactly one direction: a WRITE grant must never
|
||||
* satisfy `"manage"`. That is the whole of the third level's meaning — an
|
||||
* accepted WRITE grant carries a consent to add, and nothing about it carries
|
||||
* a consent to rewrite or remove.
|
||||
*/
|
||||
export function grantAllows(
|
||||
grant: GrantLifecycle & { access: AccountGrantAccess },
|
||||
need: GrantNeed,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!isGrantActive(grant, now)) return false;
|
||||
return ACCESS_RANK[grant.access] >= NEED_RANK[need];
|
||||
}
|
||||
|
||||
// ── Scope ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The slice of a grant row the scope predicate reads. */
|
||||
export interface GrantScope {
|
||||
scopeJson: Prisma.JsonValue | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a stored scope actually opens.
|
||||
*
|
||||
* `null` — the whole record. Not a default standing in for a missing answer:
|
||||
* it is the answer every grant written before the column existed was consented
|
||||
* as, and the answer an owner who ticks "entire record" gives today. It is a
|
||||
* first-class value everywhere, never a legacy badge.
|
||||
*
|
||||
* A set — those sections and nothing else, `record` included (see
|
||||
* {@link grantCoversDomain}).
|
||||
*/
|
||||
export type ResolvedScope = ReadonlySet<ShareDomain> | null;
|
||||
|
||||
const EMPTY_SCOPE: ReadonlySet<ShareDomain> = new Set<ShareDomain>();
|
||||
|
||||
/**
|
||||
* Turn the stored blob into what it opens, refusing anything it cannot read.
|
||||
*
|
||||
* Fail-closed, and this is the deliberate inverse of `normalisePrefs` in the
|
||||
* module gate: that one resolves a malformed preference blob to "everything
|
||||
* on", because a presentation preference nobody can read should not hide a
|
||||
* person's own data from them. This one answers an authorization question, so
|
||||
* a value it cannot read resolves to the EMPTY set — every section refused,
|
||||
* including any the garbage might have happened to name. Both directions are
|
||||
* "do the safe thing with a value we do not understand"; they only look
|
||||
* opposite because the safe thing differs.
|
||||
*
|
||||
* What counts as unreadable is deliberately wide: anything that is not an
|
||||
* array, an empty array, an array holding a key outside the vocabulary, an
|
||||
* array holding a non-string. There is no partial credit — a set that is half
|
||||
* recognisable is a set somebody wrote in a shape this file does not
|
||||
* understand, and honouring the half we recognise would be guessing at
|
||||
* consent. `record` is not a member of the vocabulary, so a stored `["record"]`
|
||||
* is unreadable too, which is the correct reading: a scope that means "no
|
||||
* scope" is a thing the invite path refuses to write, and one that reached
|
||||
* the column by any other route opens nothing.
|
||||
*/
|
||||
export function normaliseScope(stored: Prisma.JsonValue | null): ResolvedScope {
|
||||
if (stored === null || stored === undefined) return null;
|
||||
if (!Array.isArray(stored) || stored.length === 0) return EMPTY_SCOPE;
|
||||
|
||||
const domains = new Set<ShareDomain>();
|
||||
for (const entry of stored) {
|
||||
if (!isShareDomain(entry)) return EMPTY_SCOPE;
|
||||
// A scope is a subset, not merely an array of recognisable values. A
|
||||
// duplicated key is malformed rather than a second consent to the same
|
||||
// domain, so it receives no partial credit.
|
||||
if (domains.has(entry)) return EMPTY_SCOPE;
|
||||
domains.add(entry);
|
||||
}
|
||||
return domains;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this grant open the section the route declared?
|
||||
*
|
||||
* Three rules, and the second is the one that carries the invariant:
|
||||
*
|
||||
* * A NULL scope covers everything, `record` included. That is what every
|
||||
* grant in the product was until this release and what most will stay.
|
||||
* * A non-NULL scope NEVER covers `record`. A route that declares `record`
|
||||
* reads across sections — a health score, a digest, an achievement set —
|
||||
* and there is no honest way to answer one of those for a delegate who was
|
||||
* given part of a record. A score that says 70 to the owner and 64 to the
|
||||
* delegate is a support case and a clinical hazard, so the scoped delegate
|
||||
* does not get a filtered answer, they get no answer.
|
||||
* * Otherwise, membership. A key that postdates the invitation is not in the
|
||||
* stored set and so is not covered, which is why a new section is a
|
||||
* consent question rather than a schema question.
|
||||
*
|
||||
* The level is a separate question, asked by {@link grantAllows}. This one
|
||||
* knows nothing about READ, WRITE or MANAGE — and MANAGE rows carry a NULL
|
||||
* scope by construction, so it answers true for them by the first rule rather
|
||||
* than by a special case.
|
||||
*/
|
||||
export function grantCoversDomain(
|
||||
grant: GrantScope,
|
||||
domain: ShareScope,
|
||||
): boolean {
|
||||
const scope = normaliseScope(grant.scopeJson);
|
||||
if (scope === null) return true;
|
||||
if (domain === ENTIRE_RECORD) return false;
|
||||
return scope.has(domain);
|
||||
}
|
||||
|
||||
// ── Lifecycle transitions ───────────────────────────────────────────────────
|
||||
|
||||
export interface InviteGrantInput {
|
||||
@@ -878,54 +687,6 @@ export async function renounceGrantAndClearSwitch(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Resolution ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The grant that lets `granteeId` act on `grantorId`'s record right now, or
|
||||
* null.
|
||||
*
|
||||
* The query narrows on `revokedAt: null` only — which the partial unique index
|
||||
* makes at most one row — and every other condition is decided by
|
||||
* {@link isGrantActive}. That split is deliberate: a SQL predicate spelling out
|
||||
* "accepted and not expired" beside a TypeScript predicate spelling out the
|
||||
* same thing is two places deciding one question, and the copy that drifts is
|
||||
* the one nobody reads. Here the database narrows and the state machine
|
||||
* decides.
|
||||
*/
|
||||
export async function findActiveGrant(
|
||||
pair: { grantorId: string; granteeId: string },
|
||||
db: GrantDb = prisma,
|
||||
now: Date = new Date(),
|
||||
): Promise<AccountGrant | null> {
|
||||
const live = await db.accountGrant.findFirst({
|
||||
where: {
|
||||
grantorId: pair.grantorId,
|
||||
granteeId: pair.granteeId,
|
||||
revokedAt: null,
|
||||
},
|
||||
});
|
||||
if (!live) return null;
|
||||
return isGrantActive(live, now) ? live : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp `lastUsedAt` on a grant that was just exercised.
|
||||
*
|
||||
* Fire-and-forget, the `ApiToken.lastUsedAt` posture (`bearer.ts`): the request
|
||||
* that triggered it must not wait, and a failure to record the use must not
|
||||
* fail the read. The promise is returned so a test can await it; production
|
||||
* callers deliberately do not.
|
||||
*/
|
||||
export function touchGrantUsage(
|
||||
grantId: string,
|
||||
db: GrantDb = prisma,
|
||||
): Promise<void> {
|
||||
return db.accountGrant
|
||||
.update({ where: { id: grantId }, data: { lastUsedAt: new Date() } })
|
||||
.then(() => undefined)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// ── Internals ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { findActiveGrant } from "@/lib/sharing/grant-access";
|
||||
|
||||
/**
|
||||
* The authority that admitted provider work for a record.
|
||||
|
||||
@@ -31,13 +31,15 @@ import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
GrantError,
|
||||
acceptGrant,
|
||||
findActiveGrant,
|
||||
grantAllows,
|
||||
inviteGrant,
|
||||
renounceGrant,
|
||||
revokeGrant,
|
||||
touchGrantUsage,
|
||||
} from "@/lib/sharing/grants";
|
||||
import {
|
||||
findActiveGrant,
|
||||
grantAllows,
|
||||
touchGrantUsage,
|
||||
} from "@/lib/sharing/grant-access";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
@@ -4,11 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cookieJar, headerJar, queuedSessionIds } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
|
||||
import { createManagedProfile } from "@/lib/managed-profiles/create";
|
||||
import {
|
||||
acceptGrant,
|
||||
findActiveGrant,
|
||||
inviteGrant,
|
||||
} from "@/lib/sharing/grants";
|
||||
import { acceptGrant, inviteGrant } from "@/lib/sharing/grants";
|
||||
import { findActiveGrant } from "@/lib/sharing/grant-access";
|
||||
import {
|
||||
clearManagedProfileMarker,
|
||||
ManagedProfileLifecycleError,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import {
|
||||
acceptGrant,
|
||||
grantCoversDomain,
|
||||
inviteGrant,
|
||||
} from "@/lib/sharing/grants";
|
||||
import { acceptGrant, inviteGrant } from "@/lib/sharing/grants";
|
||||
import { grantCoversDomain } from "@/lib/sharing/grant-access";
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
|
||||
let counter = 0;
|
||||
|
||||
Reference in new issue
Block a user