Write new secret manager using existing RSA logic

This commit is contained in:
2025-06-22 17:17:56 +02:00
parent 5985a726e3
commit 82ec7fabb4
34 changed files with 2042 additions and 640 deletions

View File

@ -4,8 +4,8 @@ Since we have a frontend and a REST API, it makes sense to have a generic librar
"""
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from sshecret.backend import (
AuditLog,
@ -20,7 +20,7 @@ from sshecret.backend.models import ClientQueryResult, DetailedSecrets
from sshecret.backend.api import AuditAPI, KeySpec
from sshecret.crypto import encrypt_string, load_public_key
from .keepass import PasswordContext, load_password_manager
from .secret_manager import AsyncSecretContext, password_manager_context
from sshecret_admin.core.settings import AdminServerSettings
from .models import (
ClientSecretGroup,
@ -86,19 +86,27 @@ def add_clients_to_secret_group(
class AdminBackend:
"""Admin backend API."""
def __init__(self, settings: AdminServerSettings, keepass_password: str) -> None:
def __init__(
self,
settings: AdminServerSettings,
username: str | None = None,
origin: str = "UNKNOWN",
) -> 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
self.username: str = username or "UKNOWN_USER"
self.origin: str = origin
@contextmanager
def password_manager(self) -> Iterator[PasswordContext]:
"""Open the password manager."""
with load_password_manager(self.settings, self.keepass_password) as kp:
yield kp
@asynccontextmanager
async def secrets_manager(self) -> AsyncIterator[AsyncSecretContext]:
"""Open the secrets manager."""
async with password_manager_context(
self.settings, self.username, self.origin
) as manager:
yield manager
async def _get_clients(self, filter: ClientFilter | None = None) -> list[Client]:
"""Get clients from backend."""
@ -194,7 +202,7 @@ class AdminBackend:
self,
name: KeySpec,
new_key: str,
password_manager: PasswordContext,
password_manager: AsyncSecretContext,
) -> list[str]:
"""Update client public key."""
LOG.info(
@ -207,7 +215,7 @@ class AdminBackend:
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)
secret_value = await password_manager.get_secret(secret)
if not secret_value:
LOG.warning(
"Referenced secret %s does not exist! Skipping.", secret_value
@ -224,7 +232,7 @@ class AdminBackend:
async def update_client_public_key(self, name: KeySpec, new_key: str) -> list[str]:
"""Update client public key."""
try:
with self.password_manager() as password_manager:
async with self.secrets_manager() as password_manager:
return await self._update_client_public_key(
name, new_key, password_manager
)
@ -291,8 +299,8 @@ class AdminBackend:
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:
admin_secrets = password_manager.get_available_secrets()
async with self.secrets_manager() as password_manager:
admin_secrets = await password_manager.get_available_secrets()
secrets: dict[str, SecretListView] = {}
for secret in backend_secrets:
@ -324,8 +332,8 @@ class AdminBackend:
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()
async with self.secrets_manager() as password_manager:
all_secrets = await password_manager.get_available_secrets()
secrets = await self.backend.get_detailed_secrets()
backend_secret_names = [secret.name for secret in secrets]
@ -351,13 +359,13 @@ class AdminBackend:
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 with self.secrets_manager() as password_manager:
await 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 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
@ -366,23 +374,21 @@ class AdminBackend:
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 with self.secrets_manager() as password_manager:
await 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 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, keep_entries: bool = True
) -> None:
async def delete_secret_group(self, group_name: str) -> 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 with self.secrets_manager() as password_manager:
await password_manager.delete_group(group_name)
async def get_secret_groups(
self,
@ -399,18 +405,18 @@ class AdminBackend:
"""
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:
async with self.secrets_manager() as password_manager:
if flat:
all_groups = password_manager.get_secret_group_list(
all_groups = await password_manager.get_secret_group_list(
group_filter, regex=regex
)
else:
all_groups = password_manager.get_secret_groups(
all_groups = await password_manager.get_secret_groups(
group_filter, regex=regex
)
ungrouped = password_manager.get_ungrouped_secrets()
ungrouped = await password_manager.get_ungrouped_secrets()
all_admin_secrets = password_manager.get_available_secrets()
all_admin_secrets = await password_manager.get_available_secrets()
group_result: list[ClientSecretGroup] = []
for group in all_groups:
@ -452,8 +458,8 @@ class AdminBackend:
async def get_secret_group_by_path(self, path: str) -> ClientSecretGroup | None:
"""Get a group based on its path."""
with self.password_manager() as password_manager:
secret_group = password_manager.get_secret_group(path)
async with self.secrets_manager() as password_manager:
secret_group = await password_manager.get_secret_group(path)
if not secret_group:
return None
@ -476,9 +482,11 @@ class AdminBackend:
) -> SecretView | None:
"""Get a secret, including the actual unencrypted value and clients."""
secret: str | None = None
with self.password_manager() as password_manager:
secret = password_manager.get_secret(name)
secret_group = password_manager.get_entry_group(name)
async with self.secrets_manager() as password_manager:
secret = await password_manager.get_secret(name)
secret_group: str | None = None
if secret:
secret_group = await password_manager.get_entry_group(name)
secret_view = SecretView(name=name, secret=secret, group=secret_group)
@ -503,8 +511,8 @@ class AdminBackend:
async def _delete_secret(self, name: str) -> None:
"""Delete a secret."""
with self.password_manager() as password_manager:
password_manager.delete_entry(name)
async with self.secrets_manager() as password_manager:
await password_manager.delete_entry(name)
secret_mapping = await self.backend.get_secret(name)
if not secret_mapping:
@ -522,8 +530,8 @@ class AdminBackend:
group: str | None = None,
) -> None:
"""Add a secret."""
with self.password_manager() as password_manager:
password_manager.add_entry(name, value, update, group_name=group)
async with self.secrets_manager() as password_manager:
await password_manager.add_entry(name, value, update, group_path=group)
if update:
secret_map = await self.backend.get_secret(name)
@ -576,8 +584,8 @@ class AdminBackend:
if not client:
raise ClientNotFoundError(client_idname)
with self.password_manager() as password_manager:
secret = password_manager.get_secret(secret_name)
async with self.secrets_manager() as password_manager:
secret = await password_manager.get_secret(secret_name)
if not secret:
raise SecretNotFoundError()

