240 lines
13 KiB
Python
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/')
|