mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(cycle): add the calendar-feed credential table
A record's cycle calendar subscription is a URL credential: one row per feed, only the HMAC of the raw hlf_ token stored, and at most one live feed per record through a partial unique index on user_id where revoked_at is null. Both foreign keys cascade, so deleting the record or the carrier who minted the feed ends it. The row is a credential everywhere else too. It stays out of the backup (a restore would bring back a revoked link), Delete All Data takes it on both sides, a credential remediation revokes the feeds on the record and the ones the account minted elsewhere, and a managed-profile handover claim revokes any feed a guardian minted. backupVerdict moves beside the plan into its own module so the plan stays the lists and their reasons.
This commit is contained in:
1 parent
c0470df14c
commit
2480a0138d
9 files changed
+235
-18
No files matched your search
@@ -9,8 +9,8 @@ import {
|
||||
DERIVED_MODELS,
|
||||
NOT_IN_BACKUP_MODELS,
|
||||
RAW_SQL_TABLES,
|
||||
backupVerdict,
|
||||
} from "@/lib/export/backup-plan";
|
||||
import { backupVerdict } from "@/lib/export/backup-verdict";
|
||||
|
||||
/**
|
||||
* Every model in the schema has a written verdict about the backup.
|
||||
|
||||
@@ -52,6 +52,7 @@ vi.mock("@/lib/db", () => ({
|
||||
trustedDevice: { deleteMany: vi.fn() },
|
||||
mcpOAuthConnection: { updateMany: vi.fn() },
|
||||
clinicianShareLink: { updateMany: vi.fn() },
|
||||
cycleCalendarFeed: { updateMany: vi.fn() },
|
||||
stepUpElevation: { deleteMany: vi.fn() },
|
||||
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
||||
},
|
||||
@@ -584,6 +585,15 @@ describe("destroyAllSessions", () => {
|
||||
where: { userId: "user-rotated", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
// The calendar feeds on the record and the ones this account minted on a
|
||||
// record it manages are both URL credentials, and both end here.
|
||||
expect(prisma.cycleCalendarFeed.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [{ userId: "user-rotated" }, { createdById: "user-rotated" }],
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ type CredentialStore = Pick<
|
||||
| "trustedDevice"
|
||||
| "mcpOAuthConnection"
|
||||
| "clinicianShareLink"
|
||||
| "cycleCalendarFeed"
|
||||
| "stepUpElevation"
|
||||
>;
|
||||
|
||||
@@ -56,6 +57,14 @@ export function credentialRevocations(
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
}),
|
||||
// A cycle calendar feed is the same kind of door (#1350): a URL that
|
||||
// serves the record's forecast to whoever holds it. Both the feeds on this
|
||||
// record and the ones this account minted on a record it manages end here,
|
||||
// so a remediation never leaves a link it cannot see.
|
||||
db.cycleCalendarFeed.updateMany({
|
||||
where: { OR: [{ userId }, { createdById: userId }], revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
}),
|
||||
db.stepUpElevation.deleteMany({ where: { userId } }),
|
||||
];
|
||||
}
|
||||
@@ -223,6 +223,10 @@ export const WIPE_MODELS = [
|
||||
// `resolveShareGateState` both resolve an unknown `tokenHash` to `null`, and
|
||||
// null is the same blunt 404 a revoked link gets.
|
||||
"ClinicianShareLink",
|
||||
// #1350 — the cycle calendar feed, on both sides (see WIPE_OWNER_FIELDS):
|
||||
// the feed on this record, and any this account minted on a record it
|
||||
// manages. Deleting the row is the revocation, as for the share link.
|
||||
"CycleCalendarFeed",
|
||||
// v1.36.0 — account grants, on BOTH sides (see WIPE_OWNER_FIELDS): the ones
|
||||
// this account made over its own record, and the ones it holds over someone
|
||||
// else's.
|
||||
@@ -587,6 +591,8 @@ export const WIPE_OWNER_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
NotificationEvent: ["recordUserId"],
|
||||
NotificationEgressAuthorization: ["recordUserId", "recipientUserId"],
|
||||
ManagedProfileHandover: ["profileId", "createdById"],
|
||||
// #1350 — the record the feed serves, and the carrier who minted it.
|
||||
CycleCalendarFeed: ["userId", "createdById"],
|
||||
// Both sides. Wiping only the grantor side would leave the account still
|
||||
// holding read access to other people's records after it asked for
|
||||
// everything of its own to be deleted.
|
||||
|
||||
@@ -792,6 +792,8 @@ export const NOT_IN_BACKUP_MODELS: Readonly<Record<string, string>> = {
|
||||
"Live authorization over another person's health record. A backup is a snapshot of DATA, and rolling data back is a decision the owner's admin makes; rolling an authorization back is a decision nobody makes — the grant an owner revoked on Tuesday would come back alive out of Monday's file, silently, with no notification and nobody aware that access resumed. Every credential-shaped row in this file is excluded for that reason (ApiToken, TrustedDevice, StepUpElevation, UserKnownDevice) and a share link for the closest one (ClinicianShareLink). The cost is stated rather than hidden: after a disaster restore, sharing is OFF and the owner has to invite again and the delegate has to accept again. That is the fail-safe direction — access lost, never access resumed — and re-consenting after a restore is the correct amount of ceremony for handing someone your health record a second time.",
|
||||
ClinicianShareLink:
|
||||
"A live URL handed to a practice. Restoring it would resurrect a link the account may have revoked deliberately — and it points at a host that may not be this one.",
|
||||
CycleCalendarFeed:
|
||||
"A live calendar-feed URL (#1350): a credential a calendar provider polls, stored only as its hash. Restoring it would bring back a link the owner may have revoked or rotated, and it names a host that may not be this one. After a restore the owner turns the subscription on again and re-adds the new link.",
|
||||
ClinicianShareLinkDocument:
|
||||
"Names which documents a share link exposed. Without the link it grants nothing, and with it, it would re-expose files through a URL the account may have revoked.",
|
||||
|
||||
@@ -834,20 +836,3 @@ export const AUTH_MODELS_OUT_OF_SCOPE: readonly string[] = [
|
||||
"OidcNativeHandoff",
|
||||
"InviteRedemption",
|
||||
];
|
||||
|
||||
export type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH";
|
||||
|
||||
/**
|
||||
* The verdict for a model, or `null` when the schema carries a model this file
|
||||
* has never ruled on. The guard turns that `null` into a failing test, which
|
||||
* is the whole point: a new model gets a decision, not a default.
|
||||
*/
|
||||
export function backupVerdict(model: string): BackupVerdict | null {
|
||||
if ((BACKED_UP_MODELS as readonly string[]).includes(model)) {
|
||||
return "BACKED_UP";
|
||||
}
|
||||
if (model in DERIVED_MODELS) return "DERIVED";
|
||||
if (model in NOT_IN_BACKUP_MODELS) return "NOT_IN_BACKUP";
|
||||
if (AUTH_MODELS_OUT_OF_SCOPE.includes(model)) return "AUTH";
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The backup verdict for one schema model, read off the lists in
|
||||
* `./backup-plan.ts`. Kept beside it rather than in it so the plan stays the
|
||||
* lists and their reasons, and the one question asked of them lives here.
|
||||
*/
|
||||
import {
|
||||
AUTH_MODELS_OUT_OF_SCOPE,
|
||||
BACKED_UP_MODELS,
|
||||
DERIVED_MODELS,
|
||||
NOT_IN_BACKUP_MODELS,
|
||||
} from "./backup-plan";
|
||||
|
||||
export type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH";
|
||||
|
||||
/**
|
||||
* The verdict for a model, or `null` when the schema carries a model this file
|
||||
* has never ruled on. The guard turns that `null` into a failing test, which
|
||||
* is the whole point: a new model gets a decision, not a default.
|
||||
*/
|
||||
export function backupVerdict(model: string): BackupVerdict | null {
|
||||
if ((BACKED_UP_MODELS as readonly string[]).includes(model)) {
|
||||
return "BACKED_UP";
|
||||
}
|
||||
if (model in DERIVED_MODELS) return "DERIVED";
|
||||
if (model in NOT_IN_BACKUP_MODELS) return "NOT_IN_BACKUP";
|
||||
if (AUTH_MODELS_OUT_OF_SCOPE.includes(model)) return "AUTH";
|
||||
return null;
|
||||
}
|
||||
@@ -555,6 +555,25 @@ export async function clearManagedProfileMarker(
|
||||
where: { id: profile.id },
|
||||
data: { managedProfileAt: null },
|
||||
});
|
||||
// #1350 — a calendar feed a Guardian minted on the profile ends with the
|
||||
// handover. The new owner never saw the disclosure that came with it,
|
||||
// and a Guardian who keeps MANAGE afterwards would otherwise keep a
|
||||
// standing link to a grown-up's cycle dates nobody re-agreed to.
|
||||
const revoked = await client.cycleCalendarFeed.updateMany({
|
||||
where: {
|
||||
userId: profile.id,
|
||||
createdById: { not: profile.id },
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
if (revoked.count > 0) {
|
||||
await auditLog("cycle.feed.revoke.handover", {
|
||||
userId: profile.id,
|
||||
details: { revoked: revoked.count },
|
||||
client,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (tx) {
|
||||
await clear(tx);
|
||||
|
||||
Reference in new issue
Block a user