From 99fd6180950a832bd9f936b11b91e4fb802f43e3 Mon Sep 17 00:00:00 2001 From: Gabriele Mainetti Date: Mon, 13 Feb 2023 16:02:10 +0100 Subject: [PATCH 1/5] Add support for ForgeRock Identity Management Add support for obtaining GIDs from a ForgeRock Identity Management server. --- src/gafaelfawr/config.py | 47 ++++++++++++++++ src/gafaelfawr/factory.py | 25 +++++++++ src/gafaelfawr/services/idm.py | 87 +++++++++++++++++++++++++++++ src/gafaelfawr/services/userinfo.py | 17 +++++- 4 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/gafaelfawr/services/idm.py diff --git a/src/gafaelfawr/config.py b/src/gafaelfawr/config.py index fd658992a..10bddd483 100644 --- a/src/gafaelfawr/config.py +++ b/src/gafaelfawr/config.py @@ -35,6 +35,8 @@ "FirestoreSettings", "GitHubConfig", "GitHubSettings", + "IDMConfig", + "IDMSettings", "LDAPConfig", "LDAPSettings", "NotebookQuota", @@ -214,6 +216,19 @@ class LDAPSettings(CamelCaseModel): """ +class IDMSettings(CamelCaseModel): + """pydantic model of IDM configuration.""" + + url: str + """URL for IDM server.""" + + idm_id: str + """IDM client_id""" + + idm_secret_file: Path + """IDM secret""" + + class FirestoreSettings(CamelCaseModel): """pydantic model of Firestore configuration.""" @@ -353,6 +368,9 @@ class Settings(CamelCaseModel): firestore: Optional[FirestoreSettings] = None """Settings for Firestore-based UID/GID assignment.""" + idm: Optional[IDMSettings] = None + """Settings for IDM GID assignment.""" + oidc_server: Optional[OIDCServerSettings] = None """Settings for the internal OpenID Connect server.""" @@ -623,6 +641,20 @@ class LDAPConfig: """ +@dataclass(frozen=True, slots=True) +class IDMConfig: + """Configuration for IDM-based GID assignment.""" + + url: str + """URL for IDM server.""" + + idm_id: str + """IDM client_id""" + + idm_secret: str + """IDM secret""" + + @dataclass(frozen=True, slots=True) class FirestoreConfig: """Configuration for Firestore-based UID/GID assignment.""" @@ -779,6 +811,9 @@ class Config: firestore: FirestoreConfig | None """Settings for Firestore-based UID/GID assignment.""" + idm: IDMConfig | None + """Configuration for IDM-based GID assignment.""" + oidc_server: OIDCServerConfig | None """Configuration for the OpenID Connect server.""" @@ -876,6 +911,17 @@ def from_file(cls, path: Path) -> Self: project=settings.firestore.project ) + # Build IDM configuration if needed. + idm_config = None + if settings.idm: + path = settings.idm.idm_secret_file + idm_config = IDMConfig( + url=settings.idm.url, + idm_id=settings.idm.idm_id, + idm_secret=cls._load_secret( + path + ).decode(), # settings.idm.idm_secret# + ) # Build the OpenID Connect server configuration if needed. oidc_server_config = None if settings.oidc_server: @@ -970,6 +1016,7 @@ def from_file(cls, path: Path) -> Self: oidc=oidc_config, ldap=ldap_config, firestore=firestore_config, + idm=idm_config, oidc_server=oidc_server_config, quota=quota, initial_admins=tuple(settings.initial_admins), diff --git a/src/gafaelfawr/factory.py b/src/gafaelfawr/factory.py index c66d10a80..f9dd2b8d5 100644 --- a/src/gafaelfawr/factory.py +++ b/src/gafaelfawr/factory.py @@ -32,6 +32,7 @@ from .schema import Admin as SQLAdmin from .services.admin import AdminService from .services.firestore import FirestoreService +from .services.idm import IDMService from .services.kubernetes import ( KubernetesIngressService, KubernetesTokenService, @@ -389,6 +390,22 @@ def create_oidc_service(self) -> OIDCService: logger=self._logger, ) + def create_idm_service(self) -> IDMService: + """Create a minimalist IDM server. + + Returns + ------- + IDMService + A new IDM server. + """ + if not self._context.config.idm: + raise NotConfiguredError("IDM is not configured") + return IDMService( + config=self._context.config.idm, + http_client=self._context.http_client, + logger=self._logger, + ) + def create_oidc_user_info_service(self) -> OIDCUserInfoService: """Create a user information service for OpenID Connect providers. @@ -412,6 +429,9 @@ def create_oidc_user_info_service(self) -> OIDCUserInfoService: firestore = None if self._context.config.firestore: firestore = self.create_firestore_service() + idm = None + if self._context.config.idm: + idm = self.create_idm_service() ldap = None if self._context.config.ldap and self._context.ldap_pool: ldap_storage = LDAPStorage( @@ -429,6 +449,7 @@ def create_oidc_user_info_service(self) -> OIDCUserInfoService: return OIDCUserInfoService( config=self._context.config, ldap=ldap, + idm=idm, firestore=firestore, logger=self._logger, ) @@ -575,6 +596,9 @@ def create_user_info_service(self) -> UserInfoService: firestore = None if self._context.config.firestore: firestore = self.create_firestore_service() + idm = None + if self._context.config.idm: + idm = self.create_idm_service() ldap = None if self._context.config.ldap and self._context.ldap_pool: ldap_storage = LDAPStorage( @@ -592,6 +616,7 @@ def create_user_info_service(self) -> UserInfoService: return UserInfoService( config=self._context.config, ldap=ldap, + idm=idm, firestore=firestore, logger=self._logger, ) diff --git a/src/gafaelfawr/services/idm.py b/src/gafaelfawr/services/idm.py new file mode 100644 index 000000000..c6ba72895 --- /dev/null +++ b/src/gafaelfawr/services/idm.py @@ -0,0 +1,87 @@ +"""IDM lookups for group information.""" +from __future__ import annotations + +from urllib.parse import urljoin + +from httpx import AsyncClient, BasicAuth +from structlog.stdlib import BoundLogger + +from ..config import IDMConfig + +__all__ = ["IDMService"] + + +class IDMService: + """Perform IDM lookups for group information. + + Parameters + ---------- + config + The IDM config. + http_client + The AsyncClient to use. + logger + Logger to use. + """ + + def __init__( + self, + *, + config: IDMConfig, + http_client: AsyncClient, + logger: BoundLogger, + ) -> None: + self._config = config + self._logger = logger + self._http_client = http_client + + async def get_group_id(self, groupname="lsst") -> str: + """Get the GID of a group from IDM. + + Parameters + ---------- + groupname + Name of the group, by default lsst. + + Returns + ------- + str + gid + The gid if set, None otehrwise + + """ + base_url = self._config.url + idm_id = self._config.idm_id + idm_secret = self._config.idm_secret + auth = BasicAuth(idm_id, idm_secret) + # query='?_queryFilter=name+eq+"{}"&_fields=gid'.format(groupname) + url = urljoin(base_url, self._build_gid_query(groupname)) + try: + r = await self._http_client.get(url, auth=auth) + result = r.json() + gid = result.get("result")[0].get("gid") + except IndexError: + gid = None + # r.raise_for_status() + return gid + + def _build_gid_query( + self, + groupname=str, + ) -> str: + """Return the query to run to get the GID of a group from IDM. + + Parameters + ---------- + groupname + Name of the group, by default lsst. + + Returns + ------- + str + query + The query to run to get the GID from IDM + + """ + query = 'group?_queryFilter=name+eq+"{}"&_fields=gid'.format(groupname) + return query diff --git a/src/gafaelfawr/services/userinfo.py b/src/gafaelfawr/services/userinfo.py index 39a2ac62c..7cadf8465 100644 --- a/src/gafaelfawr/services/userinfo.py +++ b/src/gafaelfawr/services/userinfo.py @@ -24,6 +24,7 @@ TokenUserInfo, ) from ..services.firestore import FirestoreService +from ..services.idm import IDMService from ..services.ldap import LDAPService __all__ = ["OIDCUserInfoService", "UserInfoService"] @@ -64,11 +65,13 @@ def __init__( *, config: Config, ldap: LDAPService | None, + idm: IDMService | None, firestore: FirestoreService | None, logger: BoundLogger, ) -> None: self._config = config self._ldap = ldap + self._idm = idm self._firestore = firestore self._logger = logger @@ -319,12 +322,14 @@ def __init__( *, config: Config, ldap: LDAPService | None, + idm: IDMService | None, firestore: FirestoreService | None, logger: BoundLogger, ) -> None: super().__init__( config=config, ldap=ldap, + idm=idm, firestore=firestore, logger=logger, ) @@ -425,19 +430,26 @@ async def _get_groups_from_oidc_token( try: for oidc_group in token.claims.get(claim, []): try: + gid = None if isinstance(oidc_group, str): name = oidc_group.removeprefix("/") - groups.append(TokenGroup(name=name)) + if self._idm: + gid = await self._idm.get_group_id(name) + if gid: + groups.append(TokenGroup(name=name, id=gid)) + else: + groups.append(TokenGroup(name=name)) continue if "name" not in oidc_group: continue name = oidc_group["name"].removeprefix("/") - gid = None + if self._firestore: gid = await self._firestore.get_gid(name) elif "id" in oidc_group: gid = int(oidc_group["id"]) groups.append(TokenGroup(name=name, id=gid)) + except (TypeError, ValueError, ValidationError) as e: invalid_groups[name] = str(e) except FirestoreError as e: @@ -490,6 +502,7 @@ def _get_gid_from_oidc_token( """ if not self._oidc_config.gid_claim: return None + if self._oidc_config.gid_claim not in token.claims: msg = f"No {self._oidc_config.gid_claim} claim in token" self._logger.warning(msg, claims=token.claims, user=username) From be723ebe9b08d57938e8a9a30afb66376e5f9690 Mon Sep 17 00:00:00 2001 From: Russ Allbery Date: Fri, 10 Mar 2023 18:05:01 -0800 Subject: [PATCH 2/5] Refactor ForgeRock Identity Management support Rename the code to use forgerock instead of the generic idm. Although ForgeRock originally called their product OpenIDM, the open source release appears to be dead and their current documentation only talks about ForgeRock Identity Management. This will hopefully make the configuration and code somewhat more self-explanatory. Move the service to a storage layer since it fits there better, and inject it only into the user information service that needs it. Do some refactoring of the code to build groups from OIDC claims, since the interior of the loop had gotten quite complicated. Add an improved exception for failures using the new Slack-reportable exception code, and raise an exception on any failure to retrieve data from the service. Add documentation and a test suite. --- CHANGELOG.md | 1 + docs/dev/internals.rst | 2 + docs/user-guide/helm.rst | 34 ++++- docs/user-guide/secrets.rst | 5 + src/gafaelfawr/config.py | 62 ++++---- src/gafaelfawr/exceptions.py | 187 ++++++++++++----------- src/gafaelfawr/factory.py | 34 ++--- src/gafaelfawr/services/idm.py | 87 ----------- src/gafaelfawr/services/userinfo.py | 112 +++++++++----- src/gafaelfawr/storage/forgerock.py | 93 +++++++++++ tests/data/config/oidc-forgerock.yaml.in | 32 ++++ tests/handlers/login_oidc_test.py | 28 ++++ tests/support/config.py | 15 +- tests/support/forgerock.py | 87 +++++++++++ tests/support/github.py | 2 +- 15 files changed, 511 insertions(+), 270 deletions(-) delete mode 100644 src/gafaelfawr/services/idm.py create mode 100644 src/gafaelfawr/storage/forgerock.py create mode 100644 tests/data/config/oidc-forgerock.yaml.in create mode 100644 tests/support/forgerock.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e650484..1bea86847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Dependencies are updated to the latest available version during each release. Th - Gafaelfawr now supports setting API and notebook quotas in its configuration, and calculates the quota for a given user based on their group membership. This quota information is returned by the `/auth/api/v1/user-info` route, but is not otherwise used by Gafaelfawr (yet). - Server-side failures during login, such as inability to reach the authentication provider or invalid responses from the authentication provider, are now reported to Slack if a Slack webhook is configured. +- When using an OpenID Connect authentication provider, Gafaelfawr now supports looking up the GIDs of user groups in a ForgeRock Identity Management server (specifically, in the ``groups`` collection of the ``freeipa`` component). ### Bug fixes diff --git a/docs/dev/internals.rst b/docs/dev/internals.rst index cfb55d7fd..d01cd6e24 100644 --- a/docs/dev/internals.rst +++ b/docs/dev/internals.rst @@ -84,6 +84,8 @@ Python internal API .. automodapi:: gafaelfawr.storage.firestore +.. automodapi:: gafaelfawr.storage.forgerock + .. automodapi:: gafaelfawr.storage.history .. automodapi:: gafaelfawr.storage.kubernetes diff --git a/docs/user-guide/helm.rst b/docs/user-guide/helm.rst index 7100606bd..92889a6f4 100644 --- a/docs/user-guide/helm.rst +++ b/docs/user-guide/helm.rst @@ -296,8 +296,6 @@ You may need to set the following additional options under ``config.ldap`` depen The attribute holding the username, used to find the user's entry. Default: ``uid``. -.. _scopes: - Firestore UID/GID assignment ============================ @@ -320,6 +318,38 @@ To enable use of Firestore for UID/GID assignment, add the following configurati Set ```` to the name of the Google project for the Firestore data store. (Best practice is to make a dedicated project solely for Firestore, since there can only be one Firestore instance per Google project.) +.. _forgerock: + +ForgeRock Identity Management GID queries +========================================= + +Gafaelfawr can get the GID corresponding to a group from a ForgeRock Identity Management server. +Only GIDs, not UIDs, can be looked up this way. +When using this configuration, UIDs should be present in the OpenID Connect claim from the upstream authentication system. + +When this support is enabled, the GID for each group found in the token issued by the OpenID Connect provider during login will be looked up in a ForgeRock Identity Management server. +Specifically, Gafaelfawr will query the ``groups`` collection of the ``freeipa`` component. +The request will be authenticated with HTTP Basic authentication. + +To enable this support, add the following configuration: + +.. code-block:: yaml + + config: + forgerock: + url: "" + username: "" + +Set ```` to the base URL of the ForgeRock Identity Management REST API. +``/system/freeipa/groups`` will be added to find the ``groups`` collection. + +```` should be the username used for HTTP Basic authentication. +The corresponding password must be set in the ``forgerock-password`` field of the Gafaelfawr Vault secret (see :ref:`vault-secrets`). + +GID lookups in ForgeRock Identity Management is only supported in conjunction with OpenID Connect authentication. + +.. _scopes: + Scopes ====== diff --git a/docs/user-guide/secrets.rst b/docs/user-guide/secrets.rst index 66706f66a..d04315956 100644 --- a/docs/user-guide/secrets.rst +++ b/docs/user-guide/secrets.rst @@ -24,6 +24,11 @@ The Phalanx installer expects a Vault secret named ``gafaelfawr`` in the relevan The GitHub secret, obtained when creating the OAuth App as described above. This is only required if you're using GitHub for authentication. +``forgerock-password`` (optional) + The password used for HTTP Basic authentication to a ForgeRock Identity Management server when resolving group names to GIDs. + Only used if ForgeRock Identity Management support is enabled. + See :ref:`forgerock` for more information. + ``ldap-password`` (optional) The password used for simple binds to the LDAP server used as a source of data about users. Only used if LDAP lookups are enabled. diff --git a/src/gafaelfawr/config.py b/src/gafaelfawr/config.py index 10bddd483..29fef86d1 100644 --- a/src/gafaelfawr/config.py +++ b/src/gafaelfawr/config.py @@ -33,10 +33,10 @@ "Config", "FirestoreConfig", "FirestoreSettings", + "ForgeRockConfig", + "ForgeRockSettings", "GitHubConfig", "GitHubSettings", - "IDMConfig", - "IDMSettings", "LDAPConfig", "LDAPSettings", "NotebookQuota", @@ -216,17 +216,17 @@ class LDAPSettings(CamelCaseModel): """ -class IDMSettings(CamelCaseModel): - """pydantic model of IDM configuration.""" +class ForgeRockSettings(CamelCaseModel): + """pydantic model of ForgeRock Identity Management configuration.""" url: str - """URL for IDM server.""" + """Base URL for ForgeRock Identity Management server.""" - idm_id: str - """IDM client_id""" + username: str + """Username for authenticated queries.""" - idm_secret_file: Path - """IDM secret""" + password_file: Path + """File containing the password for authenticated queries.""" class FirestoreSettings(CamelCaseModel): @@ -368,8 +368,8 @@ class Settings(CamelCaseModel): firestore: Optional[FirestoreSettings] = None """Settings for Firestore-based UID/GID assignment.""" - idm: Optional[IDMSettings] = None - """Settings for IDM GID assignment.""" + forgerock: Optional[ForgeRockSettings] = None + """Settings for ForgeRock Identity Management server.""" oidc_server: Optional[OIDCServerSettings] = None """Settings for the internal OpenID Connect server.""" @@ -642,17 +642,17 @@ class LDAPConfig: @dataclass(frozen=True, slots=True) -class IDMConfig: - """Configuration for IDM-based GID assignment.""" +class ForgeRockConfig: + """Configuration for ForgeRock Identity Management server.""" url: str - """URL for IDM server.""" + """Base URL for ForgeRock Identity Management server.""" - idm_id: str - """IDM client_id""" + username: str + """Username for authenticated queries to server.""" - idm_secret: str - """IDM secret""" + password: str + """Password for authenticated queries to server.""" @dataclass(frozen=True, slots=True) @@ -811,8 +811,8 @@ class Config: firestore: FirestoreConfig | None """Settings for Firestore-based UID/GID assignment.""" - idm: IDMConfig | None - """Configuration for IDM-based GID assignment.""" + forgerock: ForgeRockConfig | None + """Configuration for ForgeRock Identity Management server.""" oidc_server: OIDCServerConfig | None """Configuration for the OpenID Connect server.""" @@ -911,17 +911,17 @@ def from_file(cls, path: Path) -> Self: project=settings.firestore.project ) - # Build IDM configuration if needed. - idm_config = None - if settings.idm: - path = settings.idm.idm_secret_file - idm_config = IDMConfig( - url=settings.idm.url, - idm_id=settings.idm.idm_id, - idm_secret=cls._load_secret( - path - ).decode(), # settings.idm.idm_secret# + # Build ForgeRock configuration if needed. + forgerock_config = None + if settings.forgerock: + path = settings.forgerock.password_file + forgerock_password = cls._load_secret(path).decode() + forgerock_config = ForgeRockConfig( + url=settings.forgerock.url, + username=settings.forgerock.username, + password=forgerock_password, ) + # Build the OpenID Connect server configuration if needed. oidc_server_config = None if settings.oidc_server: @@ -1016,7 +1016,7 @@ def from_file(cls, path: Path) -> Self: oidc=oidc_config, ldap=ldap_config, firestore=firestore_config, - idm=idm_config, + forgerock=forgerock_config, oidc_server=oidc_server_config, quota=quota, initial_admins=tuple(settings.initial_admins), diff --git a/src/gafaelfawr/exceptions.py b/src/gafaelfawr/exceptions.py index b43dfe7b6..40c4badb9 100644 --- a/src/gafaelfawr/exceptions.py +++ b/src/gafaelfawr/exceptions.py @@ -25,6 +25,8 @@ "FetchKeysError", "FirestoreError", "FirestoreNotInitializedError", + "ForgeRockError", + "ForgeRockWebError", "GitHubError", "InsufficientScopeError", "InvalidClientError", @@ -54,6 +56,7 @@ "PermissionDeniedError", "ProviderError", "ProviderWebError", + "SlackWebException", "UnauthorizedClientError", "UnknownAlgorithmError", "UnknownKeyIdError", @@ -316,91 +319,8 @@ class InsufficientScopeError(OAuthBearerError): status_code = status.HTTP_403_FORBIDDEN -class DeserializeError(Exception): - """A stored object could not be decrypted or deserialized. - - Used for data stored in the backing store, such as sessions or user - tokens. Should normally be treated the same as a missing object, but - reported separately so that an error can be logged. - """ - - -class ExternalUserInfoError(SlackException): - """Error in external user information source. - - This is the base exception for any error in retrieving information from an - external source of user data. External sources of data may be affected by - an external outage, and we don't want to report uncaught exceptions for - every attempt to query them (possibly multiple times per second), so this - exception base class is used to catch those errors in the high-traffic - ``/auth`` route and only log them. - """ - - -class FirestoreError(ExternalUserInfoError): - """An error occurred while reading or updating Firestore data.""" - - -class FirestoreNotInitializedError(FirestoreError): - """Firestore has not been initialized.""" - - -class NoAvailableGidError(FirestoreError): - """The assigned UID space has been exhausted.""" - - -class NoAvailableUidError(FirestoreError): - """The assigned UID space has been exhausted.""" - - -class LDAPError(ExternalUserInfoError): - """User or group information in LDAP was invalid or LDAP calls failed.""" - - -class KubernetesError(kopf.TemporaryError): - """An error occurred performing a Kubernetes operation.""" - - -class KubernetesObjectError(kopf.PermanentError): - """A Kubernetes object could not be parsed. - - Parameters - ---------- - kind - Kind of the malformed Kubernetes object. - name - Name of the malformed Kubernetes object. - namespace - Namespace of the malformed Kubernetes object. - exc - Exception from attempting to parse the object. - """ - - def __init__( - self, - kind: str, - name: str, - namespace: str, - exc: pydantic.ValidationError, - ) -> None: - msg = f"{kind} {namespace}/{name} is malformed: {str(exc)}" - super().__init__(msg) - - -class NotConfiguredError(SlackIgnoredException): - """The requested operation was not configured.""" - - -class PermissionDeniedError(SlackIgnoredException, kopf.PermanentError): - """The user does not have permission to perform this operation.""" - - -class ProviderError(SlackException): - """Something failed while talking to an authentication provider.""" - - -class ProviderWebError(ProviderError): - """An HTTP request to an authentication provider failed. +class SlackWebException(SlackException): + """An HTTP request to a remote service failed. Parameters ---------- @@ -504,6 +424,101 @@ def to_slack(self) -> SlackMessage: return message +class DeserializeError(Exception): + """A stored object could not be decrypted or deserialized. + + Used for data stored in the backing store, such as sessions or user + tokens. Should normally be treated the same as a missing object, but + reported separately so that an error can be logged. + """ + + +class ExternalUserInfoError(SlackException): + """Error in external user information source. + + This is the base exception for any error in retrieving information from an + external source of user data. External sources of data may be affected by + an external outage, and we don't want to report uncaught exceptions for + every attempt to query them (possibly multiple times per second), so this + exception base class is used to catch those errors in the high-traffic + ``/auth`` route and only log them. + """ + + +class FirestoreError(ExternalUserInfoError): + """An error occurred while reading or updating Firestore data.""" + + +class FirestoreNotInitializedError(FirestoreError): + """Firestore has not been initialized.""" + + +class ForgeRockError(ExternalUserInfoError): + """An error occurred querying ForgeRock Identity Management.""" + + +class ForgeRockWebError(ForgeRockError, SlackWebException): + """An HTTP error occurred querying ForgeRock Identity Management.""" + + +class NoAvailableGidError(FirestoreError): + """The assigned UID space has been exhausted.""" + + +class NoAvailableUidError(FirestoreError): + """The assigned UID space has been exhausted.""" + + +class LDAPError(ExternalUserInfoError): + """User or group information in LDAP was invalid or LDAP calls failed.""" + + +class KubernetesError(kopf.TemporaryError): + """An error occurred during Kubernetes secret processing.""" + + +class KubernetesObjectError(kopf.PermanentError): + """A Kubernetes object could not be parsed. + + Parameters + ---------- + kind + Kind of the malformed Kubernetes object. + name + Name of the malformed Kubernetes object. + namespace + Namespace of the malformed Kubernetes object. + exc + Exception from attempting to parse the object. + """ + + def __init__( + self, + kind: str, + name: str, + namespace: str, + exc: pydantic.ValidationError, + ) -> None: + msg = f"{kind} {namespace}/{name} is malformed: {str(exc)}" + super().__init__(msg) + + +class NotConfiguredError(SlackIgnoredException): + """The requested operation was not configured.""" + + +class PermissionDeniedError(SlackIgnoredException, kopf.PermanentError): + """The user does not have permission to perform this operation.""" + + +class ProviderError(SlackException): + """Something failed while talking to an authentication provider.""" + + +class ProviderWebError(SlackWebException, ProviderError): + """A web request to an authentication provider failed.""" + + class GitHubError(ProviderError): """GitHub returned an error from an API call.""" @@ -532,7 +547,7 @@ class VerifyTokenError(SlackException): """Base exception class for failure in verifying a token.""" -class FetchKeysError(ProviderWebError): +class FetchKeysError(SlackWebException, VerifyTokenError): """Cannot retrieve the keys from an issuer.""" diff --git a/src/gafaelfawr/factory.py b/src/gafaelfawr/factory.py index f9dd2b8d5..04ad83bf0 100644 --- a/src/gafaelfawr/factory.py +++ b/src/gafaelfawr/factory.py @@ -32,7 +32,6 @@ from .schema import Admin as SQLAdmin from .services.admin import AdminService from .services.firestore import FirestoreService -from .services.idm import IDMService from .services.kubernetes import ( KubernetesIngressService, KubernetesTokenService, @@ -45,6 +44,7 @@ from .storage.admin import AdminStore from .storage.base import RedisStorage from .storage.firestore import FirestoreStorage +from .storage.forgerock import ForgeRockStorage from .storage.history import AdminHistoryStore, TokenChangeHistoryStore from .storage.kubernetes import ( KubernetesIngressStorage, @@ -390,22 +390,6 @@ def create_oidc_service(self) -> OIDCService: logger=self._logger, ) - def create_idm_service(self) -> IDMService: - """Create a minimalist IDM server. - - Returns - ------- - IDMService - A new IDM server. - """ - if not self._context.config.idm: - raise NotConfiguredError("IDM is not configured") - return IDMService( - config=self._context.config.idm, - http_client=self._context.http_client, - logger=self._logger, - ) - def create_oidc_user_info_service(self) -> OIDCUserInfoService: """Create a user information service for OpenID Connect providers. @@ -429,9 +413,13 @@ def create_oidc_user_info_service(self) -> OIDCUserInfoService: firestore = None if self._context.config.firestore: firestore = self.create_firestore_service() - idm = None - if self._context.config.idm: - idm = self.create_idm_service() + forgerock = None + if self._context.config.forgerock: + forgerock = ForgeRockStorage( + config=self._context.config.forgerock, + http_client=self._context.http_client, + logger=self._logger, + ) ldap = None if self._context.config.ldap and self._context.ldap_pool: ldap_storage = LDAPStorage( @@ -449,8 +437,8 @@ def create_oidc_user_info_service(self) -> OIDCUserInfoService: return OIDCUserInfoService( config=self._context.config, ldap=ldap, - idm=idm, firestore=firestore, + forgerock=forgerock, logger=self._logger, ) @@ -596,9 +584,6 @@ def create_user_info_service(self) -> UserInfoService: firestore = None if self._context.config.firestore: firestore = self.create_firestore_service() - idm = None - if self._context.config.idm: - idm = self.create_idm_service() ldap = None if self._context.config.ldap and self._context.ldap_pool: ldap_storage = LDAPStorage( @@ -616,7 +601,6 @@ def create_user_info_service(self) -> UserInfoService: return UserInfoService( config=self._context.config, ldap=ldap, - idm=idm, firestore=firestore, logger=self._logger, ) diff --git a/src/gafaelfawr/services/idm.py b/src/gafaelfawr/services/idm.py deleted file mode 100644 index c6ba72895..000000000 --- a/src/gafaelfawr/services/idm.py +++ /dev/null @@ -1,87 +0,0 @@ -"""IDM lookups for group information.""" -from __future__ import annotations - -from urllib.parse import urljoin - -from httpx import AsyncClient, BasicAuth -from structlog.stdlib import BoundLogger - -from ..config import IDMConfig - -__all__ = ["IDMService"] - - -class IDMService: - """Perform IDM lookups for group information. - - Parameters - ---------- - config - The IDM config. - http_client - The AsyncClient to use. - logger - Logger to use. - """ - - def __init__( - self, - *, - config: IDMConfig, - http_client: AsyncClient, - logger: BoundLogger, - ) -> None: - self._config = config - self._logger = logger - self._http_client = http_client - - async def get_group_id(self, groupname="lsst") -> str: - """Get the GID of a group from IDM. - - Parameters - ---------- - groupname - Name of the group, by default lsst. - - Returns - ------- - str - gid - The gid if set, None otehrwise - - """ - base_url = self._config.url - idm_id = self._config.idm_id - idm_secret = self._config.idm_secret - auth = BasicAuth(idm_id, idm_secret) - # query='?_queryFilter=name+eq+"{}"&_fields=gid'.format(groupname) - url = urljoin(base_url, self._build_gid_query(groupname)) - try: - r = await self._http_client.get(url, auth=auth) - result = r.json() - gid = result.get("result")[0].get("gid") - except IndexError: - gid = None - # r.raise_for_status() - return gid - - def _build_gid_query( - self, - groupname=str, - ) -> str: - """Return the query to run to get the GID of a group from IDM. - - Parameters - ---------- - groupname - Name of the group, by default lsst. - - Returns - ------- - str - query - The query to run to get the GID from IDM - - """ - query = 'group?_queryFilter=name+eq+"{}"&_fields=gid'.format(groupname) - return query diff --git a/src/gafaelfawr/services/userinfo.py b/src/gafaelfawr/services/userinfo.py index 7cadf8465..bbd4b2b1c 100644 --- a/src/gafaelfawr/services/userinfo.py +++ b/src/gafaelfawr/services/userinfo.py @@ -2,17 +2,20 @@ from __future__ import annotations +from typing import Any + +from pydantic import ValidationError from structlog.stdlib import BoundLogger from ..config import Config from ..exceptions import ( + ExternalUserInfoError, FirestoreError, InvalidTokenClaimsError, MissingGIDClaimError, MissingUIDClaimError, MissingUsernameClaimError, NotConfiguredError, - ValidationError, ) from ..models.ldap import LDAPUserData from ..models.oidc import OIDCVerifiedToken @@ -23,9 +26,9 @@ TokenGroup, TokenUserInfo, ) -from ..services.firestore import FirestoreService -from ..services.idm import IDMService -from ..services.ldap import LDAPService +from ..storage.forgerock import ForgeRockStorage +from .firestore import FirestoreService +from .ldap import LDAPService __all__ = ["OIDCUserInfoService", "UserInfoService"] @@ -65,13 +68,11 @@ def __init__( *, config: Config, ldap: LDAPService | None, - idm: IDMService | None, firestore: FirestoreService | None, logger: BoundLogger, ) -> None: self._config = config self._ldap = ldap - self._idm = idm self._firestore = firestore self._logger = logger @@ -313,6 +314,9 @@ class OIDCUserInfoService(UserInfoService): LDAP service for user metadata, if LDAP was configured. firestore Service for Firestore UID/GID lookups, if Firestore was configured. + forgerock + Service for ForgeRock Identity Management service queries, if + ForgeRock was configured. logger Logger to use. """ @@ -322,17 +326,17 @@ def __init__( *, config: Config, ldap: LDAPService | None, - idm: IDMService | None, firestore: FirestoreService | None, + forgerock: ForgeRockStorage | None, logger: BoundLogger, ) -> None: super().__init__( config=config, ldap=ldap, - idm=idm, firestore=firestore, logger=logger, ) + self._forgerock = forgerock if not config.oidc: raise NotConfiguredError("OpenID Connect not configured") self._oidc_config = config.oidc @@ -419,40 +423,24 @@ async def _get_groups_from_oidc_token( Raises ------ - FirestoreError - An error occured obtaining the GID from Firestore. + ExternalUserInfoError + Raised if an error occurred getting a GID from an external source. InvalidTokenClaimsError - The ``isMemberOf`` claim has an invalid syntax. + The group claim has an invalid syntax. """ claim = self._oidc_config.groups_claim groups = [] - invalid_groups = {} + invalid_groups = [] try: for oidc_group in token.claims.get(claim, []): try: - gid = None - if isinstance(oidc_group, str): - name = oidc_group.removeprefix("/") - if self._idm: - gid = await self._idm.get_group_id(name) - if gid: - groups.append(TokenGroup(name=name, id=gid)) - else: - groups.append(TokenGroup(name=name)) - continue - if "name" not in oidc_group: - continue - name = oidc_group["name"].removeprefix("/") - - if self._firestore: - gid = await self._firestore.get_gid(name) - elif "id" in oidc_group: - gid = int(oidc_group["id"]) - groups.append(TokenGroup(name=name, id=gid)) - - except (TypeError, ValueError, ValidationError) as e: - invalid_groups[name] = str(e) - except FirestoreError as e: + group = await self._get_group_from_oidc_claim(oidc_group) + except (TypeError, ValidationError): + invalid_groups.append(oidc_group) + continue + if group: + groups.append(group) + except ExternalUserInfoError as e: e.user = username raise except TypeError as e: @@ -468,13 +456,64 @@ async def _get_groups_from_oidc_token( if invalid_groups: self._logger.warning( "Ignoring invalid groups in OIDC token", - error=f"{claim} claim value could not be parsed", + error=f"{claim} claim value contained invalid groups", invalid_groups=invalid_groups, user=username, ) return groups + async def _get_group_from_oidc_claim( + self, group: str | dict[str, Any] + ) -> TokenGroup | None: + """Translate one member of the OIDC group claim into a group. + + Parameters + ---------- + group + One member of the groups claim of the OpenID Connect token. This + may be a simple group name or it may be a dict with group name and + GID elements. + + Returns + ------- + TokenGroup or None + The equivalent group model, or `None` if this member of the claim + could not be resolved into a group. + + Raises + ------ + TypeError + Raised if some part of the claim has an unexpected type. + ValidationError + Raised if the group is invalid (malformatted name, for instance). + """ + # First, check if it's a simple group name. If so, treat that as the + # group name. Otherwise, assume this is a dictionary and try to get + # the group name and GID from name and id elements. One installation's + # identity management system insisted on adding leading slashes. + if isinstance(group, str): + name = group.removeprefix("/") + else: + if "name" not in group: + return None + name = group["name"].removeprefix("/") + + # Now, try to resolve that group name to a GID. Prefer ForgeRock if + # configured, then try Firestore if configured, and if not try to + # extract the GID from the OpenID Connect claim. Failing all of those, + # create a group without a GID. + gid = None + if self._forgerock: + gid = await self._forgerock.get_gid(name) + elif self._firestore: + gid = await self._firestore.get_gid(name) + elif isinstance(group, dict) and "id" in group: + gid = int(group["id"]) + + # Return the resulting group. + return TokenGroup(name=name, id=gid) + def _get_gid_from_oidc_token( self, token: OIDCVerifiedToken, username: str ) -> int | None: @@ -502,7 +541,6 @@ def _get_gid_from_oidc_token( """ if not self._oidc_config.gid_claim: return None - if self._oidc_config.gid_claim not in token.claims: msg = f"No {self._oidc_config.gid_claim} claim in token" self._logger.warning(msg, claims=token.claims, user=username) diff --git a/src/gafaelfawr/storage/forgerock.py b/src/gafaelfawr/storage/forgerock.py new file mode 100644 index 000000000..42a49967e --- /dev/null +++ b/src/gafaelfawr/storage/forgerock.py @@ -0,0 +1,93 @@ +"""ForgeRock Identity Management storage layer for Gafaelfawr.""" + +from __future__ import annotations + +from httpx import AsyncClient, BasicAuth, HTTPError +from structlog.stdlib import BoundLogger + +from ..config import ForgeRockConfig +from ..exceptions import ForgeRockError, ForgeRockWebError + +__all__ = ["ForgeRockStorage"] + + +class ForgeRockStorage: + """Perform ForgeRock Identity Management lookups. + + Parameters + ---------- + config + ForgeRock Identity Management configuration. + http_client + HTTP client to use. + logger + Logger to use. + """ + + def __init__( + self, + *, + config: ForgeRockConfig, + http_client: AsyncClient, + logger: BoundLogger, + ) -> None: + self._config = config + self._logger = logger + self._http_client = http_client + + async def get_gid(self, group_name: str) -> int | None: + """Get the GID of a group from ForgeRock Identity Management. + + Parameters + ---------- + group_name + Name of the group. + + Returns + ------- + int or None + GID if found, else `None`. + + Raises + ------ + ForgeRockError + Raised if some error occured querying the ForgeRock server (other + than that the group was not found). + + Notes + ----- + This issues a :samp:`name eq {group_name}` query against the + ``system/freeipa/group`` endpoint, which appears to be the correct + place to find group information for at least one installation of the + ForgeRock Identity Management server. This may or may not generalize + to other installations. + """ + url = self._config.url.rstrip("/") + "/system/freeipa/group" + params = { + "_queryFilter": f'name eq "{group_name}"', + "_fields": "gid", + } + try: + r = await self._http_client.get( + url, + params=params, + auth=BasicAuth(self._config.username, self._config.password), + ) + r.raise_for_status() + result = r.json() + self._logger.debug( + f"ForgeRock data for group {group_name}", + forgerock_url=url, + forgerock_results=result, + forgerock_query=params, + ) + entries = result.get("result", []) + if not entries: + return None + return int(entries[0].get("gid")) + except (AttributeError, ValueError) as e: + error = f"{type(e).__name__}: str(e)" + msg = f"ForgeRock data for {group_name} invalid: {error}" + raise ForgeRockError(msg) from e + except HTTPError as e: + raise ForgeRockWebError.from_exception(e) from e diff --git a/tests/data/config/oidc-forgerock.yaml.in b/tests/data/config/oidc-forgerock.yaml.in new file mode 100644 index 000000000..a504b0dfe --- /dev/null +++ b/tests/data/config/oidc-forgerock.yaml.in @@ -0,0 +1,32 @@ +realm: "example.com" +loglevel: "DEBUG" +sessionSecretFile: "{session_secret_file}" +databaseUrl: "{database_url}" +redisUrl: "redis://localhost:6379/0" +initialAdmins: ["admin"] +afterLogoutUrl: "https://example.com/landing" +groupMapping: + "exec:admin": ["admin"] + "exec:test": ["test"] + "read:all": ["foo", "admin", "org-a-team"] +knownScopes: + "admin:token": "Can create and modify tokens for any user" + "exec:admin": "admin description" + "exec:test": "test description" + "read:all": "can read everything" + "user:token": "Can create and modify user tokens" +forgerock: + url: "https://forgerock.example.org/" + username: "forgerock-user" + passwordFile: "{forgerock_password_file}" +oidc: + clientId: "some-oidc-client-id" + clientSecretFile: "{oidc_secret_file}" + loginUrl: "https://upstream.example.com/oidc/login" + redirectUrl: "https://upstream.example.com/login" + tokenUrl: "https://upstream.example.com/token" + scopes: + - "email" + - "voPerson" + issuer: "https://upstream.example.com/" + audience: "https://test.example.com/" diff --git a/tests/handlers/login_oidc_test.py b/tests/handlers/login_oidc_test.py index 94054ca48..ec9c76e3c 100644 --- a/tests/handlers/login_oidc_test.py +++ b/tests/handlers/login_oidc_test.py @@ -17,6 +17,7 @@ from ..support.config import reconfigure from ..support.firestore import MockFirestore +from ..support.forgerock import mock_forgerock from ..support.jwt import create_upstream_oidc_jwt from ..support.logging import parse_log from ..support.oidc import mock_oidc_provider_token, simulate_oidc_login @@ -821,6 +822,33 @@ async def test_firestore( } +@pytest.mark.asyncio +async def test_forgerock( + tmp_path: Path, + factory: Factory, + client: AsyncClient, + respx_mock: respx.Router, +) -> None: + config = await reconfigure(tmp_path, "oidc-forgerock", factory) + assert config.oidc + token = create_upstream_oidc_jwt(groups=["admin", "foo"]) + mock_forgerock(config, respx_mock, {"foo": 6768}) + + r = await simulate_oidc_login(client, respx_mock, token) + assert r.status_code == 307 + + # Check the resulting GIDs. + username = token.claims[config.oidc.username_claim] + r = await client.get("/auth/api/v1/user-info") + assert r.status_code == 200 + assert r.json() == { + "username": username, + "email": token.claims["email"], + "uid": int(token.claims[config.oidc.uid_claim]), + "groups": [{"name": "admin"}, {"name": "foo", "id": 6768}], + } + + @pytest.mark.asyncio async def test_enrollment_url( tmp_path: Path, diff --git a/tests/support/config.py b/tests/support/config.py index e17e1a009..3dc2b3238 100644 --- a/tests/support/config.py +++ b/tests/support/config.py @@ -7,6 +7,7 @@ from typing import Optional from cryptography.fernet import Fernet +from safir.logging import Profile, configure_logging from gafaelfawr.config import Config, OIDCClient from gafaelfawr.dependencies.config import config_dependency @@ -113,6 +114,7 @@ def build_config( slack_webhook_file = store_secret( tmp_path, "slack-webhook", b"https://slack.example.com/webhook" ) + forgerock_password_file = store_secret(tmp_path, "forgerock", b"password") oidc_path = tmp_path / "oidc.json" if oidc_clients: @@ -133,6 +135,7 @@ def build_config( oidc_secret_file=oidc_secret_file, oidc_server_secrets_file=oidc_path if oidc_clients else "", slack_webhook_file=slack_webhook_file, + forgerock_password_file=forgerock_password_file, ) if settings: @@ -199,7 +202,17 @@ def configure( **settings, ) config_dependency.set_config_path(config_path) - return config_dependency.config() + config = config_dependency.config() + + # Pick up any change to the log level. + configure_logging( + profile=Profile.production, + log_level=config.loglevel, + name="gafaelfawr", + add_timestamp=True, + ) + + return config async def reconfigure( diff --git a/tests/support/forgerock.py b/tests/support/forgerock.py new file mode 100644 index 000000000..2fe38efcc --- /dev/null +++ b/tests/support/forgerock.py @@ -0,0 +1,87 @@ +"""ForgeRock Identity Management API mocks for testing.""" + +from __future__ import annotations + +import re +from base64 import b64encode +from urllib.parse import parse_qs + +import respx +from httpx import Request, Response + +from gafaelfawr.config import Config + +__all__ = ["MockForgeRock", "mock_forgerock"] + + +class MockForgeRock: + """Pretends to be the ForgeRock API for testing. + + The methods of this object should be installed as respx mock side effects + using `mock_forgerock`. + + Parameters + ---------- + username + Expected authentication username. + password + Expected authentication password. + groups + Mapping of group names to GIDs to return. If the group is unknown, + the mocked API will return an empty list. + + Attributes + ---------- + groups + Mapping of group names to GIDs to return. + """ + + def __init__( + self, username: str, password: str, groups: dict[str, int] + ) -> None: + self._username = username + self._password = password + self.groups = groups + + def get_gid(self, request: Request) -> Response: + basic_auth = b64encode(f"{self._username}:{self._password}".encode()) + auth_header = f"Basic {basic_auth.decode()}" + assert request.headers["Authorization"] == auth_header + + query = parse_qs(request.url.query) + assert set(query.keys()) == {b"_fields", b"_queryFilter"} + assert query[b"_fields"] == [b"gid"] + assert len(query[b"_queryFilter"]) == 1 + query_filter = query[b"_queryFilter"][0].decode() + match = re.match(r'name eq "([^"]+)"$', query_filter) + assert match + group = match.group(1) + + if group in self.groups: + response = {"result": [{"gid": self.groups[group]}]} + return Response(200, json=response) + else: + return Response(200, json={"result": []}) + + +def mock_forgerock( + config: Config, respx_mock: respx.Router, groups: dict[str, int] +) -> MockForgeRock: + """Set up the mocks for a ForgeRock GID lookup. + + Parameters + ---------- + config + Gafaelfawr configuration. + respx_mock + The mock router. + groups + Mapping of valid group names to GIDs. + """ + assert config.forgerock + url = config.forgerock.url.rstrip("/") + "/system/freeipa/group" + mock = MockForgeRock( + config.forgerock.username, config.forgerock.password, groups + ) + respx_mock.get(url__startswith=url).mock(side_effect=mock.get_gid) + return mock diff --git a/tests/support/github.py b/tests/support/github.py index 9e975e812..70ccf6bed 100644 --- a/tests/support/github.py +++ b/tests/support/github.py @@ -17,7 +17,7 @@ from gafaelfawr.models.github import GitHubUserInfo from gafaelfawr.providers.github import GitHubProvider -__all__ = ["mock_github"] +__all__ = ["MockGitHub", "mock_github"] class MockGitHub: From 0ce52d6a52088c430794954368293f376d409e6b Mon Sep 17 00:00:00 2001 From: Russ Allbery Date: Fri, 17 Mar 2023 11:44:17 -0700 Subject: [PATCH 3/5] Move logging setup to the config dependency The refactor to move logging setup out of the Config class meant that logging was only configured properly for the main application, not for any other function. Move logging setup to the config dependency and trigger it every time the configuration is reloaded, which preserves the previous behavior while still keeping logging setup out of the path of parsing and creating the configuration. --- src/gafaelfawr/config.py | 11 ++++++++++- src/gafaelfawr/dependencies/config.py | 2 ++ src/gafaelfawr/main.py | 27 ++++++++++----------------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/gafaelfawr/config.py b/src/gafaelfawr/config.py index 29fef86d1..93e3c3f05 100644 --- a/src/gafaelfawr/config.py +++ b/src/gafaelfawr/config.py @@ -21,7 +21,7 @@ import yaml from pydantic import AnyHttpUrl, IPvAnyNetwork, validator -from safir.logging import LogLevel +from safir.logging import LogLevel, Profile, configure_logging from safir.pydantic import CamelCaseModel, validate_exactly_one_of from .constants import SCOPE_REGEX, USERNAME_REGEX @@ -1024,6 +1024,15 @@ def from_file(cls, path: Path) -> Self: group_mapping=group_mapping_frozen, ) + def configure_logging(self) -> None: + """Configure logging based on the Gafaelfawr configuration.""" + configure_logging( + profile=Profile.production, + log_level=self.loglevel, + name="gafaelfawr", + add_timestamp=True, + ) + @staticmethod def _load_secret(path: Path) -> bytes: """Load a secret from a file.""" diff --git a/src/gafaelfawr/dependencies/config.py b/src/gafaelfawr/dependencies/config.py index 8252df21f..305aff41e 100644 --- a/src/gafaelfawr/dependencies/config.py +++ b/src/gafaelfawr/dependencies/config.py @@ -40,6 +40,7 @@ def config(self) -> Config: """ if not self._config: self._config = Config.from_file(self._config_path) + self._config.configure_logging() return self._config def set_config_path(self, path: Path) -> None: @@ -52,6 +53,7 @@ def set_config_path(self, path: Path) -> None: """ self._config_path = path self._config = Config.from_file(path) + self._config.configure_logging() config_dependency = ConfigDependency() diff --git a/src/gafaelfawr/main.py b/src/gafaelfawr/main.py index 57cdb8d15..a09d742cc 100644 --- a/src/gafaelfawr/main.py +++ b/src/gafaelfawr/main.py @@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles from safir.dependencies.db_session import db_session_dependency from safir.dependencies.http_client import http_client_dependency -from safir.logging import Profile, configure_logging, configure_uvicorn_logging +from safir.logging import configure_uvicorn_logging from safir.middleware.x_forwarded import XForwardedMiddleware from safir.models import ErrorModel from safir.slack.webhook import SlackRouteErrorHandler @@ -117,18 +117,24 @@ def create_app(*, load_config: bool = True) -> FastAPI: StaticFiles(directory=str(static_path), html=True, check_dir=False), ) + # Load configuration if it is available to us and configure Uvicorn + # logging. + config = None + if load_config: + config = config_dependency.config() + configure_uvicorn_logging() + # Install the middleware. app.add_middleware( StateMiddleware, cookie_name=COOKIE_NAME, state_class=State ) - if load_config: - config = config_dependency.config() + if config: app.add_middleware(XForwardedMiddleware, proxies=config.proxies) else: app.add_middleware(XForwardedMiddleware) # Configure Slack alerts. - if load_config and config.slack_webhook: + if config and config.slack_webhook: logger = structlog.get_logger("gafaelfawr") SlackRouteErrorHandler.initialize( config.slack_webhook, "Gafaelfawr", logger @@ -144,19 +150,6 @@ def create_app(*, load_config: bool = True) -> FastAPI: app.exception_handler(PermissionDeniedError)(permission_handler) app.exception_handler(ValidationError)(validation_handler) - # Configure logging. - if load_config: - configure_logging( - profile=Profile.production, - log_level=config.loglevel, - name="gafaelfawr", - add_timestamp=True, - ) - - # Customize uvicorn logging to use the same structlog configuration as - # main application logging. - configure_uvicorn_logging() - return app From ebb7c9aa707f7e4d1b9e1122ddc3f9c0d9d4775d Mon Sep 17 00:00:00 2001 From: Russ Allbery Date: Fri, 17 Mar 2023 12:05:33 -0700 Subject: [PATCH 4/5] Set release date for 9.1.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bea86847..f0248a38b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Versioning follows [semver](https://semver.org/). Versioning assumes that Gafael Dependencies are updated to the latest available version during each release. Those changes are not noted here explicitly. -## 9.1.0 (unreleased) +## 9.1.0 (2023-03-17) ### New features From 9d5a5f46a0f43b05e81a18b30d65ea86f96b7db9 Mon Sep 17 00:00:00 2001 From: Russ Allbery Date: Fri, 17 Mar 2023 12:30:20 -0700 Subject: [PATCH 5/5] Update Python dependencies --- requirements/dev.txt | 122 +++++++++++++++++++++--------------------- requirements/main.txt | 6 +-- 2 files changed, 64 insertions(+), 64 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 88ef7b852..a454e5129 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -352,58 +352,58 @@ contourpy==1.0.7 \ --hash=sha256:fc1464c97579da9f3ab16763c32e5c5d5bb5fa1ec7ce509a4ca6108b61b84fab \ --hash=sha256:fd7dc0e6812b799a34f6d12fcb1000539098c249c8da54f3566c6a6461d0dbad # via matplotlib -coverage[toml]==7.2.1 \ - --hash=sha256:0339dc3237c0d31c3b574f19c57985fcbe494280153bbcad33f2cdf469f4ac3e \ - --hash=sha256:09643fb0df8e29f7417adc3f40aaf379d071ee8f0350ab290517c7004f05360b \ - --hash=sha256:0bd7e628f6c3ec4e7d2d24ec0e50aae4e5ae95ea644e849d92ae4805650b4c4e \ - --hash=sha256:0cf557827be7eca1c38a2480484d706693e7bb1929e129785fe59ec155a59de6 \ - --hash=sha256:0f8318ed0f3c376cfad8d3520f496946977abde080439d6689d7799791457454 \ - --hash=sha256:1b7fb13850ecb29b62a447ac3516c777b0e7a09ecb0f4bb6718a8654c87dfc80 \ - --hash=sha256:22c308bc508372576ffa3d2dbc4824bb70d28eeb4fcd79d4d1aed663a06630d0 \ - --hash=sha256:3004765bca3acd9e015794e5c2f0c9a05587f5e698127ff95e9cfba0d3f29339 \ - --hash=sha256:3a209d512d157379cc9ab697cbdbb4cfd18daa3e7eebaa84c3d20b6af0037384 \ - --hash=sha256:436313d129db7cf5b4ac355dd2bd3f7c7e5294af077b090b85de75f8458b8616 \ - --hash=sha256:49567ec91fc5e0b15356da07a2feabb421d62f52a9fff4b1ec40e9e19772f5f8 \ - --hash=sha256:4dd34a935de268a133e4741827ae951283a28c0125ddcdbcbba41c4b98f2dfef \ - --hash=sha256:570c21a29493b350f591a4b04c158ce1601e8d18bdcd21db136fbb135d75efa6 \ - --hash=sha256:5928b85416a388dd557ddc006425b0c37e8468bd1c3dc118c1a3de42f59e2a54 \ - --hash=sha256:5d2b9b5e70a21474c105a133ba227c61bc95f2ac3b66861143ce39a5ea4b3f84 \ - --hash=sha256:617a94ada56bbfe547aa8d1b1a2b8299e2ec1ba14aac1d4b26a9f7d6158e1273 \ - --hash=sha256:6a034480e9ebd4e83d1aa0453fd78986414b5d237aea89a8fdc35d330aa13bae \ - --hash=sha256:6fce673f79a0e017a4dc35e18dc7bb90bf6d307c67a11ad5e61ca8d42b87cbff \ - --hash=sha256:78d2c3dde4c0b9be4b02067185136b7ee4681978228ad5ec1278fa74f5ca3e99 \ - --hash=sha256:7f099da6958ddfa2ed84bddea7515cb248583292e16bb9231d151cd528eab657 \ - --hash=sha256:80559eaf6c15ce3da10edb7977a1548b393db36cbc6cf417633eca05d84dd1ed \ - --hash=sha256:834c2172edff5a08d78e2f53cf5e7164aacabeb66b369f76e7bb367ca4e2d993 \ - --hash=sha256:861cc85dfbf55a7a768443d90a07e0ac5207704a9f97a8eb753292a7fcbdfcfc \ - --hash=sha256:8649371570551d2fd7dee22cfbf0b61f1747cdfb2b7587bb551e4beaaa44cb97 \ - --hash=sha256:87dc37f16fb5e3a28429e094145bf7c1753e32bb50f662722e378c5851f7fdc6 \ - --hash=sha256:8a6450da4c7afc4534305b2b7d8650131e130610cea448ff240b6ab73d7eab63 \ - --hash=sha256:8d3843ca645f62c426c3d272902b9de90558e9886f15ddf5efe757b12dd376f5 \ - --hash=sha256:8dca3c1706670297851bca1acff9618455122246bdae623be31eca744ade05ec \ - --hash=sha256:97a3189e019d27e914ecf5c5247ea9f13261d22c3bb0cfcfd2a9b179bb36f8b1 \ - --hash=sha256:99f4dd81b2bb8fc67c3da68b1f5ee1650aca06faa585cbc6818dbf67893c6d58 \ - --hash=sha256:9e872b082b32065ac2834149dc0adc2a2e6d8203080501e1e3c3c77851b466f9 \ - --hash=sha256:a81dbcf6c6c877986083d00b834ac1e84b375220207a059ad45d12f6e518a4e3 \ - --hash=sha256:abacd0a738e71b20e224861bc87e819ef46fedba2fb01bc1af83dfd122e9c319 \ - --hash=sha256:ae82c988954722fa07ec5045c57b6d55bc1a0890defb57cf4a712ced65b26ddd \ - --hash=sha256:b0c0d46de5dd97f6c2d1b560bf0fcf0215658097b604f1840365296302a9d1fb \ - --hash=sha256:b1991a6d64231a3e5bbe3099fb0dd7c9aeaa4275ad0e0aeff4cb9ef885c62ba2 \ - --hash=sha256:b2167d116309f564af56f9aa5e75ef710ef871c5f9b313a83050035097b56820 \ - --hash=sha256:bd5a12239c0006252244f94863f1c518ac256160cd316ea5c47fb1a11b25889a \ - --hash=sha256:bdd3f2f285ddcf2e75174248b2406189261a79e7fedee2ceeadc76219b6faa0e \ - --hash=sha256:c77f2a9093ccf329dd523a9b2b3c854c20d2a3d968b6def3b820272ca6732242 \ - --hash=sha256:cb5f152fb14857cbe7f3e8c9a5d98979c4c66319a33cad6e617f0067c9accdc4 \ - --hash=sha256:cca7c0b7f5881dfe0291ef09ba7bb1582cb92ab0aeffd8afb00c700bf692415a \ - --hash=sha256:d2ef6cae70168815ed91388948b5f4fcc69681480a0061114db737f957719f03 \ - --hash=sha256:d9256d4c60c4bbfec92721b51579c50f9e5062c21c12bec56b55292464873508 \ - --hash=sha256:e191a63a05851f8bce77bc875e75457f9b01d42843f8bd7feed2fc26bbe60833 \ - --hash=sha256:e2b50ebc2b6121edf352336d503357321b9d8738bb7a72d06fc56153fd3f4cd8 \ - --hash=sha256:e3ea04b23b114572b98a88c85379e9e9ae031272ba1fb9b532aa934c621626d4 \ - --hash=sha256:e4d70c853f0546855f027890b77854508bdb4d6a81242a9d804482e667fff6e6 \ - --hash=sha256:f29351393eb05e6326f044a7b45ed8e38cb4dcc38570d12791f271399dc41431 \ - --hash=sha256:f3d07edb912a978915576a776756069dede66d012baa503022d3a0adba1b6afa \ - --hash=sha256:fac6343bae03b176e9b58104a9810df3cdccd5cfed19f99adfa807ffbf43cf9b +coverage[toml]==7.2.2 \ + --hash=sha256:006ed5582e9cbc8115d2e22d6d2144a0725db542f654d9d4fda86793832f873d \ + --hash=sha256:046936ab032a2810dcaafd39cc4ef6dd295df1a7cbead08fe996d4765fca9fe4 \ + --hash=sha256:0484d9dd1e6f481b24070c87561c8d7151bdd8b044c93ac99faafd01f695c78e \ + --hash=sha256:0ce383d5f56d0729d2dd40e53fe3afeb8f2237244b0975e1427bfb2cf0d32bab \ + --hash=sha256:186e0fc9cf497365036d51d4d2ab76113fb74f729bd25da0975daab2e107fd90 \ + --hash=sha256:2199988e0bc8325d941b209f4fd1c6fa007024b1442c5576f1a32ca2e48941e6 \ + --hash=sha256:299bc75cb2a41e6741b5e470b8c9fb78d931edbd0cd009c58e5c84de57c06731 \ + --hash=sha256:3668291b50b69a0c1ef9f462c7df2c235da3c4073f49543b01e7eb1dee7dd540 \ + --hash=sha256:36dd42da34fe94ed98c39887b86db9d06777b1c8f860520e21126a75507024f2 \ + --hash=sha256:38004671848b5745bb05d4d621526fca30cee164db42a1f185615f39dc997292 \ + --hash=sha256:387fb46cb8e53ba7304d80aadca5dca84a2fbf6fe3faf6951d8cf2d46485d1e5 \ + --hash=sha256:3eb55b7b26389dd4f8ae911ba9bc8c027411163839dea4c8b8be54c4ee9ae10b \ + --hash=sha256:420f94a35e3e00a2b43ad5740f935358e24478354ce41c99407cddd283be00d2 \ + --hash=sha256:4ac0f522c3b6109c4b764ffec71bf04ebc0523e926ca7cbe6c5ac88f84faced0 \ + --hash=sha256:4c752d5264053a7cf2fe81c9e14f8a4fb261370a7bb344c2a011836a96fb3f57 \ + --hash=sha256:4f01911c010122f49a3e9bdc730eccc66f9b72bd410a3a9d3cb8448bb50d65d3 \ + --hash=sha256:4f68ee32d7c4164f1e2c8797535a6d0a3733355f5861e0f667e37df2d4b07140 \ + --hash=sha256:4fa54fb483decc45f94011898727802309a109d89446a3c76387d016057d2c84 \ + --hash=sha256:507e4720791977934bba016101579b8c500fb21c5fa3cd4cf256477331ddd988 \ + --hash=sha256:53d0fd4c17175aded9c633e319360d41a1f3c6e352ba94edcb0fa5167e2bad67 \ + --hash=sha256:55272f33da9a5d7cccd3774aeca7a01e500a614eaea2a77091e9be000ecd401d \ + --hash=sha256:5764e1f7471cb8f64b8cda0554f3d4c4085ae4b417bfeab236799863703e5de2 \ + --hash=sha256:57b77b9099f172804e695a40ebaa374f79e4fb8b92f3e167f66facbf92e8e7f5 \ + --hash=sha256:5afdad4cc4cc199fdf3e18088812edcf8f4c5a3c8e6cb69127513ad4cb7471a9 \ + --hash=sha256:5cc0783844c84af2522e3a99b9b761a979a3ef10fb87fc4048d1ee174e18a7d8 \ + --hash=sha256:5e1df45c23d4230e3d56d04414f9057eba501f78db60d4eeecfcb940501b08fd \ + --hash=sha256:6146910231ece63facfc5984234ad1b06a36cecc9fd0c028e59ac7c9b18c38c6 \ + --hash=sha256:797aad79e7b6182cb49c08cc5d2f7aa7b2128133b0926060d0a8889ac43843be \ + --hash=sha256:7c20b731211261dc9739bbe080c579a1835b0c2d9b274e5fcd903c3a7821cf88 \ + --hash=sha256:817295f06eacdc8623dc4df7d8b49cea65925030d4e1e2a7c7218380c0072c25 \ + --hash=sha256:81f63e0fb74effd5be736cfe07d710307cc0a3ccb8f4741f7f053c057615a137 \ + --hash=sha256:872d6ce1f5be73f05bea4df498c140b9e7ee5418bfa2cc8204e7f9b817caa968 \ + --hash=sha256:8c99cb7c26a3039a8a4ee3ca1efdde471e61b4837108847fb7d5be7789ed8fd9 \ + --hash=sha256:8dbe2647bf58d2c5a6c5bcc685f23b5f371909a5624e9f5cd51436d6a9f6c6ef \ + --hash=sha256:8efb48fa743d1c1a65ee8787b5b552681610f06c40a40b7ef94a5b517d885c54 \ + --hash=sha256:92ebc1619650409da324d001b3a36f14f63644c7f0a588e331f3b0f67491f512 \ + --hash=sha256:9d22e94e6dc86de981b1b684b342bec5e331401599ce652900ec59db52940005 \ + --hash=sha256:ba279aae162b20444881fc3ed4e4f934c1cf8620f3dab3b531480cf602c76b7f \ + --hash=sha256:bc4803779f0e4b06a2361f666e76f5c2e3715e8e379889d02251ec911befd149 \ + --hash=sha256:bfe7085783cda55e53510482fa7b5efc761fad1abe4d653b32710eb548ebdd2d \ + --hash=sha256:c448b5c9e3df5448a362208b8d4b9ed85305528313fca1b479f14f9fe0d873b8 \ + --hash=sha256:c90e73bdecb7b0d1cea65a08cb41e9d672ac6d7995603d6465ed4914b98b9ad7 \ + --hash=sha256:d2b96123a453a2d7f3995ddb9f28d01fd112319a7a4d5ca99796a7ff43f02af5 \ + --hash=sha256:d52f0a114b6a58305b11a5cdecd42b2e7f1ec77eb20e2b33969d702feafdd016 \ + --hash=sha256:d530191aa9c66ab4f190be8ac8cc7cfd8f4f3217da379606f3dd4e3d83feba69 \ + --hash=sha256:d683d230b5774816e7d784d7ed8444f2a40e7a450e5720d58af593cb0b94a212 \ + --hash=sha256:db45eec1dfccdadb179b0f9ca616872c6f700d23945ecc8f21bb105d74b1c5fc \ + --hash=sha256:db8c2c5ace167fd25ab5dd732714c51d4633f58bac21fb0ff63b0349f62755a8 \ + --hash=sha256:e2926b8abedf750c2ecf5035c07515770944acf02e1c46ab08f6348d24c5f94d \ + --hash=sha256:e627dee428a176ffb13697a2c4318d3f60b2ccdde3acdc9b3f304206ec130ccd \ + --hash=sha256:efe1c0adad110bf0ad7fb59f833880e489a61e39d699d37249bdf42f80590169 # via # -r requirements/dev.in # pytest-cov @@ -470,9 +470,9 @@ filelock==3.10.0 \ --hash=sha256:3199fd0d3faea8b911be52b663dfccceb84c95949dd13179aa21436d1a79c4ce \ --hash=sha256:e90b34656470756edf8b19656785c5fea73afa1953f3e1b0d645cef11cab3182 # via virtualenv -fonttools==4.39.0 \ - --hash=sha256:909c104558835eac27faeb56be5a4c32694192dca123d073bf746ce9254054af \ - --hash=sha256:f5e764e1fd6ad54dfc201ff32af0ba111bcfbe0d05b24540af74c63db4ed6390 +fonttools==4.39.2 \ + --hash=sha256:85245aa2fd4cf502a643c9a9a2b5a393703e150a6eaacc3e0e84bb448053f061 \ + --hash=sha256:e2d9f10337c9e3b17f9bce17a60a16a885a7d23b59b7f45ce07ea643e5580439 # via matplotlib gitdb==4.0.10 \ --hash=sha256:6eb990b69df4e15bad899ea868dc46572c3f75339735663b81de79b06f17eb9a \ @@ -587,9 +587,9 @@ hyperframe==6.0.1 \ # via # h2 # selenium-wire -identify==2.5.20 \ - --hash=sha256:5dfef8a745ca4f2c95f27e9db74cb4c8b6d9916383988e8791f3595868f78a33 \ - --hash=sha256:c8b288552bc5f05a08aff09af2f58e6976bf8ac87beb38498a0e3d98ba64eb18 +identify==2.5.21 \ + --hash=sha256:69edcaffa8e91ae0f77d397af60f148b6b45a8044b2cc6d99cafa5b04793ff00 \ + --hash=sha256:7671a05ef9cfaf8ff63b15d45a91a1147a03aaccb2976d4e9bd047cbbc508471 # via pre-commit idna==3.4 \ --hash=sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4 \ @@ -989,9 +989,9 @@ pluggy==1.0.0 \ --hash=sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159 \ --hash=sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3 # via pytest -pre-commit==3.1.1 \ - --hash=sha256:b80254e60668e1dd1f5c03a1c9e0413941d61f568a57d745add265945f65bfe8 \ - --hash=sha256:d63e6537f9252d99f65755ae5b79c989b462d511ebbc481b561db6a297e1e865 +pre-commit==3.2.0 \ + --hash=sha256:818f0d998059934d0f81bb3667e3ccdc32da6ed7ccaac33e43dc231561ddaaa9 \ + --hash=sha256:f712d3688102e13c8e66b7d7dbd8934a6dda157e58635d89f7d6fecdca39ce8a # via -r requirements/dev.in pyasn1==0.4.8 \ --hash=sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d \ diff --git a/requirements/main.txt b/requirements/main.txt index 84ddcfdac..eedf58b59 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -1057,9 +1057,9 @@ urllib3==1.26.15 \ # via # kubernetes-asyncio # requests -uvicorn[standard]==0.21.0 \ - --hash=sha256:8635a388062222082f4b06225b867b74a7e4ef942124453d4d1d1a5cb3750932 \ - --hash=sha256:e69e955cb621ae7b75f5590a814a4fcbfb14cb8f44a36dfe3c5c75ab8aee3ad5 +uvicorn[standard]==0.21.1 \ + --hash=sha256:0fac9cb342ba099e0d582966005f3fdba5b0290579fed4a6266dc702ca7bb032 \ + --hash=sha256:e47cac98a6da10cd41e6fd036d472c6f58ede6c5dbee3dbee3ef7a100ed97742 # via -r requirements/main.in uvloop==0.17.0 \ --hash=sha256:0949caf774b9fcefc7c5756bacbbbd3fc4c05a6b7eebc7c7ad6f825b23998d6d \