Complete admin package restructuring

This commit is contained in:
2025-05-10 08:28:15 +02:00
parent 4f970a3f71
commit 0a427b6a91
80 changed files with 1282 additions and 843 deletions

View File

@ -0,0 +1,8 @@
"""Services module.
This module contains business logic.
"""
from .admin_backend import AdminBackend
__all__ = ["AdminBackend"]

View File

@ -0,0 +1,430 @@
"""API for working with the clients.
Since we have a frontend and a REST API, it makes sense to have a generic library to work with the clients.
"""
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from sshecret.backend import AuditLog, Client, ClientFilter, Secret, SshecretBackend, Operation, SubSystem
from sshecret.backend.models import DetailedSecrets
from sshecret.backend.api import AuditAPI
from sshecret.crypto import encrypt_string, load_public_key
from .keepass import PasswordContext, load_password_manager
from sshecret_admin.core.settings import AdminServerSettings
from .models import SecretView
class ClientManagementError(Exception):
"""Base exception for client management operations."""
class ClientNotFoundError(ClientManagementError):
"""Client not found."""
class SecretNotFoundError(ClientManagementError):
"""Secret not found."""
class BackendUnavailableError(ClientManagementError):
"""Backend unavailable."""
LOG = logging.getLogger(__name__)
class AdminBackend:
"""Admin backend API."""
def __init__(self, settings: AdminServerSettings, keepass_password: str) -> None:
"""Create client management API."""
self.settings: AdminServerSettings = settings
self.backend: SshecretBackend = SshecretBackend(
str(settings.backend_url), settings.backend_token
)
self.keepass_password: str = keepass_password
@contextmanager
def password_manager(self) -> Iterator[PasswordContext]:
"""Open the password manager."""
with load_password_manager(self.settings, self.keepass_password) as kp:
yield kp
async def _get_clients(self, filter: ClientFilter | None = None) -> list[Client]:
"""Get clients from backend."""
return await self.backend.get_clients(filter)
async def get_clients(self, filter: ClientFilter | None = None) -> list[Client]:
"""Get clients from backend."""
try:
return await self._get_clients(filter)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _get_client(self, name: str) -> Client | None:
"""Get a client from the backend."""
return await self.backend.get_client(name)
async def _verify_client_exists(self, name: str) -> None:
"""Check that a client exists."""
client = await self.backend.get_client(name)
if not client:
raise ClientNotFoundError()
return None
async def verify_client_exists(self, name: str) -> None:
"""Check that a client exists."""
try:
await self._verify_client_exists(name)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def get_client(self, name: str) -> Client | None:
"""Get a client from the backend."""
try:
return await self._get_client(name)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _create_client(
self,
name: str,
public_key: str,
description: str | None = None,
sources: list[str] | None = None,
) -> Client:
"""Create client."""
await self.backend.create_client(name, public_key, description)
if sources:
await self.backend.update_client_sources(name, sources)
client = await self.get_client(name)
if not client:
raise ClientNotFoundError()
return client
async def create_client(
self,
name: str,
public_key: str,
description: str | None = None,
sources: list[str] | None = None,
) -> Client:
"""Create client."""
try:
return await self._create_client(name, public_key, description, sources)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _update_client_public_key(
self, name: str, new_key: str, password_manager: PasswordContext
) -> list[str]:
"""Update client public key."""
LOG.info(
"Updating client %s public key. This will invalidate all existing secrets."
)
client = await self.get_client(name)
if not client:
raise ClientNotFoundError()
await self.backend.update_client_key(name, new_key)
updated_secrets: list[str] = []
for secret in client.secrets:
LOG.debug("Re-encrypting secret %s for client %s", secret, name)
secret_value = password_manager.get_secret(secret)
if not secret_value:
LOG.warning(
"Referenced secret %s does not exist! Skipping.", secret_value
)
continue
rsa_public_key = load_public_key(client.public_key.encode())
encrypted = encrypt_string(secret_value, rsa_public_key)
LOG.debug("Sending new encrypted value to backend.")
await self.backend.create_client_secret(name, secret, encrypted)
updated_secrets.append(secret)
return updated_secrets
async def update_client_public_key(self, name: str, new_key: str) -> list[str]:
"""Update client public key."""
try:
with self.password_manager() as password_manager:
return await self._update_client_public_key(
name, new_key, password_manager
)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _update_client(self, new_client: Client) -> Client:
"""Update a client object."""
existing_client = await self.get_client(new_client.name)
if not existing_client:
raise ClientNotFoundError()
await self.backend.update_client(new_client)
if new_client.public_key != existing_client.public_key:
await self.update_client_public_key(new_client.name, new_client.public_key)
updated_client = await self.get_client(new_client.name)
if not updated_client:
raise ClientNotFoundError()
return updated_client
async def update_client(self, new_client: Client) -> Client:
"""Update a client object."""
try:
return await self._update_client(new_client)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def update_client_sources(self, name: str, sources: list[str]) -> None:
"""Update client sources."""
try:
await self.backend.update_client_sources(name, sources)
except Exception as e:
raise BackendUnavailableError() from e
async def _delete_client(self, name: str) -> None:
"""Delete client."""
await self.backend.delete_client(name)
async def delete_client(self, name: str) -> None:
"""Delete client."""
try:
await self._delete_client(name)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def delete_client_secret(self, client_name: str, secret_name: str) -> None:
"""Delete a secret from a client."""
try:
await self.backend.delete_client_secret(client_name, secret_name)
except Exception as e:
raise BackendUnavailableError() from e
async def _get_secrets(self) -> list[Secret]:
"""Get secrets.
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
"""
with self.password_manager() as password_manager:
all_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=[]))
return secrets
async def get_secrets(self) -> list[Secret]:
"""Get secrets from backend."""
try:
return await self._get_secrets()
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _get_detailed_secrets(self) -> list[DetailedSecrets]:
"""Get detailed secrets.
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
"""
with self.password_manager() as password_manager:
all_secrets = password_manager.get_available_secrets()
secrets = await self.backend.get_detailed_secrets()
backend_secret_names = [secret.name for secret in secrets]
for secret in all_secrets:
if secret not in backend_secret_names:
secrets.append(DetailedSecrets(name=secret, ids=[], clients=[]))
return secrets
async def get_detailed_secrets(self) -> list[DetailedSecrets]:
"""Get detailed secrets from backend."""
try:
return await self._get_detailed_secrets()
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def get_secret(self, name: str) -> SecretView | None:
"""Get secrets from backend."""
try:
return await self._get_secret(name)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _get_secret(self, name: str) -> SecretView | None:
"""Get a secret, including the actual unencrypted value and clients."""
with self.password_manager() as password_manager:
secret = password_manager.get_secret(name)
if not secret:
return None
secret_view = SecretView(name=name, secret=secret)
secret_mapping = await self.backend.get_secret(name)
if secret_mapping:
secret_view.clients = secret_mapping.clients
return secret_view
async def delete_secret(self, name: str) -> None:
"""Delete a secret."""
try:
return await self._delete_secret(name)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _delete_secret(self, name: str) -> None:
"""Delete a secret."""
with self.password_manager() as password_manager:
password_manager.delete_entry(name)
secret_mapping = await self.backend.get_secret(name)
if not secret_mapping:
return
for client in secret_mapping.clients:
LOG.info("Deleting secret %s from client %s", name, client)
await self.backend.delete_client_secret(client, name)
async def _add_secret(
self, name: str, value: str, clients: list[str] | None, update: bool = False
) -> None:
"""Add a secret."""
with self.password_manager() as password_manager:
password_manager.add_entry(name, value, update)
if update:
secret_map = await self.backend.get_secret(name)
if secret_map:
clients = secret_map.clients
if not clients:
return
for client_name in clients:
client = await self.get_client(client_name)
if not client:
if update:
raise ClientNotFoundError()
LOG.warning("Requested client %s not found!", client_name)
continue
public_key = load_public_key(client.public_key.encode())
encrypted = encrypt_string(value, public_key)
LOG.info("Wrote encrypted secret for client %s", client_name)
await self.backend.create_client_secret(client_name, name, encrypted)
async def add_secret(
self, name: str, value: str, clients: list[str] | None = None
) -> None:
"""Add a secret."""
try:
await self._add_secret(name, value, clients)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def update_secret(self, name: str, value: str) -> None:
"""Update secrets."""
try:
await self._add_secret(name, value, None, True)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _create_client_secret(self, client_name: str, secret_name: str) -> None:
"""Create client secret."""
client = await self.get_client(client_name)
if not client:
raise ClientNotFoundError()
with self.password_manager() as password_manager:
secret = password_manager.get_secret(secret_name)
if not secret:
raise SecretNotFoundError()
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)
async def create_client_secret(self, client_name: str, secret_name: str) -> None:
"""Create client secret."""
try:
await self._create_client_secret(client_name, secret_name)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
@property
def audit(self) -> AuditAPI:
"""Resolve audit API."""
return self.backend.audit(SubSystem.ADMIN)
async def get_audit_log(
self,
offset: int = 0,
limit: int = 100,
client_name: str | None = None,
subsystem: str | None = None,
) -> list[AuditLog]:
"""Get audit log from backend."""
return await self.audit.get(offset, limit, client_name, subsystem)
async def write_audit_message(
self,
operation: Operation,
message: str,
origin: str,
client: Client | None = None,
secret_name: str | None = None,
**data: str,
) -> None:
"""Write an audit message."""
await self.audit.write_async(
operation=operation,
message=message,
origin=origin,
client=client,
secret=None,
secret_name=secret_name,
**data,
)
async def write_audit_log(self, entry: AuditLog) -> None:
"""Write to the audit log."""
if not entry.subsystem:
entry.subsystem = SubSystem.ADMIN
await self.audit.write_model_async(entry)
#await self.backend.add_audit_log(entry)
async def get_audit_log_count(self) -> int:
"""Get audit log count."""
return await self.audit.count()

