Operator manual
Operations guide for SU (Super User) administrators and group administrators.
Two orthogonal things are both loosely called "role" — keep them apart:
Administrative roles (who can operate the panel):
| Role | Scope | Key Actions |
|---|---|---|
| SU Admin | System-wide | All admin operations, user management, org management, affiliations, registries, partners |
| Group Admin | Per-group | Membership approvals, invites for own groups |
Professional (clinical) roles are a separate concept introduced by the
access-model reform (S1–S9). They live in an SU-managed role registry
(/api/registry/roles, e.g. doctor, nurse, researcher, …) and are attached to
a person through an Affiliation (person + CareUnit + Role), not stored as a
free-text field. See Affiliations & Guided Activation and Role &
Research-Project Registries below. A person's actual data access is composed
downstream from three independent inputs: phase grants, affiliations/role,
and organisation scope — no single one implies another.
/login with an SU admin account/su-adminReorganised in #784 (B4/B5). It was ten columns wide, which meant horizontal
scrolling on any realistic screen and no way to read one person as one thing.
It is now six columns, with the identity fields packed into a single
Person cell — GUID, email, user type, name and role together, because they
describe one person and were never useful apart:
| column | holds |
|---|---|
| Person | GUID, email, user type, name, professional role |
| Organisation | the affiliated care unit, by name |
| Phases | granted phases |
| Groups | memberships and group-admin status |
| Status | SU, activation, forced-password-change |
| Actions | Promote SU / Reset pw / Flush / Del |
The organisation is shown by name, which it never was before #784. The
page resolved it with {% set org_name = o.name %} inside a nested {% for %}
loop, and Jinja2 scopes that assignment to the loop iteration — so the variable
was always empty on the way out and the cell always fell back to the first
eight characters of the GUID. Every administrator who ever read this table saw
a hex fragment and had no way to know a name was intended.
The route now passes an explicit org_names mapping, which also replaced an
O(users × organisations) scan with a lookup. An affiliation whose organisation
GUID resolves to nothing reads "(unknown org abcd1234…)" rather than a bare
fragment — a dangling reference is a thing to fix, so it says so.
Each action button carries a tooltip stating what it does and what it does
not do (#784 B6), because "Flush" and "Reset pw" are both irreversible from
the user's point of view and neither name says how far it reaches.
API: GET /api/admin/users
Elevates a professional to system-wide admin. Requires your password for confirmation.
API: POST /api/admin/promote-su with {user_guid, password}
Warning
Only professionals can be SU admins. This action cannot be undone via the UI — demote by directly updating the database.
Removes a user and their associated data. The system:
decided_by_guid references (preserving decision history)API: DELETE /api/admin/users/<user_guid>
Warning
You cannot delete yourself. User deletion is irreversible.
Downloads every person as CSV. Use the Export CSV button on the admin page.
Two routes, and the difference matters:
| route | authenticates with | use from |
|---|---|---|
GET /admin/export-users |
the session cookie | a browser (this is what the button uses) |
GET /api/admin/export-users |
Authorization: Bearer <token> |
a script or another service |
Before #784 only the API route existed, and the admin page linked straight to
it. require_auth accepts only a bearer header, so a browser following
that link sent its cookie, no token, and opened a new tab containing
{"error": "authentication_required"}. Export appeared broken for every human
who clicked it while working perfectly for every script.
It was an asymmetry rather than a mystery: import already had a
session-authenticated twin; export never got one. The API route was
deliberately not loosened to accept a cookie — a test pins that it still
refuses one, so "fixing" this by weakening the API would fail the suite. The
columns come from a single USER_CSV_COLUMNS contract shared by the exporter,
the importer and the template below, so the three cannot drift apart.
Upload a CSV to bulk-create people. The file input reads "CSV file of people
to add:", and a Show template panel on the page prints the exact header
row expected (GET /admin/users-csv-template), generated from the same
USER_CSV_COLUMNS contract as the export — so the template cannot describe a
format the importer does not accept.
changeme01force_change_on_next_login flag is set and /dashboard redirects to theThat last point was a real defect found while writing this section, not a
feature. The documentation has to state the shared password in order to be
usable, and that is only safe to write down if the account cannot keep it. The
gate enforcing it already existed; import simply never set the flag, so every
imported account kept a known shared password indefinitely. Fixed in #784, with
a test that walks an imported user through /dashboard and asserts the
redirect fires — asserting the column alone would have proved nothing about the
gate.
API: POST /api/admin/import-users (multipart form, file field)
Use when a user has forgotten their password, a credential has leaked, or compliance requires rotation.
What happens next:
force_change_on_next_login = True on the user/api/auth/me/service to return must_change_password: truesso.pdhc.se/change-password until they pick a new passwordPOST /api/auth/change-password, the flag clears automatically and they are unblocked on every serviceAPI: POST /api/admin/users/<user_guid>/reset-password with optional {temporary_password}
This does not kill existing tokens
The user's existing JWTs are not invalidated — they just hit a forced redirect on every call. If you also want to terminate every active session (e.g. after credential theft), call Flush Sessions immediately after.
Use when you need to invalidate every active JWT for a user in one operation — typically after a credential compromise, lost device, or role change.
What happens:
user.token_revocation_epoch = now()/me/service call, because its iat timestamp is older than the new epochAPI: POST /api/admin/users/<user_guid>/flush-sessions (empty body)
Why not use the token revocation list?
The per-JWT revocation list (RevokedToken / jti) works well when a single token is being revoked (e.g. the /api/auth/logout path), but it requires knowing every jti that is currently in circulation. flush-sessions is the right primitive when you simply want "all tokens issued before now are void."
After #57, direct UserPhase grants are the sole source of phase access. Groups are orthogonal organisational/category metadata — an approved membership in a planning-typed group does not grant the planning phase. Every phase assignment goes through an explicit SU decision.
This separation lets each downstream service compose its own access policy from independent inputs (phase, group membership, org scope) rather than inheriting one conflated "group = phase" rule.
View a user's direct grants:
GET /api/admin/users/<user_guid>/phases
Response includes direct_phases and effective_phases (now identical — groups no longer contribute).
Grant a phase:
POST /api/admin/users/<user_guid>/phases
Body: {"phase": "analysis"}
The phase appears in effective_phases on the user's next blob call; no re-login required. Idempotent.
Revoke a phase:
DELETE /api/admin/users/<user_guid>/phases/<phase>
Removes the UserPhase row. Since #57 this is sufficient on its own — there is no second "group-derived" source that could keep the phase alive. Group cleanup, if desired, is a separate SU action.
Access-request approval no longer auto-grants phases (#57)
Approving an access request creates the professional and their group membership(s) but does not create UserPhase rows — even if the request's requested_phases names planning/request/etc. The approval response lists requested_phases_pending_su_grant; SU must then call POST /api/admin/users/<guid>/phases for each phase the user should actually hold.
Migration from pre-#57 installations
A read-only report — python scripts/phases_migration_report.py — lists every user who previously had a phase implicitly via group membership but has no matching UserPhase row. SU should review the list and grant explicitly. Nothing is auto-backfilled.
This is the reform's core person-onboarding flow: the person requests
sign-on; the SU assigns. A professional is created pending (zero access)
and cannot be activated until their profile is complete. All completeness
checks are computed server-side — the panel renders the same missing list
the API enforces, so hiding a field in the UI cannot bypass a check.
An Affiliation binds a person to a CareUnit + Role (with research
projects when the role is researcher):
POST /api/admin/users/<guid>/affiliations{care_unit_guid, role_guid, research_project_guids?, is_admin?}.care_unit_guid is anrole_guid is in the role registry; aresearcher role requires ≥1 known research_project_guid and any other(person, unit, role) is unique (409 on duplicate).GET /api/admin/users/<guid>/affiliationsDELETE /api/admin/users/<guid>/affiliations/<affiliation_guid>GET /api/admin/users/<guid>/completeness.missing codes: no_active_affiliation, no_useful_phase_grantresearcher_affiliation_missing_projects.Activate / deactivate:
POST /api/admin/users/<guid>/activate — flips status to active. Returnsincomplete_profile with the missing list while the profile isPOST /api/admin/users/<guid>/deactivate — sets a professional back topending (access is revoked on the next blob build; pair with FlushPhases and affiliations are both required, and independent
A complete profile needs both ≥1 affiliation and a phase grant the
role can use. Assigning an affiliation does not grant a phase, and granting
a phase does not create an affiliation — do both, then activate.
Two SU-managed registries feed the affiliation form. Reads are open to any
authenticated professional; writes are SU-only.
Roles — the clinical role vocabulary (doctor, nurse, researcher, …):
GET /api/registry/roles · POST /api/registry/rolesPUT /api/registry/roles/<guid> · DELETE /api/registry/roles/<guid>Research projects — referenced by researcher affiliations:
GET /api/registry/research-projects · POST /api/registry/research-projectsPUT / DELETE /api/registry/research-projects/<guid>Panel gap
The SU panel currently consumes these registries (the affiliation form's
Role and Projects dropdowns) but has no management UI to create/rename/
retire a role or research project — do that via the API above until a
registry admin section is added.
Third-party callers licensed to talk to PDHC are managed in the External
Partners panel (this replaced the legacy KEYAUTH_SERVICE_* env registry).
The panel supports register / rotate-secret / suspend / reactivate / revoke,
with the cleartext secret shown once on register or rotate.
See the dedicated External Partners document for the
full model, endpoints (/api/admin/partners…), auth kinds, and scopes.
Organisations are the single source of truth across all PDHC services.
Lists all registered organisations with GUID and creation date.
API: GET /api/admin/organisations
Add a new organisation. Name must be unique.
API: POST /api/admin/organisations with {name} (optionally
{parent_caregiver_guid} — see Care Hierarchy below).
Note
Downstream services pull organisation lists from GET /api/public/organisations. New organisations are immediately available system-wide.
Organisations form a 2-level PDL care hierarchy on a single
self-referential table (parent_caregiver_guid):
parent_caregiver_guid = NULL → the row is a caregiver (vårdgivare;parent_caregiver_guid = <guid> → the row is a CareUnit under thatAn organisation also carries an is_external flag distinguishing internal PDHC
organisations from external partner orgs. Affiliations may only target an
internal organisation (is_external = false) — so an org must exist and be
internal before a professional can be affiliated to it.
POST / PUT /api/admin/organisations[/<guid>] with parent_caregiver_guid.validate_care_hierarchy);400 invalid_hierarchy.GET /api/registry/care-organisations (caregivers)GET /api/registry/care-units.The organisation table is grouped, not flat. Each vårdgivare heads a
section with its vårdenheter nested beneath it, and external partners sit in
their own section at the end. Every row is classified server-side into one of
three kinds:
| kind | condition |
|---|---|
care_organisation |
vårdgivare — no parent, not external |
care_unit |
vårdenhet — has a parent_caregiver_guid |
external |
is_external = true |
The section headings state what the level MEANS, not just what it is called:
a vårdenhet is the spärrgräns — the boundary a patient's block applies
across. That is a legal fact under PDL rather than a naming convention, and an
administrator moving a unit between caregivers is changing which records a
spärr hides. Leaving the hierarchy labelled without saying so invited exactly
that mistake.
A unit whose parent GUID resolves to nothing is surfaced, not hidden.
Grouping strictly by parent would have made such a row vanish from the table
altogether — present in the database, invisible in the UI, and therefore
unfixable. It is shown so it can be corrected.
The Create-Organisation form now sends parent_caregiver_guid alongside
name, and each row carries a re-parent control
(POST /admin/set-org-parent). Re-parenting an external partner is refused:
"External partners are not part of the care hierarchy."
Still API-only
is_external cannot be set from the panel. An external organisation is
created through the External Partner subsystem, or by
PUT /api/admin/organisations/<guid>.
Professionals can suggest new groups via the UI. Proposals appear in the admin panel.
Workflow:
API: GET /api/admin/group-proposals and POST /api/admin/group-proposals with {proposal_guid, decision}
category is a free-form label, not a phase (#60, #57)
A group's category is a free-text organisational label (since #60 —
formerly a 4-value group_type enum). It has no effect on access.
Do not reuse phase names (planning/request/provider/analysis)
as categories — group membership does not confer phases (#57); the panel
warns against this. Phases are granted per-user in the Person Registry.
Removes a group and all associated memberships and invites.
API: DELETE /api/admin/groups/<group_guid>
Set any existing group member as an admin of that group.
API: POST /api/admin/assign-group-admin with {user_guid, group_guid}
Professionals can request to become group admins. These requests appear in the SU admin panel.
Workflow:
is_admin=True on their membershipAPI: GET /api/admin/leader-requests and POST /api/admin/leader-requests with {leader_request_guid, decision}
New professionals request access through the public onboarding form. The workflow has three stages:
pending → endorsed → approved (creates account)
↘ rejected
user_type = professional, status = pending)API: GET /api/admin/access-requests and POST /api/admin/access-requests with {access_request_guid, decision}
decision values: endorsed, approved, rejected
Approval does not grant access (#57, S9 #411)
Approving an access request does not auto-grant the requested phases,
and does not activate the user. The request's organisation_guid and
professional_role survive only as a non-authoritative hint the guided
UI prefills from (requested_phases_pending_su_grant / hint in the
response). After approval the SU must, in the Activation & Affiliations
panel: (1) assign ≥1 affiliation (CareUnit + Role), (2) grant the phase(s)
the role should hold, then (3) activate the user. Activation is blocked
server-side until the profile is complete — see the next section.
The Service Registry PANE was removed from the UI in #784 (D2), together
with the Documentation pane (D1). The data and the API below are unchanged —
only the admin-page panels that displayed them are gone, because neither
showed an administrator anything they could act on. The landing page's
Platform Services table and Connected Services cards went in the same pass
(A3) along with the health prober that fed them.
The oath_overview.csv tracks all services under SSO management.
View: GET /api/admin/oath-overview
Update: PUT /api/admin/oath-overview — send the full CSV as a JSON array of row objects.
Fields: service_name, service_url, api_health_url, capability_statement_url, endpoints_url, privilege_level, notes
is_admin=True in at least one group/group-adminView all pending membership requests for groups you administer.
Each request shows: applicant email, group name, submission date.
Actions:
API: GET /api/groups/admin/pending and POST /api/groups/admin/decide with {membership_guid, decision}
Generate time-limited invite tokens for a group:
The invitee redeems the token at /join or via POST /api/groups/join-by-invite.
API: POST /api/groups/admin/invite with {group_guid, hours_valid}
Note
Invite tokens create pending memberships. The group admin must still approve the membership after the invite is redeemed.
All admin actions are logged to structured audit files in LOG_DIR:
token_revocation_epochLogs include: timestamp, action type, actor GUID, target GUID, IP address, and action-specific details.