test coverage improvements; mTLS enforcement; agent broke :(
This commit is contained in:
1 parent
921b760f80
commit
31b726b439
43 files changed
+3343
-80
No files matched your search
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -39,5 +39,9 @@ python manage.py ensure_admin
|
||||
# Ensure application log directory exists and is writable by the Django runtime user.
|
||||
mkdir -p /var/log/keywarden
|
||||
chown -R djangouser:djangouser /var/log/keywarden || true
|
||||
CA_KEY_DIR="${KEYWARDEN_CA_KEY_DIR:-/var/lib/keywarden/ca-keys}"
|
||||
mkdir -p "$CA_KEY_DIR"
|
||||
chmod 700 "$CA_KEY_DIR" || true
|
||||
chown -R djangouser:djangouser "$CA_KEY_DIR" || true
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||
@@ -172,9 +172,9 @@ def build_router() -> Router:
|
||||
Auth: required.
|
||||
Permissions: requires `access.change_accessrequest` on the object.
|
||||
Rules:
|
||||
- Admin/operator (global change) can set status to approved/denied/revoked/cancelled and
|
||||
update expires_at.
|
||||
- Non-admin can only set status to cancelled, and only while pending.
|
||||
- Users with global change permission can set status to
|
||||
approved/denied/revoked/cancelled and update expires_at.
|
||||
- Non-global editors can only set status to cancelled, and only while pending.
|
||||
Side effects: updates object permissions for server visibility when
|
||||
approvals or revocations occur.
|
||||
Rationale: this is the core approval/denial path for access control.
|
||||
|
||||
@@ -35,7 +35,7 @@ from apps.servers.models import (
|
||||
hostname_validator,
|
||||
)
|
||||
from apps.telemetry.models import TelemetryEvent
|
||||
from keywarden.api.security import AgentPrincipal, AgentTokenAuth, hash_agent_token
|
||||
from keywarden.api.security import AgentMTLSAuth, AgentPrincipal, hash_agent_token
|
||||
|
||||
|
||||
class AuthorizedKeyOut(Schema):
|
||||
@@ -137,7 +137,7 @@ class AgentHeartbeatIn(Schema):
|
||||
|
||||
def build_router() -> Router:
|
||||
router = Router()
|
||||
agent_auth = AgentTokenAuth()
|
||||
agent_auth = AgentMTLSAuth()
|
||||
|
||||
@router.post("/enroll", response=AgentEnrollOut, auth=None)
|
||||
@csrf_exempt
|
||||
@@ -215,7 +215,7 @@ def build_router() -> Router:
|
||||
def authorized_keys(request: HttpRequest, server_id: int):
|
||||
"""Resolve the effective authorized_keys list for a server.
|
||||
|
||||
Auth: required (admin/operator via API).
|
||||
Auth: required (admin-level API permission).
|
||||
Permissions: requires view access to servers and keys.
|
||||
Behavior: uses access request scopes + active SSH keys to produce
|
||||
the exact key list the agent should deploy to the server.
|
||||
@@ -247,7 +247,7 @@ def build_router() -> Router:
|
||||
def account_access(request: HttpRequest, server_id: int):
|
||||
"""List accounts that should exist on a server.
|
||||
|
||||
Auth: per-agent bearer token issued at enrollment.
|
||||
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
|
||||
Behavior: resolves active users with approved shell/users scopes.
|
||||
Rationale: drives agent-side account provisioning.
|
||||
"""
|
||||
@@ -270,7 +270,7 @@ def build_router() -> Router:
|
||||
def ssh_ca(request: HttpRequest, server_id: int):
|
||||
"""Return the active SSH user CA public key for agents.
|
||||
|
||||
Auth: per-agent bearer token issued at enrollment.
|
||||
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
|
||||
"""
|
||||
_require_agent_access(request, server_id)
|
||||
_ = _get_server_or_404(server_id)
|
||||
@@ -284,7 +284,7 @@ def build_router() -> Router:
|
||||
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)):
|
||||
"""Record an agent sync report for a server.
|
||||
|
||||
Auth: per-agent bearer token issued at enrollment.
|
||||
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
|
||||
Behavior: stores a telemetry event with counts of applied/revoked keys.
|
||||
Rationale: provides an audit trail of enforcement actions without
|
||||
requiring full log ingestion for every sync cycle.
|
||||
@@ -315,7 +315,7 @@ def build_router() -> Router:
|
||||
def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn] = Body(...)):
|
||||
"""Accept log batches from agents for audit collection.
|
||||
|
||||
Auth: per-agent bearer token issued at enrollment.
|
||||
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
|
||||
Behavior: accepts structured log events for later storage and indexing.
|
||||
Storage: events are persisted in the primary datastore and indexed per
|
||||
server for filtering in the server audit view.
|
||||
@@ -359,7 +359,7 @@ def build_router() -> Router:
|
||||
def log_config(request: HttpRequest, server_id: int):
|
||||
"""Return server log-source configuration consumed by the agent.
|
||||
|
||||
Auth: per-agent bearer token issued at enrollment.
|
||||
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
|
||||
Behavior: returns enabled journal/service/file log sources for this server.
|
||||
Default: when no sources are defined, an empty list is returned and
|
||||
the agent falls back to current-boot journald collection.
|
||||
@@ -392,7 +392,7 @@ def build_router() -> Router:
|
||||
def heartbeat(request: HttpRequest, server_id: int, payload: AgentHeartbeatIn = Body(...)):
|
||||
"""Update server host metadata (hostname/IPs) reported by the agent.
|
||||
|
||||
Auth: per-agent bearer token issued at enrollment.
|
||||
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
|
||||
Behavior: updates hostname/IPv4/IPv6 when they change (e.g., DHCP).
|
||||
Conflict: unique constraints are enforced; conflicts return 409.
|
||||
Rationale: keeps the server inventory accurate without manual edits.
|
||||
@@ -449,9 +449,6 @@ def _require_agent_access(request: HttpRequest, server_id: int) -> None:
|
||||
principal = getattr(request, "auth", None)
|
||||
if not isinstance(principal, AgentPrincipal):
|
||||
raise HttpError(401, "Unauthorized")
|
||||
if principal.server_id is None:
|
||||
# Backward-compatibility path for legacy global token rollout.
|
||||
return
|
||||
if int(principal.server_id) != int(server_id):
|
||||
raise HttpError(403, "Forbidden")
|
||||
|
||||
@@ -551,7 +548,7 @@ def _load_agent_ca() -> tuple[x509.Certificate, object, str]:
|
||||
raise HttpError(500, "Agent CA not configured")
|
||||
try:
|
||||
ca_cert = x509.load_pem_x509_certificate(ca.cert_pem.encode("utf-8"))
|
||||
ca_key = serialization.load_pem_private_key(ca.key_pem.encode("utf-8"), password=None)
|
||||
ca_key = serialization.load_pem_private_key(ca.get_key_pem().encode("utf-8"), password=None)
|
||||
except (ValueError, TypeError):
|
||||
raise HttpError(500, "Invalid agent CA material")
|
||||
return ca_cert, ca_key, ca.cert_pem
|
||||
|
||||
@@ -15,7 +15,7 @@ from apps.core.rbac import ROLE_USER, get_user_role, require_perms, set_user_rol
|
||||
class UserCreateIn(Schema):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
role: Literal["administrator", "operator", "auditor", "user", "admin"]
|
||||
role: Literal["administrator", "user", "admin"]
|
||||
|
||||
|
||||
class UserListOut(Schema):
|
||||
@@ -34,7 +34,7 @@ class UserDetailOut(Schema):
|
||||
|
||||
class UserUpdateIn(Schema):
|
||||
password: str | None = Field(default=None, min_length=8)
|
||||
role: Literal["administrator", "operator", "auditor", "user", "admin"] | None = None
|
||||
role: Literal["administrator", "user", "admin"] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def build_router() -> Router:
|
||||
"""Create a platform user and assign a Keywarden role.
|
||||
|
||||
Auth: required.
|
||||
Permissions: requires `auth.add_user` (admin/operator).
|
||||
Permissions: requires `auth.add_user` (administrator).
|
||||
Behavior: uses email as username, hashes the password, and assigns a
|
||||
role which maps to Keywarden group permissions.
|
||||
Rationale: enables automation and external admin workflows; mirrors
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from secrets import compare_digest
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, ed448, padding, rsa
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
from django.http import HttpRequest
|
||||
@@ -12,7 +20,32 @@ from ninja.security import HttpBearer
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, AuthenticationFailed
|
||||
|
||||
from apps.servers.models import Server
|
||||
from apps.servers.models import AgentCertificateAuthority, Server
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_AGENT_AUTH_LOG_TTL_SECONDS = 60.0
|
||||
_agent_auth_log_cache: dict[str, float] = {}
|
||||
|
||||
|
||||
def _log_agent_auth_rejection(request: HttpRequest, reason: str, verify: str, cert_present: bool) -> None:
|
||||
path = str(getattr(request, "path", "") or "")
|
||||
key = f"{reason}|{path}"
|
||||
now = time.monotonic()
|
||||
previous = _agent_auth_log_cache.get(key)
|
||||
if previous is not None and (now - previous) < _AGENT_AUTH_LOG_TTL_SECONDS:
|
||||
return
|
||||
_agent_auth_log_cache[key] = now
|
||||
forwarded_for = str(request.META.get("HTTP_X_FORWARDED_FOR", "")).strip()
|
||||
remote_addr = str(request.META.get("REMOTE_ADDR", "")).strip()
|
||||
logger.warning(
|
||||
"agent auth rejected: reason=%s path=%s verify=%s cert_present=%s remote_addr=%s x_forwarded_for=%s",
|
||||
reason,
|
||||
path or "-",
|
||||
verify or "-",
|
||||
"yes" if cert_present else "no",
|
||||
remote_addr or "-",
|
||||
forwarded_for or "-",
|
||||
)
|
||||
|
||||
|
||||
class JWTAuth(HttpBearer):
|
||||
@@ -57,6 +90,86 @@ class AgentTokenAuth(HttpBearer):
|
||||
return None
|
||||
|
||||
|
||||
class AgentMTLSAuth:
|
||||
"""
|
||||
Auth via mTLS client certificate details forwarded by nginx.
|
||||
Requires:
|
||||
- X-Keywarden-TLS-Client-Verify: SUCCESS or FAILED:* (client cert presented)
|
||||
- X-Keywarden-TLS-Client-Cert: URL-escaped PEM certificate
|
||||
"""
|
||||
|
||||
verify_header = "HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY"
|
||||
cert_header = "HTTP_X_KEYWARDEN_TLS_CLIENT_CERT"
|
||||
fingerprint_header = "HTTP_X_KEYWARDEN_TLS_CLIENT_FINGERPRINT"
|
||||
|
||||
def __call__(self, request: HttpRequest) -> Optional["AgentPrincipal"]:
|
||||
return self.authenticate(request)
|
||||
|
||||
def authenticate(self, request: HttpRequest) -> Optional["AgentPrincipal"]:
|
||||
verify = str(request.META.get(self.verify_header, "")).strip().upper()
|
||||
# With nginx `ssl_verify_client optional_no_ca`, a presented client cert
|
||||
# can arrive as SUCCESS or FAILED:<reason> depending on local CA trust.
|
||||
# Treat NONE/missing as unauthenticated, then enforce trust in-app.
|
||||
if not verify or verify == "NONE":
|
||||
_log_agent_auth_rejection(request, "missing_or_none_verify", verify, cert_present=False)
|
||||
return None
|
||||
if not (verify == "SUCCESS" or verify.startswith("FAILED")):
|
||||
_log_agent_auth_rejection(request, "unexpected_verify_state", verify, cert_present=False)
|
||||
return None
|
||||
|
||||
cert_payload = str(request.META.get(self.cert_header, "")).strip()
|
||||
if not cert_payload:
|
||||
_log_agent_auth_rejection(request, "missing_client_cert_header", verify, cert_present=False)
|
||||
return None
|
||||
try:
|
||||
cert_pem = unquote(cert_payload)
|
||||
cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8"))
|
||||
except Exception:
|
||||
_log_agent_auth_rejection(request, "invalid_client_cert_payload", verify, cert_present=True)
|
||||
return None
|
||||
|
||||
now = datetime.now(dt_timezone.utc)
|
||||
if hasattr(cert, "not_valid_before_utc"):
|
||||
not_valid_before = cert.not_valid_before_utc
|
||||
not_valid_after = cert.not_valid_after_utc
|
||||
else:
|
||||
not_valid_before = cert.not_valid_before.replace(tzinfo=dt_timezone.utc)
|
||||
not_valid_after = cert.not_valid_after.replace(tzinfo=dt_timezone.utc)
|
||||
if not_valid_before > now or not_valid_after <= now:
|
||||
_log_agent_auth_rejection(request, "client_cert_outside_validity_window", verify, cert_present=True)
|
||||
return None
|
||||
|
||||
cert_fingerprint = cert.fingerprint(hashes.SHA256()).hex()
|
||||
cert_fingerprint_sha1 = cert.fingerprint(hashes.SHA1()).hex()
|
||||
header_fingerprint = _normalize_fingerprint(request.META.get(self.fingerprint_header, ""))
|
||||
if header_fingerprint and not (
|
||||
compare_digest(header_fingerprint, cert_fingerprint)
|
||||
or compare_digest(header_fingerprint, cert_fingerprint_sha1)
|
||||
):
|
||||
_log_agent_auth_rejection(request, "fingerprint_header_mismatch", verify, cert_present=True)
|
||||
return None
|
||||
|
||||
if not _signed_by_active_agent_ca(cert):
|
||||
_log_agent_auth_rejection(request, "client_cert_not_signed_by_active_agent_ca", verify, cert_present=True)
|
||||
return None
|
||||
|
||||
cert_serial = format(cert.serial_number, "x").lower().lstrip("0") or "0"
|
||||
server = (
|
||||
Server.objects.filter(agent_cert_fingerprint=cert_fingerprint)
|
||||
.only("id", "agent_cert_serial")
|
||||
.first()
|
||||
)
|
||||
if not server:
|
||||
_log_agent_auth_rejection(request, "no_server_for_cert_fingerprint", verify, cert_present=True)
|
||||
return None
|
||||
expected_serial = (server.agent_cert_serial or "").strip().lower().lstrip("0") or "0"
|
||||
if expected_serial and expected_serial != cert_serial:
|
||||
_log_agent_auth_rejection(request, "server_serial_mismatch", verify, cert_present=True)
|
||||
return None
|
||||
|
||||
return AgentPrincipal(server_id=server.id, mode="mtls")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentPrincipal:
|
||||
server_id: Optional[int]
|
||||
@@ -75,3 +188,48 @@ def hash_agent_token(value: str) -> str:
|
||||
if not normalized:
|
||||
return ""
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _normalize_fingerprint(value: object) -> str:
|
||||
return str(value or "").strip().lower().replace(":", "")
|
||||
|
||||
|
||||
def _signed_by_active_agent_ca(cert: x509.Certificate) -> bool:
|
||||
active_cas = AgentCertificateAuthority.objects.filter(
|
||||
is_active=True, revoked_at__isnull=True
|
||||
).only("cert_pem")
|
||||
for ca in active_cas:
|
||||
try:
|
||||
ca_cert = x509.load_pem_x509_certificate(ca.cert_pem.encode("utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
if cert.issuer != ca_cert.subject:
|
||||
continue
|
||||
if _verify_signed_by(cert, ca_cert):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _verify_signed_by(cert: x509.Certificate, ca_cert: x509.Certificate) -> bool:
|
||||
public_key = ca_cert.public_key()
|
||||
try:
|
||||
if isinstance(public_key, rsa.RSAPublicKey):
|
||||
public_key.verify(
|
||||
cert.signature,
|
||||
cert.tbs_certificate_bytes,
|
||||
padding.PKCS1v15(),
|
||||
cert.signature_hash_algorithm,
|
||||
)
|
||||
elif isinstance(public_key, ec.EllipticCurvePublicKey):
|
||||
public_key.verify(
|
||||
cert.signature,
|
||||
cert.tbs_certificate_bytes,
|
||||
ec.ECDSA(cert.signature_hash_algorithm),
|
||||
)
|
||||
elif isinstance(public_key, (ed25519.Ed25519PublicKey, ed448.Ed448PublicKey)):
|
||||
public_key.verify(cert.signature, cert.tbs_certificate_bytes)
|
||||
else:
|
||||
return False
|
||||
except (InvalidSignature, ValueError, TypeError):
|
||||
return False
|
||||
return True
|
||||
@@ -200,6 +200,10 @@ KEYWARDEN_SHELL_PRESERVE_TMP = os.getenv("KEYWARDEN_SHELL_PRESERVE_TMP", "0").st
|
||||
}
|
||||
KEYWARDEN_SHELL_DIAG_TIMEOUT_SECONDS = int(os.getenv("KEYWARDEN_SHELL_DIAG_TIMEOUT_SECONDS", "10"))
|
||||
KEYWARDEN_SHELL_REMOTE_COMMAND = os.getenv("KEYWARDEN_SHELL_REMOTE_COMMAND", "/bin/bash")
|
||||
KEYWARDEN_SHELL_STRICT_HOST_KEY_CHECKING = os.getenv(
|
||||
"KEYWARDEN_SHELL_STRICT_HOST_KEY_CHECKING", "accept-new"
|
||||
).strip().lower()
|
||||
KEYWARDEN_CA_KEY_DIR = os.getenv("KEYWARDEN_CA_KEY_DIR", "/var/lib/keywarden/ca-keys").strip()
|
||||
KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE = os.getenv(
|
||||
"KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE", "{{username}}_{{user_id}}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const outputDirArg = process.argv[2];
|
||||
if (!outputDirArg) {
|
||||
console.error("Usage: node capture_ui_screenshots.mjs <output-dir>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outputDir = path.resolve(outputDirArg);
|
||||
const baseUrl = (process.env.KEYWARDEN_SCREENSHOT_BASE_URL || "https://localhost").replace(/\/+$/, "");
|
||||
const username = process.env.KEYWARDEN_ADMIN_USERNAME || "admin";
|
||||
const adminEmail = process.env.KEYWARDEN_ADMIN_EMAIL || "";
|
||||
const password = process.env.KEYWARDEN_ADMIN_PASSWORD || "";
|
||||
const requestedServerId = process.env.KEYWARDEN_SCREENSHOT_SERVER_ID || "";
|
||||
|
||||
if (!password) {
|
||||
console.error("KEYWARDEN_ADMIN_PASSWORD is required for authenticated screenshots.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
viewport: { width: 1600, height: 900 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const shots = [];
|
||||
|
||||
async function screenshot(name, label, gotoUrl = null) {
|
||||
const fileName = `${name}.png`;
|
||||
const filePath = path.join(outputDir, fileName);
|
||||
const item = {
|
||||
name,
|
||||
label,
|
||||
requestedUrl: gotoUrl || page.url(),
|
||||
finalUrl: "",
|
||||
status: "ok",
|
||||
file: fileName,
|
||||
error: "",
|
||||
};
|
||||
try {
|
||||
if (gotoUrl) {
|
||||
await page.goto(gotoUrl, { waitUntil: "domcontentloaded", timeout: 45000 });
|
||||
}
|
||||
await page.waitForTimeout(700);
|
||||
await page.screenshot({ path: filePath, fullPage: true });
|
||||
item.finalUrl = page.url();
|
||||
} catch (err) {
|
||||
item.status = "error";
|
||||
item.error = String(err);
|
||||
item.finalUrl = page.url();
|
||||
}
|
||||
shots.push(item);
|
||||
}
|
||||
|
||||
async function ensureNativeLoginForm() {
|
||||
if (await page.locator('input[name="username"]').count()) {
|
||||
return;
|
||||
}
|
||||
const nativeLink = page.getByRole("link", { name: /Log in with Keywarden/i });
|
||||
if (await nativeLink.count()) {
|
||||
await nativeLink.first().click();
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
return;
|
||||
}
|
||||
await page.goto(`${baseUrl}/accounts/login/?native=1`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
||||
}
|
||||
|
||||
async function login() {
|
||||
await screenshot("01-login-entry", "Login entry", `${baseUrl}/accounts/login/`);
|
||||
const candidates = [...new Set([username, adminEmail].filter(Boolean))];
|
||||
if (!candidates.length) {
|
||||
throw new Error("No login identifier candidates available.");
|
||||
}
|
||||
|
||||
let loggedIn = false;
|
||||
for (const identifier of candidates) {
|
||||
await page.goto(`${baseUrl}/accounts/login/?native=1`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
||||
await ensureNativeLoginForm();
|
||||
await screenshot("02-login-native", "Native login form");
|
||||
|
||||
if (!(await page.locator('input[name="username"]').count())) {
|
||||
throw new Error("Could not find native login form.");
|
||||
}
|
||||
|
||||
await page.fill('input[name="username"]', identifier);
|
||||
await page.fill('input[name="password"]', password);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 45000 }).catch(() => null),
|
||||
page.getByRole("button", { name: /Sign in/i }).click(),
|
||||
]);
|
||||
|
||||
await page.goto(`${baseUrl}/servers/`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
||||
if (!page.url().includes("/accounts/login")) {
|
||||
loggedIn = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
throw new Error("Login failed for all configured identifiers.");
|
||||
}
|
||||
|
||||
await screenshot("03-dashboard", "Dashboard", `${baseUrl}/servers/`);
|
||||
}
|
||||
|
||||
async function detectServerId() {
|
||||
if (requestedServerId) {
|
||||
return requestedServerId;
|
||||
}
|
||||
const hrefs = await page.$$eval("a[href]", (nodes) => nodes.map((node) => node.getAttribute("href") || ""));
|
||||
for (const href of hrefs) {
|
||||
const match = href.match(/^\/servers\/(\d+)\/$/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
await login();
|
||||
await screenshot("04-profile", "Profile/key upload", `${baseUrl}/accounts/profile/`);
|
||||
await screenshot("05-server-dashboard", "Server dashboard", `${baseUrl}/servers/`);
|
||||
await screenshot("06-admin-dashboard", "Access grant/revocation queue", `${baseUrl}/servers/admin/`);
|
||||
await screenshot("07-admin-server-reg", "Server registration (Django admin)", `${baseUrl}/admin/servers/enrollmenttoken/`);
|
||||
|
||||
const serverId = await detectServerId();
|
||||
if (serverId) {
|
||||
await screenshot("08-server-detail", "Server detail/heartbeat", `${baseUrl}/servers/${serverId}/`);
|
||||
await screenshot("09-server-audit", "Server audit logs", `${baseUrl}/servers/${serverId}/audit/`);
|
||||
await screenshot("10-server-settings", "Server sync/log settings", `${baseUrl}/servers/${serverId}/settings/`);
|
||||
await screenshot("11-server-shell", "Browser shell interface", `${baseUrl}/servers/${serverId}/shell/`);
|
||||
await screenshot("12-server-admin", "Server access admin view", `${baseUrl}/servers/${serverId}/admin/`);
|
||||
} else {
|
||||
shots.push({
|
||||
name: "server-detection",
|
||||
label: "Server routes skipped",
|
||||
requestedUrl: `${baseUrl}/servers/`,
|
||||
finalUrl: page.url(),
|
||||
status: "error",
|
||||
file: "",
|
||||
error: "No server id detected from dashboard links.",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
baseUrl,
|
||||
username,
|
||||
shots,
|
||||
};
|
||||
await fs.writeFile(path.join(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
||||
|
||||
const mdLines = [
|
||||
"# Screenshot Index",
|
||||
"",
|
||||
`- Base URL: \`${baseUrl}\``,
|
||||
`- Generated: \`${manifest.generatedAtUtc}\``,
|
||||
"",
|
||||
"| File | Label | Status | URL |",
|
||||
"|---|---|---|---|",
|
||||
];
|
||||
for (const shot of shots) {
|
||||
const filePart = shot.file ? `\`${shot.file}\`` : "(none)";
|
||||
const urlPart = shot.finalUrl || shot.requestedUrl || "";
|
||||
const status = shot.status === "ok" ? "ok" : `error: ${shot.error}`;
|
||||
mdLines.push(`| ${filePart} | ${shot.label} | ${status} | \`${urlPart}\` |`);
|
||||
}
|
||||
mdLines.push("");
|
||||
await fs.writeFile(path.join(outputDir, "README.md"), mdLines.join("\n"));
|
||||
|
||||
console.log(`Captured ${shots.filter((shot) => shot.status === "ok").length}/${shots.length} screenshots to ${outputDir}`);
|
||||
@@ -1,2 +1,2 @@
|
||||
/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%;--tw-content:""}}}@layer theme{:root,:host{--font-sans:"Inter", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--animate-spin:Line truncated
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%;--tw-content:""}}}@layer theme{:root,:host{--font-sans:"Inter", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--animate-spin:Line truncated
|
||||
Reference in new issue
Block a user