Update audit logging and dashboard

This commit is contained in:
2025-05-13 21:54:40 +02:00
parent 60026a485d
commit 3055f5277b
20 changed files with 788 additions and 285 deletions

View File

@ -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>

View File

@ -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"
>
{% for entry in entries %} {% include 'audit/entry.html.j2' %} {%
endfor %}
<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 }}"
>
<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>

View File

@ -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

View File

@ -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>
<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">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>
<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">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>
</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="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>
</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="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>
</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 %}

View File

@ -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

View File

@ -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")

View File

@ -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,
},
)

View File

@ -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,
@ -423,7 +461,7 @@ class AdminBackend:
entry.subsystem = SubSystem.ADMIN
await self.audit.write_model_async(entry)
#await self.backend.add_audit_log(entry)
# await self.backend.add_audit_log(entry)
async def get_audit_log_count(self) -> int:
"""Get audit log count."""

View File

@ -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 *) {

View File

@ -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

View File

@ -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

View File

@ -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)
# audit.audit_access_audit_log(session, request)
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

View File

@ -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())

View File

@ -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,

View File

@ -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