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/')