Account & Access Model
How accounts, organisations, teams, users, roles, and patients relate. The full vision (with branches and multi-org) and the MVP subset. Designed so the MVP schema doesn't lock us out of multi-tenant growth.
1. Account Hierarchy · Full Vision
The PM has documented four use cases with progressively richer hierarchies. The MVP only ships the simplest two; the schema accommodates all of them from day one to avoid a rewrite when growth comes.
2. Schema Sketch
Postgres-flavored. Multi-tenant from day one via org_id; team scoping via team_id.
Even when MVP runs single-org / single-team, every row has the column populated. Adding multi-tenancy
later becomes an admin tool, not a schema migration.
-- Tenant root
orgs (
id UUID PK,
name TEXT,
type TEXT CHECK (type IN ('single_user', 'group_practice', 'org_with_branches')),
contract_owner_user_id UUID, -- nullable for hilo-internal accounts
created_at TIMESTAMPTZ,
-- billing/contract metadata, region, etc.
)
-- Optional team grouping inside an org (used by group_practice + org_with_branches)
teams (
id UUID PK,
org_id UUID FK orgs(id),
name TEXT,
parent_team_id UUID FK teams(id), -- supports branches later
created_at TIMESTAMPTZ,
)
-- Identity provider issues the JWT; we mirror the user record for joins
users (
id UUID PK,
org_id UUID FK orgs(id),
team_id UUID FK teams(id), -- nullable
external_id TEXT UNIQUE, -- Auth0 / Entra ID user id
email TEXT,
full_name TEXT,
created_at TIMESTAMPTZ,
)
-- Roles are global + scoped via the assignment
roles (
id TEXT PK, -- 'org_admin', 'team_lead', 'user', 'hilo_admin', 'support'
description TEXT,
)
user_roles (
user_id UUID FK users(id),
role_id TEXT FK roles(id),
scope_org_id UUID, -- for org-scoped roles
scope_team_id UUID, -- for team-scoped roles
PRIMARY KEY (user_id, role_id, scope_org_id, scope_team_id),
)
-- Patient is the subject of monitoring; assignment is the link
patients (
id UUID PK,
org_id UUID FK orgs(id), -- which org "owns" this patient record
external_b2c_id TEXT, -- link to canonical B2C record
full_name TEXT,
email TEXT,
date_of_birth DATE,
created_at TIMESTAMPTZ,
)
device_assignments (
id UUID PK,
org_id UUID FK orgs(id),
patient_id UUID FK patients(id),
device_id TEXT, -- references B2C device
assigned_to_user_id UUID FK users(id),
assigned_at TIMESTAMPTZ,
unassigned_at TIMESTAMPTZ, -- nullable
status TEXT CHECK (status IN ('active', 'paused', 'inactive')),
)
-- Consent record (legal anchor for patient-managed flow)
patient_consent (
id UUID PK,
patient_id UUID FK patients(id),
org_id UUID FK orgs(id),
scope JSONB, -- what the consent covers
granted_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
evidence JSONB, -- token, IP, user agent at grant time
)
-- Audit log · append-only · no UPDATE/DELETE grants
audit_log (
id BIGSERIAL PK,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
actor_user_id UUID,
actor_org_id UUID,
event_type TEXT, -- 'patient.read', 'export.csv', 'role.change', ...
subject_id UUID,
payload JSONB,
request_id TEXT,
ip TEXT,
user_agent TEXT,
)
CREATE INDEX audit_log_actor_idx ON audit_log (actor_user_id, ts DESC);
CREATE INDEX audit_log_subject_idx ON audit_log (subject_id, ts DESC);
3. Role Matrix · MVP Subset
The PM's full role matrix has many TBD cells; the MVP subset below is what we plan to actually ship.
| Role | Scope | Sees patient data of | Can assign devices | Can manage users | Can export | MVP? |
|---|---|---|---|---|---|---|
| Single User / Admin | Org (own) | All patients in org | Yes | N/A (only user) | Yes | In |
| Org Admin (group practice) | Org | All patients in org | Yes | Yes | Yes | Stretch |
| Team Lead | Team | Patients assigned to team users | Within team | Within team | Within team | Stretch |
| User (clinician) | Self | Patients assigned to self | Self only | No | Self only | In |
| Branch Admin | Branch (sub-org) | Branch patients | Within branch | Within branch | Within branch | Out |
| Hilo Admin | Global | (internal tooling — not in dashboard MVP) | — | — | — | Phase 2 |
| Hilo Support | Per-org (impersonation) | Read-only on assigned org with audit trail | No | No | No | Phase 2 |
4. RBAC Enforcement Pattern
Every API handler is wrapped in middleware that:
- Validates the JWT against the IdP (Auth0 / Azure AD B2C JWKS)
- Loads the user record and their roles
- Checks the requested resource against role scope (org_id, team_id)
- Records an audit log entry with actor + subject
- Calls the handler with a scoped DB query helper
// pseudo-code
app.get('/api/patients/:id', requireAuth, requireScope('patient:read'), async (req, res) => {
const patient = await db.scoped(req.user).patients.findById(req.params.id);
if (!patient) return res.status(404).end();
await audit.log(req, 'patient.read', { patient_id: patient.id });
res.json(patient);
});
Critical property: db.scoped(req.user) automatically filters by org_id (and
team_id when role is team-scoped). Cannot accidentally leak across orgs.
5. Patient Consent Flow (Patient-Managed mode — Phase 2)
Architecturally specced for Phase 2; not built for May 28th. Documented now to lock the schema decisions.
- Doctor adds patient (name + email) in the dashboard
- System creates a
patient_consentrow inpendingstate - Email with signed link sent to patient (signed = HMAC of consent_id + expiry)
- Patient clicks link → consent page rendered → terms displayed → patient grants or declines
- System records
granted_atwith evidence JSONB (IP, UA, token used) - Audit log entry written
- From this point, B2B service can read patient data via the consent gate
- Patient can revisit a self-service revocation page at any time →
revoked_atset → access removed
Legal blocker. The consent terms wording, retention period for consent records, evidence fields, and revocation cascade behavior all need Hilo legal sign-off. Cannot ship to real patients without that. See Risk R-03.
6. Open Questions from PM's Doc
| Question | Why it blocks | Owner |
|---|---|---|
| Is full account management approach in MVP scope? | Determines if we ship UI for org / team management | PM |
| Who is allowed to see private (PII) data? | Affects field-level access in API serializer | Legal |
| How do we manage online order bundles (devices + subscription + dashboard fee)? | Determines whether Shopify or backoffice owns this | PM + tech |
| When is the customer account created? (after contract / order / payment / approval) | Determines when DB rows are inserted, when emails fire | PM |
| How is the first admin created? (Hilo / checkout / activation link / SSO later) | Determines auth bootstrap flow | PM + tech |
| Edit rights / view rights / data view rights split | Determines fine-grained permissions vs role-only access | Legal + PM |
7. The Account-Based vs Device-Based Decision (resolved)
The PM doc explicitly leans account-based access — access rights and duration live on the user account, the bracelet is just hardware. This is the right call.
Pros: cleaner subscription model, clearer ownership, stronger control over transfer/abuse, closer to WHOOP model, simpler revoke semantics, easier to support multi-patient bands later (just re-assign the device — access stays with whoever the patient assigned it to).
Cons (and our mitigations):
- Replacement / gifting needs stricter rules → handled by explicit device reassignment flow with audit log
- Handing the bracelet to another person does not auto-transfer active access → that's the desired behavior, not a bug