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

+137 -1
View File
@@ -13,8 +13,17 @@ from django.test import RequestFactory, TestCase
from django.urls import reverse
from apps.accounts.forms import NativePasswordResetForm
from apps.accounts.identity import build_unique_username, normalize_email
from apps.accounts.identity import (
apply_optional_role_mapping,
build_unique_username,
claim_bool,
claim_text,
normalize_email,
parse_groups,
upsert_external_identity,
)
from apps.accounts.models import ExternalIdentity, NativeAccountSecurity
from apps.core.rbac import ROLE_ADMIN, ROLE_USER, get_user_role, set_user_role
from apps.accounts.social_auth import KeywardenSocialAccountAdapter
@@ -29,6 +38,20 @@ class IdentityHelpersTests(TestCase):
self.assertNotEqual(generated, "alice")
self.assertTrue(generated.startswith("alice"))
def test_parse_groups_supports_multiple_input_shapes(self):
self.assertEqual(parse_groups(None), set())
self.assertEqual(parse_groups("Admins, Dev Ops"), {"admins", "dev", "ops"})
self.assertEqual(parse_groups(["Team-A", " team-b "]), {"team-a", "team-b"})
self.assertEqual(parse_groups(123), {"123"})
def test_claim_helpers(self):
claims = {"name": " Alice ", "enabled": "YES", "flag": False}
self.assertEqual(claim_text(claims, "name"), "Alice")
self.assertEqual(claim_text(claims, "missing", "fallback"), "fallback")
self.assertTrue(claim_bool(claims, "enabled"))
self.assertFalse(claim_bool(claims, "flag", default=True))
self.assertTrue(claim_bool(claims, "missing", default=True))
class ImmutableEmailTests(TestCase):
def test_email_change_is_blocked(self):
@@ -53,6 +76,119 @@ class ExternalIdentityTests(TestCase):
)
self.assertIn("corporate-sso", str(identity))
def test_upsert_external_identity_create_and_update(self):
User = get_user_model()
user = User.objects.create_user(username="oidc1", email="oidc1@example.com", password="pass12345")
identity = upsert_external_identity(
user=user,
provider_type=ExternalIdentity.ProviderType.OIDC,
provider_id="oidc",
subject="sub-1",
email="oidc1@example.com",
issuer="https://issuer.example.com",
details={"groups": ["admins"]},
)
self.assertEqual(identity.user_id, user.id)
self.assertEqual(identity.issuer, "https://issuer.example.com")
self.assertEqual(identity.email_at_link, "oidc1@example.com")
refreshed = upsert_external_identity(
user=user,
provider_type=ExternalIdentity.ProviderType.OIDC,
provider_id="oidc",
subject="sub-1",
email="oidc1@example.com",
issuer="https://issuer-v2.example.com",
details={"groups": ["users"]},
)
self.assertEqual(refreshed.id, identity.id)
self.assertEqual(refreshed.issuer, "https://issuer-v2.example.com")
self.assertEqual(refreshed.details, {"groups": ["users"]})
def test_upsert_external_identity_rejects_conflicts(self):
User = get_user_model()
user1 = User.objects.create_user(username="oidc2", email="oidc2@example.com", password="pass12345")
user2 = User.objects.create_user(username="oidc3", email="oidc3@example.com", password="pass12345")
upsert_external_identity(
user=user1,
provider_type=ExternalIdentity.ProviderType.OIDC,
provider_id="oidc",
subject="sub-shared",
email="oidc2@example.com",
)
with self.assertRaises(Exception):
upsert_external_identity(
user=user2,
provider_type=ExternalIdentity.ProviderType.OIDC,
provider_id="oidc",
subject="sub-shared",
email="oidc3@example.com",
)
with self.assertRaises(Exception):
upsert_external_identity(
user=user1,
provider_type=ExternalIdentity.ProviderType.OIDC,
provider_id="oidc",
subject="sub-shared",
email="different@example.com",
)
class RoleMappingTests(TestCase):
def test_apply_optional_role_mapping_promotes_and_demotes(self):
User = get_user_model()
user = User.objects.create_user(username="role-map", email="role-map@example.com", password="pass12345")
promoted = apply_optional_role_mapping(
user=user,
groups={"platform-admins"},
enabled=True,
admin_groups={"platform-admins"},
demote_on_miss=False,
)
user.refresh_from_db()
self.assertEqual(promoted, ROLE_ADMIN)
self.assertEqual(get_user_role(user), ROLE_ADMIN)
demoted = apply_optional_role_mapping(
user=user,
groups={"non-admin"},
enabled=True,
admin_groups={"platform-admins"},
demote_on_miss=True,
)
user.refresh_from_db()
self.assertEqual(demoted, ROLE_USER)
self.assertEqual(get_user_role(user), ROLE_USER)
def test_apply_optional_role_mapping_noop_paths(self):
User = get_user_model()
user = User.objects.create_user(username="role-map2", email="role-map2@example.com", password="pass12345")
self.assertIsNone(
apply_optional_role_mapping(
user=user,
groups={"anything"},
enabled=False,
admin_groups={"platform-admins"},
demote_on_miss=True,
)
)
self.assertIsNone(
apply_optional_role_mapping(
user=user,
groups={"platform-admins"},
enabled=True,
admin_groups=set(),
demote_on_miss=True,
)
)
def test_set_user_role_invalid_raises(self):
User = get_user_model()
user = User.objects.create_user(username="role-map3", email="role-map3@example.com", password="pass12345")
with self.assertRaises(ValueError):
set_user_role(user, "invalid-role")
class _DummySocialLogin:
def __init__(
@@ -3,6 +3,7 @@ from __future__ import annotations
from django.http import HttpResponse
from django.test import RequestFactory, TestCase
from apps.audit.admin import AuditEventTypeAdminForm
from apps.audit.matching import find_matching_event_type
from apps.audit.middleware import ApiAuditLogMiddleware
from apps.audit.models import AuditEventType, AuditLog
@@ -84,3 +85,58 @@ class AuditEventMatchingTests(TestCase):
)
self.assertIsNotNone(matched)
self.assertEqual(matched.id, event_type.id)
class AuditAdminFormTests(TestCase):
def test_clean_endpoints_text_normalizes_lines(self) -> None:
form = AuditEventTypeAdminForm(
data={
"key": "admin_form_test",
"title": "Admin Form Test",
"description": "",
"kind": AuditEventType.Kind.API,
"default_severity": AuditEventType.Severity.INFO,
"endpoints_text": " /api/v1/a \n\nGET /api/v1/b \n",
"ip_whitelist_enabled": False,
"ip_whitelist_text": "",
"ip_blacklist_enabled": False,
"ip_blacklist_text": "",
}
)
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.cleaned_data["endpoints_text"], "/api/v1/a\nGET /api/v1/b")
def test_form_save_populates_endpoint_and_ip_lists(self) -> None:
form = AuditEventTypeAdminForm(
data={
"key": "admin_form_save",
"title": "Admin Form Save",
"description": "",
"kind": AuditEventType.Kind.API,
"default_severity": AuditEventType.Severity.WARNING,
"endpoints_text": "/api/v1/a\nPOST /api/v1/b",
"ip_whitelist_enabled": True,
"ip_whitelist_text": "10.0.0.1\n10.0.0.0/24",
"ip_blacklist_enabled": True,
"ip_blacklist_text": "203.0.113.10",
}
)
self.assertTrue(form.is_valid(), form.errors)
instance = form.save()
self.assertEqual(instance.endpoints, ["/api/v1/a", "POST /api/v1/b"])
self.assertEqual(instance.ip_whitelist, ["10.0.0.1", "10.0.0.0/24"])
self.assertEqual(instance.ip_blacklist, ["203.0.113.10"])
def test_form_initializes_text_fields_from_instance(self) -> None:
instance = AuditEventType.objects.create(
key="admin_form_init",
title="Admin Form Init",
kind=AuditEventType.Kind.WEBSOCKET,
endpoints=["/ws/servers/*/shell/"],
ip_whitelist=["10.0.0.1"],
ip_blacklist=["203.0.113.10"],
)
form = AuditEventTypeAdminForm(instance=instance)
self.assertEqual(form.fields["endpoints_text"].initial, "/ws/servers/*/shell/")
self.assertEqual(form.fields["ip_whitelist_text"].initial, "10.0.0.1")
self.assertEqual(form.fields["ip_blacklist_text"].initial, "203.0.113.10")
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import os
import secrets
from django.conf import settings
DEFAULT_SECRET_DIR = "/var/lib/keywarden/ca-keys"
def secret_dir() -> str:
path = str(getattr(settings, "KEYWARDEN_CA_KEY_DIR", DEFAULT_SECRET_DIR) or "").strip()
if not path:
path = DEFAULT_SECRET_DIR
os.makedirs(path, mode=0o700, exist_ok=True)
try:
os.chmod(path, 0o700)
except PermissionError:
# Best-effort hardening when process owner cannot change mode.
pass
return path
def looks_like_pem(value: str) -> bool:
return str(value or "").lstrip().startswith("-----BEGIN ")
def secret_ref_for(path: str) -> str:
return f"file://{path}"
def parse_secret_ref(value: str) -> str:
text = str(value or "").strip()
if text.lower().startswith("file://"):
return text[7:]
return ""
def write_secret_file(prefix: str, payload: str) -> str:
target_dir = secret_dir()
token = secrets.token_hex(16)
path = os.path.join(target_dir, f"{prefix}-{token}.pem")
with open(path, "w", encoding="utf-8") as handle:
handle.write(payload)
os.chmod(path, 0o600)
return secret_ref_for(path)
def read_secret_ref(value: str, *, label: str) -> str:
path = parse_secret_ref(value)
if not path:
return ""
try:
with open(path, "r", encoding="utf-8") as handle:
return handle.read()
except FileNotFoundError as exc:
raise RuntimeError(f"{label} file missing: {path}") from exc
except OSError as exc:
raise RuntimeError(f"{label} file unreadable: {path}") from exc
+1 -1
View File
@@ -41,7 +41,7 @@ def issue_certificate_for_key(key: SSHKey, created_by=None) -> SSHCertificate:
safe_name = _sanitize_label(key.name or "key")
identity = f"keywarden-cert-{key.user_id}-{safe_name}-{key.id}"
cert_text = _sign_public_key(
ca_private_key=ca.private_key,
ca_private_key=ca.get_private_key(),
ca_public_key=ca.public_key,
public_key=key.public_key,
identity=identity,
+16 -2
View File
@@ -12,6 +12,8 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from apps.core.secret_files import looks_like_pem, read_secret_ref, write_secret_file
def parse_public_key(public_key: str) -> tuple[str, str, str]:
trimmed = (public_key or "").strip()
@@ -104,8 +106,20 @@ class SSHCertificateAuthority(models.Model):
self.is_active = False
self.revoked_at = timezone.now()
def get_private_key(self) -> str:
file_payload = read_secret_ref(self.private_key, label="User CA private key")
if file_payload:
return file_payload
inline_payload = (self.private_key or "").strip()
if looks_like_pem(inline_payload):
self.private_key = write_secret_file("user-ca-key", inline_payload)
if self.pk:
self.save(update_fields=["private_key"])
return inline_payload
return ""
def ensure_material(self) -> None:
if self.public_key and self.private_key:
if self.public_key and self.get_private_key():
if not self.fingerprint:
_, _, fingerprint = parse_public_key(self.public_key)
self.fingerprint = fingerprint
@@ -130,7 +144,7 @@ class SSHCertificateAuthority(models.Model):
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc
with open(key_path, "r", encoding="utf-8") as handle:
self.private_key = handle.read()
self.private_key = write_secret_file("user-ca-key", handle.read())
with open(key_path + ".pub", "r", encoding="utf-8") as handle:
self.public_key = handle.read().strip()
_, _, fingerprint = parse_public_key(self.public_key)
+8 -7
View File
@@ -346,7 +346,7 @@ def _generate_session_keypair(tempdir: str, user, principal: str) -> tuple[str,
with open(pubkey_path, "r", encoding="utf-8") as handle:
public_key = handle.read().strip()
cert_text = _sign_public_key(
ca_private_key=ca.private_key,
ca_private_key=ca.get_private_key(),
ca_public_key=ca.public_key,
public_key=public_key,
identity=identity,
@@ -382,6 +382,11 @@ def _build_ssh_command(
log_level: str = "ERROR",
connect_timeout: int | None = None,
) -> list[str]:
strict_host_key_checking = str(
getattr(settings, "KEYWARDEN_SHELL_STRICT_HOST_KEY_CHECKING", "accept-new")
).strip().lower()
if strict_host_key_checking not in {"yes", "accept-new"}:
strict_host_key_checking = "accept-new"
command = [
"ssh",
]
@@ -404,13 +409,9 @@ def _build_ssh_command(
"-o",
"PreferredAuthentications=publickey",
"-o",
"UserKnownHostsFile=/dev/null",
f"StrictHostKeyChecking={strict_host_key_checking}",
"-o",
"GlobalKnownHostsFile=/dev/null",
"-o",
"StrictHostKeyChecking=no",
"-o",
"VerifyHostKeyDNS=no",
"UpdateHostKeys=yes",
"-o",
f"LogLevel={log_level}",
]
+16 -2
View File
@@ -12,6 +12,8 @@ from django.core.validators import RegexValidator
from django.db import models
from django.utils import timezone
from apps.core.secret_files import looks_like_pem, read_secret_ref, write_secret_file
hostname_validator = RegexValidator(
regex=r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*\.?$",
@@ -136,8 +138,20 @@ class AgentCertificateAuthority(models.Model):
self.is_active = False
self.revoked_at = timezone.now()
def get_key_pem(self) -> str:
file_payload = read_secret_ref(self.key_pem, label="Agent CA private key")
if file_payload:
return file_payload
inline_payload = (self.key_pem or "").strip()
if looks_like_pem(inline_payload):
self.key_pem = write_secret_file("agent-ca-key", inline_payload)
if self.pk:
self.save(update_fields=["key_pem"])
return inline_payload
return ""
def ensure_material(self) -> None:
if self.cert_pem and self.key_pem:
if self.cert_pem and self.get_key_pem():
return
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, self.name)])
@@ -160,7 +174,7 @@ class AgentCertificateAuthority(models.Model):
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
self.cert_pem = cert_pem
self.key_pem = key_pem
self.key_pem = write_secret_file("agent-ca-key", key_pem)
self.fingerprint = cert.fingerprint(hashes.SHA256()).hex()
self.serial = format(cert.serial_number, "x")
+122 -12
View File
@@ -1,12 +1,16 @@
from __future__ import annotations
from types import SimpleNamespace
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from guardian.shortcuts import assign_perm
from ninja.errors import HttpError
from apps.access.models import AccessRequest
from apps.core import rbac
from apps.servers.models import Server, ServerAccount
from apps.servers.permissions import user_can_logs, user_can_shell, user_can_users, user_has_any_access
@@ -71,23 +75,25 @@ class ServerAccessScopeTests(TestCase):
def test_audit_view_denied_when_logs_scope_not_granted(self) -> None:
self._create_access_request(request_users=True, request_logs=False)
self.client.login(username="alice", password="pass12345")
self.client.force_login(self.user)
response = self.client.get(reverse("servers:audit", args=[self.server.id]))
self.assertEqual(response.status_code, 404)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("servers:dashboard"))
def test_detail_view_denied_when_users_scope_not_granted(self) -> None:
self._create_access_request(request_logs=True, request_users=False)
self.client.login(username="alice", password="pass12345")
self.client.force_login(self.user)
response = self.client.get(reverse("servers:detail", args=[self.server.id]))
self.assertEqual(response.status_code, 404)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("servers:dashboard"))
def test_dashboard_request_access_creates_pending_request(self) -> None:
self._grant_perm(self.user, "access", "add_accessrequest")
self.client.login(username="alice", password="pass12345")
self.client.force_login(self.user)
response = self.client.post(reverse("servers:request_access", args=[self.server.id]))
@@ -112,7 +118,7 @@ class ServerAccessScopeTests(TestCase):
request_logs=True,
request_users=True,
)
self.client.login(username="admin", password="pass12345")
self.client.force_login(self.admin)
response = self.client.post(
reverse("servers:decide_access_request", args=[pending.id]),
@@ -125,9 +131,10 @@ class ServerAccessScopeTests(TestCase):
self.assertEqual(pending.decided_by_id, self.admin.id)
def test_admin_dashboard_requires_admin(self) -> None:
self.client.login(username="alice", password="pass12345")
self.client.force_login(self.user)
response = self.client.get(reverse("servers:admin_dashboard"))
self.assertEqual(response.status_code, 404)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("servers:dashboard"))
def test_admin_dashboard_lists_pending_requests(self) -> None:
pending = AccessRequest.objects.create(
@@ -136,7 +143,7 @@ class ServerAccessScopeTests(TestCase):
status=AccessRequest.Status.PENDING,
request_shell=True,
)
self.client.login(username="admin", password="pass12345")
self.client.force_login(self.admin)
response = self.client.get(reverse("servers:admin_dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, pending.requester.username)
@@ -144,9 +151,10 @@ class ServerAccessScopeTests(TestCase):
def test_server_admin_requires_admin(self) -> None:
assign_perm("view_server", self.user, self.server)
self.client.login(username="alice", password="pass12345")
self.client.force_login(self.user)
response = self.client.get(reverse("servers:server_admin", args=[self.server.id]))
self.assertEqual(response.status_code, 404)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("servers:dashboard"))
def test_server_admin_lists_users_and_server_accounts(self) -> None:
AccessRequest.objects.create(
@@ -163,10 +171,112 @@ class ServerAccessScopeTests(TestCase):
system_username="alice_1",
is_present=True,
)
self.client.login(username="admin", password="pass12345")
self.client.force_login(self.admin)
response = self.client.get(reverse("servers:server_admin", args=[self.server.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Users with access")
self.assertContains(response, self.user.username)
self.assertContains(response, "Server accounts")
self.assertContains(response, "alice_1")
class ServerApiRouterTests(TestCase):
def setUp(self) -> None:
super().setUp()
User = get_user_model()
self.user = User.objects.create_user(
username="api-user",
email="api-user@example.com",
password="pass12345",
)
self.server_allowed = Server.objects.create(display_name="api-allowed", hostname="api-allowed.example.test")
self.server_denied = Server.objects.create(display_name="api-denied", hostname="api-denied.example.test")
assign_perm("view_server", self.user, self.server_allowed)
def test_list_servers_returns_only_object_permitted_servers(self) -> None:
self.client.force_login(self.user)
response = self.client.get("/api/v1/servers/")
self.assertEqual(response.status_code, 200)
payload = response.json()
ids = {item["id"] for item in payload}
self.assertIn(self.server_allowed.id, ids)
self.assertNotIn(self.server_denied.id, ids)
def test_get_server_forbidden_without_object_permission(self) -> None:
self.client.force_login(self.user)
response = self.client.get(f"/api/v1/servers/{self.server_denied.id}")
self.assertEqual(response.status_code, 403)
def test_get_server_not_found(self) -> None:
self.client.force_login(self.user)
response = self.client.get("/api/v1/servers/999999")
self.assertEqual(response.status_code, 404)
def test_update_server_requires_change_permission(self) -> None:
self.client.force_login(self.user)
response = self.client.patch(
f"/api/v1/servers/{self.server_allowed.id}",
data={"display_name": "renamed"},
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
def test_update_server_validates_display_name(self) -> None:
perm = Permission.objects.get(content_type__app_label="servers", codename="change_server")
self.user.user_permissions.add(perm)
self.client.force_login(self.user)
with self.assertRaises(TypeError):
self.client.patch(
f"/api/v1/servers/{self.server_allowed.id}",
data={"display_name": " "},
content_type="application/json",
)
def test_update_server_success(self) -> None:
perm = Permission.objects.get(content_type__app_label="servers", codename="change_server")
self.user.user_permissions.add(perm)
self.client.force_login(self.user)
response = self.client.patch(
f"/api/v1/servers/{self.server_allowed.id}",
data={"display_name": "renamed-server"},
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.server_allowed.refresh_from_db()
self.assertEqual(self.server_allowed.display_name, "renamed-server")
class CoreRbacTests(TestCase):
def setUp(self) -> None:
super().setUp()
User = get_user_model()
self.user = User.objects.create_user(
username="rbac-user",
email="rbac-user@example.com",
password="pass12345",
)
def test_role_normalization_and_assignment(self) -> None:
self.assertEqual(rbac.normalize_role("ADMIN"), rbac.ROLE_ADMIN)
self.assertEqual(rbac.set_user_role(self.user, "admin"), rbac.ROLE_ADMIN)
self.user.save(update_fields=["is_staff", "is_superuser"])
self.assertEqual(rbac.get_user_role(self.user), rbac.ROLE_ADMIN)
self.assertTrue(self.user.is_staff)
self.assertTrue(self.user.is_superuser)
self.assertEqual(rbac.set_user_role(self.user, "user"), rbac.ROLE_USER)
self.user.save(update_fields=["is_staff", "is_superuser"])
self.assertEqual(rbac.get_user_role(self.user), rbac.ROLE_USER)
self.assertFalse(self.user.is_staff)
self.assertFalse(self.user.is_superuser)
with self.assertRaises(ValueError):
rbac.set_user_role(self.user, "invalid")
def test_require_auth_and_perms_guards(self) -> None:
with self.assertRaises(HttpError):
rbac.require_authenticated(SimpleNamespace(user=None))
request = SimpleNamespace(user=self.user)
with self.assertRaises(HttpError):
rbac.require_perms(request, "servers.change_server")
+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"},
+498 -3
View File
@@ -1,8 +1,33 @@
from __future__ import annotations
from django.test import SimpleTestCase, override_settings
import subprocess
import tempfile
from datetime import datetime, timedelta, timezone as dt_timezone
from types import SimpleNamespace
from urllib.parse import quote
from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, Mock, patch
from apps.servers.consumers import _build_ssh_command
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.conf import settings
from django.test import SimpleTestCase, TestCase, override_settings
from apps.servers.consumers import (
ShellConsumer,
_build_ssh_command,
_format_ssh_host,
_generate_session_keypair,
_is_truthy,
_summarize_probe_output,
_truncate_lines,
)
from apps.keys import certificates as key_certificates
from apps.keys.utils import render_system_username, sanitize_username
from apps.servers.models import AgentCertificateAuthority, Server
from keywarden.api import security as api_security
class ShellSSHCommandTests(SimpleTestCase):
@@ -13,7 +38,8 @@ class ShellSSHCommandTests(SimpleTestCase):
username="alice",
host="server.example.test",
)
self.assertEqual(command[-1], "/bin/bash")
expected = str(getattr(settings, "KEYWARDEN_SHELL_REMOTE_COMMAND", "/bin/bash")).strip()
self.assertEqual(command[-1], expected)
@override_settings(KEYWARDEN_SHELL_REMOTE_COMMAND="/bin/bash -li")
def test_build_command_appends_configured_remote_shell(self):
@@ -50,6 +76,29 @@ class ShellSSHCommandTests(SimpleTestCase):
self.assertIn("ConnectTimeout=8", command)
self.assertEqual(command[-1], "true")
def test_build_command_enforces_host_key_verification(self):
command = _build_ssh_command(
key_path="/tmp/session_key",
cert_path="/tmp/session_key-cert.pub",
username="alice",
host="server.example.test",
)
self.assertIn("StrictHostKeyChecking=accept-new", command)
self.assertIn("UpdateHostKeys=yes", command)
self.assertNotIn("StrictHostKeyChecking=no", command)
self.assertNotIn("UserKnownHostsFile=/dev/null", command)
self.assertNotIn("GlobalKnownHostsFile=/dev/null", command)
@override_settings(KEYWARDEN_SHELL_STRICT_HOST_KEY_CHECKING="yes")
def test_build_command_allows_strict_host_key_policy_override(self):
command = _build_ssh_command(
key_path="/tmp/session_key",
cert_path="/tmp/session_key-cert.pub",
username="alice",
host="server.example.test",
)
self.assertIn("StrictHostKeyChecking=yes", command)
@override_settings(KEYWARDEN_SHELL_REMOTE_COMMAND='"/bin/bash -li')
def test_build_command_tolerates_invalid_shell_quoting(self):
command = _build_ssh_command(
@@ -59,3 +108,449 @@ class ShellSSHCommandTests(SimpleTestCase):
host="server.example.test",
)
self.assertEqual(command[-1], '"/bin/bash -li')
class ShellHelpersTests(SimpleTestCase):
def test_format_ssh_host_wraps_ipv6(self):
self.assertEqual(_format_ssh_host("2001:db8::1"), "[2001:db8::1]")
self.assertEqual(_format_ssh_host("[2001:db8::1]"), "[2001:db8::1]")
self.assertEqual(_format_ssh_host("server.example.test"), "server.example.test")
def test_is_truthy_supports_common_values(self):
self.assertTrue(_is_truthy(True))
self.assertTrue(_is_truthy("YES"))
self.assertTrue(_is_truthy("1"))
self.assertFalse(_is_truthy(False))
self.assertFalse(_is_truthy(None))
self.assertFalse(_is_truthy("off"))
def test_truncate_lines_limits_output(self):
text = "\n".join([f"line-{idx}" for idx in range(20)])
clipped = _truncate_lines(text, max_lines=6, max_chars=2000)
self.assertIn("... (middle omitted) ...", clipped)
self.assertIn("line-0", clipped)
self.assertIn("line-19", clipped)
def test_summarize_probe_output_extracts_highlights(self):
raw = (
"debug: Authenticating to host\n"
"Permission denied (publickey).\n"
"certificate invalid: bad signature\n"
"no such user\n"
"tail marker\n"
)
summary = _summarize_probe_output(raw)
self.assertIn("Detected cause:", summary)
self.assertIn("SSH authentication was denied", summary)
self.assertIn("SSH certificate was rejected", summary)
self.assertIn("Remote user account does not exist", summary)
self.assertIn("Probe tail:", summary)
def test_generate_session_keypair_creates_cert_material(self):
with tempfile.TemporaryDirectory() as tmpdir:
ca = SimpleNamespace(
private_key="ca-private",
public_key="ca-public",
get_private_key=lambda: "ca-private",
)
user = SimpleNamespace(id=7)
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\n")
with open(key_path + ".pub", "w", encoding="utf-8") as handle:
handle.write("ssh-ed25519 AAAATEST comment\n")
return SimpleNamespace(returncode=0)
with (
patch("apps.servers.consumers.get_active_ca", return_value=ca),
patch("apps.servers.consumers._sign_public_key", return_value="ssh-ed25519-cert-v01 AAAACERT"),
patch("apps.servers.consumers.subprocess.run", side_effect=fake_run),
patch("apps.servers.consumers.secrets.randbits", return_value=42),
):
key_path, cert_path = _generate_session_keypair(tmpdir, user, "alice")
with open(cert_path, "r", encoding="utf-8") as handle:
cert_body = handle.read()
self.assertTrue(key_path.endswith("session_key"))
self.assertTrue(cert_path.endswith("session_key-cert.pub"))
self.assertIn("AAAACERT", cert_body)
def test_generate_session_keypair_raises_when_ssh_keygen_missing(self):
with tempfile.TemporaryDirectory() as tmpdir:
ca = SimpleNamespace(
private_key="ca-private",
public_key="ca-public",
get_private_key=lambda: "ca-private",
)
user = SimpleNamespace(id=8)
with (
patch("apps.servers.consumers.get_active_ca", return_value=ca),
patch("apps.servers.consumers.subprocess.run", side_effect=FileNotFoundError("missing")),
):
with self.assertRaises(RuntimeError):
_generate_session_keypair(tmpdir, user, "alice")
def test_generate_session_keypair_raises_on_ssh_keygen_error(self):
with tempfile.TemporaryDirectory() as tmpdir:
ca = SimpleNamespace(
private_key="ca-private",
public_key="ca-public",
get_private_key=lambda: "ca-private",
)
user = SimpleNamespace(id=9)
called = subprocess.CalledProcessError(1, ["ssh-keygen"], stderr=b"boom")
with (
patch("apps.servers.consumers.get_active_ca", return_value=ca),
patch("apps.servers.consumers.subprocess.run", side_effect=called),
):
with self.assertRaises(RuntimeError):
_generate_session_keypair(tmpdir, user, "alice")
class ShellConsumerAsyncTests(IsolatedAsyncioTestCase):
async def test_connect_rejects_unauthenticated_user(self):
consumer = ShellConsumer()
consumer.scope = {"url_route": {"kwargs": {"server_id": "1"}}}
consumer.close = AsyncMock()
await consumer.connect()
consumer.close.assert_awaited_once_with(code=4401)
async def test_connect_rejects_missing_server_id(self):
consumer = ShellConsumer()
consumer.scope = {"user": SimpleNamespace(is_authenticated=True), "url_route": {"kwargs": {}}}
consumer.close = AsyncMock()
await consumer.connect()
consumer.close.assert_awaited_once_with(code=4400)
async def test_connect_accepts_and_starts_session(self):
user = SimpleNamespace(is_authenticated=True, id=5, username="alice")
server = SimpleNamespace(id=12, hostname="server.example.test", ipv4=None, ipv6=None)
consumer = ShellConsumer()
consumer.scope = {"user": user, "url_route": {"kwargs": {"server_id": "12"}}, "path": "/ws/servers/12/shell/"}
consumer.accept = AsyncMock()
consumer.send = AsyncMock()
consumer.close = AsyncMock()
consumer._get_server = AsyncMock(return_value=server)
consumer._can_shell = AsyncMock(return_value=True)
consumer._get_system_username = AsyncMock(return_value="alice_5")
consumer._audit_websocket_event = AsyncMock()
consumer._start_ssh = AsyncMock()
await consumer.connect()
consumer.accept.assert_awaited_once()
consumer._start_ssh.assert_awaited_once_with(user)
consumer.send.assert_any_await(text_data="Connecting...\r\n")
self.assertEqual(consumer.server_id, 12)
self.assertEqual(consumer.system_username, "alice_5")
self.assertEqual(consumer.shell_target, "server.example.test")
async def test_connect_handles_start_ssh_failure(self):
user = SimpleNamespace(is_authenticated=True, id=5, username="alice")
server = SimpleNamespace(id=12, hostname="server.example.test", ipv4=None, ipv6=None)
consumer = ShellConsumer()
consumer.scope = {"user": user, "url_route": {"kwargs": {"server_id": "12"}}, "path": "/ws/servers/12/shell/"}
consumer.accept = AsyncMock()
consumer.send = AsyncMock()
consumer.close = AsyncMock()
consumer._get_server = AsyncMock(return_value=server)
consumer._can_shell = AsyncMock(return_value=True)
consumer._get_system_username = AsyncMock(return_value="alice_5")
consumer._audit_websocket_event = AsyncMock()
consumer._start_ssh = AsyncMock(side_effect=RuntimeError("boom"))
await consumer.connect()
consumer.send.assert_any_await(text_data="Connection failed.\r\n")
consumer.close.assert_awaited()
async def test_receive_forwards_payload_to_ssh_stdin(self):
stdin = SimpleNamespace(write=Mock(), drain=AsyncMock())
consumer = ShellConsumer()
consumer.proc = SimpleNamespace(stdin=stdin)
await consumer.receive(text_data="echo hi")
stdin.write.assert_called_once()
stdin.drain.assert_awaited_once()
async def test_disconnect_terminates_process_and_cleans_tempdir(self):
user = SimpleNamespace(is_authenticated=True)
reader_task = SimpleNamespace(cancel=Mock())
proc = SimpleNamespace(returncode=None, terminate=Mock(), wait=AsyncMock(return_value=0), kill=Mock())
consumer = ShellConsumer()
consumer.scope = {"user": user}
consumer.server_id = 77
consumer.reader_task = reader_task
consumer.proc = proc
consumer.ssh_started = True
consumer.tempdir_path = "/tmp/keywarden-shell-test"
consumer._audit_websocket_event = AsyncMock()
with patch("apps.servers.consumers.shutil.rmtree") as rmtree:
await consumer.disconnect(1000)
consumer._audit_websocket_event.assert_awaited_once()
reader_task.cancel.assert_called_once()
proc.terminate.assert_called_once()
rmtree.assert_called_once_with("/tmp/keywarden-shell-test", ignore_errors=True)
async def test_stream_output_relays_bytes_and_closes(self):
stdout = SimpleNamespace(read=AsyncMock(side_effect=[b"hello", b""]))
proc = SimpleNamespace(stdout=stdout, wait=AsyncMock(return_value=0))
consumer = ShellConsumer()
consumer.proc = proc
consumer.send = AsyncMock()
consumer.close = AsyncMock()
await consumer._stream_output()
consumer.send.assert_any_await(bytes_data=b"hello")
consumer.close.assert_awaited_once()
class SecurityAndCertificateHelperTests(TestCase):
def test_normalize_agent_token_and_hash(self):
self.assertEqual(api_security._normalize_agent_token(" Bearer abc123 "), "abc123")
self.assertEqual(api_security._normalize_agent_token("token-value"), "token-value")
self.assertEqual(api_security.hash_agent_token(""), "")
self.assertEqual(
api_security.hash_agent_token("Bearer test-token"),
api_security.hash_agent_token("test-token"),
)
def _build_ca_and_client_cert(self):
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.now(dt_timezone.utc)
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_cert_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")
agent_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
client_subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "agent.example.test")])
cert = (
x509.CertificateBuilder()
.subject_name(client_subject)
.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=30))
.sign(ca_key, hashes.SHA256())
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
return ca_cert_pem, ca_key_pem, cert_pem, cert
def test_agent_mtls_auth_accepts_enrolled_server_certificate(self):
ca_cert_pem, ca_key_pem, cert_pem, cert = self._build_ca_and_client_cert()
AgentCertificateAuthority.objects.create(
name="Test Agent CA",
cert_pem=ca_cert_pem,
key_pem=ca_key_pem,
is_active=True,
)
fingerprint = cert.fingerprint(hashes.SHA256()).hex()
serial = format(cert.serial_number, "x")
server = Server.objects.create(
display_name="mtls-1",
hostname="mtls-1.example.test",
agent_cert_fingerprint=fingerprint,
agent_cert_serial=serial,
)
request = SimpleNamespace(
META={
"HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY": "SUCCESS",
"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe=""),
"HTTP_X_KEYWARDEN_TLS_CLIENT_FINGERPRINT": fingerprint,
}
)
auth = api_security.AgentMTLSAuth()
principal = auth.authenticate(request)
self.assertIsNotNone(principal)
self.assertEqual(principal.mode, "mtls")
self.assertEqual(principal.server_id, server.id)
def test_agent_mtls_auth_accepts_failed_verify_when_cert_is_valid(self):
ca_cert_pem, ca_key_pem, cert_pem, cert = self._build_ca_and_client_cert()
AgentCertificateAuthority.objects.create(
name="Test Agent CA",
cert_pem=ca_cert_pem,
key_pem=ca_key_pem,
is_active=True,
)
fingerprint = cert.fingerprint(hashes.SHA256()).hex()
Server.objects.create(
display_name="mtls-1b",
hostname="mtls-1b.example.test",
agent_cert_fingerprint=fingerprint,
agent_cert_serial=format(cert.serial_number, "x"),
)
request = SimpleNamespace(
META={
"HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY": "FAILED:unable to verify the first certificate",
"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe=""),
# nginx $ssl_client_fingerprint is SHA-1 by default.
"HTTP_X_KEYWARDEN_TLS_CLIENT_FINGERPRINT": cert.fingerprint(hashes.SHA1()).hex(),
}
)
auth = api_security.AgentMTLSAuth()
principal = auth.authenticate(request)
self.assertIsNotNone(principal)
self.assertEqual(principal.mode, "mtls")
def test_agent_mtls_auth_rejects_missing_or_mismatched_metadata(self):
ca_cert_pem, ca_key_pem, cert_pem, cert = self._build_ca_and_client_cert()
AgentCertificateAuthority.objects.create(
name="Test Agent CA",
cert_pem=ca_cert_pem,
key_pem=ca_key_pem,
is_active=True,
)
Server.objects.create(
display_name="mtls-2",
hostname="mtls-2.example.test",
agent_cert_fingerprint=cert.fingerprint(hashes.SHA256()).hex(),
agent_cert_serial=format(cert.serial_number, "x"),
)
auth = api_security.AgentMTLSAuth()
missing_verify = SimpleNamespace(
META={"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe="")}
)
self.assertIsNone(auth.authenticate(missing_verify))
none_verify = SimpleNamespace(
META={
"HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY": "NONE",
"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe=""),
}
)
self.assertIsNone(auth.authenticate(none_verify))
bad_fingerprint = SimpleNamespace(
META={
"HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY": "SUCCESS",
"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe=""),
"HTTP_X_KEYWARDEN_TLS_CLIENT_FINGERPRINT": "00" * 32,
}
)
self.assertIsNone(auth.authenticate(bad_fingerprint))
def test_sanitize_username_and_render_system_username(self):
self.assertEqual(sanitize_username("Alice Smith"), "alice_smith")
self.assertEqual(sanitize_username(" "), "")
with override_settings(KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE="{{username}}--{{user_id}}"):
rendered = render_system_username("Alice Smith", 42)
self.assertEqual(rendered, "alice_smith--42")
def test_certificate_label_and_comment_helpers(self):
self.assertEqual(key_certificates._sanitize_label(" Prod Key #1 "), "prod-key-1")
self.assertEqual(key_certificates._sanitize_label("$$$"), "key")
self.assertEqual(
key_certificates._ensure_comment("ssh-ed25519 AAAA old", "new-comment"),
"ssh-ed25519 AAAA new-comment",
)
self.assertEqual(
key_certificates._ensure_comment("ssh-ed25519 AAAA", ""),
"ssh-ed25519 AAAA",
)
def test_write_file_sets_mode(self):
with tempfile.TemporaryDirectory() as tmpdir:
target = f"{tmpdir}/test.txt"
key_certificates._write_file(target, "hello", 0o600)
with open(target, "r", encoding="utf-8") as handle:
self.assertEqual(handle.read(), "hello")
def test_sign_public_key_raises_when_ca_material_missing(self):
with self.assertRaises(RuntimeError):
key_certificates._sign_public_key(
ca_private_key="",
ca_public_key="",
public_key="ssh-ed25519 AAAA",
identity="id",
principal="alice",
serial=1,
validity_days=1,
comment="c",
)
def test_sign_public_key_raises_when_ssh_keygen_missing(self):
with patch("apps.keys.certificates.subprocess.run", side_effect=FileNotFoundError("missing")):
with self.assertRaises(RuntimeError):
key_certificates._sign_public_key(
ca_private_key="private",
ca_public_key="ssh-ed25519 AAAA",
public_key="ssh-ed25519 BBBB",
identity="id",
principal="alice",
serial=1,
validity_days=1,
comment="c",
)
def test_sign_public_key_happy_path_reads_generated_cert(self):
def fake_run(cmd, check, capture_output):
pubkey_path = cmd[-1]
cert_path = pubkey_path[:-4] + "-cert.pub"
with open(cert_path, "w", encoding="utf-8") as handle:
handle.write("ssh-ed25519-cert-v01 AAAACERT")
return SimpleNamespace(stderr=b"")
with patch("apps.keys.certificates.subprocess.run", side_effect=fake_run):
cert = key_certificates._sign_public_key(
ca_private_key="private",
ca_public_key="ssh-ed25519 AAAA",
public_key="ssh-ed25519 BBBB",
identity="id",
principal="alice",
serial=1,
validity_days=1,
comment="comment",
)
self.assertIn("AAAACERT", cert)
def test_issue_certificate_for_key_updates_record(self):
user = SimpleNamespace(id=5, username="alice")
key = SimpleNamespace(id=77, user_id=5, user=user, name="Laptop key", public_key="ssh-ed25519 BBBB")
fake_ca = SimpleNamespace(
private_key="private",
public_key="public",
get_private_key=lambda: "private",
)
fake_cert = SimpleNamespace(id=9)
with (
patch("apps.keys.certificates.get_active_ca", return_value=fake_ca),
patch("apps.keys.certificates._sign_public_key", return_value="ssh-ed25519-cert-v01 AAAACERT"),
patch("apps.keys.certificates.secrets.randbits", return_value=42),
patch("apps.keys.certificates.SSHCertificate.objects.update_or_create", return_value=(fake_cert, True)) as upsert,
):
cert = key_certificates.issue_certificate_for_key(key)
self.assertEqual(cert, fake_cert)
upsert.assert_called_once()
def test_revoke_certificate_for_key_marks_cert_revoked(self):
cert = SimpleNamespace(revoke=Mock(), save=Mock())
key = SimpleNamespace(certificate=cert)
key_certificates.revoke_certificate_for_key(key)
cert.revoke.assert_called_once()
cert.save.assert_called_once_with(update_fields=["is_active", "revoked_at"])
+398
View File
@@ -0,0 +1,398 @@
from __future__ import annotations
import json
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.utils import timezone
from guardian.shortcuts import assign_perm
from ninja.errors import HttpError
from apps.keys.models import SSHCertificate, SSHKey
from keywarden.api.routers import keys as keys_router
class KeysRouterHelperTests(TestCase):
def setUp(self) -> None:
super().setUp()
User = get_user_model()
self.user = User.objects.create_user(
username="keys-helper",
email="keys-helper@example.com",
password="pass12345",
)
def _create_key(self, *, owner=None, name: str = "helper-key", key_b64: str = "AAAA") -> SSHKey:
key = SSHKey(user=owner or self.user, name=name)
key.set_public_key(f"ssh-ed25519 {key_b64} comment")
key.save()
return key
def test_ensure_certificate_raises_when_key_is_revoked(self) -> None:
key = self._create_key()
key.is_active = False
key.save(update_fields=["is_active"])
with self.assertRaises(HttpError):
keys_router._ensure_certificate(key, self.user)
def test_ensure_certificate_issues_when_missing_or_expired(self) -> None:
key_missing = self._create_key(name="missing")
key_expired = self._create_key(name="expired", key_b64="AAAB")
SSHCertificate.objects.create(
key=key_expired,
user=self.user,
certificate="ssh-ed25519-cert-v01 AAAA",
serial=1001,
principals=["keys-helper"],
valid_after=timezone.now() - timedelta(days=2),
valid_before=timezone.now() - timedelta(days=1),
is_active=True,
)
issued = SimpleNamespace(serial=999, certificate="ssh-ed25519-cert-v01 BBBB")
with patch("keywarden.api.routers.keys.issue_certificate_for_key", return_value=issued) as mocked_issue:
self.assertIs(keys_router._ensure_certificate(key_missing, self.user), issued)
self.assertIs(keys_router._ensure_certificate(key_expired, self.user), issued)
self.assertEqual(mocked_issue.call_count, 2)
def test_ensure_certificate_returns_existing_active_cert(self) -> None:
key = self._create_key()
cert = SSHCertificate.objects.create(
key=key,
user=self.user,
certificate="ssh-ed25519-cert-v01 AAAA",
serial=42,
principals=["keys-helper"],
valid_after=timezone.now() - timedelta(minutes=5),
valid_before=timezone.now() + timedelta(days=1),
is_active=True,
)
with patch("keywarden.api.routers.keys.issue_certificate_for_key") as mocked_issue:
result = keys_router._ensure_certificate(key, self.user)
self.assertEqual(result.id, cert.id)
mocked_issue.assert_not_called()
class KeysApiRouterTests(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.other_user = User.objects.create_user(
username="keys-other",
email="keys-other@example.com",
password="pass12345",
)
def _grant_global_perm(self, user, codename: str) -> None:
perm = Permission.objects.get(content_type__app_label="keys", codename=codename)
user.user_permissions.add(perm)
def _create_key(self, *, owner=None, name: str = "api-key", key_b64: str = "AAAA") -> SSHKey:
key = SSHKey(user=owner or self.user, name=name)
key.set_public_key(f"ssh-ed25519 {key_b64} comment")
key.save()
return key
def _dummy_cert(self):
now = timezone.now()
return SimpleNamespace(
serial=1234,
valid_after=now,
valid_before=now + timedelta(days=1),
principals=[self.user.username],
certificate="ssh-ed25519-cert-v01 AAAATEST",
)
def test_list_keys_returns_only_object_permitted_keys(self) -> None:
visible = self._create_key(owner=self.other_user, name="visible")
hidden = self._create_key(owner=self.other_user, name="hidden", key_b64="AAAB")
assign_perm("view_sshkey", self.user, visible)
self.client.force_login(self.user)
response = self.client.get("/api/v1/keys/")
self.assertEqual(response.status_code, 200)
ids = {item["id"] for item in response.json()}
self.assertIn(visible.id, ids)
self.assertNotIn(hidden.id, ids)
def test_list_keys_honors_global_filter_by_user(self) -> None:
self._grant_global_perm(self.user, "view_sshkey")
other_key = self._create_key(owner=self.other_user, name="other", key_b64="AAAC")
self._create_key(owner=self.user, name="self", key_b64="AAAD")
self.client.force_login(self.user)
response = self.client.get(f"/api/v1/keys/?user_id={self.other_user.id}&limit=20&offset=0")
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(len(payload), 1)
self.assertEqual(payload[0]["id"], other_key.id)
def test_create_key_requires_add_permission(self) -> None:
self.client.force_login(self.user)
response = self.client.post(
"/api/v1/keys/",
data=json.dumps({"name": "laptop", "public_key": "ssh-ed25519 AAAA"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
def test_create_key_for_self_success(self) -> None:
self._grant_global_perm(self.user, "add_sshkey")
self.client.force_login(self.user)
with patch("keywarden.api.routers.keys.issue_certificate_for_key", return_value=self._dummy_cert()) as mocked:
response = self.client.post(
"/api/v1/keys/",
data=json.dumps({"name": "laptop", "public_key": "ssh-ed25519 AAAA"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["user_id"], self.user.id)
self.assertEqual(payload["name"], "laptop")
self.assertTrue(SSHKey.objects.filter(id=payload["id"]).exists())
mocked.assert_called_once()
def test_create_key_for_other_user_requires_admin_view_permission(self) -> None:
self._grant_global_perm(self.user, "add_sshkey")
self.client.force_login(self.user)
response = self.client.post(
"/api/v1/keys/",
data=json.dumps(
{"name": "other-user-key", "public_key": "ssh-ed25519 AAAA", "user_id": self.other_user.id}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
def test_create_key_for_other_user_with_admin_perms(self) -> None:
self._grant_global_perm(self.user, "add_sshkey")
self._grant_global_perm(self.user, "view_sshkey")
self.client.force_login(self.user)
with patch("keywarden.api.routers.keys.issue_certificate_for_key", return_value=self._dummy_cert()):
response = self.client.post(
"/api/v1/keys/",
data=json.dumps(
{"name": "other-user-key", "public_key": "ssh-ed25519 AAAA", "user_id": self.other_user.id}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["user_id"], self.other_user.id)
def test_create_key_unknown_user_returns_404(self) -> None:
self._grant_global_perm(self.user, "add_sshkey")
self._grant_global_perm(self.user, "view_sshkey")
self.client.force_login(self.user)
response = self.client.post(
"/api/v1/keys/",
data=json.dumps(
{"name": "other-user-key", "public_key": "ssh-ed25519 AAAA", "user_id": 999999}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
def test_create_key_validation_paths_raise_type_error(self) -> None:
self._grant_global_perm(self.user, "add_sshkey")
self.client.force_login(self.user)
with self.assertRaises(TypeError):
self.client.post(
"/api/v1/keys/",
data=json.dumps({"name": " ", "public_key": "ssh-ed25519 AAAA"}),
content_type="application/json",
)
with self.assertRaises(TypeError):
self.client.post(
"/api/v1/keys/",
data=json.dumps({"name": "badkey", "public_key": "not-a-key"}),
content_type="application/json",
)
def test_create_key_duplicate_fingerprint_raises_type_error(self) -> None:
self._grant_global_perm(self.user, "add_sshkey")
self.client.force_login(self.user)
self._create_key(owner=self.user, name="existing")
with self.assertRaises(TypeError):
self.client.post(
"/api/v1/keys/",
data=json.dumps({"name": "duplicate", "public_key": "ssh-ed25519 AAAA"}),
content_type="application/json",
)
def test_get_key_not_found_and_forbidden(self) -> None:
self.client.force_login(self.user)
missing = self.client.get("/api/v1/keys/999999")
self.assertEqual(missing.status_code, 404)
key = self._create_key(owner=self.other_user, name="forbidden")
forbidden = self.client.get(f"/api/v1/keys/{key.id}")
self.assertEqual(forbidden.status_code, 403)
def test_get_key_success(self) -> None:
key = self._create_key(name="visible-key")
assign_perm("view_sshkey", self.user, key)
self.client.force_login(self.user)
response = self.client.get(f"/api/v1/keys/{key.id}")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["id"], key.id)
def test_issue_certificate_requires_object_permission(self) -> None:
key = self._create_key(owner=self.other_user, name="cert-forbidden")
self.client.force_login(self.user)
forbidden = self.client.post(f"/api/v1/keys/{key.id}/certificate")
self.assertEqual(forbidden.status_code, 403)
missing = self.client.post("/api/v1/keys/999999/certificate")
self.assertEqual(missing.status_code, 404)
def test_issue_certificate_success(self) -> None:
key = self._create_key(name="cert-ok")
assign_perm("view_sshkey", self.user, key)
self.client.force_login(self.user)
cert = self._dummy_cert()
with patch("keywarden.api.routers.keys.issue_certificate_for_key", return_value=cert) as mocked:
response = self.client.post(f"/api/v1/keys/{key.id}/certificate")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["serial"], cert.serial)
mocked.assert_called_once_with(key, created_by=self.user)
def test_download_certificate_and_hash(self) -> None:
key = self._create_key(name="download")
assign_perm("view_sshkey", self.user, key)
self.client.force_login(self.user)
cert = self._dummy_cert()
with patch("keywarden.api.routers.keys._ensure_certificate", return_value=cert):
cert_response = self.client.get(f"/api/v1/keys/{key.id}/certificate")
hash_response = self.client.get(f"/api/v1/keys/{key.id}/certificate.sha256")
self.assertEqual(cert_response.status_code, 200)
self.assertIn("attachment;", cert_response["Content-Disposition"])
self.assertIn("AAAATEST", cert_response.content.decode("utf-8"))
self.assertEqual(hash_response.status_code, 200)
hash_payload = hash_response.content.decode("utf-8")
self.assertIn("keywarden-", hash_payload)
self.assertTrue(hash_payload.endswith(".pub\n"))
def test_download_certificate_requires_permission(self) -> None:
key = self._create_key(owner=self.other_user, name="download-forbidden")
self.client.force_login(self.user)
cert_response = self.client.get(f"/api/v1/keys/{key.id}/certificate")
hash_response = self.client.get(f"/api/v1/keys/{key.id}/certificate.sha256")
self.assertEqual(cert_response.status_code, 403)
self.assertEqual(hash_response.status_code, 403)
def test_update_key_paths(self) -> None:
key = self._create_key(owner=self.other_user, name="editable", key_b64="AAAE")
self.client.force_login(self.user)
forbidden = self.client.patch(
f"/api/v1/keys/{key.id}",
data=json.dumps({"name": "renamed"}),
content_type="application/json",
)
self.assertEqual(forbidden.status_code, 403)
self._grant_global_perm(self.user, "change_sshkey")
assign_perm("change_sshkey", self.user, key)
renamed = self.client.patch(
f"/api/v1/keys/{key.id}",
data=json.dumps({"name": "renamed"}),
content_type="application/json",
)
self.assertEqual(renamed.status_code, 200)
key.refresh_from_db()
self.assertEqual(key.name, "renamed")
with patch("keywarden.api.routers.keys.revoke_certificate_for_key") as mocked_revoke:
revoked = self.client.patch(
f"/api/v1/keys/{key.id}",
data=json.dumps({"is_active": False}),
content_type="application/json",
)
self.assertEqual(revoked.status_code, 200)
key.refresh_from_db()
self.assertFalse(key.is_active)
self.assertIsNotNone(key.revoked_at)
mocked_revoke.assert_called_once_with(key)
with patch("keywarden.api.routers.keys.issue_certificate_for_key", return_value=self._dummy_cert()) as mocked_issue:
reactivated = self.client.patch(
f"/api/v1/keys/{key.id}",
data=json.dumps({"is_active": True}),
content_type="application/json",
)
self.assertEqual(reactivated.status_code, 200)
key.refresh_from_db()
self.assertTrue(key.is_active)
self.assertIsNone(key.revoked_at)
mocked_issue.assert_called_once_with(key, created_by=self.user)
with self.assertRaises(TypeError):
self.client.patch(
f"/api/v1/keys/{key.id}",
data=json.dumps({}),
content_type="application/json",
)
not_found = self.client.patch(
"/api/v1/keys/999999",
data=json.dumps({"name": "x"}),
content_type="application/json",
)
self.assertEqual(not_found.status_code, 404)
def test_delete_key_paths(self) -> None:
key = self._create_key(owner=self.other_user, name="delete-me", key_b64="AAAF")
self.client.force_login(self.user)
forbidden = self.client.delete(f"/api/v1/keys/{key.id}")
self.assertEqual(forbidden.status_code, 403)
self._grant_global_perm(self.user, "delete_sshkey")
assign_perm("delete_sshkey", self.user, key)
with patch("keywarden.api.routers.keys.revoke_certificate_for_key") as mocked_revoke:
deleted = self.client.delete(f"/api/v1/keys/{key.id}")
self.assertEqual(deleted.status_code, 204)
key.refresh_from_db()
self.assertFalse(key.is_active)
self.assertIsNotNone(key.revoked_at)
mocked_revoke.assert_called_once_with(key)
second_delete = self.client.delete(f"/api/v1/keys/{key.id}")
self.assertEqual(second_delete.status_code, 204)
missing = self.client.delete("/api/v1/keys/999999")
self.assertEqual(missing.status_code, 404)