|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +# Copyright 2024 Canonical Ltd. |
| 4 | +# Licensed under the Apache2.0. See LICENSE file in charm source for details. |
| 5 | + |
| 6 | +"""Library to manage the relation data for the SAML Integrator charm. |
| 7 | +
|
| 8 | +This library contains the Requires and Provides classes for handling the relation |
| 9 | +between an application and a charm providing the `saml`relation. |
| 10 | +It also contains a `SamlRelationData` class to wrap the SAML data that will |
| 11 | +be shared via the relation. |
| 12 | +
|
| 13 | +### Requirer Charm |
| 14 | +
|
| 15 | +```python |
| 16 | +
|
| 17 | +from charms.saml_integrator.v0 import SamlDataAvailableEvent, SamlRequires |
| 18 | +
|
| 19 | +class SamlRequirerCharm(ops.CharmBase): |
| 20 | + def __init__(self, *args): |
| 21 | + super().__init__(*args) |
| 22 | + self.saml = saml.SamlRequires(self) |
| 23 | + self.framework.observe(self.saml.on.saml_data_available, self._handler) |
| 24 | + ... |
| 25 | +
|
| 26 | + def _handler(self, events: SamlDataAvailableEvent) -> None: |
| 27 | + ... |
| 28 | +
|
| 29 | +``` |
| 30 | +
|
| 31 | +As shown above, the library provides a custom event to handle the scenario in |
| 32 | +which new SAML data has been added or updated. |
| 33 | +
|
| 34 | +### Provider Charm |
| 35 | +
|
| 36 | +Following the previous example, this is an example of the provider charm. |
| 37 | +
|
| 38 | +```python |
| 39 | +from charms.saml_integrator.v0 import SamlDataAvailableEvent, SamlRequires |
| 40 | +
|
| 41 | +class SamlRequirerCharm(ops.CharmBase): |
| 42 | + def __init__(self, *args): |
| 43 | + super().__init__(*args) |
| 44 | + self.saml = SamlRequires(self) |
| 45 | + self.framework.observe(self.saml.on.saml_data_available, self._on_saml_data_available) |
| 46 | + ... |
| 47 | +
|
| 48 | + def _on_saml_data_available(self, events: SamlDataAvailableEvent) -> None: |
| 49 | + ... |
| 50 | +
|
| 51 | + def __init__(self, *args): |
| 52 | + super().__init__(*args) |
| 53 | + self.saml = SamlProvides(self) |
| 54 | +
|
| 55 | +``` |
| 56 | +The SamlProvides object wraps the list of relations into a `relations` property |
| 57 | +and provides an `update_relation_data` method to update the relation data by passing |
| 58 | +a `SamlRelationData` data object. |
| 59 | +""" |
| 60 | + |
| 61 | +# The unique Charmhub library identifier, never change it |
| 62 | +LIBID = "511cdfa7de3d43568bf9b512f9c9f89d" |
| 63 | + |
| 64 | +# Increment this major API version when introducing breaking changes |
| 65 | +LIBAPI = 0 |
| 66 | + |
| 67 | +# Increment this PATCH version before using `charmcraft publish-lib` or reset |
| 68 | +# to 0 if you are raising the major API version |
| 69 | +LIBPATCH = 5 |
| 70 | + |
| 71 | +# pylint: disable=wrong-import-position |
| 72 | +import re |
| 73 | +import typing |
| 74 | + |
| 75 | +import ops |
| 76 | +from pydantic import AnyHttpUrl, BaseModel, Field |
| 77 | +from pydantic.tools import parse_obj_as |
| 78 | + |
| 79 | +DEFAULT_RELATION_NAME = "saml" |
| 80 | + |
| 81 | + |
| 82 | +class SamlEndpoint(BaseModel): |
| 83 | + """Represent a SAML endpoint. |
| 84 | +
|
| 85 | + Attrs: |
| 86 | + name: Endpoint name. |
| 87 | + url: Endpoint URL. |
| 88 | + binding: Endpoint binding. |
| 89 | + response_url: URL to address the response to. |
| 90 | + """ |
| 91 | + |
| 92 | + name: str = Field(..., min_length=1) |
| 93 | + url: AnyHttpUrl |
| 94 | + binding: str = Field(..., min_length=1) |
| 95 | + response_url: typing.Optional[AnyHttpUrl] |
| 96 | + |
| 97 | + def to_relation_data(self) -> typing.Dict[str, str]: |
| 98 | + """Convert an instance of SamlEndpoint to the relation representation. |
| 99 | +
|
| 100 | + Returns: |
| 101 | + Dict containing the representation. |
| 102 | + """ |
| 103 | + result: typing.Dict[str, str] = {} |
| 104 | + # Get the HTTP method from the SAML binding |
| 105 | + http_method = self.binding.split(":")[-1].split("-")[-1].lower() |
| 106 | + # Transform name into snakecase |
| 107 | + lowercase_name = re.sub(r"(?<!^)(?=[A-Z])", "_", self.name).lower() |
| 108 | + prefix = f"{lowercase_name}_{http_method}_" |
| 109 | + result[f"{prefix}url"] = str(self.url) |
| 110 | + result[f"{prefix}binding"] = self.binding |
| 111 | + if self.response_url: |
| 112 | + result[f"{prefix}response_url"] = str(self.response_url) |
| 113 | + return result |
| 114 | + |
| 115 | + @classmethod |
| 116 | + def from_relation_data(cls, relation_data: typing.Dict[str, str]) -> "SamlEndpoint": |
| 117 | + """Initialize a new instance of the SamlEndpoint class from the relation data. |
| 118 | +
|
| 119 | + Args: |
| 120 | + relation_data: the relation data. |
| 121 | +
|
| 122 | + Returns: |
| 123 | + A SamlEndpoint instance. |
| 124 | + """ |
| 125 | + url_key = "" |
| 126 | + for key in relation_data: |
| 127 | + # A key per method and entpoint type that is always present |
| 128 | + if key.endswith("_redirect_url") or key.endswith("_post_url"): |
| 129 | + url_key = key |
| 130 | + # Get endpoint name from the relation data key |
| 131 | + lowercase_name = "_".join(url_key.split("_")[:-2]) |
| 132 | + name = "".join(x.capitalize() for x in lowercase_name.split("_")) |
| 133 | + # Get HTTP method from the relation data key |
| 134 | + http_method = url_key.split("_")[-2] |
| 135 | + prefix = f"{lowercase_name}_{http_method}_" |
| 136 | + return cls( |
| 137 | + name=name, |
| 138 | + url=parse_obj_as(AnyHttpUrl, relation_data[f"{prefix}url"]), |
| 139 | + binding=relation_data[f"{prefix}binding"], |
| 140 | + response_url=( |
| 141 | + parse_obj_as(AnyHttpUrl, relation_data[f"{prefix}response_url"]) |
| 142 | + if f"{prefix}response_url" in relation_data |
| 143 | + else None |
| 144 | + ), |
| 145 | + ) |
| 146 | + |
| 147 | + |
| 148 | +class SamlRelationData(BaseModel): |
| 149 | + """Represent the relation data. |
| 150 | +
|
| 151 | + Attrs: |
| 152 | + entity_id: SAML entity ID. |
| 153 | + metadata_url: URL to the metadata. |
| 154 | + certificates: List of SAML certificates. |
| 155 | + endpoints: List of SAML endpoints. |
| 156 | + """ |
| 157 | + |
| 158 | + entity_id: str = Field(..., min_length=1) |
| 159 | + metadata_url: AnyHttpUrl |
| 160 | + certificates: typing.List[str] |
| 161 | + endpoints: typing.List[SamlEndpoint] |
| 162 | + |
| 163 | + def to_relation_data(self) -> typing.Dict[str, str]: |
| 164 | + """Convert an instance of SamlDataAvailableEvent to the relation representation. |
| 165 | +
|
| 166 | + Returns: |
| 167 | + Dict containing the representation. |
| 168 | + """ |
| 169 | + result = { |
| 170 | + "entity_id": self.entity_id, |
| 171 | + "metadata_url": str(self.metadata_url), |
| 172 | + "x509certs": ",".join(self.certificates), |
| 173 | + } |
| 174 | + for endpoint in self.endpoints: |
| 175 | + result.update(endpoint.to_relation_data()) |
| 176 | + return result |
| 177 | + |
| 178 | + |
| 179 | +class SamlDataAvailableEvent(ops.RelationEvent): |
| 180 | + """Saml event emitted when relation data has changed. |
| 181 | +
|
| 182 | + Attrs: |
| 183 | + entity_id: SAML entity ID. |
| 184 | + metadata_url: URL to the metadata. |
| 185 | + certificates: Tuple containing the SAML certificates. |
| 186 | + endpoints: Tuple containing the SAML endpoints. |
| 187 | + """ |
| 188 | + |
| 189 | + @property |
| 190 | + def entity_id(self) -> str: |
| 191 | + """Fetch the SAML entity ID from the relation.""" |
| 192 | + assert self.relation.app |
| 193 | + return self.relation.data[self.relation.app].get("entity_id") |
| 194 | + |
| 195 | + @property |
| 196 | + def metadata_url(self) -> str: |
| 197 | + """Fetch the SAML metadata URL from the relation.""" |
| 198 | + assert self.relation.app |
| 199 | + return parse_obj_as(AnyHttpUrl, self.relation.data[self.relation.app].get("metadata_url")) |
| 200 | + |
| 201 | + @property |
| 202 | + def certificates(self) -> typing.Tuple[str, ...]: |
| 203 | + """Fetch the SAML certificates from the relation.""" |
| 204 | + assert self.relation.app |
| 205 | + return tuple(self.relation.data[self.relation.app].get("x509certs").split(",")) |
| 206 | + |
| 207 | + @property |
| 208 | + def endpoints(self) -> typing.Tuple[SamlEndpoint, ...]: |
| 209 | + """Fetch the SAML endpoints from the relation.""" |
| 210 | + assert self.relation.app |
| 211 | + relation_data = self.relation.data[self.relation.app] |
| 212 | + endpoints = [ |
| 213 | + SamlEndpoint.from_relation_data( |
| 214 | + { |
| 215 | + key2: relation_data.get(key2) |
| 216 | + for key2 in relation_data |
| 217 | + if key2.startswith("_".join(key.split("_")[:-1])) |
| 218 | + } |
| 219 | + ) |
| 220 | + for key in relation_data |
| 221 | + if key.endswith("_redirect_url") or key.endswith("_post_url") |
| 222 | + ] |
| 223 | + endpoints.sort(key=lambda ep: ep.name) |
| 224 | + return tuple(endpoints) |
| 225 | + |
| 226 | + |
| 227 | +class SamlRequiresEvents(ops.CharmEvents): |
| 228 | + """SAML events. |
| 229 | +
|
| 230 | + This class defines the events that a SAML requirer can emit. |
| 231 | +
|
| 232 | + Attrs: |
| 233 | + saml_data_available: the SamlDataAvailableEvent. |
| 234 | + """ |
| 235 | + |
| 236 | + saml_data_available = ops.EventSource(SamlDataAvailableEvent) |
| 237 | + |
| 238 | + |
| 239 | +class SamlRequires(ops.Object): |
| 240 | + """Requirer side of the SAML relation. |
| 241 | +
|
| 242 | + Attrs: |
| 243 | + on: events the provider can emit. |
| 244 | + """ |
| 245 | + |
| 246 | + on = SamlRequiresEvents() |
| 247 | + |
| 248 | + def __init__(self, charm: ops.CharmBase, relation_name: str = DEFAULT_RELATION_NAME) -> None: |
| 249 | + """Construct. |
| 250 | +
|
| 251 | + Args: |
| 252 | + charm: the provider charm. |
| 253 | + relation_name: the relation name. |
| 254 | + """ |
| 255 | + super().__init__(charm, relation_name) |
| 256 | + self.charm = charm |
| 257 | + self.relation_name = relation_name |
| 258 | + self.framework.observe(charm.on[relation_name].relation_changed, self._on_relation_changed) |
| 259 | + |
| 260 | + def _on_relation_changed(self, event: ops.RelationChangedEvent) -> None: |
| 261 | + """Event emitted when the relation has changed. |
| 262 | +
|
| 263 | + Args: |
| 264 | + event: event triggering this handler. |
| 265 | + """ |
| 266 | + assert event.relation.app |
| 267 | + if event.relation.data[event.relation.app]: |
| 268 | + self.on.saml_data_available.emit(event.relation, app=event.app, unit=event.unit) |
| 269 | + |
| 270 | + |
| 271 | +class SamlProvides(ops.Object): |
| 272 | + """Provider side of the SAML relation. |
| 273 | +
|
| 274 | + Attrs: |
| 275 | + relations: list of charm relations. |
| 276 | + """ |
| 277 | + |
| 278 | + def __init__(self, charm: ops.CharmBase, relation_name: str = DEFAULT_RELATION_NAME) -> None: |
| 279 | + """Construct. |
| 280 | +
|
| 281 | + Args: |
| 282 | + charm: the provider charm. |
| 283 | + relation_name: the relation name. |
| 284 | + """ |
| 285 | + super().__init__(charm, relation_name) |
| 286 | + self.charm = charm |
| 287 | + self.relation_name = relation_name |
| 288 | + |
| 289 | + @property |
| 290 | + def relations(self) -> typing.List[ops.Relation]: |
| 291 | + """The list of Relation instances associated with this relation_name. |
| 292 | +
|
| 293 | + Returns: |
| 294 | + List of relations to this charm. |
| 295 | + """ |
| 296 | + return list(self.model.relations[self.relation_name]) |
| 297 | + |
| 298 | + def update_relation_data(self, relation: ops.Relation, saml_data: SamlRelationData) -> None: |
| 299 | + """Update the relation data. |
| 300 | +
|
| 301 | + Args: |
| 302 | + relation: the relation for which to update the data. |
| 303 | + saml_data: a SamlRelationData instance wrapping the data to be updated. |
| 304 | + """ |
| 305 | + relation.data[self.charm.model.app].update(saml_data.to_relation_data()) |
0 commit comments