Skip to content

Access control

Services and service info can be read by anyone, unless the deployment requires login for reads. Writing requires an access token of an OpenID Connect provider that the deployment trusts (see Authentication). The identity provider, its claims and the groups mapped to roles are deployment configuration, without defaults (see Deployment configuration). Access control is implemented with FOCA; see the security section of the configuration.

Authentication

Read operations (GET, except /services/{serviceId}/permissions and /me, see the table of endpoints) are public by default (see Requiring login for reads). All other operations require an access token of a trusted issuer, sent as a bearer token:

curl -X POST "http://localhost:8080/ga4gh/registry/v1/services" \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d @service.json

Public reads of the registry's data (GET /services, GET /services/{serviceId}, GET /services/types, GET /services/{serviceId}/verification and GET /verifications; not GET /service-info, which ignores tokens) accept an optional token: without one, they are anonymous; with one, they apply the user's visibility rules (e.g., they show the user's pending types and services). Invalid or expired tokens are rejected as below; if the token can't be validated for other reasons (e.g., the identity provider is unavailable), the request is treated as anonymous (see the API).

Operations that require login reject requests without a valid token with 401 Unauthorized and a Bearer challenge (WWW-Authenticate: Bearer, RFC 6750; with error="invalid_token" for invalid tokens); requests with a valid token, but without the required role, with 403 Forbidden.

Tokens are obtained by logging in with the identity provider through an app registered as its client (OpenID Connect), e.g., the registry's front end, or a client of your own for scripts. The registry accepts tokens only:

  • that are access tokens as per RFC 9068 (typ header at+jwt); other tokens, such as ID tokens, are rejected;
  • of the issuers in security.auth.issuers (none by default: each deployment sets its identity provider; see Several token issuers);
  • for the client IDs in security.auth.audience (the aud claim), which each deployment sets to the apps that call it (see Configuration); the Docker Compose setup accepts tokens for any audience.

The registry reads the user's group memberships from the group claim (security.access_control.claim_groups, needed for the admin role and roles of groups) and their name (name). Claims in security.auth.userinfo_claims are read from the identity provider's user info endpoint with the token, for identity providers that don't put them in access tokens, so the token must be allowed to release them (e.g., request the corresponding scopes when logging in). Tokens are validated by FOCA.

Requiring login for reads

Deployments can require login for all reads, except GET /service-info, with a config overlay, using FOCA's spec patches and named entries. The app ships a patch for this, cloud_registry/api/patches/login-for-reads.yaml, which sets the security requirements of the public read operations to a bearer token:

# Patch of the API specification (`api.specs.registry`) that requires login
# for all reads, except `GET /service-info`: sets the security requirements of
# the public read operations to a bearer token (`bearerAuth`, the security
# scheme of the GA4GH specifications). See "Requiring login for reads" in
# `docs/access-control.md`
paths:
  /services:
    get:
      security:
        - bearerAuth: []
  /services/types:
    get:
      security:
        - bearerAuth: []
  /services/{serviceId}:
    get:
      security:
        - bearerAuth: []
  /services/{serviceId}/verification:
    get:
      security:
        - bearerAuth: []
  /verifications:
    get:
      security:
        - bearerAuth: []
  /services/{serviceId}/sync:
    get:
      security:
        - bearerAuth: []
  /syncs:
    get:
      security:
        - bearerAuth: []

Apply it to the registry's API specification (named registry in the base config) in an overlay (the path is relative to the directory of the base config, where the patch is installed):

overlay.yaml
api:
    specs:
        registry:
            patches:
                - api/patches/login-for-reads.yaml

With the Helm chart, put the same in the config value. Requests to the patched reads without a valid token are then rejected with 401 Unauthorized; any authenticated user may read, by the default policies (in security.access_control.policies, which are only evaluated for reads that require login): list-services, get-service (also for /services/types, which matches /services/:id), get-service-verification, list-verifications, get-service-sync and list-syncs. GET /service-info, the API specification (/openapi.json) and the Swagger UI (/ui/) stay public.

If an overlay of the deployment gives the policies as a list, which replaces the rules of the base config, add these rules to the list (FOCA refuses named rules after a list, see Configuration).

To allow reads for some users only, replace these rules by name in the overlay, e.g., with rules for a role that a group is mapped to, in addition to the deployment's admin groups (see Admins for group_mapping; null removes a rule):

readers.yaml
security:
    access_control:
        group_mapping:
            registry-readers: reader
        policies:
            list-services: [p, reader, /ga4gh/registry/v1/services, GET]
            get-service: [p, reader, "/ga4gh/registry/v1/services/:id", GET]
            get-service-verification: null
            list-verifications: null
            get-service-sync: null
            list-syncs: null

Readers may then list and get services and get the service types, but not read verifications or the state of syncs; admins may read everything.

Owners and maintainers of services can still update them, but only read them with a rule that allows them to.

Registration stays open to any authenticated user (register-services), and services are unique by URL and API type: registering a service again, or changing one to the URL and API type of another one, returns 409 Conflict. It names the registered service (serviceId, also in detail) only to users who may read it, by the same decision as for GET /services/{serviceId} (here: readers and admins); other users get a 409 without it. They can still find out whether a service with a given URL and API type is registered (409 rather than 201), which can't be avoided while duplicates are rejected. To prevent that, restrict registration, too, by adding a rule of the same name to the policies of readers.yaml above, e.g., for the readers' role, or null for admins only:

            register-services: [p, reader, /ga4gh/registry/v1/services, POST]
            add-service-types: [p, reader, /ga4gh/registry/v1/services/types, POST]

add-service-types restricts POST /services/types the same way, which otherwise stays open to any authenticated user. Users who may register services still add types by registering services with new ones: to let only admins add types, set custom.access_control.service_types: controlled instead (see Service types).

This only stops new registrations: owners and maintainers can still find out the same by changing one of their services to the URL and API type of another one. Owners grant these roles to any user or group (see Roles on services), and users who registered services while registration was open keep theirs. So admins should review the roles on services when restricting reads (GET /services/<id>/permissions), and deployments that don't want owners to grant roles can restrict owners-change-permissions and owners-change-group-permissions by name in the same overlay, too. Owners also grant roles by invitation, so remove the rules of invitations as well (accept-invitations can stay: without owners' invitations, only those of admins remain to be accepted). To stop owners from granting roles at all:

            owners-change-permissions: null
            owners-change-group-permissions: null
            owners-manage-invitations: null
            owners-revoke-invitations: null

