Integrate group in admin rest API

This commit is contained in:
2025-05-31 14:13:49 +02:00
parent 18f61631c9
commit 773a1e2976
8 changed files with 352 additions and 15 deletions

View File

@ -3,13 +3,15 @@
# 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,
SecretCreate, SecretCreate,
SecretGroupCreate,
SecretUpdate, SecretUpdate,
SecretView, SecretView,
) )
@ -19,7 +21,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 +36,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 +74,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,
) -> list[ClientSecretGroup]:
"""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

View File

@ -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)],

View File

@ -3,6 +3,7 @@
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@9.0.3"></script> <script src="https://cdn.jsdelivr.net/npm/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>

View File

@ -19,3 +19,14 @@
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');"
/>

View File

@ -23,7 +23,7 @@ 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, SecretClientMapping, SecretGroup, SecretView
class ClientManagementError(Exception): class ClientManagementError(Exception):
@ -45,6 +45,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 +309,74 @@ 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,
) -> list[ClientSecretGroup]:
"""Get secret groups.
The starting group can be filtered with the group_name argument, which
may be a regular expression.
"""
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:
all_groups = password_manager.get_secret_groups(group_filter, regex=regex)
result: list[ClientSecretGroup] = []
for group in all_groups:
# We have to do this recursively.
result.append(add_clients_to_secret_group(group, secrets_mapping))
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 not matches:
return None
return matches[0]
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:
@ -322,11 +422,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 +453,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:

View File

@ -32,15 +32,29 @@ def create_password_db(location: Path, password: str) -> None:
def _kp_group_to_secret_group( def _kp_group_to_secret_group(
kp_group: pykeepass.group.Group, parent: SecretGroup | None = None kp_group: pykeepass.group.Group, parent: SecretGroup | None = None, depth: int | None = None
) -> SecretGroup: ) -> SecretGroup:
"""Convert keepass group to secret group dataclass.""" """Convert keepass group to secret group dataclass."""
group_name = cast(str, kp_group.name) group_name = cast(str, kp_group.name)
group = SecretGroup(name=group_name, description=kp_group.notes) 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: if parent:
group.parent_group = 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: for subgroup in kp_group.subgroups:
group.children.append(_kp_group_to_secret_group(subgroup, group)) group.children.append(_kp_group_to_secret_group(subgroup, group, depth=depth))
return group return group
@ -120,7 +134,7 @@ class PasswordContext:
raise RuntimeError(f"Cannot get password for entry {entry_name}") raise RuntimeError(f"Cannot get password for entry {entry_name}")
def get_secret_groups(self, pattern: str | None = None) -> list[SecretGroup]: def get_secret_groups(self, pattern: str | None = None, regex: bool = True) -> list[SecretGroup]:
"""Get secret groups. """Get secret groups.
A regex pattern may be provided to filter groups. A regex pattern may be provided to filter groups.
@ -128,7 +142,7 @@ class PasswordContext:
if pattern: if pattern:
groups = cast( groups = cast(
list[pykeepass.group.Group], list[pykeepass.group.Group],
self.keepass.find_groups(name=pattern, regex=True), self.keepass.find_groups(name=pattern, regex=regex),
) )
else: else:
all_groups = cast(list[pykeepass.group.Group], self.keepass.groups) all_groups = cast(list[pykeepass.group.Group], self.keepass.groups)
@ -153,7 +167,7 @@ class PasswordContext:
f"Error: Cannot find a parent group named {parent_group}" f"Error: Cannot find a parent group named {parent_group}"
) )
kp_parent_group = query kp_parent_group = query
self.keepass.add_group(kp_parent_group, name, notes=description) self.keepass.add_group(destination_group=kp_parent_group, group_name=name, notes=description)
self.keepass.save() self.keepass.save()
def set_group_description(self, name: str, description: str) -> None: def set_group_description(self, name: str, description: str) -> None:

View File

@ -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:
@ -96,6 +97,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={
@ -107,6 +109,7 @@ class SecretCreate(SecretUpdate):
}, },
{ {
"name": "MySecret", "name": "MySecret",
"group": "MySecretGroup",
"value": "mysecretstring", "value": "mysecretstring",
}, },
] ]
@ -118,6 +121,34 @@ class SecretGroup(BaseModel):
"""A secret group.""" """A secret group."""
name: str name: str
path: str
description: str | None = None description: str | None = None
parent_group: "SecretGroup | None" = None parent_group: "SecretGroup | None" = None
children: list["SecretGroup"] = Field(default_factory=list) 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

View File

@ -124,6 +124,28 @@ def test_get_secret_groups_regex(password_database: pykeepass.PyKeePass) -> None
assert len(bar_groups) == 3 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: def test_add_group(password_database: pykeepass.PyKeePass) -> None:
"""Test add_group.""" """Test add_group."""
context = PasswordContext(password_database) context = PasswordContext(password_database)
@ -231,3 +253,16 @@ def test_delete_group(password_database: pykeepass.PyKeePass) -> None:
# Check if the secrets are still there. # Check if the secrets are still there.
secrets = context.get_available_secrets() secrets = context.get_available_secrets()
assert len(secrets) == 10 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