before review

This commit is contained in:
boris committed 2026-04-28 02:08:11 +01:00
1 parent c6a830cb92
commit d9881bde01
40 files changed
+1560 -158

No files matched your search

Binary file not shown.
+6
View File
@@ -25,6 +25,8 @@ class AccessRequestAdmin(GuardedModelAdmin):
"request_shell", "request_shell",
"request_logs", "request_logs",
"request_users", "request_users",
"requested_duration_hours",
"requested_server_username",
"requested_at", "requested_at",
"expires_at", "expires_at",
"decided_by", "decided_by",
@@ -56,6 +58,8 @@ class AccessRequestAdmin(GuardedModelAdmin):
"request_shell", "request_shell",
"request_logs", "request_logs",
"request_users", "request_users",
"requested_duration_hours",
"requested_server_username",
"expires_at", "expires_at",
) )
}, },
@@ -73,6 +77,8 @@ class AccessRequestAdmin(GuardedModelAdmin):
"request_shell", "request_shell",
"request_logs", "request_logs",
"request_users", "request_users",
"requested_duration_hours",
"requested_server_username",
"expires_at", "expires_at",
) )
}, },
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("access", "0003_access_request_options"),
]
operations = [
migrations.AddField(
model_name="accessrequest",
name="requested_duration_hours",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="accessrequest",
name="requested_server_username",
field=models.CharField(blank=True, max_length=128),
),
]
+2
View File
@@ -31,6 +31,8 @@ class AccessRequest(models.Model):
request_shell = models.BooleanField(default=False) request_shell = models.BooleanField(default=False)
request_logs = models.BooleanField(default=False) request_logs = models.BooleanField(default=False)
request_users = models.BooleanField(default=False) request_users = models.BooleanField(default=False)
requested_duration_hours = models.PositiveIntegerField(null=True, blank=True)
requested_server_username = models.CharField(max_length=128, blank=True)
requested_at = models.DateTimeField(default=timezone.now, editable=False) requested_at = models.DateTimeField(default=timezone.now, editable=False)
decided_at = models.DateTimeField(null=True, blank=True) decided_at = models.DateTimeField(null=True, blank=True)
expires_at = models.DateTimeField(null=True, blank=True) expires_at = models.DateTimeField(null=True, blank=True)
@@ -458,11 +458,9 @@
<script> <script>
(function () { (function () {
function getCookie(name) { function getCsrfToken() {
var value = "; " + document.cookie; if (typeof window.keywardenGetCsrfToken === "function") {
var parts = value.split("; " + name + "="); return window.keywardenGetCsrfToken();
if (parts.length === 2) {
return parts.pop().split(";").shift();
} }
return ""; return "";
} }
@@ -485,7 +483,7 @@
if (!window.confirm("Regenerate the certificate for this key?")) { if (!window.confirm("Regenerate the certificate for this key?")) {
return; return;
} }
var csrf = getCookie("csrftoken"); var csrf = getCsrfToken();
fetch("/api/v1/keys/" + keyId + "/certificate", { fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST", method: "POST",
credentials: "same-origin", credentials: "same-origin",
+388 -103
View File
@@ -12,88 +12,327 @@
</div> </div>
<section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <a
href="{% url 'servers:admin_dashboard' %}?view=users"
class="rounded-2xl border p-5 shadow-sm transition hover:border-blue-300 hover:bg-blue-50 {% if active_view == 'users' %}border-blue-300 bg-blue-50{% else %}border-gray-200 bg-white{% endif %}"
>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Users</p> <p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Users</p>
<p class="mt-2 text-2xl font-semibold text-gray-900">{{ summary.users }}</p> <p class="mt-2 text-2xl font-semibold text-gray-900">{{ summary.users }}</p>
</article> </a>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <a
href="{% url 'servers:admin_dashboard' %}?view=servers"
class="rounded-2xl border p-5 shadow-sm transition hover:border-blue-300 hover:bg-blue-50 {% if active_view == 'servers' %}border-blue-300 bg-blue-50{% else %}border-gray-200 bg-white{% endif %}"
>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Servers</p> <p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Servers</p>
<p class="mt-2 text-2xl font-semibold text-gray-900">{{ summary.servers }}</p> <p class="mt-2 text-2xl font-semibold text-gray-900">{{ summary.servers }}</p>
</article> </a>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <a
href="{% url 'servers:admin_dashboard' %}?view=pending"
class="rounded-2xl border p-5 shadow-sm transition hover:border-amber-300 hover:bg-amber-50 {% if active_view == 'pending' %}border-amber-300 bg-amber-50{% else %}border-gray-200 bg-white{% endif %}"
>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Pending Requests</p> <p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Pending Requests</p>
<p class="mt-2 text-2xl font-semibold text-amber-700">{{ summary.pending_requests }}</p> <p class="mt-2 text-2xl font-semibold text-amber-700">{{ summary.pending_requests }}</p>
</article> </a>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <a
href="{% url 'servers:admin_dashboard' %}?view=grants"
class="rounded-2xl border p-5 shadow-sm transition hover:border-emerald-300 hover:bg-emerald-50 {% if active_view == 'grants' %}border-emerald-300 bg-emerald-50{% else %}border-gray-200 bg-white{% endif %}"
>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Active Grants</p> <p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Active Grants</p>
<p class="mt-2 text-2xl font-semibold text-emerald-700">{{ summary.active_grants }}</p> <p class="mt-2 text-2xl font-semibold text-emerald-700">{{ summary.active_grants }}</p>
</article> </a>
</section> </section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> {% if active_view == "users" %}
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4"> <section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div> <div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Access request queue</h2> <div>
<p class="mt-1 text-sm text-gray-500">Review and decide pending access requests.</p> <h2 class="text-lg font-semibold text-gray-900">Users</h2>
<p class="mt-1 text-sm text-gray-500">Manage users, review logins, and jump to user-filtered grants.</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-1 text-xs font-semibold text-blue-800">
{{ users|length }} users
</span>
</div> </div>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800"> {% if users %}
{{ pending_access_requests|length }} pending <div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
</span> <table class="min-w-full divide-y divide-gray-200 text-sm" data-sort-table>
</div> <thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
{% if pending_access_requests %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Requester</th>
<th class="px-3 py-2 font-semibold">Server</th>
<th class="px-3 py-2 font-semibold">Requested scopes</th>
<th class="px-3 py-2 font-semibold">Requested at</th>
<th class="px-3 py-2 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for req in pending_access_requests %}
<tr> <tr>
<td class="px-3 py-2">{{ req.requester.username }}</td> <th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">User <span class="ml-1" data-sort-indicator></span></th>
<td class="px-3 py-2"> <th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="date">Last login <span class="ml-1" data-sort-indicator></span></th>
<a href="{% url 'servers:server_admin' req.server.id %}" class="font-medium text-blue-700 hover:underline"> <th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="number">Grants applied <span class="ml-1" data-sort-indicator></span></th>
{{ req.server.display_name }} <th class="px-3 py-2 font-semibold text-right">Actions</th>
</a>
</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
{% if req.request_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if req.request_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if req.request_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
</div>
</td>
<td class="px-3 py-2">{{ req.requested_at|date:"M j, Y H:i" }}</td>
<td class="px-3 py-2 text-right">
<div class="inline-flex gap-2">
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Approve</button>
</form>
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="deny">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Deny</button>
</form>
</div>
</td>
</tr> </tr>
{% endfor %} </thead>
</tbody> <tbody class="divide-y divide-gray-100 text-gray-700">
</table> {% for row in users %}
<tr class="{% if row.has_pending_erasure_request %}bg-amber-50{% endif %}">
<td class="px-3 py-2" data-sort-value="{{ row.user.username|lower }}">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ row.user.username }}</span>
{% if row.has_pending_erasure_request %}
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-800">GDPR request</span>
{% endif %}
</div>
</td>
<td class="px-3 py-2" data-sort-value="{% if row.user.last_login %}{{ row.user.last_login|date:'YmdHis' }}{% else %}0{% endif %}">
{% if row.user.last_login %}
{{ row.user.last_login|date:"M j, Y H:i" }}
{% else %}
<span class="text-gray-500">Never</span>
{% endif %}
</td>
<td class="px-3 py-2" data-sort-value="{{ row.active_grants_count }}">
<a
href="{% url 'servers:admin_dashboard' %}?view=grants&grant_user={{ row.user.id }}"
class="font-medium text-blue-700 hover:underline"
title="{{ row.active_grants_tooltip }}"
>
{{ row.active_grants_count }} active
</a>
</td>
<td class="px-3 py-2 text-right">
<div class="inline-flex flex-wrap justify-end gap-2">
<form method="post" action="{% url 'servers:admin_send_password_reset' row.user.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">Send reset email</button>
</form>
{% if row.user.id != request.user.id %}
<form method="post" action="{% url 'servers:admin_delete_user' row.user.id %}" onsubmit="return window.confirm('Delete user {{ row.user.username|escapejs }}? This cannot be undone.');">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button
type="submit"
class="inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-semibold text-white {% if row.has_pending_erasure_request %}bg-amber-600 hover:bg-amber-700{% else %}bg-rose-600 hover:bg-rose-700{% endif %}"
>
Delete
</button>
</form>
{% else %}
<span class="inline-flex items-center rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-500">Current user</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No users found.</p>
{% endif %}
</section>
{% elif active_view == "servers" %}
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Servers</h2>
<p class="mt-1 text-sm text-gray-500">Manage registered servers and remove server records.</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-1 text-xs font-semibold text-blue-800">
{{ servers|length }} servers
</span>
</div> </div>
{% else %} {% if servers %}
<p class="mt-4 text-sm text-gray-500">No pending access requests.</p> <div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
{% endif %} <table class="min-w-full divide-y divide-gray-200 text-sm" data-sort-table>
</section> <thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">Server <span class="ml-1" data-sort-indicator></span></th>
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">Hostname / IP <span class="ml-1" data-sort-indicator></span></th>
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="date">Agent enrolled <span class="ml-1" data-sort-indicator></span></th>
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="date">Last heartbeat <span class="ml-1" data-sort-indicator></span></th>
<th class="px-3 py-2 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for server in servers %}
<tr>
<td class="px-3 py-2" data-sort-value="{{ server.display_name|lower }}">
<a href="{% url 'servers:server_admin' server.id %}" class="font-medium text-blue-700 hover:underline">{{ server.display_name }}</a>
</td>
<td class="px-3 py-2" data-sort-value="{{ server.hostname|default:server.ipv4|default:server.ipv6|default:'unassigned'|lower }}">{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}</td>
<td class="px-3 py-2" data-sort-value="{% if server.agent_enrolled_at %}{{ server.agent_enrolled_at|date:'YmdHis' }}{% else %}0{% endif %}">
{% if server.agent_enrolled_at %}
{{ server.agent_enrolled_at|date:"M j, Y H:i" }}
{% else %}
<span class="text-gray-500">Not enrolled</span>
{% endif %}
</td>
<td class="px-3 py-2" data-sort-value="{% if server.last_heartbeat_at %}{{ server.last_heartbeat_at|date:'YmdHis' }}{% else %}0{% endif %}">
{% if server.last_heartbeat_at %}
{{ server.last_heartbeat_at|date:"M j, Y H:i" }}
{% else %}
<span class="text-gray-500">Never</span>
{% endif %}
</td>
<td class="px-3 py-2 text-right">
<form method="post" action="{% url 'servers:admin_delete_server' server.id %}" onsubmit="return window.confirm('Delete server {{ server.display_name|escapejs }} and related records?');" class="inline-flex">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No servers available.</p>
{% endif %}
</section>
{% elif active_view == "grants" %}
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Active grants</h2>
<p class="mt-1 text-sm text-gray-500">Review active grants, revoke access, and update expiry.</p>
</div>
<form method="get" action="{% url 'servers:admin_dashboard' %}" class="flex items-center gap-2">
<input type="hidden" name="view" value="grants">
<label for="grant-user-filter" class="text-xs font-semibold uppercase tracking-wide text-gray-500">Filter by user</label>
<select id="grant-user-filter" name="grant_user" class="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700">
<option value="">All users</option>
{% for user in grant_filter_users %}
<option value="{{ user.id }}" {% if grant_user_filter_id == user.id %}selected{% endif %}>{{ user.username }}</option>
{% endfor %}
</select>
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">Apply</button>
</form>
</div>
{% if selected_grant_user %}
<p class="mt-3 text-xs text-gray-500">Filtered for user: <span class="font-semibold text-gray-700">{{ selected_grant_user.username }}</span></p>
{% endif %}
{% if grant_rows %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm" data-sort-table>
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">User <span class="ml-1" data-sort-indicator></span></th>
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">Server <span class="ml-1" data-sort-indicator></span></th>
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="date">Last use <span class="ml-1" data-sort-indicator></span></th>
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="date">Expiry <span class="ml-1" data-sort-indicator></span></th>
<th class="px-3 py-2 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for row in grant_rows %}
<tr class="{% if row.expires_soon %}bg-amber-50{% endif %}">
<td class="px-3 py-2" data-sort-value="{{ row.grant.requester.username|lower }}">{{ row.grant.requester.username }}</td>
<td class="px-3 py-2" data-sort-value="{{ row.grant.server.display_name|lower }}">{{ row.grant.server.display_name }}</td>
<td class="px-3 py-2" data-sort-value="{% if row.last_used_at %}{{ row.last_used_at|date:'YmdHis' }}{% else %}0{% endif %}">
{% if row.last_used_at %}
{{ row.last_used_at|date:"M j, Y H:i" }}
{% else %}
<span class="text-gray-500">No activity</span>
{% endif %}
</td>
<td class="px-3 py-2" data-sort-value="{% if row.grant.expires_at %}{{ row.grant.expires_at|date:'YmdHis' }}{% else %}99999999999999{% endif %}">
{% if row.grant.expires_at %}
{{ row.grant.expires_at|date:"M j, Y H:i" }}
{% else %}
<span class="text-gray-500">No expiry</span>
{% endif %}
</td>
<td class="px-3 py-2 text-right">
<div class="inline-flex flex-wrap items-center justify-end gap-2">
<form method="post" action="{% url 'servers:admin_revoke_grant' row.grant.id %}" onsubmit="return window.confirm('Revoke grant for {{ row.grant.requester.username|escapejs }} on {{ row.grant.server.display_name|escapejs }}?');">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Revoke</button>
</form>
<form method="post" action="{% url 'servers:admin_change_grant_expiry' row.grant.id %}" class="inline-flex items-center gap-2">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input
type="datetime-local"
name="expires_at"
value="{{ row.expires_at_input }}"
class="rounded-lg border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700"
>
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">Change expiry</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No active grants available for this filter.</p>
{% endif %}
</section>
{% else %}
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Access request queue</h2>
<p class="mt-1 text-sm text-gray-500">Review and decide pending access requests.</p>
</div>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
{{ pending_access_requests|length }} pending
</span>
</div>
{% if pending_access_requests %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm" data-sort-table>
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">Requester <span class="ml-1" data-sort-indicator></span></th>
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">Server <span class="ml-1" data-sort-indicator></span></th>
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="number">Requested scopes <span class="ml-1" data-sort-indicator></span></th>
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="date">Requested at <span class="ml-1" data-sort-indicator></span></th>
<th class="px-3 py-2 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for row in pending_access_requests %}
<tr>
<td class="px-3 py-2" data-sort-value="{{ row.request.requester.username|lower }}">{{ row.request.requester.username }}</td>
<td class="px-3 py-2" data-sort-value="{{ row.request.server.display_name|lower }}">
<a href="{% url 'servers:server_admin' row.request.server.id %}" class="font-medium text-blue-700 hover:underline">
{{ row.request.server.display_name }}
</a>
</td>
<td class="px-3 py-2" data-sort-value="{{ row.scope_count }}">
<div class="flex flex-wrap gap-1">
{% if row.request.request_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if row.request.request_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if row.request.request_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
{% if row.request.requested_duration_hours %}<span class="inline-flex rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700">{{ row.request.requested_duration_hours }}h</span>{% endif %}
{% if row.request.requested_server_username %}<span class="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-semibold text-gray-700">@{{ row.request.requested_server_username }}</span>{% endif %}
</div>
</td>
<td class="px-3 py-2" data-sort-value="{{ row.request.requested_at|date:'YmdHis' }}">{{ row.request.requested_at|date:"M j, Y H:i" }}</td>
<td class="px-3 py-2 text-right">
<div class="inline-flex gap-2">
<form method="post" action="{% url 'servers:decide_access_request' row.request.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Approve</button>
</form>
<form method="post" action="{% url 'servers:decide_access_request' row.request.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="deny">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Deny</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No pending access requests.</p>
{% endif %}
</section>
{% endif %}
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="border-b border-gray-100 pb-4"> <div class="border-b border-gray-100 pb-4">
@@ -109,40 +348,86 @@
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Server admin pages</h2>
<p class="mt-1 text-sm text-gray-500">Open per-server administrative controls and user/account views.</p>
</div>
{% if servers %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Server</th>
<th class="px-3 py-2 font-semibold">Hostname / IP</th>
<th class="px-3 py-2 font-semibold text-right">Admin</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for server in servers %}
<tr>
<td class="px-3 py-2">{{ server.display_name }}</td>
<td class="px-3 py-2">{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}</td>
<td class="px-3 py-2 text-right">
<a href="{% url 'servers:server_admin' server.id %}" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
Open admin
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No servers available.</p>
{% endif %}
</section>
</div> </div>
<script>
(function () {
var tables = document.querySelectorAll("[data-sort-table]");
function getCellValue(row, index) {
var cell = row.children[index];
if (!cell) {
return "";
}
var explicit = cell.getAttribute("data-sort-value");
if (explicit !== null) {
return explicit;
}
return (cell.textContent || "").trim();
}
function parseValue(value, type) {
if (type === "number") {
var n = Number(value);
return Number.isNaN(n) ? 0 : n;
}
if (type === "date") {
var d = Number(value);
return Number.isNaN(d) ? 0 : d;
}
return String(value || "").toLowerCase();
}
tables.forEach(function (table) {
var body = table.querySelector("tbody");
if (!body) {
return;
}
var headers = Array.prototype.slice.call(table.querySelectorAll("th[data-sortable='true']"));
headers.forEach(function (header) {
header.classList.add("cursor-pointer", "select-none");
header.dataset.sortDirection = "none";
header.addEventListener("click", function () {
var allHeaderCells = Array.prototype.slice.call(header.parentElement.children);
var columnIndex = allHeaderCells.indexOf(header);
if (columnIndex < 0) {
return;
}
var direction = header.dataset.sortDirection === "asc" ? "desc" : "asc";
headers.forEach(function (th) {
if (th !== header) {
th.dataset.sortDirection = "none";
var indicator = th.querySelector("[data-sort-indicator]");
if (indicator) {
indicator.textContent = "";
}
}
});
header.dataset.sortDirection = direction;
var headerIndicator = header.querySelector("[data-sort-indicator]");
if (headerIndicator) {
headerIndicator.textContent = direction === "asc" ? "▲" : "▼";
}
var rows = Array.prototype.slice.call(body.querySelectorAll("tr"));
var sortType = header.getAttribute("data-sort-type") || "text";
rows.sort(function (a, b) {
var aVal = parseValue(getCellValue(a, columnIndex), sortType);
var bVal = parseValue(getCellValue(b, columnIndex), sortType);
if (aVal < bVal) {
return direction === "asc" ? -1 : 1;
}
if (aVal > bVal) {
return direction === "asc" ? 1 : -1;
}
return 0;
});
rows.forEach(function (row) {
body.appendChild(row);
});
});
});
});
})();
</script>
{% endblock %} {% endblock %}
@@ -138,14 +138,18 @@
Request pending since {{ item.access.pending.requested_at|date:"M j, Y H:i" }} Request pending since {{ item.access.pending.requested_at|date:"M j, Y H:i" }}
</span> </span>
{% else %} {% else %}
<form method="post" action="{% url 'servers:request_access' item.server.id %}"> <button
{% csrf_token %} type="button"
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800"> class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800"
Request access data-open-request-modal
</button> data-server-id="{{ item.server.id }}"
</form> data-server-name="{{ item.server.display_name|default:item.server.hostname|default:item.server.ipv4|default:item.server.ipv6|default:'Server'|escape }}"
data-request-url="{% url 'servers:request_access' item.server.id %}"
>
Request access
</button>
{% endif %} {% endif %}
<p class="mt-2 text-xs text-gray-500">Request includes shell, logs, and users scopes.</p> <p class="mt-2 text-xs text-gray-500">Choose scopes, duration, and preferred server username in the request popup.</p>
</div> </div>
{% endif %} {% endif %}
</article> </article>
@@ -164,4 +168,144 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div id="access-request-modal" class="fixed inset-0 z-50 hidden items-center justify-center px-4 py-6" aria-hidden="true">
<div class="absolute inset-0 bg-gray-900/50" data-request-modal-close></div>
<div class="relative z-10 w-full max-w-xl rounded-2xl border border-gray-200 bg-white p-5 shadow-xl sm:p-6">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Request Scoped Access</h2>
<p class="mt-1 text-sm text-gray-500">
Server:
<span id="request-modal-server-name" class="font-semibold text-gray-700">-</span>
</p>
</div>
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-gray-200 text-gray-500 hover:bg-gray-50"
data-request-modal-close
aria-label="Close access request"
>
&times;
</button>
</div>
<form id="access-request-form" method="post" class="mt-5 space-y-5">
{% csrf_token %}
<input type="hidden" name="scoped_request" value="1">
<fieldset class="space-y-2">
<legend class="text-xs font-semibold uppercase tracking-wide text-gray-500">Requested scopes</legend>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="request_shell" value="1" class="h-4 w-4 rounded border-gray-300 text-blue-700" checked>
Shell access
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="request_logs" value="1" class="h-4 w-4 rounded border-gray-300 text-blue-700" checked>
Audit log access
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="request_users" value="1" class="h-4 w-4 rounded border-gray-300 text-blue-700" checked>
Server users/details access
</label>
</fieldset>
<div>
<label for="requested-duration-hours" class="block text-xs font-semibold uppercase tracking-wide text-gray-500">Requested duration</label>
<select id="requested-duration-hours" name="requested_duration_hours" class="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700">
<option value="">No preferred expiry</option>
<option value="1">1 hour</option>
<option value="4">4 hours</option>
<option value="8" selected>8 hours</option>
<option value="24">24 hours</option>
<option value="72">3 days</option>
<option value="168">7 days</option>
</select>
</div>
<div>
<label for="requested-server-username" class="block text-xs font-semibold uppercase tracking-wide text-gray-500">Preferred server username</label>
<input
id="requested-server-username"
name="requested_server_username"
type="text"
maxlength="128"
value="{{ request.user.username }}"
placeholder="e.g. ubuntu"
class="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700"
>
</div>
<div>
<label for="request-reason" class="block text-xs font-semibold uppercase tracking-wide text-gray-500">Reason (optional)</label>
<textarea
id="request-reason"
name="reason"
rows="3"
placeholder="Why you need this access."
class="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700"
></textarea>
</div>
<div class="flex items-center justify-end gap-2 border-t border-gray-100 pt-4">
<button type="button" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50" data-request-modal-close>
Cancel
</button>
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800">
Submit request
</button>
</div>
</form>
</div>
</div>
<script>
(function () {
var modal = document.getElementById("access-request-modal");
var modalServerName = document.getElementById("request-modal-server-name");
var form = document.getElementById("access-request-form");
var openButtons = document.querySelectorAll("[data-open-request-modal]");
var closeButtons = document.querySelectorAll("[data-request-modal-close]");
function closeModal() {
modal.classList.add("hidden");
modal.classList.remove("flex");
modal.setAttribute("aria-hidden", "true");
}
function openModal(button) {
var requestUrl = button.getAttribute("data-request-url");
var serverName = button.getAttribute("data-server-name") || "Server";
form.setAttribute("action", requestUrl);
modalServerName.textContent = serverName;
modal.classList.remove("hidden");
modal.classList.add("flex");
modal.setAttribute("aria-hidden", "false");
}
openButtons.forEach(function (button) {
button.addEventListener("click", function () {
openModal(button);
});
});
closeButtons.forEach(function (button) {
button.addEventListener("click", closeModal);
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && modal.classList.contains("flex")) {
closeModal();
}
});
form.addEventListener("submit", function (event) {
var checked = form.querySelectorAll("input[name='request_shell']:checked, input[name='request_logs']:checked, input[name='request_users']:checked");
if (!checked.length) {
event.preventDefault();
window.alert("Select at least one scope.");
}
});
})();
</script>
{% endblock %} {% endblock %}
@@ -129,11 +129,9 @@
</div> </div>
<script> <script>
(function () { (function () {
function getCookie(name) { function getCsrfToken() {
var value = "; " + document.cookie; if (typeof window.keywardenGetCsrfToken === "function") {
var parts = value.split("; " + name + "="); return window.keywardenGetCsrfToken();
if (parts.length === 2) {
return parts.pop().split(";").shift();
} }
return ""; return "";
} }
@@ -156,7 +154,7 @@
if (!window.confirm("Regenerate the certificate for this key?")) { if (!window.confirm("Regenerate the certificate for this key?")) {
return; return;
} }
var csrf = getCookie("csrftoken"); var csrf = getCsrfToken();
fetch("/api/v1/keys/" + keyId + "/certificate", { fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST", method: "POST",
credentials: "same-origin", credentials: "same-origin",
@@ -68,6 +68,8 @@
{% if req.request_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %} {% if req.request_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if req.request_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %} {% if req.request_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if req.request_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %} {% if req.request_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
{% if req.requested_duration_hours %}<span class="inline-flex rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700">{{ req.requested_duration_hours }}h</span>{% endif %}
{% if req.requested_server_username %}<span class="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-semibold text-gray-700">@{{ req.requested_server_username }}</span>{% endif %}
</div> </div>
</td> </td>
<td class="px-3 py-2">{{ req.requested_at|date:"M j, Y H:i" }}</td> <td class="px-3 py-2">{{ req.requested_at|date:"M j, Y H:i" }}</td>
@@ -148,11 +148,9 @@
<script src="{% static 'vendor/xterm/xterm.js' %}"></script> <script src="{% static 'vendor/xterm/xterm.js' %}"></script>
<script> <script>
(function () { (function () {
function getCookie(name) { function getCsrfToken() {
var value = "; " + document.cookie; if (typeof window.keywardenGetCsrfToken === "function") {
var parts = value.split("; " + name + "="); return window.keywardenGetCsrfToken();
if (parts.length === 2) {
return parts.pop().split(";").shift();
} }
return ""; return "";
} }
@@ -175,7 +173,7 @@
if (!window.confirm("Regenerate the certificate for this key?")) { if (!window.confirm("Regenerate the certificate for this key?")) {
return; return;
} }
var csrf = getCookie("csrftoken"); var csrf = getCsrfToken();
fetch("/api/v1/keys/" + keyId + "/certificate", { fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST", method: "POST",
credentials: "same-origin", credentials: "same-origin",
@@ -1,15 +1,18 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from types import SimpleNamespace from types import SimpleNamespace
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from ninja.errors import HttpError from ninja.errors import HttpError
from apps.access.models import AccessRequest from apps.access.models import AccessRequest
from apps.accounts.models import ErasureRequest
from apps.core import rbac from apps.core import rbac
from apps.servers.models import Server, ServerAccount 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 from apps.servers.permissions import user_can_logs, user_can_shell, user_can_users, user_has_any_access
@@ -109,6 +112,32 @@ class ServerAccessScopeTests(TestCase):
).exists() ).exists()
) )
def test_dashboard_request_access_scoped_options_are_saved(self) -> None:
self._grant_perm(self.user, "access", "add_accessrequest")
self.client.force_login(self.user)
response = self.client.post(
reverse("servers:request_access", args=[self.server.id]),
{
"scoped_request": "1",
"request_shell": "1",
"request_logs": "1",
"requested_duration_hours": "24",
"requested_server_username": "ubuntu",
"reason": "Investigate auth failure",
},
)
self.assertEqual(response.status_code, 302)
access_request = AccessRequest.objects.get(requester=self.user, server=self.server)
self.assertEqual(access_request.status, AccessRequest.Status.PENDING)
self.assertTrue(access_request.request_shell)
self.assertTrue(access_request.request_logs)
self.assertFalse(access_request.request_users)
self.assertEqual(access_request.requested_duration_hours, 24)
self.assertEqual(access_request.requested_server_username, "ubuntu")
self.assertEqual(access_request.reason, "Investigate auth failure")
def test_admin_can_approve_pending_request(self) -> None: def test_admin_can_approve_pending_request(self) -> None:
pending = AccessRequest.objects.create( pending = AccessRequest.objects.create(
requester=self.user, requester=self.user,
@@ -130,6 +159,28 @@ class ServerAccessScopeTests(TestCase):
self.assertEqual(pending.status, AccessRequest.Status.APPROVED) self.assertEqual(pending.status, AccessRequest.Status.APPROVED)
self.assertEqual(pending.decided_by_id, self.admin.id) self.assertEqual(pending.decided_by_id, self.admin.id)
def test_admin_approve_uses_requested_duration_for_expiry(self) -> None:
pending = AccessRequest.objects.create(
requester=self.user,
server=self.server,
status=AccessRequest.Status.PENDING,
request_shell=True,
requested_duration_hours=4,
)
self.client.force_login(self.admin)
decision_started_at = timezone.now()
response = self.client.post(
reverse("servers:decide_access_request", args=[pending.id]),
{"action": "approve"},
)
self.assertEqual(response.status_code, 302)
pending.refresh_from_db()
self.assertEqual(pending.status, AccessRequest.Status.APPROVED)
self.assertIsNotNone(pending.expires_at)
self.assertGreaterEqual(pending.expires_at, decision_started_at + timedelta(hours=3, minutes=59))
def test_admin_dashboard_requires_admin(self) -> None: def test_admin_dashboard_requires_admin(self) -> None:
self.client.force_login(self.user) self.client.force_login(self.user)
response = self.client.get(reverse("servers:admin_dashboard")) response = self.client.get(reverse("servers:admin_dashboard"))
@@ -149,6 +200,104 @@ class ServerAccessScopeTests(TestCase):
self.assertContains(response, pending.requester.username) self.assertContains(response, pending.requester.username)
self.assertContains(response, "Access request queue") self.assertContains(response, "Access request queue")
def test_admin_dashboard_users_view_shows_gdpr_marker(self) -> None:
ErasureRequest.objects.create(
user=self.user,
reason="Please erase my profile data completely.",
status=ErasureRequest.Status.PENDING,
)
self.client.force_login(self.admin)
response = self.client.get(reverse("servers:admin_dashboard"), {"view": "users"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Send reset email")
self.assertContains(response, "GDPR request")
def test_admin_delete_user_from_dashboard(self) -> None:
User = get_user_model()
victim = User.objects.create_user(
username="victim",
email="victim@example.com",
password="pass12345",
)
self.client.force_login(self.admin)
response = self.client.post(
reverse("servers:admin_delete_user", args=[victim.id]),
{"next": reverse("servers:admin_dashboard")},
)
self.assertEqual(response.status_code, 302)
self.assertFalse(User.objects.filter(id=victim.id).exists())
def test_admin_send_password_reset_from_dashboard(self) -> None:
self.client.force_login(self.admin)
response = self.client.post(
reverse("servers:admin_send_password_reset", args=[self.user.id]),
{"next": reverse("servers:admin_dashboard")},
)
self.assertEqual(response.status_code, 302)
def test_admin_delete_server_from_dashboard(self) -> None:
server = Server.objects.create(display_name="delete-me", hostname="delete-me.example.test")
self.client.force_login(self.admin)
response = self.client.post(
reverse("servers:admin_delete_server", args=[server.id]),
{"next": reverse("servers:admin_dashboard")},
)
self.assertEqual(response.status_code, 302)
self.assertFalse(Server.objects.filter(id=server.id).exists())
def test_admin_revoke_grant_from_dashboard(self) -> None:
grant = AccessRequest.objects.create(
requester=self.user,
server=self.server,
status=AccessRequest.Status.APPROVED,
request_shell=True,
expires_at=timezone.now() + timedelta(days=3),
)
self.client.force_login(self.admin)
response = self.client.post(
reverse("servers:admin_revoke_grant", args=[grant.id]),
{"next": reverse("servers:admin_dashboard")},
)
self.assertEqual(response.status_code, 302)
grant.refresh_from_db()
self.assertEqual(grant.status, AccessRequest.Status.REVOKED)
self.assertEqual(grant.decided_by_id, self.admin.id)
self.assertIsNotNone(grant.expires_at)
def test_admin_change_grant_expiry_from_dashboard(self) -> None:
grant = AccessRequest.objects.create(
requester=self.user,
server=self.server,
status=AccessRequest.Status.APPROVED,
request_shell=True,
expires_at=timezone.now() + timedelta(days=1),
)
new_expiry = timezone.now() + timedelta(days=9)
self.client.force_login(self.admin)
response = self.client.post(
reverse("servers:admin_change_grant_expiry", args=[grant.id]),
{
"expires_at": timezone.localtime(new_expiry).strftime("%Y-%m-%dT%H:%M"),
"next": reverse("servers:admin_dashboard"),
},
)
self.assertEqual(response.status_code, 302)
grant.refresh_from_db()
self.assertIsNotNone(grant.expires_at)
self.assertGreater(grant.expires_at, timezone.now() + timedelta(days=8))
def test_server_admin_requires_admin(self) -> None: def test_server_admin_requires_admin(self) -> None:
assign_perm("view_server", self.user, self.server) assign_perm("view_server", self.user, self.server)
self.client.force_login(self.user) self.client.force_login(self.user)
+5
View File
@@ -7,6 +7,11 @@ app_name = "servers"
urlpatterns = [ urlpatterns = [
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
path("admin/", views.admin_dashboard, name="admin_dashboard"), path("admin/", views.admin_dashboard, name="admin_dashboard"),
path("admin/users/<int:user_id>/password-reset/", views.admin_send_password_reset, name="admin_send_password_reset"),
path("admin/users/<int:user_id>/delete/", views.admin_delete_user, name="admin_delete_user"),
path("admin/servers/<int:server_id>/delete/", views.admin_delete_server, name="admin_delete_server"),
path("admin/grants/<int:grant_id>/revoke/", views.admin_revoke_grant, name="admin_revoke_grant"),
path("admin/grants/<int:grant_id>/expiry/", views.admin_change_grant_expiry, name="admin_change_grant_expiry"),
path("<int:server_id>/request-access/", views.request_access, name="request_access"), path("<int:server_id>/request-access/", views.request_access, name="request_access"),
path( path(
"access-requests/<int:request_id>/decision/", "access-requests/<int:request_id>/decision/",
+250 -15
View File
@@ -20,6 +20,8 @@ from guardian.shortcuts import get_objects_for_user, get_users_with_perms
from apps.access.models import AccessRequest from apps.access.models import AccessRequest
from apps.access.permissions import sync_server_view_perm from apps.access.permissions import sync_server_view_perm
from apps.accounts.forms import NativePasswordResetForm
from apps.accounts.models import ErasureRequest
from apps.keys.utils import render_system_username from apps.keys.utils import render_system_username
from apps.keys.models import SSHKey from apps.keys.models import SSHKey
from apps.servers.models import Server, ServerAccount, ServerAuditLog from apps.servers.models import Server, ServerAccount, ServerAuditLog
@@ -31,6 +33,7 @@ AUDIT_SUBPAGE_OPTIONS = {"charts", "logs"}
SSH_LOGIN_EVENT_TYPES = ("ssh.login.success", "ssh.login.fail") SSH_LOGIN_EVENT_TYPES = ("ssh.login.success", "ssh.login.fail")
HTTP_STATUS_PATTERN = re.compile(r'"\s*(\d{3})\s+\S+') HTTP_STATUS_PATTERN = re.compile(r'"\s*(\d{3})\s+\S+')
LOGIN_OUTCOME_OPTIONS = {"attempts", "success", "failed"} LOGIN_OUTCOME_OPTIONS = {"attempts", "success", "failed"}
REQUEST_DURATION_HOURS_OPTIONS = (1, 4, 8, 24, 72, 168)
def _is_admin_user(user) -> bool: def _is_admin_user(user) -> bool:
@@ -168,20 +171,122 @@ def dashboard(request):
def admin_dashboard(request): def admin_dashboard(request):
_require_admin_or_404(request) _require_admin_or_404(request)
now = timezone.now() now = timezone.now()
active_view = (request.GET.get("view") or "pending").strip().lower()
if active_view not in {"users", "servers", "pending", "grants"}:
active_view = "pending"
pending_access_requests = list( pending_access_requests = list(
AccessRequest.objects.select_related("requester", "server") AccessRequest.objects.select_related("requester", "server")
.filter(status=AccessRequest.Status.PENDING) .filter(status=AccessRequest.Status.PENDING)
.order_by("requested_at") .order_by("requested_at")
) )
total_users = get_user_model().objects.count() pending_access_request_rows = [
total_servers = Server.objects.count() {
open_access_requests = AccessRequest.objects.filter(status=AccessRequest.Status.PENDING).count() "request": pending_access_request,
active_access_grants = ( "scope_count": int(bool(pending_access_request.request_shell))
AccessRequest.objects.filter(status=AccessRequest.Status.APPROVED) + int(bool(pending_access_request.request_logs))
+ int(bool(pending_access_request.request_users)),
}
for pending_access_request in pending_access_requests
]
active_grants_qs = (
AccessRequest.objects.select_related("requester", "server")
.filter(status=AccessRequest.Status.APPROVED)
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now)) .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
.filter(Q(request_shell=True) | Q(request_logs=True) | Q(request_users=True)) .filter(Q(request_shell=True) | Q(request_logs=True) | Q(request_users=True))
.count() .order_by("requester__username", "server__display_name", "requested_at", "id")
) )
active_grants = list(active_grants_qs)
grant_servers_by_user_id: dict[int, set[str]] = {}
grant_counts_by_user_id: dict[int, int] = {}
for grant in active_grants:
grant_counts_by_user_id[grant.requester_id] = grant_counts_by_user_id.get(grant.requester_id, 0) + 1
grant_servers_by_user_id.setdefault(grant.requester_id, set()).add(grant.server.display_name)
users = list(get_user_model().objects.order_by("username", "id"))
pending_erasure_user_ids = set(
ErasureRequest.objects.filter(status=ErasureRequest.Status.PENDING).values_list("user_id", flat=True)
)
user_rows = []
for user in users:
user_servers = sorted(grant_servers_by_user_id.get(user.id, set()), key=lambda value: value.lower())
user_rows.append(
{
"user": user,
"active_grants_count": grant_counts_by_user_id.get(user.id, 0),
"active_grant_servers": user_servers,
"active_grants_tooltip": ", ".join(user_servers) if user_servers else "No active grants",
"has_pending_erasure_request": user.id in pending_erasure_user_ids,
}
)
servers = list(Server.objects.order_by("display_name", "hostname", "id"))
grant_user_filter_raw = (request.GET.get("grant_user") or "").strip()
grant_user_filter_id = None
try:
if grant_user_filter_raw:
grant_user_filter_id = int(grant_user_filter_raw)
except (TypeError, ValueError):
grant_user_filter_id = None
grants_for_view = active_grants
if grant_user_filter_id is not None:
grants_for_view = [grant for grant in grants_for_view if grant.requester_id == grant_user_filter_id]
last_used_lookup: dict[tuple[int, str], datetime] = {}
if grants_for_view:
grant_server_ids = {grant.server_id for grant in grants_for_view}
grant_usernames = {grant.requester.username for grant in grants_for_view if grant.requester.username}
if grant_server_ids and grant_usernames:
audit_rows = (
ServerAuditLog.objects.filter(server_id__in=grant_server_ids, username__in=grant_usernames)
.exclude(username="")
.values("server_id", "username", "event_at")
.order_by("server_id", "username", "-event_at")
)
for audit_row in audit_rows:
key = (audit_row["server_id"], audit_row["username"])
if key in last_used_lookup:
continue
last_used_lookup[key] = audit_row["event_at"]
grant_rows = []
for grant in grants_for_view:
last_used_at = last_used_lookup.get((grant.server_id, grant.requester.username))
expires_soon = False
if grant.expires_at is not None:
remaining = grant.expires_at - now
expires_soon = timedelta(0) <= remaining <= timedelta(days=7)
expires_at_input = ""
if grant.expires_at is not None:
expires_at_local = timezone.localtime(grant.expires_at, timezone.get_current_timezone())
expires_at_input = expires_at_local.strftime("%Y-%m-%dT%H:%M")
grant_rows.append(
{
"grant": grant,
"last_used_at": last_used_at,
"expires_soon": expires_soon,
"expires_at_input": expires_at_input,
}
)
grant_filter_users_by_id = {grant.requester_id: grant.requester for grant in active_grants}
grant_filter_users = sorted(
grant_filter_users_by_id.values(),
key=lambda user: (user.username.lower(), user.id),
)
selected_grant_user = next(
(user for user in grant_filter_users if user.id == grant_user_filter_id),
None,
)
total_users = len(users)
total_servers = len(servers)
open_access_requests = len(pending_access_requests)
active_access_grants = len(active_grants)
utilities = [ utilities = [
{ {
"title": "Server Inventory", "title": "Server Inventory",
@@ -214,11 +319,16 @@ def admin_dashboard(request):
"url": reverse("admin:index"), "url": reverse("admin:index"),
}, },
] ]
servers = list(Server.objects.order_by("display_name", "hostname", "id"))
context = { context = {
"pending_access_requests": pending_access_requests, "active_view": active_view,
"utilities": utilities, "pending_access_requests": pending_access_request_rows,
"users": user_rows,
"servers": servers, "servers": servers,
"grant_rows": grant_rows,
"grant_filter_users": grant_filter_users,
"selected_grant_user": selected_grant_user,
"grant_user_filter_id": grant_user_filter_id,
"utilities": utilities,
"summary": { "summary": {
"users": total_users, "users": total_users,
"servers": total_servers, "servers": total_servers,
@@ -247,13 +357,40 @@ def request_access(request, server_id: int):
status=AccessRequest.Status.PENDING, status=AccessRequest.Status.PENDING,
).exists(): ).exists():
return redirect("servers:dashboard") return redirect("servers:dashboard")
scoped_request = (request.POST.get("scoped_request") or "").strip() == "1"
if scoped_request:
request_shell = (request.POST.get("request_shell") or "").strip().lower() in {"1", "true", "on", "yes"}
request_logs = (request.POST.get("request_logs") or "").strip().lower() in {"1", "true", "on", "yes"}
request_users = (request.POST.get("request_users") or "").strip().lower() in {"1", "true", "on", "yes"}
if not any((request_shell, request_logs, request_users)):
return redirect("servers:dashboard")
reason = (request.POST.get("reason") or "").strip()
requested_server_username = (request.POST.get("requested_server_username") or "").strip()[:128]
requested_duration_hours = None
raw_duration = (request.POST.get("requested_duration_hours") or "").strip()
if raw_duration:
try:
parsed_duration = int(raw_duration)
except (TypeError, ValueError):
parsed_duration = None
if parsed_duration in REQUEST_DURATION_HOURS_OPTIONS:
requested_duration_hours = parsed_duration
else:
request_shell = True
request_logs = True
request_users = True
reason = ""
requested_server_username = ""
requested_duration_hours = None
AccessRequest.objects.create( AccessRequest.objects.create(
requester=request.user, requester=request.user,
server=server, server=server,
reason="", reason=reason,
request_shell=True, request_shell=request_shell,
request_logs=True, request_logs=request_logs,
request_users=True, request_users=request_users,
requested_duration_hours=requested_duration_hours,
requested_server_username=requested_server_username,
) )
return redirect("servers:dashboard") return redirect("servers:dashboard")
@@ -270,19 +407,110 @@ def decide_access_request(request, request_id: int):
if access_request.status != AccessRequest.Status.PENDING: if access_request.status != AccessRequest.Status.PENDING:
return redirect(_next_redirect_target(request, access_request.server_id)) return redirect(_next_redirect_target(request, access_request.server_id))
action = (request.POST.get("action") or "").strip().lower() action = (request.POST.get("action") or "").strip().lower()
decision_at = timezone.now()
update_fields = ["status", "decided_at", "decided_by"]
if action == "approve": if action == "approve":
access_request.status = AccessRequest.Status.APPROVED access_request.status = AccessRequest.Status.APPROVED
if access_request.requested_duration_hours and not access_request.expires_at:
access_request.expires_at = decision_at + timedelta(hours=access_request.requested_duration_hours)
update_fields.append("expires_at")
elif action == "deny": elif action == "deny":
access_request.status = AccessRequest.Status.DENIED access_request.status = AccessRequest.Status.DENIED
else: else:
return redirect(_next_redirect_target(request, access_request.server_id)) return redirect(_next_redirect_target(request, access_request.server_id))
access_request.decided_at = timezone.now() access_request.decided_at = decision_at
access_request.decided_by = request.user access_request.decided_by = request.user
access_request.save(update_fields=["status", "decided_at", "decided_by"]) access_request.save(update_fields=update_fields)
sync_server_view_perm(access_request) sync_server_view_perm(access_request)
return redirect(_next_redirect_target(request, access_request.server_id)) return redirect(_next_redirect_target(request, access_request.server_id))
@login_required(login_url="/accounts/login/")
@require_POST
def admin_send_password_reset(request, user_id: int):
_require_admin_or_404(request)
user_model = get_user_model()
try:
target_user = user_model.objects.get(id=user_id)
except user_model.DoesNotExist:
raise Http404("User not found")
reset_form = NativePasswordResetForm({"email": target_user.email})
if reset_form.is_valid():
reset_form.save(
request=request,
use_https=request.is_secure(),
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", "no-reply@localhost"),
email_template_name="accounts/password_reset_email.txt",
subject_template_name="accounts/password_reset_subject.txt",
)
return redirect(_admin_next_redirect_target(request))
@login_required(login_url="/accounts/login/")
@require_POST
def admin_delete_user(request, user_id: int):
_require_admin_or_404(request)
user_model = get_user_model()
try:
target_user = user_model.objects.get(id=user_id)
except user_model.DoesNotExist:
raise Http404("User not found")
if target_user.id == request.user.id:
return redirect(_admin_next_redirect_target(request))
target_user.delete()
return redirect(_admin_next_redirect_target(request))
@login_required(login_url="/accounts/login/")
@require_POST
def admin_delete_server(request, server_id: int):
_require_admin_or_404(request)
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise Http404("Server not found")
server.delete()
return redirect(_admin_next_redirect_target(request))
@login_required(login_url="/accounts/login/")
@require_POST
def admin_revoke_grant(request, grant_id: int):
_require_admin_or_404(request)
try:
grant = AccessRequest.objects.select_related("server", "requester").get(id=grant_id)
except AccessRequest.DoesNotExist:
raise Http404("Access grant not found")
if grant.status == AccessRequest.Status.APPROVED:
now = timezone.now()
grant.status = AccessRequest.Status.REVOKED
grant.decided_at = now
grant.decided_by = request.user
grant.expires_at = now
grant.save(update_fields=["status", "decided_at", "decided_by", "expires_at"])
sync_server_view_perm(grant)
return redirect(_admin_next_redirect_target(request))
@login_required(login_url="/accounts/login/")
@require_POST
def admin_change_grant_expiry(request, grant_id: int):
_require_admin_or_404(request)
try:
grant = AccessRequest.objects.select_related("server", "requester").get(id=grant_id)
except AccessRequest.DoesNotExist:
raise Http404("Access grant not found")
raw_expires_at = (request.POST.get("expires_at") or "").strip()
expires_at = _parse_filter_datetime(raw_expires_at, end_of_day=True) if raw_expires_at else None
if grant.status == AccessRequest.Status.APPROVED:
grant.expires_at = expires_at
grant.save(update_fields=["expires_at"])
sync_server_view_perm(grant)
return redirect(_admin_next_redirect_target(request))
@login_required(login_url="/accounts/login/") @login_required(login_url="/accounts/login/")
def detail(request, server_id: int): def detail(request, server_id: int):
now = timezone.now() now = timezone.now()
@@ -1045,6 +1273,13 @@ def _parse_line_limit(raw_value) -> int:
return DEFAULT_AUDIT_LINE_LIMIT return DEFAULT_AUDIT_LINE_LIMIT
def _admin_next_redirect_target(request) -> str:
target = (request.POST.get("next") or "").strip()
if target.startswith("/"):
return target
return reverse("servers:admin_dashboard")
def _next_redirect_target(request, server_id: int) -> str: def _next_redirect_target(request, server_id: int) -> str:
target = (request.POST.get("next") or "").strip() target = (request.POST.get("next") or "").strip()
if target.startswith("/"): if target.startswith("/"):
+18 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime, timedelta
from typing import List, Optional from typing import List, Optional
from django.http import HttpRequest from django.http import HttpRequest
@@ -22,6 +22,8 @@ class AccessRequestCreateIn(Schema):
request_shell: bool = False request_shell: bool = False
request_logs: bool = False request_logs: bool = False
request_users: bool = False request_users: bool = False
requested_duration_hours: Optional[int] = Field(default=None, ge=1, le=168)
requested_server_username: Optional[str] = None
expires_at: Optional[datetime] = None expires_at: Optional[datetime] = None
@@ -39,6 +41,8 @@ class AccessRequestOut(Schema):
request_shell: bool request_shell: bool
request_logs: bool request_logs: bool
request_users: bool request_users: bool
requested_duration_hours: Optional[int] = None
requested_server_username: str
requested_at: str requested_at: str
decided_at: Optional[str] = None decided_at: Optional[str] = None
expires_at: Optional[str] = None expires_at: Optional[str] = None
@@ -63,6 +67,8 @@ def _request_to_out(access_request: AccessRequest) -> AccessRequestOut:
request_shell=access_request.request_shell, request_shell=access_request.request_shell,
request_logs=access_request.request_logs, request_logs=access_request.request_logs,
request_users=access_request.request_users, request_users=access_request.request_users,
requested_duration_hours=access_request.requested_duration_hours,
requested_server_username=access_request.requested_server_username or "",
requested_at=access_request.requested_at.isoformat(), requested_at=access_request.requested_at.isoformat(),
decided_at=access_request.decided_at.isoformat() if access_request.decided_at else None, decided_at=access_request.decided_at.isoformat() if access_request.decided_at else None,
expires_at=access_request.expires_at.isoformat() if access_request.expires_at else None, expires_at=access_request.expires_at.isoformat() if access_request.expires_at else None,
@@ -140,6 +146,8 @@ def build_router() -> Router:
request_shell=payload.request_shell, request_shell=payload.request_shell,
request_logs=payload.request_logs, request_logs=payload.request_logs,
request_users=payload.request_users, request_users=payload.request_users,
requested_duration_hours=payload.requested_duration_hours,
requested_server_username=(payload.requested_server_username or "").strip()[:128],
) )
if payload.expires_at: if payload.expires_at:
access_request.expires_at = payload.expires_at access_request.expires_at = payload.expires_at
@@ -216,6 +224,15 @@ def build_router() -> Router:
access_request.decided_by = request.user access_request.decided_by = request.user
else: else:
access_request.decided_by = None access_request.decided_by = None
if (
is_admin
and status == AccessRequest.Status.APPROVED
and access_request.requested_duration_hours
and not access_request.expires_at
):
access_request.expires_at = access_request.decided_at + timedelta(
hours=access_request.requested_duration_hours
)
access_request.save() access_request.save()
sync_server_view_perm(access_request) sync_server_view_perm(access_request)
return _request_to_out(access_request) return _request_to_out(access_request)
+4
View File
@@ -82,6 +82,8 @@ KEYWARDEN_SOCIAL_AUTH_ENABLED = (
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_HTTPONLY = True
INSTALLED_APPS = [ INSTALLED_APPS = [
"unfold.contrib.guardian", "unfold.contrib.guardian",
@@ -238,6 +240,8 @@ PASSWORD_HASHERS = [
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR/"static" STATIC_ROOT = BASE_DIR/"static"
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Avoid wildcard CORS on static assets (Access-Control-Allow-Origin: *).
WHITENOISE_ALLOW_ALL_ORIGINS = False
TAILWIND_APP_NAME="theme" TAILWIND_APP_NAME="theme"
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
@font-face {
font-family: "Space Grotesk";
src: url("../fonts/space-grotesk/SpaceGrotesk-VariableFont_wght.ttf") format("truetype");
font-weight: 300 700;
font-style: normal;
font-display: swap;
}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+67
View File
@@ -0,0 +1,67 @@
Space Grotesk Variable Font
===========================
This download contains Space Grotesk as both a variable font and static fonts.
Space Grotesk is a variable font with this axis:
wght
This means all the styles are contained in a single file:
SpaceGrotesk-VariableFont_wght.ttf
If your app fully supports variable fonts, you can now pick intermediate styles
that aren’t available as static fonts. Not all apps support variable fonts, and
in those cases you can use the static font files for Space Grotesk:
static/SpaceGrotesk-Light.ttf
static/SpaceGrotesk-Regular.ttf
static/SpaceGrotesk-Medium.ttf
static/SpaceGrotesk-SemiBold.ttf
static/SpaceGrotesk-Bold.ttf
Get started
-----------
1. Install the font files you want to use
2. Use your app's font picker to view the font family and all the
available styles
Learn more about variable fonts
-------------------------------
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
https://variablefonts.typenetwork.com
https://medium.com/variable-fonts
In desktop apps
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
Online
https://developers.google.com/fonts/docs/getting_started
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
Installing fonts
MacOS: https://support.apple.com/en-us/HT201749
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
Android Apps
https://developers.google.com/fonts/docs/android
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
License
-------
Please read the full license text (OFL.txt) to understand the permissions,
restrictions and requirements for usage, redistribution, and modification.
You can use them in your products & projects – print or digital,
commercial or otherwise.
This isn't legal advice, please consider consulting a lawyer and see the full
license for all details.
+70 -6
View File
@@ -1,4 +1,4 @@
{% load static tailwind_tags %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -9,12 +9,11 @@
<link rel="icon" type="image/svg+xml" href="{% static 'branding/keywarden-favicon.svg' %}"> <link rel="icon" type="image/svg+xml" href="{% static 'branding/keywarden-favicon.svg' %}">
<link rel="icon" type="image/png" href="{% static 'ninja/favicon.png' %}"> <link rel="icon" type="image/png" href="{% static 'ninja/favicon.png' %}">
<meta id="kw-theme-color" name="theme-color" content="#0f172a"> <meta id="kw-theme-color" name="theme-color" content="#0f172a">
<meta name="csrf-token" content="{{ csrf_token }}">
<meta property="og:title" content="Keywarden"> <meta property="og:title" content="Keywarden">
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="{% static 'css/fonts-space-grotesk.css' %}">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="{% static 'css/dist/styles.css' %}">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
{% tailwind_css %}
<style> <style>
:root { :root {
color-scheme: light; color-scheme: light;
@@ -173,6 +172,9 @@
html[data-theme="dark"] .bg-amber-100 { html[data-theme="dark"] .bg-amber-100 {
background-color: rgba(180, 83, 9, 0.35) !important; background-color: rgba(180, 83, 9, 0.35) !important;
} }
html[data-theme="dark"] .bg-amber-50 {
background-color: rgba(180, 83, 9, 0.22) !important;
}
html[data-theme="dark"] .bg-red-50 { html[data-theme="dark"] .bg-red-50 {
background-color: rgba(127, 29, 29, 0.32) !important; background-color: rgba(127, 29, 29, 0.32) !important;
} }
@@ -374,6 +376,21 @@
</footer> </footer>
{% endif %} {% endif %}
</div> </div>
<script>
(function () {
window.keywardenGetCsrfToken = function () {
var meta = document.querySelector('meta[name="csrf-token"]');
if (meta && meta.content && meta.content !== "NOTPROVIDED") {
return meta.content;
}
var input = document.querySelector('input[name="csrfmiddlewaretoken"]');
if (input && input.value) {
return input.value;
}
return "";
};
})();
</script>
<script> <script>
(function () { (function () {
var key = "keywardenThemeMode"; var key = "keywardenThemeMode";
@@ -496,6 +513,53 @@
window.keywardenSetDisplayMode = setMode; window.keywardenSetDisplayMode = setMode;
})(); })();
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/flowbite@4.0.1/dist/flowbite.min.js"></script> <script>
(function () {
var collapseToggles = document.querySelectorAll("[data-collapse-toggle]");
for (var i = 0; i < collapseToggles.length; i += 1) {
collapseToggles[i].addEventListener("click", function (event) {
var button = event.currentTarget;
var targetId = button.getAttribute("data-collapse-toggle");
if (!targetId) {
return;
}
var target = document.getElementById(targetId);
if (!target) {
return;
}
var isHidden = target.classList.contains("hidden");
target.classList.toggle("hidden", !isHidden);
button.setAttribute("aria-expanded", isHidden ? "true" : "false");
});
}
function setTooltipState(tooltip, show) {
if (!tooltip) {
return;
}
tooltip.classList.toggle("invisible", !show);
tooltip.classList.toggle("opacity-0", !show);
tooltip.classList.toggle("opacity-100", show);
}
var tooltipTriggers = document.querySelectorAll("[data-tooltip-target]");
for (var j = 0; j < tooltipTriggers.length; j += 1) {
(function (trigger) {
var tooltipId = trigger.getAttribute("data-tooltip-target");
if (!tooltipId) {
return;
}
var tooltip = document.getElementById(tooltipId);
if (!tooltip) {
return;
}
trigger.addEventListener("mouseenter", function () { setTooltipState(tooltip, true); });
trigger.addEventListener("mouseleave", function () { setTooltipState(tooltip, false); });
trigger.addEventListener("focus", function () { setTooltipState(tooltip, true); });
trigger.addEventListener("blur", function () { setTooltipState(tooltip, false); });
})(tooltipTriggers[j]);
}
})();
</script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -1,4 +1,4 @@
{% load static tailwind_tags %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -6,7 +6,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta http-equiv="X-UA-Compatible" content="ie=edge">
{% tailwind_css %} <link rel="stylesheet" href="{% static 'css/dist/styles.css' %}">
</head> </head>
<body class="bg-gray-50 font-serif leading-normal tracking-normal"> <body class="bg-gray-50 font-serif leading-normal tracking-normal">
@@ -0,0 +1,33 @@
flowchart LR
U[User Browser]
A[Admin Browser]
I[OIDC IdP]
subgraph K[Keywarden Control Plane]
UI[Web UI + API]
WS[WebSocket Shell Service]
DB[(PostgreSQL)]
R[(Redis/Valkey)]
UCA[(User SSH CA)]
ACA[(Agent mTLS CA)]
end
subgraph M[Managed Infrastructure]
AG[Keywarden Agent]
H[Linux Host + sshd]
end
U --> UI
A --> UI
UI <--> I
UI --> DB
UI --> R
UI --> UCA
UI --> ACA
AG <--> UI
AG --> H
U --> H
U -.optional browser shell.-> WS
WS --> H
@@ -0,0 +1,37 @@
flowchart TB
subgraph Z0[Public Network]
B[Browsers]
AG[Agents]
end
subgraph Z1[Edge]
T[Traefik]
end
subgraph Z2[Keywarden Host]
N[Nginx]
G[Gunicorn Django]
D[Daphne Channels]
C[Celery Worker + Beat]
P[(PostgreSQL)]
V[(Redis/Valkey)]
K[(CA Key Files)]
end
subgraph Z3[Managed Hosts]
SSH[sshd + local accounts]
end
B -->|HTTPS 443| T
AG -->|HTTPS 443| T
T -->|HTTPS 443| N
N -->|HTTP 8000| G
N -->|WS 8001| D
G --> P
G --> V
C --> P
C --> V
G --> K
AG --> SSH
AG -.mTLS cert presented on /api/v1/agent/.-> N
@@ -0,0 +1,43 @@
erDiagram
USER ||--o{ SSHKEY : owns
SSHKEY ||--o| SSHCERTIFICATE : has_active_cert
USER ||--o{ ACCESSREQUEST : submits
SERVER ||--o{ ACCESSREQUEST : targets
SERVER ||--o{ SERVERACCOUNT : tracks
USER ||--o{ SERVERACCOUNT : maps_to_system_user
SERVER ||--o{ SERVERAUDITLOG : receives
SERVER ||--o{ SERVERLOGSOURCE : configures
SERVER ||--o{ TELEMETRYEVENT : emits
USER ||--o{ AUDITLOG : actor
SSHCERTIFICATEAUTHORITY ||--o{ SSHCERTIFICATE : signs
AGENTCERTIFICATEAUTHORITY ||--o{ SERVER : enrolls
SERVER ||--o{ ENROLLMENTTOKEN : bootstraps
USER {
int id PK
string email
bool is_active
}
SERVER {
int id PK
string display_name
string hostname
datetime last_heartbeat_at
}
ACCESSREQUEST {
int id PK
string status
bool request_shell
bool request_logs
bool request_users
datetime expires_at
}
SSHKEY {
int id PK
string fingerprint
bool is_active
}
@@ -0,0 +1,27 @@
sequenceDiagram
participant U as User
participant K as Keywarden
participant AD as Administrator
participant AG as Agent
participant H as Managed Host
U->>K: Submit access request (shell/logs/users)
K-->>U: Request status = pending
AD->>K: Approve request
K-->>AD: Grant active
loop Poll every N seconds
AG->>K: Fetch accounts + CA + log policy
K-->>AG: Effective access state
end
AG->>H: Create/lock accounts + apply trust config
AG->>K: Sync report + heartbeat + logs
K-->>AD: Updated admin/audit view
AD->>K: Revoke or let request expire
loop Next poll
AG->>K: Pull updated state
K-->>AG: User removed/expired
end
AG->>H: Enforce revocation
@@ -0,0 +1,27 @@
sequenceDiagram
participant AD as Administrator
participant AG as Agent
participant K as Keywarden API
participant N as Nginx
AD->>K: Create one-time enrollment token
AG->>AG: Generate keypair + CSR
AG->>K: POST /agent/enroll (token + CSR + host/IP)
K->>K: Validate token, create/update Server, sign client cert
K-->>AG: server_id + client_cert + ca_cert (+ token metadata)
AG->>AG: Persist cert/key/ca and server_id
loop Runtime polling
AG->>N: /api/v1/agent/* with client cert
N->>K: Forward request + TLS client headers
K->>K: Validate mTLS cert fingerprint/serial against server
K-->>AG: 200 + policy/state payload
end
alt mTLS context missing and fallback enabled
AG->>K: Authorization: Bearer <agent token>
K->>K: Validate server/global token
K-->>AG: 200 (compatibility mode)
else mTLS strict mode
K-->>AG: 401 Unauthorized
end
@@ -0,0 +1,19 @@
sequenceDiagram
participant AD as Administrator
participant K as Keywarden
participant AG as Agent
participant H as Managed Host
participant U as User
AD->>K: Revoke access at t0
Note right of K: Policy state changes immediately
loop Poll interval = N seconds
AG->>K: Poll /accounts + /ssh-ca
end
K-->>AG: Revoked state at t0 + delta
AG->>H: Remove/lock account and apply new state
H-->>U: SSH denied
Note over AD,U: Effective revocation latency <= poll interval + agent apply time
@@ -0,0 +1,23 @@
sequenceDiagram
participant U as Browser User
participant UI as Shell UI
participant SC as ShellConsumer
participant CA as User SSH CA
participant H as Target sshd
U->>UI: Open /servers/{id}/shell
UI->>SC: Open WebSocket /ws/servers/{id}/shell
SC->>SC: Authenticate session + object permission check
SC->>SC: Generate ephemeral keypair (tmp dir)
SC->>CA: Sign ephemeral public key (short TTL)
CA-->>SC: SSH certificate
SC->>H: Start ssh -tt with key + cert
U<->>SC: Terminal I/O over WebSocket
SC<->>H: SSH stream
U->>SC: Disconnect/close tab
SC->>SC: Terminate process and clean temp key material
Note over U,H: Feature is beta; session transcript retention is limited
@@ -0,0 +1,21 @@
flowchart LR
subgraph S[Event Sources]
API[API Requests]
WS[WebSocket Shell Events]
AL[Agent Log Batches]
HB[Agent Heartbeat/Sync Reports]
end
API --> MM[Audit Matcher + Event Type Rules]
WS --> MM
MM --> AL1[(AuditLog)]
AL --> N1[Normalize + Parse]
N1 --> AL2[(ServerAuditLog)]
HB --> T1[(TelemetryEvent)]
AL1 --> UI[Admin Audit Views]
AL2 --> UI
T1 --> UI
UI --> EV[Evidence Export: logs, CSV, summaries]
@@ -0,0 +1,14 @@
stateDiagram-v2
[*] --> Pending: request created
Pending --> Approved: admin approve
Pending --> Denied: admin deny
Pending --> Cancelled: requester cancel
Approved --> Revoked: admin revoke
Approved --> Expired: expires_at reached
Denied --> [*]
Cancelled --> [*]
Revoked --> [*]
Expired --> [*]
@@ -0,0 +1,38 @@
flowchart LR
ATT[External Attacker]
subgraph Z0[Untrusted Network]
U[User/Admin Browsers]
AG[Agents]
end
subgraph Z1[Edge Trust Boundary]
T[Traefik]
end
subgraph Z2[Keywarden Trusted Zone]
N[Nginx]
APP[Keywarden App]
DB[(PostgreSQL)]
R[(Redis/Valkey)]
CA[(CA Private Keys)]
end
subgraph Z3[Managed Host Zone]
SSH[sshd + local accounts]
end
U -->|HTTPS| T -->|HTTPS| N --> APP
AG -->|HTTPS/mTLS path| T
AG --> SSH
APP --> DB
APP --> R
APP --> CA
ATT -.credential theft / token misuse.-> T
ATT -.injection / auth bypass attempts.-> APP
ATT -.MITM if proxy TLS/mTLS misconfigured.-> N
ATT -.host compromise impact.-> SSH
Note1[Key Risk: CA private keys are central high-value assets]
CA --- Note1
+22
View File
@@ -0,0 +1,22 @@
# Dissertation Diagram Set
This folder contains compact Mermaid diagrams for Chapters 4-7.
## Files
- `01-system-architecture.mmd`
- `02-deployment-topology.mmd`
- `03-data-model-erd.mmd`
- `04-access-governance-sequence.mmd`
- `05-agent-enrollment-runtime-auth-sequence.mmd`
- `06-revocation-latency-timeline.mmd`
- `07-browser-shell-trust-flow.mmd`
- `08-audit-pipeline-flow.mmd`
- `09-access-request-state-machine.mmd`
- `10-threat-trust-boundaries.mmd`
## Render Example
```bash
mmdc -i 01-system-architecture.mmd -o 01-system-architecture.png
```
+8 -5
View File
@@ -1,5 +1,8 @@
add_header Content-Security-Policy "default-src 'self'; font-src *;img-src * data:; script-src * 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'"; proxy_hide_header X-Powered-By;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options nosniff; add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; form-action 'self'; connect-src 'self' https: wss:; img-src 'self' data: https:; font-src 'self' data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" always;
add_header Referrer-Policy "strict-origin"; add_header X-Frame-Options "SAMEORIGIN" always;
add_header Permissions-Policy "geolocation=(),midi=(),sync-xhr=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=()"; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(),midi=(),sync-xhr=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=()" always;
add_header X-Permitted-Cross-Domain-Policies "none" always;
+1 -1
View File
@@ -1,2 +1,2 @@
include options-http-headers.conf; include options-http-headers.conf;
add_header Strict-Transport-Security 'max-age=31536000; includeSubDomains; preload'; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;