781 lines
33 KiB
Python
781 lines
33 KiB
Python
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from fastapi.testclient import TestClient
|
|
from sqlmodel import SQLModel, create_engine
|
|
|
|
|
|
def _reset_db(tmp_path, monkeypatch):
|
|
db_path = tmp_path / "authentik_test.db"
|
|
monkeypatch.setenv("APPOPS_DB_PATH", str(db_path))
|
|
monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token")
|
|
|
|
from backend.app.db import session
|
|
from backend.app.db.init_db import init_db
|
|
|
|
engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False})
|
|
session.engine = engine
|
|
SQLModel.metadata.drop_all(engine)
|
|
SQLModel.metadata.create_all(engine)
|
|
init_db()
|
|
|
|
|
|
def _set_authentik_config(
|
|
url: str | None = "https://auth.example.test",
|
|
token: str | None = "AUTHENTIK_TOKEN_VALUE",
|
|
outpost_url: str | None = "https://outpost.example.test",
|
|
):
|
|
from backend.app.core.config import settings
|
|
|
|
previous = dict(settings.apis)
|
|
settings.apis["AUTHENTIK_URL"] = url
|
|
settings.apis["AUTHENTIK_TOKEN"] = token
|
|
settings.apis["AUTHENTIK_VERIFY_TLS"] = False
|
|
settings.apis["AUTHENTIK_TIMEOUT_SECONDS"] = "10"
|
|
settings.apis["AUTHENTIK_OUTPOST_URL"] = outpost_url
|
|
settings.apis["AUTHENTIK_EXPECT_EMBEDDED_OUTPOST"] = True
|
|
return previous
|
|
|
|
|
|
def _set_npm_config():
|
|
from backend.app.core.config import settings
|
|
|
|
settings.apis["NPM_URL"] = "https://npm.example.test"
|
|
settings.apis["NPM_IDENTITY"] = "admin@example.test"
|
|
settings.apis["NPM_SECRET"] = "NPM_SECRET_VALUE"
|
|
settings.apis["NPM_VERIFY_TLS"] = True
|
|
settings.apis["NPM_TIMEOUT_SECONDS"] = "10"
|
|
|
|
|
|
def _restore_config(previous):
|
|
from backend.app.core.config import settings
|
|
|
|
settings.apis.clear()
|
|
settings.apis.update(previous)
|
|
|
|
|
|
def _applications():
|
|
return [
|
|
{"pk": "app-media", "name": "Media", "slug": "media", "launch_url": "https://media.example.com", "provider": 101},
|
|
{"pk": "app-orphan", "name": "Orphan", "slug": "orphan", "launch_url": "https://orphan.example.com"},
|
|
]
|
|
|
|
|
|
def _providers():
|
|
return [
|
|
{"pk": 101, "name": "Media proxy", "type": "authentik_providers_proxy.proxyprovider", "assigned_application_slug": "media"},
|
|
{"pk": 202, "name": "Dashboard oauth", "type": "authentik_providers_oauth2.oauth2provider", "assigned_application_slug": "dashboard"},
|
|
]
|
|
|
|
|
|
def _proxy_providers():
|
|
return [
|
|
{"pk": 101, "name": "Media proxy", "external_host": "https://media.example.com", "assigned_application_slug": "media"},
|
|
{"pk": 303, "name": "Unused proxy", "external_host": "https://unused.example.com"},
|
|
]
|
|
|
|
|
|
def _oauth2_providers():
|
|
return [
|
|
{
|
|
"pk": 202,
|
|
"name": "Dashboard oauth",
|
|
"assigned_application_slug": "dashboard",
|
|
"redirect_uris": "https://a.example.com/cb\nhttps://b.example.com/cb\nhttps://c.example.com/cb\nhttps://d.example.com/cb",
|
|
}
|
|
]
|
|
|
|
|
|
def _outposts():
|
|
return [{"pk": "outpost-1", "name": "Embedded Outpost", "type": "proxy", "health": "healthy", "providers": [101]}]
|
|
|
|
|
|
def _events():
|
|
return [
|
|
{"pk": "evt-1", "action": "login", "created": "2026-01-01T10:00:00Z", "client_ip": "198.18.10.25", "user": {"username": "jens"}},
|
|
{"pk": "evt-2", "action": "authorize_application", "created": "2026-01-01T10:05:00Z", "client_ip": "198.19.0.2"},
|
|
]
|
|
|
|
|
|
def test_authentik_connector_missing_config_reports_required_env(tmp_path, monkeypatch):
|
|
_reset_db(tmp_path, monkeypatch)
|
|
previous = _set_authentik_config(url=None, token=None)
|
|
|
|
from backend.app.main import app
|
|
|
|
with TestClient(app) as client:
|
|
payload = client.post("/api/connectors/authentik/test").json()
|
|
|
|
assert payload["success"] is False
|
|
assert payload["status"] == "missing_config"
|
|
assert payload["missing_config"] is True
|
|
assert payload["required_env_vars"] == ["AUTHENTIK_URL", "AUTHENTIK_TOKEN"]
|
|
assert payload["secretRef"] == "AUTHENTIK_TOKEN"
|
|
|
|
_restore_config(previous)
|
|
|
|
|
|
def test_authentik_token_accepts_optional_bearer_prefix():
|
|
from backend.app.connectors.authentik import AuthentikConnector
|
|
|
|
previous = _set_authentik_config(token="Bearer AUTHENTIK_TOKEN_VALUE")
|
|
|
|
try:
|
|
config = AuthentikConnector()._config()
|
|
|
|
assert config is not None
|
|
assert config.token == "AUTHENTIK_TOKEN_VALUE"
|
|
finally:
|
|
_restore_config(previous)
|
|
|
|
|
|
def test_oidc_template_requires_the_exact_enrolled_role_claim():
|
|
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'
|
|
settings.apis['AUTHENTIK_OIDC_ROLE_CLAIM'] = 'groups'
|
|
try:
|
|
assert AuthentikConnector.deployment_oidc_template_status(role_claim='roles') == {
|
|
'status': 'missing_config',
|
|
'missing': ['AUTHENTIK_OIDC_ROLE_CLAIM'],
|
|
}
|
|
settings.apis['AUTHENTIK_OIDC_ROLE_CLAIM'] = 'roles'
|
|
assert AuthentikConnector.deployment_oidc_template_status(role_claim='roles') == {
|
|
'status': 'ready',
|
|
'missing': [],
|
|
}
|
|
finally:
|
|
_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'
|
|
writes = []
|
|
|
|
def paged(self, path, params=None):
|
|
assert params is None
|
|
return {
|
|
'/core/groups/': [{'name': 'Administrator'}, {'name': 'SupportAgent'}, {'name': 'ReadOnly'}],
|
|
'/core/applications/': [],
|
|
}[path]
|
|
|
|
def write(self, method, path, payload=None):
|
|
writes.append((method, path, payload))
|
|
return {'pk': 'provider-1'} if path == '/providers/oauth2/' else {'pk': 'application-1'}
|
|
|
|
monkeypatch.setattr(AuthentikConnector, '_paged', paged, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, '_write_request', write, raising=True)
|
|
secret = 'generated-client-secret-value'
|
|
result = AuthentikConnector().ensure_deployment_oidc_application(
|
|
application_slug='wsbc-portal',
|
|
launch_url='https://support.itworx.test',
|
|
redirect_uri='https://support.itworx.test/signin-oidc',
|
|
client_secret=secret,
|
|
administrator_group='Administrator',
|
|
support_agent_group='SupportAgent',
|
|
read_only_group='ReadOnly',
|
|
)
|
|
|
|
assert result == {
|
|
'success': True,
|
|
'status': 'created',
|
|
'message': 'oidc_application_created_from_typed_template',
|
|
'applicationId': 'application-1',
|
|
'providerId': 'provider-1',
|
|
'clientSecretCreated': True,
|
|
}
|
|
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)
|
|
|
|
|
|
def test_typed_oidc_provisioning_removes_new_provider_when_application_create_fails(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'
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
AuthentikConnector,
|
|
'_paged',
|
|
lambda self, path, params=None: (
|
|
[{'name': 'Administrator'}, {'name': 'SupportAgent'}, {'name': 'ReadOnly'}]
|
|
if path == '/core/groups/' else []
|
|
),
|
|
raising=True,
|
|
)
|
|
|
|
def write(self, method, path, payload=None):
|
|
calls.append((method, path, payload))
|
|
if path == '/providers/oauth2/':
|
|
return {'pk': 'provider-1'}
|
|
if path == '/core/applications/':
|
|
raise RuntimeError('request failed client_secret=never-return-this')
|
|
assert (method, path, payload) == ('DELETE', '/providers/oauth2/provider-1/', None)
|
|
return {}
|
|
|
|
monkeypatch.setattr(AuthentikConnector, '_write_request', write, raising=True)
|
|
result = AuthentikConnector().ensure_deployment_oidc_application(
|
|
application_slug='wsbc-portal',
|
|
launch_url='https://support.itworx.test',
|
|
redirect_uri='https://support.itworx.test/signin-oidc',
|
|
client_secret='never-return-this',
|
|
administrator_group='Administrator',
|
|
support_agent_group='SupportAgent',
|
|
read_only_group='ReadOnly',
|
|
)
|
|
|
|
assert result == {
|
|
'success': False,
|
|
'status': 'failed',
|
|
'message': 'oidc_application_creation_failed_provider_removed',
|
|
}
|
|
assert calls[-1] == ('DELETE', '/providers/oauth2/provider-1/', None)
|
|
assert 'never-return-this' not in str(result)
|
|
_restore_config(previous)
|
|
|
|
|
|
@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/': [provider],
|
|
}[path],
|
|
raising=True,
|
|
)
|
|
try:
|
|
result = AuthentikConnector().ensure_deployment_oidc_application(
|
|
application_slug='wsbc-portal',
|
|
launch_url='https://support.itworx.test',
|
|
redirect_uri='https://support.itworx.test/signin-oidc',
|
|
client_secret='never-return-this',
|
|
administrator_group='Administrator',
|
|
support_agent_group='SupportAgent',
|
|
read_only_group='ReadOnly',
|
|
)
|
|
finally:
|
|
_restore_config(previous)
|
|
|
|
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):
|
|
from backend.app.connectors.authentik import AuthentikConnector
|
|
|
|
previous = _set_authentik_config()
|
|
requested_paths = []
|
|
|
|
def ok_request(self, path, params=None):
|
|
requested_paths.append(path)
|
|
return {"version": "2026.1.0"} if path == "/root/config/" else {}
|
|
|
|
monkeypatch.setattr(AuthentikConnector, "_request", ok_request, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "applications", lambda self: {"success": True, "items": _applications(), "total": 2}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "outpost_health", lambda self: {"success": True, "status": "ok", "available": True, "items": []}, raising=True)
|
|
|
|
healthy = AuthentikConnector().health()
|
|
|
|
assert healthy["success"] is True
|
|
assert healthy["status"] == "connected"
|
|
assert healthy["version"] == "2026.1.0"
|
|
assert healthy["applicationCount"] == 2
|
|
assert healthy["url"] == "https://auth.example.test"
|
|
assert "AUTHENTIK_TOKEN_VALUE" not in str(healthy)
|
|
assert "/root/config/" in requested_paths
|
|
assert "/core/root/" not in requested_paths
|
|
|
|
def fail_request(self, path, params=None):
|
|
raise RuntimeError("auth_failed token=AUTHENTIK_TOKEN_VALUE")
|
|
|
|
monkeypatch.setattr(AuthentikConnector, "_request", fail_request, raising=True)
|
|
failed = AuthentikConnector().health()
|
|
|
|
assert failed["success"] is False
|
|
assert failed["status"] == "auth_failed"
|
|
assert "AUTHENTIK_TOKEN_VALUE" not in str(failed)
|
|
assert "[REDACTED]" in str(failed)
|
|
|
|
_restore_config(previous)
|
|
|
|
|
|
def test_authentik_resources_normalize_and_events_mask_ips(monkeypatch):
|
|
from backend.app.connectors.authentik import AuthentikConnector
|
|
|
|
previous = _set_authentik_config()
|
|
monkeypatch.setattr(
|
|
AuthentikConnector,
|
|
"_paged",
|
|
lambda self, path, params=None: {
|
|
"/core/applications/": _applications(),
|
|
"/core/providers/": _providers(),
|
|
"/providers/proxy/": _proxy_providers(),
|
|
"/providers/oauth2/": _oauth2_providers(),
|
|
"/outposts/instances/": _outposts(),
|
|
"/events/events/": _events(),
|
|
"/flows/instances/": [{"pk": "flow-1", "name": "Login", "designation": "authentication"}],
|
|
"/policies/all/": [{"pk": "policy-1", "name": "Require MFA", "enabled": True}],
|
|
}[path],
|
|
raising=True,
|
|
)
|
|
|
|
connector = AuthentikConnector()
|
|
apps = connector.applications()
|
|
providers = connector.providers()
|
|
proxy = connector.proxy_providers()
|
|
oauth = connector.oauth2_providers()
|
|
outposts = connector.outposts()
|
|
events = connector.events()
|
|
flows = connector.flows_summary()
|
|
policies = connector.policies_summary()
|
|
|
|
assert apps["items"][0]["slug"] == "media"
|
|
assert providers["items"][0]["providerType"] == "proxy"
|
|
assert proxy["items"][0]["externalHost"] == "https://media.example.com"
|
|
assert oauth["items"][0]["redirectUriCount"] == 4
|
|
assert outposts["items"][0]["providerCount"] == 1
|
|
assert events["items"][0]["clientIp"] == "192.168.10.x"
|
|
assert flows["total"] == 1
|
|
assert policies["enabled"] == 1
|
|
|
|
_restore_config(previous)
|
|
|
|
|
|
def test_authentik_auth_coverage_and_security_warnings_with_npm(monkeypatch):
|
|
from backend.app.connectors.authentik import AuthentikConnector
|
|
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
|
|
|
previous = _set_authentik_config()
|
|
_set_npm_config()
|
|
monkeypatch.setattr(AuthentikConnector, "applications", lambda self: {"success": True, "items": AuthentikConnector._map_applications(_applications())}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "providers", lambda self: {"success": True, "items": AuthentikConnector._map_providers(_providers())}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "proxy_providers", lambda self: {"success": True, "items": AuthentikConnector._map_proxy_providers(_proxy_providers())}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "oauth2_providers", lambda self: {"success": True, "items": AuthentikConnector._map_oauth2_providers(_oauth2_providers())}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "outposts", lambda self: {"success": True, "items": []}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "outpost_health", lambda self: {"success": True, "available": False, "embeddedExpected": True, "status": "warning", "items": []}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "events", lambda self, limit=25: {"success": True, "items": []}, raising=True)
|
|
monkeypatch.setattr(
|
|
NginxProxyManagerConnector,
|
|
"proxy_hosts",
|
|
lambda self: {
|
|
"success": True,
|
|
"items": [
|
|
{"id": 1, "domainNames": ["media.example.com"], "enabled": True, "accessListId": 2, "forwardHost": "plex", "forwardPort": 32400, "forwardScheme": "http"},
|
|
{"id": 2, "domainNames": ["open.example.com"], "enabled": True, "accessListId": None, "forwardHost": "app", "forwardPort": 8080, "forwardScheme": "http"},
|
|
],
|
|
},
|
|
raising=True,
|
|
)
|
|
|
|
coverage = AuthentikConnector().auth_coverage()
|
|
warnings = AuthentikConnector().security_warnings()
|
|
|
|
assert coverage["summary"]["totalPublicHosts"] == 2
|
|
assert coverage["summary"]["protectedHosts"] == 1
|
|
assert coverage["summary"]["unprotectedHosts"] == 1
|
|
assert any(item["coverageStatus"] == "protected" for item in coverage["publicHosts"])
|
|
assert any(item["coverageStatus"] == "unprotected" for item in coverage["publicHosts"])
|
|
assert any(item["type"] == "no_outposts" for item in warnings["items"])
|
|
assert any(item["type"] == "public_host_without_authentik" for item in warnings["items"])
|
|
assert any(item["type"] == "oauth_many_redirect_uris" for item in warnings["items"])
|
|
|
|
_restore_config(previous)
|
|
|
|
|
|
def test_authentik_reuses_read_results_within_connector_instance(monkeypatch):
|
|
from backend.app.connectors.authentik import AuthentikConnector
|
|
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
|
|
|
previous = _set_authentik_config()
|
|
_set_npm_config()
|
|
calls = []
|
|
|
|
def paged(self, path, params=None):
|
|
calls.append(path)
|
|
return {
|
|
"/core/applications/": _applications(),
|
|
"/core/providers/": _providers(),
|
|
"/providers/proxy/": _proxy_providers(),
|
|
"/providers/oauth2/": _oauth2_providers(),
|
|
"/outposts/instances/": _outposts(),
|
|
"/events/events/": _events(),
|
|
}[path]
|
|
|
|
monkeypatch.setattr(AuthentikConnector, "_paged", paged, raising=True)
|
|
monkeypatch.setattr(
|
|
NginxProxyManagerConnector,
|
|
"proxy_hosts",
|
|
lambda self: {
|
|
"success": True,
|
|
"status": "ok",
|
|
"items": [
|
|
{"id": 1, "domainNames": ["media.example.com"], "enabled": True, "accessListId": 2, "forwardHost": "plex", "forwardPort": 32400, "forwardScheme": "http"},
|
|
{"id": 2, "domainNames": ["open.example.com"], "enabled": True, "accessListId": None, "forwardHost": "app", "forwardPort": 8080, "forwardScheme": "http"},
|
|
],
|
|
},
|
|
raising=True,
|
|
)
|
|
|
|
connector = AuthentikConnector()
|
|
coverage = connector.auth_coverage()
|
|
warnings = connector.security_warnings()
|
|
outpost = connector.outpost_health()
|
|
|
|
assert coverage["summary"]["totalPublicHosts"] == 2
|
|
assert warnings["total"] >= 1
|
|
assert outpost["available"] is True
|
|
assert calls.count("/core/applications/") == 1
|
|
assert calls.count("/core/providers/") == 1
|
|
assert calls.count("/providers/proxy/") == 1
|
|
assert calls.count("/providers/oauth2/") == 1
|
|
assert calls.count("/outposts/instances/") == 1
|
|
assert calls.count("/events/events/") == 1
|
|
|
|
_restore_config(previous)
|
|
|
|
|
|
def test_authentik_unknown_embedded_outpost_is_warning_not_high(monkeypatch):
|
|
from backend.app.connectors.authentik import AuthentikConnector
|
|
|
|
previous = _set_authentik_config(outpost_url=None)
|
|
try:
|
|
monkeypatch.setattr(AuthentikConnector, "applications", lambda self: {"success": True, "items": AuthentikConnector._map_applications(_applications())}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "providers", lambda self: {"success": True, "items": []}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "proxy_providers", lambda self: {"success": True, "items": []}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "oauth2_providers", lambda self: {"success": True, "items": []}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "outposts", lambda self: {"success": True, "items": [{"id": "embedded", "name": "Embedded Outpost", "health": "unknown"}]}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "outpost_health", lambda self: {"success": True, "available": False, "embeddedExpected": True, "status": "warning", "items": [{"id": "embedded", "name": "Embedded Outpost", "health": "unknown"}]}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "events", lambda self, limit=25: {"success": True, "items": []}, raising=True)
|
|
|
|
warnings = AuthentikConnector().security_warnings()
|
|
embedded = next(item for item in warnings["items"] if item["type"] == "embedded_outpost_unavailable")
|
|
|
|
assert embedded["severity"] == "warning"
|
|
finally:
|
|
_restore_config(previous)
|
|
|
|
|
|
def test_authentik_numeric_pagination_reuses_collection_path(monkeypatch):
|
|
from backend.app.connectors.authentik import AuthentikConnector
|
|
|
|
previous = _set_authentik_config()
|
|
calls = []
|
|
|
|
def request(self, path, params=None):
|
|
calls.append((path, params))
|
|
page = int((params or {}).get("page", 1))
|
|
return {
|
|
"results": [{"pk": f"app-{page}"}],
|
|
"pagination": {"next": page + 1 if page < 3 else 0},
|
|
}
|
|
|
|
monkeypatch.setattr(AuthentikConnector, "_request", request, raising=True)
|
|
try:
|
|
items = AuthentikConnector()._paged("/core/applications/", {"page_size": 20})
|
|
|
|
assert [item["pk"] for item in items] == ["app-1", "app-2", "app-3"]
|
|
assert calls == [
|
|
("/core/applications/", {"page_size": 20}),
|
|
("/core/applications/", {"page_size": 20, "page": 2}),
|
|
("/core/applications/", {"page_size": 20, "page": 3}),
|
|
]
|
|
finally:
|
|
_restore_config(previous)
|
|
|
|
|
|
def test_authentik_outpost_reachability_is_not_mislabeled_as_health(monkeypatch):
|
|
from backend.app.connectors.authentik import AuthentikConnector
|
|
|
|
previous = _set_authentik_config(outpost_url="https://outpost.example.test")
|
|
try:
|
|
monkeypatch.setattr(
|
|
AuthentikConnector,
|
|
"outposts",
|
|
lambda self: {
|
|
"success": True,
|
|
"items": [{"id": "embedded", "name": "Embedded Outpost", "health": "unknown"}],
|
|
},
|
|
raising=True,
|
|
)
|
|
monkeypatch.setattr(
|
|
AuthentikConnector,
|
|
"_probe_outpost",
|
|
lambda self, config: {"status": "reachable", "reachable": True, "statusCode": 302},
|
|
raising=True,
|
|
)
|
|
|
|
result = AuthentikConnector().outpost_health()
|
|
|
|
assert result["available"] is False
|
|
assert result["reachable"] is True
|
|
assert result["status"] == "reachable_unverified"
|
|
assert result["telemetryStatus"] == "unverified"
|
|
assert result["message"] == "outpost_reachable_health_unverified"
|
|
finally:
|
|
_restore_config(previous)
|
|
|
|
|
|
def test_authentik_outpost_probe_never_forwards_api_token(monkeypatch):
|
|
from backend.app.connectors import authentik
|
|
from backend.app.connectors.authentik import AuthentikConnector
|
|
|
|
previous = _set_authentik_config(token="SENSITIVE_AUTHENTIK_TOKEN")
|
|
captured = {}
|
|
|
|
class FakeResponse:
|
|
status_code = 401
|
|
|
|
class FakeClient:
|
|
def __init__(self, **kwargs):
|
|
captured["client"] = kwargs
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return None
|
|
|
|
def get(self, url, headers):
|
|
captured["url"] = url
|
|
captured["headers"] = headers
|
|
return FakeResponse()
|
|
|
|
monkeypatch.setattr(authentik.httpx, "Client", FakeClient)
|
|
try:
|
|
config = AuthentikConnector()._config()
|
|
result = AuthentikConnector._probe_outpost(config)
|
|
finally:
|
|
_restore_config(previous)
|
|
|
|
assert result == {"status": "reachable", "reachable": True, "statusCode": 401}
|
|
assert "Authorization" not in captured["headers"]
|
|
assert "SENSITIVE_AUTHENTIK_TOKEN" not in str(captured)
|
|
|
|
|
|
def test_authentik_routes_are_read_only_and_shape_is_consistent(tmp_path, monkeypatch):
|
|
_reset_db(tmp_path, monkeypatch)
|
|
previous = _set_authentik_config()
|
|
|
|
from backend.app.connectors.authentik import AuthentikConnector
|
|
from backend.app.main import app
|
|
|
|
monkeypatch.setattr(AuthentikConnector, "_request", lambda self, path, params=None: {"version": "2026.1.0"}, raising=True)
|
|
monkeypatch.setattr(AuthentikConnector, "_paged", lambda self, path, params=None: [], raising=True)
|
|
|
|
with TestClient(app) as client:
|
|
for path in [
|
|
"/api/apps/authentik/health",
|
|
"/api/apps/authentik/system",
|
|
"/api/apps/authentik/applications",
|
|
"/api/apps/authentik/providers",
|
|
"/api/apps/authentik/providers/proxy",
|
|
"/api/apps/authentik/providers/oauth2",
|
|
"/api/apps/authentik/outposts",
|
|
"/api/apps/authentik/outposts/health",
|
|
"/api/apps/authentik/flows",
|
|
"/api/apps/authentik/policies",
|
|
"/api/apps/authentik/events",
|
|
"/api/apps/authentik/auth-coverage",
|
|
"/api/apps/authentik/security-warnings",
|
|
]:
|
|
response = client.get(path)
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert set(["success", "status", "message", "data"]).issubset(payload.keys())
|
|
assert client.post("/api/apps/authentik/applications").status_code == 405
|
|
|
|
_restore_config(previous)
|
|
|
|
|
|
def test_authentik_policy_blocks_mutations_and_unknowns_fail_closed(tmp_path, monkeypatch):
|
|
_reset_db(tmp_path, monkeypatch)
|
|
|
|
from backend.app.main import app
|
|
|
|
with TestClient(app) as client:
|
|
for action in [
|
|
"authentik.create_application",
|
|
"authentik.update_application",
|
|
"authentik.delete_application",
|
|
"authentik.create_provider",
|
|
"authentik.delete_provider",
|
|
"authentik.create_outpost",
|
|
"authentik.delete_policy",
|
|
"authentik.create_user",
|
|
"authentik.delete_group",
|
|
"authentik.rotate_token",
|
|
"authentik.force_sync",
|
|
"authentik.mass_edit",
|
|
"authentik.unknown_mutation",
|
|
]:
|
|
payload = client.post("/api/policies/evaluate", json={"action": action, "target": "authentik", "source": "manual"}).json()
|
|
assert payload["decision"] == "blocked"
|
|
events_voice = client.post("/api/policies/evaluate", json={"action": "authentik.events", "target": "authentik", "source": "voice"}).json()
|
|
assert events_voice["decision"] == "approval_required"
|
|
|
|
|
|
def test_authentik_inventory_classification_and_candidate(tmp_path, monkeypatch):
|
|
_reset_db(tmp_path, monkeypatch)
|
|
|
|
from backend.app.main import app
|
|
from backend.app.services.app_classifier import classify_container
|
|
|
|
result = classify_container({"name": "authentik-server", "image": "ghcr.io/goauthentik/server:latest", "ports": ["9000:9000"]})
|
|
|
|
assert result["app_type"] == "authentik"
|
|
assert result["category"] == "auth"
|
|
assert result["subcategory"] == "identity_provider"
|
|
assert result["connector"] == "authentik"
|
|
assert result["criticality"] == "critical"
|
|
assert result["policy_profile"] == "auth_provider"
|
|
assert result["protected"] is True
|
|
|
|
monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.test_connection", lambda self: {"status": "connected"}, raising=True)
|
|
monkeypatch.setattr(
|
|
"backend.app.connectors.portainer.PortainerConnector.list_containers",
|
|
lambda self: {"status": "ok", "containers": [{"id": "auth-container", "name": "authentik-worker", "image": "ghcr.io/goauthentik/proxy", "state": "running", "status": "running", "ports": ["9000:9000"]}]},
|
|
raising=True,
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
client.post("/api/inventory/discover")
|
|
candidates = client.get("/api/connectors/authentik/candidates").json()
|
|
|
|
assert len(candidates) == 1
|
|
assert candidates[0]["connectorId"] == "authentik"
|
|
assert candidates[0]["appType"] == "authentik"
|
|
assert candidates[0]["missingSecrets"] == ["AUTHENTIK_URL", "AUTHENTIK_TOKEN"]
|