Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@

# charmlibs.interfaces packages (alphabetical)
/interfaces/tls-certificates/ @canonical/tls
/interfaces/haproxy_spoe_auth/ @canonical/platform-engineering
Empty file.
11 changes: 11 additions & 0 deletions interfaces/haproxy_spoe_auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# charmlibs.interfaces.haproxy_spoe_auth

The `haproxy_spoe_auth` interface library.

To install, add `charmlibs-interfaces-haproxy-spoe-auth` to your Python dependencies. Then in your Python code, import as:

```py
from charmlibs.interfaces import haproxy_spoe_auth
```

See the [reference documentation](https://documentation.ubuntu.com/charmlibs/reference/charmlibs/interfaces/haproxy_spoe_auth) for more.
56 changes: 56 additions & 0 deletions interfaces/haproxy_spoe_auth/interface/v0/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# `spoe-auth/v0`

## Usage

This relation interface describes the expected behavior of any charm that can work with the haproxy charm to provide authentication capabilities through SPOE ( Stream Process Offloading Engine ).

## Direction

SPOE allows haproxy to be extended with middlewares. SPOA are agents that talk to haproxy using the Stream Process Offloading Protocol ( SPOP ).

Providers are agent charms that validates incoming requests, communicates to Haproxy the full redirect URL to the IDP in case of unauthenticated requests, receives the OIDC callback and finally issues a redirect to the original destination to set the authentication cookie on the client browser if the request is authenticated.

The haproxy-operator charm is the only requirer charm as of now.

## Behavior
### Provider

- Is expected to expose a TCP port receiving SPOE messages ( through SPOP ). This port needs to be communicated to the requirer ( haproxy ).
- Is expected to reply to the SPOE messages with the appropriate set-var Actions, mainly idicating whether the request is authenticated
and if not, what's the IDP redirect URL that haproxy need to use as the response.
- Is expected to expose an HTTP port receiving the OIDC callback requests. The hostname and path prefix used to route requests to
this port needs to be communicated to the requirer ( haproxy )

### Requirer ( haproxy )

- Is expected to use the information available in the relation data to perform the corresponding actions. Specifically:
- Update the haproxy configuration to define SPOE message parameters, define the SPOP/redirect/callback backends and add routing rules accordingly


## Relation Data

### Provider

The provider exposes via its application databag informations about the SPOP and the OIDC callback endpoints via the `spop_port`, and `oidc_callback_*` attributes respectively. The provider also communicates the name of the variables for important flags such as "Is the user authenticated" (`var_authenticated`) or "The full URL to issue a redirect to the IDP" (`var_redirect_url`). The provider also exposes the name of the SPOE message, the event that should trigger the SPOE message and the name of the cookie to include in the SPOE message via the `message_name`, `event` and `cookie_name` attribute respectively.


#### Example
```yaml
unit_data:
unit/0:
address: 10.0.0.1
Comment on lines +39 to +41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: what happens when the provider is scaled out?

Are all ip addresses equivalent? Should haproxy use a random ip in the set? Can the provider unit signal that while it exists, it's not ready to receive spop callbacks?

Copy link
Author

@Thanhphan1147 Thanhphan1147 Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible for haproxy to handle multiple units of the spoe-agent. In short, haproxy will use the addresses for 2 backends:

  1. The spop backend ( mode tcp, used to send/receive SPOE messages )
    If there are 2 units with IP at 10.0.0.1 and 10.0.0.2 then the section will look something like this:
backend spoe_agents
    mode tcp
    balance source
    hash-type consistent
    option spop-check
    
    server unit-1 10.0.0.1:12345 check
    server unit-1 10.0.0.2:12345 check    
  1. The OIDC callback backend ( mode http, used to receive the authorization code and issue redirect to set the browser cookie )

As we'll add health-check for each of the server in the backend, if one of the provider's unit is not responding to health-check requests, then the unit will be marked as down and request won't be forwarded to it anymore. If no backend is available I believe haproxy will give you a 502/503 directly

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to note also that haproxy makes no assumption about the implementation details of the provider, so it's entirely possible that the agent is not stateless and doesn't work well when scaled out, but in those cases the agent charm should be conscious about that and not allow more than one unit.

And haproxy will also try its best to ensure stickiness with

    balance source
    hash-type consistent

but ultimately ensuring consistency when scaled out should be the responsibility of the agent charm's workload I think


application_data:
spop_port: 12345
event: on-frontend-http-request
message_name: try-auth-oidc
var_authenticated: sess.auth.is_authenticated
var_redirect_url: sess.auth.redirect_url
cookie_name: sessioncookie
oidc_callback_port: 5000
oidc_callback_path: /oauth2/callback
hostname: auth.haproxy.internal
```

### Requirer
No data is communicated from the requirer side.
7 changes: 7 additions & 0 deletions interfaces/haproxy_spoe_auth/interface/v0/interface.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
providers:
- name: haproxy-spoe-auth
url: https://www.github.com/canonical/haproxy-operator/haproxy_spoe_auth_operator

requirers:
- name: haproxy
url: https://www.github.com/canonical/haproxy-operator
73 changes: 73 additions & 0 deletions interfaces/haproxy_spoe_auth/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
[project]
name = "charmlibs-interfaces-haproxy-spoe-auth"
description = "The charmlibs.interfaces.haproxy_spoe_auth package."
readme = "README.md"
requires-python = ">=3.12"
authors = [
{name="The Platform Engineering team at Canonical"},
]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Intended Audience :: Developers",
"Operating System :: POSIX :: Linux",
"Development Status :: 5 - Production/Stable",
]
dynamic = ["version"]
dependencies = [
"ops>=3.3.1",
# "ops",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# "ops",

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since ops with a version specifier is required above.

"pydantic>=2.12.4",
]

