test coverage improvements; mTLS enforcement; agent broke :(

This commit is contained in:
boris committed 2026-04-17 20:53:44 +01:00
1 parent 921b760f80
commit 31b726b439
43 files changed
+3343 -80

No files matched your search

+490 -24
View File
@@ -1,26 +1,85 @@
from __future__ import annotations
import json
from datetime import timedelta
from datetime import datetime, timedelta
from types import SimpleNamespace
from urllib.parse import quote
from unittest.mock import patch
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from guardian.shortcuts import assign_perm
from ninja.errors import HttpError
from apps.servers.models import Server, ServerAuditLog, ServerLogSource
from keywarden.api.security import hash_agent_token
from apps.access.models import AccessRequest
from apps.keys.models import SSHKey
from apps.keys.models import SSHCertificate, SSHCertificateAuthority, parse_public_key
from apps.servers.models import AgentCertificateAuthority, Server, ServerAuditLog, ServerLogSource
from keywarden.api.routers import agent as agent_router
class AgentLogIngestionTests(TestCase):
def setUp(self) -> None:
super().setUp()
self.server = Server.objects.create(display_name="web-01", hostname="web-01.example.test")
self.agent_token = "test-agent-token"
self.server.agent_api_token_hash = hash_agent_token(self.agent_token)
self.server.save(update_fields=["agent_api_token_hash"])
self.agent_auth = f"Bearer {self.agent_token}"
self._configure_agent_mtls(self.server)
def _configure_agent_mtls(self, server: Server) -> None:
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
ca_subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Keywarden Test Agent CA")])
now = datetime.utcnow()
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_subject)
.issuer_name(ca_subject)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=365))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(ca_key, hashes.SHA256())
)
ca_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
ca_key_pem = ca_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
AgentCertificateAuthority.objects.create(
name="Agent Test CA",
cert_pem=ca_pem,
key_pem=ca_key_pem,
is_active=True,
)
agent_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
agent_cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "agent.example.test")]))
.issuer_name(ca_cert.subject)
.public_key(agent_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=90))
.sign(ca_key, hashes.SHA256())
)
cert_pem = agent_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
fingerprint = agent_cert.fingerprint(hashes.SHA256()).hex()
serial = format(agent_cert.serial_number, "x")
server.agent_cert_fingerprint = fingerprint
server.agent_cert_serial = serial
server.save(update_fields=["agent_cert_fingerprint", "agent_cert_serial"])
self.agent_headers = {
"HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY": "SUCCESS",
"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe=""),
"HTTP_X_KEYWARDEN_TLS_CLIENT_FINGERPRINT": fingerprint,
}
def test_ingest_logs_persists_events(self) -> None:
payload = [
@@ -50,7 +109,7 @@ class AgentLogIngestionTests(TestCase):
f"/api/v1/agent/servers/{self.server.id}/logs",
data=json.dumps(payload),
content_type="application/json",
HTTP_AUTHORIZATION=self.agent_auth,
**self.agent_headers,
)
self.assertEqual(response.status_code, 200)
@@ -74,11 +133,11 @@ class AgentLogIngestionTests(TestCase):
"/api/v1/agent/servers/999999/logs",
data=json.dumps([]),
content_type="application/json",
HTTP_AUTHORIZATION=self.agent_auth,
**self.agent_headers,
)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.status_code, 403)
def test_ingest_logs_requires_agent_token(self) -> None:
def test_ingest_logs_requires_agent_mtls(self) -> None:
response = self.client.post(
f"/api/v1/agent/servers/{self.server.id}/logs",
data=json.dumps([]),
@@ -86,15 +145,13 @@ class AgentLogIngestionTests(TestCase):
)
self.assertEqual(response.status_code, 401)
def test_ingest_logs_rejects_token_for_different_server(self) -> None:
def test_ingest_logs_rejects_mtls_cert_for_different_server(self) -> None:
other_server = Server.objects.create(display_name="web-02", hostname="web-02.example.test")
other_server.agent_api_token_hash = hash_agent_token("other-server-token")
other_server.save(update_fields=["agent_api_token_hash"])
response = self.client.post(
f"/api/v1/agent/servers/{self.server.id}/logs",
f"/api/v1/agent/servers/{other_server.id}/logs",
data=json.dumps([]),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer other-server-token",
**self.agent_headers,
)
self.assertEqual(response.status_code, 403)
@@ -129,7 +186,7 @@ class AgentLogIngestionTests(TestCase):
)
response = self.client.get(
f"/api/v1/agent/servers/{self.server.id}/log-config",
HTTP_AUTHORIZATION=self.agent_auth,
**self.agent_headers,
)
self.assertEqual(response.status_code, 200)
payload = response.json()
@@ -145,7 +202,7 @@ class AgentLogIngestionTests(TestCase):
def test_log_config_returns_empty_when_no_sources(self) -> None:
response = self.client.get(
f"/api/v1/agent/servers/{self.server.id}/log-config",
HTTP_AUTHORIZATION=self.agent_auth,
**self.agent_headers,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])
@@ -219,7 +276,7 @@ class ServerAuditViewTests(TestCase):
)
def test_audit_view_applies_filters(self) -> None:
self.client.login(username="auditor", password="pass12345")
self.client.force_login(self.user)
response = self.client.get(
reverse("servers:audit", args=[self.server.id]),
{
@@ -234,8 +291,416 @@ class ServerAuditViewTests(TestCase):
self.assertEqual(len(logs), 1)
self.assertEqual(logs[0].event_type, "ssh.login.success")
class AgentRouterHelpersTests(TestCase):
def setUp(self) -> None:
super().setUp()
User = get_user_model()
self.user = User.objects.create_user(
username="helper",
email="helper@example.com",
password="pass12345",
)
self.server = Server.objects.create(display_name="helper-01", hostname="helper-01.example.test")
assign_perm("view_server", self.user, self.server)
now = timezone.now()
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=5),
source_kind="service",
source_name="sshd.service",
category="access",
event_type="ssh.login.success",
username="alice",
source_ip="203.0.113.10",
message="Accepted publickey for alice",
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=4),
source_kind="service",
source_name="sshd.service",
category="access",
event_type="ssh.login.fail",
username="bob",
source_ip="198.51.100.5",
message="Failed password for bob",
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=3),
source_kind="file",
source_name="/var/log/auth.log",
category="network",
event_type="network",
message="Interface eth0 changed",
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=2),
source_kind="file",
source_name="/var/log/nginx/access.log",
category="access",
event_type="http.access",
source_ip="203.0.113.10",
message='192.0.2.40 - - [15/Apr/2026:23:57:44 +0000] "GET /api/v1/agent/servers/6/log-config HTTP/2.0" 200 682 "-"',
fields={"nginx.status": "200"},
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=1),
source_kind="file",
source_name="/var/log/nginx/access.log",
category="access",
event_type="http.access",
source_ip="198.51.100.5",
message='192.0.2.41 - - [15/Apr/2026:23:57:45 +0000] "GET /private HTTP/2.0" 401 245 "-"',
fields={"nginx.status": "401"},
)
def _build_ca_material(self):
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Keywarden Test Agent CA")])
now = datetime.utcnow()
ca_cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=365))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(ca_key, hashes.SHA256())
)
ca_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
key_pem = ca_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
return ca_cert, ca_key, ca_pem, key_pem
def _build_csr(self, common_name: str = "agent.example.test"):
agent_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]))
.sign(agent_key, hashes.SHA256())
)
return csr
def test_ip_and_truncation_helpers(self) -> None:
self.assertEqual(agent_router._normalize_ip("203.0.113.10", 4), "203.0.113.10")
self.assertEqual(agent_router._normalize_ip("2001:db8::10", 6), "2001:db8::10")
self.assertIsNone(agent_router._normalize_ip("not-ip", 4))
self.assertEqual(agent_router._normalize_ip_any(" 203.0.113.11 "), "203.0.113.11")
self.assertIsNone(agent_router._normalize_ip_any("bad-value"))
self.assertEqual(agent_router._truncate(" abc ", 3), "abc")
self.assertEqual(agent_router._truncate(None, 5, default=" xyz "), "xyz")
def test_parse_event_at_and_fields_coercion(self) -> None:
fallback = datetime(2026, 4, 17, 12, 0, 0)
parsed = agent_router._parse_event_at("2026-04-17T12:34:56Z", fallback)
self.assertEqual(parsed.isoformat(), "2026-04-17T12:34:56+00:00")
fallback_parsed = agent_router._parse_event_at("not-a-time", fallback)
self.assertTrue(timezone.is_aware(fallback_parsed))
coerced = agent_router._coerce_fields({"A": 1, "B": True, "": "skip"})
self.assertEqual(coerced, {"A": "1", "B": "True"})
self.assertEqual(agent_router._coerce_fields(None), {})
def test_source_kind_name_and_match_cleaning_helpers(self) -> None:
self.assertEqual(agent_router._normalize_source_kind("service", None, None), "service")
self.assertEqual(agent_router._normalize_source_kind(None, "sshd.service", None), "service")
self.assertEqual(agent_router._normalize_source_kind(None, None, {"_TRANSPORT": "journal"}), "journal")
self.assertEqual(agent_router._normalize_source_kind(None, None, {"file_path": "/var/log/auth.log"}), "file")
self.assertEqual(agent_router._normalize_source_kind(None, None, None), "")
self.assertEqual(agent_router._normalize_source_name(" sshd ", "x", {}), "sshd")
self.assertEqual(agent_router._normalize_source_name(None, "sudo.service", {}), "sudo.service")
self.assertEqual(agent_router._normalize_source_name(None, None, {"_SYSTEMD_UNIT": "ssh.service"}), "ssh.service")
self.assertEqual(agent_router._normalize_source_name(None, None, {"file_path": "/var/log/auth.log"}), "/var/log/auth.log")
clean = agent_router._clean_source_matches({"_TRANSPORT": [" kernel ", ""], "UNIT": " sshd.service "})
self.assertEqual(clean, {"_TRANSPORT": ["kernel"], "UNIT": ["sshd.service"]})
self.assertEqual(agent_router._clean_source_matches(None), {})
def test_default_category_and_event_type_helpers(self) -> None:
journal = SimpleNamespace(
kind=ServerLogSource.Kind.JOURNAL,
service_unit="",
include_matches={"_TRANSPORT": ["kernel"]},
)
service_ssh = SimpleNamespace(
kind=ServerLogSource.Kind.SERVICE,
service_unit="sshd.service",
include_matches={},
)
service_sudo = SimpleNamespace(
kind=ServerLogSource.Kind.SERVICE,
service_unit="sudo.service",
include_matches={},
)
file_source = SimpleNamespace(
kind=ServerLogSource.Kind.FILE,
service_unit="",
include_matches={},
)
self.assertEqual(agent_router._default_category_for_source(journal), "system")
self.assertEqual(agent_router._default_category_for_source(service_ssh), "access")
self.assertEqual(agent_router._default_category_for_source(service_sudo), "auth")
self.assertEqual(agent_router._default_event_type_for_source(journal), "kernel")
self.assertEqual(agent_router._default_event_type_for_source(service_ssh), "ssh")
self.assertEqual(agent_router._default_event_type_for_source(service_sudo), "auth")
self.assertEqual(agent_router._default_event_type_for_source(file_source), "file.line")
def test_agent_access_guards_and_token_generation(self) -> None:
with self.assertRaises(HttpError) as unauth:
agent_router._require_agent_access(SimpleNamespace(auth=None), self.server.id)
self.assertEqual(unauth.exception.status_code, 401)
with self.assertRaises(HttpError) as forbidden:
agent_router._require_agent_access(
SimpleNamespace(auth=agent_router.AgentPrincipal(server_id=999, mode="mtls")),
self.server.id,
)
self.assertEqual(forbidden.exception.status_code, 403)
agent_router._require_agent_access(
SimpleNamespace(auth=agent_router.AgentPrincipal(server_id=self.server.id, mode="mtls")),
self.server.id,
)
self.assertNotEqual(agent_router._issue_agent_api_token(), agent_router._issue_agent_api_token())
def test_server_lookup_key_map_and_account_sync_helpers(self) -> None:
found = agent_router._get_server_or_404(self.server.id)
self.assertEqual(found.id, self.server.id)
with self.assertRaises(HttpError) as not_found:
agent_router._get_server_or_404(999999)
self.assertEqual(not_found.exception.status_code, 404)
key_active = SSHKey.objects.create(
user=self.user,
name="active",
public_key="ssh-ed25519 AAAA",
key_type="ssh-ed25519",
fingerprint="fp-active",
is_active=True,
revoked_at=None,
)
SSHKey.objects.create(
user=self.user,
name="revoked",
public_key="ssh-ed25519 BBBB",
key_type="ssh-ed25519",
fingerprint="fp-revoked",
is_active=True,
revoked_at=timezone.now(),
)
key_map = agent_router._key_map_for_users([self.user])
self.assertIn(self.user.id, key_map)
self.assertEqual([item.id for item in key_map[self.user.id]], [key_active.id])
payload = [agent_router.AccountSyncIn(user_id=self.user.id, system_username="helper_1", present=True)]
agent_router._update_server_accounts(self.server, payload)
account = self.server.accounts.get(user=self.user)
self.assertEqual(account.system_username, "helper_1")
self.assertTrue(account.is_present)
def test_csr_and_agent_ca_helpers(self) -> None:
with self.assertRaises(HttpError) as bad_csr:
agent_router._load_csr("not a csr")
self.assertEqual(bad_csr.exception.status_code, 422)
with patch("keywarden.api.routers.agent.x509.load_pem_x509_csr", return_value=SimpleNamespace(is_signature_valid=False)):
with self.assertRaises(HttpError) as bad_sig:
agent_router._load_csr("dummy")
self.assertEqual(bad_sig.exception.status_code, 422)
ca_cert, ca_key, ca_pem, key_pem = self._build_ca_material()
ca_record = agent_router.AgentCertificateAuthority.objects.create(
name="Test Agent CA",
cert_pem=ca_pem,
key_pem=key_pem,
is_active=True,
)
loaded_cert, loaded_key, loaded_pem = agent_router._load_agent_ca()
self.assertEqual(loaded_pem, ca_record.cert_pem)
self.assertIsNotNone(loaded_cert)
self.assertIsNotNone(loaded_key)
csr = self._build_csr("agent-1.example.test")
with patch("keywarden.api.routers.agent._load_agent_ca", return_value=(ca_cert, ca_key, ca_pem)):
cert_pem, returned_ca_pem, fingerprint, serial = agent_router._issue_client_cert(
csr=csr,
host="agent-1.example.test",
server_id=self.server.id,
)
self.assertIn("BEGIN CERTIFICATE", cert_pem)
self.assertEqual(returned_ca_pem, ca_pem)
self.assertTrue(fingerprint)
self.assertTrue(serial)
class KeysModelHelpersTests(TestCase):
def setUp(self) -> None:
super().setUp()
User = get_user_model()
self.user = User.objects.create_user(
username="keys-user",
email="keys-user@example.com",
password="pass12345",
)
self.server = Server.objects.create(display_name="keys-server", hostname="keys-server.example.test")
assign_perm("view_server", self.user, self.server)
now = timezone.now()
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=5),
source_kind="service",
source_name="sshd.service",
category="access",
event_type="ssh.login.success",
username="alice",
source_ip="203.0.113.10",
message="Accepted publickey for alice",
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=4),
source_kind="service",
source_name="sshd.service",
category="access",
event_type="ssh.login.fail",
username="bob",
source_ip="198.51.100.5",
message="Failed password for bob",
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=3),
source_kind="file",
source_name="/var/log/auth.log",
category="network",
event_type="network",
message="Interface eth0 changed",
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=2),
source_kind="file",
source_name="/var/log/nginx/access.log",
category="access",
event_type="http.access",
source_ip="203.0.113.10",
message='192.0.2.40 - - [15/Apr/2026:23:57:44 +0000] "GET /api/v1/agent/servers/6/log-config HTTP/2.0" 200 682 "-"',
fields={"nginx.status": "200"},
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=1),
source_kind="file",
source_name="/var/log/nginx/access.log",
category="access",
event_type="http.access",
source_ip="198.51.100.5",
message='192.0.2.41 - - [15/Apr/2026:23:57:45 +0000] "GET /private HTTP/2.0" 401 245 "-"',
fields={"nginx.status": "401"},
)
def test_parse_public_key_valid_and_invalid(self) -> None:
key_type, key_b64, fingerprint = parse_public_key("ssh-ed25519 AAAA comment")
self.assertEqual(key_type, "ssh-ed25519")
self.assertEqual(key_b64, "AAAA")
self.assertTrue(fingerprint.startswith("SHA256:"))
with self.assertRaises(ValidationError):
parse_public_key("invalid-key")
def test_sshkey_set_public_key_and_revoke(self) -> None:
key = SSHKey.objects.create(
user=self.user,
name="laptop",
public_key="ssh-ed25519 AAAA",
key_type="ssh-ed25519",
fingerprint="fp-laptop",
is_active=True,
)
key.set_public_key("ssh-ed25519 AAAA comment-to-drop")
self.assertEqual(key.public_key, "ssh-ed25519 AAAA")
now = timezone.now()
cert = SSHCertificate.objects.create(
key=key,
user=self.user,
certificate="ssh-ed25519-cert-v01 AAAA",
serial=42,
principals=["keys_user"],
valid_after=now - timedelta(minutes=1),
valid_before=now + timedelta(days=1),
is_active=True,
)
key.revoke()
key.save(update_fields=["is_active", "revoked_at"])
key.refresh_from_db()
cert.refresh_from_db()
self.assertFalse(key.is_active)
self.assertIsNotNone(key.revoked_at)
self.assertFalse(cert.is_active)
self.assertIsNotNone(cert.revoked_at)
def test_ssh_certificate_authority_ensure_material_paths(self) -> None:
ca = SSHCertificateAuthority(
name="Existing CA",
public_key="ssh-ed25519 AAAA",
private_key="-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATE\n-----END OPENSSH PRIVATE KEY-----\n",
fingerprint="",
)
ca.ensure_material()
self.assertTrue(ca.fingerprint.startswith("SHA256:"))
self.assertTrue(ca.private_key.startswith("file://"))
generated = SSHCertificateAuthority(name="Generated CA")
def fake_run(cmd, check, capture_output):
key_path = cmd[cmd.index("-f") + 1]
with open(key_path, "w", encoding="utf-8") as handle:
handle.write("PRIVATE-KEY\n")
with open(key_path + ".pub", "w", encoding="utf-8") as handle:
handle.write("ssh-ed25519 AAAA generated-ca\n")
return SimpleNamespace(returncode=0)
with patch("apps.keys.models.subprocess.run", side_effect=fake_run):
generated.ensure_material()
self.assertTrue(generated.private_key)
self.assertEqual(generated.public_key, "ssh-ed25519 AAAA generated-ca")
self.assertTrue(generated.fingerprint.startswith("SHA256:"))
def test_ssh_certificate_revoke(self) -> None:
key = SSHKey.objects.create(
user=self.user,
name="desktop",
public_key="ssh-ed25519 AAAA",
key_type="ssh-ed25519",
fingerprint="fp-desktop",
is_active=True,
)
cert = SSHCertificate.objects.create(
key=key,
user=self.user,
certificate="ssh-ed25519-cert-v01 AAAA",
serial=77,
principals=["keys_user"],
valid_after=timezone.now() - timedelta(minutes=1),
valid_before=timezone.now() + timedelta(days=1),
is_active=True,
)
cert.revoke()
self.assertFalse(cert.is_active)
self.assertIsNotNone(cert.revoked_at)
def test_audit_panel_view_supports_source_filters(self) -> None:
self.client.login(username="auditor", password="pass12345")
self.client.force_login(self.user)
response = self.client.get(
reverse("servers:audit_panel", args=[self.server.id]),
{
@@ -245,10 +710,11 @@ class ServerAuditViewTests(TestCase):
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "/var/log/auth.log")
self.assertNotContains(response, "ssh.login.success")
self.assertContains(response, "Interface eth0 changed")
self.assertNotContains(response, "Accepted publickey for alice")
def test_audit_view_exposes_chart_context(self) -> None:
self.client.login(username="auditor", password="pass12345")
self.client.force_login(self.user)
response = self.client.get(
reverse("servers:audit", args=[self.server.id]),
{"view": "charts"},
@@ -272,7 +738,7 @@ class ServerAuditViewTests(TestCase):
self.assertEqual(charts["http_status_codes"]["implicit_total"], 1)
def test_audit_view_drilldown_filters_logs_for_http_status(self) -> None:
self.client.login(username="auditor", password="pass12345")
self.client.force_login(self.user)
response = self.client.get(
reverse("servers:audit", args=[self.server.id]),
{"view": "logs", "http_status": "401"},
@@ -284,7 +750,7 @@ class ServerAuditViewTests(TestCase):
self.assertIn(" 401 ", logs[0].message)
def test_audit_view_drilldown_filters_logs_for_login_outcome(self) -> None:
self.client.login(username="auditor", password="pass12345")
self.client.force_login(self.user)
response = self.client.get(
reverse("servers:audit", args=[self.server.id]),
{"view": "logs", "login_outcome": "success"},