Update audit logging and dashboard
This commit is contained in:
@ -10,14 +10,29 @@
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
{{ entry.subsystem }}
|
||||
</td>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
<pre><code class="language-json">
|
||||
{%- set entry_object = ({"object": entry.object, "object_id": entry.object_id, "client_id": entry.client_id, "client_name": entry.client_name}) -%}
|
||||
{{- entry_object | tojson(indent=2) -}}</code></pre>
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">{{ entry.subsystem }}</span>
|
||||
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">{{ entry.operation }}</span>
|
||||
|
||||
{% if entry.client_id %}
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">
|
||||
Client: <abbr title="{{ entry.client_id }}">{{ entry.client_name }}</abbr>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if entry.secret_name %}
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">
|
||||
Secret:<abbr title="{{ entry.secret_id }}">{{ entry.secret_name }}</abbr>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if entry.data %}
|
||||
{% for key, value in entry.data.items() %}
|
||||
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">
|
||||
{{ key }}:{{ value }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
</td>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
@ -29,3 +44,5 @@
|
||||
>
|
||||
{{ entry.origin }}
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
@ -3,48 +3,118 @@
|
||||
<div class="overflow-x-auto">
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div class="overflow-hidden shadow">
|
||||
<table
|
||||
class="min-w-full divide-y divide-gray-200 table-fixed dark:divide-gray-600"
|
||||
>
|
||||
<thead class="bg-gray-100 dark:bg-gray-700">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
Timestamp
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Subsystem
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
<a id="filterSubsystem" data-dropdown-toggle="filterSubsystemsDropdown" class="whitespace-nowrap inline-flex items-center font-medium text-gray-500 hover:underline">
|
||||
Subsystem <svg class="w-[12px] h-[12px] text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" d="M18.425 10.271C19.499 8.967 18.57 7 16.88 7H7.12c-1.69 0-2.618 1.967-1.544 3.271l4.881 5.927a2 2 0 0 0 3.088 0l4.88-5.927Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
||||
</a>
|
||||
<div id="filterSubsystemsDropdown" class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<div class="py-2">
|
||||
<a href="?" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">All</a>
|
||||
</div>
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="filterSubsystem">
|
||||
<li>
|
||||
<a href="?subsystem=admin" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Admin</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="?subsystem=sshd" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Ssh Server</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="?subsystem=backend" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Backend</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Object
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
<a id="filterOperation" data-dropdown-toggle="filterOperationsDropdown" class="whitespace-nowrap inline-flex items-center font-medium text-gray-500 hover:underline">
|
||||
Operation <svg class="w-[12px] h-[12px] text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" d="M18.425 10.271C19.499 8.967 18.57 7 16.88 7H7.12c-1.69 0-2.618 1.967-1.544 3.271l4.881 5.927a2 2 0 0 0 3.088 0l4.88-5.927Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div id="filterOperationsDropdown" class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<div class="py-2">
|
||||
<a href="?" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">All</a>
|
||||
</div>
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="filterSubsystem">
|
||||
{% for operation in operations %}
|
||||
<li>
|
||||
<a href="?operation={{ operation }}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">{{ operation }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
Client
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
Secret
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
Message
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
Origin
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700"
|
||||
<tbody class="bg-white dark:bg-gray-800">
|
||||
|
||||
{% for entry in entries | list %}
|
||||
<tr
|
||||
class="{{ loop.cycle('', 'bg-gray-50 dark:bg-gray-700 ') }}hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
id="entry-{{ entry.id }}"
|
||||
>
|
||||
{% for entry in entries %} {% include 'audit/entry.html.j2' %} {%
|
||||
endfor %}
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.timestamp }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.subsystem }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.operation }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
|
||||
{% if entry.client_name %}
|
||||
<abbr title="{{ entry.client_id }}">{{ entry.client_name }}</abbr>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{% if entry.secret_name %}
|
||||
<abbr title="{{ entry.secret_id }}">{{ entry.secret_name }}</abbr>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
{{ entry.message }}
|
||||
</td>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
{{ entry.origin }}
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,11 @@
|
||||
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400"
|
||||
>Showing
|
||||
{% if page_info.total < page_info.last %}
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{page_info.first }}-{{ page_info.total}}</span> of
|
||||
{% else %}
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{page_info.first }}-{{ page_info.last}}</span> of
|
||||
{% endif %}
|
||||
<span class="font-semibold text-gray-900 dark:text-white"
|
||||
>{{ page_info.total }}</span
|
||||
></span
|
||||
|
||||
@ -10,80 +10,124 @@
|
||||
<div class="grid w-full grid-cols-1 gap-4 mt-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
<div class="items-center justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="w-full">
|
||||
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">Clients</h3>
|
||||
<span class="text-2xl font-bold leading-none text-gray-900 sm:text-3xl dark:text-white">{{ stats.clients }}</span>
|
||||
<h3 class="text-xl font-bold text-gray-500 dark:text-gray-400">Stats</h3>
|
||||
<dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700">
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-lg dark:text-gray-400">Clients</dt>
|
||||
<dd class="text-lg font-semibold">{{ stats.clients }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col py-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-lg dark:text-gray-400">Secrets</dt>
|
||||
<dd class="text-lg font-semibold">{{ stats.secrets }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col py-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-lg dark:text-gray-400">Audit Events</dt>
|
||||
<dd class="text-lg font-semibold">{{ stats.audit_events }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="flex mt-4 md:mt-6">
|
||||
<button id="createClientButton" class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-3 py-2.5 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800" type="button" data-drawer-target="drawer-create-client-default" data-drawer-show="drawer-create-client-default" aria-controls="drawer-create-client-default" data-drawer-placement="right">
|
||||
Add new client
|
||||
</button>
|
||||
<button id="createSecretButton" class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-3 py-2.5 ms-2 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800" type="button" data-drawer-target="drawer-create-secret-default" data-drawer-show="drawer-create-secret-default" aria-controls="drawer-create-secret-default" data-drawer-placement="right">
|
||||
Add new secret
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- reference -->
|
||||
|
||||
<div class="grid w-full grid-cols-1 gap-4 mt-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
<div class="items-center justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center 2xl: col-span-2 xl:col-span-2 justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="w-full">
|
||||
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">New products</h3>
|
||||
<span class="text-2xl font-bold leading-none text-gray-900 sm:text-3xl dark:text-white">2,340</span>
|
||||
<p class="flex items-center text-base font-normal text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center mr-1.5 text-sm text-green-500 dark:text-green-400">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path clip-rule="evenodd" fill-rule="evenodd" d="M10 17a.75.75 0 01-.75-.75V5.612L5.29 9.77a.75.75 0 01-1.08-1.04l5.25-5.5a.75.75 0 011.08 0l5.25 5.5a.75.75 0 11-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0110 17z"></path>
|
||||
</svg>
|
||||
12.5%
|
||||
</span>
|
||||
Since last month
|
||||
</p>
|
||||
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">Last Login Events</h3>
|
||||
{% if last_login_events.total > 0 %}
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Timestamp</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Subsystem</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Client/Username</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Origin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800">
|
||||
{% for entry in last_login_events.results | list %}
|
||||
<tr
|
||||
class="{{ loop.cycle('', 'bg-gray-50 dark:bg-gray-700 ') }}hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
id="login-entry-{{ entry.id }}"
|
||||
>
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.timestamp }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.subsystem }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{% if entry.client_name %}
|
||||
{{ entry.client_name }}
|
||||
{% elif entry.data.username %}
|
||||
{{ entry.data.username }}
|
||||
{% endif %}
|
||||
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.origin }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-sm italic">No entries</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="w-full" id="new-products-chart"></div>
|
||||
</div>
|
||||
<div class="items-center justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center 2xl:col-span-3 xl:col-span-3 justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="w-full">
|
||||
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">Users</h3>
|
||||
<span class="text-2xl font-bold leading-none text-gray-900 sm:text-3xl dark:text-white">2,340</span>
|
||||
<p class="flex items-center text-base font-normal text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center mr-1.5 text-sm text-green-500 dark:text-green-400">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path clip-rule="evenodd" fill-rule="evenodd" d="M10 17a.75.75 0 01-.75-.75V5.612L5.29 9.77a.75.75 0 01-1.08-1.04l5.25-5.5a.75.75 0 011.08 0l5.25 5.5a.75.75 0 11-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0110 17z"></path>
|
||||
</svg>
|
||||
3,4%
|
||||
</span>
|
||||
Since last month
|
||||
</p>
|
||||
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">Last Audit Events</h3>
|
||||
{% if last_audit_events.total > 0 %}
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Timestamp</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Subsystem</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Message</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Origin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800">
|
||||
{% for entry in last_audit_events.results | list %}
|
||||
<tr
|
||||
class="{{ loop.cycle('', 'bg-gray-50 dark:bg-gray-700 ') }}hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
id="login-entry-{{ entry.id }}"
|
||||
>
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.timestamp }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.subsystem }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.message }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.origin }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-sm italic">No entries</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="w-full" id="week-signups-chart"></div>
|
||||
</div>
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="w-full">
|
||||
<h3 class="mb-2 text-base font-normal text-gray-500 dark:text-gray-400">Audience by age</h3>
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="w-16 text-sm font-medium dark:text-white">50+</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
||||
<div class="bg-primary-600 h-2.5 rounded-full dark:bg-primary-500" style="width: 18%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="w-16 text-sm font-medium dark:text-white">40+</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
||||
<div class="bg-primary-600 h-2.5 rounded-full dark:bg-primary-500" style="width: 15%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="w-16 text-sm font-medium dark:text-white">30+</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
||||
<div class="bg-primary-600 h-2.5 rounded-full dark:bg-primary-500" style="width: 60%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="w-16 text-sm font-medium dark:text-white">20+</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
||||
<div class="bg-primary-600 h-2.5 rounded-full dark:bg-primary-500" style="width: 30%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="traffic-channels-chart" class="w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{% include '/clients/drawer_client_create.html.j2' %}
|
||||
{% include '/secrets/drawer_secret_create.html.j2' %}
|
||||
{% endblock %}
|
||||
|
||||
@ -3,10 +3,12 @@
|
||||
# pyright: reportUnusedFunction=false
|
||||
import logging
|
||||
import math
|
||||
from typing import Annotated
|
||||
from typing import Annotated, cast
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from sshecret.backend import AuditFilter, Operation
|
||||
|
||||
from sshecret_admin.auth import User
|
||||
from sshecret_admin.services import AdminBackend
|
||||
|
||||
@ -45,28 +47,33 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
templates = dependencies.templates
|
||||
|
||||
async def resolve_audit_entries(
|
||||
request: Request, current_user: User, admin: AdminBackend, page: int
|
||||
request: Request,
|
||||
current_user: User,
|
||||
admin: AdminBackend,
|
||||
page: int,
|
||||
filters: AuditFilter,
|
||||
) -> Response:
|
||||
"""Resolve audit entries."""
|
||||
LOG.info("Page: %r", page)
|
||||
total_messages = await admin.get_audit_log_count()
|
||||
per_page = 20
|
||||
offset = 0
|
||||
if page > 1:
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
entries = await admin.get_audit_log(offset=offset, limit=per_page)
|
||||
LOG.info("Entries: %r", entries)
|
||||
filter_args = cast(dict[str, str], filters.model_dump(exclude_none=True))
|
||||
audit_log = await admin.get_audit_log_detailed(offset, per_page, **filter_args)
|
||||
page_info = PagingInfo(
|
||||
page=page, limit=per_page, total=total_messages, offset=offset
|
||||
page=page, limit=per_page, total=audit_log.total, offset=offset
|
||||
)
|
||||
operations = list(Operation)
|
||||
if request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"audit/inner.html.j2",
|
||||
{
|
||||
"entries": entries,
|
||||
"entries": audit_log.results,
|
||||
"page_info": page_info,
|
||||
"operations": operations,
|
||||
},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
@ -74,9 +81,10 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
"audit/index.html.j2",
|
||||
{
|
||||
"page_title": "Audit",
|
||||
"entries": entries,
|
||||
"entries": audit_log.results,
|
||||
"user": current_user.username,
|
||||
"page_info": page_info,
|
||||
"operations": operations,
|
||||
},
|
||||
)
|
||||
|
||||
@ -85,19 +93,21 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
filters: Annotated[AuditFilter, Depends()],
|
||||
) -> Response:
|
||||
"""Get audit entries."""
|
||||
return await resolve_audit_entries(request, current_user, admin, 1)
|
||||
return await resolve_audit_entries(request, current_user, admin, 1, filters)
|
||||
|
||||
@app.get("/audit/page/{page}")
|
||||
async def get_audit_entries_page(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
filters: Annotated[AuditFilter, Depends()],
|
||||
page: int,
|
||||
) -> Response:
|
||||
"""Get audit entries."""
|
||||
LOG.info("Get audit entries page: %r", page)
|
||||
return await resolve_audit_entries(request, current_user, admin, page)
|
||||
return await resolve_audit_entries(request, current_user, admin, page, filters)
|
||||
|
||||
return app
|
||||
|
||||
@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlmodel import Session
|
||||
from sshecret_admin.services import AdminBackend
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from sshecret_admin.auth import (
|
||||
@ -17,6 +18,8 @@ from sshecret_admin.auth import (
|
||||
create_refresh_token,
|
||||
)
|
||||
|
||||
from sshecret.backend.models import Operation
|
||||
|
||||
from ..dependencies import FrontendDependencies
|
||||
from ..exceptions import RedirectException
|
||||
|
||||
@ -30,6 +33,19 @@ class LoginError(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
async def audit_login_failure(admin: AdminBackend, username: str, request: Request) -> None:
|
||||
"""Write login failure to audit log."""
|
||||
origin: str | None = None
|
||||
if request.client:
|
||||
origin = request.client.host
|
||||
await admin.write_audit_message(
|
||||
operation=Operation.DENY,
|
||||
message="Login failed",
|
||||
origin=origin or "UNKNOWN",
|
||||
username=username,
|
||||
)
|
||||
|
||||
|
||||
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
"""Create auth router."""
|
||||
|
||||
@ -64,6 +80,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
next: Annotated[str, Query()] = "/dashboard",
|
||||
error_title: str | None = None,
|
||||
@ -89,6 +106,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
)
|
||||
)
|
||||
if not user:
|
||||
await audit_login_failure(admin, form_data.username, request)
|
||||
raise login_failed
|
||||
token_data: dict[str, str] = {"sub": user.username}
|
||||
access_token = create_access_token(dependencies.settings, data=token_data)
|
||||
@ -108,6 +126,17 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
secure=False,
|
||||
samesite="strict",
|
||||
)
|
||||
origin = "UNKNOWN"
|
||||
if request.client:
|
||||
origin = request.client.host
|
||||
|
||||
await admin.write_audit_message(
|
||||
operation=Operation.LOGIN,
|
||||
message="Logged in to admin frontend",
|
||||
origin=origin,
|
||||
username=form_data.username,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@app.get("/refresh")
|
||||
|
||||
@ -56,6 +56,9 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
):
|
||||
"""Dashboard for mocking up the dashboard."""
|
||||
stats = await get_stats(admin)
|
||||
last_login_events = await admin.get_audit_log_detailed(limit=5, operation="login")
|
||||
last_audit_events = await admin.get_audit_log_detailed(limit=10)
|
||||
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@ -64,6 +67,9 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
"page_title": "sshecret",
|
||||
"user": current_user.username,
|
||||
"stats": stats,
|
||||
"last_login_events": last_login_events,
|
||||
"last_audit_events": last_audit_events,
|
||||
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -7,7 +7,17 @@ import logging
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sshecret.backend import AuditLog, Client, ClientFilter, Secret, SshecretBackend, Operation, SubSystem
|
||||
from sshecret.backend import (
|
||||
AuditLog,
|
||||
AuditFilter,
|
||||
AuditListResult,
|
||||
Client,
|
||||
ClientFilter,
|
||||
Secret,
|
||||
SshecretBackend,
|
||||
Operation,
|
||||
SubSystem,
|
||||
)
|
||||
from sshecret.backend.models import DetailedSecrets
|
||||
from sshecret.backend.api import AuditAPI
|
||||
from sshecret.crypto import encrypt_string, load_public_key
|
||||
@ -391,11 +401,39 @@ class AdminBackend:
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
client_name: str | None = None,
|
||||
subsystem: str | None = None,
|
||||
**kwargs: str,
|
||||
) -> list[AuditLog]:
|
||||
"""Get audit log from backend."""
|
||||
return await self.audit.get(offset, limit, client_name, subsystem)
|
||||
"""Get audit log from backend.
|
||||
|
||||
Keyword Arguments:
|
||||
operation: str | None
|
||||
subsystem: str | None
|
||||
client_id: str | None
|
||||
client_name: str | None
|
||||
secret_id: str | None
|
||||
secret_name: str | None
|
||||
origin: str | None
|
||||
"""
|
||||
return await self.audit.get(offset, limit, **kwargs)
|
||||
|
||||
async def get_audit_log_detailed(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
**kwargs: str,
|
||||
) -> AuditListResult:
|
||||
"""Get audit log from backend.
|
||||
|
||||
Keyword Arguments:
|
||||
operation: str | None
|
||||
subsystem: str | None
|
||||
client_id: str | None
|
||||
client_name: str | None
|
||||
secret_id: str | None
|
||||
secret_name: str | None
|
||||
origin: str | None
|
||||
"""
|
||||
return await self.audit.get_detailed(offset, limit, **kwargs)
|
||||
|
||||
async def write_audit_message(
|
||||
self,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/*! tailwindcss v4.1.4 | MIT License | https://tailwindcss.com */
|
||||
/*! tailwindcss v4.1.6 | MIT License | https://tailwindcss.com */
|
||||
@layer properties;
|
||||
@layer theme, base, components, utilities;
|
||||
@layer theme {
|
||||
@ -31,6 +31,7 @@
|
||||
--color-green-500: oklch(72.3% 0.219 149.579);
|
||||
--color-green-600: oklch(62.7% 0.194 149.214);
|
||||
--color-green-800: oklch(44.8% 0.119 151.328);
|
||||
--color-green-900: oklch(39.3% 0.095 152.535);
|
||||
--color-emerald-500: oklch(69.6% 0.17 162.48);
|
||||
--color-teal-100: oklch(95.3% 0.051 180.801);
|
||||
--color-teal-300: oklch(85.5% 0.138 181.071);
|
||||
@ -662,6 +663,9 @@
|
||||
.h-32 {
|
||||
height: calc(var(--spacing) * 32);
|
||||
}
|
||||
.h-\[12px\] {
|
||||
height: 12px;
|
||||
}
|
||||
.h-\[36rem\] {
|
||||
height: 36rem;
|
||||
}
|
||||
@ -752,6 +756,15 @@
|
||||
.w-80 {
|
||||
width: calc(var(--spacing) * 80);
|
||||
}
|
||||
.w-\[12px\] {
|
||||
width: 12px;
|
||||
}
|
||||
.w-\[200px\] {
|
||||
width: 200px;
|
||||
}
|
||||
.w-\[400px\] {
|
||||
width: 400px;
|
||||
}
|
||||
.w-auto {
|
||||
width: auto;
|
||||
}
|
||||
@ -761,6 +774,9 @@
|
||||
.max-w-2xl {
|
||||
max-width: var(--container-2xl);
|
||||
}
|
||||
.max-w-\[20rem\] {
|
||||
max-width: 20rem;
|
||||
}
|
||||
.max-w-\[140px\] {
|
||||
max-width: 140px;
|
||||
}
|
||||
@ -794,6 +810,9 @@
|
||||
.min-w-9 {
|
||||
min-width: calc(var(--spacing) * 9);
|
||||
}
|
||||
.min-w-\[12rem\] {
|
||||
min-width: 12rem;
|
||||
}
|
||||
.min-w-\[460px\] {
|
||||
min-width: 460px;
|
||||
}
|
||||
@ -1206,12 +1225,6 @@
|
||||
.border-red-600 {
|
||||
border-color: var(--color-red-600);
|
||||
}
|
||||
.border-slate-200 {
|
||||
border-color: var(--color-slate-200);
|
||||
}
|
||||
.border-slate-800 {
|
||||
border-color: var(--color-slate-800);
|
||||
}
|
||||
.border-white {
|
||||
border-color: var(--color-white);
|
||||
}
|
||||
@ -1359,9 +1372,6 @@
|
||||
background-color: color-mix(in oklab, var(--color-rose-500) 10%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-slate-800 {
|
||||
background-color: var(--color-slate-800);
|
||||
}
|
||||
.bg-teal-100 {
|
||||
background-color: var(--color-teal-100);
|
||||
}
|
||||
@ -1515,6 +1525,9 @@
|
||||
.pb-2 {
|
||||
padding-bottom: calc(var(--spacing) * 2);
|
||||
}
|
||||
.pb-3 {
|
||||
padding-bottom: calc(var(--spacing) * 3);
|
||||
}
|
||||
.pb-4 {
|
||||
padding-bottom: calc(var(--spacing) * 4);
|
||||
}
|
||||
@ -1657,9 +1670,18 @@
|
||||
--tw-tracking: var(--tracking-wider);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.text-wrap {
|
||||
text-wrap: wrap;
|
||||
}
|
||||
.break-words {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.wrap-normal {
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
.whitespace-normal {
|
||||
white-space: normal;
|
||||
}
|
||||
.whitespace-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@ -1738,9 +1760,6 @@
|
||||
.text-rose-500 {
|
||||
color: var(--color-rose-500);
|
||||
}
|
||||
.text-slate-500 {
|
||||
color: var(--color-slate-500);
|
||||
}
|
||||
.text-teal-500 {
|
||||
color: var(--color-teal-500);
|
||||
}
|
||||
@ -1750,6 +1769,9 @@
|
||||
.text-white {
|
||||
color: var(--color-white);
|
||||
}
|
||||
.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@ -1802,7 +1824,7 @@
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||
}
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter;
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
@ -2047,20 +2069,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:border-slate-400 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
border-color: var(--color-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:border-slate-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
border-color: var(--color-slate-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-gray-50 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@ -2131,20 +2139,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-slate-50 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-slate-50);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-slate-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-slate-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:text-\[\#f84525\] {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@ -2460,6 +2454,11 @@
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
}
|
||||
.sm\:flex-row {
|
||||
@media (width >= 40rem) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
.sm\:justify-between {
|
||||
@media (width >= 40rem) {
|
||||
justify-content: space-between;
|
||||
@ -2475,6 +2474,15 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
.sm\:space-y-0 {
|
||||
@media (width >= 40rem) {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-block-start: calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));
|
||||
margin-block-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
}
|
||||
.sm\:space-x-3 {
|
||||
@media (width >= 40rem) {
|
||||
:where(& > :not(:last-child)) {
|
||||
@ -2633,6 +2641,11 @@
|
||||
margin-top: calc(var(--spacing) * 0);
|
||||
}
|
||||
}
|
||||
.md\:mt-6 {
|
||||
@media (width >= 48rem) {
|
||||
margin-top: calc(var(--spacing) * 6);
|
||||
}
|
||||
}
|
||||
.md\:mr-0 {
|
||||
@media (width >= 48rem) {
|
||||
margin-right: calc(var(--spacing) * 0);
|
||||
@ -3008,6 +3021,16 @@
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
.xl\:col-span-2 {
|
||||
@media (width >= 80rem) {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
}
|
||||
.xl\:col-span-3 {
|
||||
@media (width >= 80rem) {
|
||||
grid-column: span 3 / span 3;
|
||||
}
|
||||
}
|
||||
.xl\:mb-0 {
|
||||
@media (width >= 80rem) {
|
||||
margin-bottom: calc(var(--spacing) * 0);
|
||||
@ -3121,6 +3144,11 @@
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
}
|
||||
.\32 xl\:col-span-3 {
|
||||
@media (width >= 96rem) {
|
||||
grid-column: span 3 / span 3;
|
||||
}
|
||||
}
|
||||
.\32 xl\:mb-0 {
|
||||
@media (width >= 96rem) {
|
||||
margin-bottom: calc(var(--spacing) * 0);
|
||||
@ -3275,6 +3303,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark\:bg-green-900 {
|
||||
&:where(.dark, .dark *) {
|
||||
background-color: var(--color-green-900);
|
||||
}
|
||||
}
|
||||
.dark\:bg-orange-400 {
|
||||
&:where(.dark, .dark *) {
|
||||
background-color: var(--color-orange-400);
|
||||
@ -3593,6 +3626,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark\:focus\:ring-blue-600 {
|
||||
&:where(.dark, .dark *) {
|
||||
&:focus {
|
||||
--tw-ring-color: var(--color-blue-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark\:focus\:ring-gray-600 {
|
||||
&:where(.dark, .dark *) {
|
||||
&:focus {
|
||||
@ -3649,6 +3689,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark\:focus\:ring-offset-gray-800 {
|
||||
&:where(.dark, .dark *) {
|
||||
&:focus {
|
||||
--tw-ring-offset-color: var(--color-gray-800);
|
||||
}
|
||||
}
|
||||
}
|
||||
.md\:dark\:hover\:bg-transparent {
|
||||
@media (width >= 48rem) {
|
||||
&:where(.dark, .dark *) {
|
||||
|
||||
@ -1,3 +1,143 @@
|
||||
/* PrismJS 1.30.0
|
||||
https://prismjs.com/download.html#themes=prism&languages=markup+css+json */
|
||||
code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}
|
||||
https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */
|
||||
/**
|
||||
* prism.js default theme for JavaScript, CSS and HTML
|
||||
* Based on dabblet (http://dabblet.com)
|
||||
* @author Lea Verou
|
||||
*/
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: black;
|
||||
background: none;
|
||||
text-shadow: 0 1px white;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
font-size: 1em;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection,
|
||||
code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
|
||||
text-shadow: none;
|
||||
background: #b3d4fc;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
|
||||
code[class*="language-"]::selection, code[class*="language-"] ::selection {
|
||||
text-shadow: none;
|
||||
background: #b3d4fc;
|
||||
}
|
||||
|
||||
@media print {
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background: #f5f2f0;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: .1em;
|
||||
border-radius: .3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: slategray;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.token.namespace {
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #905;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #690;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #9a6e3a;
|
||||
/* This background color was intended by the author of this theme. */
|
||||
background: hsla(0, 0%, 100%, .5);
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #07a;
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #DD4A68;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #e90;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,21 +0,0 @@
|
||||
"""Testing helper functions.
|
||||
|
||||
This allows creation of a user from within tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import bcrypt
|
||||
|
||||
from sshecret_admin.auth.models import User
|
||||
|
||||
|
||||
def create_test_user(session: Session, username: str, password: str) -> User:
|
||||
"""Create test user."""
|
||||
salt = bcrypt.gensalt()
|
||||
hashed_password = bcrypt.hashpw(password.encode(), salt)
|
||||
user = User(username=username, hashed_password=hashed_password.decode())
|
||||
session.add(user)
|
||||
session.commit()
|
||||
return user
|
||||
|
||||
@ -4,46 +4,96 @@
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, cast
|
||||
from fastapi import APIRouter, Depends, Request, Query
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.orm import InstrumentedAttribute, Session
|
||||
from sqlalchemy.sql.expression import ColumnExpressionArgument
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from sshecret_backend.models import AuditLog
|
||||
from sshecret_backend.models import AuditLog, Operation, SubSystem
|
||||
from sshecret_backend.types import DBSessionDep
|
||||
from sshecret_backend.view_models import AuditInfo, AuditView
|
||||
from sshecret_backend.view_models import AuditInfo, AuditView, AuditListResult
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuditFilter(BaseModel):
|
||||
"""Audit filter."""
|
||||
|
||||
offset: int = Field(0, ge=0)
|
||||
limit: int = Field(100, le=100)
|
||||
subsystem: SubSystem | None = None
|
||||
operation: Operation | None = None
|
||||
client_id: str | None = None
|
||||
client_name: str | None = None
|
||||
secret_id: str | None = None
|
||||
secret_name: str | None = None
|
||||
origin: str | None = None
|
||||
|
||||
@property
|
||||
def filter_mapping(self) -> list[ColumnExpressionArgument[bool]]:
|
||||
"""Construct filter mapping."""
|
||||
fields = self.model_dump(
|
||||
exclude_none=True, exclude_unset=True, exclude_defaults=True
|
||||
)
|
||||
|
||||
fieldmap: dict[str, InstrumentedAttribute[Any]] = {
|
||||
"subsystem": AuditLog.subsystem,
|
||||
"operation": AuditLog.operation,
|
||||
"client_id": AuditLog.client_id,
|
||||
"client_name": AuditLog.client_name,
|
||||
"secret_id": AuditLog.secret_id,
|
||||
"secret_name": AuditLog.secret_name,
|
||||
"origin": AuditLog.origin,
|
||||
}
|
||||
|
||||
return [
|
||||
column == value
|
||||
for key, value in fields.items()
|
||||
if (column := fieldmap.get(key)) is not None
|
||||
]
|
||||
|
||||
|
||||
def get_audit_api(get_db_session: DBSessionDep) -> APIRouter:
|
||||
"""Construct audit sub-api."""
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/audit/", response_model=list[AuditView])
|
||||
@router.get("/audit/", response_model=AuditListResult)
|
||||
async def get_audit_logs(
|
||||
request: Request,
|
||||
session: Annotated[Session, Depends(get_db_session)],
|
||||
offset: Annotated[int, Query()] = 0,
|
||||
limit: Annotated[int, Query(le=100)] = 100,
|
||||
filter_client: Annotated[str | None, Query()] = None,
|
||||
filter_subsystem: Annotated[str | None, Query()] = None,
|
||||
) -> Sequence[AuditView]:
|
||||
filters: Annotated[AuditFilter, Depends()],
|
||||
) -> AuditListResult:
|
||||
"""Get audit logs."""
|
||||
# audit.audit_access_audit_log(session, request)
|
||||
statement = select(AuditLog).offset(offset).limit(limit).order_by(AuditLog.timestamp.desc())
|
||||
if filter_client:
|
||||
statement = statement.where(AuditLog.client_name == filter_client)
|
||||
|
||||
if filter_subsystem:
|
||||
statement = statement.where(AuditLog.subsystem == filter_subsystem)
|
||||
total = session.scalars(
|
||||
select(func.count("*"))
|
||||
.select_from(AuditLog)
|
||||
.where(and_(True, *filters.filter_mapping))
|
||||
).one()
|
||||
|
||||
remaining = total - filters.offset
|
||||
statement = (
|
||||
select(AuditLog)
|
||||
.offset(filters.offset)
|
||||
.limit(filters.limit)
|
||||
.order_by(AuditLog.timestamp.desc())
|
||||
.where(and_(True, *filters.filter_mapping))
|
||||
)
|
||||
|
||||
LogAdapt = TypeAdapter(list[AuditView])
|
||||
results = session.scalars(statement).all()
|
||||
return LogAdapt.validate_python(results, from_attributes=True)
|
||||
|
||||
entries = LogAdapt.validate_python(results, from_attributes=True)
|
||||
return AuditListResult(
|
||||
results=entries,
|
||||
total=total,
|
||||
remaining=remaining,
|
||||
)
|
||||
|
||||
@router.post("/audit/")
|
||||
async def add_audit_log(
|
||||
@ -58,10 +108,13 @@ def get_audit_api(get_db_session: DBSessionDep) -> APIRouter:
|
||||
return AuditView.model_validate(audit_log, from_attributes=True)
|
||||
|
||||
@router.get("/audit/info")
|
||||
async def get_audit_info(request: Request, session: Annotated[Session, Depends(get_db_session)]) -> AuditInfo:
|
||||
async def get_audit_info(
|
||||
request: Request, session: Annotated[Session, Depends(get_db_session)]
|
||||
) -> AuditInfo:
|
||||
"""Get audit info."""
|
||||
audit_count = session.scalars(select(func.count('*')).select_from(AuditLog)).one()
|
||||
audit_count = session.scalars(
|
||||
select(func.count("*")).select_from(AuditLog)
|
||||
).one()
|
||||
return AuditInfo(entries=audit_count)
|
||||
|
||||
|
||||
return router
|
||||
|
||||
@ -172,7 +172,7 @@ def get_secrets_api(get_db_session: DBSessionDep) -> APIRouter:
|
||||
client_secret_map[client_secret.name] = []
|
||||
continue
|
||||
client_secret_map[client_secret.name].append(client_secret.client.name)
|
||||
audit.audit_client_secret_list(session, request)
|
||||
#audit.audit_client_secret_list(session, request)
|
||||
return [
|
||||
ClientSecretList(name=secret_name, clients=clients)
|
||||
for secret_name, clients in client_secret_map.items()
|
||||
@ -191,7 +191,7 @@ def get_secrets_api(get_db_session: DBSessionDep) -> APIRouter:
|
||||
if not client_secret.client:
|
||||
continue
|
||||
client_secrets[client_secret.name].clients.append(ClientReference(id=str(client_secret.client.id), name=client_secret.client.name))
|
||||
audit.audit_client_secret_list(session, request)
|
||||
#`audit.audit_client_secret_list(session, request)
|
||||
return list(client_secrets.values())
|
||||
|
||||
|
||||
|
||||
@ -6,12 +6,13 @@ from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
import click
|
||||
from sshecret_backend.auth import hash_token
|
||||
import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import create_api_token, get_engine, hash_token
|
||||
from .db import create_api_token, get_engine
|
||||
from .models import (
|
||||
APIClient,
|
||||
AuditLog,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Self, override
|
||||
from typing import Annotated, Self, Sequence, override
|
||||
|
||||
from pydantic import AfterValidator, BaseModel, Field, IPvAnyAddress, IPvAnyNetwork
|
||||
|
||||
@ -173,6 +173,8 @@ class AuditView(BaseModel):
|
||||
data: dict[str, str] | None = None
|
||||
client_id: uuid.UUID | None = None
|
||||
client_name: str | None = None
|
||||
secret_id: uuid.UUID | None = None
|
||||
secret_name: str | None = None
|
||||
origin: str | None = None
|
||||
timestamp: datetime | None = None
|
||||
|
||||
@ -181,3 +183,10 @@ class AuditInfo(BaseModel):
|
||||
"""Information about audit information."""
|
||||
|
||||
entries: int
|
||||
|
||||
|
||||
class AuditListResult(BaseModel):
|
||||
"""Class to return when listing audit entries."""
|
||||
results: Sequence[AuditView]
|
||||
total: int
|
||||
remaining: int
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
from .api import SshecretBackend
|
||||
from .models import (
|
||||
AuditLog,
|
||||
AuditListResult,
|
||||
AuditFilter,
|
||||
Client,
|
||||
ClientFilter,
|
||||
ClientReference,
|
||||
@ -17,6 +19,8 @@ from .models import (
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuditFilter",
|
||||
"AuditListResult",
|
||||
"AuditLog",
|
||||
"Client",
|
||||
"ClientFilter",
|
||||
|
||||
@ -11,7 +11,9 @@ import httpx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from .models import (
|
||||
AuditFilter,
|
||||
AuditInfo,
|
||||
AuditListResult,
|
||||
AuditLog,
|
||||
Client,
|
||||
ClientSecret,
|
||||
@ -248,28 +250,34 @@ class AuditAPI(BaseBackend):
|
||||
)
|
||||
await self.write_model_async(model)
|
||||
|
||||
async def get_detailed(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
**kwargs: str,
|
||||
) -> AuditListResult:
|
||||
"""Get a detailed response of audit entries."""
|
||||
path = f"/api/v1/audit/"
|
||||
filter_params = AuditFilter.model_validate(kwargs)
|
||||
params: dict[str, str] = {
|
||||
"offset": str(offset),
|
||||
"limit": str(limit),
|
||||
**filter_params.model_dump(exclude_none=True),
|
||||
}
|
||||
|
||||
response = await self._get(path, params=params)
|
||||
results = AuditListResult.model_validate(response.json())
|
||||
return results
|
||||
|
||||
async def get(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
client_name: str | None = None,
|
||||
subsystem: str | None = None,
|
||||
**kwargs: str,
|
||||
) -> list[AuditLog]:
|
||||
"""Get audit log."""
|
||||
path = f"/api/v1/audit/"
|
||||
params: dict[str, str] = {
|
||||
"offset": str(offset),
|
||||
"limit": str(limit),
|
||||
}
|
||||
if client_name:
|
||||
params["filter_client"] = client_name
|
||||
|
||||
if subsystem:
|
||||
params["filter_subsystem"] = subsystem
|
||||
|
||||
response = await self._get(path, params=params)
|
||||
audit_log_adapter = TypeAdapter(list[AuditLog])
|
||||
return audit_log_adapter.validate_python(response.json())
|
||||
details = await self.get_detailed(offset, limit, **kwargs)
|
||||
return details.results
|
||||
|
||||
async def count(self) -> int:
|
||||
"""Get amount of messages in the audit log."""
|
||||
|
||||
@ -141,3 +141,23 @@ class AuditInfo(BaseModel):
|
||||
"""Implementation of the backend class AuditInfo."""
|
||||
|
||||
entries: int
|
||||
|
||||
|
||||
class AuditFilter(BaseModel):
|
||||
"""Audit filters."""
|
||||
|
||||
subsystem: SubSystem | None = None
|
||||
operation: Operation | None = None
|
||||
client_id: str | None = None
|
||||
client_name: str | None = None
|
||||
secret_id: str | None = None
|
||||
secret_name: str | None = None
|
||||
origin: str | None = None
|
||||
|
||||
|
||||
class AuditListResult(BaseModel):
|
||||
"""Class to return when listing audit entries."""
|
||||
|
||||
results: list[AuditLog]
|
||||
total: int
|
||||
remaining: int
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""Tests of the backend api using pytest."""
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from httpx import Response
|
||||
@ -210,58 +211,17 @@ def test_audit_logging(test_client: TestClient) -> None:
|
||||
audit_log_resp = test_client.get("/api/v1/audit/")
|
||||
assert audit_log_resp.status_code == 200
|
||||
audit_logs = audit_log_resp.json()
|
||||
assert len(audit_logs) > 0
|
||||
for entry in audit_logs:
|
||||
assert isinstance(audit_logs, dict)
|
||||
audit_count = audit_logs.get("total")
|
||||
assert audit_count is not None
|
||||
assert audit_count > 0
|
||||
assert "results" in audit_logs
|
||||
|
||||
for entry in audit_logs["results"]:
|
||||
# Let's try to reassemble the objects
|
||||
audit_log = AuditView.model_validate(entry)
|
||||
assert audit_log is not None
|
||||
|
||||
|
||||
# def test_audit_log_filtering(
|
||||
# session: Session, test_client: TestClient
|
||||
# ) -> None:
|
||||
# """Test audit log filtering."""
|
||||
# # Create a lot of test data, but just manually.
|
||||
# audit_log_amount = 150
|
||||
# entries: list[AuditLog] = []
|
||||
# for i in range(audit_log_amount):
|
||||
# client_id = i % 5
|
||||
# entries.append(
|
||||
# AuditLog(
|
||||
# operation="TEST",
|
||||
# object_id=str(i),
|
||||
# client_name=f"client-{client_id}",
|
||||
# message="Test Message",
|
||||
# )
|
||||
# )
|
||||
|
||||
# session.add_all(entries)
|
||||
# session.commit()
|
||||
|
||||
# # This should have generated a lot of audit messages
|
||||
|
||||
# audit_path = "/api/v1/audit/"
|
||||
# audit_log_resp = test_client.get(audit_path)
|
||||
# assert audit_log_resp.status_code == 200
|
||||
# entries = audit_log_resp.json()
|
||||
# assert len(entries) == 100 # We get 100 at a time
|
||||
|
||||
# audit_log_resp = test_client.get(
|
||||
# audit_path, params={"offset": 100}
|
||||
# )
|
||||
# entries = audit_log_resp.json()
|
||||
# assert len(entries) == 52 # There should be 50 + the two requests we made
|
||||
|
||||
# # Try to get a specific client
|
||||
# # There should be 30 log entries for each client.
|
||||
# audit_log_resp = test_client.get(
|
||||
# audit_path, params={"filter_client": "client-1"}
|
||||
# )
|
||||
|
||||
# entries = audit_log_resp.json()
|
||||
# assert len(entries) == 30
|
||||
|
||||
|
||||
def test_secret_invalidation(test_client: TestClient) -> None:
|
||||
"""Test secret invalidation."""
|
||||
initial_key = make_test_key()
|
||||
@ -533,6 +493,69 @@ def test_write_audit_log(test_client: TestClient) -> None:
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
entry = data[0]
|
||||
entry = data["results"][0]
|
||||
for key, value in params.items():
|
||||
assert entry[key] == value
|
||||
|
||||
|
||||
def test_filter_audit_log(test_client: TestClient) -> None:
|
||||
"""Test filtering of audit logs."""
|
||||
# prepare some audit logs
|
||||
messages = [
|
||||
{
|
||||
"subsystem": "backend",
|
||||
"operation": "login",
|
||||
"client_id": str(uuid.uuid4()),
|
||||
"client_name": "foo",
|
||||
"origin": "192.0.2.1",
|
||||
"message": "message1",
|
||||
},
|
||||
{
|
||||
"subsystem": "backend",
|
||||
"operation": "create",
|
||||
"client_id": str(uuid.uuid4()),
|
||||
"client_name": "foo",
|
||||
"origin": "192.0.2.1",
|
||||
"secret_id": str(uuid.uuid4()),
|
||||
"message": "message2",
|
||||
},
|
||||
{
|
||||
"subsystem": "test",
|
||||
"operation": "deny",
|
||||
"client_id": str(uuid.uuid4()),
|
||||
"client_name": "bar",
|
||||
"origin": "192.0.2.2",
|
||||
"message": "message3",
|
||||
},
|
||||
]
|
||||
for message in messages:
|
||||
test_client.post("/api/v1/audit", json=message)
|
||||
|
||||
# find all client_name=foo entries
|
||||
resp = test_client.get("/api/v1/audit/", params={"client_name": "foo"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert data["total"] == 2
|
||||
assert len(data["results"]) == 2
|
||||
|
||||
# Get the one message from 'bar'
|
||||
resp = test_client.get("/api/v1/audit/", params={"client_name": "bar"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert data["total"] == 1
|
||||
assert len(data["results"]) == 1
|
||||
|
||||
assert data["results"][0]["operation"] == "deny"
|
||||
|
||||
# test combining fields to get the login event
|
||||
|
||||
resp = test_client.get("/api/v1/audit/", params={"client_name": "foo", "operation": "login"})
|
||||
data = resp.json()
|
||||
|
||||
assert data["total"] == 1
|
||||
|
||||
assert data["results"][0]["operation"] == "login"
|
||||
assert data["results"][0]["message"] == "message1"
|
||||
|
||||
Reference in New Issue
Block a user