Compare commits

...

5 Commits

32 changed files with 2474 additions and 635 deletions

View File

@ -3,13 +3,16 @@
# pyright: reportUnusedFunction=false
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sshecret.backend.models import Secret
from sshecret_admin.core.dependencies import AdminDependencies
from sshecret_admin.services import AdminBackend
from sshecret_admin.services.models import (
ClientSecretGroup,
ClientSecretGroupList,
SecretCreate,
SecretGroupCreate,
SecretUpdate,
SecretView,
)
@ -19,7 +22,7 @@ LOG = logging.getLogger(__name__)
def create_router(dependencies: AdminDependencies) -> APIRouter:
"""Create secrets router."""
app = APIRouter(dependencies=[Depends(dependencies.get_current_active_user)])
app = APIRouter(dependencies=[Security(dependencies.get_current_active_user)])
@app.get("/secrets/")
async def get_secret_names(
@ -34,7 +37,12 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""Create a secret."""
await admin.add_secret(secret.name, secret.get_secret(), secret.clients)
await admin.add_secret(
name=secret.name,
value=secret.get_secret(),
clients=secret.clients,
group=secret.group,
)
@app.get("/secrets/{name}")
async def get_secret(
@ -67,4 +75,133 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
"""Delete secret."""
await admin.delete_secret(name)
@app.get("/secrets/groups/")
async def get_secret_groups(
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
filter_regex: Annotated[str | None, Query()] = None,
) -> ClientSecretGroupList:
"""Get secret groups."""
return await admin.get_secret_groups(filter_regex)
@app.get("/secrets/groups/{group_name}/")
async def get_secret_group(
group_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> ClientSecretGroup:
"""Get a specific secret group."""
results = await admin.get_secret_groups(group_name, False)
if not results:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such group."
)
return results[0]
@app.post("/secrets/groups/")
async def add_secret_group(
group: SecretGroupCreate,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> ClientSecretGroup:
"""Create a secret grouping."""
await admin.add_secret_group(
group_name=group.name,
description=group.description,
parent_group=group.parent_group,
)
result = await admin.get_secret_group(group.name)
if not result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Group creation failed"
)
return result
@app.delete("/secrets/groups/{group_name}/")
async def delete_secret_group(
group_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""Remove a group.
Entries within the group will be moved to the root.
This also includes nested entries further down from the group.
"""
group = await admin.get_secret_group(group.name)
if not group:
return
await admin.delete_secret_group(group_name, keep_entries=True)
@app.post("/secrets/groups/{group_name}/{secret_name}")
async def move_secret_to_group(
group_name: str,
secret_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""Move a secret to a group."""
groups = await admin.get_secret_groups(group_name, False)
if not groups:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such group."
)
await admin.set_secret_group(secret_name, group_name)
@app.post("/secrets/group/{group_name}/parent/{parent_name}")
async def move_group(
group_name: str,
parent_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""Move a group."""
group = await admin.get_secret_group(group_name)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No such group {group_name}",
)
parent_group = await admin.get_secret_group(parent_name)
if not parent_group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No such group {parent_name}",
)
await admin.move_secret_group(group_name, parent_name)
@app.delete("/secrets/group/{group_name}/parent/")
async def move_group_to_root(
group_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""Move a group to the root."""
group = await admin.get_secret_group(group_name)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No such group {group_name}",
)
await admin.move_secret_group(group_name, None)
@app.delete("/secrets/groups/{group_name}/{secret_name}")
async def remove_secret_from_group(
group_name: str,
secret_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""Remove a secret from a group.
Secret will be moved to the root group.
"""
groups = await admin.get_secret_groups(group_name, False)
if not groups:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such group."
)
group = groups[0]
matching_entries = [
entry for entry in group.entries if entry.name == secret_name
]
if not matching_entries:
return
await admin.set_secret_group(secret_name, None)
return app

View File

@ -26,7 +26,7 @@ API_VERSION = "v1"
def create_router(dependencies: BaseDependencies) -> APIRouter:
"""Create clients router."""
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token")
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],

View File

