DEV Community

MidnightEcho794261
MidnightEcho794261

Posted on

Health Data Consent Explained: Enforcing Category Grants, Scope, and Withdrawal

Short answer: model consent as a versioned authorization decision over data category, purpose, recipient, and time; make withdrawal invalidate every later decision that depended on the old grant. A login proves who is asking. It doesn't prove that a marketplace member agreed to share lab results with a particular clinic for a particular purpose.

For a small team, the tempting design is one consent=true column beside the user record. It ships quickly, then becomes ambiguous as soon as the application adds a second category of health data or a second recipient. The practical choice is a compact policy layer with immutable grant events, a current-state projection, and category checks at the point where data leaves its owning boundary. That's more machinery than a boolean, but much less machinery than reconstructing authorization after the fact.

How should health data consent category checks handle grants and revocation?

Treat the request path as a four-part decision. Authentication supplies the subject and caller identities. The application supplies the requested data categories and declared purpose. A consent evaluator reads the latest grant state and returns an allow or a reasoned denial. Only then may the handler read or disclose the protected record. Keep that order: fetching sensitive data and filtering it afterward expands the amount of code that can accidentally observe it.

This small TypeScript example is deliberately a policy function, not a framework integration. The marketplace in the example connects a member with a clinic, and a grant can cover lab_results without silently covering medications.

type DataCategory = "lab_results" | "medications" | "visit_notes";

type ConsentGrant = {
  id: string;
  subjectId: string;
  recipientId: string;
  purpose: "care_delivery" | "billing";
  categories: readonly DataCategory[];
  grantedAt: string;
  expiresAt: string | null;
  revokedAt: string | null;
  version: number;
};

type AccessRequest = {
  subjectId: string;
  recipientId: string;
  purpose: ConsentGrant["purpose"];
  categories: readonly DataCategory[];
  evaluatedAt: string;
};

type Decision =
  | { allowed: true; grantId: string; grantVersion: number }
  | { allowed: false; reason: "no_matching_grant" | "expired" | "revoked" };

function evaluateConsent(
  request: AccessRequest,
  grants: readonly ConsentGrant[],
): Decision {
  const candidates = grants
    .filter((grant) =>
      grant.subjectId === request.subjectId &&
      grant.recipientId === request.recipientId &&
      grant.purpose === request.purpose &&
      request.categories.every((category) => grant.categories.includes(category))
    )
    .sort((a, b) => b.version - a.version);

  const grant = candidates[0];
  if (!grant) return { allowed: false, reason: "no_matching_grant" };
  if (grant.revokedAt && grant.revokedAt <= request.evaluatedAt) {
    return { allowed: false, reason: "revoked" };
  }
  if (grant.expiresAt && grant.expiresAt <= request.evaluatedAt) {
    return { allowed: false, reason: "expired" };
  }

  return { allowed: true, grantId: grant.id, grantVersion: grant.version };
}
Enter fullscreen mode Exit fullscreen mode

The return value carries the exact grant version used for the decision. That detail looks fussy until a member changes consent while a background export is queued. The worker can compare the recorded version with current state immediately before disclosure and refuse stale work. Don't let a queue message become an authorization snapshot with an unlimited lifetime.

Category checks belong at disclosure boundaries

Categories should describe what the application can actually enforce. A single broad label such as medical_data gives a clean form but a weak policy boundary; hundreds of narrow labels create an interface nobody can understand and rules the team can't reliably test. The useful middle is a stable application vocabulary mapped to storage and product behavior. In this marketplace, lab_results, medications, and visit_notes are separate because the service can retrieve and disclose them independently.

Decision input It must answer Unsafe shortcut
Subject Whose health data is involved? Reusing the caller ID
Recipient Who may receive it? Treating every clinic alike
Category Which records are covered? One medical_data flag
Purpose Why is disclosure allowed? Inferring purpose from a route
Time Is the grant active now? Checking only at grant creation

Check the complete requested set, not whether any category overlaps. If a grant includes lab results and a request asks for lab results plus visit notes, an some() test turns partial permission into full access. The evaluator above uses every() for exactly this reason. It is a tiny operator with a large security consequence.

