Support unmanaged secrets

This commit is contained in:
2025-06-09 18:04:58 +02:00
parent 43d00cecb4
commit 782ec19137
7 changed files with 103 additions and 60 deletions

View File

@ -5,7 +5,6 @@ import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sshecret.backend.models import Secret
from sshecret_admin.core.dependencies import AdminDependencies
from sshecret_admin.services import AdminBackend
from sshecret_admin.services.models import (
@ -13,6 +12,7 @@ from sshecret_admin.services.models import (
ClientSecretGroupList,
SecretCreate,
SecretGroupCreate,
SecretListView,
SecretUpdate,
SecretView,
)
@ -27,7 +27,7 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
@app.get("/secrets/")
async def get_secret_names(
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> list[Secret]:
) -> list[SecretListView]:
"""Get Secret Names."""
return await admin.get_secrets()

View File

@ -3,6 +3,8 @@
{% include '/secrets/partials/client_list_inner.html.j2' %}
</ul>
</div>
{% if secret.secret %}
<div class="w-full my-2" id="secretclientaction">
{% include '/secrets/partials/client_assign_button.html.j2' %}
{% endif %}
</div>

View File

@ -1,7 +1,9 @@
<div class="w-full">
<div class="mb-4">
<h3 class="text-xl font-semibold dark:text-white">Group {{name}}</h3>
{% if description %}
<span class="text-sm text-gray-500 dark:text-gray-400">{{ description }}</span>
{% endif %}
</div>
<sl-details summary="Create secret">

View File

@ -31,6 +31,12 @@
<h3 class="mb-4 text-xl font-semibold dark:text-white">{{secret.name}}</h3>
{% if secret.description %}
<span class="text-sm text-gray-500 dark:text-gray-400">{{ secret.description }}</span>
{% endif %}
{% if not secret.secret %}
<p class="text-sm text-gray-500 dark:text-gray-400 italic">This secret was created outside of sshecret-admin. It cannot be decrypted, and therefore fewer options are available here.</p>
{% endif %}
<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">
@ -46,6 +52,7 @@
{% include '/secrets/partials/client_secret_details.html.j2' %}
</div>
</sl-details>
{% if secret.secret %}
<sl-details summary="Read/Update Secret">
<div id="secretvalue">
<div class="mb-6">
@ -103,6 +110,7 @@
</form>
</sl-details>
{% endif %}
{% 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">

View File

@ -64,6 +64,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
):
groups = await admin.get_secret_groups()
LOG.info("Groups: %s", groups.model_dump_json(indent=2))
return templates.TemplateResponse(
request,
"secrets/index.html.j2",
@ -73,46 +74,46 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
},
)
@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",
{
"group_path_nodes": [],
"clients": clients,
},
)
# @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",
# {
# "group_path_nodes": [],
# "clients": clients,
# },
# )
@app.get("/secrets/partial/secret/{name}")
async def get_secret_tree_detail_partial(
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)
# @app.get("/secrets/partial/secret/{name}")
# async def get_secret_tree_detail_partial(
# 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,
},
)
# 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/group/")
async def show_root_group(
@ -573,7 +574,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Add a secret to a client."""
await admin.create_client_secret(client, name)
await admin.create_client_secret(("id", client), name)
secret = await admin.get_secret(name)
if not secret:
raise HTTPException(

View File

@ -26,6 +26,7 @@ from .models import (
ClientSecretGroup,
ClientSecretGroupList,
SecretClientMapping,
SecretListView,
SecretGroup,
SecretView,
)
@ -269,23 +270,32 @@ class AdminBackend:
except Exception as e:
raise BackendUnavailableError() from e
async def _get_secrets(self) -> list[Secret]:
async def _get_secrets(self) -> list[SecretListView]:
"""Get secrets.
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
"""
backend_secrets = await self.backend.get_secrets()
with self.password_manager() as password_manager:
all_secrets = password_manager.get_available_secrets()
admin_secrets = password_manager.get_available_secrets()
secrets = await self.backend.get_secrets()
backend_secret_names = [secret.name for secret in secrets]
for secret in all_secrets:
if secret not in backend_secret_names:
secrets.append(Secret(name=secret, clients=[]))
secrets: dict[str, SecretListView] = {}
for secret in backend_secrets:
secrets[secret.name] = SecretListView(
name=secret.name, unmanaged=True, clients=secret.clients
)
return secrets
for secret_name in admin_secrets:
if secret_name in secrets:
secrets[secret_name].unmanaged = False
continue
secrets[secret_name] = SecretListView(
name=secret_name, unmanaged=False, clients=[]
)
async def get_secrets(self) -> list[Secret]:
return list(secrets.values())
async def get_secrets(self) -> list[SecretListView]:
"""Get secrets from backend."""
try:
return await self._get_secrets()
@ -385,6 +395,8 @@ class AdminBackend:
)
ungrouped = password_manager.get_ungrouped_secrets()
all_admin_secrets = password_manager.get_available_secrets()
group_result: list[ClientSecretGroup] = []
for group in all_groups:
# We have to do this recursively.
@ -401,7 +413,19 @@ class AdminBackend:
mapping.clients = client_mapping.clients
ungrouped_clients.append(mapping)
# We need to process unmanaged secrets too.
unmanaged_secrets = [
secret for secret in all_secrets if secret.name not in all_admin_secrets
]
for secret in unmanaged_secrets:
ungrouped_clients.append(
SecretClientMapping(
name=secret.name, unmanaged=True, clients=secret.clients
)
)
result.ungrouped = ungrouped_clients
return result
async def get_secret_group(self, name: str) -> ClientSecretGroup | None:
@ -436,13 +460,13 @@ class AdminBackend:
self, name: str, secret_id: str | None = None
) -> SecretView | None:
"""Get a secret, including the actual unencrypted value and clients."""
secret: str | None = None
with self.password_manager() as password_manager:
secret = password_manager.get_secret(name)
secret_group = password_manager.get_entry_group(name)
if not secret:
return None
secret_view = SecretView(name=name, secret=secret, group=secret_group)
idname: KeySpec = name
if secret_id:
idname = ("id", secret_id)
@ -529,11 +553,13 @@ class AdminBackend:
except Exception as e:
raise BackendUnavailableError() from e
async def _create_client_secret(self, client_name: str, secret_name: str) -> None:
async def _create_client_secret(
self, client_idname: KeySpec, secret_name: str
) -> None:
"""Create client secret."""
client = await self.get_client(client_name)
client = await self.get_client(client_idname)
if not client:
raise ClientNotFoundError()
raise ClientNotFoundError(client_idname)
with self.password_manager() as password_manager:
secret = password_manager.get_secret(secret_name)
@ -542,12 +568,14 @@ class AdminBackend:
public_key = load_public_key(client.public_key.encode())
encrypted = encrypt_string(secret, public_key)
await self.backend.create_client_secret(client_name, secret_name, encrypted)
await self.backend.create_client_secret(client_idname, secret_name, encrypted)
async def create_client_secret(self, client_name: str, secret_name: str) -> None:
async def create_client_secret(
self, client_idname: KeySpec, secret_name: str
) -> None:
"""Create client secret."""
try:
await self._create_client_secret(client_name, secret_name)
await self._create_client_secret(client_idname, secret_name)
except ClientManagementError:
raise
except Exception as e:

View File

@ -25,6 +25,7 @@ class SecretListView(BaseModel):
"""Model containing a list of all available secrets."""
name: str
unmanaged: bool = False
clients: list[str] = Field(default_factory=list) # Clients that have access to it.
@ -32,7 +33,7 @@ class SecretView(BaseModel):
"""Model containing a secret, including its clear-text value."""
name: str
secret: str
secret: str | None
group: str | None = None
clients: list[str] = Field(default_factory=list) # Clients that have access to it.
@ -134,6 +135,7 @@ class SecretClientMapping(BaseModel):
"""Secret name with list of clients."""
name: str # name of secret
unmanaged: bool = False
clients: list[ClientReference] = Field(default_factory=list)