Pre-0.5 database upgrade¶
MedTracker 0.5 adds PostgreSQL runtime roles and strict household row-level security. Existing databases created before 0.5 need a one-time database-admin bootstrap before the application migration runs.
New empty installs that run compose/init-roles.sql before Rails starts do not
need this manual bootstrap.
Who needs this¶
Run this bootstrap when all of these are true:
- The database was created by a MedTracker release before 0.5.
- The deployment uses PostgreSQL roles without app-superuser privileges.
- The deployment will run migrations through its existing shared database login.
Leave DATABASE_ROLE unset for migrations. The web process uses the same
shared database login and may set DATABASE_ROLE=med_tracker_app for runtime
row-level security. Owner-role switching is deferred until an explicit
ownership-adoption and rollback design exists for databases with mixed
historical object ownership.
One-time bootstrap¶
Run this SQL as a database administrator, superuser, or a role allowed to create
and manage PostgreSQL roles. Replace <database_name> and <app_login_role>
with the actual database and login role names. Quote names that contain hyphens.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'med_tracker_owner') THEN
CREATE ROLE med_tracker_owner NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'med_tracker_app') THEN
CREATE ROLE med_tracker_app NOLOGIN;
END IF;
END
$$;
ALTER ROLE med_tracker_owner NOLOGIN;
ALTER ROLE med_tracker_app NOLOGIN NOSUPERUSER NOBYPASSRLS;
GRANT med_tracker_owner TO <app_login_role>;
GRANT med_tracker_app TO <app_login_role>;
ALTER DATABASE <database_name> OWNER TO med_tracker_owner;
ALTER SCHEMA public OWNER TO med_tracker_owner;
GRANT CONNECT ON DATABASE <database_name> TO med_tracker_owner, med_tracker_app;
GRANT USAGE, CREATE ON SCHEMA public TO med_tracker_owner;
GRANT USAGE ON SCHEMA public TO med_tracker_app;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'med_tracker') THEN
GRANT USAGE ON SCHEMA med_tracker TO med_tracker_owner, med_tracker_app;
END IF;
END
$$;
The app login should remain a normal non-superuser role. Do not grant it
CREATEROLE, BYPASSRLS, or admin option on the runtime roles for normal
upgrades.
Preflight check¶
After the bootstrap SQL and before running migrations, run the read-only Rails preflight from a Rails container using the same database login the deployment uses:
rails med_tracker:pre_0_5_database_upgrade_preflight
The preflight checks that both runtime roles exist, are NOLOGIN, are not
superusers, do not have BYPASSRLS, and that the app login is a member of both
roles. If it fails, fix the bootstrap state before running db:prepare.
Account access bootstrap¶
The 0.5 household cutover replaces legacy users.role authorization with
household memberships and person access grants. Upgrades from pre-0.5 databases
must preserve three pieces of access state:
- every account-linked person needs an active
household_membership; - every active membership linked to a person needs a self
manageperson_access_grant; - active
carer_relationshipsneed matching grants for the patient person.
MedTracker includes an idempotent Rails migration to backfill that state. It
promotes one active membership to owner only when a household has no active
owner, preferring an active platform_admin account when one exists. Do not
manually deactivate accounts to recover access; missing membership/grant rows
are the usual failure mode.
Kubernetes and CNPG¶
For CloudNativePG, run the bootstrap against the primary PostgreSQL pod. Example:
kubectl exec -n <namespace> <primary-postgres-pod> -c postgres -- \
psql -d <database_name> -v ON_ERROR_STOP=1
Paste the bootstrap SQL into that psql session. Keep DATABASE_ROLE absent
from the migration init container. The application container may use
DATABASE_ROLE=med_tracker_app with the same shared database login. Reconcile
the release through Flux or the deployment's normal controller.
Verification¶
After the migration and app startup complete, verify the role state:
SELECT rolname, rolcreaterole, rolsuper, rolbypassrls
FROM pg_roles
WHERE rolname IN ('med_tracker_owner', 'med_tracker_app', '<app_login_role>')
ORDER BY rolname;
SELECT m.roleid::regrole AS granted_role,
m.member::regrole AS member_role,
m.admin_option
FROM pg_auth_members m
WHERE m.roleid IN ('med_tracker_owner'::regrole, 'med_tracker_app'::regrole)
ORDER BY 1::text, 2::text;
Expected result:
med_tracker_ownerisNOLOGIN, not superuser, and notBYPASSRLS.med_tracker_appisNOLOGIN, not superuser, and notBYPASSRLS.- The app login is a member of both runtime roles.
admin_optionisfalsefor the app login memberships.
Verify household backfill and migration completion:
SET row_security = off;
SELECT COUNT(*) AS households
FROM households;
SELECT 'people' AS table_name, COUNT(*) FILTER (WHERE household_id IS NULL) AS null_household_id FROM people
UNION ALL SELECT 'locations', COUNT(*) FILTER (WHERE household_id IS NULL) FROM locations
UNION ALL SELECT 'location_memberships', COUNT(*) FILTER (WHERE household_id IS NULL) FROM location_memberships
UNION ALL SELECT 'medications', COUNT(*) FILTER (WHERE household_id IS NULL) FROM medications
UNION ALL SELECT 'dosages', COUNT(*) FILTER (WHERE household_id IS NULL) FROM dosages
UNION ALL SELECT 'schedules', COUNT(*) FILTER (WHERE household_id IS NULL) FROM schedules
UNION ALL SELECT 'person_medications', COUNT(*) FILTER (WHERE household_id IS NULL) FROM person_medications
UNION ALL SELECT 'medication_takes', COUNT(*) FILTER (WHERE household_id IS NULL) FROM medication_takes
UNION ALL SELECT 'notification_preferences', COUNT(*) FILTER (WHERE household_id IS NULL) FROM notification_preferences
ORDER BY table_name;
Every null_household_id count should be 0.
Audit evidence repair¶
Affected upgrades automatically append previously omitted versions and
security_audit_events rows in a separate legacy-repair epoch. Existing
ledger entries and source evidence are not rewritten. The repair creates a
pre-legacy-repair checkpoint before the new epoch and a legacy-repair
checkpoint after it.
Evidence that was omitted before this repair was not protected before repair. Integrity is established from the new repair checkpoint onward.
Before accepting the repaired audit evidence, operators must sign and export both checkpoints, drain pending delivery records, and rerun database, WORM, and combined verification. Record the deployment version, migration time, checkpoint, key, manifest, and object identifiers, verification output, and operator in an external change or incident record.
Verify the account access bootstrap:
SET row_security = off;
SELECT COUNT(*) AS account_people_without_membership
FROM people
LEFT JOIN household_memberships
ON household_memberships.household_id = people.household_id
AND household_memberships.account_id = people.account_id
AND household_memberships.status = 'active'
WHERE people.household_id IS NOT NULL
AND people.account_id IS NOT NULL
AND household_memberships.id IS NULL;
SELECT COUNT(*) AS active_memberships_without_self_grant
FROM household_memberships
LEFT JOIN person_access_grants
ON person_access_grants.household_membership_id = household_memberships.id
AND person_access_grants.person_id = household_memberships.person_id
AND person_access_grants.relationship_type = 'self'
AND person_access_grants.access_level = 'manage'
AND person_access_grants.revoked_at IS NULL
WHERE household_memberships.status = 'active'
AND household_memberships.person_id IS NOT NULL
AND person_access_grants.id IS NULL;
SELECT COUNT(*) AS ownerless_households
FROM households
WHERE NOT EXISTS (
SELECT 1
FROM household_memberships
WHERE household_memberships.household_id = households.id
AND household_memberships.role = 'owner'
AND household_memberships.status = 'active'
);
All three counts should be 0. If any count is non-zero, deploy a release that
contains the account access backfill migration and rerun migrations through the
existing shared login with DATABASE_ROLE unset.
If an earlier 0.5 attempt failed¶
If a 0.5 migration already enabled forced row-level security and then failed,
upgrade to a release containing this runbook before retrying. The strict
household migration temporarily removes FORCE ROW LEVEL SECURITY while it
backfills legacy rows, then restores forced RLS before it completes.
This avoids the emergency-only workaround of granting BYPASSRLS to the
migration role.
If users can sign in but the app redirects to login, reports that their account is deactivated, or shows "You are not authorized to perform this action" for every account after a failed cutover, check the account access bootstrap queries above before changing account statuses. That symptom usually means the deployed app cannot see an active household membership for the signing-in account.