Publish curated Unraid-AppOps-Gateway source from 68878c3167dacf2b424c16780e60e0d60815e477

This commit is contained in:
Public Source Publisher committed 2026-10-08 18:33:13 +00:00
1 parent 837c263cd8
commit 551de41e9f
18 files changed
+1183 -58

No files matched your search

+111 -4
View File
@@ -1,5 +1,7 @@
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, create_engine
@@ -133,6 +135,7 @@ def test_oidc_template_requires_the_exact_enrolled_role_claim():
from backend.app.core.config import settings
previous = _set_authentik_config()
_set_deployment_identity_template()
settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth'
settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation'
settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups'
@@ -151,11 +154,86 @@ def test_oidc_template_requires_the_exact_enrolled_role_claim():
_restore_config(previous)
def _set_deployment_identity_template():
from backend.app.core.config import settings
settings.apis.update({
'AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING': 'openid-id',
'AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING': 'email-id',
'AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING': 'profile-id',
'AUTHENTIK_OIDC_SIGNING_KEY_ID': 'rsa-key-id',
})
@pytest.mark.parametrize("failure", [None, "flow", "flow_missing", "flow_duplicate", "mapping", "scope", "signing", "unreachable", "openid_roles", "email_roles", "profile_roles"])
def test_enrollment_template_verifies_remote_flows_and_filtered_role_claim(monkeypatch, failure):
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.core.config import settings
previous = _set_authentik_config()
_set_deployment_identity_template()
settings.apis.update({
'AUTHENTIK_OIDC_AUTHORIZATION_FLOW': 'authorization-id',
'AUTHENTIK_OIDC_INVALIDATION_FLOW': 'invalidation-id',
'AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING': 'roles-id',
'AUTHENTIK_OIDC_ROLE_CLAIM': 'roles',
})
expression = 'return {"roles": sorted({group.name for group in request.user.ak_groups.all() if group.name in ["Administrator", "ReadOnly", "SupportAgent"]})}'
def request(self, path, params=None):
if failure == 'unreachable':
raise RuntimeError('SECRET_SENTINEL')
for scope in ('openid', 'email', 'profile'):
if path == f'/propertymappings/provider/scope/{scope}-id/':
expressions = {
'openid': '# This scope is required by the OpenID-spec, and must as such exist in authentik.\n# The scope by itself does not grant any information\nreturn {}',
'email': 'return {\n "email": request.user.email,\n "email_verified": False\n}',
'profile': 'return delete_none_values({\n "name": request.user.name,\n "preferred_username": request.user.username,\n})',
}
return {
'pk': f'{scope}-id', 'scope_name': 'wrong' if failure == 'scope' else scope,
'managed': f'goauthentik.io/providers/oauth2/scope-{scope}' if scope != 'profile' else None,
'expression': 'return {"roles": ["Administrator"]}' if failure == f'{scope}_roles' else expressions[scope],
}
if path.startswith('/flows/instances/'):
raise RuntimeError('HTTP 404: flow detail requires slug, not UUID')
return {
'/propertymappings/provider/scope/roles-id/': {
'pk': 'roles-id', 'scope_name': 'roles',
'expression': 'return {"roles": ["Administrator"]}' if failure == 'mapping' else expression,
},
}[path]
monkeypatch.setattr(AuthentikConnector, '_request', request)
def paged(self, path, params=None):
if path == '/flows/instances/':
authorization = {'pk': 'authorization-id', 'slug': 'default-provider-authorization-implicit-consent', 'designation': 'authentication' if failure == 'flow' else 'authorization'}
flows = [{'pk': 'invalidation-id', 'slug': 'default-provider-invalidation-flow', 'designation': 'invalidation'}]
if failure != 'flow_missing':
flows.append(authorization)
if failure == 'flow_duplicate':
flows.append(authorization)
return flows
assert path == '/crypto/certificatekeypairs/'
return [{'pk': 'rsa-key-id', 'key_type': 'ec' if failure == 'signing' else 'rsa', 'private_key_available': True}]
monkeypatch.setattr(AuthentikConnector, '_paged', paged)
try:
result = AuthentikConnector().validate_deployment_oidc_template(
role_claim='roles', role_groups=('Administrator', 'SupportAgent', 'ReadOnly'),
)
assert (result['status'] == 'ready') is (failure is None)
assert 'SECRET_SENTINEL' not in str(result)
assert 'expression' not in result
finally:
_restore_config(previous)
def test_typed_oidc_provisioning_is_bounded_and_never_returns_client_secret(monkeypatch):
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.core.config import settings
previous = _set_authentik_config()
_set_deployment_identity_template()
settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth'
settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation'
settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups'
@@ -195,6 +273,9 @@ def test_typed_oidc_provisioning_is_bounded_and_never_returns_client_secret(monk
}
assert writes[0][0:2] == ('POST', '/providers/oauth2/')
assert writes[0][2]['client_secret'] == secret
assert writes[0][2]['redirect_uris'] == [{'matching_mode': 'strict', 'url': 'https://support.itworx.test/signin-oidc'}]
assert writes[0][2]['signing_key'] == 'rsa-key-id'
assert set(writes[0][2]['property_mappings']) == {'openid-id', 'email-id', 'profile-id', 'mapping-groups'}
assert secret not in str(result)
_restore_config(previous)
@@ -204,6 +285,7 @@ def test_typed_oidc_provisioning_removes_new_provider_when_application_create_fa
from backend.app.core.config import settings
previous = _set_authentik_config()
_set_deployment_identity_template()
settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth'
settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation'
settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups'
@@ -248,21 +330,40 @@ def test_typed_oidc_provisioning_removes_new_provider_when_application_create_fa
_restore_config(previous)
def test_typed_oidc_existing_application_requires_matching_provider_contract(monkeypatch):
@pytest.mark.parametrize('damage', [None, 'client', 'regex', 'extra_mapping', 'signing_key', 'issuer'])
def test_typed_oidc_existing_application_requires_matching_provider_contract(monkeypatch, damage):
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.core.config import settings
previous = _set_authentik_config()
_set_deployment_identity_template()
settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth'
settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation'
settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups'
provider = {
'pk': 'provider-1', 'client_id': 'wsbc-portal', 'client_type': 'confidential',
'redirect_uris': [{'matching_mode': 'strict', 'url': 'https://support.itworx.test/signin-oidc'}],
'property_mappings': ['openid-id', 'email-id', 'profile-id', 'mapping-groups'],
'authorization_flow': 'flow-default-auth', 'invalidation_flow': 'flow-default-invalidation',
'signing_key': 'rsa-key-id', 'issuer_mode': 'per_provider',
}
if damage == 'client':
provider['client_id'] = 'unexpected-client'
elif damage == 'regex':
provider['redirect_uris'][0]['matching_mode'] = 'regex'
elif damage == 'extra_mapping':
provider['property_mappings'].append('other-roles')
elif damage == 'signing_key':
provider['signing_key'] = None
elif damage == 'issuer':
provider['issuer_mode'] = 'global'
monkeypatch.setattr(
AuthentikConnector,
'_paged',
lambda self, path, params=None: {
'/core/groups/': [{'name': 'Administrator'}, {'name': 'SupportAgent'}, {'name': 'ReadOnly'}],
'/core/applications/': [{'pk': 'application-1', 'slug': 'wsbc-portal', 'meta_launch_url': 'https://support.itworx.test', 'provider': 'provider-1'}],
'/providers/oauth2/': [{'pk': 'provider-1', 'client_id': 'unexpected-client', 'client_type': 'confidential', 'redirect_uris': ['https://support.itworx.test/signin-oidc'], 'property_mappings': ['mapping-groups'], 'authorization_flow': 'flow-default-auth', 'invalidation_flow': 'flow-default-invalidation'}],
'/providers/oauth2/': [provider],
}[path],
raising=True,
)
@@ -279,11 +380,17 @@ def test_typed_oidc_existing_application_requires_matching_provider_contract(mon
finally:
_restore_config(previous)
assert result == {
assert result == ({
'success': True,
'status': 'already_managed',
'message': 'oidc_application_already_matches_typed_plan',
'applicationId': 'application-1',
'clientSecretCreated': False,
} if damage is None else {
'success': False,
'status': 'conflict',
'message': 'existing_oidc_provider_does_not_match_managed_contract',
}
})
def test_authentik_health_success_and_auth_failure_redact_token(monkeypatch):
@@ -1,5 +1,6 @@
from __future__ import annotations
import pytest
from sqlmodel import Session, SQLModel, create_engine
from backend.app.db.models import Policy
@@ -28,6 +29,7 @@ def _plan(**overrides):
"proxy": {"hostname": "support.example.com"},
"oidc": {
"application_slug": "wsbc-portal",
"role_claim": "roles",
"client_secret_ref": "WSBC_PORTAL_OIDC_CLIENT_SECRET",
"roles": {
"administrator_group": "Administrator",
@@ -72,6 +74,18 @@ class _Npm:
class _Authentik:
def configure_deployment_oidc_template(self, config, *, issuer_origin=None):
self.template_config = config
self.template_issuer = issuer_origin
def validate_deployment_oidc_template(self, *, role_claim, role_groups):
assert role_claim == "roles"
assert role_groups == ("Administrator", "SupportAgent", "ReadOnly")
return {"status": "ready"}
def groups(self):
return {"success": True, "items": [{"name": name} for name in ("Administrator", "SupportAgent", "ReadOnly")]}
def test_connection(self):
return {"status": "connected"}
@@ -343,3 +357,138 @@ def test_deployment_evidence_uses_allowlists_and_never_persists_connector_secret
assert "broker-secret" not in str(result)
assert "npm-secret" not in str(result)
assert "oidc-secret" not in str(result)
@pytest.mark.parametrize("swapped_roles", [False, True])
def test_saved_root_template_is_used_by_existing_deployment_plan(tmp_path, monkeypatch, swapped_roles):
from uuid import uuid4
from backend.app.core.config import settings
from backend.app.db.models import AppSetting
from backend.app.schemas.enrollment import EnrollmentTrustConfigV1
from backend.app.services.enrollment_oidc_settings import EnrollmentOidcProfileV1, TEMPLATE_FIELDS
monkeypatch.setattr(settings, "authentik_issuer", "https://auth.example.com/application/o/appops-users/")
profile = EnrollmentOidcProfileV1(issuer="https://auth.example.com", **{field: uuid4() for field in TEMPLATE_FIELDS})
class SavedTemplateAuthentik(_Authentik):
def validate_deployment_oidc_template(self, **kwargs):
assert self.template_config == profile.template_config()
# Authentik emits the same filtered set for any permutation. The
# orchestrator must separately preserve named root role meanings.
assert kwargs["role_claim"] == "roles"
assert set(kwargs["role_groups"]) == {"Administrator", "SupportAgent", "ReadOnly"}
return {"status": "ready"}
def ensure_deployment_oidc_application(self, **kwargs):
assert self.template_config == profile.template_config()
return super().ensure_deployment_oidc_application(**kwargs)
with _session(tmp_path) as session:
trust = EnrollmentTrustConfigV1(
controller_identity="unraid-controller", bootstrap_secret_ref="ENROLLMENT_BOOTSTRAP",
approved_networks=("itworx-npm",), allowed_domain_suffixes=("example.com",),
oidc_issuer=profile.issuer, administrator_group="Administrator",
support_agent_group="SupportAgent", read_only_group="ReadOnly",
)
session.add(AppSetting(key="deployment_enrollment:trust", value_json=trust.model_dump(mode="json")))
session.add(AppSetting(key="deployment_enrollment:oidc_template", value_json=profile.model_dump(mode="json")))
session.commit()
plane = _plane()
plane.authentik = SavedTemplateAuthentik()
plan = _plan()
if swapped_roles:
oidc = plan.oidc.model_dump()
oidc["roles"].update(administrator_group="ReadOnly", read_only_group="Administrator")
plan = _plan(oidc=oidc)
# Exercise both consumers; the integration can also run on an async
# controller completion where this instance never performed preflight.
preflight = plane.preflight(session, plan)
if swapped_roles:
assert "oidc_template" in preflight["blockers"]
plane.authentik = SavedTemplateAuthentik()
set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32)
success, evidence, _ = plane._complete_external_integrations(session, plan)
assert success is not swapped_roles
if swapped_roles:
assert evidence == {}
else:
assert evidence["oidc"]["status"] == "created"
def test_invalid_oidc_template_prevents_controller_apply(tmp_path):
class UnavailableAuthentik(_Authentik):
def validate_deployment_oidc_template(self, **kwargs):
return {"status": "unavailable", "reason": "remote-secret-sentinel"}
class NoApplyBroker(_Broker):
def apply(self, *args, **kwargs):
raise AssertionError("Invalid template must fail before controller apply")
with _session(tmp_path) as session:
set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32)
set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32)
session.add(Policy(id="allow-invalid-template-test", action="deployment.apply", target="wsbc-portal", manual="allowed", voice="blocked", risk="high", reason="Test"))
session.commit()
plane = _plane()
plane.authentik = UnavailableAuthentik()
plane.broker = NoApplyBroker()
result = plane.execute(session, plan=_plan(), idempotency_key="invalid-template-0001", operation="apply")
assert result["status"] == "preflight_failed"
assert "oidc_template" in result["preflight"]["blockers"]
assert "remote-secret-sentinel" not in str(result)
def test_oidc_template_drift_after_preflight_prevents_provider_write(tmp_path):
class ChangedAuthentik(_Authentik):
def validate_deployment_oidc_template(self, **kwargs):
return {"status": "unavailable", "reason": "secret-sentinel"}
def ensure_deployment_oidc_application(self, **kwargs):
raise AssertionError("Unverified template must never create a provider")
with _session(tmp_path) as session:
set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32)
plane = _plane()
plane.authentik = ChangedAuthentik()
success, evidence, message = plane._complete_external_integrations(session, _plan())
assert not success
assert evidence == {}
assert "secret-sentinel" not in message
@pytest.mark.parametrize("claim,administrator", [("groups", "Administrator"), ("roles", "OtherAdmin")])
def test_preflight_verifies_requested_claim_and_groups(tmp_path, claim, administrator):
class RequestedContractAuthentik(_Authentik):
def validate_deployment_oidc_template(self, *, role_claim, role_groups):
assert role_claim == claim
assert role_groups == (administrator, "SupportAgent", "ReadOnly")
return {"status": "unavailable", "reason": "private-template-diagnostic"}
oidc = _plan().oidc.model_dump()
oidc["role_claim"] = claim
oidc["roles"]["administrator_group"] = administrator
with _session(tmp_path) as session:
plane = _plane()
plane.authentik = RequestedContractAuthentik()
result = plane.preflight(session, _plan(oidc=oidc))
assert "oidc_template" in result["blockers"]
assert "private-template-diagnostic" not in str(result)
def test_missing_role_group_blocks_provider_write(tmp_path):
class MissingGroupAuthentik(_Authentik):
def groups(self):
return {"success": True, "items": [{"name": "Administrator"}, {"name": "SupportAgent"}]}
def ensure_deployment_oidc_application(self, **kwargs):
raise AssertionError("Missing group must fail before provider write")
with _session(tmp_path) as session:
set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32)
plane = _plane()
plane.authentik = MissingGroupAuthentik()
result = plane.preflight(session, _plan())
assert "oidc_template" in result["blockers"]
success, evidence, _ = plane._complete_external_integrations(session, _plan())
assert not success
assert evidence == {}
@@ -0,0 +1,239 @@
from uuid import uuid4
import pytest
from sqlmodel import Session
from backend.tests.test_user_auth import identity_client, sign_in
@pytest.fixture(autouse=True)
def native_authentik_issuer(monkeypatch):
from backend.app.core.config import settings
monkeypatch.setattr(settings, 'authentik_issuer', 'https://auth.example.com/application/o/appops-users/')
monkeypatch.setitem(settings.apis, 'AUTHENTIK_URL', 'https://auth.example.com')
monkeypatch.setitem(settings.apis, 'AUTHENTIK_VERIFY_TLS', True)
def payload():
return {
'issuer': 'https://auth.example.com',
'authorization_flow': str(uuid4()), 'invalidation_flow': str(uuid4()),
'openid_mapping': str(uuid4()), 'email_mapping': str(uuid4()),
'profile_mapping': str(uuid4()), 'roles_mapping': str(uuid4()),
'signing_key': str(uuid4()),
'administrator_group': 'Administrator', 'support_agent_group': 'SupportAgent',
'read_only_group': 'ReadOnly',
}
def seed_trust():
from backend.app.db import session as database
from backend.app.schemas.enrollment import EnrollmentTrustConfigV1
from backend.app.services.enrollment_plane import EnrollmentPlane
with Session(database.engine) as db:
EnrollmentPlane.set_trust_config(db, EnrollmentTrustConfigV1(
controller_identity='unraid-controller', bootstrap_secret_ref='ENROLLMENT_BOOTSTRAP',
approved_networks=('itworx-mcp-hub-npm',), allowed_domain_suffixes=('example.com',),
autonomous_profiles=('repopulse-authenticated',), repopulse_proxy_access_list_id=1,
))
def test_oidc_profile_requires_admin_csrf_and_settings_permission(identity_client):
client = identity_client
path = '/api/enrollments/trust/oidc-profile'
assert client.patch(path, json=payload()).status_code == 401
sign_in(client, 'reader')
assert client.patch(path, json=payload(), headers={'X-AppOps-Session-CSRF': '1'}).status_code == 403
sign_in(client, 'owner')
assert client.patch(path, json=payload()).status_code == 403
assert client.patch(path, json=payload(), headers={'X-AppOps-Session-CSRF': '1', 'Origin': 'https://evil.example'}).status_code == 403
def test_oidc_profile_rejects_unverified_remote_template_without_writes(identity_client, monkeypatch):
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.db import session as database
from backend.app.db.models import AppSetting
sign_in(identity_client, 'owner')
seed_trust()
monkeypatch.setattr(AuthentikConnector, 'validate_deployment_oidc_template', lambda *a, **k: {'status': 'unavailable', 'reason': 'remote-secret-must-not-leak'})
result = identity_client.patch('/api/enrollments/trust/oidc-profile', json=payload(), headers={'X-AppOps-Session-CSRF': '1'})
assert result.status_code == 409
assert result.json() == {'detail': 'oidc_profile_not_verified'}
with Session(database.engine) as db:
assert db.get(AppSetting, 'deployment_enrollment:oidc_template') is None
assert db.get(AppSetting, 'deployment_enrollment:trust').value_json['oidc_issuer'] is None
def test_oidc_profile_persists_ids_and_preserves_other_trust_boundaries(identity_client, monkeypatch):
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.db import session as database
from backend.app.db.models import AppSetting
sign_in(identity_client, 'owner')
seed_trust()
monkeypatch.setattr(AuthentikConnector, 'is_configured', lambda _: True)
monkeypatch.setattr(AuthentikConnector, 'validate_deployment_oidc_template', lambda *a, **k: {'status': 'ready'})
monkeypatch.setattr(AuthentikConnector, 'groups', lambda _: {'success': True, 'items': [{'name': n} for n in ['Administrator', 'SupportAgent', 'ReadOnly']]})
value = payload()
result = identity_client.patch('/api/enrollments/trust/oidc-profile', json=value, headers={'X-AppOps-Session-CSRF': '1'})
assert result.status_code == 200
assert result.json()['configured'] is True
with Session(database.engine) as db:
trust = db.get(AppSetting, 'deployment_enrollment:trust').value_json
assert trust['oidc_issuer'] == value['issuer']
assert trust['autonomous_profiles'] == ['repopulse-authenticated']
assert trust['trusted_appdata_root'] == '/srv/example/appdata'
assert trust['repopulse_proxy_access_list_id'] == 1
binding = db.get(AppSetting, 'deployment_enrollment:oidc_template')
assert binding.value_json == value
assert binding.is_secret is False
assert identity_client.get('/api/enrollments/trust/oidc-profile').json()['profile'] == value
@pytest.mark.parametrize('extra', [{'client_secret': 'never-accepted'}, {'authorization_flow': 'not-a-uuid'}, {'issuer': 'http://auth.example.com'}, {'administrator_group': 'authentik Admins'}])
def test_oidc_profile_rejects_extra_secrets_unsafe_ids_and_roles(identity_client, extra):
sign_in(identity_client, 'owner')
result = identity_client.patch('/api/enrollments/trust/oidc-profile', json={**payload(), **extra}, headers={'X-AppOps-Session-CSRF': '1'})
assert result.status_code == 422
assert result.json() == {'detail': 'invalid_oidc_profile'}
def test_oidc_profile_concurrent_trust_change_preserves_newer_selection(identity_client, monkeypatch):
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.db import session as database
from backend.app.db.models import AppSetting
from backend.app.core.time import utc_now
sign_in(identity_client, 'owner')
seed_trust()
monkeypatch.setattr(AuthentikConnector, 'is_configured', lambda _: True)
def concurrent_edit(*args, **kwargs):
with Session(database.engine) as db:
row = db.get(AppSetting, 'deployment_enrollment:trust')
row.value_json = {**row.value_json, 'repopulse_proxy_access_list_id': 2}
row.updated_at = utc_now()
db.add(row)
db.commit()
return {'status': 'ready'}
monkeypatch.setattr(AuthentikConnector, 'validate_deployment_oidc_template', concurrent_edit)
monkeypatch.setattr(AuthentikConnector, 'groups', lambda _: {'success': True, 'items': [{'name': n} for n in ['Administrator', 'SupportAgent', 'ReadOnly']]})
result = identity_client.patch('/api/enrollments/trust/oidc-profile', json=payload(), headers={'X-AppOps-Session-CSRF': '1'})
assert result.status_code == 409
with Session(database.engine) as db:
assert db.get(AppSetting, 'deployment_enrollment:trust').value_json['repopulse_proxy_access_list_id'] == 2
assert db.get(AppSetting, 'deployment_enrollment:oidc_template') is None
def test_stored_generic_binding_is_local_and_corruption_never_falls_back(identity_client, monkeypatch):
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.core.config import settings
from backend.app.db import session as database
from backend.app.db.models import AppSetting
from backend.app.services.enrollment_oidc_settings import bind_template
seed_trust()
original = dict(settings.apis)
first = AuthentikConnector()
second = AuthentikConnector()
with Session(database.engine) as db:
db.add(AppSetting(key='deployment_enrollment:oidc_template', value_json={'corrupt': True}))
db.commit()
bind_template(db, first)
assert first._deployment_oidc_config() == {}
assert second._deployment_oidc_config() == original
assert settings.apis == original
def test_custom_settings_operator_cannot_bind_root_profile(identity_client):
from backend.app.db import session as database
from backend.app.db.models import GatewayUser
user_id = sign_in(identity_client, 'settings-operator')
with Session(database.engine) as db:
user = db.get(GatewayUser, user_id)
user.role = 'custom'
user.permissions = ['settings.manage']
db.add(user)
db.commit()
result = identity_client.patch('/api/enrollments/trust/oidc-profile', json=payload(), headers={'X-AppOps-Session-CSRF': '1'})
assert result.status_code == 403
@pytest.mark.parametrize('origin', ['https://wrong.example', 'https://auth.example'])
def test_root_profile_rejects_foreign_issuer_before_connector_reads(identity_client, monkeypatch, origin):
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.db import session as database
from backend.app.db.models import AppSetting
sign_in(identity_client, 'owner')
seed_trust()
def unexpected_read(*args, **kwargs):
pytest.fail('No remote request is permitted for a foreign issuer')
monkeypatch.setattr(AuthentikConnector, 'validate_deployment_oidc_template', unexpected_read)
result = identity_client.patch('/api/enrollments/trust/oidc-profile', json={**payload(), 'issuer': origin}, headers={'X-AppOps-Session-CSRF': '1'})
assert result.status_code == 409
assert result.json() == {'detail': 'oidc_issuer_mismatch'}
with Session(database.engine) as db:
assert db.get(AppSetting, 'deployment_enrollment:oidc_template') is None
@pytest.mark.parametrize('native_issuer', [None, 'https://[invalid', 'http://auth.example.com'])
def test_root_profile_requires_established_native_issuer(identity_client, monkeypatch, native_issuer):
from backend.app.core.config import settings
sign_in(identity_client, 'owner')
seed_trust()
monkeypatch.setattr(settings, 'authentik_issuer', native_issuer)
assert identity_client.get('/api/enrollments/trust/oidc-profile').json()['configured'] is False
result = identity_client.patch('/api/enrollments/trust/oidc-profile', json=payload(), headers={'X-AppOps-Session-CSRF': '1'})
assert result.status_code == 409
assert result.json() == {'detail': 'oidc_issuer_mismatch'}
@pytest.mark.parametrize('url,verify_tls', [
('https://other.example', True),
('http://auth.example.com', True),
('https://auth.example.com', False),
('https://user:secret@auth.example.com', True),
])
def test_root_profile_rejects_unbound_connector_before_remote_reads(identity_client, monkeypatch, url, verify_tls):
from backend.app.core.config import settings
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.db import session as database
from backend.app.db.models import AppSetting
sign_in(identity_client, 'owner')
seed_trust()
monkeypatch.setitem(settings.apis, 'AUTHENTIK_URL', url)
monkeypatch.setitem(settings.apis, 'AUTHENTIK_VERIFY_TLS', verify_tls)
def unexpected_read(*args, **kwargs):
pytest.fail('Unbound connector must not receive template reads')
monkeypatch.setattr(AuthentikConnector, 'validate_deployment_oidc_template', unexpected_read)
result = identity_client.patch('/api/enrollments/trust/oidc-profile', json=payload(), headers={'X-AppOps-Session-CSRF': '1'})
assert result.status_code == 409
assert result.json() == {'detail': 'oidc_connector_issuer_mismatch'}
with Session(database.engine) as db:
assert db.get(AppSetting, 'deployment_enrollment:oidc_template') is None
def test_connector_drift_invalidates_bound_template_before_remote_reads(monkeypatch):
from backend.app.core.config import settings
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.services.enrollment_oidc_settings import EnrollmentOidcProfileV1
connector = AuthentikConnector()
profile = EnrollmentOidcProfileV1.model_validate(payload())
connector.configure_deployment_oidc_template(profile.template_config(), issuer_origin=profile.issuer)
assert connector._deployment_oidc_config() == profile.template_config()
monkeypatch.setitem(settings.apis, 'AUTHENTIK_URL', 'https://other.example')
assert connector._deployment_oidc_config() == {}
assert connector.validate_deployment_oidc_template(role_claim='roles', role_groups=('Administrator', 'SupportAgent', 'ReadOnly'))['status'] == 'missing_config'
def test_connector_drift_blocks_each_subsequent_http_request(monkeypatch):
from backend.app.core.config import settings
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.services.enrollment_oidc_settings import EnrollmentOidcProfileV1
profile = EnrollmentOidcProfileV1.model_validate(payload())
monkeypatch.setitem(settings.apis, 'AUTHENTIK_TOKEN', 'fixture-token')
connector = AuthentikConnector()
connector.configure_deployment_oidc_template(profile.template_config(), issuer_origin=profile.issuer)
assert connector._config().url == profile.issuer
monkeypatch.setitem(settings.apis, 'AUTHENTIK_URL', 'https://other.example')
assert connector._config() is None
with pytest.raises(RuntimeError, match='missing_config'):
connector._write_request('POST', '/providers/oauth2/', {'client_secret': 'fixture-secret'})
with pytest.raises(RuntimeError, match='missing_config'):
connector._request('/core/applications/')
+76
View File
@@ -202,6 +202,9 @@ class _Authentik:
assert role_claim == "roles"
return {"status": "ready", "missing": []}
def validate_deployment_oidc_template(self, *, role_claim, role_groups):
return self.deployment_oidc_template_status(role_claim=role_claim)
def ensure_deployment_oidc_application(self, **kwargs):
assert kwargs["application_slug"] == "wsbc-portal"
assert kwargs["client_secret"]
@@ -434,6 +437,79 @@ def test_capabilities_require_a_live_bootstrap_controller_contract(tmp_path):
assert result["controller"]["status"] == "ready"
@pytest.mark.parametrize("failure", ["template", "groups", "unreachable"])
def test_capabilities_do_not_advertise_unusable_oidc_profile(tmp_path, failure):
class UnavailableAuthentik(_Authentik):
def deployment_oidc_template_status(self, *, role_claim):
return {"status": "missing_config" if failure == "template" else "ready"}
def groups(self):
if failure == "groups":
return {"success": True, "items": [{"name": "Administrator"}]}
if failure == "unreachable":
return {"success": False, "message": "SECRET_SENTINEL"}
return super().groups()
with _session(tmp_path) as session:
_configure(session)
result = _plane(authentik=UnavailableAuthentik()).capabilities(session)
assert result["oidcProvisioning"] == {
"status": "unavailable", "blockerCode": "oidc_profile_unavailable",
}
assert "SECRET_SENTINEL" not in str(result)
assert result["targetEnrollment"]["status"] == "ready"
assert session.exec(select(Enrollment)).all() == []
assert session.exec(select(Policy)).all() == []
def test_capabilities_advertise_oidc_only_with_template_and_all_role_groups(tmp_path):
with _session(tmp_path) as session:
_configure(session)
assert _plane().capabilities(session)["oidcProvisioning"]["status"] == "ready"
@pytest.mark.parametrize('issuer', ['https://auth.example.com/unsupported-path/', 'https://auth.example.com/application/o/bad/path/'])
def test_capabilities_refuse_unusable_issuer_paths(tmp_path, issuer):
with _session(tmp_path) as session:
_configure(session)
EnrollmentPlane.set_trust_config(session, _trust().model_copy(update={'oidc_issuer': issuer}))
assert _plane().capabilities(session)['oidcProvisioning']['status'] == 'unavailable'
def test_enrollment_refuses_another_applications_oidc_issuer(tmp_path):
with _session(tmp_path) as session:
_configure(session)
EnrollmentPlane.set_trust_config(
session, _trust().model_copy(update={
"oidc_issuer": "https://auth.example.com/application/o/another-project/",
}),
)
result = _plane().preflight(session, _plan())
assert result["status"] == "blocked"
assert "oidc_issuer_mismatch" in result["blockers"]
assert session.exec(select(Enrollment)).all() == []
@pytest.mark.parametrize("configured", [
"https://auth.example.com",
"https://auth.example.com/application/o/",
"https://auth.example.com/application/o/wsbc-portal",
])
def test_enrollment_seals_its_own_per_provider_issuer(tmp_path, configured):
class RuntimeBroker(_Broker):
def configure_enrollment_runtime(self, runtime, *, secret):
self.issuer = runtime["values"]["OIDC_ISSUER"]
return super().configure_enrollment_runtime(runtime, secret=secret)
broker = RuntimeBroker()
with _session(tmp_path) as session:
_configure(session)
EnrollmentPlane.set_trust_config(session, _trust().model_copy(update={"oidc_issuer": configured}))
result = _plane(broker=broker).enroll(session, _plan(), idempotency_key="own-issuer-test-0001")
assert result["enrollment"]["status"] == "ready"
assert broker.issuer == "https://auth.example.com/application/o/wsbc-portal/"
def test_capabilities_block_when_controller_root_differs_from_operator_trust(tmp_path):
class AlternateRootBroker(_Broker):
appdata_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"