Write new secret manager using existing RSA logic
This commit is contained in:
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
Reference in New Issue
Block a user