diff --git a/changelog.d/20240117_152943_rra_DM_42384.md b/changelog.d/20240117_152943_rra_DM_42384.md new file mode 100644 index 000000000..fa8084b66 --- /dev/null +++ b/changelog.d/20240117_152943_rra_DM_42384.md @@ -0,0 +1,3 @@ +### New features + +- Add a new `rubin` scope for the OpenID Connect server that, if requested, provides a `data_rights` claim listing the data releases to which the user has rights. Add a new `config.oidcServer.dataRightsMapping` configuration option that is used to determine that list of data releases from a user's group memberships. diff --git a/docs/user-guide/helm.rst b/docs/user-guide/helm.rst index d578f267a..0a5864c07 100644 --- a/docs/user-guide/helm.rst +++ b/docs/user-guide/helm.rst @@ -753,10 +753,34 @@ Kubernetes also deletes completed and failed jobs as necessary to maintain a cap To change the time limit for maintenance jobs (if, for instance, you have a huge user database or your database is very slow), set ``config.maintenance.deadlineSeconds`` to the length of time jobs are allowed to run for. To change the retention time for completed jobs, set ``config.maintenance.cleanupSeconds`` to the maximum lifetime of a completed job. +.. _helm-oidc-server: + OpenID Connect server ===================== Gafaelfawr can act as an OpenID Connect identity provider for relying parties inside the Kubernetes cluster. To enable this, set ``config.oidcServer.enabled`` to true. If this is set, ``oidc-server-secrets`` and ``signing-key`` must be set in the Gafaelfawr Vault secret. + +Gafaelfawr can provide an OpenID Connect ID token claim listing the data releases to which the user has access. +To do so, it must be configured with a mapping of group names to data releases to which membership in that group grants access. +This is done via the ``config.oidcServer.dataRightsMapping`` setting. +For example: + +.. code-block:: yaml + + config: + oidcServer: + dataRightsMapping: + g_users: + - dp0.1 + - dp0.2 + - dp0.3 + g_preview: + - dp0.1 + +This configuration indicates members of the ``g_preview`` group have access to the ``dp0.1`` release and members of the ``g_users`` group have access to all of ``dp0.1``, ``dp0.2``, and ``dp0.3``. +Users have access to the union of data releases across all of their group memberships. + See :ref:`openid-connect` for more information. +See :dmtn:`253` for how this OpenID Connect support can be used by International Data Access Centers. diff --git a/docs/user-guide/openid-connect.rst b/docs/user-guide/openid-connect.rst index f83042ea7..5f7990eaa 100644 --- a/docs/user-guide/openid-connect.rst +++ b/docs/user-guide/openid-connect.rst @@ -51,6 +51,11 @@ The following OpenID Connect scopes are supported and influence what claims are ``email`` Adds the ``email`` claim if the user's email address is known. +``rubin`` + Adds the ``data_rights`` claim with a space-separated list of data releases the user has access to, if there are any. + See :ref:`helm-oidc-server` for details on how to configure a mapping from group memberships to data releases. + For more information about how this scope is used, see :dmtn:`253`. + Examples ======== diff --git a/src/gafaelfawr/config.py b/src/gafaelfawr/config.py index 9b0e1d46d..644197458 100644 --- a/src/gafaelfawr/config.py +++ b/src/gafaelfawr/config.py @@ -321,6 +321,18 @@ class OIDCServerSettings(CamelCaseModel): secrets_file: Path """Path to file containing OpenID Connect client secrets in JSON.""" + data_rights_mapping: dict[str, list[str]] = Field( + {}, + title="Group to data rights mapping", + description=( + "Mapping of group names to keywords for data releases, indicating" + " membership in that group grants access to that data release." + " Used to construct the `data_rights` claim, which can be" + " requested by asking for the `rubin` scope." + ), + examples=[{"g_users": ["dp0.1", "dp0.2", "dp0.3"]}], + ) + class NotebookQuotaSettings(CamelCaseModel): """Quota settings for the Notebook Aspect.""" @@ -761,6 +773,14 @@ class OIDCServerConfig: clients: tuple[OIDCClient, ...] """Supported OpenID Connect clients.""" + data_rights_mapping: Mapping[str, frozenset[str]] + """Mapping of group names to keywords for data releases. + + Indicates that membership in the given group grants access to that set of + data releases. Used to construct the ``data_rights`` claim, which can be + requested by asking for the ``rubin`` scope. + """ + @dataclass(frozen=True, slots=True) class NotebookQuota: @@ -1004,12 +1024,17 @@ def from_file(cls, path: Path) -> Self: # noqa: PLR0912,PLR0915,C901 OIDCClient(client_id=c["id"], client_secret=c["secret"]) for c in oidc_secrets ) + data_rights_mapping = { + g: frozenset(r) + for g, r in settings.oidc_server.data_rights_mapping.items() + } oidc_server_config = OIDCServerConfig( issuer=str(settings.oidc_server.issuer), key_id=settings.oidc_server.key_id, keypair=oidc_keypair, lifetime=timedelta(minutes=settings.token_lifetime_minutes), clients=oidc_clients, + data_rights_mapping=data_rights_mapping, ) # Build the quota configuration if needed. diff --git a/src/gafaelfawr/models/oidc.py b/src/gafaelfawr/models/oidc.py index f66ad4941..dcae2b4d5 100644 --- a/src/gafaelfawr/models/oidc.py +++ b/src/gafaelfawr/models/oidc.py @@ -39,6 +39,7 @@ class OIDCScope(StrEnum): openid = "openid" profile = "profile" email = "email" + rubin = "rubin" @classmethod def parse_scopes(cls, scopes: str) -> list[Self]: diff --git a/src/gafaelfawr/services/oidc.py b/src/gafaelfawr/services/oidc.py index a8cc49868..a30e2fd45 100644 --- a/src/gafaelfawr/services/oidc.py +++ b/src/gafaelfawr/services/oidc.py @@ -29,7 +29,7 @@ OIDCToken, OIDCVerifiedToken, ) -from ..models.token import Token +from ..models.token import Token, TokenUserInfo from ..storage.oidc import OIDCAuthorizationStore from .token import TokenService from .userinfo import UserInfoService @@ -42,6 +42,7 @@ ), OIDCScope.profile: frozenset(["name", "preferred_username"]), OIDCScope.email: frozenset(["email"]), + OIDCScope.rubin: frozenset(["data_rights"]), } """Mapping of scope values to the claims to expose for that scope.""" @@ -215,6 +216,7 @@ async def issue_id_token( payload: dict[str, Any] = { "aud": authorization.client_id, "auth_time": int(token_data.created.timestamp()), + "data_rights": self._build_data_rights_for_user(user_info), "iat": int(now.timestamp()), "iss": str(self._config.issuer), "email": user_info.email, @@ -366,6 +368,35 @@ def verify_token(self, token: OIDCToken) -> OIDCVerifiedToken: except KeyError as e: raise InvalidTokenError(f"Missing claim {e!s}") from e + def _build_data_rights_for_user( + self, user_info: TokenUserInfo + ) -> str | None: + """Construct the data rights string for the user. + + This is a space-separated list of data releases to which the user has + access, based on the mapping rules from group names to data releases + in the Gafaelfawr configuration. This claim is very Rubin-specific. + + Parameters + ---------- + user_info + Metadata for the user. + + Returns + ------- + str or None + Space-separated list of data release keywords or `None` if the + user has no data rights. + """ + if not user_info.groups: + return None + releases: set[str] = set() + for group in user_info.groups: + mapping = self._config.data_rights_mapping.get(group.name) + if mapping: + releases.update(mapping) + return " ".join(sorted(releases)) + def _check_client_secret( self, client_id: str, client_secret: str | None ) -> None: diff --git a/tests/data/config/github-oidc-server.yaml.in b/tests/data/config/github-oidc-server.yaml.in index f141c47b9..e4c3726be 100644 --- a/tests/data/config/github-oidc-server.yaml.in +++ b/tests/data/config/github-oidc-server.yaml.in @@ -23,8 +23,10 @@ oidcServer: issuer: "https://test.example.com/" keyId: "some-kid" keyFile: "{issuer_key_file}" - audience: "https://example.com/" secretsFile: "{oidc_server_secrets_file}" + dataRightsMapping: + "admin": ["dp0.1"] + "foo": ["dp0.3", "dp0.2"] github: clientId: "some-github-client-id" clientSecretFile: "{github_secret_file}" diff --git a/tests/handlers/oidc_test.py b/tests/handlers/oidc_test.py index b78321ca3..abe2c7ead 100644 --- a/tests/handlers/oidc_test.py +++ b/tests/handlers/oidc_test.py @@ -775,3 +775,67 @@ async def test_nonce( "scope": "openid", "sub": token_data.username, } + + +@pytest.mark.asyncio +async def test_data_rights( + tmp_path: Path, client: AsyncClient, factory: Factory +) -> None: + clients = [OIDCClient(client_id="some-id", client_secret="some-secret")] + config = await reconfigure( + tmp_path, "github-oidc-server", factory, oidc_clients=clients + ) + assert config.oidc_server + token_data = await create_session_token(factory, group_names=["foo"]) + await set_session_cookie(client, token_data.token) + + token = await authenticate( + factory, + client, + { + "response_type": "code", + "scope": "openid rubin", + "client_id": "some-id", + "state": "random-state", + "redirect_uri": f"https://{TEST_HOSTNAME}/", + }, + client_secret="some-secret", + expires=token_data.expires, + ) + assert token.claims == { + "aud": "some-id", + "data_rights": "dp0.2 dp0.3", + "exp": int(token_data.expires.timestamp()), + "iat": ANY, + "iss": config.oidc_server.issuer, + "jti": ANY, + "scope": "openid rubin", + "sub": token_data.username, + } + + token_data = await create_session_token(factory, group_names=["admin"]) + await set_session_cookie(client, token_data.token) + + token = await authenticate( + factory, + client, + { + "response_type": "code", + "scope": "openid rubin", + "client_id": "some-id", + "state": "random-state", + "redirect_uri": f"https://{TEST_HOSTNAME}/", + }, + client_secret="some-secret", + expires=token_data.expires, + ) + assert token.claims == { + "aud": "some-id", + "data_rights": "dp0.1", + "exp": int(token_data.expires.timestamp()), + "iat": ANY, + "iss": config.oidc_server.issuer, + "jti": ANY, + "scope": "openid rubin", + "sub": token_data.username, + }