diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/audit/entry.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/audit/entry.html.j2 index 96ce727..25aa9d5 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/audit/entry.html.j2 +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/audit/entry.html.j2 @@ -10,14 +10,29 @@ - {{ entry.subsystem }} - - -

-    {%- 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) -}}
+ {{ entry.subsystem }} + + {{ entry.operation }} + + {% if entry.client_id %} + + Client: {{ entry.client_name }} + + {% endif %} + {% if entry.secret_name %} + + Secret:{{ entry.secret_name }} + + {% endif %} + {% if entry.data %} + {% for key, value in entry.data.items() %} + + + {{ key }}:{{ value }} + + {% endfor %} +{% endif %} + {{ entry.origin }} + + diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/audit/inner.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/audit/inner.html.j2 index 9450a95..2c1848f 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/audit/inner.html.j2 +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/audit/inner.html.j2 @@ -3,48 +3,118 @@
- - +
+ - - - - + + - - - {% for entry in entries %} {% include 'audit/entry.html.j2' %} {% - endfor %} + + + {% for entry in entries | list %} + + + + + + + + + + + + + + + + {% endfor %}
+ + Timestamp - Subsystem + + + Subsystem + + + + - Object + + + Operation + + + + + Client + + Secret + Message + Origin
+ {{ entry.timestamp }} + + {{ entry.subsystem }} + + {{ entry.operation }} + + + {% if entry.client_name %} + {{ entry.client_name }} + {% endif %} + + {% if entry.secret_name %} + {{ entry.secret_name }} + {% endif %} + + {{ entry.message }} + + {{ entry.origin }} +
diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/audit/pagination.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/audit/pagination.html.j2 index 79a9a58..40e77dd 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/audit/pagination.html.j2 +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/audit/pagination.html.j2 @@ -6,7 +6,11 @@ Showing + {% if page_info.total < page_info.last %} + {{page_info.first }}-{{ page_info.total}} of + {% else %} {{page_info.first }}-{{ page_info.last}} of + {% endif %} {{ page_info.total }}
-

Clients

- {{ stats.clients }} +

Stats

+
+
+
Clients
+
{{ stats.clients }}
+
+
+
Secrets
+
{{ stats.secrets }}
+
+
+
Audit Events
+
{{ stats.audit_events }}
+
+
+
+ + +
+
+
+
+
+

Last Login Events

+ {% if last_login_events.total > 0 %} + + + + + + + + + + + {% for entry in last_login_events.results | list %} + + + + + + + + + + {% endfor %} + +
TimestampSubsystemClient/UsernameOrigin
+ {{ entry.timestamp }} + + {{ entry.subsystem }} + + {% if entry.client_name %} + {{ entry.client_name }} + {% elif entry.data.username %} + {{ entry.data.username }} + {% endif %} + + + {{ entry.origin }} +
+ {% else %} +

No entries

+ {% endif %} +
+
+
+
+

Last Audit Events

+ {% if last_audit_events.total > 0 %} + + + + + + + + + + + {% for entry in last_audit_events.results | list %} + + + + + + + + + + {% endfor %} + +
TimestampSubsystemMessageOrigin
+ {{ entry.timestamp }} + + {{ entry.subsystem }} + + {{ entry.message }} + + {{ entry.origin }} +
+ {% else %} +

No entries

+ {% endif %}
- - -
-
-
-

New products

- 2,340 -

- - - 12.5% - - Since last month -

-
-
-
-
-
-

Users

- 2,340 -

- - - 3,4% - - Since last month -

-
-
-
-
-
-

Audience by age

