Standardize IDs, fix group APIs, fix tests
This commit is contained in:
@ -1,7 +1,4 @@
|
||||
"""Client-related endpoints factory.
|
||||
|
||||
# TODO: Settle on name/keyspec pattern
|
||||
"""
|
||||
"""Client-related endpoints factory."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
@ -20,10 +17,15 @@ from sshecret_admin.services.models import (
|
||||
UpdatePoliciesRequest,
|
||||
)
|
||||
|
||||
from sshecret.backend.identifiers import ClientIdParam, FlexID, KeySpec
|
||||
from sshecret.backend.models import ClientQueryResult, ClientReference, FilterType
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
def _id(identifier: str) -> KeySpec:
|
||||
"""Parse ID."""
|
||||
parsed = FlexID.from_string(identifier)
|
||||
return parsed.keyspec
|
||||
|
||||
def query_filter_to_client_filter(query_filter: ClientListParams) -> ClientFilter:
|
||||
"""Convert query filter to client filter."""
|
||||
@ -95,11 +97,11 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
|
||||
@app.get("/clients/{id}")
|
||||
async def get_client(
|
||||
id: str,
|
||||
id: ClientIdParam,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> Client:
|
||||
"""Get a client."""
|
||||
client = await admin.get_client(("id", id))
|
||||
client = await admin.get_client(_id(id))
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||
@ -109,12 +111,12 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
|
||||
@app.put("/clients/{id}")
|
||||
async def update_client(
|
||||
id: str,
|
||||
id: ClientIdParam,
|
||||
updated: ClientCreate,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> Client:
|
||||
"""Update a client."""
|
||||
client = await admin.get_client(("id", id))
|
||||
client = await admin.get_client(_id(id))
|
||||
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
@ -132,20 +134,20 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
|
||||
@app.delete("/clients/{id}")
|
||||
async def delete_client(
|
||||
id: str,
|
||||
id: ClientIdParam,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Delete a client."""
|
||||
await admin.delete_client(("id", id))
|
||||
await admin.delete_client(_id(id))
|
||||
|
||||
@app.delete("/clients/{id}/secrets/{secret_name}")
|
||||
async def delete_secret_from_client(
|
||||
id: str,
|
||||
id: ClientIdParam,
|
||||
secret_name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Delete a secret from a client."""
|
||||
client = await admin.get_client(("id", id))
|
||||
client = await admin.get_client(_id(id))
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||
@ -164,7 +166,7 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> Client:
|
||||
"""Update the client access policies."""
|
||||
client = await admin.get_client(("id", id))
|
||||
client = await admin.get_client(_id(id))
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||
@ -182,7 +184,7 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
|
||||
@app.put("/clients/{id}/public-key")
|
||||
async def update_client_public_key(
|
||||
id: str,
|
||||
id: ClientIdParam,
|
||||
updated: UpdateKeyModel,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> UpdateKeyResponse:
|
||||
@ -193,7 +195,7 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
"""
|
||||
# Let's first ensure that the key is actually updated.
|
||||
updated_secrets = await admin.update_client_public_key(
|
||||
("id", id), updated.public_key
|
||||
_id(id), updated.public_key
|
||||
)
|
||||
return UpdateKeyResponse(
|
||||
public_key=updated.public_key, updated_secrets=updated_secrets
|
||||
@ -201,11 +203,11 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
|
||||
@app.put("/clients/{id}/secrets/{secret_name}")
|
||||
async def add_secret_to_client(
|
||||
id: str,
|
||||
id: ClientIdParam,
|
||||
secret_name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Add secret to a client."""
|
||||
await admin.create_client_secret(("id", id), secret_name)
|
||||
await admin.create_client_secret(_id(id), secret_name)
|
||||
|
||||
return app
|
||||
|
||||
@ -10,12 +10,19 @@ from sshecret_admin.services import AdminBackend
|
||||
from sshecret_admin.services.models import (
|
||||
ClientSecretGroup,
|
||||
ClientSecretGroupList,
|
||||
GroupPath,
|
||||
SecretCreate,
|
||||
SecretGroupAssign,
|
||||
SecretGroupCreate,
|
||||
SecretGroupUdate,
|
||||
SecretListView,
|
||||
SecretUpdate,
|
||||
SecretView,
|
||||
)
|
||||
from sshecret_admin.services.secret_manager import (
|
||||
InvalidGroupNameError,
|
||||
InvalidSecretNameError,
|
||||
)
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
@ -81,20 +88,50 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
filter_regex: Annotated[str | None, Query()] = None,
|
||||
) -> ClientSecretGroupList:
|
||||
"""Get secret groups."""
|
||||
return await admin.get_secret_groups(filter_regex)
|
||||
result = await admin.get_secret_groups(filter_regex)
|
||||
return result
|
||||
|
||||
@app.get("/secrets/groups/{group_name}/")
|
||||
@app.get("/secrets/groups/{group_path:path}/")
|
||||
async def get_secret_group(
|
||||
group_name: str,
|
||||
group_path: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> ClientSecretGroup:
|
||||
"""Get a specific secret group."""
|
||||
results = await admin.get_secret_groups(group_name, False)
|
||||
results = await admin.get_secret_group_by_path(group_path)
|
||||
if not results:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="No such group."
|
||||
)
|
||||
return results.groups[0]
|
||||
return results
|
||||
|
||||
@app.put("/secrets/groups/{group_path:path}/")
|
||||
async def update_secret_group(
|
||||
group_path: str,
|
||||
group: SecretGroupUdate,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> ClientSecretGroup:
|
||||
"""Update a secret group."""
|
||||
existing_group = await admin.lookup_secret_group(group_path)
|
||||
if not existing_group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="No such group."
|
||||
)
|
||||
|
||||
params: dict[str, str] = {}
|
||||
if name := group.name:
|
||||
params["name"] = name
|
||||
|
||||
if description := group.description:
|
||||
params["description"] = description
|
||||
|
||||
if parent := group.parent_group:
|
||||
params["parent"] = parent
|
||||
|
||||
new_group = await admin.update_secret_group(
|
||||
group_path,
|
||||
**params,
|
||||
)
|
||||
return new_group
|
||||
|
||||
@app.post("/secrets/groups/")
|
||||
async def add_secret_group(
|
||||
@ -108,16 +145,16 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
parent_group=group.parent_group,
|
||||
)
|
||||
|
||||
result = await admin.get_secret_group(group.name)
|
||||
result = await admin.lookup_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}/")
|
||||
@app.delete("/secrets/groups/{group_path:path}/")
|
||||
async def delete_secret_group(
|
||||
group_name: str,
|
||||
group_path: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Remove a group.
|
||||
@ -125,83 +162,55 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
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)
|
||||
group = await admin.get_secret_group_by_path(group_path)
|
||||
if not group:
|
||||
return
|
||||
await admin.delete_secret_group(group_name)
|
||||
await admin.delete_secret_group(group_path)
|
||||
|
||||
@app.post("/secrets/groups/{group_name}/{secret_name}")
|
||||
async def move_secret_to_group(
|
||||
group_name: str,
|
||||
secret_name: str,
|
||||
@app.post("/secrets/set-group")
|
||||
async def assign_secret_group(
|
||||
assignment: SecretGroupAssign,
|
||||
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:
|
||||
"""Assign a secret to a group or root."""
|
||||
try:
|
||||
await admin.set_secret_group(assignment.secret_name, assignment.group_path)
|
||||
except InvalidSecretNameError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="No such group."
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not fount"
|
||||
)
|
||||
except InvalidGroupNameError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Invalid group name"
|
||||
)
|
||||
|
||||
await admin.set_secret_group(secret_name, group_name)
|
||||
|
||||
@app.post("/secrets/group/{group_name}/parent/{parent_name}")
|
||||
@app.post("/secrets/move-group/{group_name:path}")
|
||||
async def move_group(
|
||||
group_name: str,
|
||||
parent_name: str,
|
||||
destination: GroupPath,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Move a group."""
|
||||
group = await admin.get_secret_group(group_name)
|
||||
group = await admin.lookup_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)
|
||||
parent_path: str | None = destination.path
|
||||
if destination.path == "/" or not destination.path:
|
||||
# / means root
|
||||
parent_path = None
|
||||
|
||||
@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}",
|
||||
)
|
||||
LOG.debug("Moving group %s to %r", group_name, parent_path)
|
||||
|
||||
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.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)
|
||||
if parent_path:
|
||||
parent_group = await admin.get_secret_group_by_path(destination.path)
|
||||
if not parent_group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No such group {parent_path}",
|
||||
)
|
||||
await admin.move_secret_group(group_name, parent_path)
|
||||
|
||||
return app
|
||||
|
||||
@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from sshecret_admin.services.admin_backend import AdminBackend
|
||||
from sshecret_admin.core.dependencies import BaseDependencies, AdminDependencies
|
||||
from sshecret_admin.auth import PasswordDB, User, decode_token
|
||||
from sshecret_admin.auth import User, decode_token
|
||||
from sshecret_admin.auth.constants import LOCAL_ISSUER
|
||||
|
||||
from .endpoints import auth, clients, secrets
|
||||
@ -93,18 +93,10 @@ def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||
|
||||
async def get_admin_backend(
|
||||
request: Request,
|
||||
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||
):
|
||||
"""Get admin backend API."""
|
||||
username = get_optional_username(request)
|
||||
origin = get_client_origin(request)
|
||||
password_db = session.scalars(
|
||||
select(PasswordDB).where(PasswordDB.id == 1)
|
||||
).first()
|
||||
if not password_db:
|
||||
raise HTTPException(
|
||||
500, detail="Error: The password manager has not yet been set up."
|
||||
)
|
||||
admin = AdminBackend(
|
||||
dependencies.settings,
|
||||
username=username,
|
||||
|
||||
@ -58,12 +58,14 @@ def create_admin_app(
|
||||
|
||||
def setup_password_manager() -> None:
|
||||
"""Setup password manager."""
|
||||
LOG.info("Setting up password manager")
|
||||
setup_private_key(settings, regenerate=False)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
"""Create database before starting the server."""
|
||||
if create_db:
|
||||
LOG.info("Setting up database")
|
||||
Base.metadata.create_all(engine)
|
||||
setup_password_manager()
|
||||
yield
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
"""Sshecret admin CLI helper."""
|
||||
|
||||
import asyncio
|
||||
import code
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from typing import cast
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
@ -14,10 +11,9 @@ from pydantic import ValidationError
|
||||
from sqlalchemy import select, create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sshecret_admin.auth.authentication import hash_password
|
||||
from sshecret_admin.auth.models import AuthProvider, PasswordDB, User
|
||||
from sshecret_admin.auth.models import AuthProvider, User
|
||||
from sshecret_admin.core.app import create_admin_app
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
from sshecret_admin.services.admin_backend import AdminBackend
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter(
|
||||
@ -143,36 +139,6 @@ def cli_run(
|
||||
)
|
||||
|
||||
|
||||
@cli.command("repl")
|
||||
@click.pass_context
|
||||
def cli_repl(ctx: click.Context) -> None:
|
||||
"""Run an interactive console."""
|
||||
settings = cast(AdminServerSettings, ctx.obj)
|
||||
engine = create_engine(settings.admin_db)
|
||||
with Session(engine) as session:
|
||||
password_db = session.scalars(
|
||||
select(PasswordDB).where(PasswordDB.id == 1)
|
||||
).first()
|
||||
|
||||
if not password_db:
|
||||
raise click.ClickException(
|
||||
"Error: Password database has not yet been setup. Start the server to finish setup."
|
||||
)
|
||||
|
||||
def run(func: Awaitable[Any]) -> Any:
|
||||
"""Run an async function."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(func)
|
||||
|
||||
admin = AdminBackend(settings, )
|
||||
locals = {
|
||||
"run": run,
|
||||
"admin": admin,
|
||||
}
|
||||
banner = "Sshecret-admin REPL\nAdmin backend API bound to 'admin'. Run async functions with run()"
|
||||
console = code.InteractiveConsole(locals=locals, local_exit=True)
|
||||
console.interact(banner=banner, exitmsg="Bye!")
|
||||
|
||||
@cli.command("openapi")
|
||||
@click.argument("destination", type=click.Path(file_okay=False, dir_okay=True, path_type=Path))
|
||||
@click.pass_context
|
||||
|
||||
@ -23,7 +23,7 @@ def setup_database(
|
||||
) -> tuple[Engine, Callable[[], Generator[Session, None, None]]]:
|
||||
"""Setup database."""
|
||||
|
||||
engine = create_engine(db_url, echo=True, future=True)
|
||||
engine = create_engine(db_url, echo=False, future=True)
|
||||
if db_url.drivername.startswith("sqlite"):
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
|
||||
@ -15,7 +15,7 @@ from sshecret_admin.core.settings import AdminServerSettings
|
||||
DBSessionDep = Callable[[], Generator[Session, None, None]]
|
||||
AsyncSessionDep = Callable[[], AsyncGenerator[AsyncSession, None]]
|
||||
|
||||
AdminDep = Callable[[Request, Session], AsyncGenerator[AdminBackend, None]]
|
||||
AdminDep = Callable[[Request], AsyncGenerator[AdminBackend, None]]
|
||||
|
||||
GetUserDep = Callable[[User], Awaitable[User]]
|
||||
|
||||
|
||||
@ -7,19 +7,18 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from sshecret_admin.auth.authentication import generate_user_info
|
||||
from sshecret_admin.auth.models import AuthProvider, IdentityClaims, LocalUserInfo
|
||||
from starlette.datastructures import URL
|
||||
|
||||
|
||||
from sshecret_admin.auth import PasswordDB, User, decode_token
|
||||
from sshecret_admin.auth import User, decode_token
|
||||
from sshecret_admin.auth.constants import LOCAL_ISSUER
|
||||
|
||||
from sshecret_admin.core.dependencies import BaseDependencies
|
||||
@ -50,18 +49,10 @@ def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||
|
||||
async def get_admin_backend(
|
||||
request: Request,
|
||||
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||
):
|
||||
"""Get admin backend API."""
|
||||
password_db = session.scalars(
|
||||
select(PasswordDB).where(PasswordDB.id == 1)
|
||||
).first()
|
||||
username = get_optional_username(request)
|
||||
origin = get_client_origin(request)
|
||||
if not password_db:
|
||||
raise HTTPException(
|
||||
500, detail="Error: The password manager has not yet been set up."
|
||||
)
|
||||
admin = AdminBackend(
|
||||
dependencies.settings,
|
||||
username=username,
|
||||
|
||||
@ -6,6 +6,7 @@ Since we have a frontend and a REST API, it makes sense to have a generic librar
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Unpack
|
||||
|
||||
from sshecret.backend import (
|
||||
AuditLog,
|
||||
@ -16,15 +17,21 @@ from sshecret.backend import (
|
||||
Operation,
|
||||
SubSystem,
|
||||
)
|
||||
from sshecret.backend.identifiers import KeySpec
|
||||
from sshecret.backend.models import ClientQueryResult, ClientReference, DetailedSecrets
|
||||
from sshecret.backend.api import AuditAPI, KeySpec
|
||||
from sshecret.backend.api import AuditAPI
|
||||
from sshecret.crypto import encrypt_string, load_public_key
|
||||
|
||||
from .secret_manager import AsyncSecretContext, password_manager_context
|
||||
from .secret_manager import (
|
||||
AsyncSecretContext,
|
||||
SecretUpdateParams,
|
||||
password_manager_context,
|
||||
)
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
from .models import (
|
||||
ClientSecretGroup,
|
||||
ClientSecretGroupList,
|
||||
GroupReference,
|
||||
SecretClientMapping,
|
||||
SecretListView,
|
||||
SecretGroup,
|
||||
@ -57,11 +64,14 @@ def add_clients_to_secret_group(
|
||||
parent: ClientSecretGroup | None = None,
|
||||
) -> ClientSecretGroup:
|
||||
"""Add client information to a secret group."""
|
||||
parent_ref = None
|
||||
if parent:
|
||||
parent_ref = parent.reference()
|
||||
client_secret_group = ClientSecretGroup(
|
||||
group_name=group.name,
|
||||
path=group.path,
|
||||
description=group.description,
|
||||
parent_group=parent,
|
||||
parent_group=parent_ref,
|
||||
)
|
||||
for entry in group.entries:
|
||||
secret_entries = SecretClientMapping(name=entry)
|
||||
@ -74,12 +84,11 @@ def 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,
|
||||
reference = GroupReference(
|
||||
group_name=group.parent_group.name, path=group.parent_group.path
|
||||
)
|
||||
client_secret_group.parent_group = reference
|
||||
return client_secret_group
|
||||
|
||||
|
||||
@ -371,28 +380,29 @@ class AdminBackend:
|
||||
async with self.secrets_manager() as password_manager:
|
||||
await password_manager.set_secret_group(secret_name, group_name)
|
||||
|
||||
async def move_secret_group(
|
||||
self, group_name: str, parent_group: str | None
|
||||
) -> None:
|
||||
async def move_secret_group(self, group_name: str, parent_group: str | None) -> str:
|
||||
"""Move a group.
|
||||
|
||||
If parent_group is None, it will be moved to the root.
|
||||
Returns the new path of the group.
|
||||
"""
|
||||
async with self.secrets_manager() as password_manager:
|
||||
await password_manager.move_group(group_name, parent_group)
|
||||
new_path = await password_manager.move_group(group_name, parent_group)
|
||||
|
||||
return new_path
|
||||
|
||||
async def set_group_description(self, group_name: str, description: str) -> None:
|
||||
"""Set a group description."""
|
||||
async with self.secrets_manager() as password_manager:
|
||||
await password_manager.set_group_description(group_name, description)
|
||||
|
||||
async def delete_secret_group(self, group_name: str) -> None:
|
||||
async def delete_secret_group(self, group_path: str) -> None:
|
||||
"""Delete a group.
|
||||
|
||||
If keep_entries is set to False, all entries in the group will be deleted.
|
||||
"""
|
||||
async with self.secrets_manager() as password_manager:
|
||||
await password_manager.delete_group(group_name)
|
||||
await password_manager.delete_group(group_path)
|
||||
|
||||
async def get_secret_groups(
|
||||
self,
|
||||
@ -453,6 +463,23 @@ class AdminBackend:
|
||||
|
||||
return result
|
||||
|
||||
async def update_secret_group(
|
||||
self, group_path: str, **params: Unpack[SecretUpdateParams]
|
||||
) -> ClientSecretGroup:
|
||||
"""Update secret group."""
|
||||
async with self.secrets_manager() as password_manager:
|
||||
secret_group = await password_manager.update_group(group_path, **params)
|
||||
|
||||
all_secrets = await self.backend.get_detailed_secrets()
|
||||
secrets_mapping = {secret.name: secret for secret in all_secrets}
|
||||
return add_clients_to_secret_group(secret_group, secrets_mapping)
|
||||
|
||||
async def lookup_secret_group(self, name_path: str) -> ClientSecretGroup | None:
|
||||
"""Lookup a secret group."""
|
||||
if "/" in name_path:
|
||||
return await self.get_secret_group_by_path(name_path)
|
||||
return await self.get_secret_group(name_path)
|
||||
|
||||
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)
|
||||
@ -500,7 +527,10 @@ class AdminBackend:
|
||||
|
||||
secret_mapping = await self.backend.get_secret(idname)
|
||||
if secret_mapping:
|
||||
secret_view.clients = [ClientReference(id=ref.id, name=ref.name) for ref in secret_mapping.clients]
|
||||
secret_view.clients = [
|
||||
ClientReference(id=ref.id, name=ref.name)
|
||||
for ref in secret_mapping.clients
|
||||
]
|
||||
|
||||
return secret_view
|
||||
|
||||
|
||||
@ -144,16 +144,31 @@ class SecretClientMapping(BaseModel):
|
||||
clients: list[ClientReference] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GroupReference(BaseModel):
|
||||
"""Reference to a group.
|
||||
|
||||
This will be used for references to parent groups to avoid circular
|
||||
references.
|
||||
"""
|
||||
|
||||
group_name: str
|
||||
path: str
|
||||
|
||||
|
||||
class ClientSecretGroup(BaseModel):
|
||||
"""Client secrets grouped."""
|
||||
|
||||
group_name: str
|
||||
path: str
|
||||
description: str | None = None
|
||||
parent_group: "ClientSecretGroup | None" = None
|
||||
parent_group: GroupReference | None = None
|
||||
children: list["ClientSecretGroup"] = Field(default_factory=list)
|
||||
entries: list[SecretClientMapping] = Field(default_factory=list)
|
||||
|
||||
def reference(self) -> GroupReference:
|
||||
"""Create a reference."""
|
||||
return GroupReference(group_name=self.group_name, path=self.path)
|
||||
|
||||
|
||||
class SecretGroupCreate(BaseModel):
|
||||
"""Create model for creating secret groups."""
|
||||
@ -163,6 +178,14 @@ class SecretGroupCreate(BaseModel):
|
||||
parent_group: str | None = None
|
||||
|
||||
|
||||
class SecretGroupUdate(BaseModel):
|
||||
"""Update model for updating secret groups."""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
parent_group: str | None = None
|
||||
|
||||
|
||||
class ClientSecretGroupList(BaseModel):
|
||||
"""Secret group list."""
|
||||
|
||||
@ -196,3 +219,19 @@ class ClientListParams(BaseModel):
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class SecretGroupAssign(BaseModel):
|
||||
"""Model for assigning secrets to a group.
|
||||
|
||||
If group is None, then it will be placed in the root.
|
||||
"""
|
||||
|
||||
secret_name: str
|
||||
group_path: str | None
|
||||
|
||||
|
||||
class GroupPath(BaseModel):
|
||||
"""Path to a group."""
|
||||
|
||||
path: str = Field(pattern="^/.*")
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import NotRequired, TypedDict, Unpack
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
@ -16,7 +17,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, aliased
|
||||
from sshecret.backend import SshecretBackend
|
||||
from sshecret.backend.api import AuditAPI, KeySpec
|
||||
from sshecret.backend.api import AuditAPI
|
||||
from sshecret.backend.identifiers import KeySpec
|
||||
from sshecret.backend.models import Client, ClientSecret, Operation, SubSystem
|
||||
from sshecret.crypto import (
|
||||
create_private_rsa_key,
|
||||
@ -91,6 +93,14 @@ class SecretDataExport(BaseModel):
|
||||
groups: list[SecretDataGroupExport]
|
||||
|
||||
|
||||
class SecretUpdateParams(TypedDict):
|
||||
"""Secret update parameters."""
|
||||
|
||||
name: NotRequired[str]
|
||||
description: NotRequired[str]
|
||||
parent: NotRequired[str]
|
||||
|
||||
|
||||
def split_path(path: str) -> list[str]:
|
||||
"""Split a path into a list of groups."""
|
||||
elements = path.split("/")
|
||||
@ -201,7 +211,9 @@ class AsyncSecretContext:
|
||||
"""Build a group tree."""
|
||||
path = "/"
|
||||
if parent:
|
||||
path = os.path.join(parent.path, path)
|
||||
path = parent.path
|
||||
|
||||
path = os.path.join(path, group.name)
|
||||
secret_group = SecretGroup(
|
||||
name=group.name, path=path, description=group.description
|
||||
)
|
||||
@ -217,6 +229,8 @@ class AsyncSecretContext:
|
||||
parent_group = await self._get_group_by_id(group.parent.id)
|
||||
assert parent_group is not None
|
||||
parent = await self._build_group_tree(parent_group, depth=current_depth)
|
||||
path = os.path.join(parent.path, group.name)
|
||||
secret_group.path = path
|
||||
parent.children.append(secret_group)
|
||||
secret_group.parent_group = parent
|
||||
|
||||
@ -224,6 +238,14 @@ class AsyncSecretContext:
|
||||
return secret_group
|
||||
|
||||
for subgroup in group.children:
|
||||
LOG.debug(
|
||||
"group: %s, subgroup: %s path=%r, group_path: %r, parent: %r",
|
||||
group.name,
|
||||
subgroup.name,
|
||||
path,
|
||||
secret_group.path,
|
||||
bool(parent),
|
||||
)
|
||||
child_group = await self._get_group_by_id(subgroup.id)
|
||||
assert child_group is not None
|
||||
secret_subgroup = await self._build_group_tree(
|
||||
@ -462,6 +484,13 @@ class AsyncSecretContext:
|
||||
result = await self.session.scalars(statement)
|
||||
return result.one()
|
||||
|
||||
async def _lookup_group(self, name_path: str) -> Group | None:
|
||||
"""Lookup group by path."""
|
||||
if "/" in name_path:
|
||||
elements = parse_path(name_path)
|
||||
return await self._get_group(elements.item, elements.parent)
|
||||
return await self._get_group(name_path)
|
||||
|
||||
async def _get_group(
|
||||
self, name: str, parent: str | None = None, exact_match: bool = False
|
||||
) -> Group | None:
|
||||
@ -528,7 +557,7 @@ class AsyncSecretContext:
|
||||
parent_group = elements.parent
|
||||
|
||||
if parent_group:
|
||||
if parent := (await self._get_group(parent_group)):
|
||||
if parent := (await self._lookup_group(parent_group)):
|
||||
child_names = [child.name for child in parent.children]
|
||||
if group_name in child_names:
|
||||
raise InvalidGroupNameError(
|
||||
@ -554,6 +583,63 @@ class AsyncSecretContext:
|
||||
# We don't audit-log this operation.
|
||||
await self.session.commit()
|
||||
|
||||
async def update_group(
|
||||
self, name_path: str, **params: Unpack[SecretUpdateParams]
|
||||
) -> SecretGroup:
|
||||
"""Perform a complete update of a group.
|
||||
|
||||
This allows a patch operation. Only keyword arguments added will be considered.
|
||||
"""
|
||||
group = await self._lookup_group(name_path)
|
||||
|
||||
if not group:
|
||||
raise InvalidGroupNameError("Invalid or non-existing parent group name.")
|
||||
if description := params.get("description"):
|
||||
group.description = description
|
||||
|
||||
target_name = group.name
|
||||
rename = False
|
||||
if new_name := params.get("name"):
|
||||
target_name = new_name
|
||||
if target_name != group.name:
|
||||
rename = True
|
||||
|
||||
parent_group: Group | None = None
|
||||
move_to_root = False
|
||||
if parent := params.get("parent"):
|
||||
if parent == "/":
|
||||
group.parent = None
|
||||
move_to_root = True
|
||||
if rename:
|
||||
groups = await self._get_groups(root_groups=True)
|
||||
root_names = [x.name for x in groups]
|
||||
if target_name in root_names:
|
||||
raise InvalidGroupNameError("Name is already in use")
|
||||
|
||||
else:
|
||||
new_parent_group = await self._lookup_group(parent)
|
||||
if not new_parent_group:
|
||||
raise InvalidGroupNameError(
|
||||
"Invalid or non-existing parent group name."
|
||||
)
|
||||
parent_group = new_parent_group
|
||||
group.parent_id = new_parent_group.id
|
||||
elif group.parent_id and not move_to_root:
|
||||
parent_group = await self._get_group_by_id(group.parent_id)
|
||||
|
||||
if parent_group and rename and not move_to_root:
|
||||
child_names = [child.name for child in parent_group.children]
|
||||
if target_name in child_names:
|
||||
raise InvalidGroupNameError(
|
||||
f"Parent group {parent_group.name} already has a group with this name: {target_name}. Params: {params !r}"
|
||||
)
|
||||
group.name = target_name
|
||||
|
||||
self.session.add(group)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(group, ["parent"])
|
||||
return await self._build_group_tree(group)
|
||||
|
||||
async def set_group_description(self, path: str, description: str) -> None:
|
||||
"""Set group description."""
|
||||
elements = parse_path(path)
|
||||
@ -591,11 +677,12 @@ class AsyncSecretContext:
|
||||
managed_secret=entry,
|
||||
)
|
||||
|
||||
async def move_group(self, path: str, parent_group: str | None) -> None:
|
||||
async def move_group(self, path: str, parent_group: str | None) -> str:
|
||||
"""Move group.
|
||||
|
||||
If parent_group is None, it will be moved to the root.
|
||||
"""
|
||||
LOG.info("Move group: %s => %s", path, parent_group)
|
||||
elements = parse_path(path)
|
||||
group = await self._get_group(elements.item, elements.parent, True)
|
||||
if not group:
|
||||
@ -603,7 +690,7 @@ class AsyncSecretContext:
|
||||
|
||||
parent_group_id: uuid.UUID | None = None
|
||||
if parent_group:
|
||||
db_parent_group = await self._get_group(parent_group)
|
||||
db_parent_group = await self._lookup_group(parent_group)
|
||||
if not db_parent_group:
|
||||
raise InvalidGroupNameError("Invalid or non-existing parent group.")
|
||||
parent_group_id = db_parent_group.id
|
||||
@ -612,6 +699,9 @@ class AsyncSecretContext:
|
||||
|
||||
self.session.add(group)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(group)
|
||||
new_path = await self._get_group_path(group)
|
||||
return new_path
|
||||
|
||||
async def delete_group(self, path: str) -> None:
|
||||
"""Delete a group."""
|
||||
|
||||
Reference in New Issue
Block a user