Add sub-projects

This commit is contained in:
2025-04-16 15:08:51 +02:00
parent 2dbf216d37
commit db538adfdd
22 changed files with 1157 additions and 0 deletions

View File

@ -0,0 +1,11 @@
# Backend
This is the backend part of the SSHecret library.
The principle here is that it stores encrypted secrets that can be long to clients.
It does not store much data about the clients, and purely manages access to
encrypted values based on the SSH RSA fingerprint and an optional list of
allowed IP addresses.
While there is a model for the client, it is purely meant for aggregation and utility.

View File

@ -0,0 +1,23 @@
[project]
name = "sshecret-backend"
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 = [
"passlib[bcrypt]>=1.7.4",
"pydantic>=2.10.6",
"pytest>=8.3.5",
"sqlmodel>=0.0.24",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"

View File

@ -0,0 +1 @@
"""Sshecret backend."""

View File

@ -0,0 +1,289 @@
"""FastAPI api."""
import logging
from contextlib import asynccontextmanager
from typing import Annotated
from collections.abc import Sequence
import bcrypt
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Query, Request
from sqlmodel import Session, select
from . import audit
from .db import get_engine
from .models import APIClient, AuditLog, Client, ClientSecret, init_db
from .settings import get_settings
from .view_models import (
BodyValue,
ClientCreate,
ClientListResponse,
ClientSecretPublic,
ClientSecretResponse,
ClientUpdate,
ClientView,
)
settings = get_settings()
engine = get_engine(settings.db_file)
LOG = logging.getLogger(__name__)
API_VERSION = "v1"
def verify_token(token: str, stored_hash: str) -> bool:
"""Verify token."""
token_bytes = token.encode("utf-8")
stored_bytes = stored_hash.encode("utf-8")
return bcrypt.checkpw(token_bytes, stored_bytes)
@asynccontextmanager
async def lifespan(_app: FastAPI):
"""Create database before starting the server."""
init_db(engine)
yield
async def get_session():
"""Get the session."""
with Session(engine) as session:
yield session
async def validate_token(
x_api_token: Annotated[str, Header()],
session: Annotated[Session, Depends(get_session)],
) -> str:
"""Validate token."""
LOG.debug("Validating token %s", x_api_token)
statement = select(APIClient)
results = session.exec(statement)
valid = False
for result in results:
if verify_token(x_api_token, result.token):
valid = True
LOG.debug("Token is valid")
break
if not valid:
LOG.debug("Token is not valid.")
raise HTTPException(status_code=401, detail="unauthorized. invalid api token.")
return x_api_token
async def get_client_by_name(session: Session, name: str) -> Client | None:
"""Get client by name."""
client_filter = select(Client).where(Client.name == name)
client_results = session.exec(client_filter)
return client_results.first()
async def lookup_client_secret(
session: Session, client: Client, name: str
) -> ClientSecret | None:
"""Look up a secret for a client."""
statement = (
select(ClientSecret)
.where(ClientSecret.client_id == client.id)
.where(ClientSecret.name == name)
)
results = session.exec(statement)
return results.first()
LOG.info("Initializing app.")
backend_api = APIRouter(
prefix=f"/api/{API_VERSION}",
lifespan=lifespan,
dependencies=[Depends(validate_token)],
)
@backend_api.get("/clients/")
async def get_clients(
session: Annotated[Session, Depends(get_session)]
) -> list[ClientListResponse]:
"""Get clients."""
statement = select(Client)
results = session.exec(statement)
clients = list(results)
return ClientListResponse.from_clients(clients)
@backend_api.get("/clients/{name}")
async def get_client(
request: Request, name: str, session: Annotated[Session, Depends(get_session)]
) -> ClientView:
"""Fetch a client."""
statement = select(Client).where(Client.name == name)
results = session.exec(statement)
client = results.first()
if not client:
raise HTTPException(
status_code=404, detail="Cannot find a client with the given name."
)
audit.audit_access_secrets(session, request, client)
return ClientView.from_client(client)
@backend_api.post("/clients/{name}/update_fingerprint")
async def update_client_fingerprint(
request: Request,
name: str,
client_update: ClientUpdate,
session: Annotated[Session, Depends(get_session)],
) -> ClientView:
"""Update the client fingerprint.
This invalidates all secrets.
"""
statement = select(Client).where(Client.name == name)
results = session.exec(statement)
client = results.first()
if not client:
raise HTTPException(
status_code=404, detail="Cannot find a client with the given name."
)
client.fingerprint = client_update.fingerprint
for secret in session.exec(
select(ClientSecret).where(ClientSecret.client_id == client.id)
).all():
LOG.debug("Invalidated secret %s", secret.id)
secret.invalidated = True
secret.client_id = None
secret.client = None
session.add(client)
session.refresh(client)
session.commit()
audit.audit_invalidate_secrets(session, request, client)
return ClientView.from_client(client)
@backend_api.post("/clients/")
async def create_client(
request: Request,
client: ClientCreate,
session: Annotated[Session, Depends(get_session)],
) -> ClientView:
"""Create client."""
db_client = Client.model_validate(client)
session.add(db_client)
session.commit()
session.refresh(db_client)
audit.audit_create_client(session, request, db_client)
return ClientView.from_client(db_client)
@backend_api.post("/clients/{name}/secrets/")
async def add_secret_to_client(
request: Request,
name: str,
client_secret: ClientSecretPublic,
session: Annotated[Session, Depends(get_session)],
) -> None:
"""Add secret to a client."""
client = await get_client_by_name(session, name)
if not client:
raise HTTPException(
status_code=404, detail="Cannot find a client with the given name."
)
existing_secret = await lookup_client_secret(session, client, client_secret.name)
if existing_secret:
raise HTTPException(
status_code=400,
detail="Cannot add a secret. A different secret with the same name already exists.",
)
db_secret = ClientSecret(
name=client_secret.name, client_id=client.id, secret=client_secret.secret
)
session.add(db_secret)
session.commit()
session.refresh(db_secret)
audit.audit_create_secret(session, request, client, db_secret)
@backend_api.put("/clients/{name}/secrets/{secret_name}")
async def update_client_secret(
request: Request,
name: str,
secret_name: str,
secret_data: BodyValue,
session: Annotated[Session, Depends(get_session)],
) -> ClientSecretResponse:
"""Update a client secret.
This can also be used for destructive creates.
"""
client = await get_client_by_name(session, name)
if not client:
raise HTTPException(
status_code=404, detail="Cannot find a client with the given name."
)
existing_secret = await lookup_client_secret(session, client, secret_name)
if existing_secret:
existing_secret.secret = secret_data.value
session.add(existing_secret)
session.commit()
session.refresh(existing_secret)
audit.audit_update_secret(session, request, client, existing_secret)
return ClientSecretResponse.from_client_secret(existing_secret)
db_secret = ClientSecret(
name=secret_name,
client_id=client.id,
secret=secret_data.value,
)
session.add(db_secret)
session.commit()
session.refresh(db_secret)
audit.audit_create_secret(session, request, client, db_secret)
return ClientSecretResponse.from_client_secret(db_secret)
@backend_api.get("/clients/{name}/secrets/{secret_name}")
async def request_client_secret(
request: Request,
name: str,
secret_name: str,
session: Annotated[Session, Depends(get_session)],
) -> ClientSecretResponse:
"""Get a client secret."""
client = await get_client_by_name(session, name)
if not client:
raise HTTPException(
status_code=404, detail="Cannot find a client with the given name."
)
secret = await lookup_client_secret(session, client, secret_name)
if not secret:
raise HTTPException(
status_code=404, detail="Cannot find a secret with the given name."
)
response_model = ClientSecretResponse.from_client_secret(secret)
audit.audit_access_secret(session, request, client, secret)
return response_model
@backend_api.get("/audit/", response_model=list[AuditLog])
async def get_audit_logs(
request: Request,
session: Annotated[Session, Depends(get_session)],
offset: Annotated[int, Query()] = 0,
limit: Annotated[int, Query(le=100)] = 100,
filter_client: Annotated[str | None, Query()] = None,
) -> Sequence[AuditLog]:
"""Get audit logs."""
audit.audit_access_audit_log(session, request)
statement = select(AuditLog).offset(offset).limit(limit)
if filter_client:
statement = statement.where(AuditLog.client_name == filter_client)
results = session.exec(statement).all()
return results

View File

@ -0,0 +1,151 @@
"""Audit methods."""
from collections.abc import Sequence
from fastapi import Request
from sqlmodel import Session, select
from .models import AuditLog, Client, ClientSecret
def _get_origin(request: Request) -> str | None:
"""Resolve the request origin."""
origin: str | None = None
if request.client:
origin = request.client.host
return origin
def _write_audit_log(
session: Session, request: Request, entry: AuditLog, commit: bool = True
) -> None:
"""Write the audit log."""
origin = _get_origin(request)
entry.origin = origin
session.add(entry)
if commit:
session.commit()
def audit_create_client(
session: Session, request: Request, client: Client, commit: bool = True
) -> None:
"""Log the creation of a client."""
entry = AuditLog(
operation="CREATE",
client_id=client.id,
client_name=client.name,
message="Client Created",
)
_write_audit_log(session, request, entry, commit)
def audit_create_secret(
session: Session,
request: Request,
client: Client,
secret: ClientSecret,
commit: bool = True,
) -> None:
"""Audit a create secret event."""
entry = AuditLog(
operation="CREATE",
object="ClientSecret",
object_id=str(secret.id),
client_id=client.id,
client_name=client.name,
message="Added secret to client",
)
_write_audit_log(session, request, entry, commit)
def audit_update_secret(
session: Session,
request: Request,
client: Client,
secret: ClientSecret,
commit: bool = True,
) -> None:
"""Audit an update secret event."""
entry = AuditLog(
operation="UPDATE",
object="ClientSecret",
object_id=str(secret.id),
client_id=client.id,
client_name=client.name,
message="Secret value updated",
)
_write_audit_log(session, request, entry, commit)
def audit_invalidate_secrets(
session: Session,
request: Request,
client: Client,
commit: bool = True,
) -> None:
"""Audit Invalidate client secrets."""
entry = AuditLog(
operation="INVALIDATE",
object="ClientSecret",
client_name=client.name,
client_id=client.id,
message="Client fingerprint updated. All secrets invalidated.",
)
_write_audit_log(session, request, entry, commit)
def audit_access_secrets(
session: Session,
request: Request,
client: Client,
secrets: Sequence[ClientSecret] | None = None,
commit: bool = True,
) -> None:
"""Audit that multiple secrets were accessed.
With no secrets provided, all secrets of the client will be resolved.
"""
if not secrets:
secrets = session.exec(
select(ClientSecret).where(ClientSecret.client_id == client.id)
).all()
for secret in secrets:
audit_access_secret(session, request, client, secret, False)
if commit:
session.commit()
def audit_access_secret(
session: Session,
request: Request,
client: Client,
secret: ClientSecret,
commit: bool = True,
) -> None:
"""Audit that someone accessed one secrets."""
entry = AuditLog(
operation="ACCESS",
message="Secret was viewed",
object="ClientSecret",
object_id=str(secret.id),
client_id=client.id,
client_name=client.name,
)
_write_audit_log(session, request, entry, commit)
def audit_access_audit_log(
session: Session, request: Request, commit: bool = True
) -> None:
"""Audit access to the audit log.
Because why not...
"""
entry = AuditLog(
operation="ACCESS",
message="Audit log was viewed",
object="AuditLog",
)
_write_audit_log(session, request, entry, commit)

View File

@ -0,0 +1,24 @@
"""CLI and main entry point."""
from pathlib import Path
import click
from pydantic import BaseModel, FilePath
class BackendSettings(BaseModel):
"""Backend Settings."""
db_file: FilePath
regenerate_tokens: bool = False
@click.group()
@click.option("--db-file", envvar="sshecret_db_file", type=click.Path(path_type=Path))
@click.option("--regenerate-tokens", is_flag=True, default=False)
@click.pass_context
def cli(ctx: click.Context, db_file: Path, regenerate_tokens: bool) -> None:
"""Sshecret database handler."""
if not isinstance(ctx.obj, BackendSettings):
ctx.obj = BackendSettings(db_file=db_file, regenerate_tokens=regenerate_tokens)

View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
import secrets
from pathlib import Path
from sqlalchemy import Engine
from sqlmodel import Session, create_engine, text
import bcrypt
from dotenv import load_dotenv
from sqlalchemy.engine import URL
from .models import APIClient, init_db
load_dotenv()
def get_engine(filename: Path, echo: bool = False) -> Engine:
"""Initialize the engine."""
url = URL.create(drivername="sqlite", database=str(filename.absolute()))
engine = create_engine(url, echo=echo)
with engine.connect() as connection:
connection.execute(text("PRAGMA foreign_keys=ON")) # for SQLite only
return engine
def create_db_and_tables(filename: Path, echo: bool = True) -> bool:
"""Create database and tables.
Returns True if the database was created.
"""
created = False
if not filename.exists():
created = True
engine = get_engine(filename, echo)
init_db(engine)
return created
def create_api_token(session: Session, read_write: bool) -> str:
"""Create API token."""
token = secrets.token_urlsafe(32)
pwbytes = token.encode("utf-8")
salt = bcrypt.gensalt()
hashed_bytes = bcrypt.hashpw(password=pwbytes, salt=salt)
hashed = hashed_bytes.decode()
api_token = APIClient(token=hashed, read_write=read_write)
session.add(api_token)
session.commit()
return token

View File

@ -0,0 +1,91 @@
#!/usr/bin/env python3
import uuid
from datetime import datetime
from sqlalchemy import Engine, Column, DateTime, func
from sqlmodel import Field, Relationship, SQLModel
class Client(SQLModel, table=True):
"""Client model."""
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
name: str = Field(unique=True)
fingerprint: str
created_at: datetime = Field(
default=None,
sa_column=Column(
DateTime(timezone=True), server_default=func.now(), nullable=True
),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(DateTime(timezone=True), onupdate=func.now(), nullable=True),
)
secrets: list["ClientSecret"] = Relationship(
back_populates="client", passive_deletes="all"
)
class ClientSecret(SQLModel, table=True):
"""A client secret."""
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
name: str
client_id: uuid.UUID | None = Field(foreign_key="client.id", ondelete="CASCADE")
client: Client | None = Relationship(back_populates="secrets")
secret: str
invalidated: bool = Field(default=False)
created_at: datetime = Field(
default=None,
sa_column=Column(
DateTime(timezone=True), server_default=func.now(), nullable=True
),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(DateTime(timezone=True), onupdate=func.now(), nullable=True),
)
class AuditLog(SQLModel, table=True):
"""Audit log.
This is implemented without any foreign keys to avoid losing data on
deletions.
"""
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
object: str | None = None
object_id: str | None = None
operation: str
client_id: uuid.UUID | None = None
client_name: str | None = None
message: str
origin: str | None = None
timestamp: datetime | None = Field(
default=None,
sa_column=Column(
DateTime(timezone=True), server_default=func.now(), nullable=True
),
)
class APIClient(SQLModel, table=True):
"""Stores API Keys."""
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
token: str
read_write: bool
created_at: datetime = Field(
default=None,
sa_column=Column(
DateTime(timezone=True), server_default=func.now(), nullable=True
),
)
def init_db(engine: Engine) -> None:
"""Create database."""
SQLModel.metadata.create_all(engine)

View File

@ -0,0 +1,9 @@
"""API Router."""
from fastapi import FastAPI
from .app import backend_api
app = FastAPI()
app.include_router(backend_api)

View File

@ -0,0 +1,26 @@
"""Settings management."""
import os
from pathlib import Path
from pydantic import BaseModel
from dotenv import load_dotenv
DEFAULT_DATABASE = "sshecret.db"
load_dotenv()
class BackendSettings(BaseModel):
"""Backend Settings."""
db_file: Path
regenerate_tokens: bool = False
def get_settings() -> BackendSettings:
"""Get settings."""
db_filename = os.getenv("SSHECRET_DATABASE") or DEFAULT_DATABASE
db_file = Path(db_filename).absolute()
return BackendSettings(db_file=db_file)

View File

@ -0,0 +1,13 @@
"""Test helpers."""
from sqlmodel import Session
from .db import get_engine, create_api_token
from .models import init_db
from .settings import get_settings
def create_test_token(session: Session) -> str:
"""Create test token."""
settings = get_settings()
engine = get_engine(settings.db_file)
init_db(engine)
return create_api_token(session, True)

View File

@ -0,0 +1,112 @@
"""Models for API views."""
import uuid
from datetime import datetime
from typing import Self, override
from sqlmodel import Field, SQLModel
from . import models
class ClientListResponse(SQLModel):
"""Model list responses."""
id: uuid.UUID
name: str
fingerprint: str
created_at: datetime
updated_at: datetime | None = None
@classmethod
def from_clients(cls, clients: list[models.Client]) -> list[Self]:
"""Generate a list of responses from a list of clients."""
responses: list[Self] = []
for client in clients:
responses.append(
cls(
id=client.id,
name=client.name,
fingerprint=client.fingerprint,
created_at=client.created_at,
updated_at=client.updated_at or None,
)
)
return responses
class ClientView(ClientListResponse):
"""View for a single client."""
secrets: list[str] = Field(default_factory=list)
@classmethod
def from_client(cls, client: models.Client) -> Self:
"""Instantiate from a client."""
view = cls(
id=client.id,
name=client.name,
fingerprint=client.fingerprint,
created_at=client.created_at,
updated_at=client.updated_at or None,
)
if client.secrets:
view.secrets = [secret.name for secret in client.secrets]
return view
class ClientCreate(SQLModel):
"""Model to create a client."""
name: str
fingerprint: str
def to_client(self) -> models.Client:
"""Instantiate a client."""
return models.Client(name=self.name, fingerprint=self.fingerprint)
class ClientUpdate(SQLModel):
"""Model to update the client fingerprint."""
fingerprint: str
class BodyValue(SQLModel):
"""A generic model with just a value parameter."""
value: str
class ClientSecretPublic(SQLModel):
"""Public model to manage client secrets."""
name: str
secret: str
@classmethod
def from_client_secret(cls, client_secret: models.ClientSecret) -> Self:
"""Instantiate from ClientSecret."""
return cls(
name=client_secret.name,
secret=client_secret.secret,
)
class ClientSecretResponse(ClientSecretPublic):
"""A secret view."""
created_at: datetime
updated_at: datetime | None = None
@override
@classmethod
def from_client_secret(cls, client_secret: models.ClientSecret) -> Self:
"""Instantiate from ClientSecret."""
return cls(
name=client_secret.name,
secret=client_secret.secret,
created_at=client_secret.created_at,
updated_at=client_secret.updated_at,
)

Binary file not shown.

View File

@ -0,0 +1,279 @@
"""Tests of the backend api using pytest."""
import logging
from httpx import Response
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool
from sshecret_backend.router import app
from sshecret_backend.app import get_session
from sshecret_backend.testing import create_test_token
from sshecret_backend.models import AuditLog
LOG = logging.getLogger()
handler = logging.StreamHandler()
formatter = logging.Formatter("'%(asctime)s - %(levelname)s - %(message)s'")
handler.setFormatter(formatter)
LOG.addHandler(handler)
LOG.setLevel(logging.DEBUG)
TEST_FINGERPRINT = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff"
def create_client(
test_client: TestClient,
headers: dict[str, str],
name: str,
fingerprint: str = TEST_FINGERPRINT,
) -> Response:
"""Create client."""
data = {"name": name, "fingerprint": fingerprint}
create_response = test_client.post("/api/v1/clients", headers=headers, json=data)
return create_response
@pytest.fixture(name="session")
def session_fixture():
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
@pytest.fixture(name="token")
def token_fixture(session: Session):
"""Generate a token."""
token = create_test_token(session)
return token
@pytest.fixture(name="headers")
def headers_fixture(token: str) -> dict[str, str]:
"""Generate headers."""
return {"X-API-Token": token}
@pytest.fixture(name="test_client")
def test_client_fixture(session: Session):
"""Test client fixture."""
def get_session_override():
return session
app.dependency_overrides[get_session] = get_session_override
test_client = TestClient(app)
yield test_client
app.dependency_overrides.clear()
def test_missing_token(test_client: TestClient) -> None:
"""Test logging in with missing token."""
response = test_client.get("/api/v1/clients/")
assert response.status_code == 422
def test_incorrect_token(test_client: TestClient) -> None:
"""Test logging in with missing token."""
response = test_client.get("/api/v1/clients/", headers={"X-API-Token": "WRONG"})
assert response.status_code == 401
def test_with_token(test_client: TestClient, token: str) -> None:
"""Test with a valid token."""
response = test_client.get("/api/v1/clients/", headers={"X-API-Token": token})
assert response.status_code == 200
assert len(response.json()) == 0
def test_create_client(test_client: TestClient, headers: dict[str, str]) -> None:
"""Test creating a client."""
client_name = "test"
client_fingerprint = TEST_FINGERPRINT
create_response = create_client(
test_client, headers, client_name, client_fingerprint
)
assert create_response.status_code == 200
response = test_client.get("/api/v1/clients/", headers=headers)
assert response.status_code == 200
clients = response.json()
assert isinstance(clients, list)
client = clients[0]
assert isinstance(client, dict)
assert client.get("name") == client_name
assert client.get("fingerprint") == client_fingerprint
assert client.get("created_at") is not None
def test_add_secret(test_client: TestClient, headers: dict[str, str]) -> None:
"""Test adding a secret to a client."""
client_name = "test"
client_fingerprint = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff"
create_response = create_client(
test_client, headers, client_name, client_fingerprint
)
assert create_response.status_code == 200
secret_name = "mysecret"
secret_value = "shhhh"
data = {"name": secret_name, "secret": secret_value}
response = test_client.post(
"/api/v1/clients/test/secrets/", headers=headers, json=data
)
assert response.status_code == 200
# Get it back
get_response = test_client.get(
"/api/v1/clients/test/secrets/mysecret", headers=headers
)
assert get_response.status_code == 200
secret_body = get_response.json()
assert secret_body["name"] == data["name"]
assert secret_body["secret"] == data["secret"]
def test_put_add_secret(test_client: TestClient, headers: dict[str, str]) -> None:
"""Test adding secret via PUT."""
# Use the test_create_client function to create a client.
test_create_client(test_client, headers)
secret_name = "mysecret"
secret_value = "shhhh"
data = {"name": secret_name, "secret": secret_value}
response = test_client.put(
"/api/v1/clients/test/secrets/mysecret",
headers=headers,
json={"value": secret_value},
)
assert response.status_code == 200
response_model = response.json()
del response_model["created_at"]
del response_model["updated_at"]
assert response_model == data
def test_put_update_secret(test_client: TestClient, headers: dict[str, str]) -> None:
"""Test updating a client secret."""
test_add_secret(test_client, headers)
new_value = "itsasecret"
update_response = test_client.put(
"/api/v1/clients/test/secrets/mysecret",
headers=headers,
json={"value": new_value},
)
assert update_response.status_code == 200
expected = {"name": "mysecret", "secret": new_value}
response_model = update_response.json()
assert {
"name": response_model["name"],
"secret": response_model["secret"],
} == expected
# Ensure that the updated_at has been set.
assert "updated_at" in response_model
def test_audit_logging(test_client: TestClient, headers: dict[str, str]) -> None:
"""Test audit logging."""
create_client_resp = create_client(test_client, headers, "test")
assert create_client_resp.status_code == 200
secrets = {"secret1": "foo", "secret2": "bar", "secret3": "baz"}
for name, secret in secrets.items():
add_resp = test_client.post(
"/api/v1/clients/test/secrets/",
headers=headers,
json={"name": name, "secret": secret},
)
assert add_resp.status_code == 200
# Fetch the entire client.
get_client_resp = test_client.get("/api/v1/clients/test", headers=headers)
assert get_client_resp.status_code == 200
# Fetch the audit log
audit_log_resp = test_client.get("/api/v1/audit/", headers=headers)
assert audit_log_resp.status_code == 200
audit_logs = audit_log_resp.json()
assert len(audit_logs) > 0
for entry in audit_logs:
# Let's try to reassemble the objects
audit_log = AuditLog.model_validate(entry)
assert audit_log is not None
def test_audit_log_filtering(
session: Session, test_client: TestClient, headers: dict[str, str]
) -> None:
"""Test audit log filtering."""
# Create a lot of test data, but just manually.
audit_log_amount = 150
entries: list[AuditLog] = []
for i in range(audit_log_amount):
client_id = i % 5
entries.append(
AuditLog(
operation="TEST",
object_id=str(i),
client_name=f"client-{client_id}",
message="Test Message",
)
)
session.add_all(entries)
session.commit()
# This should have generated a lot of audit messages
audit_path = "/api/v1/audit/"
audit_log_resp = test_client.get(audit_path, headers=headers)
assert audit_log_resp.status_code == 200
entries = audit_log_resp.json()
assert len(entries) == 100 # We get 100 at a time
audit_log_resp = test_client.get(
audit_path, headers=headers, params={"offset": 100}
)
entries = audit_log_resp.json()
assert len(entries) == 52 # There should be 50 + the two requests we made
# Try to get a specific client
# There should be 30 log entries for each client.
audit_log_resp = test_client.get(
audit_path, headers=headers, params={"filter_client": "client-1"}
)
entries = audit_log_resp.json()
assert len(entries) == 30
def test_secret_invalidation(test_client: TestClient, headers: dict[str, str]) -> None:
"""Test secret invalidation."""
create_client_resp = create_client(test_client, headers, "test")
assert create_client_resp.status_code == 200
secrets = {"secret1": "foo", "secret2": "bar", "secret3": "baz"}
for name, secret in secrets.items():
add_resp = test_client.post(
"/api/v1/clients/test/secrets/",
headers=headers,
json={"name": name, "secret": secret},
)
assert add_resp.status_code == 200
# Update the fingerprint. This should cause all secrets to be invalidated
# and no longer associated with a client.
update_resp = test_client.post(
"/api/v1/clients/test/update_fingerprint",
headers=headers,
json={"fingerprint": "foobar"},
)
assert update_resp.status_code == 200
# Fetch the client. The list of secrets should be empty.
get_resp = test_client.get("/api/v1/clients/test", headers=headers)
assert get_resp.status_code == 200
client = get_resp.json()
secrets = client.get("secrets")
assert bool(secrets) is False