Files
kevinveenbirkenbachandClaude Opus 5 9d0f618e23 refactor(sso): compute the app logout target once in the sso lookup
The previous commit wrote the same proxy-aware logout expression into
four javascript.js.j2 templates. The rule it encodes - sign out of
oauth2-proxy first when the app is proxy-gated, else go straight to the
provider - belongs where proxy-gating is already decided.

utils/roles/applications/services/sso.py gains logout_url(url,
is_proxy_gated) next to get_sso_config, with OAUTH2_SIGN_OUT_PATH as the
path constant; the rd value is quoted exactly as Jinja's urlencode
filter does it. The sso lookup exposes it as the logout_url term: it
renders OIDC through the templar, takes is_proxy_gated from the same
resolution, and refuses a context without OIDC. Prometheus, Checkmk,
Baserow and Jitsi now render lookup('sso', application_id, 'logout_url').

Behaviour is unchanged: the resolver tests pin the rd encoding against
jinja2's do_urlencode, and the lookup tests cover a proxy-gated app, an
OIDC-native app, a disabled SSO and a missing OIDC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 13:06:19 +02:00

162 lines
6.7 KiB
Python

"""Single-source-of-truth resolver for a role's unified SSO surface.
Reads ``services.sso.{enabled,shared,flavor,oauth2.*}`` from the merged
applications payload and derives the semantic predicates and
flavor-specific sub-values that the SSO-proxy wiring, the redis-sidecar
gating, and the consumer-side playwright env all need.
The flavor enum is ``{oidc, oauth2, saml}`` with ``oidc`` as the default
when omitted. The ``is_proxy_gated`` predicate is the one place that
encodes "the oauth2-proxy sidecar fronts this role" — every other layer
MUST consult this helper instead of re-implementing the
``enabled AND flavor == 'oauth2'`` compound. The ``oauth2_*`` keys
expose the gated upstream config so call sites resolve cross-role data
through one named property rather than a literal ``lookup('config',
app_id, 'services.sso.oauth2.<sub>')`` (which would otherwise trip the
literal-protocol-lookup and lookup-config-path static-scan guards).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from urllib.parse import quote
from utils.roles.applications.config import get
if TYPE_CHECKING:
from collections.abc import Mapping
_DEFAULT_FLAVOR = "oidc"
_VALID_FLAVORS = frozenset({"oidc", "oauth2", "saml"})
OAUTH2_SIGN_OUT_PATH = "/oauth2/sign_out"
def is_potentially_enabled(value: Any) -> bool:
"""Return True unless the value is statically known to be disabled.
Jinja templates render at runtime, so any unrendered Jinja string
(e.g. ``"{{ 'web-app-keycloak' in group_names }}"``) MUST be treated
as potentially truthy by static-analysis callers (lint tests,
contract checks).
Statically disabled means: the value is literal ``False``, ``None``,
or the string literal ``"false"`` (case-insensitive, whitespace
trimmed). Everything else is assumed to *possibly* render truthy.
Use this for static lint checks that walk ``meta/services.yml``
text. Runtime callers should prefer ``get_sso_config``'s
``is_enabled`` predicate, which already runs against the merged +
rendered payload.
"""
if value is None or value is False:
return False
return not (isinstance(value, str) and value.strip().lower() == "false")
def _as_bool(value: Any) -> bool:
"""Coerce a merged-payload flag to bool.
Args:
value: the raw value, which may still be the string a template rendered.
Returns:
True only for a literal True or a string spelling an affirmative.
"""
if isinstance(value, str):
return value.strip().lower() in ("true", "1", "yes", "on")
return bool(value)
def _get(applications: Mapping[str, Any], app_id: str, path: str, default: Any) -> Any:
"""Thin wrapper around ``get`` with the lenient flags this module uses."""
return get(
applications=applications,
application_id=app_id,
config_path=path,
strict=False,
default=default,
skip_missing_app=True,
)
def get_sso_config(
applications: Mapping[str, Any],
application_id: str,
) -> dict[str, Any]:
"""Resolve the SSO state for a consumer role.
Returns a dict with keys:
enabled bool — ``services.sso.enabled``
shared bool — ``services.sso.shared``
flavor str — one of {oidc, oauth2, saml}; default 'oidc'
is_enabled bool — alias of ``enabled``, semantic name
is_proxy_gated bool — ``enabled AND flavor == 'oauth2'``
is_oidc_native bool — ``enabled AND flavor == 'oidc'``
is_saml bool — ``enabled AND flavor == 'saml'``
oauth2_origin_host str — ``services.sso.oauth2.origin.host`` or ''
oauth2_origin_port str — ``services.sso.oauth2.origin.port`` or ''
oauth2_acl dict — ``services.sso.oauth2.acl`` or {}
oauth2_allowed_groups list — ``services.sso.oauth2.allowed_groups`` or []
oauth2_pass_access_token bool — ``services.sso.oauth2.pass_access_token``,
default False: the gate hands the upstream a
bearer token only where the app decodes one
The compound predicates are the documented call surface — call sites
should prefer ``is_proxy_gated`` over manually combining
``enabled`` and ``flavor``. The ``oauth2_*`` fields are the documented
way to read the gated upstream config; templates that previously
spelled out ``lookup('config', app_id, 'services.sso.oauth2.<sub>')``
SHOULD use ``lookup('sso', app_id, 'oauth2_<sub>')`` instead so the
Python-side helper and the Ansible-side lookup share one schema.
"""
enabled = bool(_get(applications, application_id, "services.sso.enabled", False))
shared = bool(_get(applications, application_id, "services.sso.shared", False))
raw_flavor = _get(
applications, application_id, "services.sso.flavor", _DEFAULT_FLAVOR
)
flavor = str(raw_flavor).strip() if isinstance(raw_flavor, str) else _DEFAULT_FLAVOR
if flavor not in _VALID_FLAVORS:
flavor = _DEFAULT_FLAVOR
raw_host = _get(applications, application_id, "services.sso.oauth2.origin.host", "")
raw_port = _get(applications, application_id, "services.sso.oauth2.origin.port", "")
raw_acl = _get(applications, application_id, "services.sso.oauth2.acl", {})
raw_allowed_groups = _get(
applications, application_id, "services.sso.oauth2.allowed_groups", []
)
raw_pass_access_token = _get(
applications, application_id, "services.sso.oauth2.pass_access_token", False
)
return {
"enabled": enabled,
"shared": shared,
"flavor": flavor,
"is_enabled": enabled,
"is_proxy_gated": enabled and flavor == "oauth2",
"is_oidc_native": enabled and flavor == "oidc",
"is_saml": enabled and flavor == "saml",
"oauth2_origin_host": str(raw_host) if raw_host else "",
"oauth2_origin_port": str(raw_port) if raw_port not in (None, "") else "",
"oauth2_acl": raw_acl if isinstance(raw_acl, dict) else {},
"oauth2_allowed_groups": (
list(raw_allowed_groups)
if isinstance(raw_allowed_groups, (list, tuple))
else []
),
"oauth2_pass_access_token": _as_bool(raw_pass_access_token),
}
def logout_url(oidc_logout_url: str, is_proxy_gated: bool) -> str:
"""Return the logout target of an application's own logout control.
Args:
oidc_logout_url: the identity provider's end-session endpoint.
is_proxy_gated: whether oauth2-proxy fronts the application.
"""
if not is_proxy_gated:
return oidc_logout_url
return f"{OAUTH2_SIGN_OUT_PATH}?rd={quote(oidc_logout_url, safe='/')}"