before review

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

No files matched your search

+388 -103
View File
@@ -12,88 +12,327 @@
</div>
<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="mt-2 text-2xl font-semibold text-gray-900">{{ summary.users }}</p>
</article>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
</a>
<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="mt-2 text-2xl font-semibold text-gray-900">{{ summary.servers }}</p>
</article>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
</a>
<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="mt-2 text-2xl font-semibold text-amber-700">{{ summary.pending_requests }}</p>
</article>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
</a>
<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="mt-2 text-2xl font-semibold text-emerald-700">{{ summary.active_grants }}</p>
</article>
</a>
</section>
<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>
{% if active_view == "users" %}
<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">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>
<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">
<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 %}
{% if users %}
<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>
<td class="px-3 py-2">{{ req.requester.username }}</td>
<td class="px-3 py-2">
<a href="{% url 'servers:server_admin' req.server.id %}" class="font-medium text-blue-700 hover:underline">
{{ req.server.display_name }}
</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>
<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="date">Last login <span class="ml-1" data-sort-indicator></span></th>
<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>
<th class="px-3 py-2 font-semibold text-right">Actions</th>
</tr>
{% endfor %}
</tbody>
</table>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% 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>
{% else %}
<p class="mt-4 text-sm text-gray-500">No pending access requests.</p>
{% endif %}
</section>
{% 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" 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">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">
<div class="border-b border-gray-100 pb-4">
@@ -109,40 +348,86 @@
{% endfor %}
</div>
</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>
<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 %}
@@ -138,14 +138,18 @@
Request pending since {{ item.access.pending.requested_at|date:"M j, Y H:i" }}
</span>
{% else %}
<form method="post" action="{% url 'servers:request_access' item.server.id %}">
{% csrf_token %}
<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">
Request access
</button>
</form>
<button
type="button"
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800"
data-open-request-modal
data-server-id="{{ item.server.id }}"
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 %}
<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>
{% endif %}
</article>
@@ -164,4 +168,144 @@
</div>
{% endif %}
</div>
<div id="access-request-modal" class="fixed inset-0 z-50 hidden items-center justify-center px-4 py-6" aria-hidden="true">
<div class="absolute inset-0 bg-gray-900/50" data-request-modal-close></div>
<div class="relative z-10 w-full max-w-xl rounded-2xl border border-gray-200 bg-white p-5 shadow-xl sm:p-6">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Request Scoped Access</h2>
<p class="mt-1 text-sm text-gray-500">
Server:
<span id="request-modal-server-name" class="font-semibold text-gray-700">-</span>
</p>
</div>
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-gray-200 text-gray-500 hover:bg-gray-50"
data-request-modal-close
aria-label="Close access request"
>
&times;
</button>
</div>
<form id="access-request-form" method="post" class="mt-5 space-y-5">
{% csrf_token %}
<input type="hidden" name="scoped_request" value="1">
<fieldset class="space-y-2">
<legend class="text-xs font-semibold uppercase tracking-wide text-gray-500">Requested scopes</legend>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="request_shell" value="1" class="h-4 w-4 rounded border-gray-300 text-blue-700" checked>
Shell access
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="request_logs" value="1" class="h-4 w-4 rounded border-gray-300 text-blue-700" checked>
Audit log access
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="request_users" value="1" class="h-4 w-4 rounded border-gray-300 text-blue-700" checked>
Server users/details access
</label>
</fieldset>
<div>
<label for="requested-duration-hours" class="block text-xs font-semibold uppercase tracking-wide text-gray-500">Requested duration</label>
<select id="requested-duration-hours" name="requested_duration_hours" class="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700">
<option value="">No preferred expiry</option>
<option value="1">1 hour</option>
<option value="4">4 hours</option>
<option value="8" selected>8 hours</option>
<option value="24">24 hours</option>
<option value="72">3 days</option>
<option value="168">7 days</option>
</select>
</div>
<div>
<label for="requested-server-username" class="block text-xs font-semibold uppercase tracking-wide text-gray-500">Preferred server username</label>
<input
id="requested-server-username"
name="requested_server_username"
type="text"
maxlength="128"
value="{{ request.user.username }}"
placeholder="e.g. ubuntu"
class="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700"
>
</div>
<div>
<label for="request-reason" class="block text-xs font-semibold uppercase tracking-wide text-gray-500">Reason (optional)</label>
<textarea
id="request-reason"
name="reason"
rows="3"
placeholder="Why you need this access."
class="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700"
></textarea>
</div>
<div class="flex items-center justify-end gap-2 border-t border-gray-100 pt-4">
<button type="button" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50" data-request-modal-close>
Cancel
</button>
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800">
Submit request
</button>
</div>
</form>
</div>
</div>
<script>
(function () {
var modal = document.getElementById("access-request-modal");
var modalServerName = document.getElementById("request-modal-server-name");
var form = document.getElementById("access-request-form");
var openButtons = document.querySelectorAll("[data-open-request-modal]");
var closeButtons = document.querySelectorAll("[data-request-modal-close]");
function closeModal() {
modal.classList.add("hidden");
modal.classList.remove("flex");
modal.setAttribute("aria-hidden", "true");
}
function openModal(button) {
var requestUrl = button.getAttribute("data-request-url");
var serverName = button.getAttribute("data-server-name") || "Server";
form.setAttribute("action", requestUrl);
modalServerName.textContent = serverName;
modal.classList.remove("hidden");
modal.classList.add("flex");
modal.setAttribute("aria-hidden", "false");
}
openButtons.forEach(function (button) {
button.addEventListener("click", function () {
openModal(button);
});
});
closeButtons.forEach(function (button) {
button.addEventListener("click", closeModal);
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && modal.classList.contains("flex")) {
closeModal();
}
});
form.addEventListener("submit", function (event) {
var checked = form.querySelectorAll("input[name='request_shell']:checked, input[name='request_logs']:checked, input[name='request_users']:checked");
if (!checked.length) {
event.preventDefault();
window.alert("Select at least one scope.");
}
});
})();
</script>
{% endblock %}
@@ -129,11 +129,9 @@
</div>
<script>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
function getCsrfToken() {
if (typeof window.keywardenGetCsrfToken === "function") {
return window.keywardenGetCsrfToken();
}
return "";
}
@@ -156,7 +154,7 @@
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
var csrf = getCsrfToken();
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
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_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.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>
</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>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
function getCsrfToken() {
if (typeof window.keywardenGetCsrfToken === "function") {
return window.keywardenGetCsrfToken();
}
return "";
}
@@ -175,7 +173,7 @@
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
var csrf = getCsrfToken();
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
credentials: "same-origin",
@@ -1,15 +1,18 @@
from __future__ import annotations
from datetime import timedelta
from types import SimpleNamespace
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from guardian.shortcuts import assign_perm
from ninja.errors import HttpError
from apps.access.models import AccessRequest
from apps.accounts.models import ErasureRequest
from apps.core import rbac
from apps.servers.models import Server, ServerAccount
from apps.servers.permissions import user_can_logs, user_can_shell, user_can_users, user_has_any_access
@@ -109,6 +112,32 @@ class ServerAccessScopeTests(TestCase):
).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:
pending = AccessRequest.objects.create(
requester=self.user,
@@ -130,6 +159,28 @@ class ServerAccessScopeTests(TestCase):
self.assertEqual(pending.status, AccessRequest.Status.APPROVED)
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:
self.client.force_login(self.user)
response = self.client.get(reverse("servers:admin_dashboard"))
@@ -149,6 +200,104 @@ class ServerAccessScopeTests(TestCase):
self.assertContains(response, pending.requester.username)
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:
assign_perm("view_server", self.user, self.server)
self.client.force_login(self.user)
+5
View File
@@ -7,6 +7,11 @@ app_name = "servers"
urlpatterns = [
path("", views.dashboard, name="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(
"access-requests/<int:request_id>/decision/",
+250 -15
View File
@@ -20,6 +20,8 @@ from guardian.shortcuts import get_objects_for_user, get_users_with_perms
from apps.access.models import AccessRequest
from apps.access.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.models import SSHKey
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")
HTTP_STATUS_PATTERN = re.compile(r'"\s*(\d{3})\s+\S+')
LOGIN_OUTCOME_OPTIONS = {"attempts", "success", "failed"}
REQUEST_DURATION_HOURS_OPTIONS = (1, 4, 8, 24, 72, 168)
def _is_admin_user(user) -> bool:
@@ -168,20 +171,122 @@ def dashboard(request):
def admin_dashboard(request):
_require_admin_or_404(request)
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(
AccessRequest.objects.select_related("requester", "server")
.filter(status=AccessRequest.Status.PENDING)
.order_by("requested_at")
)
total_users = get_user_model().objects.count()
total_servers = Server.objects.count()
open_access_requests = AccessRequest.objects.filter(status=AccessRequest.Status.PENDING).count()
active_access_grants = (
AccessRequest.objects.filter(status=AccessRequest.Status.APPROVED)
pending_access_request_rows = [
{
"request": pending_access_request,
"scope_count": int(bool(pending_access_request.request_shell))
+ 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(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 = [
{
"title": "Server Inventory",
@@ -214,11 +319,16 @@ def admin_dashboard(request):
"url": reverse("admin:index"),
},
]
servers = list(Server.objects.order_by("display_name", "hostname", "id"))
context = {
"pending_access_requests": pending_access_requests,
"utilities": utilities,
"active_view": active_view,
"pending_access_requests": pending_access_request_rows,
"users": user_rows,
"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": {
"users": total_users,
"servers": total_servers,
@@ -247,13 +357,40 @@ def request_access(request, server_id: int):
status=AccessRequest.Status.PENDING,
).exists():
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(
requester=request.user,
server=server,
reason="",
request_shell=True,
request_logs=True,
request_users=True,
reason=reason,
request_shell=request_shell,
request_logs=request_logs,
request_users=request_users,
requested_duration_hours=requested_duration_hours,
requested_server_username=requested_server_username,
)
return redirect("servers:dashboard")
@@ -270,19 +407,110 @@ def decide_access_request(request, request_id: int):
if access_request.status != AccessRequest.Status.PENDING:
return redirect(_next_redirect_target(request, access_request.server_id))
action = (request.POST.get("action") or "").strip().lower()
decision_at = timezone.now()
update_fields = ["status", "decided_at", "decided_by"]
if action == "approve":
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":
access_request.status = AccessRequest.Status.DENIED
else:
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.save(update_fields=["status", "decided_at", "decided_by"])
access_request.save(update_fields=update_fields)
sync_server_view_perm(access_request)
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/")
def detail(request, server_id: int):
now = timezone.now()
@@ -1045,6 +1273,13 @@ def _parse_line_limit(raw_value) -> int:
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:
target = (request.POST.get("next") or "").strip()
if target.startswith("/"):