View File

@ -0,0 +1,116 @@
"""Keepass password manager."""
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import cast
import pykeepass
from .master_password import decrypt_master_password
from sshecret_admin.core.settings import AdminServerSettings
LOG = logging.getLogger(__name__)
NO_USERNAME = "NO_USERNAME"
DEFAULT_LOCATION = "keepass.kdbx"
def create_password_db(location: Path, password: str) -> None:
"""Create the password database."""
LOG.info("Creating password database at %s", location)
pykeepass.create_database(str(location.absolute()), password=password)
class PasswordContext:
"""Password Context class."""
def __init__(self, keepass: pykeepass.PyKeePass) -> None:
"""Initialize password context."""
self.keepass: pykeepass.PyKeePass = keepass
def add_entry(self, entry_name: str, secret: str, overwrite: bool = False) -> None:
"""Add an entry.
Specify overwrite=True to overwrite the existing secret value, if it exists.
"""
entry = cast(
"pykeepass.entry.Entry | None",
self.keepass.find_entries(title=entry_name, first=True),
)
if entry and overwrite:
entry.password = secret
elif entry:
raise ValueError("Error: A secret with this name already exists.")
LOG.debug("Add secret entry to keepass: %s", entry_name)
entry = self.keepass.add_entry(
destination_group=self.keepass.root_group,
title=entry_name,
username=NO_USERNAME,
password=secret,
)
self.keepass.save()
def get_secret(self, entry_name: str) -> str | None:
"""Get the secret value."""
entry = cast(
"pykeepass.entry.Entry | None",
self.keepass.find_entries(title=entry_name, first=True),
)
if not entry:
return None
LOG.warning("Secret name %s accessed", entry_name)
if password := cast(str, entry.password):
return str(password)
raise RuntimeError(f"Cannot get password for entry {entry_name}")
def get_available_secrets(self) -> list[str]:
"""Get the names of all secrets in the database."""
entries = self.keepass.entries
if not entries:
return []
return [str(entry.title) for entry in entries]
def delete_entry(self, entry_name: str) -> None:
"""Delete entry."""
entry = cast(
"pykeepass.entry.Entry | None",
self.keepass.find_entries(title=entry_name, first=True),
)
if not entry:
return
entry.delete()
self.keepass.save()
@contextmanager
def _password_context(location: Path, password: str) -> Iterator[PasswordContext]:
"""Open the password context."""
database = pykeepass.PyKeePass(str(location.absolute()), password=password)
context = PasswordContext(database)
yield context
@contextmanager
def load_password_manager(
settings: AdminServerSettings,
encrypted_password: str,
location: str = DEFAULT_LOCATION,
) -> Iterator[PasswordContext]:
"""Load password manager.
This function decrypts the password, and creates the password database if it
has not yet been created.
"""
db_location = Path(location)
password = decrypt_master_password(settings=settings, encrypted=encrypted_password)
if not db_location.exists():
create_password_db(db_location, password)
with _password_context(db_location, password) as context:
yield context

