Skip to content

Commit cdd64c2

Browse files
Add SAML integration (#199)
* test SAML integration * test SAML integration * test SAML integration * test SAML integration * test SAML integration * test SAML integration * test SAML integration * test SAML integration * test SAML integration * test SAML integration * Fix SAML integration test (#202) Co-authored-by: Weii Wang <[email protected]> * follow up with the SAML fix * pin cosl * add focal compatibility * pin pydantic * pin pydantic * pin pydantic * address comments * address comments * address comments * address comments * address comments --------- Co-authored-by: Weii Wang <[email protected]>
1 parent 1ab1047 commit cdd64c2

File tree

14 files changed

+500
-141
lines changed

14 files changed

+500
-141
lines changed

.github/workflows/integration_test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ jobs:
88
uses: canonical/operator-workflows/.github/workflows/integration_test.yaml@main
99
secrets: inherit
1010
with:
11-
extra-arguments: --localstack-address 172.17.0.1 -m "not (requires_secrets)"
11+
extra-arguments: --localstack-address 172.17.0.1
1212
pre-run-script: localstack-installation.sh
1313
trivy-image-config: "trivy.yaml"
1414
juju-channel: 3.1/stable

.github/workflows/integration_test_with_secrets.yaml

Lines changed: 0 additions & 15 deletions
This file was deleted.

config.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,6 @@ options:
2525
type: string
2626
description: "Comma-separated list of groups to sync from SAML provider."
2727
default: ""
28-
saml_target_url:
29-
type: string
30-
description: "SAML authentication target url."
31-
default: ""
3228
smtp_address:
3329
type: string
3430
description: "Hostname / IP that should be used to send SMTP mail."

docs/how-to/configure-saml.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22

33
To configure Discourse's SAML integration you'll have to set the following configuration options with the appropriate values for your SAML server by running `juju config [charm_name] [configuration]=[value]`.
44

5-
The SAML URL needs to be scpecified in `saml_target_url`. If you wish to force the login to go through SAML, enable `force_saml_login`.
5+
If you wish to force the login to go through SAML, enable `force_saml_login`.
66
The groups to be synced from the provider can be defined in `saml_sync_groups` as a comma-separated list of values.
7+
In order to implement the relation discourse has to be related with the [saml-integrator](https://charmhub.io/saml-integrator):
8+
```
9+
juju deploy saml-integrator --channel=edge
10+
# Set the SAML integrator configs
11+
juju config saml-integrator metadata_url=https://login.staging.ubuntu.com/saml/metadata
12+
juju config saml-integrator entity_id=https://login.staging.ubuntu.com
13+
juju integrate discourse-k8s saml-integrator
14+
```
715

816
For more details on the configuration options and their default values see the [configuration reference](https://charmhub.io/discourse-k8s/configure).

lib/charms/saml_integrator/v0/saml.py

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
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())

metadata.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ requires:
5151
limit: 1
5252
logging:
5353
interface: loki_push_api
54+
saml:
55+
interface: saml
56+
limit: 1
57+
optional: true
5458
assumes:
5559
- k8s-api
5660

pyproject.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@ show_missing = true
1818
[tool.pytest.ini_options]
1919
minversion = "6.0"
2020
log_cli_level = "INFO"
21-
markers = [
22-
"requires_secrets: mark tests that require external secrets"
23-
]
2421

2522
# Formatting tools configuration
2623
[tool.black]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
ops-lib-pgsql
2+
pydantic==1.10.14

0 commit comments

Comments
 (0)