[dependency-groups]
lint = [ # installed for `just lint interfaces/haproxy_spoe_auth` (unit, functional, and integration are also installed)
# "typing_extensions",
]
unit = [ # installed for `just unit interfaces/haproxy_spoe_auth`
"ops[testing]",
]
functional = [ # installed for `just functional interfaces/haproxy_spoe_auth`
]
integration = [ # installed for `just integration interfaces/haproxy_spoe_auth`
"jubilant",
]

[project.urls]
"Repository" = "https://github.com/canonical/charmlibs"
"Issues" = "https://github.com/canonical/charmlibs/issues"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/charmlibs"]

[tool.hatch.version]
path = "src/charmlibs/interfaces/haproxy_spoe_auth/_version.py"

[tool.ruff]
extend = "../../pyproject.toml"
src = ["src", "tests/unit", "tests/functional", "tests/integration"] # correctly sort local imports in tests

[tool.ruff.lint.extend-per-file-ignores]
# add additional per-file-ignores here to avoid overriding repo-level config
"tests/**/*" = [
# "E501", # line too long
]

[tool.pyright]
extends = "../../pyproject.toml"
include = ["src", "tests"]
pythonVersion = "3.12" # check no python > 3.12 features are used

[tool.charmlibs.functional]
ubuntu = [] # ubuntu versions to run functional tests with, e.g. "24.04" (defaults to just "latest")
pebble = [] # pebble versions to run functional tests with, e.g. "v1.0.0", "master" (defaults to no pebble versions)
sudo = false # whether to run functional tests with sudo (defaults to false)

[tool.charmlibs.integration]
# tags to run integration tests with (defaults to running once with no tag, i.e. tags = [''])
# Available in CI in tests/integration/pack.sh and integration tests as CHARMLIBS_TAG
tags = [] # Not used by the pack.sh and integration tests generated by the template
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright 2025 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""The charmlibs.interfaces.haproxy_spoe_auth package."""

from ._spoe_auth import (
HaproxyEvent,
SpoeAuthAvailableEvent,
SpoeAuthInvalidRelationDataError,
SpoeAuthProvider,
SpoeAuthProviderAppData,
SpoeAuthProviderUnitData,
SpoeAuthRemovedEvent,
SpoeAuthRequirer,
)
from ._version import __version__ as __version__

# only the names listed in __all__ are imported when executing:
# from charmlibs.haproxy_spoe_auth import *
__all__ = [
'HaproxyEvent',
'SpoeAuthAvailableEvent',
'SpoeAuthInvalidRelationDataError',
'SpoeAuthProvider',
'SpoeAuthProvider',
'SpoeAuthProviderAppData',
'SpoeAuthProviderUnitData',
'SpoeAuthRemovedEvent',
'SpoeAuthRequirer',
]
Loading
Loading