All documentation Download (Markdown) Operator

SSO · sso.pdhc.se

Operator manual


Admin Manual

Operations guide for SU (Super User) administrators and group administrators.

Roles Overview

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.

SU Admin Operations

Accessing the Admin Panel

  1. Log in at /login with an SU admin account
  2. Navigate to the dashboard — the SU Admin section is visible only to SU admins
  3. Or go directly to /su-admin

User Management

The Person Registry

Reorganised 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

Promote to SU Admin

Elevates a professional to system-wide admin. Requires your password for confirmation.

  1. Click Promote next to the target user
  2. Enter your password
  3. Confirm

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.

Delete User

Removes a user and their associated data. The system:

API: DELETE /api/admin/users/<user_guid>

Warning

You cannot delete yourself. User deletion is irreversible.

CSV Export

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.

CSV Import

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.

That 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)


Forcing a Password Reset (#43)

Use when a user has forgotten their password, a credential has leaked, or compliance requires rotation.

  1. Locate the user in the admin panel
  2. Click Reset password (or POST to the endpoint below)
  3. Optionally supply a temporary password; if omitted, the server generates one and returns it
  4. Communicate the temporary password to the user out-of-band (phone, in-person)

What happens next:

API: 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.

Flushing User Sessions (#44)

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.

  1. Locate the user in the admin panel
  2. Click Flush sessions

What happens:

API: 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."

Managing Phase Grants (#46 + #57)

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.


Affiliations & Guided Activation (S9, #411)

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):

Activate / deactivate:

Phases 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.


Role & Research-Project Registries (S2/#398, #400)

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, …):

Research projects — referenced by researcher affiliations:

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.


External Partners

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.


Organisation Management

Organisations are the single source of truth across all PDHC services.

View Organisations

Lists all registered organisations with GUID and creation date.

API: GET /api/admin/organisations

Create Organisation

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.

Care Hierarchy — vårdgivare / CareUnit (S8, #410/#187)

Organisations form a 2-level PDL care hierarchy on a single
self-referential table (parent_caregiver_guid):

An 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.

How the panel displays it (#784 C1)

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>.


Group Lifecycle

Group Proposals

Professionals can suggest new groups via the UI. Proposals appear in the admin panel.

Workflow:

  1. Professional submits group proposal (name + type)
  2. SU reviews proposal in admin panel
  3. Approve — creates the group immediately
  4. Reject — proposal archived

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.

Delete Group

Removes a group and all associated memberships and invites.

API: DELETE /api/admin/groups/<group_guid>

Assign Group Admin

Set any existing group member as an admin of that group.

API: POST /api/admin/assign-group-admin with {user_guid, group_guid}


Leader Requests

Professionals can request to become group admins. These requests appear in the SU admin panel.

Workflow:

  1. Professional requests admin role for a specific group
  2. SU reviews in admin panel
  3. Approve — sets is_admin=True on their membership
  4. Reject — request archived

API: GET /api/admin/leader-requests and POST /api/admin/leader-requests with {leader_request_guid, decision}


Access Request Workflow

New professionals request access through the public onboarding form. The workflow has three stages:

pending → endorsed → approved (creates account)
              ↘ rejected
  1. Pending — new request submitted. SU can endorse or reject.
  2. Endorsed — leader endorsement recorded. SU can approve or reject.
  3. Approved — system creates a pending professional with zero
    access
    :
    - User account (user_type = professional, status = pending)
    - Professional record (with role, name)
    - Organisation link
    - Nothing else. No phase grants, no affiliations, no active access.

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.


Service Registry (Oath Overview)

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


Group Admin Operations

Accessing the Group Admin Panel

  1. Log in with a professional account that has is_admin=True in at least one group
  2. Navigate to /group-admin

Pending Membership Requests

View 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:

  1. Select the group
  2. Set validity period (default 48 hours)
  3. Share the generated token/link with the invitee

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.


Audit Trail

All admin actions are logged to structured audit files in LOG_DIR:

Logs include: timestamp, action type, actor GUID, target GUID, IP address, and action-specific details.