Publish curated Unraid-AppOps-Gateway source from 68878c3167dacf2b424c16780e60e0d60815e477
This commit is contained in:
1 parent
837c263cd8
commit
551de41e9f
18 files changed
+1183
-58
No files matched your search
@@ -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/')
|
||||
Reference in new issue
Block a user