Compare commits
5 Commits
f853ca81d0
...
ecad667521
| Author | SHA1 | Date | |
|---|---|---|---|
| ecad667521 | |||
| ba936ac645 | |||
| 773a1e2976 | |||
| 18f61631c9 | |||
| 289352d872 |
@ -3,13 +3,16 @@
|
|||||||
# pyright: reportUnusedFunction=false
|
# pyright: reportUnusedFunction=false
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
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.backend.models import Secret
|
||||||
from sshecret_admin.core.dependencies import AdminDependencies
|
from sshecret_admin.core.dependencies import AdminDependencies
|
||||||
from sshecret_admin.services import AdminBackend
|
from sshecret_admin.services import AdminBackend
|
||||||
from sshecret_admin.services.models import (
|
from sshecret_admin.services.models import (
|
||||||
|
ClientSecretGroup,
|
||||||
|
ClientSecretGroupList,
|
||||||
SecretCreate,
|
SecretCreate,
|
||||||
|
SecretGroupCreate,
|
||||||
SecretUpdate,
|
SecretUpdate,
|
||||||
SecretView,
|
SecretView,
|
||||||
)
|
)
|
||||||
@ -19,7 +22,7 @@ LOG = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def create_router(dependencies: AdminDependencies) -> APIRouter:
|
def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||||
"""Create secrets router."""
|
"""Create secrets router."""
|
||||||
app = APIRouter(dependencies=[Depends(dependencies.get_current_active_user)])
|
app = APIRouter(dependencies=[Security(dependencies.get_current_active_user)])
|
||||||
|
|
||||||
@app.get("/secrets/")
|
@app.get("/secrets/")
|
||||||
async def get_secret_names(
|
async def get_secret_names(
|
||||||
@ -34,7 +37,12 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
|||||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create a secret."""
|
"""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}")
|
@app.get("/secrets/{name}")
|
||||||
async def get_secret(
|
async def get_secret(
|
||||||
@ -67,4 +75,133 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
|||||||
"""Delete secret."""
|
"""Delete secret."""
|
||||||
await admin.delete_secret(name)
|
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
|
return app
|
||||||
|
|||||||
@ -26,7 +26,7 @@ API_VERSION = "v1"
|
|||||||
def create_router(dependencies: BaseDependencies) -> APIRouter:
|
def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||||
"""Create clients router."""
|
"""Create clients router."""
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token")
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
token: Annotated[str, Depends(oauth2_scheme)],
|
token: Annotated[str, Depends(oauth2_scheme)],
|
||||||
|
|||||||
@ -2,126 +2,259 @@
|
|||||||
|
|
||||||
<div class="px-4 pt-6">
|
<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">
|
<div class="py-8 px-4 mt-4 mx-auto max-w-screen-xl text-center lg:py-16">
|
||||||
|
<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>
|
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>
|
||||||
|
|
||||||
<div class="grid w-full grid-cols-1 gap-4 mt-4 xl:grid-cols-2 2xl:grid-cols-3">
|
<div
|
||||||
<div class="items-center justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800" id="dashboard-stats-panel">
|
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">
|
<div class="w-full">
|
||||||
<h3 class="text-base text-gray-500 dark:text-gray-400">Stats</h3>
|
<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">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 text-xs dark:text-gray-400">Clients</dt>
|
<dt class="mb-1 text-gray-500 text-xs dark:text-gray-400">
|
||||||
<dd class="text-lg font-semibold" id="stats-client-count">{{ stats.clients }}</dd>
|
Clients
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-semibold" id="stats-client-count">
|
||||||
|
{{ stats.clients }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col py-3">
|
<div class="flex flex-col py-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secrets</dt>
|
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">
|
||||||
<dd class="text-lg font-semibold" id="stats-secret-count">{{ stats.secrets }}</dd>
|
Secrets
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-semibold" id="stats-secret-count">
|
||||||
|
{{ stats.secrets }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col py-3">
|
<div class="flex flex-col py-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Audit Events</dt>
|
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">
|
||||||
<dd class="text-lg font-semibold" id="stats-audit-count">{{ stats.audit_events }}</dd>
|
Audit Events
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-semibold" id="stats-audit-count">
|
||||||
|
{{ stats.audit_events }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="items-center 2xl: col-span-2 xl:col-span-2 justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
<div
|
||||||
|
class="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">
|
<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 %}
|
{% 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">
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
<tr>
|
<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
|
||||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Subsystem</th>
|
scope="col"
|
||||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Client/Username</th>
|
class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white"
|
||||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Origin</th>
|
>
|
||||||
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white dark:bg-gray-800">
|
<tbody class="bg-white dark:bg-gray-800">
|
||||||
{% for entry in last_login_events.results | list %}
|
{% 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 }}">
|
<tr
|
||||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
class="{{ loop.cycle('', 'bg-gray-50 dark:bg-gray-700 ') }}hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
<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">
|
id="login-entry-{{ entry.id }}"
|
||||||
<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>
|
<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>
|
</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">
|
<div
|
||||||
<dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2">
|
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">
|
<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>
|
<dd class="text-xs font-semibold">{{ entry.id }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Subsystem</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.subsystem }}</dd>
|
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>
|
||||||
|
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Timestamp</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.timestamp }}</dd>
|
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>
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Operation</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.operation }}</dd>
|
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>
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client ID</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.client_id }}</dd>
|
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>
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client Name</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.client_name }}</dd>
|
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>
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret ID</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.secret_id }}</dd>
|
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>
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret Name</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.secret_name }}</dd>
|
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>
|
||||||
<div class="flex flex-col pb-3">
|
<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>
|
<dd class="text-xs font-semibold">{{ entry.message }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col pb-3">
|
<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>
|
<dd class="text-xs font-semibold">{{ entry.origin }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% if entry.data %}
|
{% if entry.data %} {% for key, value in entry.data.items()
|
||||||
{% for key, value in entry.data.items() %}
|
%}
|
||||||
<div class="flex flex-col pb-3">
|
<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>
|
<dd class="text-xs font-semibold">{{ value }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %} {% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</td>
|
</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 }}
|
{{ entry.subsystem }}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
<td
|
||||||
{% if entry.client_name %}
|
class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white"
|
||||||
{{ entry.client_name }}
|
>
|
||||||
{% elif entry.data.username %}
|
{% if entry.client_name %} {{ entry.client_name }} {% elif
|
||||||
{{ entry.data.username }}
|
entry.data.username %} {{ entry.data.username }} {% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</td>
|
</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 }}
|
{{ entry.origin }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -133,96 +266,211 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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 %}
|
{% 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">
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
<tr>
|
<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
|
||||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Subsystem</th>
|
scope="col"
|
||||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Message</th>
|
class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white"
|
||||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Origin</th>
|
>
|
||||||
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white dark:bg-gray-800">
|
<tbody class="bg-white dark:bg-gray-800">
|
||||||
{% for entry in last_audit_events.results | list %}
|
{% 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 }}">
|
<tr
|
||||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
class="{{ loop.cycle('', 'bg-gray-50 dark:bg-gray-700 ') }}hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
<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">
|
id="login-entry-{{ entry.id }}"
|
||||||
<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>
|
<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">
|
<div
|
||||||
<dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2">
|
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">
|
<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>
|
<dd class="text-xs font-semibold">{{ entry.id }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Subsystem</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.subsystem }}</dd>
|
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>
|
||||||
|
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Timestamp</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.timestamp }}</dd>
|
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>
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Operation</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.operation }}</dd>
|
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>
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client ID</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.client_id }}</dd>
|
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>
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client Name</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.client_name }}</dd>
|
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>
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret ID</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.secret_id }}</dd>
|
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>
|
||||||
<div class="flex flex-col pb-3">
|
<div class="flex flex-col pb-3">
|
||||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret Name</dt>
|
<dt
|
||||||
<dd class="text-xs font-semibold">{{ entry.secret_name }}</dd>
|
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>
|
||||||
<div class="flex flex-col pb-3">
|
<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>
|
<dd class="text-xs font-semibold">{{ entry.message }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col pb-3">
|
<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>
|
<dd class="text-xs font-semibold">{{ entry.origin }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% if entry.data %}
|
{% if entry.data %} {% for key, value in entry.data.items()
|
||||||
{% for key, value in entry.data.items() %}
|
%}
|
||||||
<div class="flex flex-col pb-3">
|
<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>
|
<dd class="text-xs font-semibold">{{ value }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %} {% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</td>
|
</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 }}
|
{{ entry.subsystem }}
|
||||||
</td>
|
</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 }}
|
{{ entry.message }}
|
||||||
</td>
|
</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 }}
|
{{ entry.origin }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -235,8 +483,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include '/dashboard/drawer_client_create_dashboard.html.j2' %}
|
|
||||||
{% include '/dashboard/drawer_secret_create_dashboard.html.j2' %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@9.0.3"></script>
|
<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://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 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 type="text/javascript" src="{{ url_for('static', path="js/prism.js") }}"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -14,8 +14,24 @@
|
|||||||
href="{{ url_for('static', path='css/prism.css') }}"
|
href="{{ url_for('static', path='css/prism.css') }}"
|
||||||
type="text/css"
|
type="text/css"
|
||||||
/>
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('static', path='css/style.css') }}"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
|
||||||
<link
|
<link
|
||||||
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
|
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
|
||||||
rel="stylesheet"
|
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');"
|
||||||
|
/>
|
||||||
|
|||||||
@ -61,23 +61,7 @@
|
|||||||
data-dropdown-toggle="dropdown-2"
|
data-dropdown-toggle="dropdown-2"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Open user menu</span>
|
<span class="sr-only">Open user menu</span>
|
||||||
<svg
|
<sl-avatar label="User avatar"></sl-avatar>
|
||||||
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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Dropdown menu -->
|
<!-- Dropdown menu -->
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
{% for client in clients %}
|
|
||||||
<option value="{{ client.id }}">{{ client.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
@ -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>
|
|
||||||
@ -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 %}
|
{% 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="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="w-full mb-1">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@ -14,7 +46,6 @@
|
|||||||
<div class="flex items-center">
|
<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>
|
<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>
|
<span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Secrets</span>
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
@ -22,24 +53,73 @@
|
|||||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Secrets</h1>
|
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Secrets</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="items-center justify-between block sm:flex">
|
<div class="grid w-full grid-cols-1 gap-4 mt-4 xl:grid-cols-3">
|
||||||
<div class="flex items-center mb-4 sm:mb-0">
|
<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">
|
||||||
<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="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>
|
||||||
</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="secretsContent">
|
<script>
|
||||||
{% include '/secrets/inner.html.j2' %}
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
</div>
|
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 %}
|
{% endblock %}
|
||||||
|
|||||||
@ -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 %}
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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 %}
|
||||||
@ -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>
|
||||||
@ -83,7 +83,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="bottom-0 left-0 flex justify-center w-full pb-4 space-x-4 md:px-4 md:absolute"
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@ -91,28 +90,5 @@
|
|||||||
>
|
>
|
||||||
Add Secret
|
Add Secret
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
|
||||||
@ -5,11 +5,14 @@
|
|||||||
import logging
|
import logging
|
||||||
import secrets as pysecrets
|
import secrets as pysecrets
|
||||||
from typing import Annotated, Any
|
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 pydantic import BaseModel, BeforeValidator, Field
|
||||||
|
|
||||||
from sshecret_admin.auth import LocalUserInfo
|
from sshecret_admin.auth import LocalUserInfo
|
||||||
from sshecret_admin.services import AdminBackend
|
from sshecret_admin.services import AdminBackend
|
||||||
|
from sshecret_admin.services.models import SecretGroupCreate
|
||||||
|
|
||||||
|
from sshecret.backend.models import Operation
|
||||||
|
|
||||||
from ..dependencies import FrontendDependencies
|
from ..dependencies import FrontendDependencies
|
||||||
|
|
||||||
@ -55,70 +58,399 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
|||||||
templates = dependencies.templates
|
templates = dependencies.templates
|
||||||
|
|
||||||
@app.get("/secrets/")
|
@app.get("/secrets/")
|
||||||
async def get_secrets(
|
async def get_secrets_tree(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
|
||||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||||
):
|
):
|
||||||
"""Get secrets index page."""
|
groups = await admin.get_secret_groups()
|
||||||
secrets = await admin.get_detailed_secrets()
|
LOG.info("Groups: %r", groups)
|
||||||
clients = await admin.get_clients()
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"secrets/index.html.j2",
|
"secrets/index.html.j2",
|
||||||
{
|
{
|
||||||
"page_title": "Secrets",
|
"groups": groups,
|
||||||
"secrets": secrets,
|
|
||||||
"user": current_user,
|
"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,
|
"clients": clients,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.post("/secrets/")
|
@app.get("/secrets/partial/secret/{name}")
|
||||||
async def add_secret(
|
async def get_secret_tree_detail(
|
||||||
request: Request,
|
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)],
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
secret: Annotated[CreateSecret, Form()],
|
secret: Annotated[CreateSecret, Form()],
|
||||||
):
|
):
|
||||||
"""Add secret."""
|
"""Create secret in group."""
|
||||||
LOG.info("secret: %s", secret.model_dump_json(indent=2))
|
LOG.info("secret: %s", secret.model_dump_json(indent=2))
|
||||||
|
|
||||||
clients = await admin.get_clients()
|
|
||||||
if secret.value:
|
if secret.value:
|
||||||
value = secret.value
|
value = secret.value
|
||||||
else:
|
else:
|
||||||
value = pysecrets.token_urlsafe(32)
|
value = pysecrets.token_urlsafe(32)
|
||||||
|
|
||||||
await admin.add_secret(secret.name, value, secret.clients)
|
await admin.add_secret(secret.name, value, secret.clients, group=name)
|
||||||
secrets = await admin.get_detailed_secrets()
|
|
||||||
return templates.TemplateResponse(
|
headers = {"Hx-Refresh": "true"}
|
||||||
request,
|
new_secret = await admin.get_secret(secret.name)
|
||||||
"secrets/inner.html.j2",
|
groups = await admin.get_secret_groups()
|
||||||
{
|
events = await admin.get_audit_log_detailed(limit=10, secret_name=secret.name)
|
||||||
"secrets": secrets,
|
|
||||||
"clients": clients,
|
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(
|
async def remove_client_secret_access(
|
||||||
request: Request,
|
request: Request,
|
||||||
name: str,
|
name: str,
|
||||||
id: str,
|
client_name: str,
|
||||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
):
|
):
|
||||||
"""Remove a client's access to a secret."""
|
"""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()
|
clients = await admin.get_clients()
|
||||||
|
|
||||||
secrets = await admin.get_detailed_secrets()
|
secret = await admin.get_secret(name)
|
||||||
headers = {"Hx-Refresh": "true"}
|
if not secret:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
|
||||||
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"secrets/inner.html.j2",
|
"secrets/partials/client_list_inner.html.j2",
|
||||||
{"clients": clients, "secret": secrets},
|
{"clients": clients, "secret": secret},
|
||||||
headers=headers,
|
)
|
||||||
|
|
||||||
|
@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/")
|
@app.post("/secrets/{name}/clients/")
|
||||||
@ -130,40 +462,42 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
|||||||
):
|
):
|
||||||
"""Add a secret to a client."""
|
"""Add a secret to a client."""
|
||||||
await admin.create_client_secret(client, name)
|
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()
|
clients = await admin.get_clients()
|
||||||
secrets = await admin.get_detailed_secrets()
|
|
||||||
headers = {"Hx-Refresh": "true"}
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"secrets/inner.html.j2",
|
"secrets/partials/client_secret_details.html.j2",
|
||||||
{
|
{
|
||||||
|
"secret": secret,
|
||||||
"clients": clients,
|
"clients": clients,
|
||||||
"secrets": secrets,
|
|
||||||
},
|
},
|
||||||
headers=headers,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.delete("/secrets/{name}")
|
# @app.delete("/secrets/{name}")
|
||||||
async def delete_secret(
|
# async def delete_secret(
|
||||||
request: Request,
|
# request: Request,
|
||||||
name: str,
|
# name: str,
|
||||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
# admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
):
|
# ):
|
||||||
"""Delete a secret."""
|
# """Delete a secret."""
|
||||||
await admin.delete_secret(name)
|
# await admin.delete_secret(name)
|
||||||
clients = await admin.get_clients()
|
# clients = await admin.get_clients()
|
||||||
secrets = await admin.get_detailed_secrets()
|
# secrets = await admin.get_detailed_secrets()
|
||||||
headers = {"Hx-Refresh": "true"}
|
# headers = {"Hx-Refresh": "true"}
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
# return templates.TemplateResponse(
|
||||||
request,
|
# request,
|
||||||
"secrets/inner.html.j2",
|
# "secrets/inner.html.j2",
|
||||||
{
|
# {
|
||||||
"clients": clients,
|
# "clients": clients,
|
||||||
"secrets": secrets,
|
# "secrets": secrets,
|
||||||
},
|
# },
|
||||||
headers=headers,
|
# headers=headers,
|
||||||
)
|
# )
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@ -23,7 +23,13 @@ from sshecret.crypto import encrypt_string, load_public_key
|
|||||||
|
|
||||||
from .keepass import PasswordContext, load_password_manager
|
from .keepass import PasswordContext, load_password_manager
|
||||||
from sshecret_admin.core.settings import AdminServerSettings
|
from sshecret_admin.core.settings import AdminServerSettings
|
||||||
from .models import SecretView
|
from .models import (
|
||||||
|
ClientSecretGroup,
|
||||||
|
ClientSecretGroupList,
|
||||||
|
SecretClientMapping,
|
||||||
|
SecretGroup,
|
||||||
|
SecretView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ClientManagementError(Exception):
|
class ClientManagementError(Exception):
|
||||||
@ -45,6 +51,38 @@ class BackendUnavailableError(ClientManagementError):
|
|||||||
LOG = logging.getLogger(__name__)
|
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:
|
class AdminBackend:
|
||||||
"""Admin backend API."""
|
"""Admin backend API."""
|
||||||
|
|
||||||
@ -277,6 +315,98 @@ class AdminBackend:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BackendUnavailableError() from 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:
|
async def get_secret(self, name: str) -> SecretView | None:
|
||||||
"""Get secrets from backend."""
|
"""Get secrets from backend."""
|
||||||
try:
|
try:
|
||||||
@ -290,10 +420,11 @@ class AdminBackend:
|
|||||||
"""Get a secret, including the actual unencrypted value and clients."""
|
"""Get a secret, including the actual unencrypted value and clients."""
|
||||||
with self.password_manager() as password_manager:
|
with self.password_manager() as password_manager:
|
||||||
secret = password_manager.get_secret(name)
|
secret = password_manager.get_secret(name)
|
||||||
|
secret_group = password_manager.get_entry_group(name)
|
||||||
|
|
||||||
if not secret:
|
if not secret:
|
||||||
return None
|
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)
|
secret_mapping = await self.backend.get_secret(name)
|
||||||
if secret_mapping:
|
if secret_mapping:
|
||||||
secret_view.clients = secret_mapping.clients
|
secret_view.clients = secret_mapping.clients
|
||||||
@ -322,11 +453,16 @@ class AdminBackend:
|
|||||||
await self.backend.delete_client_secret(client, name)
|
await self.backend.delete_client_secret(client, name)
|
||||||
|
|
||||||
async def _add_secret(
|
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:
|
) -> None:
|
||||||
"""Add a secret."""
|
"""Add a secret."""
|
||||||
with self.password_manager() as password_manager:
|
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:
|
if update:
|
||||||
secret_map = await self.backend.get_secret(name)
|
secret_map = await self.backend.get_secret(name)
|
||||||
@ -348,11 +484,15 @@ class AdminBackend:
|
|||||||
await self.backend.create_client_secret(client_name, name, encrypted)
|
await self.backend.create_client_secret(client_name, name, encrypted)
|
||||||
|
|
||||||
async def add_secret(
|
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:
|
) -> None:
|
||||||
"""Add a secret."""
|
"""Add a secret."""
|
||||||
try:
|
try:
|
||||||
await self._add_secret(name, value, clients)
|
await self._add_secret(name=name, value=value, clients=clients, group=group)
|
||||||
except ClientManagementError:
|
except ClientManagementError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -7,24 +7,58 @@ from pathlib import Path
|
|||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import pykeepass
|
import pykeepass
|
||||||
from .master_password import decrypt_master_password
|
import pykeepass.exceptions
|
||||||
from sshecret_admin.core.settings import AdminServerSettings
|
from sshecret_admin.core.settings import AdminServerSettings
|
||||||
|
|
||||||
|
from .models import SecretGroup
|
||||||
|
from .master_password import decrypt_master_password
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
NO_USERNAME = "NO_USERNAME"
|
NO_USERNAME = "NO_USERNAME"
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_LOCATION = "keepass.kdbx"
|
DEFAULT_LOCATION = "keepass.kdbx"
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordCredentialsError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def create_password_db(location: Path, password: str) -> None:
|
def create_password_db(location: Path, password: str) -> None:
|
||||||
"""Create the password database."""
|
"""Create the password database."""
|
||||||
LOG.info("Creating password database at %s", location)
|
LOG.info("Creating password database at %s", location)
|
||||||
pykeepass.create_database(str(location.absolute()), password=password)
|
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:
|
class PasswordContext:
|
||||||
"""Password Context class."""
|
"""Password Context class."""
|
||||||
|
|
||||||
@ -32,15 +66,41 @@ class PasswordContext:
|
|||||||
"""Initialize password context."""
|
"""Initialize password context."""
|
||||||
self.keepass: pykeepass.PyKeePass = keepass
|
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.
|
"""Add an entry.
|
||||||
|
|
||||||
Specify overwrite=True to overwrite the existing secret value, if it exists.
|
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(
|
entry = self._get_entry(entry_name)
|
||||||
"pykeepass.entry.Entry | None",
|
|
||||||
self.keepass.find_entries(title=entry_name, first=True),
|
|
||||||
)
|
|
||||||
if entry and overwrite:
|
if entry and overwrite:
|
||||||
entry.password = secret
|
entry.password = secret
|
||||||
self.keepass.save()
|
self.keepass.save()
|
||||||
@ -48,9 +108,14 @@ class PasswordContext:
|
|||||||
|
|
||||||
if entry:
|
if entry:
|
||||||
raise ValueError("Error: A secret with this name already exists.")
|
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(
|
entry = self.keepass.add_entry(
|
||||||
destination_group=self.keepass.root_group,
|
destination_group=destination_group,
|
||||||
title=entry_name,
|
title=entry_name,
|
||||||
username=NO_USERNAME,
|
username=NO_USERNAME,
|
||||||
password=secret,
|
password=secret,
|
||||||
@ -59,10 +124,7 @@ class PasswordContext:
|
|||||||
|
|
||||||
def get_secret(self, entry_name: str) -> str | None:
|
def get_secret(self, entry_name: str) -> str | None:
|
||||||
"""Get the secret value."""
|
"""Get the secret value."""
|
||||||
entry = cast(
|
entry = self._get_entry(entry_name)
|
||||||
"pykeepass.entry.Entry | None",
|
|
||||||
self.keepass.find_entries(title=entry_name, first=True),
|
|
||||||
)
|
|
||||||
if not entry:
|
if not entry:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -72,9 +134,124 @@ class PasswordContext:
|
|||||||
|
|
||||||
raise RuntimeError(f"Cannot get password for entry {entry_name}")
|
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."""
|
"""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:
|
if not entries:
|
||||||
return []
|
return []
|
||||||
return [str(entry.title) for entry in entries]
|
return [str(entry.title) for entry in entries]
|
||||||
@ -90,11 +267,38 @@ class PasswordContext:
|
|||||||
entry.delete()
|
entry.delete()
|
||||||
self.keepass.save()
|
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
|
@contextmanager
|
||||||
def _password_context(location: Path, password: str) -> Iterator[PasswordContext]:
|
def _password_context(location: Path, password: str) -> Iterator[PasswordContext]:
|
||||||
"""Open the password context."""
|
"""Open the password context."""
|
||||||
|
try:
|
||||||
database = pykeepass.PyKeePass(str(location.absolute()), password=password)
|
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)
|
context = PasswordContext(database)
|
||||||
yield context
|
yield context
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from pydantic import (
|
|||||||
IPvAnyNetwork,
|
IPvAnyNetwork,
|
||||||
)
|
)
|
||||||
from sshecret.crypto import validate_public_key
|
from sshecret.crypto import validate_public_key
|
||||||
|
from sshecret.backend.models import ClientReference
|
||||||
|
|
||||||
|
|
||||||
def public_key_validator(value: str) -> str:
|
def public_key_validator(value: str) -> str:
|
||||||
@ -32,6 +33,7 @@ class SecretView(BaseModel):
|
|||||||
|
|
||||||
name: str
|
name: str
|
||||||
secret: str
|
secret: str
|
||||||
|
group: str | None = None
|
||||||
clients: list[str] = Field(default_factory=list) # Clients that have access to it.
|
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(
|
clients: list[str] | None = Field(
|
||||||
default=None, description="Assign the secret to a list of clients."
|
default=None, description="Assign the secret to a list of clients."
|
||||||
)
|
)
|
||||||
|
group: str | None = None
|
||||||
|
|
||||||
model_config: ConfigDict = ConfigDict(
|
model_config: ConfigDict = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
@ -103,12 +106,58 @@ class SecretCreate(SecretUpdate):
|
|||||||
{
|
{
|
||||||
"name": "MySecret",
|
"name": "MySecret",
|
||||||
"clients": ["client-1", "client-2"],
|
"clients": ["client-1", "client-2"],
|
||||||
|
"group": None,
|
||||||
"value": {"auto_generate": True, "length": 32},
|
"value": {"auto_generate": True, "length": 32},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "MySecret",
|
"name": "MySecret",
|
||||||
|
"group": "MySecretGroup",
|
||||||
"value": "mysecretstring",
|
"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)
|
||||||
|
|||||||
@ -39,15 +39,16 @@
|
|||||||
--color-teal-300: oklch(85.5% 0.138 181.071);
|
--color-teal-300: oklch(85.5% 0.138 181.071);
|
||||||
--color-teal-500: oklch(70.4% 0.14 182.503);
|
--color-teal-500: oklch(70.4% 0.14 182.503);
|
||||||
--color-teal-600: oklch(60% 0.118 184.704);
|
--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-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-200: oklch(88.2% 0.059 254.128);
|
||||||
--color-blue-300: oklch(80.9% 0.105 251.813);
|
--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-500: oklch(62.3% 0.214 259.815);
|
||||||
--color-blue-600: oklch(54.6% 0.245 262.881);
|
--color-blue-600: oklch(54.6% 0.245 262.881);
|
||||||
--color-blue-700: oklch(48.8% 0.243 264.376);
|
--color-blue-700: oklch(48.8% 0.243 264.376);
|
||||||
--color-blue-800: oklch(42.4% 0.199 265.638);
|
--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-500: oklch(58.5% 0.233 277.117);
|
||||||
--color-indigo-600: oklch(51.1% 0.262 276.966);
|
--color-indigo-600: oklch(51.1% 0.262 276.966);
|
||||||
--color-indigo-700: oklch(45.7% 0.24 277.023);
|
--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);
|
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--animate-spin: spin 1s linear infinite;
|
--animate-spin: spin 1s linear infinite;
|
||||||
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) 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-duration: 150ms;
|
||||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--default-font-family: var(--font-sans);
|
--default-font-family: var(--font-sans);
|
||||||
@ -415,12 +417,18 @@
|
|||||||
.m-361 {
|
.m-361 {
|
||||||
margin: calc(var(--spacing) * 361);
|
margin: calc(var(--spacing) * 361);
|
||||||
}
|
}
|
||||||
|
.mx-2\.5 {
|
||||||
|
margin-inline: calc(var(--spacing) * 2.5);
|
||||||
|
}
|
||||||
.mx-3 {
|
.mx-3 {
|
||||||
margin-inline: calc(var(--spacing) * 3);
|
margin-inline: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
.mx-4 {
|
.mx-4 {
|
||||||
margin-inline: calc(var(--spacing) * 4);
|
margin-inline: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
|
.mx-\[1rem\] {
|
||||||
|
margin-inline: 1rem;
|
||||||
|
}
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
@ -478,6 +486,9 @@
|
|||||||
.mt-2 {
|
.mt-2 {
|
||||||
margin-top: calc(var(--spacing) * 2);
|
margin-top: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.mt-2\.5 {
|
||||||
|
margin-top: calc(var(--spacing) * 2.5);
|
||||||
|
}
|
||||||
.mt-3 {
|
.mt-3 {
|
||||||
margin-top: calc(var(--spacing) * 3);
|
margin-top: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
@ -541,6 +552,9 @@
|
|||||||
.mb-2 {
|
.mb-2 {
|
||||||
margin-bottom: calc(var(--spacing) * 2);
|
margin-bottom: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.mb-2\.5 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 2.5);
|
||||||
|
}
|
||||||
.mb-3 {
|
.mb-3 {
|
||||||
margin-bottom: calc(var(--spacing) * 3);
|
margin-bottom: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
@ -583,6 +597,9 @@
|
|||||||
.ml-6 {
|
.ml-6 {
|
||||||
margin-left: calc(var(--spacing) * 6);
|
margin-left: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.ml-\[1rem\] {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
.ml-auto {
|
.ml-auto {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@ -667,6 +684,9 @@
|
|||||||
.h-32 {
|
.h-32 {
|
||||||
height: calc(var(--spacing) * 32);
|
height: calc(var(--spacing) * 32);
|
||||||
}
|
}
|
||||||
|
.h-48 {
|
||||||
|
height: calc(var(--spacing) * 48);
|
||||||
|
}
|
||||||
.h-\[0\.125rem\] {
|
.h-\[0\.125rem\] {
|
||||||
height: 0.125rem;
|
height: 0.125rem;
|
||||||
}
|
}
|
||||||
@ -736,12 +756,21 @@
|
|||||||
.w-11 {
|
.w-11 {
|
||||||
width: calc(var(--spacing) * 11);
|
width: calc(var(--spacing) * 11);
|
||||||
}
|
}
|
||||||
|
.w-12 {
|
||||||
|
width: calc(var(--spacing) * 12);
|
||||||
|
}
|
||||||
.w-16 {
|
.w-16 {
|
||||||
width: calc(var(--spacing) * 16);
|
width: calc(var(--spacing) * 16);
|
||||||
}
|
}
|
||||||
|
.w-24 {
|
||||||
|
width: calc(var(--spacing) * 24);
|
||||||
|
}
|
||||||
.w-28 {
|
.w-28 {
|
||||||
width: calc(var(--spacing) * 28);
|
width: calc(var(--spacing) * 28);
|
||||||
}
|
}
|
||||||
|
.w-32 {
|
||||||
|
width: calc(var(--spacing) * 32);
|
||||||
|
}
|
||||||
.w-36 {
|
.w-36 {
|
||||||
width: calc(var(--spacing) * 36);
|
width: calc(var(--spacing) * 36);
|
||||||
}
|
}
|
||||||
@ -863,6 +892,9 @@
|
|||||||
.animate-ping {
|
.animate-ping {
|
||||||
animation: var(--animate-ping);
|
animation: var(--animate-ping);
|
||||||
}
|
}
|
||||||
|
.animate-pulse {
|
||||||
|
animation: var(--animate-pulse);
|
||||||
|
}
|
||||||
.animate-spin {
|
.animate-spin {
|
||||||
animation: var(--animate-spin);
|
animation: var(--animate-spin);
|
||||||
}
|
}
|
||||||
@ -1196,6 +1228,9 @@
|
|||||||
--tw-border-style: solid;
|
--tw-border-style: solid;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
}
|
}
|
||||||
|
.border-blue-700 {
|
||||||
|
border-color: var(--color-blue-700);
|
||||||
|
}
|
||||||
.border-gray-100 {
|
.border-gray-100 {
|
||||||
border-color: var(--color-gray-100);
|
border-color: var(--color-gray-100);
|
||||||
}
|
}
|
||||||
@ -1208,6 +1243,9 @@
|
|||||||
.border-gray-500 {
|
.border-gray-500 {
|
||||||
border-color: var(--color-gray-500);
|
border-color: var(--color-gray-500);
|
||||||
}
|
}
|
||||||
|
.border-gray-900 {
|
||||||
|
border-color: var(--color-gray-900);
|
||||||
|
}
|
||||||
.border-green-100 {
|
.border-green-100 {
|
||||||
border-color: var(--color-green-100);
|
border-color: var(--color-green-100);
|
||||||
}
|
}
|
||||||
@ -1262,6 +1300,9 @@
|
|||||||
background-color: color-mix(in oklab, var(--color-black) 50%, transparent);
|
background-color: color-mix(in oklab, var(--color-black) 50%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.bg-blue-100 {
|
||||||
|
background-color: var(--color-blue-100);
|
||||||
|
}
|
||||||
.bg-blue-200 {
|
.bg-blue-200 {
|
||||||
background-color: var(--color-blue-200);
|
background-color: var(--color-blue-200);
|
||||||
}
|
}
|
||||||
@ -1295,6 +1336,9 @@
|
|||||||
.bg-gray-200 {
|
.bg-gray-200 {
|
||||||
background-color: var(--color-gray-200);
|
background-color: var(--color-gray-200);
|
||||||
}
|
}
|
||||||
|
.bg-gray-300 {
|
||||||
|
background-color: var(--color-gray-300);
|
||||||
|
}
|
||||||
.bg-gray-800 {
|
.bg-gray-800 {
|
||||||
background-color: var(--color-gray-800);
|
background-color: var(--color-gray-800);
|
||||||
}
|
}
|
||||||
@ -1692,12 +1736,21 @@
|
|||||||
.text-\[\#f84525\] {
|
.text-\[\#f84525\] {
|
||||||
color: #f84525;
|
color: #f84525;
|
||||||
}
|
}
|
||||||
|
.text-blue-400 {
|
||||||
|
color: var(--color-blue-400);
|
||||||
|
}
|
||||||
.text-blue-500 {
|
.text-blue-500 {
|
||||||
color: var(--color-blue-500);
|
color: var(--color-blue-500);
|
||||||
}
|
}
|
||||||
.text-blue-600 {
|
.text-blue-600 {
|
||||||
color: var(--color-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 {
|
.text-emerald-500 {
|
||||||
color: var(--color-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\:bg-gray-50 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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\:text-gray-100 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: 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);
|
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\:ring-blue-500 {
|
||||||
&:focus {
|
&:focus {
|
||||||
--tw-ring-color: var(--color-blue-500);
|
--tw-ring-color: var(--color-blue-500);
|
||||||
@ -2538,6 +2624,11 @@
|
|||||||
padding-inline: calc(var(--spacing) * 4);
|
padding-inline: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:px-16 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
padding-inline: calc(var(--spacing) * 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:py-2 {
|
.sm\:py-2 {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
padding-block: calc(var(--spacing) * 2);
|
padding-block: calc(var(--spacing) * 2);
|
||||||
@ -3013,6 +3104,12 @@
|
|||||||
line-height: var(--tw-leading, var(--text-6xl--line-height));
|
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 {
|
.lg\:hover\:underline {
|
||||||
@media (width >= 64rem) {
|
@media (width >= 64rem) {
|
||||||
&:hover {
|
&:hover {
|
||||||
@ -3140,6 +3237,11 @@
|
|||||||
padding-inline: calc(var(--spacing) * 0);
|
padding-inline: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.xl\:px-48 {
|
||||||
|
@media (width >= 80rem) {
|
||||||
|
padding-inline: calc(var(--spacing) * 48);
|
||||||
|
}
|
||||||
|
}
|
||||||
.xl\:py-24 {
|
.xl\:py-24 {
|
||||||
@media (width >= 80rem) {
|
@media (width >= 80rem) {
|
||||||
padding-block: calc(var(--spacing) * 24);
|
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 {
|
.dark\:border-gray-500 {
|
||||||
&:where(.dark, .dark *) {
|
&:where(.dark, .dark *) {
|
||||||
border-color: var(--color-gray-500);
|
border-color: var(--color-gray-500);
|
||||||
@ -3291,6 +3398,11 @@
|
|||||||
border-color: var(--color-red-800);
|
border-color: var(--color-red-800);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.dark\:bg-blue-900 {
|
||||||
|
&:where(.dark, .dark *) {
|
||||||
|
background-color: var(--color-blue-900);
|
||||||
|
}
|
||||||
|
}
|
||||||
.dark\:bg-gray-600 {
|
.dark\:bg-gray-600 {
|
||||||
&:where(.dark, .dark *) {
|
&:where(.dark, .dark *) {
|
||||||
background-color: var(--color-gray-600);
|
background-color: var(--color-gray-600);
|
||||||
@ -3359,6 +3471,11 @@
|
|||||||
background-color: var(--color-teal-900);
|
background-color: var(--color-teal-900);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.dark\:text-blue-300 {
|
||||||
|
&:where(.dark, .dark *) {
|
||||||
|
color: var(--color-blue-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
.dark\:text-blue-500 {
|
.dark\:text-blue-500 {
|
||||||
&:where(.dark, .dark *) {
|
&:where(.dark, .dark *) {
|
||||||
color: var(--color-blue-500);
|
color: var(--color-blue-500);
|
||||||
@ -3399,6 +3516,11 @@
|
|||||||
color: var(--color-gray-600);
|
color: var(--color-gray-600);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.dark\:text-gray-700 {
|
||||||
|
&:where(.dark, .dark *) {
|
||||||
|
color: var(--color-gray-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
.dark\:text-green-400 {
|
.dark\:text-green-400 {
|
||||||
&:where(.dark, .dark *) {
|
&:where(.dark, .dark *) {
|
||||||
color: var(--color-green-400);
|
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 {
|
.dark\:hover\:bg-gray-600 {
|
||||||
&:where(.dark, .dark *) {
|
&:where(.dark, .dark *) {
|
||||||
&:hover {
|
&: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 {
|
.dark\:hover\:text-gray-200 {
|
||||||
&:where(.dark, .dark *) {
|
&:where(.dark, .dark *) {
|
||||||
&:hover {
|
&: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 {
|
.dark\:focus\:ring-gray-600 {
|
||||||
&:where(.dark, .dark *) {
|
&:where(.dark, .dark *) {
|
||||||
&:focus {
|
&:focus {
|
||||||
@ -3976,6 +4132,11 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@layer properties {
|
@layer properties {
|
||||||
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
@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 {
|
*, ::before, ::after, ::backdrop {
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
sl-avatar {
|
||||||
|
--size: 24pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-with-lines {
|
||||||
|
--indent-guide-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-post-button {
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: [
|
||||||
"./src/sshecret_admin/templates/**/*.html",
|
"./src/sshecret_admin/**/*.html",
|
||||||
"./src/sshecret_admin/templates/**/*.html.j2",
|
"./src/sshecret_admin/**/*.html.j2",
|
||||||
"./src/sshecret_admin/static/**/*.js",
|
"./src/sshecret_admin/static/**/*.js",
|
||||||
],
|
],
|
||||||
safelist: [
|
safelist: [
|
||||||
|
|||||||
285
packages/sshecret-admin/tests/test_password_context.py
Normal file
285
packages/sshecret-admin/tests/test_password_context.py
Normal 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-")
|
||||||
@ -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
|
||||||
Reference in New Issue
Block a user