Skip to content

Commit

Permalink
Merge pull request #944 from lsst-sqre/tickets/DM-42384
Browse files Browse the repository at this point in the history
DM-42384: Add support for data rights information
  • Loading branch information
rra authored Jan 18, 2024
2 parents b5a255f + c2c9072 commit 02fa27b
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 2 deletions.
3 changes: 3 additions & 0 deletions changelog.d/20240117_152943_rra_DM_42384.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions docs/user-guide/helm.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions docs/user-guide/openid-connect.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
========

Expand Down
25 changes: 25 additions & 0 deletions src/gafaelfawr/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/gafaelfawr/models/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class OIDCScope(StrEnum):
openid = "openid"
profile = "profile"
email = "email"
rubin = "rubin"

@classmethod
def parse_scopes(cls, scopes: str) -> list[Self]:
Expand Down
33 changes: 32 additions & 1 deletion src/gafaelfawr/services/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion tests/data/config/github-oidc-server.yaml.in
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
64 changes: 64 additions & 0 deletions tests/handlers/oidc_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

0 comments on commit 02fa27b

Please sign in to comment.