Skip to content

09 Family Profile Isolation

Anubha Parashar edited this page Oct 10, 2026 · 1 revision

One HealthBook AI account (internal code name: BabyCare) can hold several family profiles: the user, a child, a parent or any other family member. Every document, medicine, vaccination, lab value, Daily Health entry and timeline item belongs to exactly one profile, and the API checks that ownership on every request. Records are never merged or shared between profiles. Another account can see a profile only through an explicit caregiver grant.

Responsibilities

  • Model each profile as one patients row owned by one account (patients.user_id).
  • Resolve every profile-scoped or resource-scoped request through own_patient() / own_resource().
  • Return 404, not 403, for resources the caller has no relationship to.
  • Reject citations of source documents from another profile.
  • Grant other accounts viewer or editor access through single-use invitations, which only the owner can issue or revoke.

Inputs and outputs

Endpoint Purpose Ownership rule
POST /api/patients Create a profile (name, DOB, tz, sex, relationship) Owned by the caller
GET /api/patients List profiles Owned ones plus those with a caregiver_access row
GET/PATCH /api/patients/{pid}/profile Profile metadata and age group own_patient
PUT /api/patients/{pid}/modules Show or hide modules for this profile own_patient
POST /api/patients/{pid}/invitations Issue an invitation code (viewer or editor, guardian_permission: true) Owner only (guardian)
POST /api/invitations/accept Accept a code Code hash, unaccepted, not expired, same email
GET /api/patients/{pid}/family Owner flag and members own_patient
DELETE /api/patients/{pid}/family/{access_id} Revoke a caregiver Owner only

Mechanism

flowchart TD
  ACC["Account demo@example.invalid"]
  OTHER["Other account without a grant"]
  CG["Caregiver account with grant on A"]
  subgraph BOUNDARY["Authorization boundary - require_user then own_patient or own_resource on every request"]
    subgraph PA["Family Profile A - Alex Example"]
      A1["Reports and documents"]
      A2["Medicines"]
      A3["Vaccinations"]
      A4["Labs"]
      A5["Daily Health"]
      A6["Timeline"]
    end
    subgraph PB["Family Profile B - isolated"]
      B1["Reports and documents"]
      B2["Medicines"]
      B3["Vaccinations"]
      B4["Labs"]
      B5["Daily Health"]
      B6["Timeline"]
    end
  end
  ACC -->|"owns"| PA
  ACC -->|"owns"| PB
  A2 -.->|"source_document_id must be in A"| A1
  B2 -.->|"source_document_id must be in B"| B1
  A2 -.-x|"citing a B document returns 400"| B1
  OTHER -.-x|"404 Not found"| BOUNDARY
  CG -->|"viewer reads, editor writes"| PA
Loading

own_patient and own_resource

patient = db.get(Patient, patient_id)
if not patient: raise HTTPException(404, 'Not found')
if patient.user_id != user.id:
    grant = db.query(CaregiverAccess).filter_by(patient_id=patient_id, user_id=user.id).first()
    if not grant: raise HTTPException(404, 'Not found')
    if user._write_access and grant.role != 'editor':
        raise HTTPException(403, 'Read-only caregiver access')

own_resource(db, user, Model, id) loads the row, reads its patient_id and runs the same check. Routes keyed by a record id rather than a profile id (/api/medicines/{id}, /api/vaccines/{id}, /api/documents/{id}/download, lifetime courses, care entries, cameras and so on) are therefore protected exactly like routes under /api/patients/{pid}/.... Every router (main, workflows, care_api, lifetime_api, history_api, export_api, share, camera_api, vitals_api, profiles_api) calls one of these two functions before touching data.

Why 404 and not 403

A foreign profile and a nonexistent profile both return 404 Not found. A 403 would confirm that profile or record id 42 exists and belongs to someone, which lets a caller enumerate ids across families. 403 is used only where the caller is already a legitimate member: a viewer caregiver trying to write, or a non-owner trying to manage sharing.

Source-document cross-profile rejection

Structured records keep provenance to an original document. safe_doc(db, pid, doc_id, page) requires that the document exists and that document.patient_id == pid. Otherwise it returns 400 "Source document does not belong to this profile". It also returns 422 if the cited page is outside the document. care_api and lifetime_api apply the same rule to linked entities (for example an episode or course on another profile). This blocks the case where the caller owns both profiles but cites a document from the wrong one.

Caregiver roles

Role Read Write /ask, /signed-access POST Manage sharing
Owner yes yes yes yes
editor yes yes yes no (403)
viewer yes no (403) yes (treated as reads) no (403)

Invitations: the owner calls POST /invitations and receives a secrets.token_urlsafe(32) code. Only its SHA-256 is stored, it expires after 3 days and the owner hands it over personally. Accepting requires the signed-in account's email to match the invitation. The owner cannot accept for their own profile (409), and re-accepting updates the role. Revoking deletes the grant and cancels that caregiver's pending or retrying notifications for the profile. Access ends at once, so the next download returns 404.

Modules are presentation only

api/profiles.py derives an age group (infant, toddler, child, teen, adult, senior) from the DOB and default modules per group. web/profiles.js uses profile.modules to hide navigation tabs and care_kinds to tailor Daily Health. Disabling a module never deletes data or moves it between profiles.

Security boundaries

  • PostgreSQL: harden_database() enables row-level security on every table and revokes the hosted-database client roles (anon, authenticated), so only the API's owner connection can read rows. Profile isolation itself is enforced in the API layer.
  • Account deletion removes the account's own profiles and records and leaves other families untouched (test_account_deletion_removes_owned_records_but_not_other_family).
  • Reminder fan-out and push delivery re-check that the recipient is still the owner or a grantee before sending.

Clinical safety rules

Isolation also protects provenance. A medicine, lab value or antibiotic course can cite only an original document from the same profile, so a parent-entered or clinician-documented record never points at another person's evidence.

Implementation

api/security.py (own_patient, own_resource), api/main.py (safe_doc, profile routes), api/workflows.py (guardian, invitations, family, revoke), api/profiles.py, api/profiles_api.py, api/models.py (Patient, CaregiverAccess, Invitation), api/cloud.py (harden_database), web/profiles.js. Tests: tests/test_api.py (test_authentication_and_family_isolation, test_cross_family_source_document_is_rejected), tests/test_profiles.py, tests/test_workflows.py (test_read_only_invitation_and_immediate_revocation, test_review_wrong_page_and_cross_family_are_rejected), tests/test_lifetime.py, tests/test_history_api.py, tests/test_import_history.py, tests/test_export.py, tests/test_review_confirm.py, tests/test_share.py, tests/test_delivery_security.py, tests/browser/family_workspace.spec.js.

Related pages

Authentication and Session Security · Database and Data Model · Medical Document Ingestion · Share With Doctor Architecture · API Design · Security and Privacy Model

Clone this wiki locally