before review
This commit is contained in:
1 parent
c6a830cb92
commit
d9881bde01
40 files changed
+1560
-158
No files matched your search
Binary file not shown.
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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)
|
||||||
|
|||||||
@@ -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
@@ -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("/"):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+70
-6
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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,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;
|
||||||
Reference in new issue
Block a user