From 28f40521eac76adaed441b8b60f2c9ef9c35ae88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:40:50 +0000 Subject: [PATCH 01/13] Initial plan From fa25ea617e599ef06da52edca79e3d9ca930b3ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 19:01:09 +0000 Subject: [PATCH 02/13] Implement spoe-auth Juju Relation Interface library Co-authored-by: Thanhphan1147 <42444001+Thanhphan1147@users.noreply.github.com> --- lib/charms/haproxy/v0/spoe_auth.py | 502 +++++++++++++++++++++++++++++ 1 file changed, 502 insertions(+) create mode 100644 lib/charms/haproxy/v0/spoe_auth.py diff --git a/lib/charms/haproxy/v0/spoe_auth.py b/lib/charms/haproxy/v0/spoe_auth.py new file mode 100644 index 000000000..ef27d97bf --- /dev/null +++ b/lib/charms/haproxy/v0/spoe_auth.py @@ -0,0 +1,502 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +# pylint: disable=duplicate-code + +"""SPOE-auth interface library. + +## Getting Started + +To get started using the library, you just need to fetch the library using `charmcraft`. + +```shell +cd some-charm +charmcraft fetch-lib charms.haproxy.v0.spoe_auth +``` + +## Using the library as the Requirer + +The requirer charm (haproxy-operator) should expose the interface as shown below: + +In the `metadata.yaml` of the charm, add the following: + +```yaml +requires: + spoe-auth: + interface: spoe-auth + limit: 1 +``` + +Then, to initialise the library: + +```python +from charms.haproxy.v0.spoe_auth import SpoeAuthRequirer + +class HaproxyCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.spoe_auth = SpoeAuthRequirer(self, relation_name="spoe-auth") + + self.framework.observe( + self.spoe_auth.on.available, self._on_spoe_auth_available + ) + self.framework.observe( + self.spoe_auth.on.removed, self._on_spoe_auth_removed + ) + + def _on_spoe_auth_available(self, event): + # The SPOE auth configuration is available + if self.spoe_auth.is_available(): + config = self.spoe_auth.get_config() + # Use config.spop_port, config.oidc_callback_port, etc. + ... + + def _on_spoe_auth_removed(self, event): + # Handle relation broken event + ... +``` + +## Using the library as the Provider + +The provider charm (SPOE agent) should expose the interface as shown below: + +```yaml +provides: + spoe-auth: + interface: spoe-auth +``` + +Then, to initialise the library: + +```python +from charms.haproxy.v0.spoe_auth import SpoeAuthProvider + +class SpoeAuthCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.spoe_auth = SpoeAuthProvider(self, relation_name="spoe-auth") + + self.framework.observe( + self.on.config_changed, self._on_config_changed + ) + + def _on_config_changed(self, event): + # Publish the SPOE auth configuration + self.spoe_auth.set_config( + spop_port=9000, + oidc_callback_port=8080, + event="on-http-request", + var_authenticated="txn.authenticated", + var_redirect_url="txn.redirect_url", + cookie_name="auth_session", + oidc_callback_hostname="auth.example.com", + oidc_callback_path="/oauth2/callback", + ) +``` +""" + +import json +import logging +from typing import MutableMapping, Optional + +from ops import CharmBase, RelationBrokenEvent +from ops.charm import CharmEvents +from ops.framework import EventBase, EventSource, Object +from ops.model import Relation +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +# The unique Charmhub library identifier, never change it +LIBID = "a1b2c3d4e5f6789012345678901234ab" + +# 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 + +logger = logging.getLogger(__name__) +SPOE_AUTH_RELATION_NAME = "spoe-auth" + + +class DataValidationError(Exception): + """Raised when data validation fails.""" + + +class SpoeAuthInvalidRelationDataError(Exception): + """Raised when data validation of the spoe-auth relation fails.""" + + +class _DatabagModel(BaseModel): + """Base databag model. + + Attrs: + model_config: pydantic model configuration. + """ + + model_config = ConfigDict( + # tolerate additional keys in databag + extra="ignore", + # Allow instantiating this class by field name (instead of forcing alias). + populate_by_name=True, + # Custom config key: whether to nest the whole datastructure (as json) + # under a field or spread it out at the toplevel. + _NEST_UNDER=None, + ) # type: ignore + """Pydantic config.""" + + @classmethod + def load(cls, databag: MutableMapping) -> "_DatabagModel": + """Load this model from a Juju json databag. + + Args: + databag: Databag content. + + Raises: + DataValidationError: When model validation failed. + + Returns: + _DatabagModel: The validated model. + """ + nest_under = cls.model_config.get("_NEST_UNDER") + if nest_under: + return cls.model_validate(json.loads(databag[nest_under])) + + try: + data = { + k: json.loads(v) + for k, v in databag.items() + # Don't attempt to parse model-external values + if k in {(f.alias or n) for n, f in cls.model_fields.items()} + } + except json.JSONDecodeError as e: + msg = f"invalid databag contents: expecting json. {databag}" + logger.error(msg) + raise DataValidationError(msg) from e + + try: + return cls.model_validate_json(json.dumps(data)) + except ValidationError as e: + msg = f"failed to validate databag: {databag}" + logger.error(str(e), exc_info=True) + raise DataValidationError(msg) from e + + @classmethod + def from_dict(cls, values: dict) -> "_DatabagModel": + """Load this model from a dict. + + Args: + values: Dict values. + + Raises: + DataValidationError: When model validation failed. + + Returns: + _DatabagModel: The validated model. + """ + try: + logger.info("Loading values from dictionary: %s", values) + return cls.model_validate(values) + except ValidationError as e: + msg = f"failed to validate: {values}" + logger.debug(msg, exc_info=True) + raise DataValidationError(msg) from e + + def dump( + self, databag: Optional[MutableMapping] = None, clear: bool = True + ) -> Optional[MutableMapping]: + """Write the contents of this model to Juju databag. + + Args: + databag: The databag to write to. + clear: Whether to clear the databag before writing. + + Returns: + MutableMapping: The databag. + """ + if clear and databag: + databag.clear() + + if databag is None: + databag = {} + nest_under = self.model_config.get("_NEST_UNDER") + if nest_under: + databag[nest_under] = self.model_dump_json( + by_alias=True, + # skip keys whose values are default + exclude_defaults=True, + ) + return databag + + dct = self.model_dump(mode="json", by_alias=True, exclude_defaults=True) + databag.update({k: json.dumps(v) for k, v in dct.items()}) + return databag + + +class SpoeAuthProviderAppData(_DatabagModel): + """Configuration model for SPOE authentication provider. + + Attributes: + spop_port: The port on the agent listening for SPOP. + oidc_callback_port: The port on the agent handling OIDC callbacks. + event: The event that triggers SPOE messages (e.g., on-http-request). + var_authenticated: Name of the variable set by the SPOE agent for auth status. + var_redirect_url: Name of the variable set by the SPOE agent for IDP redirect URL. + cookie_name: Name of the authentication cookie used by the SPOE agent. + oidc_callback_path: Path for OIDC callback. + oidc_callback_hostname: The hostname HAProxy should route OIDC callbacks to. + """ + + spop_port: int = Field( + description="The port on the agent listening for SPOP.", + ) + oidc_callback_port: int = Field( + description="The port on the agent handling OIDC callbacks.", + ) + event: str = Field( + description="The event that triggers SPOE messages (e.g., on-http-request).", + ) + var_authenticated: str = Field( + description="Name of the variable set by the SPOE agent for auth status.", + ) + var_redirect_url: str = Field( + description="Name of the variable set by the SPOE agent for IDP redirect URL.", + ) + cookie_name: str = Field( + description="Name of the authentication cookie used by the SPOE agent.", + ) + oidc_callback_path: str = Field( + description="Path for OIDC callback.", + default="/oauth2/callback", + ) + oidc_callback_hostname: str = Field( + description="The hostname HAProxy should route OIDC callbacks to.", + ) + + +class SpoeAuthProviderDataAvailableEvent(EventBase): + """SpoeAuthProviderDataAvailableEvent custom event.""" + + +class SpoeAuthProviderDataRemovedEvent(EventBase): + """SpoeAuthProviderDataRemovedEvent custom event.""" + + +class SpoeAuthProviderEvents(CharmEvents): + """List of events that the SPOE auth provider charm can leverage. + + Attributes: + data_available: Emitted when requirer relation data is available. + data_removed: Emitted when requirer relation is broken. + """ + + data_available = EventSource(SpoeAuthProviderDataAvailableEvent) + data_removed = EventSource(SpoeAuthProviderDataRemovedEvent) + + +class SpoeAuthProvider(Object): + """SPOE auth interface provider implementation. + + Attributes: + on: Custom events of the provider. + relations: Related applications. + """ + + on = SpoeAuthProviderEvents() + + def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_RELATION_NAME) -> None: + """Initialize the SpoeAuthProvider. + + Args: + charm: The charm that is instantiating the library. + relation_name: The name of the relation to bind to. + """ + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + + self.framework.observe( + self.charm.on[self.relation_name].relation_changed, self._on_relation_changed + ) + self.framework.observe( + self.charm.on[self.relation_name].relation_broken, self._on_relation_broken + ) + + @property + def relations(self) -> list[Relation]: + """The list of Relation instances associated with this relation_name. + + Returns: + list[Relation]: The list of relations. + """ + return list(self.charm.model.relations[self.relation_name]) + + def _on_relation_changed(self, _: EventBase) -> None: + """Handle relation changed events.""" + self.on.data_available.emit() + + def _on_relation_broken(self, _: EventBase) -> None: + """Handle relation broken events.""" + self.on.data_removed.emit() + + # pylint: disable=too-many-arguments,too-many-positional-arguments + def set_config( + self, + spop_port: int, + oidc_callback_port: int, + event: str, + var_authenticated: str, + var_redirect_url: str, + cookie_name: str, + oidc_callback_hostname: str, + oidc_callback_path: str = "/oauth2/callback", + ) -> None: + """Set the SPOE auth configuration in the application databag. + + Args: + spop_port: The port on the agent listening for SPOP. + oidc_callback_port: The port on the agent handling OIDC callbacks. + event: The event that triggers SPOE messages. + var_authenticated: Name of the variable for auth status. + var_redirect_url: Name of the variable for IDP redirect URL. + cookie_name: Name of the authentication cookie. + oidc_callback_hostname: The hostname HAProxy should route OIDC callbacks to. + oidc_callback_path: Path for OIDC callback. + """ + if not self.charm.unit.is_leader(): + logger.warning("Only the leader unit can set the SPOE auth configuration.") + return + + config_data = SpoeAuthProviderAppData( + spop_port=spop_port, + oidc_callback_port=oidc_callback_port, + event=event, + var_authenticated=var_authenticated, + var_redirect_url=var_redirect_url, + cookie_name=cookie_name, + oidc_callback_hostname=oidc_callback_hostname, + oidc_callback_path=oidc_callback_path, + ) + + for relation in self.relations: + config_data.dump(relation.data[self.charm.app], clear=True) + + +class SpoeAuthAvailableEvent(EventBase): + """SpoeAuthAvailableEvent custom event.""" + + +class SpoeAuthRemovedEvent(EventBase): + """SpoeAuthRemovedEvent custom event.""" + + +class SpoeAuthRequirerEvents(CharmEvents): + """List of events that the SPOE auth requirer charm can leverage. + + Attributes: + available: Emitted when provider configuration is available. + removed: Emitted when the provider relation is broken. + """ + + available = EventSource(SpoeAuthAvailableEvent) + removed = EventSource(SpoeAuthRemovedEvent) + + +class SpoeAuthRequirer(Object): + """SPOE auth interface requirer implementation. + + Attributes: + on: Custom events of the requirer. + relation: The related application. + """ + + on = SpoeAuthRequirerEvents() + + def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_RELATION_NAME) -> None: + """Initialize the SpoeAuthRequirer. + + Args: + charm: The charm that is instantiating the library. + relation_name: The name of the relation to bind to. + """ + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + + self.framework.observe( + self.charm.on[self.relation_name].relation_changed, self._on_relation_changed + ) + self.framework.observe( + self.charm.on[self.relation_name].relation_broken, self._on_relation_broken + ) + + @property + def relation(self) -> Optional[Relation]: + """The relation instance associated with this relation_name. + + Returns: + Optional[Relation]: The relation instance, or None if not available. + """ + relations = self.charm.model.relations[self.relation_name] + return relations[0] if relations else None + + def _on_relation_changed(self, _: EventBase) -> None: + """Handle relation changed events.""" + if self.is_available(): + self.on.available.emit() + + def _on_relation_broken(self, _: RelationBrokenEvent) -> None: + """Handle relation broken events.""" + self.on.removed.emit() + + def is_available(self) -> bool: + """Check if the SPOE auth configuration is available and valid. + + Returns: + bool: True if configuration is available and valid, False otherwise. + """ + if not self.relation: + return False + + if not self.relation.app: + return False + + try: + databag = self.relation.data[self.relation.app] + if not databag: + return False + SpoeAuthProviderAppData.load(databag) + return True + except (DataValidationError, KeyError): + return False + + def get_config(self) -> Optional[SpoeAuthProviderAppData]: + """Get the SPOE auth configuration from the provider. + + Returns: + Optional[SpoeAuthProviderAppData]: The SPOE auth configuration, + or None if not available. + + Raises: + SpoeAuthInvalidRelationDataError: When configuration data is invalid. + """ + if not self.relation: + return None + + if not self.relation.app: + return None + + try: + databag = self.relation.data[self.relation.app] + if not databag: + return None + return SpoeAuthProviderAppData.load(databag) # type: ignore + except DataValidationError as exc: + logger.error( + "spoe-auth data validation failed for relation %s: %s", + self.relation, + str(exc), + ) + raise SpoeAuthInvalidRelationDataError( + f"spoe-auth data validation failed for relation: {self.relation}" + ) from exc From d5a28a501a711416ee88f3f53cde3d966ea4f0f5 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 13 Nov 2025 12:55:40 +0100 Subject: [PATCH 03/13] Add relation and relation library --- charmcraft.yaml | 4 + lib/charms/haproxy/v0/spoe_auth.py | 250 +++++++++++++++++++---------- 2 files changed, 167 insertions(+), 87 deletions(-) diff --git a/charmcraft.yaml b/charmcraft.yaml index 1de440bef..7248887c2 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -57,6 +57,10 @@ requires: receive-ca-certs: interface: certificate_transfer description: Receive a CA certs for haproxy to trust. + spoe-auth: + interface: spoe-auth + description: Relation to provide authentication proxy to HAProxy. + limit: 1 provides: ingress: diff --git a/lib/charms/haproxy/v0/spoe_auth.py b/lib/charms/haproxy/v0/spoe_auth.py index ef27d97bf..d0613b2fe 100644 --- a/lib/charms/haproxy/v0/spoe_auth.py +++ b/lib/charms/haproxy/v0/spoe_auth.py @@ -1,8 +1,6 @@ # Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. -# pylint: disable=duplicate-code - """SPOE-auth interface library. ## Getting Started @@ -47,8 +45,8 @@ def __init__(self, *args): def _on_spoe_auth_available(self, event): # The SPOE auth configuration is available if self.spoe_auth.is_available(): - config = self.spoe_auth.get_config() - # Use config.spop_port, config.oidc_callback_port, etc. + application_data = self.spoe_auth.get_provider_application_data() + unit_data = self.spoe_auth.get_provider_unit_data() ... def _on_spoe_auth_removed(self, event): @@ -69,7 +67,7 @@ def _on_spoe_auth_removed(self, event): Then, to initialise the library: ```python -from charms.haproxy.v0.spoe_auth import SpoeAuthProvider +from charms.haproxy.v0.spoe_auth import SpoeAuthProvider, HaproxyEvent class SpoeAuthCharm(CharmBase): def __init__(self, *args): @@ -82,12 +80,12 @@ def __init__(self, *args): def _on_config_changed(self, event): # Publish the SPOE auth configuration - self.spoe_auth.set_config( - spop_port=9000, - oidc_callback_port=8080, - event="on-http-request", - var_authenticated="txn.authenticated", - var_redirect_url="txn.redirect_url", + self.spoe_auth.provide_spoe_auth_requirements( + spop_port=8081, + oidc_callback_port=5000, + event=HaproxyEvent.ON_HTTP_REQUEST, + var_authenticated="var.sess.is_authenticated", + var_redirect_url="var.sess.redirect_url", cookie_name="auth_session", oidc_callback_hostname="auth.example.com", oidc_callback_path="/oauth2/callback", @@ -97,16 +95,17 @@ def _on_config_changed(self, event): import json import logging -from typing import MutableMapping, Optional +from enum import StrEnum +from typing import Annotated, MutableMapping, Optional, cast from ops import CharmBase, RelationBrokenEvent from ops.charm import CharmEvents from ops.framework import EventBase, EventSource, Object from ops.model import Relation -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, IPvAnyAddress, ValidationError # The unique Charmhub library identifier, never change it -LIBID = "a1b2c3d4e5f6789012345678901234ab" +LIBID = "3f644e37fffc483aa97bea91d4fc0bce" # Increment this major API version when introducing breaking changes LIBAPI = 0 @@ -117,6 +116,33 @@ def _on_config_changed(self, event): logger = logging.getLogger(__name__) SPOE_AUTH_RELATION_NAME = "spoe-auth" +HAPROXY_CONFIG_INVALID_CHARACTERS = "\n\t#\\'\"\r$ " +# Definition of a hostname according to RFC 1123 +# https://stackoverflow.com/a/2063247 +HOSTNAME_REGEXP = r"^(?![0-9]+$)(?!-)[a-zA-Z0-9-]{,63}(? Optional[str]: + """Validate if value contains invalid haproxy config characters. + + Args: + value: The value to validate. + + Raises: + ValueError: When value contains invalid characters. + + Returns: + The validated value. + """ + if value is None: + return value + + if [char for char in value if char in HAPROXY_CONFIG_INVALID_CHARACTERS]: + raise ValueError(f"Relation data contains invalid character(s) {value}") + return value + + +VALIDSTR = Annotated[str, BeforeValidator(value_contains_invalid_characters)] class DataValidationError(Exception): @@ -181,27 +207,6 @@ def load(cls, databag: MutableMapping) -> "_DatabagModel": logger.error(str(e), exc_info=True) raise DataValidationError(msg) from e - @classmethod - def from_dict(cls, values: dict) -> "_DatabagModel": - """Load this model from a dict. - - Args: - values: Dict values. - - Raises: - DataValidationError: When model validation failed. - - Returns: - _DatabagModel: The validated model. - """ - try: - logger.info("Loading values from dictionary: %s", values) - return cls.model_validate(values) - except ValidationError as e: - msg = f"failed to validate: {values}" - logger.debug(msg, exc_info=True) - raise DataValidationError(msg) from e - def dump( self, databag: Optional[MutableMapping] = None, clear: bool = True ) -> Optional[MutableMapping]: @@ -233,6 +238,12 @@ def dump( return databag +class HaproxyEvent(StrEnum): + """Enumeration of HAProxy SPOE events.""" + + ON_FRONTEND_HTTP_REQUEST = "on-frontend-http-request" + + class SpoeAuthProviderAppData(_DatabagModel): """Configuration model for SPOE authentication provider. @@ -249,49 +260,44 @@ class SpoeAuthProviderAppData(_DatabagModel): spop_port: int = Field( description="The port on the agent listening for SPOP.", + gt=0, + le=65525, ) oidc_callback_port: int = Field( description="The port on the agent handling OIDC callbacks.", + gt=0, + le=65525, ) - event: str = Field( + event: HaproxyEvent = Field( description="The event that triggers SPOE messages (e.g., on-http-request).", ) - var_authenticated: str = Field( + var_authenticated: VALIDSTR = Field( description="Name of the variable set by the SPOE agent for auth status.", ) - var_redirect_url: str = Field( + var_redirect_url: VALIDSTR = Field( description="Name of the variable set by the SPOE agent for IDP redirect URL.", ) - cookie_name: str = Field( + cookie_name: VALIDSTR = Field( description="Name of the authentication cookie used by the SPOE agent.", ) - oidc_callback_path: str = Field( + oidc_callback_path: VALIDSTR = Field( description="Path for OIDC callback.", default="/oauth2/callback", ) oidc_callback_hostname: str = Field( description="The hostname HAProxy should route OIDC callbacks to.", + pattern=HOSTNAME_REGEXP, ) -class SpoeAuthProviderDataAvailableEvent(EventBase): - """SpoeAuthProviderDataAvailableEvent custom event.""" - - -class SpoeAuthProviderDataRemovedEvent(EventBase): - """SpoeAuthProviderDataRemovedEvent custom event.""" - - -class SpoeAuthProviderEvents(CharmEvents): - """List of events that the SPOE auth provider charm can leverage. +class SpoeAuthProviderUnitData(_DatabagModel): + """spoe-auth provider unit data. Attributes: - data_available: Emitted when requirer relation data is available. - data_removed: Emitted when requirer relation is broken. + address: IP address of the unit. """ - data_available = EventSource(SpoeAuthProviderDataAvailableEvent) - data_removed = EventSource(SpoeAuthProviderDataRemovedEvent) + address: IPvAnyAddress = Field(description="IP address of the unit.") class SpoeAuthProvider(Object): @@ -302,8 +308,6 @@ class SpoeAuthProvider(Object): relations: Related applications. """ - on = SpoeAuthProviderEvents() - def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_RELATION_NAME) -> None: """Initialize the SpoeAuthProvider. @@ -315,13 +319,6 @@ def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_RELATION_NAM self.charm = charm self.relation_name = relation_name - self.framework.observe( - self.charm.on[self.relation_name].relation_changed, self._on_relation_changed - ) - self.framework.observe( - self.charm.on[self.relation_name].relation_broken, self._on_relation_broken - ) - @property def relations(self) -> list[Relation]: """The list of Relation instances associated with this relation_name. @@ -331,29 +328,24 @@ def relations(self) -> list[Relation]: """ return list(self.charm.model.relations[self.relation_name]) - def _on_relation_changed(self, _: EventBase) -> None: - """Handle relation changed events.""" - self.on.data_available.emit() - - def _on_relation_broken(self, _: EventBase) -> None: - """Handle relation broken events.""" - self.on.data_removed.emit() - # pylint: disable=too-many-arguments,too-many-positional-arguments - def set_config( + def provide_spoe_auth_requirements( self, + relation: Relation, spop_port: int, oidc_callback_port: int, - event: str, + event: HaproxyEvent, var_authenticated: str, var_redirect_url: str, cookie_name: str, oidc_callback_hostname: str, oidc_callback_path: str = "/oauth2/callback", + unit_address: Optional[str] = None, ) -> None: """Set the SPOE auth configuration in the application databag. Args: + relation: The relation instance to set data on. spop_port: The port on the agent listening for SPOP. oidc_callback_port: The port on the agent handling OIDC callbacks. event: The event that triggers SPOE messages. @@ -362,24 +354,57 @@ def set_config( cookie_name: Name of the authentication cookie. oidc_callback_hostname: The hostname HAProxy should route OIDC callbacks to. oidc_callback_path: Path for OIDC callback. + unit_address: The address of the unit. + + Raises: + DataValidationError: When validation of application data fails. """ if not self.charm.unit.is_leader(): logger.warning("Only the leader unit can set the SPOE auth configuration.") return - config_data = SpoeAuthProviderAppData( - spop_port=spop_port, - oidc_callback_port=oidc_callback_port, - event=event, - var_authenticated=var_authenticated, - var_redirect_url=var_redirect_url, - cookie_name=cookie_name, - oidc_callback_hostname=oidc_callback_hostname, - oidc_callback_path=oidc_callback_path, - ) + try: + application_data = SpoeAuthProviderAppData( + spop_port=spop_port, + oidc_callback_port=oidc_callback_port, + event=event, + var_authenticated=var_authenticated, + var_redirect_url=var_redirect_url, + cookie_name=cookie_name, + oidc_callback_hostname=oidc_callback_hostname, + oidc_callback_path=oidc_callback_path, + ) + unit_data = self._prepare_unit_data(unit_address) + except ValidationError as exc: + logger.error("Validation error when preparing provider relation data.") + raise DataValidationError( + "Validation error when preparing provider relation data." + ) from exc - for relation in self.relations: - config_data.dump(relation.data[self.charm.app], clear=True) + if self.charm.unit.is_leader(): + application_data.dump(relation.data[self.charm.app], clear=True) + unit_data.dump(relation.data[self.charm.unit], clear=True) + + def _prepare_unit_data(self, unit_address: Optional[str]) -> SpoeAuthProviderUnitData: + """Prepare and validate unit data. + + Raises: + DataValidationError: When no address or unit IP is available. + + Returns: + RequirerUnitData: The validated unit data model. + """ + if not unit_address: + network_binding = self.charm.model.get_binding("juju-info") + if ( + network_binding is not None + and (bind_address := network_binding.network.bind_address) is not None + ): + unit_address = str(bind_address) + else: + logger.error("No unit IP available.") + raise DataValidationError("No unit IP available.") + return SpoeAuthProviderUnitData(address=cast(IPvAnyAddress, unit_address)) class SpoeAuthAvailableEvent(EventBase): @@ -410,7 +435,8 @@ class SpoeAuthRequirer(Object): relation: The related application. """ - on = SpoeAuthRequirerEvents() + # Ignore this for pylance + on = SpoeAuthRequirerEvents() # type: ignore def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_RELATION_NAME) -> None: """Initialize the SpoeAuthRequirer. @@ -470,7 +496,7 @@ def is_available(self) -> bool: except (DataValidationError, KeyError): return False - def get_config(self) -> Optional[SpoeAuthProviderAppData]: + def get_data(self) -> Optional[SpoeAuthProviderAppData]: """Get the SPOE auth configuration from the provider. Returns: @@ -500,3 +526,53 @@ def get_config(self) -> Optional[SpoeAuthProviderAppData]: raise SpoeAuthInvalidRelationDataError( f"spoe-auth data validation failed for relation: {self.relation}" ) from exc + + def get_provider_unit_data(self, relation: Relation) -> list[SpoeAuthProviderUnitData]: + """Fetch and validate the requirer's units data. + + Args: + relation: The relation to fetch unit data from. + + Raises: + DataValidationError: When unit data validation fails. + + Returns: + list[SpoeAuthProviderUnitData]: List of validated unit data from the provider. + """ + requirer_units_data: list[SpoeAuthProviderUnitData] = [] + + for unit in relation.units: + databag = relation.data.get(unit) + if not databag: + logger.error( + "Requirer unit data does not exist even though the unit is still present." + ) + continue + try: + data = cast(SpoeAuthProviderUnitData, SpoeAuthProviderUnitData.load(databag)) + requirer_units_data.append(data) + except DataValidationError: + logger.error("Invalid requirer application data for %s", unit) + raise + return requirer_units_data + + def get_provider_application_data(self, relation: Relation) -> SpoeAuthProviderAppData: + """Fetch and validate the requirer's application databag. + + Args: + relation: The relation to fetch application data from. + + Raises: + DataValidationError: When requirer application data validation fails. + + Returns: + RequirerApplicationData: Validated application data from the requirer. + """ + try: + return cast( + SpoeAuthProviderAppData, + SpoeAuthProviderAppData.load(relation.data[relation.app]), + ) + except DataValidationError: + logger.error("Invalid requirer application data for %s", relation.app.name) + raise From e994bf8fa9066bcb286504b49b234e6fa6bc7875 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 13 Nov 2025 14:02:09 +0100 Subject: [PATCH 04/13] update lib, add test, ignore ruff lint errors --- lib/charms/haproxy/v0/spoe_auth.py | 50 +++-- pyproject.toml | 2 +- tests/unit/test_spoe_auth_lib.py | 348 +++++++++++++++++++++++++++++ 3 files changed, 381 insertions(+), 19 deletions(-) create mode 100644 tests/unit/test_spoe_auth_lib.py diff --git a/lib/charms/haproxy/v0/spoe_auth.py b/lib/charms/haproxy/v0/spoe_auth.py index d0613b2fe..1bb349bf9 100644 --- a/lib/charms/haproxy/v0/spoe_auth.py +++ b/lib/charms/haproxy/v0/spoe_auth.py @@ -95,6 +95,7 @@ def _on_config_changed(self, event): import json import logging +import re from enum import StrEnum from typing import Annotated, MutableMapping, Optional, cast @@ -117,10 +118,11 @@ def _on_config_changed(self, event): logger = logging.getLogger(__name__) SPOE_AUTH_RELATION_NAME = "spoe-auth" HAPROXY_CONFIG_INVALID_CHARACTERS = "\n\t#\\'\"\r$ " -# Definition of a hostname according to RFC 1123 -# https://stackoverflow.com/a/2063247 -HOSTNAME_REGEXP = r"^(?![0-9]+$)(?!-)[a-zA-Z0-9-]{,63}(? Optional[str]: """Validate if value contains invalid haproxy config characters. @@ -142,6 +144,20 @@ def value_contains_invalid_characters(value: Optional[str]) -> Optional[str]: return value +def validate_hostname(value: str) -> str: + """Validate if value is a valid hostname per RFC 1123. + + Args: + value: The value to validate. + + Raises: + ValueError: When value is not a valid hostname. + """ + if not re.match(HOSTNAME_REGEX, value): + raise ValueError(f"Invalid hostname: {value}") + return value + + VALIDSTR = Annotated[str, BeforeValidator(value_contains_invalid_characters)] @@ -228,18 +244,20 @@ def dump( if nest_under: databag[nest_under] = self.model_dump_json( by_alias=True, - # skip keys whose values are default - exclude_defaults=True, ) return databag - dct = self.model_dump(mode="json", by_alias=True, exclude_defaults=True) + dct = self.model_dump(mode="json", by_alias=True) databag.update({k: json.dumps(v) for k, v in dct.items()}) return databag class HaproxyEvent(StrEnum): - """Enumeration of HAProxy SPOE events.""" + """Enumeration of HAProxy SPOE events. + + Attributes: + ON_FRONTEND_HTTP_REQUEST: Event triggered on frontend HTTP request. + """ ON_FRONTEND_HTTP_REQUEST = "on-frontend-http-request" @@ -280,13 +298,11 @@ class SpoeAuthProviderAppData(_DatabagModel): cookie_name: VALIDSTR = Field( description="Name of the authentication cookie used by the SPOE agent.", ) - oidc_callback_path: VALIDSTR = Field( - description="Path for OIDC callback.", - default="/oauth2/callback", + oidc_callback_path: Optional[VALIDSTR] = Field( + description="Path for OIDC callback.", default="/oauth2/callback" ) - oidc_callback_hostname: str = Field( + oidc_callback_hostname: Annotated[str, BeforeValidator(validate_hostname)] = Field( description="The hostname HAProxy should route OIDC callbacks to.", - pattern=HOSTNAME_REGEXP, ) @@ -304,7 +320,6 @@ class SpoeAuthProvider(Object): """SPOE auth interface provider implementation. Attributes: - on: Custom events of the provider. relations: Related applications. """ @@ -449,9 +464,8 @@ def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_RELATION_NAM self.charm = charm self.relation_name = relation_name - self.framework.observe( - self.charm.on[self.relation_name].relation_changed, self._on_relation_changed - ) + self.framework.observe(self.charm.on[self.relation_name].relation_created, self._configure) + self.framework.observe(self.charm.on[self.relation_name].relation_changed, self._configure) self.framework.observe( self.charm.on[self.relation_name].relation_broken, self._on_relation_broken ) @@ -466,7 +480,7 @@ def relation(self) -> Optional[Relation]: relations = self.charm.model.relations[self.relation_name] return relations[0] if relations else None - def _on_relation_changed(self, _: EventBase) -> None: + def _configure(self, _: EventBase) -> None: """Handle relation changed events.""" if self.is_available(): self.on.available.emit() diff --git a/pyproject.toml b/pyproject.toml index 4b083dae9..3598d0a1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ extend-ignore = [ ] ignore = ["E501", "D107"] extend-exclude = ["__pycache__", "*.egg_info"] -per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} +per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104","D205","D212"]} [tool.ruff.mccabe] max-complexity = 10 diff --git a/tests/unit/test_spoe_auth_lib.py b/tests/unit/test_spoe_auth_lib.py new file mode 100644 index 000000000..74a09f478 --- /dev/null +++ b/tests/unit/test_spoe_auth_lib.py @@ -0,0 +1,348 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for SPOE auth interface library.""" + +import json +from typing import Any, cast + +import pytest +from charms.haproxy.v0.spoe_auth import ( + DataValidationError, + HaproxyEvent, + SpoeAuthProviderAppData, + SpoeAuthProviderUnitData, +) +from pydantic import IPvAnyAddress, ValidationError + +MOCK_ADDRESS = "10.0.0.1" +MOCK_SPOP_PORT = 8081 +MOCK_OIDC_CALLBACK_PORT = 5000 +MOCK_VAR_AUTHENTICATED = "var.sess.is_authenticated" +MOCK_VAR_REDIRECT_URL = "var.sess.redirect_url" +MOCK_COOKIE_NAME = "auth_session" +MOCK_OIDC_CALLBACK_HOSTNAME = "auth.example.com" +MOCK_OIDC_CALLBACK_PATH = "/oauth2/callback" + + +@pytest.fixture(name="mock_provider_app_data_dict") +def mock_provider_app_data_dict_fixture(): + """Create mock provider application data dictionary.""" + return { + "spop_port": MOCK_SPOP_PORT, + "oidc_callback_port": MOCK_OIDC_CALLBACK_PORT, + "event": "on-frontend-http-request", + "var_authenticated": MOCK_VAR_AUTHENTICATED, + "var_redirect_url": MOCK_VAR_REDIRECT_URL, + "cookie_name": MOCK_COOKIE_NAME, + "oidc_callback_path": MOCK_OIDC_CALLBACK_PATH, + "oidc_callback_hostname": MOCK_OIDC_CALLBACK_HOSTNAME, + } + + +@pytest.fixture(name="mock_provider_unit_data_dict") +def mock_provider_unit_data_dict_fixture(): + """Create mock provider unit data dictionary.""" + return {"address": MOCK_ADDRESS} + + +def test_spoe_auth_provider_app_data_validation(): + """ + arrange: Create a SpoeAuthProviderAppData model with valid data. + act: Validate the model. + assert: Model validation passes. + """ + data = SpoeAuthProviderAppData( + spop_port=MOCK_SPOP_PORT, + oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + var_authenticated=MOCK_VAR_AUTHENTICATED, + var_redirect_url=MOCK_VAR_REDIRECT_URL, + cookie_name=MOCK_COOKIE_NAME, + oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, + oidc_callback_path=MOCK_OIDC_CALLBACK_PATH, + ) + + assert data.spop_port == MOCK_SPOP_PORT + assert data.oidc_callback_port == MOCK_OIDC_CALLBACK_PORT + assert data.event == HaproxyEvent.ON_FRONTEND_HTTP_REQUEST + assert data.var_authenticated == MOCK_VAR_AUTHENTICATED + assert data.var_redirect_url == MOCK_VAR_REDIRECT_URL + assert data.cookie_name == MOCK_COOKIE_NAME + assert data.oidc_callback_hostname == MOCK_OIDC_CALLBACK_HOSTNAME + assert data.oidc_callback_path == MOCK_OIDC_CALLBACK_PATH + + +def test_spoe_auth_provider_app_data_default_callback_path(): + """Create SpoeAuthProviderAppData with default callback path. + + arrange: Create a SpoeAuthProviderAppData model without specifying oidc_callback_path. + act: Validate the model. + assert: Model validation passes with default callback path. + """ + data = SpoeAuthProviderAppData( + spop_port=MOCK_SPOP_PORT, + oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + var_authenticated=MOCK_VAR_AUTHENTICATED, + var_redirect_url=MOCK_VAR_REDIRECT_URL, + cookie_name=MOCK_COOKIE_NAME, + oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, + oidc_callback_path="/oauth2/callback", # Explicitly set to the default value + ) + + assert data.oidc_callback_path == "/oauth2/callback" + + +@pytest.mark.parametrize("port", [0, 65526]) +def test_spoe_auth_provider_app_data_invalid_spop_port(port: int): + """ + arrange: Create a SpoeAuthProviderAppData model with spop_port set to 0. + act: Validate the model. + assert: Validation raises an error. + """ + with pytest.raises(ValidationError): + SpoeAuthProviderAppData( + spop_port=port, # Invalid: port must be > 0 and <= 65525 + oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + var_authenticated=MOCK_VAR_AUTHENTICATED, + var_redirect_url=MOCK_VAR_REDIRECT_URL, + cookie_name=MOCK_COOKIE_NAME, + oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, + ) + + +@pytest.mark.parametrize("port", [0, 65526]) +def test_spoe_auth_provider_app_data_invalid_oidc_callback_port(port: int): + """ + arrange: Create a SpoeAuthProviderAppData model with invalid oidc_callback_port. + act: Validate the model. + assert: Validation raises an error. + """ + with pytest.raises(ValidationError): + SpoeAuthProviderAppData( + spop_port=MOCK_SPOP_PORT, + oidc_callback_port=port, # Invalid: port must be > 0 and <= 65525 + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + var_authenticated=MOCK_VAR_AUTHENTICATED, + var_redirect_url=MOCK_VAR_REDIRECT_URL, + cookie_name=MOCK_COOKIE_NAME, + oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, + ) + + +def test_spoe_auth_provider_app_data_invalid_hostname_format(): + """ + arrange: Create a SpoeAuthProviderAppData model with invalid hostname format. + act: Validate the model. + assert: Validation raises an error. + """ + with pytest.raises(ValidationError): + SpoeAuthProviderAppData( + spop_port=MOCK_SPOP_PORT, + oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + var_authenticated=MOCK_VAR_AUTHENTICATED, + var_redirect_url=MOCK_VAR_REDIRECT_URL, + cookie_name=MOCK_COOKIE_NAME, + oidc_callback_hostname="invalid-hostname-!@#", # Invalid: contains special chars + ) + + +def test_spoe_auth_provider_app_data_invalid_char_in_var_authenticated(): + """ + arrange: Create a SpoeAuthProviderAppData model with invalid characters in var_authenticated. + act: Validate the model. + assert: Validation raises an error. + """ + with pytest.raises(ValidationError): + SpoeAuthProviderAppData( + spop_port=MOCK_SPOP_PORT, + oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + var_authenticated="invalid\nvar", # Invalid: newline character + var_redirect_url=MOCK_VAR_REDIRECT_URL, + cookie_name=MOCK_COOKIE_NAME, + oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, + ) + + +def test_spoe_auth_provider_unit_data_validation(): + """ + arrange: Create a SpoeAuthProviderUnitData model with valid data. + act: Validate the model. + assert: Model validation passes. + """ + data = SpoeAuthProviderUnitData(address=cast(IPvAnyAddress, MOCK_ADDRESS)) + + assert str(data.address) == MOCK_ADDRESS + + +def test_spoe_auth_provider_unit_data_ipv6_validation(): + """ + arrange: Create a SpoeAuthProviderUnitData model with IPv6 address. + act: Validate the model. + assert: Model validation passes. + """ + ipv6_address = "2001:db8::1" + data = SpoeAuthProviderUnitData(address=cast(IPvAnyAddress, ipv6_address)) + + assert str(data.address) == ipv6_address + + +def test_spoe_auth_provider_unit_data_invalid_address(): + """ + arrange: Create a SpoeAuthProviderUnitData model with invalid IP address. + act: Validate the model. + assert: Validation raises an error. + """ + with pytest.raises(ValidationError): + SpoeAuthProviderUnitData(address=cast(IPvAnyAddress, "invalid-ip-address")) + + +def test_load_provider_app_data(mock_provider_app_data_dict): + """ + arrange: Create a databag with valid provider application data. + act: Load the data with SpoeAuthProviderAppData.load(). + assert: Data is loaded correctly. + """ + databag = {k: json.dumps(v) for k, v in mock_provider_app_data_dict.items()} + data = cast(SpoeAuthProviderAppData, SpoeAuthProviderAppData.load(databag)) + + assert data.spop_port == MOCK_SPOP_PORT + assert data.oidc_callback_port == MOCK_OIDC_CALLBACK_PORT + assert data.event == HaproxyEvent.ON_FRONTEND_HTTP_REQUEST + assert data.var_authenticated == MOCK_VAR_AUTHENTICATED + assert data.var_redirect_url == MOCK_VAR_REDIRECT_URL + assert data.cookie_name == MOCK_COOKIE_NAME + assert data.oidc_callback_path == MOCK_OIDC_CALLBACK_PATH + assert data.oidc_callback_hostname == MOCK_OIDC_CALLBACK_HOSTNAME + + +def test_load_provider_app_data_invalid_databag(): + """ + arrange: Create a databag with invalid JSON. + act: Load the data with SpoeAuthProviderAppData.load(). + assert: DataValidationError is raised. + """ + invalid_databag = { + "spop_port": "not-json", + } + with pytest.raises(DataValidationError): + SpoeAuthProviderAppData.load(invalid_databag) + + +def test_dump_provider_app_data(): + """Dump provider app data to databag. + + arrange: Create a SpoeAuthProviderAppData model with valid data. + act: Dump the model to a databag. + assert: Databag contains correct data. + """ + data = SpoeAuthProviderAppData( + spop_port=MOCK_SPOP_PORT, + oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + var_authenticated=MOCK_VAR_AUTHENTICATED, + var_redirect_url=MOCK_VAR_REDIRECT_URL, + cookie_name=MOCK_COOKIE_NAME, + oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, + oidc_callback_path=MOCK_OIDC_CALLBACK_PATH, + ) + + databag: dict[str, Any] = {} + result = data.dump(databag) + + assert result is not None + assert "spop_port" in databag + assert json.loads(databag["spop_port"]) == MOCK_SPOP_PORT + assert json.loads(databag["oidc_callback_port"]) == MOCK_OIDC_CALLBACK_PORT + assert json.loads(databag["event"]) == "on-frontend-http-request" + assert json.loads(databag["var_authenticated"]) == MOCK_VAR_AUTHENTICATED + assert json.loads(databag["var_redirect_url"]) == MOCK_VAR_REDIRECT_URL + assert json.loads(databag["cookie_name"]) == MOCK_COOKIE_NAME + # oidc_callback_path should be included when explicitly set + if "oidc_callback_path" in databag: + assert json.loads(databag["oidc_callback_path"]) == MOCK_OIDC_CALLBACK_PATH + assert json.loads(databag["oidc_callback_hostname"]) == MOCK_OIDC_CALLBACK_HOSTNAME + + +def test_dump_and_load_provider_app_data_roundtrip(): + """ + arrange: Create a SpoeAuthProviderAppData model. + act: Dump and then load it again. + assert: The loaded data matches the original. + """ + original_data = SpoeAuthProviderAppData( + spop_port=MOCK_SPOP_PORT, + oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + var_authenticated=MOCK_VAR_AUTHENTICATED, + var_redirect_url=MOCK_VAR_REDIRECT_URL, + cookie_name=MOCK_COOKIE_NAME, + oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, + oidc_callback_path=MOCK_OIDC_CALLBACK_PATH, + ) + + # Dump to databag + databag: dict[str, Any] = {} + original_data.dump(databag) + + # Load from databag + loaded_data = cast(SpoeAuthProviderAppData, SpoeAuthProviderAppData.load(databag)) + + assert loaded_data.spop_port == original_data.spop_port + assert loaded_data.oidc_callback_port == original_data.oidc_callback_port + assert loaded_data.event == original_data.event + assert loaded_data.var_authenticated == original_data.var_authenticated + assert loaded_data.var_redirect_url == original_data.var_redirect_url + assert loaded_data.cookie_name == original_data.cookie_name + assert loaded_data.oidc_callback_hostname == original_data.oidc_callback_hostname + assert loaded_data.oidc_callback_path == original_data.oidc_callback_path + + +def test_load_provider_unit_data(mock_provider_unit_data_dict): + """ + arrange: Create a databag with valid unit data. + act: Load the data with SpoeAuthProviderUnitData.load(). + assert: Data is loaded correctly. + """ + databag = {k: json.dumps(v) for k, v in mock_provider_unit_data_dict.items()} + data = cast(SpoeAuthProviderUnitData, SpoeAuthProviderUnitData.load(databag)) + + assert str(data.address) == MOCK_ADDRESS + + +def test_dump_provider_unit_data(): + """ + arrange: Create a SpoeAuthProviderUnitData model with valid data. + act: Dump the model to a databag. + assert: Databag contains correct data. + """ + data = SpoeAuthProviderUnitData(address=cast(IPvAnyAddress, MOCK_ADDRESS)) + + databag: dict[str, Any] = {} + result = data.dump(databag) + + assert result is not None + assert "address" in databag + assert json.loads(databag["address"]) == MOCK_ADDRESS + + +def test_dump_and_load_provider_unit_data_roundtrip(): + """ + arrange: Create a SpoeAuthProviderUnitData model. + act: Dump and then load it again. + assert: The loaded data matches the original. + """ + original_data = SpoeAuthProviderUnitData(address=cast(IPvAnyAddress, MOCK_ADDRESS)) + + # Dump to databag + databag: dict[str, Any] = {} + original_data.dump(databag) + + # Load from databag + loaded_data = cast(SpoeAuthProviderUnitData, SpoeAuthProviderUnitData.load(databag)) + + assert str(loaded_data.address) == str(original_data.address) From e310ce43b143cb8d8373f543720c9758d370dd9b Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 13 Nov 2025 14:05:20 +0100 Subject: [PATCH 05/13] add changelog --- docs/changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 358b4bf87..8f210c19a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Each revision is versioned by the date of the revision. +## 2025-11-13 + +- Added the `spoe-auth` library and requirer/provider class implementation. + ## 2025-10-14 - Added action `get-proxied-endpoints`. From 3904e3c91002fbee3d474dbc143630e65776f854 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 13 Nov 2025 14:22:18 +0100 Subject: [PATCH 06/13] minor fix to the dataclass, fix lint --- lib/charms/haproxy/v0/spoe_auth.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/charms/haproxy/v0/spoe_auth.py b/lib/charms/haproxy/v0/spoe_auth.py index 1bb349bf9..5e51f66c7 100644 --- a/lib/charms/haproxy/v0/spoe_auth.py +++ b/lib/charms/haproxy/v0/spoe_auth.py @@ -1,6 +1,7 @@ # Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. +# pylint: disable=duplicate-code """SPOE-auth interface library. ## Getting Started @@ -44,10 +45,9 @@ def __init__(self, *args): def _on_spoe_auth_available(self, event): # The SPOE auth configuration is available - if self.spoe_auth.is_available(): - application_data = self.spoe_auth.get_provider_application_data() - unit_data = self.spoe_auth.get_provider_unit_data() - ... + application_data = self.spoe_auth.get_provider_application_data() + unit_data = self.spoe_auth.get_provider_unit_data() + ... def _on_spoe_auth_removed(self, event): # Handle relation broken event @@ -124,7 +124,8 @@ def _on_config_changed(self, event): r"{1,63}(? Optional[str]: + +def value_contains_invalid_characters(value: str) -> str: """Validate if value contains invalid haproxy config characters. Args: @@ -136,9 +137,6 @@ def value_contains_invalid_characters(value: Optional[str]) -> Optional[str]: Returns: The validated value. """ - if value is None: - return value - if [char for char in value if char in HAPROXY_CONFIG_INVALID_CHARACTERS]: raise ValueError(f"Relation data contains invalid character(s) {value}") return value @@ -152,6 +150,9 @@ def validate_hostname(value: str) -> str: Raises: ValueError: When value is not a valid hostname. + + Returns: + The validated value. """ if not re.match(HOSTNAME_REGEX, value): raise ValueError(f"Invalid hostname: {value}") @@ -298,7 +299,7 @@ class SpoeAuthProviderAppData(_DatabagModel): cookie_name: VALIDSTR = Field( description="Name of the authentication cookie used by the SPOE agent.", ) - oidc_callback_path: Optional[VALIDSTR] = Field( + oidc_callback_path: VALIDSTR = Field( description="Path for OIDC callback.", default="/oauth2/callback" ) oidc_callback_hostname: Annotated[str, BeforeValidator(validate_hostname)] = Field( From 51cafe9facb16c3688dec71f4ed7f5b4361bdc80 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 13 Nov 2025 14:24:44 +0100 Subject: [PATCH 07/13] rename constant --- lib/charms/haproxy/v0/spoe_auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/charms/haproxy/v0/spoe_auth.py b/lib/charms/haproxy/v0/spoe_auth.py index 5e51f66c7..0003c7899 100644 --- a/lib/charms/haproxy/v0/spoe_auth.py +++ b/lib/charms/haproxy/v0/spoe_auth.py @@ -116,7 +116,7 @@ def _on_config_changed(self, event): LIBPATCH = 1 logger = logging.getLogger(__name__) -SPOE_AUTH_RELATION_NAME = "spoe-auth" +SPOE_AUTH_DEFAULT_RELATION_NAME = "spoe-auth" HAPROXY_CONFIG_INVALID_CHARACTERS = "\n\t#\\'\"\r$ " # RFC-1034 and RFC-2181 compliance REGEX for validating FQDNs HOSTNAME_REGEX = ( @@ -324,7 +324,7 @@ class SpoeAuthProvider(Object): relations: Related applications. """ - def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_RELATION_NAME) -> None: + def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_DEFAULT_RELATION_NAME) -> None: """Initialize the SpoeAuthProvider. Args: @@ -454,7 +454,7 @@ class SpoeAuthRequirer(Object): # Ignore this for pylance on = SpoeAuthRequirerEvents() # type: ignore - def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_RELATION_NAME) -> None: + def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_DEFAULT_RELATION_NAME) -> None: """Initialize the SpoeAuthRequirer. Args: From a6c32c25bcac4300de629e14baf1fbd07be0ebb5 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 22:16:43 +0100 Subject: [PATCH 08/13] update lib, update tests --- lib/charms/haproxy/v0/spoe_auth.py | 108 ++++-------- tests/unit/test_spoe_auth_lib.py | 262 ++++++++++++++++++----------- 2 files changed, 194 insertions(+), 176 deletions(-) diff --git a/lib/charms/haproxy/v0/spoe_auth.py b/lib/charms/haproxy/v0/spoe_auth.py index 0003c7899..bd9ab1675 100644 --- a/lib/charms/haproxy/v0/spoe_auth.py +++ b/lib/charms/haproxy/v0/spoe_auth.py @@ -13,50 +13,9 @@ charmcraft fetch-lib charms.haproxy.v0.spoe_auth ``` -## Using the library as the Requirer - -The requirer charm (haproxy-operator) should expose the interface as shown below: - -In the `metadata.yaml` of the charm, add the following: - -```yaml -requires: - spoe-auth: - interface: spoe-auth - limit: 1 -``` - -Then, to initialise the library: - -```python -from charms.haproxy.v0.spoe_auth import SpoeAuthRequirer - -class HaproxyCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - self.spoe_auth = SpoeAuthRequirer(self, relation_name="spoe-auth") - - self.framework.observe( - self.spoe_auth.on.available, self._on_spoe_auth_available - ) - self.framework.observe( - self.spoe_auth.on.removed, self._on_spoe_auth_removed - ) - - def _on_spoe_auth_available(self, event): - # The SPOE auth configuration is available - application_data = self.spoe_auth.get_provider_application_data() - unit_data = self.spoe_auth.get_provider_unit_data() - ... - - def _on_spoe_auth_removed(self, event): - # Handle relation broken event - ... -``` - ## Using the library as the Provider -The provider charm (SPOE agent) should expose the interface as shown below: +The provider charm should expose the interface as shown below: ```yaml provides: @@ -87,7 +46,7 @@ def _on_config_changed(self, event): var_authenticated="var.sess.is_authenticated", var_redirect_url="var.sess.redirect_url", cookie_name="auth_session", - oidc_callback_hostname="auth.example.com", + hostname="auth.example.com", oidc_callback_path="/oauth2/callback", ) ``` @@ -96,8 +55,9 @@ def _on_config_changed(self, event): import json import logging import re +from collections.abc import MutableMapping from enum import StrEnum -from typing import Annotated, MutableMapping, Optional, cast +from typing import Annotated, cast from ops import CharmBase, RelationBrokenEvent from ops.charm import CharmEvents @@ -105,23 +65,13 @@ def _on_config_changed(self, event): from ops.model import Relation from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, IPvAnyAddress, ValidationError -# The unique Charmhub library identifier, never change it -LIBID = "3f644e37fffc483aa97bea91d4fc0bce" - -# 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 - logger = logging.getLogger(__name__) SPOE_AUTH_DEFAULT_RELATION_NAME = "spoe-auth" HAPROXY_CONFIG_INVALID_CHARACTERS = "\n\t#\\'\"\r$ " # RFC-1034 and RFC-2181 compliance REGEX for validating FQDNs HOSTNAME_REGEX = ( - r"(?=.{1,253})(?!.*--.*)(?:(?!-)(?![0-9])[a-zA-Z0-9-]" - r"{1,63}(? "_DatabagModel": + def load(cls, databag: MutableMapping[str, str]) -> "_DatabagModel": """Load this model from a Juju json databag. Args: @@ -221,12 +171,12 @@ def load(cls, databag: MutableMapping) -> "_DatabagModel": return cls.model_validate_json(json.dumps(data)) except ValidationError as e: msg = f"failed to validate databag: {databag}" - logger.error(str(e), exc_info=True) + logger.error(msg) raise DataValidationError(msg) from e def dump( - self, databag: Optional[MutableMapping] = None, clear: bool = True - ) -> Optional[MutableMapping]: + self, databag: MutableMapping[str, str] | None = None, clear: bool = True + ) -> MutableMapping[str, str] | None: """Write the contents of this model to Juju databag. Args: @@ -290,6 +240,9 @@ class SpoeAuthProviderAppData(_DatabagModel): event: HaproxyEvent = Field( description="The event that triggers SPOE messages (e.g., on-http-request).", ) + message_name: str = Field( + description="The name of the SPOE message that the provider expects." + ) var_authenticated: VALIDSTR = Field( description="Name of the variable set by the SPOE agent for auth status.", ) @@ -302,7 +255,7 @@ class SpoeAuthProviderAppData(_DatabagModel): oidc_callback_path: VALIDSTR = Field( description="Path for OIDC callback.", default="/oauth2/callback" ) - oidc_callback_hostname: Annotated[str, BeforeValidator(validate_hostname)] = Field( + hostname: Annotated[str, BeforeValidator(validate_hostname)] = Field( description="The hostname HAProxy should route OIDC callbacks to.", ) @@ -324,7 +277,9 @@ class SpoeAuthProvider(Object): relations: Related applications. """ - def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_DEFAULT_RELATION_NAME) -> None: + def __init__( + self, charm: CharmBase, relation_name: str = SPOE_AUTH_DEFAULT_RELATION_NAME + ) -> None: """Initialize the SpoeAuthProvider. Args: @@ -351,12 +306,13 @@ def provide_spoe_auth_requirements( spop_port: int, oidc_callback_port: int, event: HaproxyEvent, + message_name: str, var_authenticated: str, var_redirect_url: str, cookie_name: str, - oidc_callback_hostname: str, + hostname: str, oidc_callback_path: str = "/oauth2/callback", - unit_address: Optional[str] = None, + unit_address: str | None = None, ) -> None: """Set the SPOE auth configuration in the application databag. @@ -365,10 +321,11 @@ def provide_spoe_auth_requirements( spop_port: The port on the agent listening for SPOP. oidc_callback_port: The port on the agent handling OIDC callbacks. event: The event that triggers SPOE messages. + message_name: The name of the SPOE message that the provider expects. var_authenticated: Name of the variable for auth status. var_redirect_url: Name of the variable for IDP redirect URL. cookie_name: Name of the authentication cookie. - oidc_callback_hostname: The hostname HAProxy should route OIDC callbacks to. + hostname: The hostname HAProxy should route OIDC callbacks to. oidc_callback_path: Path for OIDC callback. unit_address: The address of the unit. @@ -384,10 +341,11 @@ def provide_spoe_auth_requirements( spop_port=spop_port, oidc_callback_port=oidc_callback_port, event=event, + message_name=message_name, var_authenticated=var_authenticated, var_redirect_url=var_redirect_url, cookie_name=cookie_name, - oidc_callback_hostname=oidc_callback_hostname, + hostname=hostname, oidc_callback_path=oidc_callback_path, ) unit_data = self._prepare_unit_data(unit_address) @@ -401,7 +359,7 @@ def provide_spoe_auth_requirements( application_data.dump(relation.data[self.charm.app], clear=True) unit_data.dump(relation.data[self.charm.unit], clear=True) - def _prepare_unit_data(self, unit_address: Optional[str]) -> SpoeAuthProviderUnitData: + def _prepare_unit_data(self, unit_address: str | None) -> SpoeAuthProviderUnitData: """Prepare and validate unit data. Raises: @@ -411,7 +369,7 @@ def _prepare_unit_data(self, unit_address: Optional[str]) -> SpoeAuthProviderUni RequirerUnitData: The validated unit data model. """ if not unit_address: - network_binding = self.charm.model.get_binding("juju-info") + network_binding = self.charm.model.get_binding(self.relation_name) if ( network_binding is not None and (bind_address := network_binding.network.bind_address) is not None @@ -420,7 +378,7 @@ def _prepare_unit_data(self, unit_address: Optional[str]) -> SpoeAuthProviderUni else: logger.error("No unit IP available.") raise DataValidationError("No unit IP available.") - return SpoeAuthProviderUnitData(address=cast(IPvAnyAddress, unit_address)) + return SpoeAuthProviderUnitData(address=cast("IPvAnyAddress", unit_address)) class SpoeAuthAvailableEvent(EventBase): @@ -454,7 +412,9 @@ class SpoeAuthRequirer(Object): # Ignore this for pylance on = SpoeAuthRequirerEvents() # type: ignore - def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_DEFAULT_RELATION_NAME) -> None: + def __init__( + self, charm: CharmBase, relation_name: str = SPOE_AUTH_DEFAULT_RELATION_NAME + ) -> None: """Initialize the SpoeAuthRequirer. Args: @@ -472,7 +432,7 @@ def __init__(self, charm: CharmBase, relation_name: str = SPOE_AUTH_DEFAULT_RELA ) @property - def relation(self) -> Optional[Relation]: + def relation(self) -> Relation | None: """The relation instance associated with this relation_name. Returns: @@ -511,7 +471,7 @@ def is_available(self) -> bool: except (DataValidationError, KeyError): return False - def get_data(self) -> Optional[SpoeAuthProviderAppData]: + def get_data(self) -> SpoeAuthProviderAppData | None: """Get the SPOE auth configuration from the provider. Returns: @@ -564,7 +524,7 @@ def get_provider_unit_data(self, relation: Relation) -> list[SpoeAuthProviderUni ) continue try: - data = cast(SpoeAuthProviderUnitData, SpoeAuthProviderUnitData.load(databag)) + data = cast("SpoeAuthProviderUnitData", SpoeAuthProviderUnitData.load(databag)) requirer_units_data.append(data) except DataValidationError: logger.error("Invalid requirer application data for %s", unit) @@ -585,7 +545,7 @@ def get_provider_application_data(self, relation: Relation) -> SpoeAuthProviderA """ try: return cast( - SpoeAuthProviderAppData, + "SpoeAuthProviderAppData", SpoeAuthProviderAppData.load(relation.data[relation.app]), ) except DataValidationError: diff --git a/tests/unit/test_spoe_auth_lib.py b/tests/unit/test_spoe_auth_lib.py index 74a09f478..4ba82de54 100644 --- a/tests/unit/test_spoe_auth_lib.py +++ b/tests/unit/test_spoe_auth_lib.py @@ -4,10 +4,12 @@ """Unit tests for SPOE auth interface library.""" import json +import re from typing import Any, cast import pytest from charms.haproxy.v0.spoe_auth import ( + HOSTNAME_REGEX, DataValidationError, HaproxyEvent, SpoeAuthProviderAppData, @@ -15,35 +17,36 @@ ) from pydantic import IPvAnyAddress, ValidationError -MOCK_ADDRESS = "10.0.0.1" -MOCK_SPOP_PORT = 8081 -MOCK_OIDC_CALLBACK_PORT = 5000 -MOCK_VAR_AUTHENTICATED = "var.sess.is_authenticated" -MOCK_VAR_REDIRECT_URL = "var.sess.redirect_url" -MOCK_COOKIE_NAME = "auth_session" -MOCK_OIDC_CALLBACK_HOSTNAME = "auth.example.com" -MOCK_OIDC_CALLBACK_PATH = "/oauth2/callback" +PLACEHOLDER_ADDRESS = "10.0.0.1" +PLACEHOLDER_SPOP_PORT = 8081 +PLACEHOLDER_OIDC_CALLBACK_PORT = 5000 +PLACEHOLDER_VAR_AUTHENTICATED = "var.sess.is_authenticated" +PLACEHOLDER_VAR_REDIRECT_URL = "var.sess.redirect_url" +PLACEHOLDER_COOKIE_NAME = "auth_session" +PLACEHOLDER_HOSTNAME = "auth.example.com" +PLACEHOLDER_OIDC_CALLBACK_PATH = "/oauth2/callback" @pytest.fixture(name="mock_provider_app_data_dict") -def mock_provider_app_data_dict_fixture(): +def mock_provider_app_data_dict_fixture() -> dict[str, Any]: """Create mock provider application data dictionary.""" return { - "spop_port": MOCK_SPOP_PORT, - "oidc_callback_port": MOCK_OIDC_CALLBACK_PORT, + "spop_port": PLACEHOLDER_SPOP_PORT, + "oidc_callback_port": PLACEHOLDER_OIDC_CALLBACK_PORT, "event": "on-frontend-http-request", - "var_authenticated": MOCK_VAR_AUTHENTICATED, - "var_redirect_url": MOCK_VAR_REDIRECT_URL, - "cookie_name": MOCK_COOKIE_NAME, - "oidc_callback_path": MOCK_OIDC_CALLBACK_PATH, - "oidc_callback_hostname": MOCK_OIDC_CALLBACK_HOSTNAME, + "message_name": "try-auth-oidc", + "var_authenticated": PLACEHOLDER_VAR_AUTHENTICATED, + "var_redirect_url": PLACEHOLDER_VAR_REDIRECT_URL, + "cookie_name": PLACEHOLDER_COOKIE_NAME, + "oidc_callback_path": PLACEHOLDER_OIDC_CALLBACK_PATH, + "hostname": PLACEHOLDER_HOSTNAME, } @pytest.fixture(name="mock_provider_unit_data_dict") -def mock_provider_unit_data_dict_fixture(): +def mock_provider_unit_data_dict_fixture() -> dict[str, str]: """Create mock provider unit data dictionary.""" - return {"address": MOCK_ADDRESS} + return {"address": PLACEHOLDER_ADDRESS} def test_spoe_auth_provider_app_data_validation(): @@ -53,24 +56,25 @@ def test_spoe_auth_provider_app_data_validation(): assert: Model validation passes. """ data = SpoeAuthProviderAppData( - spop_port=MOCK_SPOP_PORT, - oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, - var_authenticated=MOCK_VAR_AUTHENTICATED, - var_redirect_url=MOCK_VAR_REDIRECT_URL, - cookie_name=MOCK_COOKIE_NAME, - oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, - oidc_callback_path=MOCK_OIDC_CALLBACK_PATH, + message_name="try-auth-oidc", + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_HOSTNAME, + oidc_callback_path=PLACEHOLDER_OIDC_CALLBACK_PATH, ) - assert data.spop_port == MOCK_SPOP_PORT - assert data.oidc_callback_port == MOCK_OIDC_CALLBACK_PORT + assert data.spop_port == PLACEHOLDER_SPOP_PORT + assert data.oidc_callback_port == PLACEHOLDER_OIDC_CALLBACK_PORT assert data.event == HaproxyEvent.ON_FRONTEND_HTTP_REQUEST - assert data.var_authenticated == MOCK_VAR_AUTHENTICATED - assert data.var_redirect_url == MOCK_VAR_REDIRECT_URL - assert data.cookie_name == MOCK_COOKIE_NAME - assert data.oidc_callback_hostname == MOCK_OIDC_CALLBACK_HOSTNAME - assert data.oidc_callback_path == MOCK_OIDC_CALLBACK_PATH + assert data.var_authenticated == PLACEHOLDER_VAR_AUTHENTICATED + assert data.var_redirect_url == PLACEHOLDER_VAR_REDIRECT_URL + assert data.cookie_name == PLACEHOLDER_COOKIE_NAME + assert data.hostname == PLACEHOLDER_HOSTNAME + assert data.oidc_callback_path == PLACEHOLDER_OIDC_CALLBACK_PATH def test_spoe_auth_provider_app_data_default_callback_path(): @@ -81,13 +85,14 @@ def test_spoe_auth_provider_app_data_default_callback_path(): assert: Model validation passes with default callback path. """ data = SpoeAuthProviderAppData( - spop_port=MOCK_SPOP_PORT, - oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, - var_authenticated=MOCK_VAR_AUTHENTICATED, - var_redirect_url=MOCK_VAR_REDIRECT_URL, - cookie_name=MOCK_COOKIE_NAME, - oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, + message_name="try-auth-oidc", + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_HOSTNAME, oidc_callback_path="/oauth2/callback", # Explicitly set to the default value ) @@ -104,12 +109,13 @@ def test_spoe_auth_provider_app_data_invalid_spop_port(port: int): with pytest.raises(ValidationError): SpoeAuthProviderAppData( spop_port=port, # Invalid: port must be > 0 and <= 65525 - oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, - var_authenticated=MOCK_VAR_AUTHENTICATED, - var_redirect_url=MOCK_VAR_REDIRECT_URL, - cookie_name=MOCK_COOKIE_NAME, - oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, + message_name="try-auth-oidc", + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_HOSTNAME, ) @@ -122,13 +128,14 @@ def test_spoe_auth_provider_app_data_invalid_oidc_callback_port(port: int): """ with pytest.raises(ValidationError): SpoeAuthProviderAppData( - spop_port=MOCK_SPOP_PORT, + spop_port=PLACEHOLDER_SPOP_PORT, oidc_callback_port=port, # Invalid: port must be > 0 and <= 65525 event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, - var_authenticated=MOCK_VAR_AUTHENTICATED, - var_redirect_url=MOCK_VAR_REDIRECT_URL, - cookie_name=MOCK_COOKIE_NAME, - oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, + message_name="try-auth-oidc", + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_HOSTNAME, ) @@ -140,13 +147,14 @@ def test_spoe_auth_provider_app_data_invalid_hostname_format(): """ with pytest.raises(ValidationError): SpoeAuthProviderAppData( - spop_port=MOCK_SPOP_PORT, - oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, - var_authenticated=MOCK_VAR_AUTHENTICATED, - var_redirect_url=MOCK_VAR_REDIRECT_URL, - cookie_name=MOCK_COOKIE_NAME, - oidc_callback_hostname="invalid-hostname-!@#", # Invalid: contains special chars + message_name="try-auth-oidc", + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname="invalid-hostname-!@#", # Invalid: contains special chars ) @@ -158,13 +166,14 @@ def test_spoe_auth_provider_app_data_invalid_char_in_var_authenticated(): """ with pytest.raises(ValidationError): SpoeAuthProviderAppData( - spop_port=MOCK_SPOP_PORT, - oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, + message_name="try-auth-oidc", var_authenticated="invalid\nvar", # Invalid: newline character - var_redirect_url=MOCK_VAR_REDIRECT_URL, - cookie_name=MOCK_COOKIE_NAME, - oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_HOSTNAME, ) @@ -174,9 +183,9 @@ def test_spoe_auth_provider_unit_data_validation(): act: Validate the model. assert: Model validation passes. """ - data = SpoeAuthProviderUnitData(address=cast(IPvAnyAddress, MOCK_ADDRESS)) + data = SpoeAuthProviderUnitData(address=cast("IPvAnyAddress", PLACEHOLDER_ADDRESS)) - assert str(data.address) == MOCK_ADDRESS + assert str(data.address) == PLACEHOLDER_ADDRESS def test_spoe_auth_provider_unit_data_ipv6_validation(): @@ -186,7 +195,7 @@ def test_spoe_auth_provider_unit_data_ipv6_validation(): assert: Model validation passes. """ ipv6_address = "2001:db8::1" - data = SpoeAuthProviderUnitData(address=cast(IPvAnyAddress, ipv6_address)) + data = SpoeAuthProviderUnitData(address=cast("IPvAnyAddress", ipv6_address)) assert str(data.address) == ipv6_address @@ -198,26 +207,27 @@ def test_spoe_auth_provider_unit_data_invalid_address(): assert: Validation raises an error. """ with pytest.raises(ValidationError): - SpoeAuthProviderUnitData(address=cast(IPvAnyAddress, "invalid-ip-address")) + SpoeAuthProviderUnitData(address=cast("IPvAnyAddress", "invalid-ip-address")) -def test_load_provider_app_data(mock_provider_app_data_dict): +def test_load_provider_app_data(mock_provider_app_data_dict: dict[str, Any]): """ arrange: Create a databag with valid provider application data. act: Load the data with SpoeAuthProviderAppData.load(). assert: Data is loaded correctly. """ databag = {k: json.dumps(v) for k, v in mock_provider_app_data_dict.items()} - data = cast(SpoeAuthProviderAppData, SpoeAuthProviderAppData.load(databag)) + data = cast("SpoeAuthProviderAppData", SpoeAuthProviderAppData.load(databag)) - assert data.spop_port == MOCK_SPOP_PORT - assert data.oidc_callback_port == MOCK_OIDC_CALLBACK_PORT + assert data.spop_port == PLACEHOLDER_SPOP_PORT + assert data.oidc_callback_port == PLACEHOLDER_OIDC_CALLBACK_PORT assert data.event == HaproxyEvent.ON_FRONTEND_HTTP_REQUEST - assert data.var_authenticated == MOCK_VAR_AUTHENTICATED - assert data.var_redirect_url == MOCK_VAR_REDIRECT_URL - assert data.cookie_name == MOCK_COOKIE_NAME - assert data.oidc_callback_path == MOCK_OIDC_CALLBACK_PATH - assert data.oidc_callback_hostname == MOCK_OIDC_CALLBACK_HOSTNAME + assert data.var_authenticated == PLACEHOLDER_VAR_AUTHENTICATED + assert data.var_redirect_url == PLACEHOLDER_VAR_REDIRECT_URL + assert data.cookie_name == PLACEHOLDER_COOKIE_NAME + assert data.oidc_callback_path == PLACEHOLDER_OIDC_CALLBACK_PATH + assert data.hostname == PLACEHOLDER_HOSTNAME + assert data.message_name == "try-auth-oidc" def test_load_provider_app_data_invalid_databag(): @@ -241,14 +251,15 @@ def test_dump_provider_app_data(): assert: Databag contains correct data. """ data = SpoeAuthProviderAppData( - spop_port=MOCK_SPOP_PORT, - oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, - var_authenticated=MOCK_VAR_AUTHENTICATED, - var_redirect_url=MOCK_VAR_REDIRECT_URL, - cookie_name=MOCK_COOKIE_NAME, - oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, - oidc_callback_path=MOCK_OIDC_CALLBACK_PATH, + message_name="try-auth-oidc", + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_HOSTNAME, + oidc_callback_path=PLACEHOLDER_OIDC_CALLBACK_PATH, ) databag: dict[str, Any] = {} @@ -256,16 +267,17 @@ def test_dump_provider_app_data(): assert result is not None assert "spop_port" in databag - assert json.loads(databag["spop_port"]) == MOCK_SPOP_PORT - assert json.loads(databag["oidc_callback_port"]) == MOCK_OIDC_CALLBACK_PORT + assert json.loads(databag["spop_port"]) == PLACEHOLDER_SPOP_PORT + assert json.loads(databag["oidc_callback_port"]) == PLACEHOLDER_OIDC_CALLBACK_PORT assert json.loads(databag["event"]) == "on-frontend-http-request" - assert json.loads(databag["var_authenticated"]) == MOCK_VAR_AUTHENTICATED - assert json.loads(databag["var_redirect_url"]) == MOCK_VAR_REDIRECT_URL - assert json.loads(databag["cookie_name"]) == MOCK_COOKIE_NAME + assert json.loads(databag["var_authenticated"]) == PLACEHOLDER_VAR_AUTHENTICATED + assert json.loads(databag["var_redirect_url"]) == PLACEHOLDER_VAR_REDIRECT_URL + assert json.loads(databag["cookie_name"]) == PLACEHOLDER_COOKIE_NAME # oidc_callback_path should be included when explicitly set if "oidc_callback_path" in databag: - assert json.loads(databag["oidc_callback_path"]) == MOCK_OIDC_CALLBACK_PATH - assert json.loads(databag["oidc_callback_hostname"]) == MOCK_OIDC_CALLBACK_HOSTNAME + assert json.loads(databag["oidc_callback_path"]) == PLACEHOLDER_OIDC_CALLBACK_PATH + assert json.loads(databag["hostname"]) == PLACEHOLDER_HOSTNAME + assert json.loads(databag["message_name"]) == "try-auth-oidc" def test_dump_and_load_provider_app_data_roundtrip(): @@ -275,14 +287,15 @@ def test_dump_and_load_provider_app_data_roundtrip(): assert: The loaded data matches the original. """ original_data = SpoeAuthProviderAppData( - spop_port=MOCK_SPOP_PORT, - oidc_callback_port=MOCK_OIDC_CALLBACK_PORT, + spop_port=PLACEHOLDER_SPOP_PORT, + oidc_callback_port=PLACEHOLDER_OIDC_CALLBACK_PORT, event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST, - var_authenticated=MOCK_VAR_AUTHENTICATED, - var_redirect_url=MOCK_VAR_REDIRECT_URL, - cookie_name=MOCK_COOKIE_NAME, - oidc_callback_hostname=MOCK_OIDC_CALLBACK_HOSTNAME, - oidc_callback_path=MOCK_OIDC_CALLBACK_PATH, + message_name="try-auth-oidc", + var_authenticated=PLACEHOLDER_VAR_AUTHENTICATED, + var_redirect_url=PLACEHOLDER_VAR_REDIRECT_URL, + cookie_name=PLACEHOLDER_COOKIE_NAME, + hostname=PLACEHOLDER_HOSTNAME, + oidc_callback_path=PLACEHOLDER_OIDC_CALLBACK_PATH, ) # Dump to databag @@ -290,7 +303,7 @@ def test_dump_and_load_provider_app_data_roundtrip(): original_data.dump(databag) # Load from databag - loaded_data = cast(SpoeAuthProviderAppData, SpoeAuthProviderAppData.load(databag)) + loaded_data = cast("SpoeAuthProviderAppData", SpoeAuthProviderAppData.load(databag)) assert loaded_data.spop_port == original_data.spop_port assert loaded_data.oidc_callback_port == original_data.oidc_callback_port @@ -298,20 +311,20 @@ def test_dump_and_load_provider_app_data_roundtrip(): assert loaded_data.var_authenticated == original_data.var_authenticated assert loaded_data.var_redirect_url == original_data.var_redirect_url assert loaded_data.cookie_name == original_data.cookie_name - assert loaded_data.oidc_callback_hostname == original_data.oidc_callback_hostname + assert loaded_data.hostname == original_data.hostname assert loaded_data.oidc_callback_path == original_data.oidc_callback_path -def test_load_provider_unit_data(mock_provider_unit_data_dict): +def test_load_provider_unit_data(mock_provider_unit_data_dict: dict[str, str]): """ arrange: Create a databag with valid unit data. act: Load the data with SpoeAuthProviderUnitData.load(). assert: Data is loaded correctly. """ databag = {k: json.dumps(v) for k, v in mock_provider_unit_data_dict.items()} - data = cast(SpoeAuthProviderUnitData, SpoeAuthProviderUnitData.load(databag)) + data = cast("SpoeAuthProviderUnitData", SpoeAuthProviderUnitData.load(databag)) - assert str(data.address) == MOCK_ADDRESS + assert str(data.address) == PLACEHOLDER_ADDRESS def test_dump_provider_unit_data(): @@ -320,14 +333,14 @@ def test_dump_provider_unit_data(): act: Dump the model to a databag. assert: Databag contains correct data. """ - data = SpoeAuthProviderUnitData(address=cast(IPvAnyAddress, MOCK_ADDRESS)) + data = SpoeAuthProviderUnitData(address=cast("IPvAnyAddress", PLACEHOLDER_ADDRESS)) databag: dict[str, Any] = {} result = data.dump(databag) assert result is not None assert "address" in databag - assert json.loads(databag["address"]) == MOCK_ADDRESS + assert json.loads(databag["address"]) == PLACEHOLDER_ADDRESS def test_dump_and_load_provider_unit_data_roundtrip(): @@ -336,13 +349,58 @@ def test_dump_and_load_provider_unit_data_roundtrip(): act: Dump and then load it again. assert: The loaded data matches the original. """ - original_data = SpoeAuthProviderUnitData(address=cast(IPvAnyAddress, MOCK_ADDRESS)) + original_data = SpoeAuthProviderUnitData(address=cast("IPvAnyAddress", PLACEHOLDER_ADDRESS)) # Dump to databag databag: dict[str, Any] = {} original_data.dump(databag) # Load from databag - loaded_data = cast(SpoeAuthProviderUnitData, SpoeAuthProviderUnitData.load(databag)) + loaded_data = cast("SpoeAuthProviderUnitData", SpoeAuthProviderUnitData.load(databag)) assert str(loaded_data.address) == str(original_data.address) + + +@pytest.mark.parametrize( + "hostname,is_valid", + [ + ("example.com", True), + ("sub.example.com", True), + ("test.sub.example.com", True), + ("a.b.c.d.e.f.g.example.com", True), + ("test-123.example.com", True), + ("a.example.com", True), + ("test.example-with-dash.com", True), + ("very-long-subdomain-name-that-is-still-valid.example.com", True), + ("x.y", True), + ("123test.example.com", False), # Must start with a letter + ("example", False), # No TLD + ("-example.com", False), # Starts with hyphen + ("example-.com", False), # Ends with hyphen + ("ex--ample.com", False), # Double hyphen + ("example..com", False), # Double dots + (".example.com", False), # Starts with dot + ("example.com.", False), # Ends with dot + ("example.", False), # Ends with dot after TLD + ("example..", False), # Multiple dots at end + ("", False), # Empty string + ("a" * 64 + ".com", False), # Label too long (>63 chars) + ("invalid-hostname-!@#.com", False), # Invalid characters + ("example with spaces.com", False), # Spaces not allowed + ("example\nnewline.com", False), # Newline not allowed + ("UPPERCASE.COM", True), # Should be valid (case insensitive) + ("mixed-Case.Example.COM", True), # Mixed case should be valid + ], +) +def test_hostname_regex_validation(hostname: str, is_valid: bool): + """Test HOSTNAME_REGEX validates FQDNs correctly. + + arrange: Test various hostname strings against HOSTNAME_REGEX. + act: Check if the hostname matches the regex pattern. + assert: The result matches the expected validity. + """ + match = re.match(HOSTNAME_REGEX, hostname) + if is_valid: + assert match is not None, f"Expected '{hostname}' to be valid but regex didn't match" + else: + assert match is None, f"Expected '{hostname}' to be invalid but regex matched" From 496768a72feab9a7469e1a08264991dc695cbde5 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 22:19:24 +0100 Subject: [PATCH 09/13] remove nest_under model config --- lib/charms/haproxy/v0/spoe_auth.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/lib/charms/haproxy/v0/spoe_auth.py b/lib/charms/haproxy/v0/spoe_auth.py index bd9ab1675..c7105f517 100644 --- a/lib/charms/haproxy/v0/spoe_auth.py +++ b/lib/charms/haproxy/v0/spoe_auth.py @@ -132,9 +132,6 @@ class _DatabagModel(BaseModel): extra="ignore", # Allow instantiating this class by field name (instead of forcing alias). populate_by_name=True, - # Custom config key: whether to nest the whole datastructure (as json) - # under a field or spread it out at the toplevel. - _NEST_UNDER=None, ) # type: ignore """Pydantic config.""" @@ -151,10 +148,6 @@ def load(cls, databag: MutableMapping[str, str]) -> "_DatabagModel": Returns: _DatabagModel: The validated model. """ - nest_under = cls.model_config.get("_NEST_UNDER") - if nest_under: - return cls.model_validate(json.loads(databag[nest_under])) - try: data = { k: json.loads(v) @@ -191,12 +184,6 @@ def dump( if databag is None: databag = {} - nest_under = self.model_config.get("_NEST_UNDER") - if nest_under: - databag[nest_under] = self.model_dump_json( - by_alias=True, - ) - return databag dct = self.model_dump(mode="json", by_alias=True) databag.update({k: json.dumps(v) for k, v in dct.items()}) From 4f15e62b18bfb411e4d70c0732fc0fa6cd326d64 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 22:22:10 +0100 Subject: [PATCH 10/13] update docs to suggest supporting only 1 relation as the provider --- lib/charms/haproxy/v0/spoe_auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/charms/haproxy/v0/spoe_auth.py b/lib/charms/haproxy/v0/spoe_auth.py index c7105f517..ee1e9ab53 100644 --- a/lib/charms/haproxy/v0/spoe_auth.py +++ b/lib/charms/haproxy/v0/spoe_auth.py @@ -21,6 +21,7 @@ provides: spoe-auth: interface: spoe-auth + limit: 1 ``` Then, to initialise the library: From 2ea073632162247cf3c28a72ba2f39482ec4817d Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 22:38:33 +0100 Subject: [PATCH 11/13] update docstring --- lib/charms/haproxy/v0/spoe_auth.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/charms/haproxy/v0/spoe_auth.py b/lib/charms/haproxy/v0/spoe_auth.py index ee1e9ab53..ba65c9d02 100644 --- a/lib/charms/haproxy/v0/spoe_auth.py +++ b/lib/charms/haproxy/v0/spoe_auth.py @@ -6,11 +6,19 @@ ## Getting Started -To get started using the library, you just need to fetch the library using `charmcraft`. +To get started using the library, you need to first declare the library in + the charm-libs section of your `charmcraft.yaml` file: +```yaml +charm-libs: +- lib: haproxy.spoe_auth + version: "0" +``` + +Then, fetch the library using `charmcraft`: ```shell cd some-charm -charmcraft fetch-lib charms.haproxy.v0.spoe_auth +charmcraft fetch-libs ``` ## Using the library as the Provider From bd7d3c3e28a15b4ac5ea8373793dcc3076b6d114 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 22:41:58 +0100 Subject: [PATCH 12/13] add change artifact --- docs/release-notes/artifacts/pr0229.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/release-notes/artifacts/pr0229.yaml diff --git a/docs/release-notes/artifacts/pr0229.yaml b/docs/release-notes/artifacts/pr0229.yaml new file mode 100644 index 000000000..88d521d59 --- /dev/null +++ b/docs/release-notes/artifacts/pr0229.yaml @@ -0,0 +1,15 @@ +# --- Release notes change artifact ---- + +schema_version: 1 +changes: + - title: Add SPOE Auth interface library + author: tphan025 + type: major + description: | + Add the `charms.haproxy.v0.spoe_auth` library to enable SPOE authentication integration. + urls: + pr: https://github.com/canonical/haproxy-operator/pull/229 + related_doc: + related_issue: + visibility: public + highlight: false From de830929deedf5903903e7c2298498a76ea60046 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 26 Nov 2025 22:42:59 +0100 Subject: [PATCH 13/13] Remove entry for docs codeower because change artifacts are now required --- CODEOWNERS | 2 -- 1 file changed, 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 9e0880913..76a0bae11 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,4 +1,2 @@ # Ignore changelog from codeowner requests docs/changelog.md - -docs/* @erinecon