The app warns on startup if configured rules let owners create invitations while no rule lets them grant roles to users. More on the two rules of roles:

  • owners-change-permissions (/ga4gh/registry/v1/services/:id/permissions/users/*) only covers the roles of users, and owners-change-group-permissions (.../permissions/groups/*) only those of groups, so that each can be changed on its own: to let owners grant roles to users but not to groups, set owners-change-group-permissions: null, and the other way around.
  • Rules that cover both, e.g., .../permissions/* (the path of owners-change-permissions in 1.0.0rc1), also let owners change roles of groups. If the rule of groups is changed or removed, the app warns on startup of configured rules that still let owners do so.
  • .../permissions/users/:user doesn't match user identifiers with slashes: with several token issuers, those of issuers whose namespace contains slashes (by default, the issuer URL). Keep the * to cover all users, or add a rule per such namespace (e.g., .../permissions/users/https:/* for issuer URLs).

With great power comes great responsibility

Patches and rules in overlays can weaken or remove authentication and access control as easily as they add them. Review them like code, keep them as small as possible, and check the startup logs, which list the applied patches and warn of operations that patches make public (see FOCA's patching specifications).

Roles on services

  • Any authenticated user can register a service (POST /services) and becomes its owner; users other than admins only up to a limited number of services (50 by default; see Services per user).
  • Owners can update (PUT) and delete (DELETE) their services, and grant other users the roles owner or maintainer on them, by their user identifier (sub claim; userId of GET /me, with several token issuers namespaced by issuer, as <namespace>|<sub>: encode it in URLs, slashes may also be kept):
curl -X PUT "http://localhost:8080/ga4gh/registry/v1/services/<id>/permissions/users/<user-id>" \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{"role": "maintainer"}'

Users who don't know their identifier can be invited by link instead. GET /services/<id>/permissions lists the roles granted on a service (with the users' names, once they have used the registry since being granted the role; names are deleted with the role), and DELETE /services/<id>/permissions/users/<user-id> revokes them. The last owner of a service can't be removed or downgraded (409 Conflict); delete the service instead. The role maintainer is only granted on services with an owner (400 Bad Request otherwise, e.g., on services stored before owners were introduced: admins grant owner first). User identifiers and groups with the segments . or .. (e.g., .. or a/../b; backslashes count as slashes, as in browsers) are rejected (400 Bad Request), as clients resolve them in URLs (encodeURIComponent leaves dots unencoded). - Maintainers can update, but not delete a service. - Admins can do everything.

Roles are granted, revoked and listed with FOCA's resource ownership helpers, for the roles in security.access_control.resource_roles (owner and maintainer; don't change them, as the API and the policies are written for them). Groups must not be mapped to these roles (security.access_control.group_mapping), as all members of such a group would have the role on all services; FOCA refuses to start then. FOCA's service clients (security.auth.service_clients, security.auth.on_behalf_of_header) are not supported: the app refuses to start with them, as FOCA would make the clients (or the users they name) owners, which the registry's endpoints neither list nor manage.

Roles of groups

Owners can also grant these roles to groups they are members of, by the group (a value of the group claim, e.g., a group of the team that runs the service), URL-encoded in the path; all members of the group then have the role, so team changes are handled centrally in the identity provider instead of per service:

curl -X PUT "http://localhost:8080/ga4gh/registry/v1/services/<id>/permissions/groups/urn%3Aexample%3Agroup%3Aregistry-team%23login.example.org" \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{"role": "owner"}'

Granting roles to groups the owner is not a member of is refused (403 Forbidden), as owners could otherwise hand a service to any group; admins can grant roles to any group. Any owner can revoke the roles of a group (DELETE /services/<id>/permissions/groups/<group>). GET /services/<id>/permissions lists the roles of groups together with those of users, as {"group": <group>, "role": <role>} (users: userId, name, role). Groups count as owners: the last user owner can be removed while a group is owner, and the last owner group can't be removed or downgraded (409 Conflict). A service whose only owner is a group then depends on the group's membership in the identity provider: if the group is emptied or deleted, only admins can manage the service. Roles of groups apply because the base config sets security.access_control.keep_group_subjects (all values of the group claim are used as subjects, besides the admin group mapping); don't unset it in overlays (the app warns on startup if it is unset, and roles can't be granted to groups then: 400 Bad Request).

Warning

All members of an owner group are owners: they can list the identifiers and names of all users with roles on the service (GET /services/<id>/permissions), and remove the other owners, users and groups. Don't grant roles to broad groups, e.g., to all members of a virtual organization (VO) or of a large community, but to groups of the team that runs the service.

Entitlement-style groups

Groups that are URNs, such as the values of an entitlement claim, contain : and #, which must be percent-encoded in the path (: as %3A, # as %23), as in the example above. With curl, pass the encoded form, e.g., urn%3Aexample%3Agroup%3Ateam%23issuer.example.org for urn:example:group:team#issuer.example.org. The configuration needed to read such claims (issuer, audience, claim_groups, userinfo_claims, the login scope and access tokens of type at+jwt) is in Configuration.

Group membership changes

Group memberships are those of the token: if the group claim is in the access token, users removed from a group keep its roles, also the role admin, until the token expires. If it is read from the user info (security.auth.userinfo_claims), FOCA caches the user info per token for up to security.auth.cache_ttl seconds (default: 300, but not beyond the token's expiry; per worker process). So users removed from a group keep its roles for up to 5 minutes, and users added to a group get them only after that time (or with a new token). To shorten the delay, lower cache_ttl (which also sets how long the issuers' discovery documents and keys are cached), or set security.auth.validate_userinfo: true, which fetches the user info for every request with a token (and so also rejects revoked tokens), at the cost of a request to the identity provider each time (see FOCA's authentication).

Invitations

Owners (and admins) can also invite users by link, without knowing their user identifier: they create an invitation to a service for a role, send the link through any channel, and the invited user logs in and accepts it, which grants the role to their own identifier (replacing another role of theirs on the service, as PUT /services/<id>/permissions/users/<user-id> would). No email addresses are involved.

# owner: create an invitation; the response has its `token` (only here)
curl -X POST "http://localhost:8080/ga4gh/registry/v1/services/<id>/invitations" \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{"role": "maintainer", "label": "Bob (variant calling team)"}'
# invited user: accept it
curl -X POST "http://localhost:8080/ga4gh/registry/v1/invitations/accept" \
  -H "Authorization: Bearer <token of the invited user>" \
  -H "Content-Type: application/json" -d '{"token": "<invitation token>"}'
  • Links: clients (e.g., a web portal) build the link, and should carry the token in the URL fragment (e.g., https://portal.example.org/invitations#<token>), which browsers don't send to servers; the token goes in the body of POST /invitations/accept, not in a URL, so that it doesn't end up in access logs.
  • Single use and expiry: each invitation can be accepted once (409 Conflict, code invitation-accepted, also for the same user), also by concurrent requests, and expires after custom.access_control.invitations.expiry_days days (default: 7), also once accepted. Unknown, revoked and expired invitations, and those of deleted services, are not found (404). If the role can't be granted, e.g., because the user is the last owner of the service and the invitation is for maintainer (409, last-owner), the invitation stays open.
  • Labels: an optional label says what an invitation is for (e.g., who it was sent to), so that owners don't send the wrong link: plain text of at most 100 characters on one line, not blank, without control or direction-changing characters (400 Bad Request otherwise; whitespace around it is removed). It is only returned to those who can list the invitations of the service, not to the user who accepts it. Labels may name people, so don't put more in them than needed. A label is stored as long as its invitation (see Removal), and removed earlier if the invitation was accepted and the user's roles on the service are revoked; database backups keep it as long as the deployment keeps them. The registry doesn't log labels at INFO, nor quote rejected ones in the invalid fields (errors) of the response. The response's detail, however, is the validator's reason, which quotes a rejected label (exceptions.validation_reasons, see Errors), and FOCA logs that reason at DEBUG, so clients shouldn't show or store detail. Clients must show labels as text, not as markup. Whether clients require a label is up to them; the API doesn't.
  • Creators: invitations only grant their role while their creator is an owner of the service: as a user, or through the group that made them owner when they created it, as long as that group is owner (and, if they accept the invitation themselves, they are still a member of it). Otherwise, the invitations are deleted instead of accepted (404), so that removed owners can't regain the role with invitations they created before. The group stays owner when a member leaves it, though, so the invitations that member created through it stay valid (only the member can't accept them themselves): when a member leaves an owner group, the other owners should revoke the member's open invitations (listed with their identifier in createdBy). Invitations of admins don't depend on their role, as the registry can't check admin groups without the admin's token: when removing an admin, delete the open invitations they created on services where you don't revoke their roles (no endpoint lists them across services: query the collection invitations by createdBy). Revoking a user's roles deletes the invitations they created that nobody accepted (see Removal).
  • Identifiers: users whose roles the permissions API couldn't revoke (identifiers that start with a slash, or contain the segments . or ..; see Roles on services) can't accept invitations (400 Bad Request).
  • Listing and revoking: GET /services/<id>/invitations lists the invitations that weren't accepted, never with a token, with their status: open, or expired if they weren't accepted in time, so that owners see who needs a nudge. With ?includeAccepted=true, it also lists those that were accepted (status accepted) and haven't expired, with who accepted them (accepted: user identifier, and the name and verified email address if the identity provider releases them, e.g., with name, email and email_verified in security.auth.userinfo_claims; removed when the user's roles on the service are revoked). DELETE /services/<id>/invitations/<invitation-id> revokes an open one or removes an expired one. Accepted invitations can't be revoked (409, invitation-accepted): revoke the role instead. Only the user identifier reliably identifies who accepted: users may be able to choose their name, and email addresses are only shown if the identity provider marks them as verified (email_verified: true). Maintainers and other users can't create, list or revoke invitations (403 Forbidden). Deployments that stop owners from granting roles also remove the rules of owners' invitations (see Requiring login for reads).
  • Removal: MongoDB removes accepted invitations when they expire, and others custom.access_control.invitations.keep_expired_days days after that (default: 30, at most 365; 0 removes them when they expire), in both cases within about a minute. Owners can remove expired ones earlier. Until then, invitations that weren't accepted keep their label, and who created them and when (createdBy, createdAs, createdAt), and current owners see the creators. Revoking the roles of a user who has one on a service (DELETE /services/{serviceId}/permissions/users/{userId}; otherwise 404, and nothing is deleted) also deletes the invitations to that service that the user created and nobody accepted, open or expired, also those created as an admin or through a group, so that their links stop working and createdBy, createdAs and createdAt are gone. Accepted ones keep these fields until they expire, as they hold an acceptance; the name and email address of who accepted, and the label, are removed when that user's roles are revoked. Invitations stay listed if their creator only lost the role that allowed them (e.g., was downgraded to maintainer, or the group's role was revoked): they show open until they expire, although accepting them then returns 404. Those created as an admin, or through a group that is still owner, stay valid, so revoke them if they are no longer wanted (see Creators).
  • Limit: a service has at most custom.access_control.invitations.max_per_service invitations that haven't expired, open or accepted (default: 20; null for no limit), so that owners can't grow the collection without bound (e.g., by accepting their own invitations). Accepted invitations count until they expire, also if lists leave them out, and expired ones don't count. Further ones are refused (409, invitations-limit) until open ones are revoked or invitations expire. Concurrent requests can exceed the limit by their number.
  • Storage: only the SHA-256 hash of the token is stored, so that those who read the database can't accept invitations. Invitations are deleted with their service, and move with it when an admin renames it.

Warning

A forwarded link grants the role to whoever accepts it first. Expiry, single use, and owners seeing who accepted (and being able to revoke the role) limit this, as with invitations in other services.

The options, e.g., in a config overlay (with the Helm chart, in config):

custom:
    access_control:
        invitations:
            expiry_days: 2
            keep_expired_days: 7
            max_per_service: 5

Admins

Members of the groups that the deployment maps to the role admin (security.access_control.group_mapping) are admins: they can do everything, including registering services with chosen identifiers (PUT of a new service), renaming services, verifying services and listing the registry itself. E.g., with the group claim groups:

security:
    access_control:
        claim_groups: groups
        group_mapping:
            registry-admins: admin

On startup, the app logs the groups mapped to admin. Only groups can make users admins: the role admin is opt-in (see Admin mode), so rules can't grant it (see Operations).

Admin mode

Admins act as admins only in admin mode: requests that ask for the role admin in the header X-Request-Roles. Other requests of admins have the permissions of other users (plus their roles on services and those of their other groups), so that being admin is a deliberate action, e.g., a switch in a UI. This is FOCA's opt-in for roles (security.access_control.opt_in_roles: [admin] in the base config; see FOCA's docs). Scripts and other API clients send the header with each request that needs admin rights:

curl -X DELETE "http://localhost:8080/ga4gh/registry/v1/services/<id>" \
  -H "Authorization: Bearer <admin token>" -H "X-Request-Roles: admin"

The header grants nothing to users who aren't admins. GET /me tells clients whether the user may switch to admin mode (adminAvailable) and whether the request is in admin mode (admin). Responses vary by the header (Vary: X-Request-Roles), so that caches keep them apart. Browser apps on other origins can send the header if CORS allows it (security.cors.allow_headers; FOCA's default allows all headers); the app warns on startup otherwise.

To let admins act as admins on every request, as before 0.6.0, remove the opt-in in the deployment's config (security.access_control.opt_in_roles: []). Deployments whose admins work in a web UI (e.g., cloud-registry-ui) keep the opt-in only once their UI sends the header (an admin mode switch); until then, set opt_in_roles: [], or admins can't act as admins in the UI.

Notifications

Notifications need a login, by the rules list-notifications and dismiss-notifications, for any authenticated user; what users see depends on their roles on services. Admins see those of all services, and pending approvals and values that the configuration no longer allows, only in admin mode.

Current user

GET /me returns the current user's identifier (to give to owners), whether the request is in admin mode (admin) and whether they may switch to it (adminAvailable), whether they may add service types (mayAddServiceTypes) and whether these are pending (serviceTypesPending), and their roles on services, also those through groups (with group; a service is listed once per role); it also stores their name for the services with roles granted to them. Owners are not shown in the public service responses.