Standardize IDs, fix group APIs, fix tests

This commit is contained in:
2025-07-07 16:51:44 +02:00
parent 880d556542
commit 6faed0dbd4
22 changed files with 765 additions and 262 deletions

View File

@ -11,9 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import Select
from sshecret_backend import audit
from sshecret_backend.api.common import (
FlexID,
IdType,
RelaxedId,
create_new_client_version,
query_active_clients,
get_client_by_id,
@ -22,6 +19,8 @@ from sshecret_backend.api.common import (
reload_client_with_relationships,
)
from sshecret_backend.models import Client, ClientAccessPolicy
from sshecret.backend.identifiers import FlexID, IdType, RelaxedId
from .schemas import (
ClientListParams,
ClientCreate,
@ -91,7 +90,7 @@ class ClientOperations:
) -> Client | None:
"""Get client."""
if client.type is IdType.ID:
client_id = uuid.UUID(client.value)
client_id = _id(client.value)
else:
client_id = await self.get_client_id(
client, version=version, include_deleted=include_deleted

View File

@ -21,7 +21,8 @@ from sshecret_backend.api.clients.schemas import (
)
from sshecret_backend.api.clients import operations
from sshecret_backend.api.clients.operations import ClientOperations
from sshecret_backend.api.common import FlexID
from sshecret.backend.identifiers import FlexID
LOG = logging.getLogger(__name__)

View File

@ -2,13 +2,10 @@
import re
import logging
from typing import Self
import uuid
from dataclasses import dataclass, field
from enum import Enum
import bcrypt
from pydantic import BaseModel
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
@ -20,41 +17,6 @@ RE_UUID = re.compile(
"^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$"
)
RelaxedId = uuid.UUID | str
class IdType(Enum):
"""Id type."""
ID = "id"
NAME = "name"
class FlexID(BaseModel):
"""Flexible identifier."""
type: IdType
value: RelaxedId
@classmethod
def id(cls, id: RelaxedId) -> Self:
"""Construct from ID."""
return cls(type=IdType.ID, value=id)
@classmethod
def name(cls, name: str) -> Self:
"""Construct from name."""
return cls(type=IdType.NAME, value=name)
@classmethod
def from_string(cls, value: str) -> Self:
"""Convert from path string."""
if value.startswith("id:"):
return cls.id(value[3:])
elif value.startswith("name:"):
return cls.name(value[5:])
return cls.name(value)
@dataclass
class NewClientVersion:

View File

@ -10,8 +10,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sshecret_backend import audit
from sshecret_backend.api.common import (
FlexID,
IdType,
get_client_by_id,
resolve_client_id,
)
@ -24,9 +22,9 @@ from sshecret_backend.api.secrets.schemas import (
)
from sshecret_backend.models import Client, ClientSecret
LOG = logging.getLogger(__name__)
from sshecret.backend.identifiers import FlexID, IdType, RelaxedId
RelaxedId = uuid.UUID | str
LOG = logging.getLogger(__name__)
def _id(id: RelaxedId) -> uuid.UUID:
@ -85,6 +83,8 @@ class ClientSecretOperations:
return None
client = await get_client_by_id(self.session, client_id)
if client and (client.is_deleted and not self.include_deleted):
return None
self.client = client
return client
@ -199,15 +199,20 @@ class ClientSecretOperations:
async def resolve_client_secret_mapping(
session: AsyncSession,
session: AsyncSession, include_deleted_clients: bool = False
) -> list[ClientSecretDetailList]:
"""Resolve mapping of clients to secrets."""
"""Resolve mapping of clients to secrets.
If a secret is not deleted, but the client is, the secret is returned with
no clients attached.
"""
result = await session.execute(
select(ClientSecret)
.join(ClientSecret.client)
.options(selectinload(ClientSecret.client))
.where(Client.is_active.is_not(False))
.where(ClientSecret.deleted.is_not(True))
.where(Client.is_system.is_not(True))
)
client_secrets: dict[str, ClientSecretDetailList] = {}
for secret in result.scalars().all():
@ -216,6 +221,9 @@ async def resolve_client_secret_mapping(
client_secrets[secret.name].ids.append(str(secret.id))
if not secret.client:
continue
if secret.client.is_deleted and not include_deleted_clients:
continue
client_secrets[secret.name].clients.append(
ClientReference(id=str(secret.client.id), name=secret.client.name)
)
@ -224,7 +232,10 @@ async def resolve_client_secret_mapping(
async def resolve_client_secret_clients(
session: AsyncSession, name: str, include_deleted: bool = False
session: AsyncSession,
name: str,
include_deleted: bool = False,
include_deleted_clients: bool = False,
) -> ClientSecretDetailList | None:
"""Resolve client association to a secret."""
statement = (
@ -243,6 +254,8 @@ async def resolve_client_secret_clients(
clients = ClientSecretDetailList(name=name)
clients.ids.append(str(client_secret.id))
if client_secret.client and not client_secret.client.is_system:
if client_secret.client.is_deleted and not include_deleted_clients:
continue
clients.clients.append(
ClientReference(
id=str(client_secret.client.id), name=client_secret.client.name

View File

@ -8,7 +8,6 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sshecret_backend.api.common import FlexID
from sshecret_backend.types import AsyncDBSessionDep
from sshecret_backend.api.secrets.operations import (
ClientSecretOperations,
@ -22,6 +21,8 @@ from sshecret_backend.api.secrets.schemas import (
ClientSecretResponse,
)
from sshecret.backend.identifiers import FlexID
LOG = logging.getLogger(__name__)

View File

@ -1,6 +1,5 @@
"""CLI and main entry point."""
import code
import logging
import os
from pathlib import Path
@ -16,10 +15,6 @@ from sqlalchemy.orm import Session
from .db import create_api_token, get_engine
from .models import (
APIClient,
AuditLog,
Client,
ClientAccessPolicy,
ClientSecret,
SubSystem,
)
from .settings import BackendSettings
@ -128,26 +123,3 @@ def cli_run(host: str, port: int, dev: bool, workers: int | None) -> None:
uvicorn.run(
"sshecret_backend.main:app", host=host, port=port, reload=dev, workers=workers
)
@cli.command("repl")
@click.pass_context
def cli_repl(ctx: click.Context) -> None:
"""Run an interactive console."""
settings = cast(BackendSettings, ctx.obj)
engine = get_engine(settings.db_url, True)
with Session(engine) as session:
locals = {
"session": session,
"select": select,
"Client": Client,
"ClientSecret": ClientSecret,
"ClientAccessPolicy": ClientAccessPolicy,
"APIClient": APIClient,
"AuditLog": AuditLog,
}
console = code.InteractiveConsole(locals=locals, local_exit=True)
banner = "Sshecret-backend REPL.\nUse 'session' to interact with the database."
console.interact(banner=banner, exitmsg="Bye!")