@ -2,126 +2,259 @@
<div class="px-4 pt-6">
<div class="py-8 px-4 mt-4 mx-auto max-w-screen-xl text-center lg:py-16">
<h1 class="mb-4 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">Welcome to Sshecret</h1>
<h1
class="mb-4 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white"
>
Welcome to Sshecret
</h1>
</div>
<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" id="dashboard-stats-panel">
<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"
id="dashboard-stats-panel"
>
<div class="w-full">
<h3 class="text-base 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">
<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 text-xs dark:text-gray-400">Clients</dt>
<dd class="text-lg font-semibold" id="stats-client-count">{{ stats.clients }}</dd>
<dt class="mb-1 text-gray-500 text-xs dark:text-gray-400">
Clients
</dt>
<dd class="text-lg font-semibold" id="stats-client-count">
{{ stats.clients }}
</dd>
</div>
<div class="flex flex-col py-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secrets</dt>
<dd class="text-lg font-semibold" id="stats-secret-count">{{ stats.secrets }}</dd>
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">
Secrets
</dt>
<dd class="text-lg font-semibold" id="stats-secret-count">
{{ stats.secrets }}
</dd>
</div>
<div class="flex flex-col py-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Audit Events</dt>
<dd class="text-lg font-semibold" id="stats-audit-count">{{ stats.audit_events }}</dd>
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">
Audit Events
</dt>
<dd class="text-lg font-semibold" id="stats-audit-count">
{{ stats.audit_events }}
</dd>
</div>
</dl>
</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="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>
<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" id="last-login-events">
<table
class="min-w-full divide-y divide-gray-200 dark:divide-gray-600"
id="last-login-events"
>
<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>
<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">
<p>{{ entry.timestamp }}<button data-popover-target="popover-login-entry-{{ entry.id }}" data-popover-placement="bottom-end" type="button" id="btn-popover-login-entry-{{ entry.id }}"><svg class="w-4 h-4 ms-2 text-gray-400 hover:text-gray-500" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"></path>
</svg><span class="sr-only">Show information</span></button>
<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"
>
<p>
{{ entry.timestamp }}<button
data-popover-target="popover-login-entry-{{ entry.id }}"
data-popover-placement="bottom-end"
type="button"
id="btn-popover-login-entry-{{ entry.id }}"
>
<svg
class="w-4 h-4 ms-2 text-gray-400 hover:text-gray-500"
aria-hidden="true"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
></path></svg
><span class="sr-only">Show information</span>
</button>
</p>
<div data-popover id="popover-login-entry-{{entry.id}}" role="tooltip" class="absolute z-10 invisible inline-block text-sm text-gray-500 transition-opacity duration-300 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 w-80 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 popover-login-entry">
<dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2">
<div
data-popover
id="popover-login-entry-{{entry.id}}"
role="tooltip"
class="absolute z-10 invisible inline-block text-sm text-gray-500 transition-opacity duration-300 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 w-80 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 popover-login-entry"
>
<dl
class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2"
>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">ID</dt>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
ID
</dt>
<dd class="text-xs font-semibold">{{ entry.id }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Subsystem</dt>
<dd class="text-xs font-semibold">{{ entry.subsystem }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Subsystem
</dt>
<dd class="text-xs font-semibold">
{{ entry.subsystem }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Timestamp</dt>
<dd class="text-xs font-semibold">{{ entry.timestamp }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Timestamp
</dt>
<dd class="text-xs font-semibold">
{{ entry.timestamp }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Operation</dt>
<dd class="text-xs font-semibold">{{ entry.operation }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Operation
</dt>
<dd class="text-xs font-semibold">
{{ entry.operation }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client ID</dt>
<dd class="text-xs font-semibold">{{ entry.client_id }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Client ID
</dt>
<dd class="text-xs font-semibold">
{{ entry.client_id }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client Name</dt>
<dd class="text-xs font-semibold">{{ entry.client_name }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Client Name
</dt>
<dd class="text-xs font-semibold">
{{ entry.client_name }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret ID</dt>
<dd class="text-xs font-semibold">{{ entry.secret_id }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Secret ID
</dt>
<dd class="text-xs font-semibold">
{{ entry.secret_id }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret Name</dt>
<dd class="text-xs font-semibold">{{ entry.secret_name }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Secret Name
</dt>
<dd class="text-xs font-semibold">
{{ entry.secret_name }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Message</dt>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Message
</dt>
<dd class="text-xs font-semibold">{{ entry.message }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Origin</dt>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Origin
</dt>
<dd class="text-xs font-semibold">{{ entry.origin }}</dd>
</div>
{% if entry.data %}
{% for key, value in entry.data.items() %}
{% if entry.data %} {% for key, value in entry.data.items()
%}
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">{{ key | capitalize }}</dt>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
{{ key | capitalize }}
</dt>
<dd class="text-xs font-semibold">{{ value }}</dd>
</div>
{% endfor %}
{% endif %}
{% endfor %} {% endif %}
</dl>
</div>
</td>
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
<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
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">
<td
class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white"
>
{{ entry.origin }}
</td>
</tr>
@ -133,96 +266,211 @@
{% 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="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>
<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" id="last-audit-events">
<table
class="min-w-full divide-y divide-gray-200 dark:divide-gray-600"
id="last-audit-events"
>
<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>
<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">
<p>{{ entry.timestamp }}<button data-popover-target="popover-audit-entry-{{ entry.id }}" data-popover-placement="bottom-end" type="button"><svg class="w-4 h-4 ms-2 text-gray-400 hover:text-gray-500" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"></path>
</svg><span class="sr-only">Show information</span></button></p>
<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"
>
<p>
{{ entry.timestamp }}<button
data-popover-target="popover-audit-entry-{{ entry.id }}"
data-popover-placement="bottom-end"
type="button"
>
<svg
class="w-4 h-4 ms-2 text-gray-400 hover:text-gray-500"
aria-hidden="true"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
></path></svg
><span class="sr-only">Show information</span>
</button>
</p>
<div data-popover id="popover-audit-entry-{{entry.id}}" role="tooltip" class="absolute z-10 invisible inline-block text-sm text-gray-500 transition-opacity duration-300 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 w-80 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
<dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2">
<div
data-popover
id="popover-audit-entry-{{entry.id}}"
role="tooltip"
class="absolute z-10 invisible inline-block text-sm text-gray-500 transition-opacity duration-300 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 w-80 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400"
>
<dl
class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2"
>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">ID</dt>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
ID
</dt>
<dd class="text-xs font-semibold">{{ entry.id }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Subsystem</dt>
<dd class="text-xs font-semibold">{{ entry.subsystem }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Subsystem
</dt>
<dd class="text-xs font-semibold">
{{ entry.subsystem }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Timestamp</dt>
<dd class="text-xs font-semibold">{{ entry.timestamp }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Timestamp
</dt>
<dd class="text-xs font-semibold">
{{ entry.timestamp }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Operation</dt>
<dd class="text-xs font-semibold">{{ entry.operation }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Operation
</dt>
<dd class="text-xs font-semibold">
{{ entry.operation }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client ID</dt>
<dd class="text-xs font-semibold">{{ entry.client_id }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Client ID
</dt>
<dd class="text-xs font-semibold">
{{ entry.client_id }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client Name</dt>
<dd class="text-xs font-semibold">{{ entry.client_name }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Client Name
</dt>
<dd class="text-xs font-semibold">
{{ entry.client_name }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret ID</dt>
<dd class="text-xs font-semibold">{{ entry.secret_id }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Secret ID
</dt>
<dd class="text-xs font-semibold">
{{ entry.secret_id }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret Name</dt>
<dd class="text-xs font-semibold">{{ entry.secret_name }}</dd>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Secret Name
</dt>
<dd class="text-xs font-semibold">
{{ entry.secret_name }}
</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Message</dt>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Message
</dt>
<dd class="text-xs font-semibold">{{ entry.message }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Origin</dt>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
Origin
</dt>
<dd class="text-xs font-semibold">{{ entry.origin }}</dd>
</div>
{% if entry.data %}
{% for key, value in entry.data.items() %}
{% if entry.data %} {% for key, value in entry.data.items()
%}
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">{{ key | capitalize }}</dt>
<dt
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
>
{{ key | capitalize }}
</dt>
<dd class="text-xs font-semibold">{{ value }}</dd>
</div>
{% endfor %}
{% endif %}
{% endfor %} {% endif %}
</dl>
</div>
</td>
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
<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">
<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">
<td
class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white"
>
{{ entry.origin }}
</td>
</tr>
@ -235,8 +483,6 @@
</div>
</div>
</div>
{% include '/dashboard/drawer_client_create_dashboard.html.j2' %}
{% include '/dashboard/drawer_secret_create_dashboard.html.j2' %}
</div>
{% endblock %}

View File

@ -3,6 +3,7 @@
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@9.0.3"></script>
<script src="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/shoelace-autoloader.js"></script>
<script type="text/javascript" src="{{ url_for('static', path="js/prism.js") }}"></script>
<script>

View File

@ -14,8 +14,24 @@
href="{{ url_for('static', path='css/prism.css') }}"
type="text/css"
/>
<link
rel="stylesheet"
href="{{ url_for('static', path='css/style.css') }}"
type="text/css"
/>
<link
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
rel="stylesheet"
/>
<link
rel="stylesheet"
media="(prefers-color-scheme:light)"
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/themes/light.css"
/>
<link
rel="stylesheet"
media="(prefers-color-scheme:dark)"
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/themes/dark.css"
onload="document.documentElement.classList.add('sl-theme-dark');"
/>

View File

@ -61,23 +61,7 @@
data-dropdown-toggle="dropdown-2"
>
<span class="sr-only">Open user menu</span>
<svg
class="w-6 h-6 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0 0a8.949 8.949 0 0 0 4.951-1.488A3.987 3.987 0 0 0 13 16h-2a3.987 3.987 0 0 0-3.951 3.512A8.948 8.948 0 0 0 12 21Zm3-11a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
<sl-avatar label="User avatar"></sl-avatar>
</button>
</div>
<!-- Dropdown menu -->

View File

@ -1,3 +0,0 @@
{% for client in clients %}
<option value="{{ client.id }}">{{ client.name }}</option>
{% endfor %}

View File

@ -1,38 +0,0 @@
<div
id="drawer-create-secret-default"
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
tabindex="-1"
aria-labelledby="drawer-label"
aria-hidden="true"
>
<h5
id="drawer-label"
class="inline-flex items-center mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
>
New Secret
</h5>
<button
type="button"
data-drawer-dismiss="drawer-create-secret-default"
aria-controls="drawer-create-secret-default"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg
aria-hidden="true"
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
<span class="sr-only">Close menu</span>
</button>
<form hx-post="/secrets/" hx-target="#secretsContent">
{% include '/secrets/drawer_secret_create_inner.html.j2' %}
</form>
</div>

View File

@ -1,4 +1,36 @@
{% macro display_entry(entry) %}
<sl-tree-item
id="entry_{{ entry.name }}"
class="tree-entry-item"
data-type="entry"
data-name="{{ entry.name }}"
>
<sl-icon name="shield"> </sl-icon>
<span class="px-2">{{ entry.name }}</span>
</sl-tree-item>
{% endmacro %}
{% macro display_group(group) %}
<sl-tree-item
class="secret-group-list-item"
data-type="group"
data-name="{{ group.group_name }}"
>
<sl-icon name="folder"> </sl-icon>
<span class="px-2">{{ group.group_name }}</span>
{% for entry in group.entries %}
{{ display_entry(entry) }}
{% endfor %}
{% for child in group.children %}
{{ display_group(child) }}
{% endfor %}
</sl-tree-item>
{% endmacro %}
{% extends "/dashboard/_base.html" %} {% block content %}
<div class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700">
<div class="w-full mb-1">
<div class="mb-4">
@ -14,7 +46,6 @@
<div class="flex items-center">
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
<span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Secrets</span>
</svg>
</div>
</li>
</ol>
@ -22,24 +53,73 @@
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Secrets</h1>
</div>
<div class="items-center justify-between block sm:flex">
<div class="flex items-center mb-4 sm:mb-0">
<label for="secret-search" class="sr-only">Search</label>
<div class="relative w-48 mt-1 sm:w-64 xl:w-96">
<input type="search" name="query" id="secret-search" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" placeholder="Search for secrets" hx-post="/secrets/query" hx-trigger="keyup changed delay:500ms, query" hx-target="#secretsContent">
<div class="grid w-full grid-cols-1 gap-4 mt-4 xl:grid-cols-3">
<div class="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" id="secret-tree">
<div class="flex flex-col">
<div class="h-full">
<sl-tree class="tree-with-icons">
<sl-tree-item
id="secret-group-root-item"
data-type="root"
data-name="root"
expanded=""
>
<sl-icon name="folder"> </sl-icon>
<span class="px-2">Ungrouped</span>
{% for entry in groups.ungrouped %}
{{ display_entry(entry) }}
{% endfor %}
</sl-tree-item>
{% for child in groups.groups %}
{{ display_group(child) }}
{% endfor %}
</sl-tree>
</div>
</div>
<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-5 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-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 class="2xl:col-span-2 xl:col-span-2 p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
{% include '/secrets/partials/default_detail.html.j2' %}
</div>
<div id="secretsContent">
{% include '/secrets/inner.html.j2' %}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tree = document.querySelector('sl-tree');
{% include '/secrets/drawer_secret_create.html.j2' %}
if (!tree) return;
tree.addEventListener('sl-selection-change', (event) => {
const selectedEl = event.detail.selection[0];
if (!selectedEl) return;
const type = selectedEl.dataset.type;
const name = selectedEl.dataset.name;
console.log(`Event on ${type} ${name}`);
if (!type || !name) return;
let url = '';
if (type === 'entry') {
url = `/secrets/partial/secret/${encodeURIComponent(name)}`;
} else if (type === 'group') {
url = `/secrets/partial/group/${encodeURIComponent(name)}`;
} else if (type == 'root') {
url = `/secrets/partial/root_group`;
}
if (url) {
htmx.ajax('GET', url, {
target: '#secretdetails',
swap: 'OuterHTML',
indicator: '.secret-spinner'
});
}
});
});
</script>
{% endblock %}

View File

@ -1,36 +0,0 @@
<div>
<div class="flex flex-col">
<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">
<tr>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Name
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Clients associated
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
{% for secret in secrets %}
{% include '/secrets/secret.html.j2'%}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% for secret in secrets %}
{% include '/secrets/modal_client_secret.html.j2' %}
{% endfor %}

View File

@ -1,122 +0,0 @@
<div
id="client-secret-modal-{{ secret.name }}"
tabindex="-1"
aria-hidden="true"
class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full"
>
<div class="relative p-4 w-full max-w-md max-h-full">
<div class="relative bg-white rounded-lg shadow-sm dark:bg-gray-700">
<div
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600 border-gray-200"
>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Edit Client Access
</h3>
<button
type="button"
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
data-modal-hide="client-secret-modal-{{ secret.name }}"
>
<svg
class="w-3 h-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">Close modal</span>
</button>
</div>
<div class="p-4 md:p-5">
{% if secret.clients %}
<div class="space-y-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Existing clients with access
</h3>
{% for client in secret.clients %}
<span
class="inline-flex items-center px-2 py-1 me-2 text-sm font-medium text-red-800 bg-red-100 rounded-sm dark:bg-red-900 dark:text-red-300 pill-client-secret"
id="client-secret-{{ secret.name }}-pill-{{ client.name }}"
>{{ client.name }}
<button
type="button"
class="inline-flex items-center p-1 ms-2 text-sm text-gray-400 bg-transparent rounded-xs hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-gray-300"
aria-label="Remove"
hx-delete="/secrets/{{ secret.name }}/clients/{{ client.id }}"
hx-target="#secretsContent"
hx-confirm="Remove client {{ client.name }} from secret {{secret.name}}?"
id="btn-remove-client-{{ client.name }}-secret-{{ secret.name }}"
>
<svg
class="w-2 h-2"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">Remove badge</span>
</button>
</span>
{% endfor %}
</div>
{% endif %}
<form
class="space-y-4"
hx-post="/secrets/{{ secret.name }}/clients/"
hx-target="#secretsContent"
>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Give client access
</h3>
<label
for="client"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Client
</label>
<select
name="client"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
id="sel-add-client-secret-{{ secret.name }}"
>
<option selected="selected">
Select clients to assign the secret to
</option>
{% for client in clients %}
{% if client.id|string not in secret.clients|map(attribute='id')|list %}
<option value="{{ client.id }}">{{ client.name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Give Access
</button>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,22 @@
<form
hx-post="/secrets/{{secret.name}}/clients/"
hx-target="#secretclientdetails"
>
<div class="mb-6">
<label for="client" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client</label>
</div>
<div class="mb-6">
<sl-select label="Select client" name="client">
{% for client in clients %}
<sl-option value="{{ client.id }}">{{ client.name }}</sl-option>
{% endfor %}
</sl-select>
</div>
<div class="mb-6">
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Add Client to Secret
</button>
</div>

View File

@ -0,0 +1,7 @@
<button
type="button"
class="text-gray-900 hover:text-blue-700 border border-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-500 dark:focus:ring-blue-800"
hx-get="/secrets/{{secret.name}}/clients/"
hx-target="#secretclientaction"
>Assign to new client
</button>

View File

@ -0,0 +1,19 @@
{% for client in secret.clients %}
<li class="w-full px-4 py-2">
<span class="inline-flex items-center px-2 py-1 me-2 text-sm font-medium text-blue-800 bg-blue-100 rounded-sm dark:bg-blue-900 dark:text-blue-300">
{{ client }}
<button
type="button"
class="inline-flex items-center p-1 ms-2 text-sm text-blue-400 bg-transparent rounded-xs hover:bg-blue-200 hover:text-blue-900 dark:hover:bg-blue-800 dark:hover:text-blue-300"
hx-delete="/secrets/{{ secret.name }}/clients/{{ client }}"
hx-target="#secretclientlist"
hx-confirm="Remove client {{ client }} from secret?"
aria-label="Remove">
<svg class="w-2 h-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Remove client</span>
</button>
</span>
</li>
{% endfor %}

View File

@ -0,0 +1,8 @@
<div class="w-full my-2">
<ul class="w-48 text-sm font-medium text-gray-900 bg-white dark:bg-gray-700 dark:text-white" id="secretclientlist">
{% include '/secrets/partials/client_list_inner.html.j2' %}
</ul>
</div>
<div class="w-full my-2" id="secretclientaction">
{% include '/secrets/partials/client_assign_button.html.j2' %}
</div>

View File

@ -83,7 +83,6 @@
</div>
<div
class="bottom-0 left-0 flex justify-center w-full pb-4 space-x-4 md:px-4 md:absolute"
>
<button
type="submit"
@ -91,28 +90,5 @@
>
Add Secret
</button>
<button
type="button"
data-drawer-dismiss="drawer-create-secret-default"
aria-controls="drawer-create-secret-default"
class="inline-flex w-full justify-center text-gray-500 items-center bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-primary-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
>
<svg
aria-hidden="true"
class="w-5 h-5 -ml-1 sm:mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
Cancel
</button>
</div>
</div>

View File

@ -0,0 +1,6 @@
<div class="w-full" id="secretdetails">
<h3 class="mb-4 text-sm italic text-gray-400 dark:text-white">Click an item to view details</h3>
<div class="htmx-indicator secret-spinner">
{% include '/secrets/partials/skeleton.html.j2' %}
</div>
</div>

View File

@ -0,0 +1,53 @@
<div class="w-full">
<sl-details summary="Create secret">
<form
hx-post="/secrets/create/root"
hx-target="#secretdetails"
hx-swap="OuterHTML"
>
{% include '/secrets/partials/create_secret.html.j2' %}
</form>
</sl-details>
<sl-details summary="Create group">
<form
hx-post="/secrets/group/"
hx-target="#secretdetails"
hx-swap="OuterHTML"
hx-indicator=".secret-spinner"
>
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
</div>
<div class="mb-6">
<input
type="text"
name="name"
id="name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Group name"
required=""
/>
</div>
<div class="mb-6">
<label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
</div>
<div class="mb-6">
<input
type="text"
name="description"
id="description"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Description"
/>
</div>
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Add Group
</button>
</form>
</sl-details>
</div>

View File

@ -0,0 +1,127 @@
<div class="w-full">
<div class="mb-4">
<h3 class="text-xl font-semibold dark:text-white">Group {{name}}</h3>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ description }}</span>
</div>
<sl-details summary="Create secret">
<form
hx-post="/secrets/create/group/{{ name }}"
hx-target="#secretdetails"
hx-swap="OuterHTML"
>
{% include '/secrets/partials/create_secret.html.j2' %}
</form>
</sl-details>
<sl-details summary="Create nested group">
<form
hx-post="/secrets/group/"
hx-target="#secretdetails"
hx-swap="OuterHTML"
hx-indicator=".secret-spinner"
>
<div class="mb-6">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
</div>
<div class="mb-6">
<input
type="text"
name="name"
id="name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Group name"
required=""
/>
</div>
<div class="mb-6">
<label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
</div>
<div class="mb-6">
<input
type="text"
name="description"
id="description"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Description"
/>
</div>
<input type="hidden" name="parent_group" value="{{ name }}" />
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Add Group
</button>
</form>
</sl-details>
<sl-details summary="Edit group">
<form
hx-put="/secrets/partial/group/{{name}}/description"
hx-target="#secretdetails"
hx-swap="OuterHTML"
>
<div class="mb-6">
<label
for="description"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Description</label
>
</div>
<div class="flex w-full">
<div class="relative w-full">
<input
type="text"
name="description"
id="description"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
value="{{ description }}"
required=""
/>
</div>
<div class="px-2.5 mb-2">
<button type="Submit" class="text-gray-900 hover:text-blue-700 border border-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-500 dark:focus:ring-blue-800">Update</button>
</div>
</div>
</form>
<div class="mt-5">
<button
type="button"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900"
hx-delete="/secrets/group/{{ name }}"
hx-target="#secretdetails"
hx-swap="OuterHTML"
hx-confirm="Deleting a group will move all its secrets to the Ungrouped category. Continue?"
>
<svg
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
></path>
</svg>
Delete group
</button>
</div>
</sl-details>
<div class="htmx-indicator secret-spinner">
<div role="status">
<svg aria-hidden="true" class="inline w-6 h-6 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
</div>

View File

@ -0,0 +1,18 @@
<form hx-put="/secrets/partial/secret/{{ secret.name }}/value" hx-indicator="#secretupdatespinner">
<div class="mb-6">
<label for="secret_value" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Value</label>
</div>
<div class="flex w-full">
<div class="relative w-full">
<input type="text" name="secret_value" aria-label="secret-value" class="mb-6 bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500" value="{{ secret.secret }}">
</div>
<div class="px-2.5 mb-2">
<button type="submit" class="bg-primary-700 text-white hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark-bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
Update
</button>
</div>
</div>
{% if updated %}
<p class="text-sm text-green-600 dark:text-green-500">Secret updated.</p>
{% endif %}
</form>

View File

@ -0,0 +1,38 @@
<div role="status" class="w-full p-4 space-y-4 border border-gray-200 divide-y divide-gray-200 rounded-sm shadow-sm animate-pulse dark:divide-gray-700 md:p-6 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
</div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
</div>
<div class="flex items-center justify-between pt-4">
<div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
</div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
</div>
<div class="flex items-center justify-between pt-4">
<div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
</div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
</div>
<div class="flex items-center justify-between pt-4">
<div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
</div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
</div>
<div class="flex items-center justify-between pt-4">
<div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
</div>
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
</div>
<span class="sr-only">Loading...</span>
</div>

View File

@ -0,0 +1,164 @@
<div class="w-full" id="secretdetails">
<h3 class="mb-4 text-xl font-semibold dark:text-white">{{secret.name}}</h3>
<div class="htmx-indicator secret-spinner">
<div role="status">
<svg aria-hidden="true" class="inline w-6 h-6 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
<sl-details summary="Clients" open>
<div id="secretclientdetails">
{% include '/secrets/partials/client_secret_details.html.j2' %}
</div>
</sl-details>
<sl-details summary="Read/Update Secret">
<div id="secretvalue">
<div class="mb-6">
<label for="secret-value" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Value</label>
</div>
<div class="flex w-full">
<div class="relative w-full">
<input type="text" id="disabled-input" aria-label="disabled input" class="mb-6 bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 cursor-not-allowed dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="••••••••" disabled>
</div>
<div class="px-2.5 mb-2">
<button
type="button"
class="text-gray-900 hover:text-blue-700 border border-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-500 dark:focus:ring-blue-800"
hx-get="/secrets/partial/{{ secret.name }}/viewsecret"
hx-target="#secretvalue"
hx-trigger="click"
hx-indicator="#secretupdatespinner"
>
View
</button>
</div>
</div>
</div>
<div class="htmx-indicator" id="secretupdatespinner">
<div role="status">
<svg aria-hidden="true" class="inline w-4 h-4 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
</sl-details>
{% if groups.groups %}
<sl-details summary="Group">
<form
hx-put="/secrets/set-group/{{ secret.name }}"
hx-target="#secretdetails"
hx-swap="OuterHTML"
hx-indicator=".secret-spinner"
>
<div class="flex w-full">
<div class="relative w-full">
<select id="group_name" name="group_name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="__ROOT">Ungrouped</option>
{% for group in groups.groups %}
<option value="{{ group.group_name }}" {% if group.name == secret.group -%}selected{% endif %}>{{ group.path }}</option>
{% endfor %}
</select>
</div>
<div class="px-2.5 mb-2">
<button type="Submit" class="text-gray-900 hover:text-blue-700 border border-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-500 dark:focus:ring-blue-800">Update</button>
</div>
</div>
</form>
</sl-details>
{% endif %}
<sl-details summary="Events">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600" id="last-audit-events">
<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 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">
<p>{{ entry.timestamp }}<button data-popover-target="popover-audit-entry-{{ entry.id }}" data-popover-placement="bottom-end" type="button"><svg class="w-4 h-4 ms-2 text-gray-400 hover:text-gray-500" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"></path>
</svg><span class="sr-only">Show information</span></button></p>
<div data-popover id="popover-audit-entry-{{entry.id}}" role="tooltip" class="absolute z-10 invisible inline-block text-sm text-gray-500 transition-opacity duration-300 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 w-80 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
<dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2">
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">ID</dt>
<dd class="text-xs font-semibold">{{ entry.id }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Subsystem</dt>
<dd class="text-xs font-semibold">{{ entry.subsystem }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Timestamp</dt>
<dd class="text-xs font-semibold">{{ entry.timestamp }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Operation</dt>
<dd class="text-xs font-semibold">{{ entry.operation }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client ID</dt>
<dd class="text-xs font-semibold">{{ entry.client_id }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client Name</dt>
<dd class="text-xs font-semibold">{{ entry.client_name }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret ID</dt>
<dd class="text-xs font-semibold">{{ entry.secret_id }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret Name</dt>
<dd class="text-xs font-semibold">{{ entry.secret_name }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Message</dt>
<dd class="text-xs font-semibold">{{ entry.message }}</dd>
</div>
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Origin</dt>
<dd class="text-xs font-semibold">{{ entry.origin }}</dd>
</div>
{% if entry.data %}
{% for key, value in entry.data.items() %}
<div class="flex flex-col pb-3">
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">{{ key | capitalize }}</dt>
<dd class="text-xs font-semibold">{{ value }}</dd>
</div>
{% endfor %}
{% endif %}
</dl>
</div>
</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>
</sl-details>
</div>

View File

@ -1,73 +0,0 @@
<tr
class="hover:bg-gray-100 dark:hover:bg-gray-700"
id="secret-{{ secret.id }}"
>
<td
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
>{{- secret.name -}}</td>
<td
class="max-w-sm p-4 overflow-hidden text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400 secret-client-list"
id="secret-client-list-{{ secret.name }}"
>
{% if secret.clients %}
{% for client in secret.clients %}
<span class="bg-gray-100 text-gray-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-sm me-2 dark:bg-gray-700 dark:text-gray-400 border border-gray-500 ">
<svg class="w-6 h-6 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="M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm-2 9a4 4 0 0 0-4 4v1a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-1a4 4 0 0 0-4-4h-4Z" clip-rule="evenodd"/>
</svg>
{{- client.name -}}
</span>
{% endfor %}
{% else %}
<p class="italic font-small">No clients</p>
{% endif %}
</td>
<td class="p-4 space-x-2 whitespace-nowrap">
<button
type="button"
data-modal-target="client-secret-modal-{{secret.name}}" data-modal-toggle="client-secret-modal-{{ secret.name }}"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
id="manage-client-access-btn-{{ secret.name }}"
>
<svg
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"
></path>
<path
fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"
></path>
</svg>
Manage Client Access
</button>
<button
type="button"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900"
hx-delete="/secrets/{{ secret.name }}"
hx-confirm="Are you sure you want to delete the secret {{ secret.name }}?"
hx-target="#secretsContent"
id="delete-secret-btn-{{ secret.name }}"
>
<svg
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
></path>
</svg>
Delete item
</button>
</td>
</tr>

View File

@ -5,11 +5,14 @@
import logging
import secrets as pysecrets
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Form, Request
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from pydantic import BaseModel, BeforeValidator, Field
from sshecret_admin.auth import LocalUserInfo
from sshecret_admin.services import AdminBackend
from sshecret_admin.services.models import SecretGroupCreate
from sshecret.backend.models import Operation
from ..dependencies import FrontendDependencies
@ -55,70 +58,399 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
templates = dependencies.templates
@app.get("/secrets/")
async def get_secrets(
async def get_secrets_tree(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
):
"""Get secrets index page."""
secrets = await admin.get_detailed_secrets()
clients = await admin.get_clients()
groups = await admin.get_secret_groups()
LOG.info("Groups: %r", groups)
return templates.TemplateResponse(
request,
"secrets/index.html.j2",
{
"page_title": "Secrets",
"secrets": secrets,
"groups": groups,
"user": current_user,
},
)
@app.get("/secrets/partial/root_group")
async def get_root_group(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Get root group."""
clients = await admin.get_clients()
return templates.TemplateResponse(
request,
"secrets/partials/edit_root.html.j2",
{
"clients": clients,
},
)
@app.post("/secrets/")
async def add_secret(
@app.get("/secrets/partial/secret/{name}")
async def get_secret_tree_detail(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Get partial secret detail."""
secret = await admin.get_secret(name)
groups = await admin.get_secret_groups(flat=True)
events = await admin.get_audit_log_detailed(limit=10, secret_name=name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
return templates.TemplateResponse(
request,
"secrets/partials/tree_detail.html.j2",
{
"secret": secret,
"groups": groups,
"events": events,
},
)
@app.get("/secrets/partial/group/{name}")
async def get_group_details(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Get group details partial."""
group = await admin.get_secret_group(name)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
clients = await admin.get_clients()
return templates.TemplateResponse(
request,
"secrets/partials/group_detail.html.j2",
{
"name": group.group_name,
"description": group.description,
"clients": clients,
},
)
@app.delete("/secrets/group/{name}")
async def delete_secret_group(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Delete a secret group."""
group = await admin.get_secret_group(name)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
await admin.delete_secret_group(name)
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/partials/default_detail.html.j2",
headers=headers,
)
@app.post("/secrets/group/")
async def create_group(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
group: Annotated[SecretGroupCreate, Form()],
):
"""Create group."""
LOG.info("Creating secret group: %r", group)
await admin.add_secret_group(
group_name=group.name,
description=group.description,
parent_group=group.parent_group,
)
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/partials/default_detail.html.j2",
headers=headers,
)
@app.put("/secrets/set-group/{name}")
async def set_secret_group(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
group_name: Annotated[str, Form()],
):
"""Move a secret to a group."""
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
if group_name == "__ROOT":
await admin.set_secret_group(name, None)
else:
group = await admin.get_secret_group(group_name)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
await admin.set_secret_group(name, group_name)
groups = await admin.get_secret_groups()
events = await admin.get_audit_log_detailed(limit=10, secret_name=secret.name)
secret = await admin.get_secret(name)
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/partials/tree_detail.html.j2",
{
"secret": secret,
"groups": groups,
"events": events,
},
headers=headers,
)
@app.put("/secrets/partial/group/{name}/description")
async def update_group_description(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
description: Annotated[str, Form()],
):
"""Update group description."""
group = await admin.get_secret_group(name)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
await admin.set_group_description(group_name=name, description=description)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/partials/group_detail.html.j2",
{
"name": group.group_name,
"description": group.description,
"clients": clients,
},
headers=headers,
)
@app.put("/secrets/partial/secret/{name}/value")
async def update_secret_value_inline(
request: Request,
name: str,
secret_value: Annotated[str, Form()],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Update secret value."""
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
origin = "UNKNOWN"
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.UPDATE,
message="Secret was updated via admin interface",
secret_name=name,
origin=origin,
username=current_user.display_name,
)
await admin.update_secret(name, secret_value)
secret = await admin.get_secret(name)
return templates.TemplateResponse(
request,
"secrets/partials/secret_value.html.j2",
{
"secret": secret,
"updated": True,
},
)
@app.get("/secrets/partial/{name}/viewsecret")
async def view_secret_in_tree(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
):
"""View secret inline partial."""
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
origin = "UNKNOWN"
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.READ,
message="Secret viewed",
secret_name=name,
origin=origin,
username=current_user.display_name,
)
return templates.TemplateResponse(
request,
"secrets/partials/secret_value.html.j2",
{
"secret": secret,
"updated": False,
},
)
@app.post("/secrets/create/group/{name}")
async def add_secret_in_group(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
secret: Annotated[CreateSecret, Form()],
):
"""Add secret."""
"""Create secret in group."""
LOG.info("secret: %s", secret.model_dump_json(indent=2))
clients = await admin.get_clients()
if secret.value:
value = secret.value
else:
value = pysecrets.token_urlsafe(32)
await admin.add_secret(secret.name, value, secret.clients)
secrets = await admin.get_detailed_secrets()
return templates.TemplateResponse(
request,
"secrets/inner.html.j2",
{
"secrets": secrets,
"clients": clients,
},
await admin.add_secret(secret.name, value, secret.clients, group=name)
headers = {"Hx-Refresh": "true"}
new_secret = await admin.get_secret(secret.name)
groups = await admin.get_secret_groups()
events = await admin.get_audit_log_detailed(limit=10, secret_name=secret.name)
if not new_secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
@app.delete("/secrets/{name}/clients/{id}")
return templates.TemplateResponse(
request,
"secrets/partials/tree_detail.html.j2",
{
"secret": new_secret,
"groups": groups,
"events": events,
},
headers=headers,
)
@app.post("/secrets/create/root")
async def add_secret_in_root(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
secret: Annotated[CreateSecret, Form()],
):
"""Create secret in the root."""
LOG.info("secret: %s", secret.model_dump_json(indent=2))
if secret.value:
value = secret.value
else:
value = pysecrets.token_urlsafe(32)
await admin.add_secret(secret.name, value, secret.clients, group=None)
headers = {"Hx-Refresh": "true"}
new_secret = await admin.get_secret(secret.name)
groups = await admin.get_secret_groups()
events = await admin.get_audit_log_detailed(limit=10, secret_name=secret.name)
if not new_secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
)
return templates.TemplateResponse(
request,
"secrets/partials/tree_detail.html.j2",
{
"secret": new_secret,
"groups": groups,
"events": events,
},
headers=headers,
)
@app.delete("/secrets/{name}/clients/{client_name}")
async def remove_client_secret_access(
request: Request,
name: str,
id: str,
client_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Remove a client's access to a secret."""
await admin.delete_client_secret(id, name)
client = await admin.get_client(client_name)
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Client not found."
)
await admin.delete_client_secret(str(client.id), name)
clients = await admin.get_clients()
secrets = await admin.get_detailed_secrets()
headers = {"Hx-Refresh": "true"}
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
)
return templates.TemplateResponse(
request,
"secrets/inner.html.j2",
{"clients": clients, "secret": secrets},
headers=headers,
"secrets/partials/client_list_inner.html.j2",
{"clients": clients, "secret": secret},
)
@app.get("/secrets/{name}/clients/")
async def show_secret_client_add(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Show partial to add new client to a secret."""
clients = await admin.get_clients()
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
)
return templates.TemplateResponse(
request,
"secrets/partials/client_assign.html.j2",
{
"clients": clients,
"secret": secret,
},
)
@app.post("/secrets/{name}/clients/")
@ -130,40 +462,42 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
):
"""Add a secret to a client."""
await admin.create_client_secret(client, name)
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
)
clients = await admin.get_clients()
secrets = await admin.get_detailed_secrets()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/inner.html.j2",
"secrets/partials/client_secret_details.html.j2",
{
"secret": secret,
"clients": clients,
"secrets": secrets,
},
headers=headers,
)
@app.delete("/secrets/{name}")
async def delete_secret(
request: Request,
name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Delete a secret."""
await admin.delete_secret(name)
clients = await admin.get_clients()
secrets = await admin.get_detailed_secrets()
headers = {"Hx-Refresh": "true"}
# @app.delete("/secrets/{name}")
# async def delete_secret(
# request: Request,
# name: str,
# admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
# ):
# """Delete a secret."""
# await admin.delete_secret(name)
# clients = await admin.get_clients()
# secrets = await admin.get_detailed_secrets()
# headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/inner.html.j2",
{
"clients": clients,
"secrets": secrets,
},
headers=headers,
)
# return templates.TemplateResponse(
# request,
# "secrets/inner.html.j2",
# {
# "clients": clients,
# "secrets": secrets,
# },
# headers=headers,
# )
return app

View File

@ -23,7 +23,13 @@ from sshecret.crypto import encrypt_string, load_public_key
from .keepass import PasswordContext, load_password_manager
from sshecret_admin.core.settings import AdminServerSettings
from .models import SecretView
from .models import (
ClientSecretGroup,
ClientSecretGroupList,
SecretClientMapping,
SecretGroup,
SecretView,
)
class ClientManagementError(Exception):
@ -45,6 +51,38 @@ class BackendUnavailableError(ClientManagementError):
LOG = logging.getLogger(__name__)
def add_clients_to_secret_group(
group: SecretGroup,
client_secret_mapping: dict[str, DetailedSecrets],
parent: ClientSecretGroup | None = None,
) -> ClientSecretGroup:
"""Add client information to a secret group."""
client_secret_group = ClientSecretGroup(
group_name=group.name,
path=group.path,
description=group.description,
parent_group=parent,
)
for entry in group.entries:
secret_entries = SecretClientMapping(name=entry)
if details := client_secret_mapping.get(entry):
secret_entries.clients = details.clients
client_secret_group.entries.append(secret_entries)
for subgroup in group.children:
client_secret_group.children.append(
add_clients_to_secret_group(
subgroup, client_secret_mapping, client_secret_group
)
)
# We'll save a bit of memory and complexity by just adding the name of the parent, if available.
if not parent and group.parent_group:
client_secret_group.parent_group = ClientSecretGroup(
group_name=group.parent_group.name,
path=group.parent_group.path,
)
return client_secret_group
class AdminBackend:
"""Admin backend API."""
@ -277,6 +315,98 @@ class AdminBackend:
except Exception as e:
raise BackendUnavailableError() from e
async def add_secret_group(
self,
group_name: str,
description: str | None = None,
parent_group: str | None = None,
) -> None:
"""Add secret group."""
with self.password_manager() as password_manager:
password_manager.add_group(group_name, description, parent_group)
async def set_secret_group(self, secret_name: str, group_name: str | None) -> None:
"""Assign a group to a secret."""
with self.password_manager() as password_manager:
password_manager.set_secret_group(secret_name, group_name)
async def move_secret_group(
self, group_name: str, parent_group: str | None
) -> None:
"""Move a group.
If parent_group is None, it will be moved to the root.
"""
with self.password_manager() as password_manager:
password_manager.move_group(group_name, parent_group)
async def set_group_description(self, group_name: str, description: str) -> None:
"""Set a group description."""
with self.password_manager() as password_manager:
password_manager.set_group_description(group_name, description)
async def delete_secret_group(
self, group_name: str, keep_entries: bool = True
) -> None:
"""Delete a group.
If keep_entries is set to False, all entries in the group will be deleted.
"""
with self.password_manager() as password_manager:
password_manager.delete_group(group_name, keep_entries)
async def get_secret_groups(
self,
group_filter: str | None = None,
regex: bool = True,
flat: bool = False,
) -> ClientSecretGroupList:
"""Get secret groups.
The starting group can be filtered with the group_name argument, which
may be a regular expression.
Groups are returned in a tree, unless flat is True.
"""
all_secrets = await self.backend.get_detailed_secrets()
secrets_mapping = {secret.name: secret for secret in all_secrets}
with self.password_manager() as password_manager:
if flat:
all_groups = password_manager.get_secret_group_list(
group_filter, regex=regex
)
else:
all_groups = password_manager.get_secret_groups(
group_filter, regex=regex
)
ungrouped = password_manager.get_ungrouped_secrets()
group_result: list[ClientSecretGroup] = []
for group in all_groups:
# We have to do this recursively.
group_result.append(add_clients_to_secret_group(group, secrets_mapping))
result = ClientSecretGroupList(groups=group_result)
if group_filter:
return result
ungrouped_clients: list[SecretClientMapping] = []
for name in ungrouped:
mapping = SecretClientMapping(name=name)
if client_mapping := secrets_mapping.get(name):
mapping.clients = client_mapping.clients
ungrouped_clients.append(mapping)
result.ungrouped = ungrouped_clients
return result
async def get_secret_group(self, name: str) -> ClientSecretGroup | None:
"""Get a single secret group by name."""
matches = await self.get_secret_groups(group_filter=name, regex=False)
if matches.groups:
return matches.groups[0]
return None
async def get_secret(self, name: str) -> SecretView | None:
"""Get secrets from backend."""
try:
@ -290,10 +420,11 @@ class AdminBackend:
"""Get a secret, including the actual unencrypted value and clients."""
with self.password_manager() as password_manager:
secret = password_manager.get_secret(name)
secret_group = password_manager.get_entry_group(name)
if not secret:
return None
secret_view = SecretView(name=name, secret=secret)
secret_view = SecretView(name=name, secret=secret, group=secret_group)
secret_mapping = await self.backend.get_secret(name)
if secret_mapping:
secret_view.clients = secret_mapping.clients
@ -322,11 +453,16 @@ class AdminBackend:
await self.backend.delete_client_secret(client, name)
async def _add_secret(
self, name: str, value: str, clients: list[str] | None, update: bool = False
self,
name: str,
value: str,
clients: list[str] | None,
update: bool = False,
group: str | None = None,
) -> None:
"""Add a secret."""
with self.password_manager() as password_manager:
password_manager.add_entry(name, value, update)
password_manager.add_entry(name, value, update, group_name=group)
if update:
secret_map = await self.backend.get_secret(name)
@ -348,11 +484,15 @@ class AdminBackend:
await self.backend.create_client_secret(client_name, name, encrypted)
async def add_secret(
self, name: str, value: str, clients: list[str] | None = None
self,
name: str,
value: str,
clients: list[str] | None = None,
group: str | None = None,
) -> None:
"""Add a secret."""
try:
await self._add_secret(name, value, clients)
await self._add_secret(name=name, value=value, clients=clients, group=group)
except ClientManagementError:
raise
except Exception as e:

View File

@ -7,24 +7,58 @@ from pathlib import Path
from typing import cast
import pykeepass
from .master_password import decrypt_master_password
import pykeepass.exceptions
from sshecret_admin.core.settings import AdminServerSettings
from .models import SecretGroup
from .master_password import decrypt_master_password
LOG = logging.getLogger(__name__)
NO_USERNAME = "NO_USERNAME"
DEFAULT_LOCATION = "keepass.kdbx"
class PasswordCredentialsError(Exception):
pass
def create_password_db(location: Path, password: str) -> None:
"""Create the password database."""
LOG.info("Creating password database at %s", location)
pykeepass.create_database(str(location.absolute()), password=password)
def _kp_group_to_secret_group(
kp_group: pykeepass.group.Group, parent: SecretGroup | None = None, depth: int | None = None
) -> SecretGroup:
"""Convert keepass group to secret group dataclass."""
group_name = cast(str, kp_group.name)
path = "/".join(cast(list[str], kp_group.path))
group = SecretGroup(name=group_name, path=path, description=kp_group.notes)
for entry in kp_group.entries:
group.entries.append(str(entry.title))
if parent:
group.parent_group = parent
current_depth = len(kp_group.path)
if not parent and current_depth > 1:
parent = _kp_group_to_secret_group(kp_group.parentgroup, depth=current_depth)
parent.children.append(group)
group.parent_group = parent
if depth and depth == current_depth:
return group
for subgroup in kp_group.subgroups:
group.children.append(_kp_group_to_secret_group(subgroup, group, depth=depth))
return group
class PasswordContext:
"""Password Context class."""
@ -32,15 +66,41 @@ class PasswordContext:
"""Initialize password context."""
self.keepass: pykeepass.PyKeePass = keepass
def add_entry(self, entry_name: str, secret: str, overwrite: bool = False) -> None:
@property
def _root_group(self) -> pykeepass.group.Group:
"""Return the root group."""
return cast(pykeepass.group.Group, self.keepass.root_group)
def _get_entry(self, name: str) -> pykeepass.entry.Entry | None:
"""Get entry."""
entry = cast(
"pykeepass.entry.Entry | None",
self.keepass.find_entries(title=name, first=True),
)
return entry
def _get_group(self, name: str) -> pykeepass.group.Group | None:
"""Find a group."""
group = cast(
pykeepass.group.Group | None,
self.keepass.find_groups(name=name, first=True),
)
return group
def add_entry(
self,
entry_name: str,
secret: str,
overwrite: bool = False,
group_name: str | None = None,
) -> None:
"""Add an entry.
Specify overwrite=True to overwrite the existing secret value, if it exists.
This will not move the entry, if the group_name is different from the original group.
"""
entry = cast(
"pykeepass.entry.Entry | None",
self.keepass.find_entries(title=entry_name, first=True),
)
entry = self._get_entry(entry_name)
if entry and overwrite:
entry.password = secret
self.keepass.save()
@ -48,9 +108,14 @@ class PasswordContext:
if entry:
raise ValueError("Error: A secret with this name already exists.")
LOG.debug("Add secret entry to keepass: %s", entry_name)
LOG.debug("Add secret entry to keepass: %s, group: %r", entry_name, group_name)
if group_name:
destination_group = self._get_group(group_name)
else:
destination_group = self._root_group
entry = self.keepass.add_entry(
destination_group=self.keepass.root_group,
destination_group=destination_group,
title=entry_name,
username=NO_USERNAME,
password=secret,
@ -59,10 +124,7 @@ class PasswordContext:
def get_secret(self, entry_name: str) -> str | None:
"""Get the secret value."""
entry = cast(
"pykeepass.entry.Entry | None",
self.keepass.find_entries(title=entry_name, first=True),
)
entry = self._get_entry(entry_name)
if not entry:
return None
@ -72,9 +134,124 @@ class PasswordContext:
raise RuntimeError(f"Cannot get password for entry {entry_name}")
def get_available_secrets(self) -> list[str]:
def get_entry_group(self, entry_name: str) -> str | None:
"""Get the group for an entry."""
entry = self._get_entry(entry_name)
if not entry:
return None
if entry.group.is_root_group:
return None
return str(entry.group.name)
def get_secret_groups(self, pattern: str | None = None, regex: bool = True) -> list[SecretGroup]:
"""Get secret groups.
A regex pattern may be provided to filter groups.
"""
if pattern:
groups = cast(
list[pykeepass.group.Group],
self.keepass.find_groups(name=pattern, regex=regex),
)
else:
groups = self._root_group.subgroups
secret_groups = [_kp_group_to_secret_group(group) for group in groups]
return secret_groups
def get_secret_group_list(self, pattern: str | None = None, regex: bool = True) -> list[SecretGroup]:
"""Get a flat list of groups."""
if pattern:
return self.get_secret_groups(pattern, regex)
groups = [ group for group in self.keepass.groups if not group.is_root_group ]
secret_groups = [_kp_group_to_secret_group(group) for group in groups]
return secret_groups
def get_ungrouped_secrets(self) -> list[str]:
"""Get secrets without groups."""
entries: list[str] = []
for entry in self._root_group.entries:
entries.append(str(entry.title))
return entries
def add_group(
self, name: str, description: str | None = None, parent_group: str | None = None
) -> None:
"""Add a group."""
kp_parent_group = self._root_group
if parent_group:
query = cast(
pykeepass.group.Group | None,
self.keepass.find_groups(name=parent_group, first=True),
)
if not query:
raise ValueError(
f"Error: Cannot find a parent group named {parent_group}"
)
kp_parent_group = query
self.keepass.add_group(destination_group=kp_parent_group, group_name=name, notes=description)
self.keepass.save()
def set_group_description(self, name: str, description: str) -> None:
"""Set the description of a group."""
group = self._get_group(name)
if not group:
raise ValueError(f"Error: No such group {name}")
group.notes = description
self.keepass.save()
def set_secret_group(self, entry_name: str, group_name: str | None) -> None:
"""Move a secret to a group.
If group is None, the secret will be placed in the root group.
"""
entry = self._get_entry(entry_name)
if not entry:
raise ValueError(
f"Cannot find secret entry named {entry_name} in secrets database"
)
if group_name:
group = self._get_group(group_name)
if not group:
raise ValueError(f"Cannot find a group named {group_name}")
else:
group = self._root_group
self.keepass.move_entry(entry, group)
self.keepass.save()
def move_group(self, name: str, parent_group: str | None) -> None:
"""Move a group.
If parent_group is None, it will be moved to the root.
"""
group = self._get_group(name)
if not group:
raise ValueError(f"Error: No such group {name}")
if parent_group:
parent = self._get_group(parent_group)
if not parent:
raise ValueError(f"Error: No such group {parent_group}")
else:
parent = self._root_group
self.keepass.move_group(group, parent)
self.keepass.save()
def get_available_secrets(self, group_name: str | None = None) -> list[str]:
"""Get the names of all secrets in the database."""
entries = self.keepass.entries
if group_name:
group = self._get_group(group_name)
if not group:
raise ValueError(f"Error: No such group {group_name}")
entries = group.entries
else:
entries = cast(list[pykeepass.entry.Entry], self.keepass.entries)
if not entries:
return []
return [str(entry.title) for entry in entries]
@ -90,11 +267,38 @@ class PasswordContext:
entry.delete()
self.keepass.save()
def delete_group(self, name: str, keep_entries: bool = True) -> None:
"""Delete a group.
If keep_entries is set to False, all entries in the group will be deleted.
"""
group = self._get_group(name)
if not group:
return
if keep_entries:
for entry in cast(
list[pykeepass.entry.Entry],
self.keepass.find_entries(recursive=True, group=group),
):
# Move the entry to the root group.
LOG.warning(
"Moving orphaned secret entry %s to root group", entry.title
)
self.keepass.move_entry(entry, self._root_group)
self.keepass.delete_group(group)
self.keepass.save()
@contextmanager
def _password_context(location: Path, password: str) -> Iterator[PasswordContext]:
"""Open the password context."""
try:
database = pykeepass.PyKeePass(str(location.absolute()), password=password)
except pykeepass.exceptions.CredentialsError as e:
raise PasswordCredentialsError(
"Could not open password database. Invalid credentials."
) from e
context = PasswordContext(database)
yield context

View File

@ -11,6 +11,7 @@ from pydantic import (
IPvAnyNetwork,
)
from sshecret.crypto import validate_public_key
from sshecret.backend.models import ClientReference
def public_key_validator(value: str) -> str:
@ -32,6 +33,7 @@ class SecretView(BaseModel):
name: str
secret: str
group: str | None = None
clients: list[str] = Field(default_factory=list) # Clients that have access to it.
@ -96,6 +98,7 @@ class SecretCreate(SecretUpdate):
clients: list[str] | None = Field(
default=None, description="Assign the secret to a list of clients."
)
group: str | None = None
model_config: ConfigDict = ConfigDict(
json_schema_extra={
@ -103,12 +106,58 @@ class SecretCreate(SecretUpdate):
{
"name": "MySecret",
"clients": ["client-1", "client-2"],
"group": None,
"value": {"auto_generate": True, "length": 32},
},
{
"name": "MySecret",
"group": "MySecretGroup",
"value": "mysecretstring",
},
]
}
)
class SecretGroup(BaseModel):
"""A secret group."""
name: str
path: str
description: str | None = None
parent_group: "SecretGroup | None" = None
children: list["SecretGroup"] = Field(default_factory=list)
entries: list[str] = Field(default_factory=list)
class SecretClientMapping(BaseModel):
"""Secret name with list of clients."""
name: str # name of secret
clients: list[ClientReference] = Field(default_factory=list)
class ClientSecretGroup(BaseModel):
"""Client secrets grouped."""
group_name: str
path: str
description: str | None = None
parent_group: "ClientSecretGroup | None" = None
children: list["ClientSecretGroup"] = Field(default_factory=list)
entries: list[SecretClientMapping] = Field(default_factory=list)
class SecretGroupCreate(BaseModel):
"""Create model for creating secret groups."""
name: str
description: str | None = None
parent_group: str | None = None
class ClientSecretGroupList(BaseModel):
"""Secret group list."""
ungrouped: list[SecretClientMapping] = Field(default_factory=list)
groups: list[ClientSecretGroup] = Field(default_factory=list)

View File

@ -39,15 +39,16 @@
--color-teal-300: oklch(85.5% 0.138 181.071);
--color-teal-500: oklch(70.4% 0.14 182.503);
--color-teal-600: oklch(60% 0.118 184.704);
--color-teal-700: oklch(51.1% 0.096 186.391);
--color-teal-900: oklch(38.6% 0.063 188.416);
--color-blue-100: oklch(93.2% 0.032 255.585);
--color-blue-200: oklch(88.2% 0.059 254.128);
--color-blue-300: oklch(80.9% 0.105 251.813);
--color-blue-400: oklch(70.7% 0.165 254.624);
--color-blue-500: oklch(62.3% 0.214 259.815);
--color-blue-600: oklch(54.6% 0.245 262.881);
--color-blue-700: oklch(48.8% 0.243 264.376);
--color-blue-800: oklch(42.4% 0.199 265.638);
--color-indigo-200: oklch(87% 0.065 274.039);
--color-blue-900: oklch(37.9% 0.146 265.522);
--color-indigo-500: oklch(58.5% 0.233 277.117);
--color-indigo-600: oklch(51.1% 0.262 276.966);
--color-indigo-700: oklch(45.7% 0.24 277.023);
@ -120,6 +121,7 @@
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--animate-spin: spin 1s linear infinite;
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
@ -415,12 +417,18 @@
.m-361 {
margin: calc(var(--spacing) * 361);
}
.mx-2\.5 {
margin-inline: calc(var(--spacing) * 2.5);
}
.mx-3 {
margin-inline: calc(var(--spacing) * 3);
}
.mx-4 {
margin-inline: calc(var(--spacing) * 4);
}
.mx-\[1rem\] {
margin-inline: 1rem;
}
.mx-auto {
margin-inline: auto;
}
@ -478,6 +486,9 @@
.mt-2 {
margin-top: calc(var(--spacing) * 2);
}
.mt-2\.5 {
margin-top: calc(var(--spacing) * 2.5);
}
.mt-3 {
margin-top: calc(var(--spacing) * 3);
}
@ -541,6 +552,9 @@
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
.mb-2\.5 {
margin-bottom: calc(var(--spacing) * 2.5);
}
.mb-3 {
margin-bottom: calc(var(--spacing) * 3);
}
@ -583,6 +597,9 @@
.ml-6 {
margin-left: calc(var(--spacing) * 6);
}
.ml-\[1rem\] {
margin-left: 1rem;
}
.ml-auto {
margin-left: auto;
}
@ -667,6 +684,9 @@
.h-32 {
height: calc(var(--spacing) * 32);
}
.h-48 {
height: calc(var(--spacing) * 48);
}
.h-\[0\.125rem\] {
height: 0.125rem;
}
@ -736,12 +756,21 @@
.w-11 {
width: calc(var(--spacing) * 11);
}
.w-12 {
width: calc(var(--spacing) * 12);
}
.w-16 {
width: calc(var(--spacing) * 16);
}
.w-24 {
width: calc(var(--spacing) * 24);
}
.w-28 {
width: calc(var(--spacing) * 28);
}
.w-32 {
width: calc(var(--spacing) * 32);
}
.w-36 {
width: calc(var(--spacing) * 36);
}
@ -863,6 +892,9 @@
.animate-ping {
animation: var(--animate-ping);
}
.animate-pulse {
animation: var(--animate-pulse);
}
.animate-spin {
animation: var(--animate-spin);
}
@ -1196,6 +1228,9 @@
--tw-border-style: solid;
border-style: solid;
}
.border-blue-700 {
border-color: var(--color-blue-700);
}
.border-gray-100 {
border-color: var(--color-gray-100);
}
@ -1208,6 +1243,9 @@
.border-gray-500 {
border-color: var(--color-gray-500);
}
.border-gray-900 {
border-color: var(--color-gray-900);
}
.border-green-100 {
border-color: var(--color-green-100);
}
@ -1262,6 +1300,9 @@
background-color: color-mix(in oklab, var(--color-black) 50%, transparent);
}
}
.bg-blue-100 {
background-color: var(--color-blue-100);
}
.bg-blue-200 {
background-color: var(--color-blue-200);
}
@ -1295,6 +1336,9 @@
.bg-gray-200 {
background-color: var(--color-gray-200);
}
.bg-gray-300 {
background-color: var(--color-gray-300);
}
.bg-gray-800 {
background-color: var(--color-gray-800);
}
@ -1692,12 +1736,21 @@
.text-\[\#f84525\] {
color: #f84525;
}
.text-blue-400 {
color: var(--color-blue-400);
}
.text-blue-500 {
color: var(--color-blue-500);
}
.text-blue-600 {
color: var(--color-blue-600);
}
.text-blue-700 {
color: var(--color-blue-700);
}
.text-blue-800 {
color: var(--color-blue-800);
}
.text-emerald-500 {
color: var(--color-emerald-500);
}
@ -2083,6 +2136,27 @@
}
}
}
.hover\:bg-blue-200 {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-200);
}
}
}
.hover\:bg-blue-700 {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-700);
}
}
}
.hover\:bg-blue-800 {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-800);
}
}
}
.hover\:bg-gray-50 {
&:hover {
@media (hover: hover) {
@ -2174,6 +2248,13 @@
}
}
}
.hover\:text-blue-900 {
&:hover {
@media (hover: hover) {
color: var(--color-blue-900);
}
}
}
.hover\:text-gray-100 {
&:hover {
@media (hover: hover) {
@ -2316,6 +2397,11 @@
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus\:ring-blue-300 {
&:focus {
--tw-ring-color: var(--color-blue-300);
}
}
.focus\:ring-blue-500 {
&:focus {
--tw-ring-color: var(--color-blue-500);
@ -2538,6 +2624,11 @@
padding-inline: calc(var(--spacing) * 4);
}
}
.sm\:px-16 {
@media (width >= 40rem) {
padding-inline: calc(var(--spacing) * 16);
}
}
.sm\:py-2 {
@media (width >= 40rem) {
padding-block: calc(var(--spacing) * 2);
@ -3013,6 +3104,12 @@
line-height: var(--tw-leading, var(--text-6xl--line-height));
}
}
.lg\:text-xl {
@media (width >= 64rem) {
font-size: var(--text-xl);
line-height: var(--tw-leading, var(--text-xl--line-height));
}
}
.lg\:hover\:underline {
@media (width >= 64rem) {
&:hover {
@ -3140,6 +3237,11 @@
padding-inline: calc(var(--spacing) * 0);
}
}
.xl\:px-48 {
@media (width >= 80rem) {
padding-inline: calc(var(--spacing) * 48);
}
}
.xl\:py-24 {
@media (width >= 80rem) {
padding-block: calc(var(--spacing) * 24);
@ -3231,6 +3333,11 @@
}
}
}
.dark\:border-blue-500 {
&:where(.dark, .dark *) {
border-color: var(--color-blue-500);
}
}
.dark\:border-gray-500 {
&:where(.dark, .dark *) {
border-color: var(--color-gray-500);
@ -3291,6 +3398,11 @@
border-color: var(--color-red-800);
}
}
.dark\:bg-blue-900 {
&:where(.dark, .dark *) {
background-color: var(--color-blue-900);
}
}
.dark\:bg-gray-600 {
&:where(.dark, .dark *) {
background-color: var(--color-gray-600);
@ -3359,6 +3471,11 @@
background-color: var(--color-teal-900);
}
}
.dark\:text-blue-300 {
&:where(.dark, .dark *) {
color: var(--color-blue-300);
}
}
.dark\:text-blue-500 {
&:where(.dark, .dark *) {
color: var(--color-blue-500);
@ -3399,6 +3516,11 @@
color: var(--color-gray-600);
}
}
.dark\:text-gray-700 {
&:where(.dark, .dark *) {
color: var(--color-gray-700);
}
}
.dark\:text-green-400 {
&:where(.dark, .dark *) {
color: var(--color-green-400);
@ -3536,6 +3658,24 @@
}
}
}
.dark\:hover\:bg-blue-500 {
&:where(.dark, .dark *) {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-500);
}
}
}
}
.dark\:hover\:bg-blue-800 {
&:where(.dark, .dark *) {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-800);
}
}
}
}
.dark\:hover\:bg-gray-600 {
&:where(.dark, .dark *) {
&:hover {
@ -3572,6 +3712,15 @@
}
}
}
.dark\:hover\:text-blue-300 {
&:where(.dark, .dark *) {
&:hover {
@media (hover: hover) {
color: var(--color-blue-300);
}
}
}
}
.dark\:hover\:text-gray-200 {
&:where(.dark, .dark *) {
&:hover {
@ -3675,6 +3824,13 @@
}
}
}
.dark\:focus\:ring-blue-800 {
&:where(.dark, .dark *) {
&:focus {
--tw-ring-color: var(--color-blue-800);
}
}
}
.dark\:focus\:ring-gray-600 {
&:where(.dark, .dark *) {
&:focus {
@ -3976,6 +4132,11 @@
opacity: 0;
}
}
@keyframes pulse {
50% {
opacity: 0.5;
}
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {

View File

@ -0,0 +1,10 @@
sl-avatar {
--size: 24pt;
}
.tree-with-lines {
--indent-guide-width: 1px;
}
.tree-post-button {
}

View File

@ -1,7 +1,7 @@
module.exports = {
content: [
"./src/sshecret_admin/templates/**/*.html",
"./src/sshecret_admin/templates/**/*.html.j2",
"./src/sshecret_admin/**/*.html",
"./src/sshecret_admin/**/*.html.j2",
"./src/sshecret_admin/static/**/*.js",
],
safelist: [

View File

@ -0,0 +1,285 @@
"""Unit tests for the password context class.
We are primarily testing whether the actions of the PasswordContext matches
those in the low level API.
"""
import random
import string
from typing import cast
import pykeepass
from sshecret_admin.services.keepass import PasswordContext
def random_string(length: int = 5) -> str:
"""Generate random string."""
chars = string.ascii_lowercase
return "".join(random.choice(chars) for _ in range(length))
def create_random_entries(
password_database: pykeepass.PyKeePass,
amount: int = 10,
prefix: str = "secret",
group: pykeepass.group.Group | None = None,
) -> None:
"""Create some random entries."""
if not group:
group = cast(pykeepass.group.Group, password_database.root_group)
for n in range(amount):
name = f"{prefix}-{n}"
username = "NONE"
password = random_string(12)
password_database.add_entry(
destination_group=group,
title=name,
username=username,
password=password,
)
password_database.save()
def test_add_entry(password_database: pykeepass.PyKeePass) -> None:
"""Test add entry."""
context = PasswordContext(password_database)
context.add_entry("testentry", "testsecret")
entry = password_database.find_entries(title="testentry", first=True)
assert entry is not None
assert isinstance(entry, pykeepass.entry.Entry)
assert entry.password == "testsecret"
def test_get_secret(password_database: pykeepass.PyKeePass) -> None:
"""Test get secret."""
context = PasswordContext(password_database)
context.add_entry("testentry", "testsecret")
secret = context.get_secret("testentry")
assert secret is not None
assert secret == "testsecret"
def test_get_available_secrets(password_database: pykeepass.PyKeePass) -> None:
"""Test get_available_secrets."""
create_random_entries(password_database, 10)
context = PasswordContext(password_database)
available_secrets = context.get_available_secrets()
assert len(available_secrets) == 10
for n in range(10):
assert f"secret-{n}" in available_secrets
def test_delete_entry(password_database: pykeepass.PyKeePass) -> None:
"""Test deletion of entry."""
create_random_entries(password_database, 3)
context = PasswordContext(password_database)
available_secrets = context.get_available_secrets()
assert len(available_secrets) == 3
context.delete_entry("secret-2")
entry = password_database.find_entries(title="secret-2", first=True)
assert entry is None
available_secrets = context.get_available_secrets()
assert len(available_secrets) == 2
def test_get_secret_groups(password_database: pykeepass.PyKeePass) -> None:
"""Test get secret groups."""
# We create a hierarchy of groups to test how that parses.
root_group = password_database.root_group
first_group = password_database.add_group(
root_group, "level_one", notes="A group in the root"
)
password_database.add_group(
first_group, "level_two", notes="A group one level down"
)
# Another group at the root, without a note
password_database.add_group(root_group, "free_group")
password_database.save()
context = PasswordContext(password_database)
groups = context.get_secret_groups()
assert len(groups) == 3
def test_get_secret_groups_regex(password_database: pykeepass.PyKeePass) -> None:
"""Get secret groups matching a regex."""
# create some groups matching a pattern
for n in range(4):
password_database.add_group(password_database.root_group, f"foo-{n}")
for n in range(3):
parent_group = password_database.find_groups(name="foo-1", first=True)
password_database.add_group(parent_group, f"bar-{n}")
password_database.save()
context = PasswordContext(password_database)
foo_groups = context.get_secret_groups("foo-.*")
assert len(foo_groups) == 4
bar_groups = context.get_secret_groups("bar-.*")
assert len(bar_groups) == 3
def test_get_secret_groups_with_entries(password_database: pykeepass.PyKeePass) -> None:
"""Get secret groups with entries."""
context = PasswordContext(password_database)
context.add_group("test_group", "Test Group")
context.add_group("parent_group", "Test Group")
context.add_group("nested_group", "Test Group", "parent_group")
context.add_entry("free_entry", "test", group_name="test_group")
context.add_entry("middle_entry", "test", group_name="parent_group")
context.add_entry("lower_entry", "test", group_name="nested_group")
groups = context.get_secret_groups()
assert len(groups) == 3
for group in groups:
assert len(group.entries) == 1
if group.name == "test_group":
assert "free_entry" in group.entries
elif group.name == "parent_group":
assert "middle_entry" in group.entries
elif group.name == "nested_group":
assert "lower_entry" in group.entries
def test_add_group(password_database: pykeepass.PyKeePass) -> None:
"""Test add_group."""
context = PasswordContext(password_database)
context.add_group("test_group", "Test Group")
assert password_database.find_groups(name="test_group", first=True) is not None
# add a nested group below the first one
context.add_group("nested_group", "Nested test group", "test_group")
group = password_database.find_groups(name="nested_group", first=True)
assert group is not None
assert isinstance(group, pykeepass.group.Group)
parent_group = group.parentgroup
assert parent_group.name == "test_group"
def test_set_group_description(password_database: pykeepass.PyKeePass) -> None:
"""Test setting the group description."""
context = PasswordContext(password_database)
context.add_group("test_group", "Test Group")
kp_group = password_database.find_groups(name="test_group", first=True)
assert isinstance(kp_group, pykeepass.group.Group)
assert kp_group.notes == "Test Group"
context.set_group_description("test_group", "New Description")
kp_group = password_database.find_groups(name="test_group", first=True)
assert isinstance(kp_group, pykeepass.group.Group)
assert kp_group.notes == "New Description"
def test_add_entry_with_group(password_database: pykeepass.PyKeePass) -> None:
"""Test adding an entry with a group."""
context = PasswordContext(password_database)
context.add_group("test_group", "A test group")
context.add_entry("test_entry", "test_secret", group_name="test_group")
entry = password_database.find_entries(title="test_entry", first=True)
assert entry is not None
assert isinstance(entry, pykeepass.entry.Entry)
assert entry.group.name == "test_group"
def test_move_entry(password_database: pykeepass.PyKeePass) -> None:
"""Test moving entries between groups."""
context = PasswordContext(password_database)
context.add_group("test_group", "A test group")
context.add_group("test_group_2", "Another test group")
context.add_entry("test_entry", "test_secret")
entry = password_database.find_entries(title="test_entry", first=True)
assert isinstance(entry, pykeepass.entry.Entry)
assert entry.group.is_root_group is True
context.set_secret_group("test_entry", "test_group")
entry = password_database.find_entries(title="test_entry", first=True)
assert isinstance(entry, pykeepass.entry.Entry)
assert entry.group.is_root_group is False
assert entry.group.name == "test_group"
context.set_secret_group("test_entry", "test_group_2")
entry = password_database.find_entries(title="test_entry", first=True)
assert isinstance(entry, pykeepass.entry.Entry)
assert entry.group.is_root_group is False
assert entry.group.name == "test_group_2"
context.set_secret_group("test_entry", None)
entry = password_database.find_entries(title="test_entry", first=True)
assert isinstance(entry, pykeepass.entry.Entry)
assert entry.group.is_root_group is True
def test_move_group(password_database: pykeepass.PyKeePass) -> None:
"""Test moving a group."""
context = PasswordContext(password_database)
context.add_group("test_group", "A test group")
context.add_group("parent_group", "A parent group")
context.move_group("test_group", "parent_group")
kp_group = password_database.find_groups(name="test_group", first=True)
assert isinstance(kp_group, pykeepass.group.Group)
assert kp_group.parentgroup.name == "parent_group"
def test_delete_group(password_database: pykeepass.PyKeePass) -> None:
"""Test group deletion."""
context = PasswordContext(password_database)
context.add_group("test_group", "A test group")
# Add some entries to this group.
kp_group = password_database.find_groups(name="test_group", first=True)
assert isinstance(kp_group, pykeepass.group.Group)
create_random_entries(password_database, amount=10, group=kp_group)
context.delete_group("test_group")
kp_group = password_database.find_groups(name="test_group", first=True)
assert kp_group is None
# Check if the secrets are still there.
secrets = context.get_available_secrets()
assert len(secrets) == 10
def test_get_specific_group(password_database: pykeepass.PyKeePass) -> None:
"""Test fetching a specific group."""
context = PasswordContext(password_database)
context.add_group("parent", "A parent group")
context.add_group("test_group", "A test group", "parent")
context.add_group("test_group_2", "A test group")
context.add_group("test_group_3", "A test group")
context.add_group("Other Group", "A test group")
results = context.get_secret_groups("test_group", False)
assert len(results) == 1
# Check if the parent reference is available.
assert results[0].parent_group is not None
def test_get_ungrouped_secrets(password_database: pykeepass.PyKeePass) -> None:
"""Test fetching secrets without groups."""
context = PasswordContext(password_database)
context.add_group("test_group", "A test group")
for n in range(7):
context.add_entry(f"grouped-{n}", "foo", group_name="test_group")
for n in range(5):
context.add_entry(f"ungrouped-{n}", "bar")
ungrouped = context.get_ungrouped_secrets()
assert len(ungrouped) == 5
for entry in ungrouped:
assert entry.startswith("ungrouped-")

View File

@ -0,0 +1,26 @@
"""Tests various error types."""
from pathlib import Path
import pykeepass
import pykeepass.exceptions
import pytest
from sshecret_admin.services.keepass import PasswordContext, _password_context, PasswordCredentialsError
def test_open_invalid_database() -> None:
"""Test opening a non-existing database."""
bogus_path = Path("/tmp/non/existing/password/database.kdbx")
with pytest.raises(FileNotFoundError):
with _password_context(bogus_path, "foobar") as context:
assert context is not None
def test_incorrect_password(tmp_path: Path) -> None:
"""Test opening database with incorrect password."""
filename = tmp_path / "db.kdbx"
pykeepass.create_database(str(filename), password="correct")
with pytest.raises(PasswordCredentialsError):
with _password_context(filename, "incorrect") as context:
assert context is not None