View File

@ -0,0 +1,82 @@
"""Functions related to handling the password database master password."""
import secrets
from pathlib import Path
from sshecret.crypto import (
create_private_rsa_key,
load_private_key,
encrypt_string,
decode_string,
)
from sshecret_admin.core.settings import AdminServerSettings
KEY_FILENAME = "sshecret-admin-key"
def setup_master_password(
settings: AdminServerSettings,
filename: str = KEY_FILENAME,
regenerate: bool = False,
) -> str | None:
"""Setup master password.
If regenerate is True, a new key will be generated.
This method should run just after setting up the database.
"""
created = _initial_key_setup(settings, filename, regenerate)
if not created:
return None
return _generate_master_password(settings, filename)
def decrypt_master_password(
settings: AdminServerSettings, encrypted: str, filename: str = KEY_FILENAME
) -> str:
"""Retrieve master password."""
keyfile = Path(filename)
if not keyfile.exists():
raise RuntimeError("Error: Private key has not been generated yet.")
private_key = load_private_key(KEY_FILENAME, password=settings.secret_key)
return decode_string(encrypted, private_key)
def _generate_password() -> str:
"""Generate a password."""
return secrets.token_urlsafe(32)
def _initial_key_setup(
settings: AdminServerSettings,
filename: str = KEY_FILENAME,
regenerate: bool = False,
) -> bool:
"""Set up initial keys."""
keyfile = Path(filename)
if keyfile.exists() and not regenerate:
return False
assert settings.secret_key is not None, (
"Error: Could not load a secret key from environment."
)
create_private_rsa_key(keyfile, password=settings.secret_key)
return True
def _generate_master_password(
settings: AdminServerSettings, filename: str = KEY_FILENAME
) -> str:
"""Generate master password for password database.
Returns the encrypted string, base64 encoded.
"""
keyfile = Path(filename)
if not keyfile.exists():
raise RuntimeError("Error: Private key has not been generated yet.")
private_key = load_private_key(filename, password=settings.secret_key)
public_key = private_key.public_key()
master_password = _generate_password()
return encrypt_string(master_password, public_key)

