test(lint): require the onion in every variant that switches SSO on

SSO over a plaintext onion is where this platform actually breaks, and none of
it shows on clearnet: a framework's Secure session-cookie default makes Chromium
drop the cookie, an https identity provider cannot be embedded in an http page,
and crypto.subtle is undefined because an onion is no secure context. Pixelfed,
Taiga, Joomla and WordPress each cost a red job over exactly that. The
combination is only exercised if a variant deploys both, so the lint requires
it: services.sso.enabled true implies services.tor.enabled true.

69 variants enable SSO; 59 already complied. Of the ten that did not, seven are
flipped on because the mechanism is proven there - matrix and nextcloud both run
SSO behind the onion in variant 0 already (20ddda8c1), and their later variants
were pinned false only by the blanket rule from 1d9d355bc. Nextcloud variant 1
stays false: SSO is off there, and it is what gives the coverage test its
false-pinning variant.

Three roles opt out, each for a structural reason named in the marker. jitsi and
bigbluebutton pin tor.enabled false in meta/services.yml because the conference
needs getUserMedia, which no plaintext onion can offer. seaweedfs gates its own
sso flag on 'svc-net-tor not in group_names', so on a tor node there is no login
flow to test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-12 12:24:37 +02:00
1 parent bc1d2d86f6
commit 7a70d27b7c
6 files changed
+129 -16

No files matched your search

