Files
Unraid-AppOps-Gateway-Public/backend/tests/test_enrollment_oidc_settings.py
T

240 lines
13 KiB
Python

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