From 2a25dd3ffe0aea590b56cc2c43f2cb0597f95119 Mon Sep 17 00:00:00 2001 From: natalia Date: Tue, 24 Oct 2023 16:21:29 +0200 Subject: [PATCH] feat: add forward-auth relation --- .../harness_extensions/v0/capture_events.py | 1 - lib/charms/oathkeeper/v0/forward_auth.py | 577 ++++++++++++++++++ metadata.yaml | 3 + src/charm.py | 100 ++- tests/unit/test_charm.py | 85 ++- 5 files changed, 743 insertions(+), 23 deletions(-) create mode 100644 lib/charms/oathkeeper/v0/forward_auth.py diff --git a/lib/charms/harness_extensions/v0/capture_events.py b/lib/charms/harness_extensions/v0/capture_events.py index 88819159..e5e2fd4e 100644 --- a/lib/charms/harness_extensions/v0/capture_events.py +++ b/lib/charms/harness_extensions/v0/capture_events.py @@ -83,4 +83,3 @@ def capture(charm: CharmBase, typ_: Type[_T] = EventBase) -> Iterator[Captured[_ event = captured[0] assert isinstance(event, typ_), f"expected {typ_}, not {type(event)}" result.event = event - diff --git a/lib/charms/oathkeeper/v0/forward_auth.py b/lib/charms/oathkeeper/v0/forward_auth.py new file mode 100644 index 00000000..d29318df --- /dev/null +++ b/lib/charms/oathkeeper/v0/forward_auth.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Interface library for providing API Gateways with Identity and Access Proxy information. + +It is required to integrate with Oathkeeper (Policy Decision Point). + +## Getting Started + +To get started using the library, you 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.oathkeeper.v0.forward_auth +``` + +To use the library from the requirer side, add the following to the `metadata.yaml` of the charm: + +```yaml +requires: + forward-auth: + interface: forward_auth + limit: 1 +``` + +Then, to initialise the library: +```python +from charms.oathkeeper.v0.forward_auth import ForwardAuthConfigChangedEvent, ForwardAuthRequirer + +class ApiGatewayCharm(CharmBase): + def __init__(self, *args): + # ... + self.forward_auth = ForwardAuthRequirer(self) + self.framework.observe( + self.forward_auth.on.forward_auth_config_changed, + self.some_event_function + ) + + def some_event_function(self, event: ForwardAuthConfigChangedEvent): + if self.forward_auth.is_ready(): + # Fetch the relation info + forward_auth_data = self.forward_auth.get_forward_auth_data() + # update ingress configuration + # ... +``` +""" + +import inspect +import json +import logging +from dataclasses import asdict, dataclass, field +from typing import Dict, List, Mapping, Optional + +import jsonschema +from ops.charm import CharmBase, RelationChangedEvent, RelationCreatedEvent, RelationDepartedEvent +from ops.framework import EventBase, EventSource, Handle, Object, ObjectEvents +from ops.model import Relation, TooManyRelatedAppsError + +# The unique Charmhub library identifier, never change it +LIBID = "3fd31fa89da34d7f9ad9b62d5f7e7b48" + +# 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 = 1 + +RELATION_NAME = "forward-auth" +INTERFACE_NAME = "forward_auth" + +logger = logging.getLogger(__name__) + +FORWARD_AUTH_PROVIDER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://canonical.github.io/charm-relation-interfaces/docs/json_schemas/forward_auth/v0/provider.json", + "type": "object", + "properties": { + "decisions_address": {"type": "string", "default": None}, + "app_names": {"type": "array", "default": None, "items": {"type": "string"}}, + "headers": {"type": "array", "default": None, "items": {"type": "string"}}, + }, + "required": ["decisions_address", "app_names"], +} + +FORWARD_AUTH_REQUIRER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://canonical.github.io/charm-relation-interfaces/docs/json_schemas/forward_auth/v0/requirer.json", + "type": "object", + "properties": { + "ingress_app_names": {"type": "array", "default": None, "items": {"type": "string"}}, + }, + "required": ["ingress_app_names"], +} + + +class ForwardAuthConfigError(Exception): + """Emitted when invalid forward auth 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 ForwardAuthRelation(Object): + """A class containing helper methods for forward-auth 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 ForwardAuthConfig: + """Helper class containing configuration required by API Gateway to set up the proxy.""" + + decisions_address: str + app_names: List[str] + headers: List[str] = field(default_factory=lambda: []) + + @classmethod + def from_dict(cls, dic: Dict) -> "ForwardAuthConfig": + """Generate ForwardAuthConfig instance from dict.""" + return cls(**{k: v for k, v in dic.items() if k in inspect.signature(cls).parameters}) + + 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 RequirerConfig: + """Helper class containing configuration required by Oathkeeper. + + Its purpose is to evaluate whether apps can be protected by IAP. + """ + + ingress_app_names: List[str] = field(default_factory=lambda: []) + + def to_dict(self) -> Dict: + """Convert object to dict.""" + return {k: v for k, v in asdict(self).items() if v is not None} + + +class ForwardAuthConfigChangedEvent(EventBase): + """Event to notify the requirer charm that the forward-auth config has changed.""" + + def __init__( + self, + handle: Handle, + decisions_address: str, + app_names: List[str], + headers: List[str], + relation_id: int, + relation_app_name: str, + ) -> None: + super().__init__(handle) + self.decisions_address = decisions_address + self.app_names = app_names + self.headers = headers + self.relation_id = relation_id + self.relation_app_name = relation_app_name + + def snapshot(self) -> Dict: + """Save event.""" + return { + "decisions_address": self.decisions_address, + "app_names": self.app_names, + "headers": self.headers, + "relation_id": self.relation_id, + "relation_app_name": self.relation_app_name, + } + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + self.decisions_address = snapshot["decisions_address"] + self.app_names = snapshot["app_names"] + self.headers = snapshot["headers"] + self.relation_id = snapshot["relation_id"] + self.relation_app_name = snapshot["relation_app_name"] + + +class ForwardAuthConfigRemovedEvent(EventBase): + """Event to notify the requirer charm that the forward-auth config was removed.""" + + 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 ForwardAuthRequirerEvents(ObjectEvents): + """Event descriptor for events raised by `ForwardAuthRequirer`.""" + + forward_auth_config_changed = EventSource(ForwardAuthConfigChangedEvent) + forward_auth_config_removed = EventSource(ForwardAuthConfigRemovedEvent) + + +class ForwardAuthRequirer(ForwardAuthRelation): + """Requirer side of the forward-auth relation.""" + + on = ForwardAuthRequirerEvents() + + def __init__( + self, + charm: CharmBase, + relation_name: str = RELATION_NAME, + forward_auth_requirer_config: Optional[RequirerConfig] = None, + ): + super().__init__(charm, relation_name) + + self._charm = charm + self._relation_name = relation_name + self._forward_auth_requirer_config = forward_auth_requirer_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_departed, self._on_relation_departed_event) + + def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: + """Update the relation with requirer data when a relation is created.""" + if not self.model.unit.is_leader(): + return + + self.update_requirer_relation_data(self._forward_auth_requirer_config, event.relation.id) + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Get the forward-auth config and emit a custom config-changed event.""" + if not self.model.unit.is_leader(): + return + + data = event.relation.data[event.app] + if not data: + logger.info("No provider relation data available.") + return + + try: + forward_auth_data = _load_data(data, FORWARD_AUTH_PROVIDER_JSON_SCHEMA) + except DataValidationError as e: + logger.error( + f"Received invalid config from the provider: {e}. Config-changed will not be emitted." + ) + return + + decisions_address = forward_auth_data.get("decisions_address") + app_names = forward_auth_data.get("app_names") + headers = forward_auth_data.get("headers") + + relation_id = event.relation.id + relation_app_name = event.relation.app.name + + # Notify Traefik to update the routes + self.on.forward_auth_config_changed.emit( + decisions_address, app_names, headers, relation_id, relation_app_name + ) + + def _on_relation_departed_event(self, event: RelationDepartedEvent) -> None: + """Notify the requirer that the relation has departed.""" + self.on.forward_auth_config_removed.emit(event.relation.id) + + def update_requirer_relation_data( + self, ingress_app_names: Optional[RequirerConfig], relation_id: Optional[int] = None + ) -> None: + """Update the relation databag with app names that can get IAP protection.""" + if not self.model.unit.is_leader(): + return + + if not ingress_app_names: + logger.error("Ingress-related app names are missing") + return + + if not isinstance(ingress_app_names, RequirerConfig): + raise ValueError(f"Unexpected type: {type(ingress_app_names)}") + + try: + relation = self.model.get_relation( + relation_name=self._relation_name, relation_id=relation_id + ) + except TooManyRelatedAppsError: + raise RuntimeError("More than one relation is defined. Please provide a relation_id") + + if not relation or not relation.app: + return + + data = _dump_data(ingress_app_names.to_dict(), FORWARD_AUTH_REQUIRER_JSON_SCHEMA) + relation.data[self.model.app].update(data) + + def get_provider_info(self, relation_id: Optional[int] = None) -> Optional[ForwardAuthConfig]: + """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 relation is 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, FORWARD_AUTH_PROVIDER_JSON_SCHEMA) + forward_auth_config = ForwardAuthConfig.from_dict(data) + logger.info(f"ForwardAuthConfig: {forward_auth_config}") + + return forward_auth_config + + def get_remote_app_name(self, relation_id: Optional[int] = None) -> Optional[str]: + """Get the remote app name.""" + 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 relation is defined. Please provide a relation_id") + if not relation or not relation.app: + return None + + return relation.app.name + + def is_ready(self, relation_id: Optional[int] = None) -> Optional[bool]: + """Checks whether ForwardAuth is ready on this relation. + + Returns True when Oathkeeper shared the config; False otherwise. + """ + 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 relation is defined. Please provide a relation_id") + + if not relation or not relation.app: + return None + + return ( + "decisions_address" in relation.data[relation.app] + and "app_names" in relation.data[relation.app] + ) + + +class ForwardAuthProxySet(EventBase): + """Event to notify the charm that the proxy was set successfully.""" + + def snapshot(self) -> Dict: + """Save event.""" + return {} + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + pass + + +class InvalidForwardAuthConfigEvent(EventBase): + """Event to notify the charm that the forward-auth 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 ForwardAuthRelationRemovedEvent(EventBase): + """Event to notify the charm that the relation was removed.""" + + 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 ForwardAuthProviderEvents(ObjectEvents): + """Event descriptor for events raised by `ForwardAuthProvider`.""" + + forward_auth_proxy_set = EventSource(ForwardAuthProxySet) + invalid_forward_auth_config = EventSource(InvalidForwardAuthConfigEvent) + forward_auth_relation_removed = EventSource(ForwardAuthRelationRemovedEvent) + + +class ForwardAuthProvider(ForwardAuthRelation): + """Provider side of the forward-auth relation.""" + + on = ForwardAuthProviderEvents() + + def __init__( + self, + charm: CharmBase, + relation_name: str = RELATION_NAME, + forward_auth_config: Optional[ForwardAuthConfig] = None, + ) -> None: + super().__init__(charm, relation_name) + self.charm = charm + self._relation_name = relation_name + self.forward_auth_config = forward_auth_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_departed, self._on_relation_departed_event) + + def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: + """Update the relation with provider data when a relation is created.""" + if not self.model.unit.is_leader(): + return + + try: + self._update_relation_data(self.forward_auth_config, event.relation.id) + except ForwardAuthConfigError as e: + self.on.invalid_forward_auth_config.emit(e.args[0]) + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Update the relation with forward-auth config when a relation is changed.""" + if not self.model.unit.is_leader(): + return + + # Compare ingress-related apps with apps that requested the proxy + self._compare_apps() + + def _on_relation_departed_event(self, event: RelationDepartedEvent) -> None: + """Wipe the relation databag and notify the charm that the relation has departed.""" + # Workaround for https://github.com/canonical/operator/issues/888 + self._pop_relation_data(event.relation.id) + + self.on.forward_auth_relation_removed.emit(event.relation.id) + + def _compare_apps(self, relation_id: Optional[int] = None) -> None: + """Compare app names provided by Oathkeeper with apps that are related via ingress. + + The ingress-related app names are provided by the relation requirer. + If an app is not related via ingress-per-app/leader/unit, + emit `InvalidForwardAuthConfigEvent`. + If the app is related via ingress and thus eligible for IAP, emit `ForwardAuthProxySet`. + """ + 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 relation is defined. Please provide a relation_id") + if not relation or not relation.app: + return None + + requirer_data = relation.data[relation.app] + if not requirer_data: + logger.info("No requirer relation data available.") + return + + ingress_apps = requirer_data["ingress_app_names"] + + for app in json.loads(relation.data[self.model.app]["app_names"]): + if app not in ingress_apps: + self.on.invalid_forward_auth_config.emit(error=f"{app} is not related via ingress") + return + self.on.forward_auth_proxy_set.emit() + + def _update_relation_data( + self, forward_auth_config: Optional[ForwardAuthConfig], relation_id: Optional[int] = None + ) -> None: + """Validate the forward-auth config and update the relation databag.""" + if not self.model.unit.is_leader(): + return + + if not forward_auth_config: + logger.info("Forward-auth config is missing") + return + + if not isinstance(forward_auth_config, ForwardAuthConfig): + raise ValueError(f"Unexpected forward_auth_config type: {type(forward_auth_config)}") + + try: + relation = self.model.get_relation( + relation_name=self._relation_name, relation_id=relation_id + ) + except TooManyRelatedAppsError: + raise RuntimeError("More than one relation is defined. Please provide a relation_id") + + if not relation or not relation.app: + return + + data = _dump_data(forward_auth_config.to_dict(), FORWARD_AUTH_PROVIDER_JSON_SCHEMA) + relation.data[self.model.app].update(data) + + def update_forward_auth_config( + self, forward_auth_config: ForwardAuthConfig, relation_id: Optional[int] = None + ) -> None: + """Update the forward-auth config stored in the object.""" + self._update_relation_data(forward_auth_config, relation_id=relation_id) diff --git a/metadata.yaml b/metadata.yaml index e46d1425..29824202 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -72,6 +72,9 @@ requires: limit: 1 description: | Send a CSR to- and obtain a signed certificate from an external CA. + forward-auth: + interface: forward_auth + limit: 1 logging: interface: loki_push_api description: | diff --git a/src/charm.py b/src/charm.py index 95cf5bac..3076651f 100755 --- a/src/charm.py +++ b/src/charm.py @@ -13,7 +13,7 @@ import socket import typing from string import Template -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union from urllib.parse import urlparse import yaml @@ -28,6 +28,12 @@ ) from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider from charms.loki_k8s.v0.loki_push_api import LogProxyConsumer +from charms.oathkeeper.v0.forward_auth import ( + ForwardAuthConfigChangedEvent, + ForwardAuthConfigRemovedEvent, + ForwardAuthRequirer, + RequirerConfig, +) from charms.observability_libs.v0.cert_handler import CertHandler from charms.observability_libs.v1.kubernetes_service_patch import ( KubernetesServicePatch, @@ -221,6 +227,11 @@ def __init__(self, *args): self.on.update_status, # type: ignore ], ) + + self.forward_auth = ForwardAuthRequirer( + self, forward_auth_requirer_config=self._forward_auth_requirer_config + ) + observe = self.framework.observe # TODO update init params once auto-renew is implemented @@ -254,6 +265,13 @@ def __init__(self, *args): self._on_recv_ca_cert_removed, ) + observe( + self.forward_auth.on.forward_auth_config_changed, self._on_forward_auth_config_changed + ) + observe( + self.forward_auth.on.forward_auth_config_removed, self._on_forward_auth_config_removed + ) + # observe data_provided and data_removed events for all types of ingress we offer: for ingress in (self.ingress_per_unit, self.ingress_per_appv1, self.ingress_per_appv2): observe(ingress.on.data_provided, self._handle_ingress_data_provided) # type: ignore @@ -266,6 +284,26 @@ def __init__(self, *args): # Action handlers observe(self.on.show_proxied_endpoints_action, self._on_show_proxied_endpoints) # type: ignore + @property + def _forward_auth_requirer_config(self) -> RequirerConfig: + ingress_app_names = [] + for ingress_relation in ( + self.ingress_per_appv1.relations + + self.ingress_per_appv2.relations + + self.ingress_per_unit.relations + + self.traefik_route.relations + ): + ingress_app_names.append(ingress_relation.app.name) + return RequirerConfig(ingress_app_names) + + def _on_forward_auth_config_changed(self, event: ForwardAuthConfigChangedEvent): + self.forward_auth.update_requirer_relation_data(self._forward_auth_requirer_config) + if self.forward_auth.is_ready(): + self._process_status_and_configurations() + + def _on_forward_auth_config_removed(self, event: ForwardAuthConfigRemovedEvent): + self._process_status_and_configurations() + def _on_recv_ca_cert_available(self, event: CertificateTransferAvailableEvent): # Assuming only one cert per relation (this is in line with the original lib design). if not self.container.can_connect(): @@ -721,6 +759,9 @@ def _handle_ingress_data_provided(self, event: RelationEvent): # update-status. self._process_status_and_configurations() + if self.forward_auth.is_ready(): + self.forward_auth.update_requirer_relation_data(self._forward_auth_requirer_config) + if isinstance(self.unit.status, MaintenanceStatus): self.unit.status = ActiveStatus() @@ -730,6 +771,9 @@ def _handle_ingress_data_removed(self, event: RelationEvent): event.relation, wipe_rel_data=not isinstance(event, RelationBrokenEvent) ) + if self.forward_auth.is_ready(): + self.forward_auth.update_requirer_relation_data(self._forward_auth_requirer_config) + # FIXME? on relation broken, data is still there so cannot simply call # self._process_status_and_configurations(). For this reason, the static config in # _STATIC_CONFIG_PATH will be updated only on update-status. @@ -930,6 +974,21 @@ def _generate_middleware_config( "cannot create middleware: multi-types middleware not supported, consider declaring two different pieces of middleware instead" """ + forwardauth_middleware = {} + if self.forward_auth.is_ready(): + # Define the middleware only for Oathkeeper + policy_decision_point_app = self.forward_auth.get_remote_app_name() + if data.get("name") == policy_decision_point_app: + forward_auth_config = self.forward_auth.get_provider_info() + forwardauth_middleware[ + f"juju-sidecar-forward-auth-{policy_decision_point_app}" + ] = { + "forwardAuth": { + "address": forward_auth_config.decisions_address, + "authResponseHeaders": forward_auth_config.headers, + } + } + no_prefix_middleware = {} # type: Dict[str, Dict[str, Any]] if self._routing_mode is _RoutingMode.path: if data.get("strip-prefix", False): @@ -945,7 +1004,7 @@ def _generate_middleware_config( "redirectScheme": {"scheme": "https", "port": 443, "permanent": True} } - return {**no_prefix_middleware, **redir_scheme_middleware} + return {**forwardauth_middleware, **no_prefix_middleware, **redir_scheme_middleware} def _generate_per_unit_tcp_config(self, prefix: str, data: RequirerData_IPU) -> dict: """Generate a config dict for a given unit for IngressPerUnit in tcp mode.""" @@ -1076,8 +1135,45 @@ def _generate_config_block( if f"{traefik_router_name}-tls" in router_cfg: router_cfg[f"{traefik_router_name}-tls"]["middlewares"] = list(middlewares.keys()) + if self.forward_auth.is_ready(): + ( + router_cfg[traefik_router_name]["middlewares"], + router_cfg[f"{traefik_router_name}-tls"]["middlewares"], + ) = self._update_middlewares_with_forward_auth(traefik_router_name, data, router_cfg) + return config + def _update_middlewares_with_forward_auth( + self, traefik_router_name: str, data: Dict[str, Any], router_cfg: Dict[str, Any] + ) -> Tuple[Any, Any]: + """Update the configuration segment with forwardAuth middleware.""" + forward_auth_middleware = ( + f"juju-sidecar-forward-auth-{self.forward_auth.get_remote_app_name()}" + ) + + if data.get("name") == self.forward_auth.get_remote_app_name(): + # Remove the middleware key from oathkeeper; keep only its definition + router_cfg[traefik_router_name]["middlewares"].remove(forward_auth_middleware) + if f"{traefik_router_name}-tls" in router_cfg: + router_cfg[f"{traefik_router_name}-tls"]["middlewares"].remove( + forward_auth_middleware + ) + + # Append the middleware if the app name was provided by forward-auth + forward_auth_config = self.forward_auth.get_provider_info() + if data.get("name") in forward_auth_config.app_names: + # ForwardAuth must come before other middlewares + router_cfg[traefik_router_name]["middlewares"].insert(0, forward_auth_middleware) + if f"{traefik_router_name}-tls" in router_cfg: + router_cfg[f"{traefik_router_name}-tls"]["middlewares"].insert( + 0, forward_auth_middleware + ) + + return ( + router_cfg[traefik_router_name]["middlewares"], + router_cfg[f"{traefik_router_name}-tls"]["middlewares"], + ) + def _generate_tls_block( self, router_name: str, diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index d5416252..91f20d29 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -95,26 +95,6 @@ def _render_middlewares(*, strip_prefix: bool = False, redirect_https: bool = Fa ) -def _render_middlewares(*, strip_prefix: bool = False, redirect_https: bool = False) -> dict: - middlewares = {} - if redirect_https: - middlewares.update({"redirectScheme": {"scheme": "https", "port": 443, "permanent": True}}) - if strip_prefix: - middlewares.update( - { - "stripPrefix": { - "prefixes": ["/test-model-remote-0"], - "forceSlash": False, - } - } - ) - return ( - {"middlewares": {"juju-sidecar-noprefix-test-model-remote-0": middlewares}} - if middlewares - else {} - ) - - class _RequirerMock: local_app: Application = None relation: Relation = None @@ -409,6 +389,71 @@ def test_tcp_config(self): static_config = charm.unit.get_container("traefik").pull(_STATIC_CONFIG_PATH).read() assert yaml.safe_load(static_config)["entryPoints"][prefix] == expected_entrypoint + def setup_forward_auth_relation(self) -> int: + relation_id = self.harness.add_relation("forward-auth", "provider") + self.harness.add_relation_unit(relation_id, "provider/0") + self.harness.update_relation_data( + relation_id, + "provider", + { + "decisions_address": "https://oathkeeper.test-model.svc.cluster.local:4456/decisions", + "app_names": '["charmed-app"]', + "headers": '["X-User"]', + }, + ) + + return relation_id + + @patch("charm.KubernetesServicePatch", lambda *_, **__: None) + def test_forward_auth_relation_databag(self): + self.harness.set_leader(True) + self.harness.update_config({"external_hostname": "testhostname"}) + self.harness.begin_with_initial_hooks() + + provider_info = { + "decisions_address": "https://oathkeeper.test-model.svc.cluster.local:4456/decisions", + "app_names": ["charmed-app"], + "headers": ["X-User"], + } + + _ = self.setup_forward_auth_relation() + + self.assertTrue(self.harness.charm.forward_auth.is_ready()) + + expected_provider_info = self.harness.charm.forward_auth.get_provider_info() + + assert expected_provider_info.decisions_address == provider_info["decisions_address"] + assert expected_provider_info.app_names == provider_info["app_names"] + assert expected_provider_info.headers == provider_info["headers"] + + @patch("charm.KubernetesServicePatch", lambda *_, **__: None) + def test_forward_auth_relation_changed(self): + self.harness.set_leader(True) + self.harness.update_config({"external_hostname": "testhostname"}) + self.harness.begin_with_initial_hooks() + + self.harness.charm._on_forward_auth_config_changed = mocked_handle = Mock( + return_value=None + ) + + _ = self.setup_forward_auth_relation() + assert mocked_handle.called + + @patch("charm.KubernetesServicePatch", lambda *_, **__: None) + def test_forward_auth_relation_removed(self): + self.harness.set_leader(True) + self.harness.update_config({"external_hostname": "testhostname"}) + self.harness.begin_with_initial_hooks() + + self.harness.charm._on_forward_auth_config_removed = mocked_handle = Mock( + return_value=None + ) + + relation_id = self.setup_forward_auth_relation() + self.harness.remove_relation(relation_id) + + assert mocked_handle.called + class TestTraefikCertTransferInterface(unittest.TestCase): def setUp(self):