mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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>
162 lines
6.7 KiB
Python
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='/')}"
|