@@ -1,6 +1,10 @@
---
# Matrix-deploy variant overrides for web-app-bigbluebutton.
# See docs/contributing/artefact/files/role/variants.md for the file format.
#
# nocheck: sso-variant-tor — meta/services.yml pins tor.enabled false: the conference
# needs getUserMedia, which Chromium withholds outside a secure context, and a
# plaintext onion is not one. There is no onion to log into.
# variant pinning every dynamic services.yml flag to true so the
# matrix exercises the in-group_names branch of web-app-bigbluebutton.
+4 -2
View File
@@ -1,6 +1,10 @@
---
# Matrix-deploy variant overrides for web-app-jitsi.
# See docs/contributing/artefact/files/role/variants.md for the file format.
#
# nocheck: sso-variant-tor — meta/services.yml pins tor.enabled false: the conference
# needs getUserMedia, which Chromium withholds outside a secure context, and a
# plaintext onion is not one. There is no onion to log into.
# V1: oidc + ldap + everything that can be true, is true
- services:
@@ -32,7 +36,6 @@
enabled: false # nocheck: variants-static-override
shared: false # nocheck: variants-static-override
# V2: no auth — everything off, AUTH_TYPE=internal, anonymous rooms
- services:
css:
enabled: false
@@ -62,7 +65,6 @@
enabled: false # nocheck: variants-static-override
shared: false # nocheck: variants-static-override
# V3: ldap only — direct prosody mod_auth_ldap2 against svc-db-openldap
- services:
css:
enabled: false
+1 -2
View File
@@ -120,8 +120,7 @@
enabled: true
shared: true
tor:
enabled: false
shared: false # nocheck: variants-static-override
enabled: true
addons:
mautrix-whatsapp:
enabled: true
+9 -12
View File
@@ -2,6 +2,9 @@
# Matrix-deploy variant overrides for web-app-nextcloud.
# See docs/contributing/artefact/files/role/variants.md for the file format.
#
# nocheck: comments-valid: eight variants of ~90 near-identical service lines are
# unreadable without a heading naming what each one adds.
#
# nocheck: variant-limit: the loosely-coupled (bond<1) bridge partners are too heavy
# to co-host, so each budget-bin of them needs its own variant.
#
@@ -272,8 +275,7 @@
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
tor:
enabled: false
shared: false # nocheck: variants-static-override
enabled: true
# Variant 3: openproject + mattermost + matrix
- services:
container_backup:
@@ -361,8 +363,7 @@
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
tor:
enabled: false
shared: false # nocheck: variants-static-override
enabled: true
# Variant 4: onlyoffice + mastodon
- services:
container_backup:
@@ -450,8 +451,7 @@
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
tor:
enabled: false
shared: false # nocheck: variants-static-override
enabled: true
# Variant 5: zammad
- services:
container_backup:
@@ -539,8 +539,7 @@
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
tor:
enabled: false
shared: false # nocheck: variants-static-override
enabled: true
# Variant 6: gitlab
- services:
container_backup:
@@ -628,8 +627,7 @@
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
tor:
enabled: false
shared: false # nocheck: variants-static-override
enabled: true
# Variant 7: discourse + peertube
- services:
container_backup:
@@ -717,5 +715,4 @@
enabled: true
shared: true
tor:
enabled: false
shared: false # nocheck: variants-static-override
enabled: true
@@ -1,4 +1,7 @@
---
# nocheck: sso-variant-tor — meta/services.yml gates sso on
# "svc-net-tor not in group_names": the frontend is off on a tor node by design, so
# there is no SSO-over-onion flow to exercise.
- services:
container_backup:
enabled: true
@@ -0,0 +1,108 @@
"""Lint guard: a variant that turns SSO on must also turn the onion on.
Single sign-on over a plaintext ``http://<onion>`` origin is where the platform
breaks in ways clearnet never shows: a framework's ``Secure`` session-cookie
default silently drops the cookie, an https identity provider cannot be embedded
in an http page, and ``crypto.subtle`` is undefined because an onion is not a
secure context. None of that surfaces unless a variant deploys SSO and the onion
together, so every ``meta/variants.yml`` entry pinning
``services.sso.enabled: true`` MUST also pin ``services.tor.enabled: true``.
A role that genuinely cannot serve SSO over the onion opts out with
``# nocheck: sso-variant-tor`` anywhere in its ``meta/variants.yml`` or
``meta/services.yml``, naming the reason. Three kinds qualify: a role whose
``meta/services.yml`` pins ``tor.enabled`` to a literal false (it has no onion at
all), a role whose SSO gate is itself disabled on a tor node, and a role whose
login flow needs a browser API that a non-secure context withholds.
"""
from __future__ import annotations
import unittest
from typing import TYPE_CHECKING, Any
import yaml
from utils.annotations.suppress import line_has_rule
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_str
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_META_VARIANTS
from . import PROJECT_ROOT
if TYPE_CHECKING:
from pathlib import Path
ROLES_DIR = PROJECT_ROOT / "roles"
_RULE = "sso-variant-tor"
def _load_yaml(path: Path) -> Any:
if not path.is_file():
return None
try:
text = read_text(str(path))
except UnicodeDecodeError:
return None
if not text.strip():
return None
try:
return load_yaml_str(text)
except yaml.YAMLError:
return None
def _suppressed(paths: tuple[Path, ...]) -> bool:
for path in paths:
if not path.is_file():
continue
if any(
line_has_rule(line, _RULE) for line in read_text(str(path)).splitlines()
):
return True
return False
def _flag(variant: Any, service: str) -> Any:
services = variant.get("services") if isinstance(variant, dict) else None
entry = services.get(service) if isinstance(services, dict) else None
return entry.get("enabled") if isinstance(entry, dict) else None
class TestSsoVariantRequiresTor(unittest.TestCase):
def test_every_sso_variant_pins_the_onion_on(self) -> None:
offenders: list[str] = []
for role_dir in sorted(p for p in ROLES_DIR.iterdir() if p.is_dir()):
variants_path = role_dir / ROLE_FILE_META_VARIANTS
variants = _load_yaml(variants_path)
if not isinstance(variants, list) or not variants:
continue
if _suppressed((variants_path, role_dir / ROLE_FILE_META_SERVICES)):
continue
for index, variant in enumerate(variants):
if _flag(variant, "sso") is not True:
continue
tor = _flag(variant, "tor")
if tor is True:
continue
offenders.append(
f"{role_dir.name}: variant {index} pins services.sso.enabled "
f"true but services.tor.enabled is {tor!r} — SSO must be "
f"exercised over the onion, where it actually breaks."
)
if offenders:
self.fail(
f"Variants enabling SSO without the onion ({len(offenders)}):\n"
+ "\n".join(f" - {o}" for o in offenders)
+ "\n\nAdd to the variant's services block:\n"
+ " tor:\n"
+ " enabled: true\n"
+ f"\nOr opt the role out with `# nocheck: {_RULE} — <reason>` in "
+ "meta/variants.yml when it cannot serve SSO over an onion."
)
if __name__ == "__main__":
unittest.main()