View File

@ -0,0 +1,112 @@
"""Models for the API."""
import secrets
from typing import Annotated, Literal
from pydantic import (
AfterValidator,
BaseModel,
ConfigDict,
Field,
IPvAnyAddress,
IPvAnyNetwork,
)
from sshecret.crypto import validate_public_key
def public_key_validator(value: str) -> str:
"""Public key validator."""
if validate_public_key(value):
return value
raise ValueError("Error: Public key must be a valid RSA public key.")
class SecretListView(BaseModel):
"""Model containing a list of all available secrets."""
name: str
clients: list[str] = Field(default_factory=list) # Clients that have access to it.
class SecretView(BaseModel):
"""Model containing a secret, including its clear-text value."""
name: str
secret: str
clients: list[str] = Field(default_factory=list) # Clients that have access to it.
class UpdateKeyModel(BaseModel):
"""Model for updating client public key."""
public_key: Annotated[str, AfterValidator(public_key_validator)]
class UpdateKeyResponse(BaseModel):
"""Response model after updating the public key."""
public_key: str
updated_secrets: list[str] = Field(default_factory=list)
detail: str | None = None
class UpdatePoliciesRequest(BaseModel):
"""Update policy request."""
sources: list[IPvAnyAddress | IPvAnyNetwork]
class ClientCreate(BaseModel):
"""Model to create a client."""
name: str
public_key: Annotated[str, AfterValidator(public_key_validator)]
sources: list[IPvAnyAddress | IPvAnyNetwork] = Field(default_factory=list)
class AutoGenerateOpts(BaseModel):
"""Option to auto-generate a password."""
auto_generate: Literal[True]
length: int = 32
class SecretUpdate(BaseModel):
"""Model to update a secret."""
value: str | AutoGenerateOpts = Field(
description="Secret as string value or auto-generated with optional length",
examples=["MySecretString", {"auto_generate": True, "length": 32}]
)
def get_secret(self) -> str:
"""Get secret.
This returns the specified one, or generates one according to auto-generation.
"""
if isinstance(self.value, str):
return self.value
secret = secrets.token_urlsafe(self.value.length)
return secret
class SecretCreate(SecretUpdate):
"""Model to create a secret."""
name: str
clients: list[str] | None = Field(default=None, description="Assign the secret to a list of clients.")
model_config: ConfigDict = ConfigDict(
json_schema_extra={
"examples": [
{
"name": "MySecret",
"clients": ["client-1", "client-2"],
"value": { "auto_generate": True, "length": 32 }
},
{
"name": "MySecret",
"value": "mysecretstring",
}
]
}
)