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

View File

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

View File

@ -1,7 +1,9 @@
<div class="w-full"> <div class="w-full">
<div class="mb-4"> <div class="mb-4">
<h3 class="text-xl font-semibold dark:text-white">Group {{name}}</h3> <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> <span class="text-sm text-gray-500 dark:text-gray-400">{{ description }}</span>
{% endif %}
</div> </div>
<sl-details summary="Create secret"> <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> <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 class="htmx-indicator secret-spinner">
<div role="status"> <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"> <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' %} {% include '/secrets/partials/client_secret_details.html.j2' %}
</div> </div>
</sl-details> </sl-details>
{% if secret.secret %}
<sl-details summary="Read/Update Secret"> <sl-details summary="Read/Update Secret">
<div id="secretvalue"> <div id="secretvalue">
<div class="mb-6"> <div class="mb-6">
@ -103,6 +110,7 @@
</form> </form>
</sl-details> </sl-details>
{% endif %} {% endif %}
{% endif %}
<sl-details summary="Events"> <sl-details summary="Events">
<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">

View File

@ -64,6 +64,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
): ):
groups = await admin.get_secret_groups() groups = await admin.get_secret_groups()
LOG.info("Groups: %s", groups.model_dump_json(indent=2))
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"secrets/index.html.j2", "secrets/index.html.j2",
@ -73,46 +74,46 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
}, },
) )
@app.get("/secrets/partial/root_group") # @app.get("/secrets/partial/root_group")
async def get_root_group( # async def get_root_group(
request: Request, # request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], # admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
): # ):
"""Get root group.""" # """Get root group."""
clients = await admin.get_clients() # clients = await admin.get_clients()
return templates.TemplateResponse( # return templates.TemplateResponse(
request, # request,
"secrets/partials/edit_root.html.j2", # "secrets/partials/edit_root.html.j2",
{ # {
"group_path_nodes": [], # "group_path_nodes": [],
"clients": clients, # "clients": clients,
}, # },
) # )
@app.get("/secrets/partial/secret/{name}") # @app.get("/secrets/partial/secret/{name}")
async def get_secret_tree_detail_partial( # async def get_secret_tree_detail_partial(
request: Request, # request: Request,
name: str, # name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], # admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
): # ):
"""Get partial secret detail.""" # """Get partial secret detail."""
secret = await admin.get_secret(name) # secret = await admin.get_secret(name)
groups = await admin.get_secret_groups(flat=True) # groups = await admin.get_secret_groups(flat=True)
events = await admin.get_audit_log_detailed(limit=10, secret_name=name) # events = await admin.get_audit_log_detailed(limit=10, secret_name=name)
if not secret: # if not secret:
raise HTTPException( # raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found" # status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
) # )
return templates.TemplateResponse( # return templates.TemplateResponse(
request, # request,
"secrets/partials/tree_detail.html.j2", # "secrets/partials/tree_detail.html.j2",
{ # {
"secret": secret, # "secret": secret,
"groups": groups, # "groups": groups,
"events": events, # "events": events,
}, # },
) # )
@app.get("/secrets/group/") @app.get("/secrets/group/")
async def show_root_group( async def show_root_group(
@ -573,7 +574,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
): ):
"""Add a secret to a client.""" """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) secret = await admin.get_secret(name)
if not secret: if not secret:
raise HTTPException( raise HTTPException(

View File

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

View File

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