mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 20:26:33 +00:00
Implements docs/requirements/021-sso-flavor-migration.md atomically:
* Schema: every role's meta/services.yml replaces the parallel
`oauth2:` / `oidc:` top-level blocks with a single `sso:` block
carrying `enabled`, `shared`, and a `flavor` discriminator
(`oidc` | `oauth2` | `saml`; default `oidc`). Flavor-specific
sub-keys live under `sso.<flavor>.*` (`sso.oauth2.{origin,acl,
allowed_groups}`, `sso.oidc.plugin`).
* Provider role: roles/web-app-oauth2-proxy/ is deleted in its
entirety; its 5 sidecar templates move under
roles/web-app-keycloak/templates/sso_proxy/, and the per-consumer
rendering task lives at roles/web-app-keycloak/tasks/sso_proxy.yml.
web-app-keycloak now declares `provides: sso`.
* Runtime: new resolver utils/roles/applications/services/sso.py
centralises the SSO predicates (`is_enabled`, `is_proxy_gated`,
`is_oidc_native`) and the oauth2 sub-values
(`oauth2_origin_{host,port}`, `oauth2_acl`, `oauth2_allowed_groups`).
Three lookup plugins consume the resolver: `sso` (single-app
predicate access), `sso_proxy_consumers` (enumerates oauth2-flavored
consumers), `sso_oidc_plugin` (renamed from `oidc_flavor`, Nextcloud
plugin selector). sys-stk-backend, sys-svc-compose, sys-svc-proxy
and plugins/filter/compose_volumes.py all gate on
`lookup('sso', ..., 'is_proxy_gated')` instead of manually combining
`enabled` and `flavor`.
* Identifier renames repo-wide: `services.<entity>.ports.local.oauth2`
→ `ports.local.sso`; `PORT_BANDS.local.oauth2` → `PORT_BANDS.local.sso`;
`OAUTH2_PROXY_*` env/Jinja vars → `SSO_PROXY_*`;
`oauth2_proxy_cookie_secret` → `sso_proxy_cookie_secret`;
`OAUTH2_SERVICE_ENABLED` + `OIDC_SERVICE_ENABLED` collapse into
`SSO_SERVICE_ENABLED`; Playwright gating helpers
(isServiceEnabled/skipUnlessServiceEnabled/requireService/safeIsEnabled/
safeSkipUnlessEnabled) move from `oidc`/`oauth2` to `sso`.
* Dual-block resolution: per req's Background-Table, web-app-bookwyrm
is `flavor: oauth2`; web-app-gitea and web-app-friendica keep
`flavor: oauth2` with their existing `oauth2.acl.blacklist`
(/user/login) and `oauth2.acl.whitelist` (federation + discovery
endpoints) — preserving the operative oauth2-proxy gating.
* Tests: the two mutual-exclusion guards
(tests/integration/iam/oauth2_oidc/test_mutual_exclusive.py and
test_acl_mutual_exclusive.py) are deleted per Decision #10;
test_oauth2_contract.py is renamed and rewritten to
test_sso_contract.py, asserting that `flavor: oauth2` roles carry
`oauth2.origin.{host,port}` plus `ports.local.sso`; test_sso.py
(web-app integration) now requires the unified `sso:` block with an
explicit flavor or a `# nocheck: sso` opt-out; new
tests/lint/repository/no_legacy_sso_paths/ guards the source tree
against re-introducing the legacy strings.
* Docs: req 21 itself has every AC checked off, and reqs 006, 008,
009, 013, 015, 016, 019, 020 plus 10+ role READMEs and
docs/contributing/actions/testing/suppression.md are updated to the
unified SSO naming.
Empirically validated end-to-end against the validation app set
(web-app-keycloak, web-app-nextcloud, web-app-bookwyrm, web-app-gitea,
web-app-friendica, web-app-dashboard) in matrix variants 0 and 1; v2
also passes the 4 target roles' Playwright after a documented
pre-existing KC_HOSTNAME-drift workaround (separate concern, not
caused by this refactor).
BREAKING CHANGE: every consumer of `services.oauth2.*`,
`services.oidc.*`, `OAUTH2_PROXY_*`, `OAUTH2_SERVICE_ENABLED`,
`OIDC_SERVICE_ENABLED`, `oauth2_proxy_cookie_secret`,
`oauth2_proxy_application_id`, `ports.local.oauth2`, the
`web-app-oauth2-proxy` role, or the legacy gating helper service names
(`oidc`/`oauth2`) must migrate to the unified `sso` shape documented
in docs/requirements/021-sso-flavor-migration.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
140 lines
5.0 KiB
Python
140 lines
5.0 KiB
Python
# tests/unit/utils/test_invokable.py
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import shutil
|
||
import tempfile
|
||
from pathlib import Path
|
||
from unittest import TestCase, main
|
||
from unittest.mock import patch
|
||
|
||
import utils.roles.validation.invokable as inv
|
||
from utils.cache.yaml import dump_yaml
|
||
from utils.roles.mapping import ROLE_FILE_VARS_MAIN
|
||
|
||
|
||
class TestInvokable(TestCase):
|
||
def setUp(self) -> None:
|
||
self.tmp = Path(tempfile.mkdtemp(prefix="invokable_modutils_test_"))
|
||
self.roles_dir = self.tmp / "roles"
|
||
self.roles_dir.mkdir(parents=True, exist_ok=True)
|
||
self.categories_file = self.roles_dir / "categories.yml"
|
||
|
||
# This structure triggers the YAML fallback in _get_invokable_paths()
|
||
# because it expects "categories", but we will patch _get_invokable_paths() anyway
|
||
dump_yaml(
|
||
self.categories_file,
|
||
{"categories": [{"invokable_paths": ["web-app", "update", "util-desk"]}]},
|
||
)
|
||
|
||
def mk_role(name: str, application_id: str | None = None) -> None:
|
||
rd = self.roles_dir / name
|
||
(rd / "vars").mkdir(parents=True, exist_ok=True)
|
||
if application_id is not None:
|
||
dump_yaml(rd / ROLE_FILE_VARS_MAIN, {"application_id": application_id})
|
||
else:
|
||
dump_yaml(rd / ROLE_FILE_VARS_MAIN, {})
|
||
|
||
mk_role("web-app-nextcloud", "web-app-nextcloud")
|
||
mk_role("web-app-matomo", "matomo-app")
|
||
mk_role("web-svc-nginx", None) # not invokable by our patched paths
|
||
mk_role("update", None) # exact match
|
||
mk_role("util-desk-custom", None) # prefix match
|
||
|
||
def tearDown(self) -> None:
|
||
shutil.rmtree(self.tmp)
|
||
|
||
def test_list_invokable_app_ids(self) -> None:
|
||
with (
|
||
patch.object(inv, "PROJECT_ROOT", self.tmp),
|
||
patch.object(
|
||
inv,
|
||
"_get_invokable_paths",
|
||
return_value=["web-app", "update", "util-desk"],
|
||
),
|
||
):
|
||
got = inv.list_invokable_app_ids()
|
||
|
||
self.assertEqual(
|
||
got,
|
||
sorted(["web-app-nextcloud", "matomo-app", "update", "util-desk-custom"]),
|
||
)
|
||
|
||
def test_list_invokables_by_type(self) -> None:
|
||
with (
|
||
patch.object(inv, "PROJECT_ROOT", self.tmp),
|
||
patch.object(
|
||
inv,
|
||
"_get_invokable_paths",
|
||
return_value=["web-app", "update", "util-desk"],
|
||
),
|
||
):
|
||
grouped = inv.list_invokables_by_type()
|
||
|
||
# server: web-app-* minus excluded oauth2 proxy (not present in test)
|
||
self.assertIn("server", grouped)
|
||
self.assertIn("workstation", grouped)
|
||
self.assertIn("universal", grouped)
|
||
|
||
self.assertEqual(grouped["server"], sorted(["web-app-nextcloud", "matomo-app"]))
|
||
self.assertEqual(grouped["workstation"], sorted(["util-desk-custom"]))
|
||
|
||
# universal = invokable - (server ∪ workstation) -> update remains
|
||
self.assertEqual(grouped["universal"], ["update"])
|
||
|
||
def test_list_invokables_by_type_exclude(self) -> None:
|
||
# Exercise the exclude_re path of DeploymentTypeRule via a
|
||
# synthetic rule. The historical DEFAULT_RULES exclude (the now
|
||
# dissolved oauth2-proxy helper role) was removed when its
|
||
# sidecar logic was absorbed into web-app-keycloak.
|
||
rd = self.roles_dir / "web-app-excluded"
|
||
(rd / "vars").mkdir(parents=True, exist_ok=True)
|
||
dump_yaml(rd / ROLE_FILE_VARS_MAIN, {})
|
||
|
||
rules = (
|
||
inv.DeploymentTypeRule(
|
||
name="server",
|
||
include_re=re.compile(r"^(web-app-|web-svc-)"),
|
||
exclude_re=re.compile(r"^web-app-excluded$"),
|
||
),
|
||
inv.DeploymentTypeRule(
|
||
name="universal", include_re=re.compile(r".*"), exclude_re=None
|
||
),
|
||
)
|
||
with (
|
||
patch.object(inv, "PROJECT_ROOT", self.tmp),
|
||
patch.object(
|
||
inv,
|
||
"_get_invokable_paths",
|
||
return_value=["web-app", "update", "util-desk"],
|
||
),
|
||
):
|
||
grouped = inv.list_invokables_by_type(rules=rules)
|
||
|
||
self.assertNotIn("web-app-excluded", grouped["server"])
|
||
|
||
def test_types_from_group_names(self) -> None:
|
||
with (
|
||
patch.object(inv, "PROJECT_ROOT", self.tmp),
|
||
patch.object(
|
||
inv,
|
||
"_get_invokable_paths",
|
||
return_value=["web-app", "update", "util-desk"],
|
||
),
|
||
):
|
||
got = inv.types_from_group_names(
|
||
[
|
||
"all", # not invokable -> ignored
|
||
"web-app-nextcloud", # invokable + matches server rule
|
||
"util-desk-custom", # invokable + matches workstation rule
|
||
"update", # invokable leftover -> universal
|
||
"foo", # not invokable -> ignored
|
||
]
|
||
)
|
||
|
||
self.assertEqual(got, ["server", "universal", "workstation"])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|