View File

@ -1,348 +0,0 @@
"""Keepass password manager."""
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import cast
import pykeepass
import pykeepass.exceptions
from sshecret_admin.core.settings import AdminServerSettings
from .models import SecretGroup
from .master_password import decrypt_master_password
LOG = logging.getLogger(__name__)
NO_USERNAME = "NO_USERNAME"
DEFAULT_LOCATION = "keepass.kdbx"
class PasswordCredentialsError(Exception):
pass
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)
def _kp_group_to_secret_group(
kp_group: pykeepass.group.Group,
parent: SecretGroup | None = None,
depth: int | None = None,
) -> SecretGroup:
"""Convert keepass group to secret group dataclass."""
group_name = cast(str, kp_group.name)
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:
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:
group.children.append(_kp_group_to_secret_group(subgroup, group, depth=depth))
return group
class PasswordContext:
"""Password Context class."""
def __init__(self, keepass: pykeepass.PyKeePass) -> None:
"""Initialize password context."""
self.keepass: pykeepass.PyKeePass = keepass
@property
def _root_group(self) -> pykeepass.group.Group:
"""Return the root group."""
return cast(pykeepass.group.Group, self.keepass.root_group)
def _get_entry(self, name: str) -> pykeepass.entry.Entry | None:
"""Get entry."""
entry = cast(
"pykeepass.entry.Entry | None",
self.keepass.find_entries(title=name, first=True),
)
return entry
def _get_group(self, name: str) -> pykeepass.group.Group | None:
"""Find a group."""
group = cast(
pykeepass.group.Group | None,
self.keepass.find_groups(name=name, first=True),
)
return group
def add_entry(
self,
entry_name: str,
secret: str,
overwrite: bool = False,
group_name: str | None = None,
) -> None:
"""Add an entry.
Specify overwrite=True to overwrite the existing secret value, if it exists.
This will not move the entry, if the group_name is different from the original group.
"""
entry = self._get_entry(entry_name)
if entry and overwrite:
entry.password = secret
self.keepass.save()
return
if entry:
raise ValueError("Error: A secret with this name already exists.")
LOG.debug("Add secret entry to keepass: %s, group: %r", entry_name, group_name)
if group_name:
destination_group = self._get_group(group_name)
else:
destination_group = self._root_group
entry = self.keepass.add_entry(
destination_group=destination_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 = self._get_entry(entry_name)
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_entry_group(self, entry_name: str) -> str | None:
"""Get the group for an entry."""
entry = self._get_entry(entry_name)
if not entry:
return None
if entry.group.is_root_group:
return None
return str(entry.group.name)
def get_secret_groups(
self, pattern: str | None = None, regex: bool = True
) -> list[SecretGroup]:
"""Get secret groups.
A regex pattern may be provided to filter groups.
"""
if pattern:
groups = cast(
list[pykeepass.group.Group],
self.keepass.find_groups(name=pattern, regex=regex),
)
else:
groups = self._root_group.subgroups
secret_groups = [_kp_group_to_secret_group(group) for group in groups]
return secret_groups
def get_secret_group_list(
self, pattern: str | None = None, regex: bool = True
) -> list[SecretGroup]:
"""Get a flat list of groups."""
if pattern:
return self.get_secret_groups(pattern, regex)
groups = [group for group in self.keepass.groups if not group.is_root_group]
secret_groups = [_kp_group_to_secret_group(group) for group in groups]
return secret_groups
def get_secret_group(self, path: str) -> SecretGroup | None:
"""Get a secret group by path."""
elements = path.split("/")
final_element = elements[-1]
current = self._root_group
while elements:
groupname = elements.pop(0)
matches = [
subgroup for subgroup in current.subgroups if subgroup.name == groupname
]
if matches:
current = matches[0]
else:
return None
if not current.is_root_group and current.name == final_element:
return _kp_group_to_secret_group(current)
return None
def get_ungrouped_secrets(self) -> list[str]:
"""Get secrets without groups."""
entries: list[str] = []
for entry in self._root_group.entries:
entries.append(str(entry.title))
return entries
def add_group(
self, name: str, description: str | None = None, parent_group: str | None = None
) -> None:
"""Add a group."""
kp_parent_group = self._root_group
if parent_group:
query = cast(
pykeepass.group.Group | None,
self.keepass.find_groups(name=parent_group, first=True),
)
if not query:
raise ValueError(
f"Error: Cannot find a parent group named {parent_group}"
)
kp_parent_group = query
self.keepass.add_group(
destination_group=kp_parent_group, group_name=name, notes=description
)
self.keepass.save()
def set_group_description(self, name: str, description: str) -> None:
"""Set the description of a group."""
group = self._get_group(name)
if not group:
raise ValueError(f"Error: No such group {name}")
group.notes = description
self.keepass.save()
def set_secret_group(self, entry_name: str, group_name: str | None) -> None:
"""Move a secret to a group.
If group is None, the secret will be placed in the root group.
"""
entry = self._get_entry(entry_name)
if not entry:
raise ValueError(
f"Cannot find secret entry named {entry_name} in secrets database"
)
if group_name:
group = self._get_group(group_name)
if not group:
raise ValueError(f"Cannot find a group named {group_name}")
else:
group = self._root_group
self.keepass.move_entry(entry, group)
self.keepass.save()
def move_group(self, name: str, parent_group: str | None) -> None:
"""Move a group.
If parent_group is None, it will be moved to the root.
"""
group = self._get_group(name)
if not group:
raise ValueError(f"Error: No such group {name}")
if parent_group:
parent = self._get_group(parent_group)
if not parent:
raise ValueError(f"Error: No such group {parent_group}")
else:
parent = self._root_group
self.keepass.move_group(group, parent)
self.keepass.save()
def get_available_secrets(self, group_name: str | None = None) -> list[str]:
"""Get the names of all secrets in the database."""
if group_name:
group = self._get_group(group_name)
if not group:
raise ValueError(f"Error: No such group {group_name}")
entries = group.entries
else:
entries = cast(list[pykeepass.entry.Entry], 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()
def delete_group(self, 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.
"""
group = self._get_group(name)
if not group:
return
if keep_entries:
for entry in cast(
list[pykeepass.entry.Entry],
self.keepass.find_entries(recursive=True, group=group),
):
# Move the entry to the root group.
LOG.warning(
"Moving orphaned secret entry %s to root group", entry.title
)
self.keepass.move_entry(entry, self._root_group)
self.keepass.delete_group(group)
self.keepass.save()
@contextmanager
def _password_context(location: Path, password: str) -> Iterator[PasswordContext]:
"""Open the password context."""
try:
database = pykeepass.PyKeePass(str(location.absolute()), password=password)
except pykeepass.exceptions.CredentialsError as e:
raise PasswordCredentialsError(
"Could not open password database. Invalid credentials."
) from e
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

@ -1,86 +0,0 @@
"""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.
"""
keyfile = Path(filename)
if settings.password_manager_directory:
keyfile = settings.password_manager_directory / filename
created = _initial_key_setup(settings, keyfile, regenerate)
if not created:
return None
return _generate_master_password(settings, keyfile)
def decrypt_master_password(
settings: AdminServerSettings, encrypted: str, filename: str = KEY_FILENAME
) -> str:
"""Retrieve master password."""
keyfile = Path(filename)
if settings.password_manager_directory:
keyfile = settings.password_manager_directory / filename
if not keyfile.exists():
raise RuntimeError("Error: Private key has not been generated yet.")
private_key = load_private_key(
str(keyfile.absolute()), 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,
keyfile: Path,
regenerate: bool = False,
) -> bool:
"""Set up initial keys."""
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, keyfile: Path) -> str:
"""Generate master password for password database.
Returns the encrypted string, base64 encoded.
"""
if not keyfile.exists():
raise RuntimeError("Error: Private key has not been generated yet.")
private_key = load_private_key(
str(keyfile.absolute()), 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,776 @@
"""Rewritten secret manager using a rsa keys."""
import logging
import os
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import cached_property
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric import rsa
from pydantic import BaseModel
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.models import Client, ClientSecret, Operation, SubSystem
from sshecret.crypto import (
create_private_rsa_key,
decode_string,
encrypt_string,
generate_public_key_string,
load_private_key,
load_public_key,
)
from sshecret_admin.auth import PasswordDB
from sshecret_admin.auth.models import Group, ManagedSecret
from sshecret_admin.core.db import DatabaseSessionManager
from sshecret_admin.core.settings import AdminServerSettings
from sshecret_admin.services.models import SecretGroup
KEY_FILENAME = "sshecret-admin-key"
PASSWORD_MANAGER_ID = "SshecretAdminPasswordManager"
LOG = logging.getLogger(PASSWORD_MANAGER_ID)
class SecretManagerError(Exception):
"""Secret manager error."""
class InvalidGroupNameError(SecretManagerError):
"""Invalid group name."""
class InvalidSecretNameError(SecretManagerError):
"""Invalid secret name."""
@dataclass
class ClientAuditData:
"""Client audit data."""
username: str
origin: str
@dataclass
class ParsedPath:
"""Parsed path."""
item: str
full_path: str
parent: str | None = None
class SecretDataEntryExport(BaseModel):
"""Exportable secret entry."""
name: str
secret: str
group: str | None = None
class SecretDataGroupExport(BaseModel):
"""Exportable secret grouping."""
name: str
path: str
description: str | None = None
class SecretDataExport(BaseModel):
"""Exportable object containing secrets and groups."""
entries: list[SecretDataEntryExport]
groups: list[SecretDataGroupExport]
def split_path(path: str) -> list[str]:
"""Split a path into a list of groups."""
elements = path.split("/")
if path.startswith("/"):
elements = elements[1:]
return elements
def parse_path(path: str) -> ParsedPath:
"""Parse path."""
elements = split_path(path)
parsed = ParsedPath(elements[-1], path)
if len(elements) > 1:
parsed.parent = elements[-2]
return parsed
class AsyncSecretContext:
"""Async secret context."""
def __init__(
self,
private_key: rsa.RSAPrivateKey,
manager_client: Client,
session: AsyncSession,
backend: SshecretBackend,
audit_data: ClientAuditData,
) -> None:
"""Initialize secret manager"""
self._private_key: rsa.RSAPrivateKey = private_key
self._manager_client: Client = manager_client
self._id: KeySpec = ("id", str(manager_client.id))
self.backend: SshecretBackend = backend
self.session: AsyncSession = session
self.audit_data: ClientAuditData = audit_data
self.audit: AuditAPI = backend.audit(SubSystem.ADMIN)
self._import_has_run: bool = False
async def _create_missing_entries(self) -> None:
"""Create any missing entries."""
new_secrets: bool = False
to_check = set(self._manager_client.secrets)
for secret_name in to_check:
# entry = await self._get_entry(secret_name, include_deleted=True)
statement = select(ManagedSecret).where(ManagedSecret.name == secret_name)
result = await self.session.scalars(statement)
if not result.first():
new_secrets = True
managed_secret = ManagedSecret(name=secret_name)
self.session.add(managed_secret)
await self.session.flush()
await self.write_audit(
Operation.CREATE,
message="Imported managed secret from backend.",
secret_name=secret_name,
managed_secret=managed_secret,
)
if new_secrets:
await self.session.commit()
async def _get_group_depth(self, group: Group) -> int:
"""Get the depth of a group."""
depth = 1
if not group.parent_id:
return depth
current = group
while current.parent is not None:
if current.parent:
depth += 1
current = await self._get_group_by_id(current.parent.id)
else:
break
return depth
async def _get_group_path(self, group: Group) -> str:
"""Get the path of a group."""
if not group.parent_id:
return group.name
path: list[str] = []
current = group
while current.parent_id is not None:
path.append(current.name)
current = await self._get_group_by_id(current.parent_id)
path.append("")
path.reverse()
return "/".join(path)
async def _get_group_secrets(self, group: Group) -> list[ManagedSecret]:
"""Get secrets in a group."""
statement = (
select(ManagedSecret)
.where(ManagedSecret.group_id == group.id)
.where(ManagedSecret.is_deleted.is_not(True))
)
results = await self.session.scalars(statement)
return list(results.all())
async def _build_group_tree(
self, group: Group, parent: SecretGroup | None = None, depth: int | None = None
) -> SecretGroup:
"""Build a group tree."""
path = "/"
if parent:
path = os.path.join(parent.path, path)
secret_group = SecretGroup(
name=group.name, path=path, description=group.description
)
group_secrets = await self._get_group_secrets(group)
for secret in group_secrets:
secret_group.entries.append(secret.name)
if parent:
secret_group.parent_group = parent
current_depth = await self._get_group_depth(group)
if not parent and group.parent:
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)
parent.children.append(secret_group)
secret_group.parent_group = parent
if depth and depth == current_depth:
return secret_group
for subgroup in group.children:
child_group = await self._get_group_by_id(subgroup.id)
assert child_group is not None
secret_subgroup = await self._build_group_tree(
child_group, secret_group, depth=depth
)
secret_group.children.append(secret_subgroup)
return secret_group
async def write_audit(
self,
operation: Operation,
message: str,
group_name: str | None = None,
client_secret: ClientSecret | None = None,
secret_name: str | None = None,
managed_secret: ManagedSecret | None = None,
**data: str,
) -> None:
"""Write Audit message."""
if group_name:
data["group"] = group_name
data["username"] = self.audit_data.username
if client_secret and not secret_name:
secret_name = client_secret.name
if managed_secret:
data["managed_secret"] = str(managed_secret.id)
await self.audit.write_async(
operation=operation,
message=message,
origin=self.audit_data.origin,
client=self._manager_client,
secret=client_secret,
secret_name=secret_name,
**data,
)
@cached_property
def public_key(self) -> rsa.RSAPublicKey:
"""Get public key."""
keystring = self._manager_client.public_key
return load_public_key(keystring.encode())
async def _get_entry(
self, name: str, include_deleted: bool = False
) -> ManagedSecret | None:
"""Get managed secret."""
if not self._import_has_run:
await self._create_missing_entries()
self._import_has_run = True
statement = (
select(ManagedSecret)
.options(selectinload(ManagedSecret.group))
.where(ManagedSecret.name == name)
)
if not include_deleted:
statement = statement.where(ManagedSecret.is_deleted.is_not(True))
result = await self.session.scalars(statement)
return result.first()
async def add_entry(
self,
entry_name: str,
secret: str,
overwrite: bool = False,
group_path: str | None = None,
) -> None:
"""Add entry."""
existing_entry = await self._get_entry(entry_name)
if existing_entry and not overwrite:
raise InvalidSecretNameError(
"Another secret with this name is already defined."
)
encrypted = encrypt_string(secret, self.public_key)
client_secret = await self.backend.create_client_secret(
self._id, entry_name, encrypted
)
group_id: uuid.UUID | None = None
if group_path:
elements = parse_path(group_path)
group = await self._get_group(elements.item, elements.parent, True)
if not group:
raise InvalidGroupNameError("Invalid group name")
group_id = group.id
if existing_entry:
existing_entry.updated_at = datetime.now(timezone.utc)
if group_id:
existing_entry.group_id = group_id
self.session.add(existing_entry)
await self.session.commit()
await self.write_audit(
Operation.UPDATE,
"Updated secret value",
group_name=group_path,
client_secret=client_secret,
managed_secret=existing_entry,
)
else:
managed_secret = ManagedSecret(
name=entry_name,
group_id=group_id,
)
self.session.add(managed_secret)
await self.session.commit()
await self.write_audit(
Operation.CREATE,
"Created managed client secret",
group_path,
client_secret=client_secret,
managed_secret=managed_secret,
)
async def get_secret(self, entry_name: str) -> str | None:
"""Get secret."""
client_secret = await self.backend.get_client_secret(
self._id, ("name", entry_name)
)
if not client_secret:
return None
decrypted = decode_string(client_secret, self._private_key)
await self.write_audit(
Operation.READ,
"Secret was viewed from secret manager",
secret_name=entry_name,
)
return decrypted
async def get_available_secrets(self, group_path: str | None = None) -> list[str]:
"""Get the names of all secrets in the db."""
if not self._import_has_run:
await self._create_missing_entries()
if group_path:
elements = parse_path(group_path)
group = await self._get_group(elements.item, elements.parent)
if not group:
raise InvalidGroupNameError("Invalid or nonexisting group name.")
entries = group.secrets
else:
result = await self.session.scalars(
select(ManagedSecret)
.options(selectinload(ManagedSecret.group))
.where(ManagedSecret.is_deleted.is_not(True))
)
entries = list(result.all())
return [entry.name for entry in entries]
async def delete_entry(self, entry_name: str) -> None:
"""Delete a secret."""
entry = await self._get_entry(entry_name)
if not entry:
return
entry.is_deleted = True
entry.deleted_at = datetime.now(timezone.utc)
self.session.add(entry)
await self.session.commit()
await self.backend.delete_client_secret(
("id", str(self._manager_client.id)), ("name", entry_name)
)
await self.write_audit(
Operation.DELETE,
"Managed secret entry deleted",
secret_name=entry_name,
managed_secret=entry,
)
async def get_entry_group(self, entry_name: str) -> str | None:
"""Get group of entry."""
entry = await self._get_entry(entry_name)
if not entry:
raise InvalidSecretNameError("Invalid secret name or secret not found.")
if entry.group:
return entry.group.name
return None
async def _get_groups(
self, pattern: str | None = None, regex: bool = True, root_groups: bool = False
) -> list[Group]:
"""Get groups."""
statement = select(Group).options(
selectinload(Group.children), selectinload(Group.parent)
)
if pattern and regex:
statement = statement.where(Group.name.regexp_match(pattern))
elif pattern:
statement = statement.where(Group.name.contains(pattern))
if root_groups:
statement = statement.where(Group.parent_id == None)
results = await self.session.scalars(statement)
return list(results.all())
async def get_secret_groups(
self, pattern: str | None = None, regex: bool = True
) -> list[SecretGroup]:
"""Get secret groups, as a hierarcy."""
if pattern:
groups = await self._get_groups(pattern, regex)
else:
groups = await self._get_groups(root_groups=True)
secret_groups: list[SecretGroup] = []
for group in groups:
secret_group = await self._build_group_tree(group)
secret_groups.append(secret_group)
return secret_groups
async def get_secret_group_list(
self, pattern: str | None = None, regex: bool = True
) -> list[SecretGroup]:
"""Get secret group list."""
groups = await self._get_groups(pattern, regex)
return [(await self._build_group_tree(group)) for group in groups]
async def _get_group_by_id(self, id: uuid.UUID) -> Group:
"""Get group by ID."""
statement = (
select(Group)
.options(
selectinload(Group.parent),
selectinload(Group.children),
selectinload(Group.secrets),
)
.where(Group.id == id)
)
result = await self.session.scalars(statement)
return result.one()
async def _get_group(
self, name: str, parent: str | None = None, exact_match: bool = False
) -> Group | None:
"""Get a group."""
statement = (
select(Group)
.options(
selectinload(Group.parent),
selectinload(Group.children),
selectinload(Group.secrets),
)
.where(Group.name == name)
)
if parent:
ParentGroup = aliased(Group)
statement = statement.join(ParentGroup, Group.parent).where(
ParentGroup.name == parent
)
elif exact_match:
statement = statement.where(Group.parent_id == None)
result = await self.session.scalars(statement)
return result.first()
async def get_secret_group(self, path: str) -> SecretGroup | None:
"""Get a secret group by path."""
elements = parse_path(path)
group_name = elements.item
parent_group = elements.parent
group = await self._get_group(group_name, parent_group)
if not group:
return None
return await self._build_group_tree(group)
async def get_ungrouped_secrets(self) -> list[str]:
"""Get ungrouped secrets."""
statement = (
select(ManagedSecret)
.where(ManagedSecret.is_deleted.is_not(True))
.where(ManagedSecret.group_id == None)
)
result = await self.session.scalars(statement)
secrets = result.all()
return [secret.name for secret in secrets]
async def add_group(
self,
name_or_path: str,
description: str | None = None,
parent_group: str | None = None,
) -> None:
"""Add a group."""
parent_id: uuid.UUID | None = None
group_name = name_or_path
if parent_group and name_or_path.startswith("/"):
raise InvalidGroupNameError(
"Path as name cannot be used if parent is also specified."
)
if name_or_path.startswith("/"):
elements = parse_path(name_or_path)
group_name = elements.item
parent_group = elements.parent
if parent_group:
if parent := (await self._get_group(parent_group)):
child_names = [child.name for child in parent.children]
if group_name in child_names:
raise InvalidGroupNameError(
"Parent group already has a group with this name."
)
parent_id = parent.id
else:
raise InvalidGroupNameError(
"Invalid or non-existing parent group name."
)
else:
existing_group = await self._get_group(group_name)
if existing_group:
raise InvalidGroupNameError("A group with this name already exists.")
group = Group(
name=group_name,
description=description,
parent_id=parent_id,
)
self.session.add(group)
# We don't audit-log this operation.
await self.session.commit()
async def set_group_description(self, path: str, description: str) -> None:
"""Set group description."""
elements = parse_path(path)
group = await self._get_group(elements.item, elements.parent, True)
if not group:
raise InvalidGroupNameError("Invalid or non-existing group name.")
group.description = description
self.session.add(group)
await self.session.commit()
async def set_secret_group(self, entry_name: str, group_name: str | None) -> None:
"""Move a secret to a group.
If group_name is None, the secret will be moved out of any group it may exist in.
"""
entry = await self._get_entry(entry_name)
if not entry:
raise InvalidSecretNameError("Invalid or non-existing secret.")
if group_name:
elements = parse_path(group_name)
group = await self._get_group(elements.item, elements.parent, True)
if not group:
raise InvalidGroupNameError("Invalid or non-existing group name.")
entry.group_id = group.id
else:
entry.group_id = None
self.session.add(entry)
await self.session.commit()
await self.write_audit(
Operation.UPDATE,
"Secret group updated",
group_name=group_name or "ROOT",
secret_name=entry_name,
managed_secret=entry,
)
async def move_group(self, path: str, parent_group: str | None) -> None:
"""Move group.
If parent_group is None, it will be moved to the root.
"""
elements = parse_path(path)
group = await self._get_group(elements.item, elements.parent, True)
if not group:
raise InvalidGroupNameError("Invalid or non-existing group name.")
parent_group_id: uuid.UUID | None = None
if parent_group:
db_parent_group = await self._get_group(parent_group)
if not db_parent_group:
raise InvalidGroupNameError("Invalid or non-existing parent group.")
parent_group_id = db_parent_group.id
group.parent_id = parent_group_id
self.session.add(group)
await self.session.commit()
async def delete_group(self, path: str) -> None:
"""Delete a group."""
elements = parse_path(path)
group = await self._get_group(elements.item, elements.parent, True)
if not group:
return
await self.session.delete(group)
await self.session.commit()
# We don't audit-log this operation currently, even though it indirectly
# may affect secrets.
async def _export_entries(self) -> list[SecretDataEntryExport]:
"""Export entries as a pydantic object."""
statement = (
select(ManagedSecret)
.options(selectinload(ManagedSecret.group))
.where(ManagedSecret.is_deleted.is_(False))
)
results = await self.session.scalars(statement)
entries: list[SecretDataEntryExport] = []
for entry in results.all():
group: str | None = None
if entry.group:
group = await self._get_group_path(entry.group)
secret = await self.get_secret(entry.name)
if not secret:
continue
data = SecretDataEntryExport(name=entry.name, secret=secret, group=group)
entries.append(data)
return entries
async def _export_groups(self) -> list[SecretDataGroupExport]:
"""Export groups as pydantic objects."""
groups = await self.get_secret_group_list()
entries = [
SecretDataGroupExport(
name=group.name,
path=group.path,
description=group.description,
)
for group in groups
]
return entries
async def export_secrets(self) -> SecretDataExport:
"""Export the managed secrets as a pydantic object."""
entries = await self._export_entries()
groups = await self._export_groups()
return SecretDataExport(entries=entries, groups=groups)
async def export_secrets_json(self) -> str:
"""Export secrets as JSON."""
export = await self.export_secrets()
return export.model_dump_json(indent=2)
def get_managed_private_key(
settings: AdminServerSettings,
filename: str = KEY_FILENAME,
regenerate: bool = False,
) -> rsa.RSAPrivateKey:
"""Load our private key."""
keyfile = Path(filename)
if settings.password_manager_directory:
keyfile = settings.password_manager_directory / filename
if not keyfile.exists():
_initial_key_setup(settings, keyfile)
setup_password_manager(settings, keyfile, regenerate)
return load_private_key(str(keyfile.absolute()), password=settings.secret_key)
def setup_password_manager(
settings: AdminServerSettings, filename: Path, regenerate: bool = False
) -> bool:
"""Setup password manager."""
if filename.exists() and not regenerate:
return False
if not settings.secret_key:
raise RuntimeError("Error: Could not load secret key from environment.")
create_private_rsa_key(filename, password=settings.secret_key)
return True
async def create_manager_client(
backend: SshecretBackend, public_key: rsa.RSAPublicKey
) -> Client:
"""Create the manager client."""
public_key_string = generate_public_key_string(public_key)
new_client = await backend.create_system_client(
"AdminPasswordManager",
public_key_string,
)
return new_client
@asynccontextmanager
async def password_manager_context(
settings: AdminServerSettings, username: str, origin: str
) -> AsyncIterator[AsyncSecretContext]:
"""Start a context for the password manager."""
audit_context_data = ClientAuditData(username=username, origin=origin)
session_manager = DatabaseSessionManager(settings.async_db_url)
backend = SshecretBackend(str(settings.backend_url), settings.backend_token)
private_key = get_managed_private_key(settings)
async with session_manager.session() as session:
# Check if there is a client_id stored already.
query = select(PasswordDB).where(PasswordDB.id == 1)
result = await session.scalars(query)
password_db = result.first()
if not password_db:
password_db = PasswordDB(id=1)
session.add(password_db)
await session.flush()
if not password_db.client_id:
manager_client = await create_manager_client(
backend, private_key.public_key()
)
password_db.client_id = manager_client.id
session.add(password_db)
await session.commit()
else:
manager_client = await backend.get_client(
("id", str(password_db.client_id))
)
if not manager_client:
raise SecretManagerError("Error: Could not fetch system client.")
context = AsyncSecretContext(
private_key, manager_client, session, backend, audit_context_data
)
yield context
def setup_private_key(
settings: AdminServerSettings,
filename: str = KEY_FILENAME,
regenerate: bool = False,
) -> None:
"""Setup secret manager private key."""
keyfile = Path(filename)
if settings.password_manager_directory:
keyfile = settings.password_manager_directory / filename
_initial_key_setup(settings, keyfile, regenerate)
def _initial_key_setup(
settings: AdminServerSettings,
keyfile: Path,
regenerate: bool = False,
) -> bool:
"""Set up initial keys."""
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