Complete admin
This commit is contained in:
0
packages/sshecret-admin/README.md
Normal file
0
packages/sshecret-admin/README.md
Normal file
24
packages/sshecret-admin/pyproject.toml
Normal file
24
packages/sshecret-admin/pyproject.toml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
[project]
|
||||||
|
name = "sshecret-admin"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [
|
||||||
|
{ name = "Allan Eising", email = "allan@eising.dk" }
|
||||||
|
]
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"bcrypt>=4.3.0",
|
||||||
|
"click>=8.1.8",
|
||||||
|
"cryptography>=44.0.2",
|
||||||
|
"fastapi[standard]>=0.115.12",
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
"pydantic>=2.10.6",
|
||||||
|
"pyjwt>=2.10.1",
|
||||||
|
"pykeepass>=4.1.1.post1",
|
||||||
|
"sqlmodel>=0.0.24",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
5
packages/sshecret-admin/src/sshecret_admin/__init__.py
Normal file
5
packages/sshecret-admin/src/sshecret_admin/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Sshecret Admin API."""
|
||||||
|
|
||||||
|
from .app import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
417
packages/sshecret-admin/src/sshecret_admin/admin_api.py
Normal file
417
packages/sshecret-admin/src/sshecret_admin/admin_api.py
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
"""Admin API."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from . import keepass
|
||||||
|
from .auth_models import User, PasswordDB, get_engine
|
||||||
|
from .backend import BackendClient, Client
|
||||||
|
from .master_password import setup_master_password
|
||||||
|
from .settings import ServerSettings
|
||||||
|
from .view_models import (
|
||||||
|
ClientCreate,
|
||||||
|
SecretListView,
|
||||||
|
SecretView,
|
||||||
|
UpdateKeyModel,
|
||||||
|
UpdateKeyResponse,
|
||||||
|
UpdatePoliciesRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
API_VERSION = "v1"
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
"""Token data."""
|
||||||
|
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
|
settings = ServerSettings()
|
||||||
|
|
||||||
|
engine = get_engine(settings.admin_db)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
"""Create database."""
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session():
|
||||||
|
"""Get the session."""
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
def setup_password_manager() -> None:
|
||||||
|
"""Setup password manager."""
|
||||||
|
encr_master_password = setup_master_password(regenerate=False)
|
||||||
|
if not encr_master_password:
|
||||||
|
return
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
pwdb = PasswordDB(id=1, encrypted_password=encr_master_password)
|
||||||
|
session.add(pwdb)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_password_manager(session: Annotated[Session, Depends(get_session)]):
|
||||||
|
"""Get password manager."""
|
||||||
|
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
||||||
|
if not password_db:
|
||||||
|
raise HTTPException(
|
||||||
|
500, detail="Error: The password manager has not yet been set up."
|
||||||
|
)
|
||||||
|
with keepass.load_password_manager(password_db.encrypted_password) as kp:
|
||||||
|
yield kp
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify password against stored hash."""
|
||||||
|
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_user(session: Session, username: str, password: str) -> User | None:
|
||||||
|
"""Authenticate user."""
|
||||||
|
user = session.exec(select(User).where(User.username == username)).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
token: Annotated[str, Depends(oauth2_scheme)],
|
||||||
|
session: Annotated[Session, Depends(get_session)],
|
||||||
|
) -> User:
|
||||||
|
"""Get current user from token."""
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
|
||||||
|
username = payload.get("sub")
|
||||||
|
if not username:
|
||||||
|
raise credentials_exception
|
||||||
|
token_data = TokenData(username=username)
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
user = session.exec(
|
||||||
|
select(User).where(User.username == token_data.username)
|
||||||
|
).first()
|
||||||
|
if not user:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_active_user(
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)]
|
||||||
|
) -> User:
|
||||||
|
"""Get current active user."""
|
||||||
|
if current_user.disabled:
|
||||||
|
raise HTTPException(status_code=400, detail="Inactive or disabled user")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
data: dict[str, Any], expires_delta: timedelta | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Create access token."""
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=JWT_ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
async def get_backend():
|
||||||
|
"""Get backend client."""
|
||||||
|
backend_client = BackendClient(settings)
|
||||||
|
yield backend_client
|
||||||
|
|
||||||
|
|
||||||
|
async def map_secrets_to_clients(backend: BackendClient) -> defaultdict[str, list[str]]:
|
||||||
|
"""Map secrets to clients."""
|
||||||
|
clients = await backend.get_clients()
|
||||||
|
client_secret_map: defaultdict[str, list[str]] = defaultdict(list)
|
||||||
|
for client in clients:
|
||||||
|
for secret in client.secrets:
|
||||||
|
client_secret_map[secret].append(client.name)
|
||||||
|
return client_secret_map
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
"""Create lifespan context for the app."""
|
||||||
|
init_db()
|
||||||
|
setup_password_manager()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
api = APIRouter(
|
||||||
|
prefix=f"/api/{API_VERSION}",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/token")
|
||||||
|
async def login_for_access_token(
|
||||||
|
session: Annotated[Session, Depends(get_session)],
|
||||||
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
|
) -> Token:
|
||||||
|
"""Login user and generate token."""
|
||||||
|
user = authenticate_user(session, form_data.username, form_data.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": user.username}, expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
return Token(access_token=access_token, token_type="bearer")
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/secrets/")
|
||||||
|
async def get_secret_names(
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||||
|
password_manager: Annotated[keepass.PasswordContext, Depends(get_password_manager)],
|
||||||
|
) -> list[SecretListView]:
|
||||||
|
"""Get Secret Names."""
|
||||||
|
# We get the list of clients first, so we can resolve access.
|
||||||
|
LOG.info("User %s requested get_secret_names", current_user.username)
|
||||||
|
client_secret_map = await map_secrets_to_clients(backend)
|
||||||
|
secrets = password_manager.get_available_secrets()
|
||||||
|
results: list[SecretListView] = []
|
||||||
|
for secret in secrets:
|
||||||
|
client_list = client_secret_map.get(secret, [])
|
||||||
|
results.append(SecretListView(name=secret, clients=client_list))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/secrets/{name}")
|
||||||
|
async def get_secret(
|
||||||
|
name: str,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||||
|
password_manager: Annotated[keepass.PasswordContext, Depends(get_password_manager)],
|
||||||
|
) -> SecretView:
|
||||||
|
"""Get a secret."""
|
||||||
|
LOG.info("User %s viewed secret %s", current_user.username, name)
|
||||||
|
client_secret_map = await map_secrets_to_clients(backend)
|
||||||
|
secret = password_manager.get_secret(name)
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found."
|
||||||
|
)
|
||||||
|
clients = client_secret_map[secret]
|
||||||
|
return SecretView(name=name, secret=secret, clients=clients)
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/clients/")
|
||||||
|
async def get_clients(
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||||
|
) -> list[Client]:
|
||||||
|
"""Get clients."""
|
||||||
|
LOG.info("User %s requested get_clients", current_user.username)
|
||||||
|
clients = await backend.get_clients()
|
||||||
|
return clients
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/clients/")
|
||||||
|
async def create_client(
|
||||||
|
new_client: ClientCreate,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||||
|
) -> Client:
|
||||||
|
"""Create a new client."""
|
||||||
|
LOG.info(
|
||||||
|
"User %s requested create_client %", current_user.username, new_client.name
|
||||||
|
)
|
||||||
|
await backend.register_client(new_client.name, new_client.public_key)
|
||||||
|
if new_client.sources:
|
||||||
|
LOG.debug("Creating policy sources")
|
||||||
|
sources = [str(source) for source in new_client.sources]
|
||||||
|
await backend.update_client_sources(new_client.name, sources)
|
||||||
|
|
||||||
|
client = await backend.get_client(new_client.name)
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="The client could not be created",
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@api.delete("/clients/{name}")
|
||||||
|
async def delete_client(
|
||||||
|
name: str,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||||
|
) -> None:
|
||||||
|
"""Delete a client."""
|
||||||
|
LOG.info("User %s requested delete_client %s", current_user.username, name)
|
||||||
|
await backend.delete_client(name)
|
||||||
|
|
||||||
|
|
||||||
|
@api.put("/clients/{name}/public-key")
|
||||||
|
async def update_client_public_key(
|
||||||
|
name: str,
|
||||||
|
updated: UpdateKeyModel,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||||
|
password_manager: Annotated[keepass.PasswordContext, Depends(get_password_manager)],
|
||||||
|
) -> UpdateKeyResponse:
|
||||||
|
"""Update client public key.
|
||||||
|
|
||||||
|
Updating the public key will invalidate the current secrets, so these well
|
||||||
|
be resolved first, and re-encrypted using the new key.
|
||||||
|
"""
|
||||||
|
LOG.info(
|
||||||
|
"User %s requested public key update for client %s", current_user.username, name
|
||||||
|
)
|
||||||
|
# Let's first ensure that the key is actually updated.
|
||||||
|
client = await backend.get_client(name)
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
if client.public_key == updated.public_key:
|
||||||
|
return UpdateKeyResponse(
|
||||||
|
public_key=updated.public_key,
|
||||||
|
detail="Updated key identical to existing key. No changes were made.",
|
||||||
|
)
|
||||||
|
LOG.debug("Updating public key on backend.")
|
||||||
|
await backend.update_client_key(name, updated.public_key)
|
||||||
|
client.public_key = updated.public_key
|
||||||
|
response = UpdateKeyResponse(public_key=updated.public_key)
|
||||||
|
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
|
||||||
|
encrypted = client.encrypt(secret_value)
|
||||||
|
LOG.debug("Sending new encrypted value to backend.")
|
||||||
|
await backend.create_secret(name, secret, encrypted)
|
||||||
|
response.updated_secrets.append(secret)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@api.put("/clients/{name}/secrets/{secret_name}")
|
||||||
|
async def add_secret_to_client(
|
||||||
|
name: str,
|
||||||
|
secret_name: str,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||||
|
password_manager: Annotated[keepass.PasswordContext, Depends(get_password_manager)],
|
||||||
|
) -> None:
|
||||||
|
"""Add secret to a client."""
|
||||||
|
LOG.info(
|
||||||
|
"User %s requested add_secret_to_client for secret %s",
|
||||||
|
current_user.username,
|
||||||
|
secret_name,
|
||||||
|
)
|
||||||
|
client = await backend.get_client(name)
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||||
|
)
|
||||||
|
secret = password_manager.get_secret(secret_name)
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||||
|
)
|
||||||
|
await backend.create_secret(name, secret_name, client.encrypt(secret))
|
||||||
|
|
||||||
|
|
||||||
|
@api.delete("/clients/{name}/secrets/{secret_name}")
|
||||||
|
async def delete_secret_from_client(
|
||||||
|
name: str,
|
||||||
|
secret_name: str,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||||
|
) -> None:
|
||||||
|
"""Delete a secret from a client."""
|
||||||
|
LOG.info(
|
||||||
|
"User % requested delete of secret. Client: %s, secret: %s",
|
||||||
|
current_user.username,
|
||||||
|
name,
|
||||||
|
secret_name,
|
||||||
|
)
|
||||||
|
client = await backend.get_client(name)
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if secret_name not in client.secrets:
|
||||||
|
LOG.debug("Client does not have requested secret. No action to perform.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
await backend.delete_client_secret(name, secret_name)
|
||||||
|
|
||||||
|
|
||||||
|
@api.put("/clients/{name}/policies")
|
||||||
|
async def update_client_policies(
|
||||||
|
name: str,
|
||||||
|
updated: UpdatePoliciesRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||||
|
) -> Client:
|
||||||
|
"""Update the client access policies."""
|
||||||
|
LOG.info("User %s requested update_client_policies.", current_user.username)
|
||||||
|
client = await backend.get_client(name)
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG.debug("Old policies: %r. New: %r", client.policies, updated.sources)
|
||||||
|
|
||||||
|
addresses: list[str] = [str(source) for source in updated.sources]
|
||||||
|
await backend.update_client_sources(name, addresses)
|
||||||
|
client = await backend.get_client(name)
|
||||||
|
|
||||||
|
assert client is not None, "Critical: The client disappeared after update!"
|
||||||
|
|
||||||
|
return client
|
||||||
21
packages/sshecret-admin/src/sshecret_admin/app.py
Normal file
21
packages/sshecret-admin/src/sshecret_admin/app.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"""FastAPI app."""
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, status
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from .admin_api import api
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(api)
|
||||||
50
packages/sshecret-admin/src/sshecret_admin/auth_models.py
Normal file
50
packages/sshecret-admin/src/sshecret_admin/auth_models.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"""Models for authentication."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.engine import URL
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel, Field, create_engine
|
||||||
|
|
||||||
|
|
||||||
|
class User(SQLModel, table=True):
|
||||||
|
"""Users."""
|
||||||
|
|
||||||
|
username: str = Field(unique=True, primary_key=True)
|
||||||
|
hashed_password: str
|
||||||
|
disabled: bool = Field(default=False)
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_type=sa.DateTime(timezone=True),
|
||||||
|
sa_column_kwargs={"server_default": sa.func.now()},
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordDB(SQLModel, table=True):
|
||||||
|
"""Password database."""
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
encrypted_password: str
|
||||||
|
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_type=sa.DateTime(timezone=True),
|
||||||
|
sa_column_kwargs={"server_default": sa.func.now()},
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_type=sa.DateTime(timezone=True),
|
||||||
|
sa_column_kwargs={"onupdate": sa.func.now(), "server_default": sa.func.now()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine(filename: Path, echo: bool = False) -> sa.Engine:
|
||||||
|
"""Initialize the engine."""
|
||||||
|
url = URL.create(drivername="sqlite", database=str(filename.absolute()))
|
||||||
|
engine = create_engine(url, echo=echo)
|
||||||
|
|
||||||
|
return engine
|
||||||
156
packages/sshecret-admin/src/sshecret_admin/backend.py
Normal file
156
packages/sshecret-admin/src/sshecret_admin/backend.py
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
"""Backend API.
|
||||||
|
|
||||||
|
TODO:
|
||||||
|
- Handle exceptions with custom exceptions
|
||||||
|
- Move to shared library.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork, TypeAdapter
|
||||||
|
|
||||||
|
from .crypto import load_public_key, encrypt_string
|
||||||
|
from .settings import ServerSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Client(BaseModel):
|
||||||
|
"""Implementation of the backend class ClientView."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
public_key: str
|
||||||
|
secrets: list[str]
|
||||||
|
policies: list[IPvAnyNetwork | IPvAnyAddress]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
def encrypt(self, value: str) -> str:
|
||||||
|
"""Encrypt a string."""
|
||||||
|
public_key = load_public_key(self.public_key.encode())
|
||||||
|
return encrypt_string(value, public_key=public_key)
|
||||||
|
|
||||||
|
|
||||||
|
class BackendClient:
|
||||||
|
"""Backend Client."""
|
||||||
|
|
||||||
|
def __init__(self, settings: ServerSettings | None = None) -> None:
|
||||||
|
"""Initialize backend client."""
|
||||||
|
if not settings:
|
||||||
|
settings = ServerSettings() # pyright: ignore[reportCallIssue]
|
||||||
|
self.settings: ServerSettings = settings
|
||||||
|
|
||||||
|
@property
|
||||||
|
def headers(self) -> dict[str, str]:
|
||||||
|
"""Get the headers."""
|
||||||
|
return {"X-Api-Token": self.settings.backend_token}
|
||||||
|
|
||||||
|
def _format_url(self, path: str) -> str:
|
||||||
|
"""Format a URL."""
|
||||||
|
return urllib.parse.urljoin(str(self.settings.backend_url), path)
|
||||||
|
|
||||||
|
async def request(self, path: str) -> httpx.Response:
|
||||||
|
"""Send a simple GET request."""
|
||||||
|
url = self._format_url(path)
|
||||||
|
async with httpx.AsyncClient() as http_client:
|
||||||
|
response = await http_client.get(url, headers=self.headers)
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def register_client(self, username: str, public_key: str) -> None:
|
||||||
|
"""Register a new client."""
|
||||||
|
data = {
|
||||||
|
"name": username,
|
||||||
|
"public_key": public_key,
|
||||||
|
}
|
||||||
|
path = "api/v1/clients/"
|
||||||
|
url = self._format_url(path)
|
||||||
|
async with httpx.AsyncClient() as http_client:
|
||||||
|
response = await http_client.post(url, headers=self.headers, json=data)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
async def update_client_key(self, client_name: str, public_key: str) -> None:
|
||||||
|
"""Update the client key."""
|
||||||
|
path = f"api/v1/clients/{client_name}/public-key"
|
||||||
|
url = self._format_url(path)
|
||||||
|
async with httpx.AsyncClient() as http_client:
|
||||||
|
response = await http_client.post(
|
||||||
|
url, headers=self.headers, json={"public_key": public_key}
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
async def create_secret(
|
||||||
|
self, client_name: str, secret_name: str, encrypted_secret: str
|
||||||
|
) -> None:
|
||||||
|
"""Create a secret.
|
||||||
|
|
||||||
|
This will overwrite any existing secret with that name.
|
||||||
|
"""
|
||||||
|
path = f"api/v1/clients/{client_name}/secrets/{secret_name}"
|
||||||
|
url = self._format_url(path)
|
||||||
|
async with httpx.AsyncClient() as http_client:
|
||||||
|
response = await http_client.put(
|
||||||
|
url, headers=self.headers, json={"value": encrypted_secret}
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
async def delete_client_secret(self, client_name: str, secret_name: str) -> None:
|
||||||
|
"""Delete a secret from a client."""
|
||||||
|
path = f"api/v1/clients/{client_name}/secrets/{secret_name}"
|
||||||
|
url = self._format_url(path)
|
||||||
|
async with httpx.AsyncClient() as http_client:
|
||||||
|
response = await http_client.delete(url, headers=self.headers)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
async def get_client(self, name: str) -> Client | None:
|
||||||
|
"""Get a single client."""
|
||||||
|
path = f"api/v1/clients/{name}"
|
||||||
|
response = await self.request(path)
|
||||||
|
if response.status_code == 404:
|
||||||
|
return None
|
||||||
|
response.raise_for_status()
|
||||||
|
client = Client.model_validate(response.json())
|
||||||
|
return client
|
||||||
|
|
||||||
|
async def get_clients(self) -> list[Client]:
|
||||||
|
"""Get all clients."""
|
||||||
|
path = "api/v1/clients/"
|
||||||
|
url = self._format_url(path)
|
||||||
|
async with httpx.AsyncClient() as http_client:
|
||||||
|
response = await http_client.get(url, headers=self.headers)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
client_list_adapter = TypeAdapter(list[Client])
|
||||||
|
return client_list_adapter.validate_python(response.json())
|
||||||
|
|
||||||
|
async def update_client_sources(
|
||||||
|
self, client_name: str, addresses: list[str] | None
|
||||||
|
) -> None:
|
||||||
|
"""Update client source addresses.
|
||||||
|
|
||||||
|
Pass None to sources to allow from all.
|
||||||
|
"""
|
||||||
|
if not addresses:
|
||||||
|
addresses = []
|
||||||
|
|
||||||
|
path = f"api/v1/clients/{client_name}/policies/"
|
||||||
|
url = self._format_url(path)
|
||||||
|
async with httpx.AsyncClient() as http_client:
|
||||||
|
response = await http_client.put(
|
||||||
|
url, headers=self.headers, json={"sources": addresses}
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
async def delete_client(self, client_name: str) -> None:
|
||||||
|
"""Delete a client."""
|
||||||
|
path = f"api/v1/clients/{client_name}"
|
||||||
|
url = self._format_url(path)
|
||||||
|
async with httpx.AsyncClient() as http_client:
|
||||||
|
response = await http_client.delete(url, headers=self.headers)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
2
packages/sshecret-admin/src/sshecret_admin/constants.py
Normal file
2
packages/sshecret-admin/src/sshecret_admin/constants.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
RSA_PUBLIC_EXPONENT = 65537
|
||||||
|
RSA_KEY_SIZE = 2048
|
||||||
125
packages/sshecret-admin/src/sshecret_admin/crypto.py
Normal file
125
packages/sshecret-admin/src/sshecret_admin/crypto.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""Encryption related functions.
|
||||||
|
|
||||||
|
Note! Encryption uses the less secure PKCS1v15 padding. This is to allow
|
||||||
|
decryption via openssl on the command line.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
|
||||||
|
from . import constants
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def load_public_key(keybytes: bytes) -> rsa.RSAPublicKey:
|
||||||
|
public_key = serialization.load_ssh_public_key(keybytes)
|
||||||
|
if not isinstance(public_key, rsa.RSAPublicKey):
|
||||||
|
raise RuntimeError("Only RSA keys are supported.")
|
||||||
|
return public_key
|
||||||
|
|
||||||
|
|
||||||
|
def validate_public_key(key: str) -> bool:
|
||||||
|
"""Check if key provided in a string is valid."""
|
||||||
|
valid = False
|
||||||
|
public_key: rsa.RSAPublicKey | None = None
|
||||||
|
try:
|
||||||
|
keybytes = key.encode()
|
||||||
|
public_key = load_public_key(keybytes)
|
||||||
|
except Exception as e:
|
||||||
|
LOG.debug("Validation of public key failed: %s", e, exc_info=True)
|
||||||
|
else:
|
||||||
|
valid = True
|
||||||
|
|
||||||
|
if not isinstance(public_key, rsa.RSAPublicKey):
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
return valid
|
||||||
|
|
||||||
|
|
||||||
|
def load_private_key(filename: str, password: str | None = None) -> rsa.RSAPrivateKey:
|
||||||
|
"""Load a private key."""
|
||||||
|
password_bytes: bytes | None = None
|
||||||
|
if password:
|
||||||
|
password_bytes = password.encode()
|
||||||
|
with open(filename, "rb") as f:
|
||||||
|
private_key = serialization.load_ssh_private_key(f.read(), password=password_bytes)
|
||||||
|
if not isinstance(private_key, rsa.RSAPrivateKey):
|
||||||
|
raise RuntimeError("Only RSA keys are supported.")
|
||||||
|
return private_key
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_string(string: str, public_key: rsa.RSAPublicKey) -> str:
|
||||||
|
"""Encrypt string, end return it base64 encoded."""
|
||||||
|
message = string.encode()
|
||||||
|
ciphertext = public_key.encrypt(
|
||||||
|
message,
|
||||||
|
padding.PKCS1v15(),
|
||||||
|
)
|
||||||
|
return base64.b64encode(ciphertext).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decode_string(ciphertext: str, private_key: rsa.RSAPrivateKey) -> str:
|
||||||
|
"""Decode a string. String must be base64 encoded."""
|
||||||
|
decoded = base64.b64decode(ciphertext)
|
||||||
|
decrypted = private_key.decrypt(
|
||||||
|
decoded,
|
||||||
|
padding.PKCS1v15(),
|
||||||
|
)
|
||||||
|
return decrypted.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_private_key() -> rsa.RSAPrivateKey:
|
||||||
|
"""Generate private RSA key."""
|
||||||
|
private_key = rsa.generate_private_key(
|
||||||
|
public_exponent=constants.RSA_PUBLIC_EXPONENT, key_size=constants.RSA_KEY_SIZE
|
||||||
|
)
|
||||||
|
return private_key
|
||||||
|
|
||||||
|
|
||||||
|
def generate_pem(private_key: rsa.RSAPrivateKey) -> str:
|
||||||
|
"""Generate PEM."""
|
||||||
|
pem = private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.OpenSSH,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
return pem.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def create_private_rsa_key(filename: Path, password: str | None = None) -> None:
|
||||||
|
"""Create an RSA Private key at the given path.
|
||||||
|
|
||||||
|
A password may be provided for secure storage.
|
||||||
|
"""
|
||||||
|
if filename.exists():
|
||||||
|
raise RuntimeError("Error: private key file already exists.")
|
||||||
|
LOG.debug("Generating private RSA key at %s", filename)
|
||||||
|
private_key = generate_private_key()
|
||||||
|
encryption_algorithm = serialization.NoEncryption()
|
||||||
|
if password:
|
||||||
|
password_bytes = password.encode()
|
||||||
|
encryption_algorithm = serialization.BestAvailableEncryption(password_bytes)
|
||||||
|
with open(filename, "wb") as f:
|
||||||
|
pem = private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.OpenSSH,
|
||||||
|
encryption_algorithm=encryption_algorithm,
|
||||||
|
)
|
||||||
|
lines = f.write(pem)
|
||||||
|
LOG.debug("Wrote %s lines", lines)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_public_key_string(public_key: rsa.RSAPublicKey) -> str:
|
||||||
|
"""Generate public key string."""
|
||||||
|
keybytes = public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.OpenSSH,
|
||||||
|
format=serialization.PublicFormat.OpenSSH,
|
||||||
|
)
|
||||||
|
return keybytes.decode()
|
||||||
101
packages/sshecret-admin/src/sshecret_admin/keepass.py
Normal file
101
packages/sshecret-admin/src/sshecret_admin/keepass.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""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 sshecret_admin.master_password import retrieve_master_password
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NO_USERNAME = "NO_USERNAME"
|
||||||
|
|
||||||
|
settings = ServerSettings()
|
||||||
|
|
||||||
|
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(entry.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]
|
||||||
|
|
||||||
|
|
||||||
|
@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(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 = retrieve_master_password(encrypted_password)
|
||||||
|
if not db_location.exists():
|
||||||
|
create_password_db(db_location, password)
|
||||||
|
|
||||||
|
with _password_context(db_location, password) as context:
|
||||||
|
yield context
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
"""Functions related to handling the password database master password."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from sshecret_admin.crypto import (
|
||||||
|
create_private_rsa_key,
|
||||||
|
load_private_key,
|
||||||
|
encrypt_string,
|
||||||
|
decode_string,
|
||||||
|
)
|
||||||
|
from sshecret_admin.settings import ServerSettings
|
||||||
|
|
||||||
|
KEY_FILENAME = "sshecret-admin-key"
|
||||||
|
|
||||||
|
|
||||||
|
settings = ServerSettings()
|
||||||
|
|
||||||
|
|
||||||
|
def setup_master_password(
|
||||||
|
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(filename, regenerate)
|
||||||
|
if not created:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _generate_master_password(filename)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_master_password(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(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(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)
|
||||||
23
packages/sshecret-admin/src/sshecret_admin/settings.py
Normal file
23
packages/sshecret-admin/src/sshecret_admin/settings.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"""SSH Server settings."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from pydantic import AnyHttpUrl, Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LISTEN_PORT = 8822
|
||||||
|
|
||||||
|
DEFAULT_DB = "ssh_admin.db"
|
||||||
|
|
||||||
|
class ServerSettings(BaseSettings):
|
||||||
|
"""Server Settings."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".admin.env", env_prefix="sshecret_admin_", secrets_dir='/var/run')
|
||||||
|
|
||||||
|
backend_url: AnyHttpUrl = Field(alias="sshecret_backend_url")
|
||||||
|
backend_token: str
|
||||||
|
listen_address: str = Field(default="")
|
||||||
|
secret_key: str
|
||||||
|
port: int = DEFAULT_LISTEN_PORT
|
||||||
|
admin_db: Path = Path(DEFAULT_DB)
|
||||||
|
debug: bool = False
|
||||||
55
packages/sshecret-admin/src/sshecret_admin/view_models.py
Normal file
55
packages/sshecret-admin/src/sshecret_admin/view_models.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"""Models for the API."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from pydantic import AfterValidator, BaseModel, Field, IPvAnyAddress, IPvAnyNetwork
|
||||||
|
from .crypto import validate_public_key
|
||||||
|
from .backend import Client
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user