Support unmanaged secrets
This commit is contained in:
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ description }}</span>
|
{% if description %}
|
||||||
|
<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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user