Access control¶
Roles and individual users are granted read, write and reverse-lookup access per namespace. Only
admins (Authorization__AdminRoles) can see any of this, and only while Authorization__IsEnabled
is true - with authorization off, every request already has full access to everything.
Grants are made on the Namespaces page: select a namespace in the hierarchy and use Configure access. The Access Control page is a read-only overview - the admin roles, and who effectively holds which permission on each namespace.
A grant names one scope (a single namespace, or All namespaces - which also covers namespaces created later) and one grantee:
- a role, matched against the caller's
Authorization__RoleClaimTypeclaims exactly as the identity provider emits them; or - an email address, matched against the caller's
emailclaim, granting that one person access regardless of which roles they hold. Only a verified address matches: the caller's token must also carryemail_verified: true, since an unconfirmed address is a self-asserted string and a realm allowing self-registration or an unverified address change would otherwise let anyone claim someone else's grants. An identity provider that issues noemail_verifiedclaim at all therefore never matches an email grant - use role grants there.
Grants are additive and there are no deny rules - a caller gets the union of everything granted to any of their roles and to their email address, and the absence of a grant is the denial. Reverse-lookup (revealing a pseudonym's original value) is a separate, more tightly-scoped permission than read: read access alone never reveals original values. Grants are not inherited down a namespace hierarchy - like the generation settings a namespace carries, access is set per namespace.
Toggling a permission saves it immediately. It takes effect at once on the replica handling the
request; other replicas pick it up within Authorization__GrantCacheDuration (30s by default).
Grants scoped to All namespaces can no longer be created or changed through the UI, since the per-namespace dialog only edits grants belonging to the namespace it was opened for. Existing ones keep working, and are listed for context wherever they apply.
Authenticating API calls¶
With Authorization__IsEnabled set, the whole API - gRPC, the JSON-transcoded REST routes
(/v1/namespaces/...) and the FHIR operation (/v1/fhir/$create-pseudonym) - accepts bearer
tokens only. A call arriving without one is refused by the request pipeline before it reaches a
service method: gRPC clients get UNAUTHENTICATED, HTTP clients a 401 carrying
WWW-Authenticate: Bearer. A machine client obtains its token from the authority the usual way - an
OAuth2 client_credentials grant against a confidential client of its own, separate from
Authorization__ClientId, which is the admin UI's - and the token's aud has to match
Authorization__Audience. Namespace access is then resolved from the token's roles and email
claim against the grants described above, so a valid token still only reaches the namespaces it has
been granted.
A client that can't get a confidential client of its own provisioned in the realm can instead
present a vfps-issued access token - Authorization: Bearer vfps_pat_... or vfps_sat_... -
once Authorization__AccessTokens__IsEnabled is on. The two credentials share the header and are
told apart by that prefix, so each request reaches exactly one handler: a vfps token is never sent
to the identity provider, and keeps working while the authority is unreachable. See
Access Tokens.
That pipeline-level 401 carries no body, so it is the one FHIR error the service answers without
an OperationOutcome. Every refusal made after authentication - including a 403 for a namespace
the token holds no grant on - is an OperationOutcome.
The gRPC health-checking service and the /healthz, /livez and /readyz endpoints stay
anonymous, so probes and load balancers need no token. gRPC server reflection does not: it
describes the API, so listing the services takes a token like calling them does - grpcurl sends
its -H header with both. An admin's browser session, on the other
hand, does not authenticate an API call: the login cookie is for the admin UI, and the API reads
bearer tokens only. The bundled Swagger UI at /swagger therefore gains an Authorize button
when authorization is enabled - paste an access token there, without the Bearer prefix, before
using Try it out.
Access tokens¶
Not every client can obtain a token from the identity provider. A data-integration job may belong
to a team that can't get a confidential client provisioned in the realm, or run in a tool with no
OAuth2 support at all. For those, vfps can issue its own bearer credentials - set
Authorization__AccessTokens__IsEnabled to true (it is off by default) and two things appear in
the admin UI.
Personal access tokens are self-service: any signed-in user creates them on the Access tokens page, and a token acts as its creator. It captures the identity they hold at that moment - their subject, their verified email address and their roles - and every request made with it resolves against the same grants their browser session does. Nobody can grant themselves anything by creating one, which is why no admin role is needed to do it.
Service accounts are the other half, and are admin-only: they are principals of their own, for a client that belongs to no person. Create one on the Service accounts page, then grant it access exactly like a role or a user - Configure access on the Namespaces page, with Service account as the grantee type - and issue it a token. Rotating that token is issuing a second one, rolling it out, and revoking the first; the grants belong to the account, so they never move.
Both kinds are presented the same way as an identity provider's token, so no client needs changing:
# the token as the admin UI showed it, once
export VFPS_TOKEN="vfps_sat_..."
curl -H "Authorization: Bearer $VFPS_TOKEN" \
http://localhost:8080/v1/namespaces/test/pseudonyms \
-H "Content-Type: application/json" \
-d '{"originalValue": "to be pseudonymized"}'
grpcurl \
-plaintext \
-H "authorization: Bearer $VFPS_TOKEN" \
-d '{"namespace": "test", "originalValue": "to be pseudonymized"}' \
127.0.0.1:8081 \
vfps.api.v1.PseudonymService/Create
A token is shown once, when it is created: only its SHA-256 hash is stored, so one that has
been lost can be replaced but never recovered. The vfps_pat_/vfps_sat_ prefix is fixed so that
a leaked token is findable by a secret scanner and recognizable in a log.
Things worth knowing before switching this on:
- A token always expires.
Authorization__AccessTokens__DefaultLifetimepre-fills the form andAuthorization__AccessTokens__MaximumLifetimecaps it. Expiry is the bound that holds without anyone noticing anything is wrong - a token whose owner has left, or that was pasted into a CI log, stops working on its own. - Revoking is immediate on the replica that does it, and takes effect on the others within
Authorization__GrantCacheDuration, the same window that bounds a withdrawn grant. Admins see every token in the deployment, and can revoke anyone's, on the Access tokens page. - A service account is never an admin, whatever it is granted: it cannot create or delete a namespace, manage access grants, or open the Hangfire dashboard. A personal access token, by contrast, is as powerful as its owner - an admin's token can do admin-only API operations, though no token can sign in to the admin UI, which is cookie-authenticated.
- No token can create another token. Otherwise a leaked one could renew itself indefinitely and its expiry would stop meaning anything.
- A personal token's identity is a snapshot. Roles withdrawn at the identity provider are not reflected in a token already issued - revoke it (or let it expire) when someone's roles change. Grants, on the other hand, are read live, so editing or removing one takes effect at once.
- Last used is recorded per token, written back on a timer
(
Authorization__AccessTokens__UsageFlushInterval) rather than per request, so it lags by up to that interval. It is there to tell a token still in use from one nobody has touched in months.
Deleting a service account deletes its tokens and its access grants, so that an account later re-created under the same name doesn't silently inherit them.
Noticing a token before it expires¶
A token close to its expiry is marked Expires soon on the Access tokens and Service accounts
pages, Authorization__AccessTokens__ExpiryWarningPeriod (14 days by default) ahead. That's enough
for a personal token, since its owner is the one using it. A service-account token usually sits in
a pipeline that nobody watches, so it is also exported as a metric to alert on:
vfps_service_account_token_expiration_timestamp_seconds, a gauge holding each unrevoked service-account token's expiry as a Unix timestamp, labelledservice_account,token_idandtoken_name. An expired token stays reported until it is revoked or deleted, so an alert keeps firing past the expiry instead of resolving the moment the token stops working. Revoking the old token once its replacement is rolled out is what clears it. Every replica reports the same values, so aggregate withmax().
# days left on each service-account token
(max by (service_account, token_id, token_name) (vfps_service_account_token_expiration_timestamp_seconds) - time()) / 86400
The Helm chart ships this as two alerts, VfpsServiceAccountTokenExpiringSoon and
VfpsServiceAccountTokenExpired, behind prometheusRule.enabled.
Migrating from Authorization__NamespaceRules¶
The static Authorization__NamespaceRules section is gone and is not imported automatically -
an upgrade leaves every non-admin without access until an admin re-creates the equivalent grants
with Configure access on the Namespaces page. Each old rule maps directly: the rule's Namespace
becomes the namespace whose access is being configured, and each role in ReadRoles/WriteRoles/
ReverseLookupRoles becomes a role grant with the matching permission switched on. A rule scoped
to "*" has no UI equivalent any more and has to be re-created per namespace. Remove the
Authorization__NamespaceRules__* variables from your deployment; they are now ignored.