Add sub-projects
This commit is contained in:
@ -0,0 +1 @@
|
||||
"""Sshecret backend."""
|
||||
289
packages/sshecret-backend/src/sshecret_backend/app.py
Normal file
289
packages/sshecret-backend/src/sshecret_backend/app.py
Normal 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
|
||||
151
packages/sshecret-backend/src/sshecret_backend/audit.py
Normal file
151
packages/sshecret-backend/src/sshecret_backend/audit.py
Normal 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)
|
||||
24
packages/sshecret-backend/src/sshecret_backend/cli.py
Normal file
24
packages/sshecret-backend/src/sshecret_backend/cli.py
Normal 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)
|
||||
54
packages/sshecret-backend/src/sshecret_backend/db.py
Normal file
54
packages/sshecret-backend/src/sshecret_backend/db.py
Normal 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
|
||||
91
packages/sshecret-backend/src/sshecret_backend/models.py
Normal file
91
packages/sshecret-backend/src/sshecret_backend/models.py
Normal 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)
|
||||
9
packages/sshecret-backend/src/sshecret_backend/router.py
Normal file
9
packages/sshecret-backend/src/sshecret_backend/router.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""API Router."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .app import backend_api
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(backend_api)
|
||||
26
packages/sshecret-backend/src/sshecret_backend/settings.py
Normal file
26
packages/sshecret-backend/src/sshecret_backend/settings.py
Normal 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)
|
||||
13
packages/sshecret-backend/src/sshecret_backend/testing.py
Normal file
13
packages/sshecret-backend/src/sshecret_backend/testing.py
Normal 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)
|
||||
112
packages/sshecret-backend/src/sshecret_backend/view_models.py
Normal file
112
packages/sshecret-backend/src/sshecret_backend/view_models.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user