-
-
50+
-
-
-
-
-
-
40+
-
-
-
-
-
-
30+
-
-
-
-
-
-
20+
-
-
-
-
-
-
-
-
- - - - +{% include '/clients/drawer_client_create.html.j2' %} +{% include '/secrets/drawer_secret_create.html.j2' %} {% endblock %} diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/views/audit.py b/packages/sshecret-admin/src/sshecret_admin/frontend/views/audit.py index 0efcfae..844b42a 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/views/audit.py +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/views/audit.py @@ -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 diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/views/auth.py b/packages/sshecret-admin/src/sshecret_admin/frontend/views/auth.py index fbf896a..ccb5221 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/views/auth.py +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/views/auth.py @@ -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") diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/views/index.py b/packages/sshecret-admin/src/sshecret_admin/frontend/views/index.py index 8404233..661e538 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/views/index.py +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/views/index.py @@ -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, + }, ) diff --git a/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py b/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py index f320b24..5adbd11 100644 --- a/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py +++ b/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py @@ -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.""" diff --git a/packages/sshecret-admin/src/sshecret_admin/static/css/main.css b/packages/sshecret-admin/src/sshecret_admin/static/css/main.css index 37c870a..fc00079 100644 --- a/packages/sshecret-admin/src/sshecret_admin/static/css/main.css +++ b/packages/sshecret-admin/src/sshecret_admin/static/css/main.css @@ -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 *) { diff --git a/packages/sshecret-admin/src/sshecret_admin/static/css/prism.css b/packages/sshecret-admin/src/sshecret_admin/static/css/prism.css index 1d703b3..eef30bd 100644 --- a/packages/sshecret-admin/src/sshecret_admin/static/css/prism.css +++ b/packages/sshecret-admin/src/sshecret_admin/static/css/prism.css @@ -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; +} + diff --git a/packages/sshecret-admin/src/sshecret_admin/static/js/prism.js b/packages/sshecret-admin/src/sshecret_admin/static/js/prism.js index 0b047a0..405b70c 100644 --- a/packages/sshecret-admin/src/sshecret_admin/static/js/prism.js +++ b/packages/sshecret-admin/src/sshecret_admin/static/js/prism.js @@ -1,6 +1,7 @@ /* PrismJS 1.30.0 -https://prismjs.com/download.html#themes=prism&languages=markup+css+json */ +https://prismjs.com/download.html#themes=prism-coy&languages=markup+css+clike+javascript */ var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=g.reach);A+=w.value.length,w=w.next){var P=w.value;if(n.length>e.length)return;if(!(P instanceof i)){var E,S=1;if(y){if(!(E=l(b,A,e,m))||E.index>=e.length)break;var L=E.index,O=E.index+E[0].length,C=A;for(C+=w.value.length;L>=C;)C+=(w=w.next).value.length;if(A=C-=w.value.length,w.value instanceof i)continue;for(var j=w;j!==n.tail&&(Cg.reach&&(g.reach=W);var I=w.prev;if(_&&(I=u(n,I,_),A+=_.length),c(n,I,S),w=u(n,I,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),S>1){var T={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,T),g&&T.reach>g.reach&&(g.reach=T.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a"+i.content+""},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); Prism.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern://i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^$/i;var t={"included-cdata":{pattern://i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:))*\\]\\]>|(?!)".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml; !function(s){var e=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/;s.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:RegExp("@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|"+e.source+")*?(?:;|(?=\\s*\\{))"),inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+e.source+"|(?:[^\\\\\r\n()\"']|\\\\[^])*)\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+e.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+e.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:e,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},s.languages.css.atrule.inside.rest=s.languages.css;var t=s.languages.markup;t&&(t.tag.addInlined("style","css"),t.tag.addAttribute("style","css"))}(Prism); -Prism.languages.json={property:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?=\s*:)/,lookbehind:!0,greedy:!0},string:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?!\s*:)/,lookbehind:!0,greedy:!0},comment:{pattern:/\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},number:/-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i,punctuation:/[{}[\],]/,operator:/:/,boolean:/\b(?:false|true)\b/,null:{pattern:/\bnull\b/,alias:"keyword"}},Prism.languages.webmanifest=Prism.languages.json; +Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; +Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp("(^|[^\\w$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w$])"),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:RegExp("((?:^|[^$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)/(?:(?:\\[(?:[^\\]\\\\\r\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|/\\*(?:[^*]|\\*(?!/))*\\*/)*(?:$|[\r\n,.;:})\\]]|//))"),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:Prism.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),Prism.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),Prism.languages.markup&&(Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.markup.tag.addAttribute("on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)","javascript")),Prism.languages.js=Prism.languages.javascript; diff --git a/packages/sshecret-admin/src/sshecret_admin/testing.py b/packages/sshecret-admin/src/sshecret_admin/testing.py deleted file mode 100644 index 254dc96..0000000 --- a/packages/sshecret-admin/src/sshecret_admin/testing.py +++ /dev/null @@ -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 - diff --git a/packages/sshecret-backend/src/sshecret_backend/api/audit.py b/packages/sshecret-backend/src/sshecret_backend/api/audit.py index 00eabd4..6356adb 100644 --- a/packages/sshecret-backend/src/sshecret_backend/api/audit.py +++ b/packages/sshecret-backend/src/sshecret_backend/api/audit.py @@ -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 diff --git a/packages/sshecret-backend/src/sshecret_backend/api/secrets.py b/packages/sshecret-backend/src/sshecret_backend/api/secrets.py index 0808db4..096d594 100644 --- a/packages/sshecret-backend/src/sshecret_backend/api/secrets.py +++ b/packages/sshecret-backend/src/sshecret_backend/api/secrets.py @@ -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()) diff --git a/packages/sshecret-backend/src/sshecret_backend/cli.py b/packages/sshecret-backend/src/sshecret_backend/cli.py index 1eda37f..705389d 100644 --- a/packages/sshecret-backend/src/sshecret_backend/cli.py +++ b/packages/sshecret-backend/src/sshecret_backend/cli.py @@ -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, diff --git a/packages/sshecret-backend/src/sshecret_backend/view_models.py b/packages/sshecret-backend/src/sshecret_backend/view_models.py index cc92384..395bb94 100644 --- a/packages/sshecret-backend/src/sshecret_backend/view_models.py +++ b/packages/sshecret-backend/src/sshecret_backend/view_models.py @@ -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 diff --git a/src/sshecret/backend/__init__.py b/src/sshecret/backend/__init__.py index 619e3d1..28fd606 100644 --- a/src/sshecret/backend/__init__.py +++ b/src/sshecret/backend/__init__.py @@ -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", diff --git a/src/sshecret/backend/api.py b/src/sshecret/backend/api.py index 7125c48..ddec637 100644 --- a/src/sshecret/backend/api.py +++ b/src/sshecret/backend/api.py @@ -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.""" diff --git a/src/sshecret/backend/models.py b/src/sshecret/backend/models.py index 423f8fd..89a7172 100644 --- a/src/sshecret/backend/models.py +++ b/src/sshecret/backend/models.py @@ -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 diff --git a/tests/packages/backend/test_backend.py b/tests/packages/backend/test_backend.py index fc6031c..a6576c1 100644 --- a/tests/packages/backend/test_backend.py +++ b/tests/packages/backend/test_backend.py @@ -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"