diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 1080b5fd..8d7cc6fe 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -10,3 +10,5 @@ jobs: name: PR uses: canonical/observability/.github/workflows/charm-pull-request.yaml@main secrets: inherit + with: + ip-range: 10.64.140.43/30 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7b491387..b5add285 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,3 +9,5 @@ jobs: release: uses: canonical/observability/.github/workflows/charm-release.yaml@main secrets: inherit + with: + ip-range: 10.64.140.43/30 \ No newline at end of file diff --git a/lib/charms/hydra/v0/oauth.py b/lib/charms/hydra/v0/oauth.py new file mode 100644 index 00000000..6d8ed1ef --- /dev/null +++ b/lib/charms/hydra/v0/oauth.py @@ -0,0 +1,767 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""# Oauth Library. + +This library is designed to enable applications to register OAuth2/OIDC +clients with an OIDC Provider through the `oauth` interface. + +## Getting started + +To get started using this library you just need to fetch the library using `charmcraft`. **Note +that you also need to add `jsonschema` to your charm's `requirements.txt`.** + +```shell +cd some-charm +charmcraft fetch-lib charms.hydra.v0.oauth +EOF +``` + +Then, to initialize the library: +```python +# ... +from charms.hydra.v0.oauth import ClientConfig, OAuthRequirer + +OAUTH = "oauth" +OAUTH_SCOPES = "openid email" +OAUTH_GRANT_TYPES = ["authorization_code"] + +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + self.oauth = OAuthRequirer(self, client_config, relation_name=OAUTH) + + self.framework.observe(self.oauth.on.oauth_info_changed, self._configure_application) + # ... + + def _on_ingress_ready(self, event): + self.external_url = "https://example.com" + self._set_client_config() + + def _set_client_config(self): + client_config = ClientConfig( + urljoin(self.external_url, "/oauth/callback"), + OAUTH_SCOPES, + OAUTH_GRANT_TYPES, + ) + self.oauth.update_client_config(client_config) +``` +""" + +import inspect +import json +import logging +import re +from dataclasses import asdict, dataclass, field +from typing import Dict, List, Mapping, Optional + +import jsonschema +from ops.charm import ( + CharmBase, + RelationBrokenEvent, + RelationChangedEvent, + RelationCreatedEvent, + RelationDepartedEvent, +) +from ops.framework import EventBase, EventSource, Handle, Object, ObjectEvents +from ops.model import Relation, Secret, TooManyRelatedAppsError + +# The unique Charmhub library identifier, never change it +LIBID = "a3a301e325e34aac80a2d633ef61fe97" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 5 + +logger = logging.getLogger(__name__) + +DEFAULT_RELATION_NAME = "oauth" +ALLOWED_GRANT_TYPES = ["authorization_code", "refresh_token", "client_credentials"] +ALLOWED_CLIENT_AUTHN_METHODS = ["client_secret_basic", "client_secret_post"] +CLIENT_SECRET_FIELD = "secret" + +url_regex = re.compile( + r"(^http://)|(^https://)" # http:// or https:// + r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|" + r"[A-Z0-9-]{2,}\.?)|" # domain... + r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" # ...or ip + r"(?::\d+)?" # optional port + r"(?:/?|[/?]\S+)$", + re.IGNORECASE, +) + +OAUTH_PROVIDER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://canonical.github.io/charm-relation-interfaces/interfaces/oauth/schemas/provider.json", + "type": "object", + "properties": { + "issuer_url": { + "type": "string", + }, + "authorization_endpoint": { + "type": "string", + }, + "token_endpoint": { + "type": "string", + }, + "introspection_endpoint": { + "type": "string", + }, + "userinfo_endpoint": { + "type": "string", + }, + "jwks_endpoint": { + "type": "string", + }, + "scope": { + "type": "string", + }, + "client_id": { + "type": "string", + }, + "client_secret_id": { + "type": "string", + }, + "groups": {"type": "string", "default": None}, + "ca_chain": {"type": "array", "items": {"type": "string"}, "default": []}, + }, + "required": [ + "issuer_url", + "authorization_endpoint", + "token_endpoint", + "introspection_endpoint", + "userinfo_endpoint", + "jwks_endpoint", + "scope", + ], +} +OAUTH_REQUIRER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://canonical.github.io/charm-relation-interfaces/interfaces/oauth/schemas/requirer.json", + "type": "object", + "properties": { + "redirect_uri": { + "type": "string", + "default": None, + }, + "audience": {"type": "array", "default": [], "items": {"type": "string"}}, + "scope": {"type": "string", "default": None}, + "grant_types": { + "type": "array", + "default": None, + "items": { + "enum": ["authorization_code", "client_credentials", "refresh_token"], + "type": "string", + }, + }, + "token_endpoint_auth_method": { + "type": "string", + "enum": ["client_secret_basic", "client_secret_post"], + "default": "client_secret_basic", + }, + }, + "required": ["redirect_uri", "audience", "scope", "grant_types", "token_endpoint_auth_method"], +} + + +class ClientConfigError(Exception): + """Emitted when invalid client config is provided.""" + + +class DataValidationError(RuntimeError): + """Raised when data validation fails on relation data.""" + + +def _load_data(data: Mapping, schema: Optional[Dict] = None) -> Dict: + """Parses nested fields and checks whether `data` matches `schema`.""" + ret = {} + for k, v in data.items(): + try: + ret[k] = json.loads(v) + except json.JSONDecodeError: + ret[k] = v + + if schema: + _validate_data(ret, schema) + return ret + + +def _dump_data(data: Dict, schema: Optional[Dict] = None) -> Dict: + if schema: + _validate_data(data, schema) + + ret = {} + for k, v in data.items(): + if isinstance(v, (list, dict)): + try: + ret[k] = json.dumps(v) + except json.JSONDecodeError as e: + raise DataValidationError(f"Failed to encode relation json: {e}") + else: + ret[k] = v + return ret + + +class OAuthRelation(Object): + """A class containing helper methods for oauth relation.""" + + def _pop_relation_data(self, relation_id: Relation) -> None: + if not self.model.unit.is_leader(): + return + + if len(self.model.relations) == 0: + return + + relation = self.model.get_relation(self._relation_name, relation_id=relation_id) + if not relation or not relation.app: + return + + try: + for data in list(relation.data[self.model.app]): + relation.data[self.model.app].pop(data, "") + except Exception as e: + logger.info(f"Failed to pop the relation data: {e}") + + +def _validate_data(data: Dict, schema: Dict) -> None: + """Checks whether `data` matches `schema`. + + Will raise DataValidationError if the data is not valid, else return None. + """ + try: + jsonschema.validate(instance=data, schema=schema) + except jsonschema.ValidationError as e: + raise DataValidationError(data, schema) from e + + +@dataclass +class ClientConfig: + """Helper class containing a client's configuration.""" + + redirect_uri: str + scope: str + grant_types: List[str] + audience: List[str] = field(default_factory=lambda: []) + token_endpoint_auth_method: str = "client_secret_basic" + client_id: Optional[str] = None + + def validate(self) -> None: + """Validate the client configuration.""" + # Validate redirect_uri + if not re.match(url_regex, self.redirect_uri): + raise ClientConfigError(f"Invalid URL {self.redirect_uri}") + + if self.redirect_uri.startswith("http://"): + logger.warning("Provided Redirect URL uses http scheme. Don't do this in production") + + # Validate grant_types + for grant_type in self.grant_types: + if grant_type not in ALLOWED_GRANT_TYPES: + raise ClientConfigError( + f"Invalid grant_type {grant_type}, must be one " f"of {ALLOWED_GRANT_TYPES}" + ) + + # Validate client authentication methods + if self.token_endpoint_auth_method not in ALLOWED_CLIENT_AUTHN_METHODS: + raise ClientConfigError( + f"Invalid client auth method {self.token_endpoint_auth_method}, " + f"must be one of {ALLOWED_CLIENT_AUTHN_METHODS}" + ) + + def to_dict(self) -> Dict: + """Convert object to dict.""" + return {k: v for k, v in asdict(self).items() if v is not None} + + +@dataclass +class OauthProviderConfig: + """Helper class containing provider's configuration.""" + + issuer_url: str + authorization_endpoint: str + token_endpoint: str + introspection_endpoint: str + userinfo_endpoint: str + jwks_endpoint: str + scope: str + client_id: Optional[str] = None + client_secret: Optional[str] = None + groups: Optional[str] = None + ca_chain: Optional[str] = None + + @classmethod + def from_dict(cls, dic: Dict) -> "OauthProviderConfig": + """Generate OauthProviderConfig instance from dict.""" + return cls(**{k: v for k, v in dic.items() if k in inspect.signature(cls).parameters}) + + +class OAuthInfoChangedEvent(EventBase): + """Event to notify the charm that the information in the databag changed.""" + + def __init__(self, handle: Handle, client_id: str, client_secret_id: str): + super().__init__(handle) + self.client_id = client_id + self.client_secret_id = client_secret_id + + def snapshot(self) -> Dict: + """Save event.""" + return { + "client_id": self.client_id, + "client_secret_id": self.client_secret_id, + } + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + self.client_id = snapshot["client_id"] + self.client_secret_id = snapshot["client_secret_id"] + + +class InvalidClientConfigEvent(EventBase): + """Event to notify the charm that the client configuration is invalid.""" + + def __init__(self, handle: Handle, error: str): + super().__init__(handle) + self.error = error + + def snapshot(self) -> Dict: + """Save event.""" + return { + "error": self.error, + } + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + self.error = snapshot["error"] + + +class OAuthInfoRemovedEvent(EventBase): + """Event to notify the charm that the provider data was removed.""" + + def snapshot(self) -> Dict: + """Save event.""" + return {} + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + pass + + +class OAuthRequirerEvents(ObjectEvents): + """Event descriptor for events raised by `OAuthRequirerEvents`.""" + + oauth_info_changed = EventSource(OAuthInfoChangedEvent) + oauth_info_removed = EventSource(OAuthInfoRemovedEvent) + invalid_client_config = EventSource(InvalidClientConfigEvent) + + +class OAuthRequirer(OAuthRelation): + """Register an oauth client.""" + + on = OAuthRequirerEvents() + + def __init__( + self, + charm: CharmBase, + client_config: Optional[ClientConfig] = None, + relation_name: str = DEFAULT_RELATION_NAME, + ) -> None: + super().__init__(charm, relation_name) + self._charm = charm + self._relation_name = relation_name + self._client_config = client_config + events = self._charm.on[relation_name] + self.framework.observe(events.relation_created, self._on_relation_created_event) + self.framework.observe(events.relation_changed, self._on_relation_changed_event) + self.framework.observe(events.relation_broken, self._on_relation_broken_event) + + def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: + try: + self._update_relation_data(self._client_config, event.relation.id) + except ClientConfigError as e: + self.on.invalid_client_config.emit(e.args[0]) + + def _on_relation_broken_event(self, event: RelationBrokenEvent) -> None: + # Workaround for https://github.com/canonical/operator/issues/888 + self._pop_relation_data(event.relation.id) + if self.is_client_created(): + event.defer() + logger.info("Relation data still available. Deferring the event") + return + + # Notify the requirer that the relation data was removed + self.on.oauth_info_removed.emit() + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + if not self.model.unit.is_leader(): + return + + data = event.relation.data[event.app] + if not data: + logger.info("No relation data available.") + return + + data = _load_data(data, OAUTH_PROVIDER_JSON_SCHEMA) + + client_id = data.get("client_id") + client_secret_id = data.get("client_secret_id") + if not client_id or not client_secret_id: + logger.info("OAuth Provider info is available, waiting for client to be registered.") + # The client credentials are not ready yet, so we do nothing + # This could mean that the client credentials were removed from the databag, + # but we don't allow that (for now), so we don't have to check for it. + return + + self.on.oauth_info_changed.emit(client_id, client_secret_id) + + def _update_relation_data( + self, client_config: Optional[ClientConfig], relation_id: Optional[int] = None + ) -> None: + if not self.model.unit.is_leader() or not client_config: + return + + if not isinstance(client_config, ClientConfig): + raise ValueError(f"Unexpected client_config type: {type(client_config)}") + + client_config.validate() + + try: + relation = self.model.get_relation( + relation_name=self._relation_name, relation_id=relation_id + ) + except TooManyRelatedAppsError: + raise RuntimeError("More than one relations are defined. Please provide a relation_id") + + if not relation or not relation.app: + return + + data = _dump_data(client_config.to_dict(), OAUTH_REQUIRER_JSON_SCHEMA) + relation.data[self.model.app].update(data) + + def is_client_created(self, relation_id: Optional[int] = None) -> bool: + """Check if the client has been created.""" + if len(self.model.relations) == 0: + return None + try: + relation = self.model.get_relation(self._relation_name, relation_id=relation_id) + except TooManyRelatedAppsError: + raise RuntimeError("More than one relations are defined. Please provide a relation_id") + + if not relation or not relation.app: + return None + + return ( + "client_id" in relation.data[relation.app] + and "client_secret_id" in relation.data[relation.app] + ) + + def get_provider_info(self, relation_id: Optional[int] = None) -> OauthProviderConfig: + """Get the provider information from the databag.""" + if len(self.model.relations) == 0: + return None + try: + relation = self.model.get_relation(self._relation_name, relation_id=relation_id) + except TooManyRelatedAppsError: + raise RuntimeError("More than one relations are defined. Please provide a relation_id") + if not relation or not relation.app: + return None + + data = relation.data[relation.app] + if not data: + logger.info("No relation data available.") + return + + data = _load_data(data, OAUTH_PROVIDER_JSON_SCHEMA) + + client_secret_id = data.get("client_secret_id") + if client_secret_id: + _client_secret = self.get_client_secret(client_secret_id) + client_secret = _client_secret.get_content()[CLIENT_SECRET_FIELD] + data["client_secret"] = client_secret + + oauth_provider = OauthProviderConfig.from_dict(data) + return oauth_provider + + def get_client_secret(self, client_secret_id: str) -> Secret: + """Get the client_secret.""" + client_secret = self.model.get_secret(id=client_secret_id) + return client_secret + + def update_client_config( + self, client_config: ClientConfig, relation_id: Optional[int] = None + ) -> None: + """Update the client config stored in the object.""" + self._client_config = client_config + self._update_relation_data(client_config, relation_id=relation_id) + + +class ClientCreatedEvent(EventBase): + """Event to notify the Provider charm to create a new client.""" + + def __init__( + self, + handle: Handle, + redirect_uri: str, + scope: str, + grant_types: List[str], + audience: List, + token_endpoint_auth_method: str, + relation_id: int, + ) -> None: + super().__init__(handle) + self.redirect_uri = redirect_uri + self.scope = scope + self.grant_types = grant_types + self.audience = audience + self.token_endpoint_auth_method = token_endpoint_auth_method + self.relation_id = relation_id + + def snapshot(self) -> Dict: + """Save event.""" + return { + "redirect_uri": self.redirect_uri, + "scope": self.scope, + "grant_types": self.grant_types, + "audience": self.audience, + "token_endpoint_auth_method": self.token_endpoint_auth_method, + "relation_id": self.relation_id, + } + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + self.redirect_uri = snapshot["redirect_uri"] + self.scope = snapshot["scope"] + self.grant_types = snapshot["grant_types"] + self.audience = snapshot["audience"] + self.token_endpoint_auth_method = snapshot["token_endpoint_auth_method"] + self.relation_id = snapshot["relation_id"] + + def to_client_config(self) -> ClientConfig: + """Convert the event information to a ClientConfig object.""" + return ClientConfig( + self.redirect_uri, + self.scope, + self.grant_types, + self.audience, + self.token_endpoint_auth_method, + ) + + +class ClientChangedEvent(EventBase): + """Event to notify the Provider charm that the client config changed.""" + + def __init__( + self, + handle: Handle, + redirect_uri: str, + scope: str, + grant_types: List, + audience: List, + token_endpoint_auth_method: str, + relation_id: int, + client_id: str, + ) -> None: + super().__init__(handle) + self.redirect_uri = redirect_uri + self.scope = scope + self.grant_types = grant_types + self.audience = audience + self.token_endpoint_auth_method = token_endpoint_auth_method + self.relation_id = relation_id + self.client_id = client_id + + def snapshot(self) -> Dict: + """Save event.""" + return { + "redirect_uri": self.redirect_uri, + "scope": self.scope, + "grant_types": self.grant_types, + "audience": self.audience, + "token_endpoint_auth_method": self.token_endpoint_auth_method, + "relation_id": self.relation_id, + "client_id": self.client_id, + } + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + self.redirect_uri = snapshot["redirect_uri"] + self.scope = snapshot["scope"] + self.grant_types = snapshot["grant_types"] + self.audience = snapshot["audience"] + self.token_endpoint_auth_method = snapshot["token_endpoint_auth_method"] + self.relation_id = snapshot["relation_id"] + self.client_id = snapshot["client_id"] + + def to_client_config(self) -> ClientConfig: + """Convert the event information to a ClientConfig object.""" + return ClientConfig( + self.redirect_uri, + self.scope, + self.grant_types, + self.audience, + self.token_endpoint_auth_method, + self.client_id, + ) + + +class ClientDeletedEvent(EventBase): + """Event to notify the Provider charm that the client was deleted.""" + + def __init__( + self, + handle: Handle, + relation_id: int, + ) -> None: + super().__init__(handle) + self.relation_id = relation_id + + def snapshot(self) -> Dict: + """Save event.""" + return {"relation_id": self.relation_id} + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + self.relation_id = snapshot["relation_id"] + + +class OAuthProviderEvents(ObjectEvents): + """Event descriptor for events raised by `OAuthProviderEvents`.""" + + client_created = EventSource(ClientCreatedEvent) + client_changed = EventSource(ClientChangedEvent) + client_deleted = EventSource(ClientDeletedEvent) + + +class OAuthProvider(OAuthRelation): + """A provider object for OIDC Providers.""" + + on = OAuthProviderEvents() + + def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME) -> None: + super().__init__(charm, relation_name) + self._charm = charm + self._relation_name = relation_name + + events = self._charm.on[relation_name] + self.framework.observe( + events.relation_changed, + self._get_client_config_from_relation_data, + ) + self.framework.observe( + events.relation_departed, + self._on_relation_departed, + ) + + def _get_client_config_from_relation_data(self, event: RelationChangedEvent) -> None: + if not self.model.unit.is_leader(): + return + + data = event.relation.data[event.app] + if not data: + logger.info("No requirer relation data available.") + return + + client_data = _load_data(data, OAUTH_REQUIRER_JSON_SCHEMA) + redirect_uri = client_data.get("redirect_uri") + scope = client_data.get("scope") + grant_types = client_data.get("grant_types") + audience = client_data.get("audience") + token_endpoint_auth_method = client_data.get("token_endpoint_auth_method") + + data = event.relation.data[self._charm.app] + if not data: + logger.info("No provider relation data available.") + return + provider_data = _load_data(data, OAUTH_PROVIDER_JSON_SCHEMA) + client_id = provider_data.get("client_id") + + relation_id = event.relation.id + + if client_id: + # Modify an existing client + self.on.client_changed.emit( + redirect_uri, + scope, + grant_types, + audience, + token_endpoint_auth_method, + relation_id, + client_id, + ) + else: + # Create a new client + self.on.client_created.emit( + redirect_uri, scope, grant_types, audience, token_endpoint_auth_method, relation_id + ) + + def _get_secret_label(self, relation: Relation) -> str: + return f"client_secret_{relation.id}" + + def _on_relation_departed(self, event: RelationDepartedEvent) -> None: + # Workaround for https://github.com/canonical/operator/issues/888 + self._pop_relation_data(event.relation.id) + + self._delete_juju_secret(event.relation) + self.on.client_deleted.emit(event.relation.id) + + def _create_juju_secret(self, client_secret: str, relation: Relation) -> Secret: + """Create a juju secret and grant it to a relation.""" + secret = {CLIENT_SECRET_FIELD: client_secret} + juju_secret = self.model.app.add_secret(secret, label=self._get_secret_label(relation)) + juju_secret.grant(relation) + return juju_secret + + def _delete_juju_secret(self, relation: Relation) -> None: + secret = self.model.get_secret(label=self._get_secret_label(relation)) + secret.remove_all_revisions() + + def set_provider_info_in_relation_data( + self, + issuer_url: str, + authorization_endpoint: str, + token_endpoint: str, + introspection_endpoint: str, + userinfo_endpoint: str, + jwks_endpoint: str, + scope: str, + groups: Optional[str] = None, + ca_chain: Optional[str] = None, + ) -> None: + """Put the provider information in the databag.""" + if not self.model.unit.is_leader(): + return + + data = { + "issuer_url": issuer_url, + "authorization_endpoint": authorization_endpoint, + "token_endpoint": token_endpoint, + "introspection_endpoint": introspection_endpoint, + "userinfo_endpoint": userinfo_endpoint, + "jwks_endpoint": jwks_endpoint, + "scope": scope, + } + if groups: + data["groups"] = groups + if ca_chain: + data["ca_chain"] = ca_chain + + for relation in self.model.relations[self._relation_name]: + relation.data[self.model.app].update(_dump_data(data)) + + def set_client_credentials_in_relation_data( + self, relation_id: int, client_id: str, client_secret: str + ) -> None: + """Put the client credentials in the databag.""" + if not self.model.unit.is_leader(): + return + + relation = self.model.get_relation(self._relation_name, relation_id) + if not relation or not relation.app: + return + # TODO: What if we are refreshing the client_secret? We need to add a + # new revision for that + secret = self._create_juju_secret(client_secret, relation) + data = dict(client_id=client_id, client_secret_id=secret.id) + relation.data[self.model.app].update(_dump_data(data)) diff --git a/metadata.yaml b/metadata.yaml index 60048da4..08d94aac 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -59,6 +59,12 @@ requires: This relation can be used with a local CA to obtain the CA cert that was used to sign proxied endpoints. limit: 1 + oauth: + interface: oauth + limit: 1 + description: | + Receive oauth server's info and a set of client credentials. + This relation can be used to integrate grafana with an oAuth2/OIDC Provider. provides: metrics-endpoint: diff --git a/src/charm.py b/src/charm.py index 98eeb68a..815f586a 100755 --- a/src/charm.py +++ b/src/charm.py @@ -29,7 +29,7 @@ import time from io import StringIO from pathlib import Path -from typing import Any, Callable, Dict +from typing import Any, Callable, Dict, cast from urllib.parse import urlparse import subprocess @@ -42,6 +42,11 @@ GrafanaSourceEvents, SourceFieldsMissingError, ) +from charms.hydra.v0.oauth import ( + ClientConfig as OauthClientConfig, + OAuthInfoChangedEvent, + OAuthRequirer, +) from charms.observability_libs.v0.kubernetes_compute_resources_patch import ( K8sResourcePatchFailedEvent, KubernetesComputeResourcesPatch, @@ -110,6 +115,9 @@ TRUSTED_CA_TEMPLATE = string.Template( "/usr/local/share/ca-certificates/trusted-ca-cert-$rel_id-ca.crt" ) +# https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/generic-oauth +OAUTH_SCOPES = "openid email offline_access" +OAUTH_GRANT_TYPES = ["authorization_code", "refresh_token"] class GrafanaCharm(CharmBase): @@ -256,6 +264,16 @@ def __init__(self, *args): self.trusted_cert_transfer.on.certificate_removed, self._on_trusted_certificate_removed, # pyright: ignore ) + # oauth relation + self.oauth = OAuthRequirer(self, self._oauth_client_config) + + # oauth relation observations + self.framework.observe( + self.oauth.on.oauth_info_changed, self._on_oauth_info_changed # pyright: ignore + ) + self.framework.observe( + self.oauth.on.oauth_info_removed, self._on_oauth_info_changed # pyright: ignore + ) # self.catalog = CatalogueConsumer(charm=self, item=self._catalogue_item) @@ -456,6 +474,8 @@ def _configure(self, force_restart: bool = False) -> None: restart = True + self.oauth.update_client_config(client_config=self._oauth_client_config) + if self._check_datasource_provisioning(): # Non-leaders will get updates from litestream if self.unit.is_leader(): @@ -814,6 +834,10 @@ def restart_grafana(self) -> None: # now that this is done, build_layer should include cert and key into the config and we'll # be sure that the files are actually there before grafana is (re)started. + # If available, we collect all trusted certs from the receive-ca-cert relation + # we do this here, downstream from a container readiness check + self._update_trusted_ca_certs() + layer = self._build_layer() try: self.containers["workload"].add_layer(self.name, layer, combine=True) @@ -832,10 +856,6 @@ def restart_grafana(self) -> None: make_dirs=True, ) - # If available, we collect all trusted certs from the receive-ca-cert relation - # we do this here, downstream from a container readiness check - self._update_trusted_ca_certs() - pragma = self.containers["workload"].exec( [ "/usr/local/bin/sqlite3", @@ -926,6 +946,27 @@ def _build_layer(self) -> Layer: } ) + if self.oauth.is_client_created(): + oauth_provider_info = self.oauth.get_provider_info() + + extra_info.update( + { + "GF_AUTH_GENERIC_OAUTH_ENABLED": "True", + "GF_AUTH_GENERIC_OAUTH_NAME": "external identity provider", + "GF_AUTH_GENERIC_OAUTH_CLIENT_ID": cast(str, oauth_provider_info.client_id), + "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": cast( + str, oauth_provider_info.client_secret + ), + "GF_AUTH_GENERIC_OAUTH_SCOPES": OAUTH_SCOPES, + "GF_AUTH_GENERIC_OAUTH_AUTH_URL": oauth_provider_info.authorization_endpoint, + "GF_AUTH_GENERIC_OAUTH_TOKEN_URL": oauth_provider_info.token_endpoint, + "GF_AUTH_GENERIC_OAUTH_API_URL": oauth_provider_info.userinfo_endpoint, + "GF_AUTH_GENERIC_OAUTH_USE_REFRESH_TOKEN": "True", + # TODO: This toggle will be removed on grafana v10.3, remove it + "GF_FEATURE_TOGGLES_ENABLE": "accessTokenExpirationCheck", + } + ) + layer = Layer( { "summary": "grafana-k8s layer", @@ -1396,9 +1437,20 @@ def _on_trusted_certificate_removed(self, event: CertificateRemovedEvent): container = self.containers["workload"] cert_path = TRUSTED_CA_TEMPLATE.substitute(rel_id=event.relation_id) container.remove_path(cert_path, recursive=True) - self.restart_grafana() + @property + def _oauth_client_config(self) -> OauthClientConfig: + return OauthClientConfig( + os.path.join(self.external_url, "login/generic_oauth"), + OAUTH_SCOPES, + OAUTH_GRANT_TYPES, + ) + + def _on_oauth_info_changed(self, event: OAuthInfoChangedEvent) -> None: + """Event handler for the oauth_info_changed event.""" + self._configure() + if __name__ == "__main__": main(GrafanaCharm, use_juju_for_storage=True) diff --git a/tests/integration/test_external_url.py b/tests/integration/test_external_url.py index beb3d846..9c428bf0 100644 --- a/tests/integration/test_external_url.py +++ b/tests/integration/test_external_url.py @@ -41,12 +41,12 @@ async def test_deploy(ops_test, grafana_charm): await asyncio.gather( ops_test.model.wait_for_idle( apps=[grafana_app_name], - wait_for_units=2, + wait_for_at_least_units=2, timeout=600, ), ops_test.model.wait_for_idle( apps=["traefik"], - wait_for_units=1, + wait_for_at_least_units=1, timeout=600, ), ) diff --git a/tests/integration/test_grafana_auth.py b/tests/integration/test_grafana_auth.py index bd17b801..fab8ec63 100644 --- a/tests/integration/test_grafana_auth.py +++ b/tests/integration/test_grafana_auth.py @@ -43,7 +43,16 @@ async def test_auth_proxy_is_set(ops_test, grafana_charm, grafana_tester_charm): ), ) await ops_test.model.wait_for_idle( - apps=[grafana_app_name, tester_app_name], status="active", wait_for_units=1, timeout=300 + apps=[grafana_app_name], + status="active", + wait_for_at_least_units=1, + timeout=300, + ) + await ops_test.model.wait_for_idle( + apps=[tester_app_name], + status="active", + wait_for_at_least_units=1, + timeout=300, ) await check_grafana_is_ready(ops_test, grafana_app_name, 0) await ops_test.model.add_relation( diff --git a/tests/integration/test_grafana_dashboard.py b/tests/integration/test_grafana_dashboard.py index 950f6726..b6780697 100644 --- a/tests/integration/test_grafana_dashboard.py +++ b/tests/integration/test_grafana_dashboard.py @@ -42,7 +42,16 @@ async def test_deploy(ops_test, grafana_charm, grafana_tester_charm): ), ) await ops_test.model.wait_for_idle( - apps=[grafana_app_name, tester_app_name], status="active", wait_for_units=1, timeout=300 + apps=[grafana_app_name], + status="active", + wait_for_at_least_units=1, + timeout=300, + ) + await ops_test.model.wait_for_idle( + apps=[tester_app_name], + status="active", + wait_for_at_least_units=1, + timeout=300, ) await check_grafana_is_ready(ops_test, grafana_app_name, 0) diff --git a/tests/integration/test_grafana_oauth.py b/tests/integration/test_grafana_oauth.py new file mode 100644 index 00000000..4a0c4d77 --- /dev/null +++ b/tests/integration/test_grafana_oauth.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Tests the oauth library using the Canonical Identity Stack. + +It tests that the grafana charm can provide Single Sign-On Services to users +with the oauth integration. +""" + +import logging +from helpers import oci_image + +import os +import pytest +import requests +from playwright.async_api._generated import Page, BrowserContext +from pytest_operator.plugin import OpsTest + +from oauth_tools.dex import ExternalIdpManager +from oauth_tools.oauth_test_helper import ( + deploy_identity_bundle, + get_reverse_proxy_app_url, + complete_external_idp_login, + access_application_login_page, + click_on_sign_in_button_by_text, + verify_page_loads, + get_cookie_from_browser_by_name, +) +from oauth_tools.conftest import * # noqa +from oauth_tools.constants import EXTERNAL_USER_EMAIL, APPS + +logger = logging.getLogger(__name__) + +tester_resources = { + "grafana-tester-image": oci_image( + "./tests/integration/grafana-tester/metadata.yaml", "grafana-tester-image" + ) +} +grafana_resources = { + "grafana-image": oci_image("./metadata.yaml", "grafana-image"), + "litestream-image": oci_image("./metadata.yaml", "litestream-image"), +} + + +@pytest.mark.skip_if_deployed +@pytest.mark.abort_on_fail +async def test_build_and_deploy(ops_test: OpsTest, grafana_charm): + # Instantiating the ExternalIdpManager object deploys the external identity provider. + external_idp_manager = ExternalIdpManager(ops_test=ops_test) + + await deploy_identity_bundle(ops_test=ops_test, external_idp_manager=external_idp_manager) + + # Deploy grafana + await ops_test.model.deploy( + grafana_charm, + resources=grafana_resources, + application_name="grafana", + trust=True, + ) + + # Integrate grafana with the identity bundle + await ops_test.model.integrate("grafana:oauth", APPS.HYDRA) + await ops_test.model.integrate("grafana:ingress", APPS.TRAEFIK_PUBLIC) + await ops_test.model.integrate("grafana:receive-ca-cert", APPS.SELF_SIGNED_CERTIFICATES) + + await ops_test.model.wait_for_idle( + apps=[ + APPS.HYDRA, + APPS.TRAEFIK_PUBLIC, + APPS.SELF_SIGNED_CERTIFICATES, + "grafana", + ], + status="active", + raise_on_blocked=False, + raise_on_error=False, + timeout=1000, + ) + + +async def test_oauth_login_with_identity_bundle( + ops_test: OpsTest, page: Page, context: BrowserContext +) -> None: + external_idp_manager = ExternalIdpManager(ops_test=ops_test) + + grafana_proxy = await get_reverse_proxy_app_url(ops_test, APPS.TRAEFIK_PUBLIC, "grafana") + redirect_login = os.path.join(grafana_proxy, "login") + + await access_application_login_page( + page=page, url=grafana_proxy, redirect_login_url=redirect_login + ) + + await click_on_sign_in_button_by_text( + page=page, text="Sign in with external identity provider" + ) + + await complete_external_idp_login( + page=page, ops_test=ops_test, external_idp_manager=external_idp_manager + ) + + redirect_url = os.path.join(grafana_proxy, "?*") + await verify_page_loads(page=page, url=redirect_url) + + # Verifying that the login flow was successful is application specific. + # The test uses Grafana's /api/user endpoint to verify the session cookie is valid + grafana_session_cookie = await get_cookie_from_browser_by_name( + browser_context=context, name="grafana_session" + ) + request = requests.get( + os.path.join(grafana_proxy, "api/user"), + headers={"Cookie": f"grafana_session={grafana_session_cookie}"}, + verify=False, + ) + assert request.status_code == 200 + assert request.json()["email"] == EXTERNAL_USER_EMAIL + + external_idp_manager.remove_idp_service() diff --git a/tests/integration/test_grafana_source.py b/tests/integration/test_grafana_source.py index fd27cb7c..f3466ffe 100644 --- a/tests/integration/test_grafana_source.py +++ b/tests/integration/test_grafana_source.py @@ -46,7 +46,10 @@ async def test_grafana_source_relation_data_with_grafana_tester( ), ) await ops_test.model.wait_for_idle( - apps=[grafana_app_name, tester_app_name], status="active", wait_for_units=1, timeout=300 + apps=[grafana_app_name, tester_app_name], + status="active", + wait_for_at_least_units=1, + timeout=300, ) await check_grafana_is_ready(ops_test, grafana_app_name, 0) diff --git a/tests/integration/test_kubectl_delete.py b/tests/integration/test_kubectl_delete.py index e6876ae4..faf179cf 100644 --- a/tests/integration/test_kubectl_delete.py +++ b/tests/integration/test_kubectl_delete.py @@ -102,7 +102,7 @@ async def test_config_values_are_retained_after_pod_deleted_and_restarted(ops_te logger.debug(stdout) await ops_test.model.wait_for_idle( - apps=[grafana_app_name], status="active", wait_for_units=1, timeout=1000 + apps=[grafana_app_name], status="active", wait_for_at_least_units=1, timeout=1000 ) await check_grafana_is_ready(ops_test, grafana_app_name, 0) diff --git a/tests/integration/test_multiple_units.py b/tests/integration/test_multiple_units.py index 857994cb..b684c5a4 100644 --- a/tests/integration/test_multiple_units.py +++ b/tests/integration/test_multiple_units.py @@ -58,13 +58,13 @@ async def test_grafana_dashboard_relation_data_with_grafana_tester( apps=[grafana_app_name], status="active", wait_for_exact_units=2, timeout=300 ), ops_test.model.wait_for_idle( - apps=[tester_app_name], status="active", wait_for_units=1, timeout=300 + apps=[tester_app_name], status="active", wait_for_at_least_units=1, timeout=300 ), ) logging.info("waiting for idle to ensure the second unit has an address") await ops_test.model.wait_for_idle( - apps=[grafana_app_name], status="active", wait_for_units=2, timeout=300 + apps=[grafana_app_name], status="active", wait_for_at_least_units=2, timeout=300 ) assert ops_test.model.applications[grafana_app_name].units[0].workload_status == "active" diff --git a/tests/integration/test_tls_web.py b/tests/integration/test_tls_web.py index 8dc1301a..08f635d4 100644 --- a/tests/integration/test_tls_web.py +++ b/tests/integration/test_tls_web.py @@ -22,7 +22,6 @@ } -@pytest.mark.xfail async def test_deploy(ops_test, grafana_charm): await asyncio.gather( ops_test.model.deploy( @@ -33,7 +32,7 @@ async def test_deploy(ops_test, grafana_charm): trust=True, ), ops_test.model.deploy( - "ch:self-signed-certificates", + "self-signed-certificates", application_name="ca", channel="edge", ), @@ -43,13 +42,11 @@ async def test_deploy(ops_test, grafana_charm): await asyncio.gather( ops_test.model.wait_for_idle( apps=[grafana.name], - wait_for_units=2, raise_on_error=False, timeout=1200, ), ops_test.model.wait_for_idle( apps=["ca"], - wait_for_units=1, raise_on_error=False, timeout=600, ), diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 45d31dae..a96f2f1c 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -75,6 +75,7 @@ """ + AUTH_PROVIDER_APPLICATION = "auth_provider" @@ -113,7 +114,7 @@ def cli_arg(plan, cli_opt): ) -class TestCharm(unittest.TestCase): +class BaseTestCharm(unittest.TestCase): def setUp(self, *unused): self.harness = Harness(GrafanaCharm) self.addCleanup(self.harness.cleanup) @@ -144,6 +145,8 @@ def setUp(self, *unused): str(yaml.dump(MINIMAL_DATASOURCES_CONFIG)).encode("utf-8") ).hexdigest() + +class TestCharm(BaseTestCharm): def test_datasource_config_is_updated_by_raw_grafana_source_relation(self): self.harness.set_leader(True) diff --git a/tests/unit/test_oauth.py b/tests/unit/test_oauth.py new file mode 100644 index 00000000..98a7a828 --- /dev/null +++ b/tests/unit/test_oauth.py @@ -0,0 +1,131 @@ +# Copyright 2020 Canonical Ltd. +# See LICENSE file for licensing details. + +from unittest.mock import patch +from tests.unit.test_charm import BaseTestCharm + +OAUTH_CLIENT_ID = "grafana_client_id" +OAUTH_CLIENT_SECRET = "s3cR#T" +OAUTH_PROVIDER_INFO = { + "authorization_endpoint": "https://example.oidc.com/oauth2/auth", + "introspection_endpoint": "https://example.oidc.com/admin/oauth2/introspect", + "issuer_url": "https://example.oidc.com", + "jwks_endpoint": "https://example.oidc.com/.well-known/jwks.json", + "scope": "openid profile email phone", + "token_endpoint": "https://example.oidc.com/oauth2/token", + "userinfo_endpoint": "https://example.oidc.com/userinfo", +} + + +class TestOauth(BaseTestCharm): + def test_config_is_updated_with_oauth_relation_data(self): + self.harness.set_leader(True) + self.harness.container_pebble_ready("grafana") + + # add oauth relation with provider endpoints details + rel_id = self.harness.add_relation("oauth", "hydra") + self.harness.add_relation_unit(rel_id, "hydra/0") + secret_id = self.harness.add_model_secret("hydra", {"secret": OAUTH_CLIENT_SECRET}) + self.harness.grant_secret(secret_id, "grafana-k8s") + self.harness.update_relation_data( + rel_id, + "hydra", + { + "client_id": OAUTH_CLIENT_ID, + "client_secret_id": secret_id, + **OAUTH_PROVIDER_INFO, + }, + ) + + services = ( + self.harness.charm.containers["workload"].get_plan().services["grafana"].to_dict() + ) + env = services["environment"] + + expected_env = { + "GF_AUTH_GENERIC_OAUTH_ENABLED": "True", + "GF_AUTH_GENERIC_OAUTH_NAME": "external identity provider", + "GF_AUTH_GENERIC_OAUTH_CLIENT_ID": OAUTH_CLIENT_ID, + "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": OAUTH_CLIENT_SECRET, + "GF_AUTH_GENERIC_OAUTH_SCOPES": "openid email offline_access", + "GF_AUTH_GENERIC_OAUTH_AUTH_URL": OAUTH_PROVIDER_INFO["authorization_endpoint"], + "GF_AUTH_GENERIC_OAUTH_TOKEN_URL": OAUTH_PROVIDER_INFO["token_endpoint"], + "GF_AUTH_GENERIC_OAUTH_API_URL": OAUTH_PROVIDER_INFO["userinfo_endpoint"], + "GF_AUTH_GENERIC_OAUTH_USE_REFRESH_TOKEN": "True", + "GF_FEATURE_TOGGLES_ENABLE": "accessTokenExpirationCheck", + } + all(self.assertEqual(env[k], v) for k, v in expected_env.items()) + + def test_config_with_empty_oauth_relation_data(self): + self.harness.set_leader(True) + self.harness.container_pebble_ready("grafana") + + rel_id = self.harness.add_relation("oauth", "hydra") + self.harness.add_relation_unit(rel_id, "hydra/0") + + services = ( + self.harness.charm.containers["workload"].get_plan().services["grafana"].to_dict() + ) + env = services["environment"] + + oauth_env = { + "GF_AUTH_GENERIC_OAUTH_ENABLED", + "GF_AUTH_GENERIC_OAUTH_NAME", + "GF_AUTH_GENERIC_OAUTH_CLIENT_ID", + "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET", + "GF_AUTH_GENERIC_OAUTH_SCOPES", + "GF_AUTH_GENERIC_OAUTH_AUTH_URL", + "GF_AUTH_GENERIC_OAUTH_TOKEN_URL", + "GF_AUTH_GENERIC_OAUTH_API_URL", + "GF_AUTH_GENERIC_OAUTH_USE_REFRESH_TOKEN", + "GF_FEATURE_TOGGLES_ENABLE", + } + all(self.assertNotIn(k, env) for k in oauth_env) + + # The oauth library tries to access the relation databag + # when the relation is departing. This causes harness to throw an + # error, a behavior not implemented in juju. This will be fixed + # once https://github.com/canonical/operator/issues/940 is merged + def test_config_is_updated_with_oauth_relation_data_removed(self): + patcher = patch("charms.hydra.v0.oauth.OAuthRequirer.is_client_created") + self.mock_resolve_dir = patcher.start() + self.mock_resolve_dir.return_value = False + self.addCleanup(patcher.stop) + self.harness.set_leader(True) + self.harness.container_pebble_ready("grafana") + + # add oauth relation with provider endpoints details + rel_id = self.harness.add_relation("oauth", "hydra") + self.harness.add_relation_unit(rel_id, "hydra/0") + secret_id = self.harness.add_model_secret("hydra", {"secret": OAUTH_CLIENT_SECRET}) + self.harness.grant_secret(secret_id, "grafana-k8s") + self.harness.update_relation_data( + rel_id, + "hydra", + { + "client_id": OAUTH_CLIENT_ID, + "client_secret_id": secret_id, + **OAUTH_PROVIDER_INFO, + }, + ) + self.mock_resolve_dir.return_value = True + rel_id = self.harness.remove_relation(rel_id) + + services = ( + self.harness.charm.containers["workload"].get_plan().services["grafana"].to_dict() + ) + env = services["environment"] + + oauth_env = { + "GF_AUTH_GENERIC_OAUTH_ENABLED", + "GF_AUTH_GENERIC_OAUTH_NAME", + "GF_AUTH_GENERIC_OAUTH_CLIENT_ID", + "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET", + "GF_AUTH_GENERIC_OAUTH_SCOPES", + "GF_AUTH_GENERIC_OAUTH_AUTH_URL", + "GF_AUTH_GENERIC_OAUTH_TOKEN_URL", + "GF_AUTH_GENERIC_OAUTH_API_URL", + "GF_AUTH_GENERIC_OAUTH_USE_REFRESH_TOKEN", + "GF_FEATURE_TOGGLES_ENABLE", + } + all(self.assertNotIn(k, env) for k in oauth_env) diff --git a/tox.ini b/tox.ini index 5d58a606..8dbc087f 100644 --- a/tox.ini +++ b/tox.ini @@ -92,8 +92,12 @@ deps = aiohttp asyncstdlib # Libjuju needs to track the juju version - juju ~= 3.1.0 + juju ~= 3.3.0 pytest pytest-operator + pytest-playwright + lightkube + git+https://github.com/canonical/iam-bundle@671a33419869fab1fe63d81873b41f6b181498e3#egg=oauth_tools commands = + playwright install pytest -vv --tb native --log-cli-level=INFO --color=yes -s {posargs} {toxinidir}/tests/integration