Authentication remains a separate control. OWASP recommends consistent authentication handling and careful session management, but a valid session cannot answer whether a particular disclosure is permitted. Conflating those decisions makes logout, account suspension, and consent withdrawal look interchangeable when they have different scope and audit meaning.

There is a catch. Category enforcement at every boundary adds policy calls to interactive reads, exports, webhooks, and scheduled jobs. A service with one fixed disclosure and no category-level choice may be better served by a smaller, explicit authorization rule. Move to a dedicated evaluator when multiple recipients, purposes, or asynchronous paths make duplicated checks harder to reason about than the policy layer itself.

Grants need history, not mutable truth

A grant record should answer who the subject and recipient were, which categories and purpose were approved, when the decision took effect, when it expires, and which version produced an access decision. Store new grant and withdrawal events instead of overwriting the old row. The current-state projection is allowed to be mutable because it exists for fast decisions; the underlying history should remain available for an audit trail.

Keep the signed-in account identifier distinct from the consent subject identifier. They may happen to match in a simple adult account, but a health application can have delegated workflows, and collapsing the two concepts early makes later policy changes dangerous. This is a modeling recommendation, not a claim that one delegation scheme fits every jurisdiction. I'm not sure any universal category vocabulary would survive contact with both a product's data model and its legal review; the uncertainty is resolved by documenting the mapping, getting domain review, and testing the enforcement points against it.

Grant creation also needs idempotency. A retried submit should not create two active grants with competing versions, while an actual change in categories should create a new version. Use a client request identifier or an equivalent deduplication key, record the normalized policy input, and make the resulting version explicit. The same rule helps a solo team keep audit storage predictable: retain compact decision inputs and identifiers, not copied health records in every log line.

Withdrawal is a distributed-systems event

Withdrawal changes authorization from a defined effective time onward. The synchronous write can append the event and update the current projection in one transaction, but the real system usually has caches, queues, exports, and downstream recipients. Each path needs a stated contract: when it rechecks consent, which version it saw, and what it does with work authorized under an older version.

Consider one ordinary race. At 10:00, grant version 7 permits a clinic to receive lab_results, and an export job records that version when it enters the queue. At 10:02, the member withdraws the category, producing version 8. The worker starts at 10:04. If it trusts the enqueue-time decision, it sends data under stale authority; if it evaluates current state just before loading the record, it sees the withdrawal and ends the job without touching the payload. A retry at 10:06 must reach the same denial rather than resurrecting version 7. This is why the queue should carry policy coordinates and a version for comparison, not a copied allow result treated as permanent authority. The timestamps here illustrate ordering, not a promised propagation window: the actual service-level objective depends on the storage, cache, and queue guarantees the team can test.

No stale allow.

Be strict at the last responsible moment.

Interactive reads should evaluate current state before the protected read. A queued disclosure should re-evaluate before sending. A cache key should include the consent version or be evicted when a withdrawal event is committed. Observability should record subject, recipient, purpose, requested categories, decision reason, and grant version using non-sensitive identifiers; it should not dump the underlying clinical payload. Those choices make a denial explainable without turning telemetry into another copy of the health dataset.

Revocation semantics also expose a real limitation: software can stop future access inside systems it controls, but it cannot pretend that an already completed disclosure never occurred. The product language and architecture should distinguish blocking future processing from handling data already delivered under a valid earlier decision. Legal requirements vary, so counsel and privacy specialists must define retention and downstream obligations; the engineering model should represent their decision rather than invent it.

Before release, exercise the awkward transitions in tests: a category added to a request, expiry at the exact evaluation timestamp, withdrawal while a job is queued, a duplicate grant submission, and a stale cached allow decision. Deploy the evaluator in report-only mode only if reports contain no protected payload and cannot authorize access; then make denial the default for missing or malformed policy state. Watch denial reasons and policy latency separately from login failures. This checklist is intentionally operational because consent design fails in the gaps between a correct table and the code paths that actually disclose data.

The selection rule is plain: use a versioned, category-aware grant model when the application has independently shareable health data, multiple purposes or recipients, or asynchronous disclosures. Stick with a narrower application rule when the workflow truly has one fixed scope and the extra policy surface would create ceremony without a meaningful user choice. In either case, keep authentication, consent, and data retrieval as separate decisions that can be tested and audited.

References

Top comments (0)