Backend fixed and features

This commit is contained in:
2025-07-05 16:01:08 +02:00
parent 3ef659be61
commit 880d556542
29 changed files with 567 additions and 156 deletions

View File

@ -2,39 +2,86 @@
# pyright: reportUnusedFunction=false
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Form, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sshecret_admin.auth import Token, authenticate_user_async, create_access_token
from sshecret_admin.auth import (
Token,
authenticate_user_async,
create_access_token,
create_refresh_token,
decode_token,
)
from sshecret_admin.core.dependencies import AdminDependencies
LOG = logging.getLogger(__name__)
class RefreshTokenForm(BaseModel):
"""The refresh token form data."""
grant_type: Literal["refresh_token"]
refresh_token: str
def create_router(dependencies: AdminDependencies) -> APIRouter:
"""Create auth router."""
app = APIRouter()
@app.post("/token")
async def login_for_access_token(
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
"""Login user and generate token."""
user = await authenticate_user_async(session, form_data.username, form_data.password)
user = await authenticate_user_async(
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"},
)
token_data: dict[str, str] = {"sub": user.username}
access_token = create_access_token(
dependencies.settings,
data={"sub": user.username},
data=token_data,
)
refresh_token = create_refresh_token(dependencies.settings, data=token_data)
return Token(
access_token=access_token, refresh_token=refresh_token, token_type="bearer"
)
@app.post("/refresh")
async def refresh_token(
form_data: Annotated[RefreshTokenForm, Form()],
) -> Token:
"""Refresh access token."""
LOG.info("Refresh token data: %r", form_data)
claims = decode_token(dependencies.settings, form_data.refresh_token)
if not claims:
LOG.info("Could not decode claims")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
headers={"WWW-Authenticate": "Bearer"},
)
token_data: dict[str, str] = {"sub": claims.sub}
access_token = create_access_token(
dependencies.settings,
data=token_data,
)
refresh_token = create_refresh_token(dependencies.settings, data=token_data)
return Token(
access_token=access_token, refresh_token=refresh_token, token_type="bearer"
)
return Token(access_token=access_token, token_type="bearer")
return app

View File

@ -1,24 +1,52 @@
"""Client-related endpoints factory."""
"""Client-related endpoints factory.
# TODO: Settle on name/keyspec pattern
"""
# pyright: reportUnusedFunction=false
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sshecret.backend import Client
from sshecret.backend import Client, ClientFilter
from sshecret_admin.core.dependencies import AdminDependencies
from sshecret_admin.services import AdminBackend
from sshecret_admin.services.models import (
ClientCreate,
ClientListParams,
UpdateKeyModel,
UpdateKeyResponse,
UpdatePoliciesRequest,
)
from sshecret.backend.models import ClientQueryResult, ClientReference, FilterType
LOG = logging.getLogger(__name__)
def query_filter_to_client_filter(query_filter: ClientListParams) -> ClientFilter:
"""Convert query filter to client filter."""
client_filter = ClientFilter(
limit=query_filter.limit,
offset=query_filter.offset,
order_by=query_filter.order_by,
order_reverse=query_filter.order_reverse,
)
if client_id := query_filter.id:
client_filter.id = str(client_id)
if match_name := query_filter.name:
client_filter.name = match_name
elif match_name_like := query_filter.name__like:
client_filter.name = match_name_like
client_filter.filter_name = FilterType.LIKE
elif match_name_contains := query_filter.name__contains:
client_filter.name = match_name_contains
client_filter.filter_name = FilterType.CONTAINS
return client_filter
def create_router(dependencies: AdminDependencies) -> APIRouter:
"""Create clients router."""
app = APIRouter(dependencies=[Depends(dependencies.get_current_active_user)])
@ -31,6 +59,23 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
clients = await admin.get_clients()
return clients
@app.get("/clients/terse/")
async def get_clients_terse(
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> list[ClientReference]:
"""Get a list of client ids and names."""
return await admin.get_clients_terse()
@app.get("/query/clients/")
async def query_clients(
filter_query: Annotated[ClientListParams, Query()],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> ClientQueryResult:
"""Query clients."""
client_filter = query_filter_to_client_filter(filter_query)
clients = await admin.query_clients(client_filter)
return clients
@app.post("/clients/")
async def create_client(
new_client: ClientCreate,
@ -41,26 +86,66 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
if new_client.sources:
sources = [str(source) for source in new_client.sources]
client = await admin.create_client(
new_client.name, new_client.public_key, sources=sources
name=new_client.name,
public_key=new_client.public_key,
description=new_client.description,
sources=sources,
)
return client
@app.delete("/clients/{name}")
@app.get("/clients/{id}")
async def get_client(
id: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Client:
"""Get a client."""
client = await admin.get_client(("id", id))
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
)
return client
@app.put("/clients/{id}")
async def update_client(
id: str,
updated: ClientCreate,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Client:
"""Update a client."""
client = await admin.get_client(("id", id))
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
)
update_fields = {
"description": updated.description,
"public_key": updated.public_key,
"policies": updated.sources,
}
new_client = client.model_copy(update=update_fields)
result = await admin.update_client(new_client)
return result
@app.delete("/clients/{id}")
async def delete_client(
name: str,
id: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""Delete a client."""
await admin.delete_client(name)
await admin.delete_client(("id", id))
@app.delete("/clients/{name}/secrets/{secret_name}")
@app.delete("/clients/{id}/secrets/{secret_name}")
async def delete_secret_from_client(
name: str,
id: str,
secret_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""Delete a secret from a client."""
client = await admin.get_client(name)
client = await admin.get_client(("id", id))
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
@ -70,16 +155,16 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
LOG.debug("Client does not have requested secret. No action to perform.")
return None
await admin.delete_client_secret(name, secret_name)
await admin.delete_client_secret(("id", id), secret_name)
@app.put("/clients/{name}/policies")
@app.put("/clients/{id}/policies")
async def update_client_policies(
name: str,
id: str,
updated: UpdatePoliciesRequest,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Client:
"""Update the client access policies."""
client = await admin.get_client(name)
client = await admin.get_client(("id", id))
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
@ -88,16 +173,16 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
LOG.debug("Old policies: %r. New: %r", client.policies, updated.sources)
addresses: list[str] = [str(source) for source in updated.sources]
await admin.update_client_sources(name, addresses)
client = await admin.get_client(name)
await admin.update_client_sources(("id", id), addresses)
client = await admin.get_client(("id", id))
assert client is not None, "Critical: The client disappeared after update!"
return client
@app.put("/clients/{name}/public-key")
@app.put("/clients/{id}/public-key")
async def update_client_public_key(
name: str,
id: str,
updated: UpdateKeyModel,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> UpdateKeyResponse:
@ -107,18 +192,20 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
be resolved first, and re-encrypted using the new key.
"""
# Let's first ensure that the key is actually updated.
updated_secrets = await admin.update_client_public_key(name, updated.public_key)
updated_secrets = await admin.update_client_public_key(
("id", id), updated.public_key
)
return UpdateKeyResponse(
public_key=updated.public_key, updated_secrets=updated_secrets
)
@app.put("/clients/{name}/secrets/{secret_name}")
@app.put("/clients/{id}/secrets/{secret_name}")
async def add_secret_to_client(
name: str,
id: str,
secret_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""Add secret to a client."""
await admin.create_client_secret(name, secret_name)
await admin.create_client_secret(("id", id), secret_name)
return app

View File

@ -27,7 +27,7 @@ API_VERSION = "v1"
def create_router(dependencies: BaseDependencies) -> APIRouter:
"""Create clients router."""
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token", refreshUrl="/api/v1/refresh")
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],

View File

@ -168,6 +168,7 @@ class TokenData(BaseModel):
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str

View File

@ -13,6 +13,8 @@ from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.ext.asyncio import AsyncSession
from sshecret_backend.db import DatabaseSessionManager
from starlette.middleware.sessions import SessionMiddleware
@ -68,6 +70,14 @@ def create_admin_app(
app = FastAPI(lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
origins = [ settings.frontend_origin ]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(

View File

@ -2,8 +2,10 @@
import asyncio
import code
import json
import logging
from collections.abc import Awaitable
from pathlib import Path
from typing import Any, cast
import click
@ -13,6 +15,7 @@ from sqlalchemy import select, create_engine
from sqlalchemy.orm import Session
from sshecret_admin.auth.authentication import hash_password
from sshecret_admin.auth.models import AuthProvider, PasswordDB, User
from sshecret_admin.core.app import create_admin_app
from sshecret_admin.core.settings import AdminServerSettings
from sshecret_admin.services.admin_backend import AdminBackend
@ -169,3 +172,20 @@ def cli_repl(ctx: click.Context) -> None:
banner = "Sshecret-admin REPL\nAdmin backend API bound to 'admin'. Run async functions with run()"
console = code.InteractiveConsole(locals=locals, local_exit=True)
console.interact(banner=banner, exitmsg="Bye!")
@cli.command("openapi")
@click.argument("destination", type=click.Path(file_okay=False, dir_okay=True, path_type=Path))
@click.pass_context
def cli_generate_openapi(ctx: click.Context, destination: Path) -> None:
"""Generate openapi schema.
A openapi.json file will be written to the destination directory.
"""
settings = cast(AdminServerSettings, ctx.obj)
app = create_admin_app(settings, with_frontend=False)
schema = app.openapi()
output_file = destination / "openapi.json"
with open(output_file, "w") as f:
json.dump(schema, f)
click.echo(f"Wrote schema to {output_file.absolute()}")

View File

@ -39,6 +39,7 @@ class AdminServerSettings(BaseSettings):
debug: bool = False
password_manager_directory: Path | None = None
oidc: OidcSettings | None = None
frontend_origin: str = Field(default="*")
@property
def admin_db(self) -> URL:

View File

@ -1,27 +1,26 @@
{% extends 'base/page.html.j2' %}
{% extends "/base/navbar.html.j2" %}
{% block content %}
{% block title %}Clients{% endblock %}
{% block page_content %}
<!-- Master-Detail Layout -->
<div class="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-4">
<!-- Master (e.g., tree or list) -->
<div id="master-pane">
<div class="flex h-[calc(100vh-3.5rem)] overflow-hidden">
<aside id="master-pane"
class="flex flex-col overflow-hidden hidden lg:block lg:w-80 w-full shrink-0 border-r bg-white border-gray-200 p-4 dark:bg-gray-800 dark:border-gray-700">
{% block master %}
<p>Master list goes here</p>
{% endblock %}
</div>
{% endblock master %}
<!-- Detail (loaded by HTMX or inline) -->
<div id="detail-pane" class="bg-white rounded shadow p-4">
</aside>
<section id="detail-pane"
class="flex-1 flex overflow-y-auto bg-white p-4 dark:bg-gray-800">
<div class="flex flex-col w-full">
{% block detail %}
<p>Select an item from the list to view details.</p>
{% endblock %}
{% include '/base/partials/breadcrumbs.html.j2' %}
<div>
<p class="p-4 text-gray-500 dark:text-gray-200">Select an item to view details</p>
</div>
{% endblock detail %}
</div>
</section>
</div>
{% endblock %}

View File

@ -8,7 +8,7 @@
<script>
const sidebarToggle = document.getElementById('sidebar-toggle');
const sidebarDrawer = document.getElementById('sidebar');
const sidebarDrawer = document.getElementById('master-pane');
sidebarToggle?.addEventListener('click', () => {
sidebarDrawer.classList.toggle("hidden");

View File

@ -1,27 +1,38 @@
{% extends 'base/master-detail-email.html.j2' %}
{% block title %}Client {{ client.name }}{% endblock %}
{% extends "/base/master-detail.html.j2" %}
{% block master %}
<div id="client-tree">
{% include '/clients/partials/tree.html.j2' %}
</div>
{% include '/clients/partials/drawer_create.html.j2' %}
{% endblock %}
<sl-tab-group id="sideTabs" class="flex flex-col flex-1 h-full overflow-hidden master-pane-tabs">
<sl-tab slot="nav" panel="clients">Clients</sl-tab>
<sl-tab slot="nav" panel="secrets">Secrets</sl-tab>
<sl-tab slot="nav" panel="audit">Audit</sl-tab>
<sl-tab-panel name="clients" >
<div id="client-tree" class="flex flex-col flex-1 w-full h-full">
</div>
</sl-tab-panel>
<sl-tab-panel name="secrets">
<div id="secrets-tree" class="flex flex-col flex-1 w-full h-full">
</div>
</sl-tab-panel>
<sl-tab-panel name="audit">
<div id="audit-tree" class="flex flex-col flex-1 w-full h-full">
</div>
</sl-tab-panel>
</sl-tab-group>
{% endblock master %}
{% block detail %}
<div id="clientdetails" class="w-full">
{% include '/clients/partials/client_details.html.j2' %}
</div>
<!-- after clientdetails -->
{% endblock %}
{% endblock detail %}
{% block local_scripts %}
<script>
{% include '/clients/partials/tree_event.js' %}
{% include '/admin/partials/master.js' %}
</script>
{% endblock local_scripts %}

View File

@ -1,23 +1,33 @@
{% extends 'base/master-detail-email.html.j2' %}
{% block title %}Clients{% endblock %}
{% extends "/base/master-detail.html.j2" %}
{% block master %}
<div id="client-tree">
<sl-tab-group id="sideTabs" class="flex flex-col flex-1 h-full overflow-hidden master-pane-tabs">
<sl-tab slot="nav" panel="clients">Clients</sl-tab>
<sl-tab slot="nav" panel="secrets">Secrets</sl-tab>
<sl-tab slot="nav" panel="audit">Audit</sl-tab>
<sl-tab-panel name="clients" >
<div id="client-tree" class="flex flex-col flex-1 w-full h-full">
{% include '/clients/partials/tree.html.j2' %}
</div>
{% endblock %}
{% block detail %}
<div id="clientdetails" class="w-full bg-white dark:bg-gray-800">
<h3 class="mb-4 text-sm italic text-gray-400 dark:text-white">Click an item to view details</h3>
</sl-tab-panel>
<sl-tab-panel name="secrets">
<div id="secrets-tree" class="flex flex-col flex-1 w-full h-full">
</div>
{% include '/clients/partials/drawer_create.html.j2' %}
{% endblock %}
</sl-tab-panel>
<sl-tab-panel name="audit">
<div id="audit-tree" class="flex flex-col flex-1 w-full h-full">
</div>
</sl-tab-panel>
</sl-tab-group>
{% endblock master %}
{% block local_scripts %}
<script>
{% include '/clients/partials/tree_event.js' %}
{% include '/admin/partials/master.js' %}
</script>
{% endblock local_scripts %}

View File

@ -1,7 +1,7 @@
{# This is the master block #}
<div class="flowbite-init-target">
<div class="tree-header grid grid-cols-2 place-content-between mb-2">
<div class="flowbite-init-target flex flex-col h-full min-h-0">
<div class="tree-header mb-2 grid grid-cols-2 place-content-between">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Client List</h1>
<div class="flex">
<div
@ -30,7 +30,7 @@
></sl-icon-button>
</div>
</div>
<div class="col-span-full">
<div class="col-span-full"> <!-- was: col-span-full -->
<div class="relative">
<div class="border-b border-gray-200 py-2">
<label for="default-search" class="mb-2 text-xs font-medium text-gray-900 sr-only dark:text-white">Search</label>
@ -57,8 +57,7 @@
</div>
</div>
</div>
<div id="client-tree-items">
{% include '/clients/partials/tree_items.html.j2' %}
</div>
</div>
{% include '/clients/partials/drawer_create.html.j2' %}

View File

@ -1,8 +1,9 @@
<div class="flowbite-init-target">
<div id="client-tree-items" class="flowbite-init-target flex flex-col h-full min-h-0">
{% if more_results %}
<span class="text-gray-400 text-xs italic mt-4">{{more_results}} more results. Narrow search to show them...</span>
{% endif %}
<sl-tree class="full-height-tree">
<div class="flex-1 overflow-y-auto">
<sl-tree class="w-full">
{% for item in clients %}
<sl-tree-item
id="client-{{ item.id }}"
@ -30,12 +31,16 @@
</sl-tree-item>
{% endfor %}
</sl-tree>
</div>
{% if pages %}
<div class="mt-4 text-center flex items-center flex-col border-t border-gray-100">
<div class="shrink-0 mt-4 pt-2 border-t border-gray-100 dark:border-gray-700 bg-white dark:bg-gray-800">
<div class="mt-4 text-center flex items-center flex-col">
<span class="text-sm text-gray-700 dark:text-gray-400">
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ pages.offset + 1 }}</span> to <span class="font-semibold text-gray-900 dark:text-white">{{ pages.limit }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ results.total_results }}</span> Entries
</span>
{% include 'clients/partials/pagination.html.j2' %}
</div>
</div>
{% endif %}
</div>

View File

@ -79,39 +79,60 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
templates = dependencies.templates
@app.get("/clients/")
async def get_client_tree(
async def get_test_page(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Response:
"""Get client tree view."""
page = 1
per_page = CLIENTS_PER_PAGE
offset = 0
"""Get test page."""
client_filter = ClientFilter(offset=offset, limit=per_page)
results = await admin.query_clients(client_filter)
paginate = PagingInfo(
page=page, limit=per_page, total=results.total_results, offset=offset
)
breadcrumbs = [("clients", "/clients/")]
LOG.info("Results %r", results)
return templates.TemplateResponse(
request,
"clients/index.html.j2",
"admin/index.html.j2",
{
"breadcrumbs": breadcrumbs,
"page_title": "Clients",
"offset": offset,
"pages": paginate,
"clients": results.clients,
"user": current_user,
"results": results,
},
}
)
# @app.get("/clients/")
# async def get_client_tree(
# request: Request,
# current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
# admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
# ) -> Response:
# """Get client tree view."""
# page = 1
# per_page = CLIENTS_PER_PAGE
# offset = 0
# client_filter = ClientFilter(offset=offset, limit=per_page)
# results = await admin.query_clients(client_filter)
# paginate = PagingInfo(
# page=page, limit=per_page, total=results.total_results, offset=offset
# )
# breadcrumbs = [("clients", "/clients/")]
# LOG.info("Results %r", results)
# return templates.TemplateResponse(
# request,
# "clients/index_new.html.j2",
# {
# "breadcrumbs": breadcrumbs,
# "page_title": "Clients",
# "offset": offset,
# "pages": paginate,
# "clients": results.clients,
# "user": current_user,
# "results": results,
# },
# )
@app.get("/clients/page/{page}")
async def get_client_page(
request: Request,

View File

@ -3,7 +3,7 @@
# pyright: reportUnusedFunction=false
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request
from fastapi import APIRouter, Depends, Form, Request, Response
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession

View File

@ -16,7 +16,7 @@ from sshecret.backend import (
Operation,
SubSystem,
)
from sshecret.backend.models import ClientQueryResult, DetailedSecrets
from sshecret.backend.models import ClientQueryResult, ClientReference, DetailedSecrets
from sshecret.backend.api import AuditAPI, KeySpec
from sshecret.crypto import encrypt_string, load_public_key
@ -121,6 +121,10 @@ class AdminBackend:
except Exception as e:
raise BackendUnavailableError() from e
async def get_clients_terse(self) -> list[ClientReference]:
"""Get a list of client ids and names."""
return await self.backend.get_client_terse()
async def query_clients(
self, filter: ClientFilter | None = None
) -> ClientQueryResult:
@ -496,7 +500,7 @@ class AdminBackend:
secret_mapping = await self.backend.get_secret(idname)
if secret_mapping:
secret_view.clients = [ref.name for ref in secret_mapping.clients]
secret_view.clients = [ClientReference(id=ref.id, name=ref.name) for ref in secret_mapping.clients]
return secret_view

View File

@ -1,7 +1,8 @@
"""Models for the API."""
import secrets
from typing import Annotated, Literal
from typing import Annotated, Literal, Self
import uuid
from pydantic import (
AfterValidator,
BaseModel,
@ -9,6 +10,7 @@ from pydantic import (
Field,
IPvAnyAddress,
IPvAnyNetwork,
model_validator,
)
from sshecret.crypto import validate_public_key
from sshecret.backend.models import ClientReference
@ -35,7 +37,9 @@ class SecretView(BaseModel):
name: str
secret: str | None
group: str | None = None
clients: list[str] = Field(default_factory=list) # Clients that have access to it.
clients: list[ClientReference] = Field(
default_factory=list
) # Clients that have access to it.
class UpdateKeyModel(BaseModel):
@ -62,6 +66,7 @@ class ClientCreate(BaseModel):
"""Model to create a client."""
name: str
description: str | None = None
public_key: Annotated[str, AfterValidator(public_key_validator)]
sources: list[IPvAnyAddress | IPvAnyNetwork] = Field(default_factory=list)
@ -163,3 +168,31 @@ class ClientSecretGroupList(BaseModel):
ungrouped: list[SecretClientMapping] = Field(default_factory=list)
groups: list[ClientSecretGroup] = Field(default_factory=list)
class ClientListParams(BaseModel):
"""Client list parameters."""
limit: int = Field(100, gt=0, le=100)
offset: int = Field(0, ge=0)
id: uuid.UUID | None = None
name: str | None = None
name__like: str | None = None
name__contains: str | None = None
order_by: str = "created_at"
order_reverse: bool = True
@model_validator(mode="after")
def validate_expressions(self) -> Self:
"""Validate mutually exclusive expression."""
name_filter = False
if self.name__like or self.name__contains:
name_filter = True
if self.name__like and self.name__contains:
raise ValueError("You may only specify one name expression")
if self.name and name_filter:
raise ValueError(
"You must either specify name or one of name__like or name__contains"
)
return self

View File

@ -679,6 +679,9 @@
.h-11 {
height: calc(var(--spacing) * 11);
}
.h-14 {
height: calc(var(--spacing) * 14);
}
.h-16 {
height: calc(var(--spacing) * 16);
}
@ -880,6 +883,9 @@
.flex-1 {
flex: 1;
}
.flex-none {
flex: none;
}
.flex-shrink {
flex-shrink: 1;
}
@ -3000,12 +3006,31 @@
}
}
}
.md\:space-x-8 {
@media (width >= 48rem) {
:where(& > :not(:last-child)) {
--tw-space-x-reverse: 0;
margin-inline-start: calc(calc(var(--spacing) * 8) * var(--tw-space-x-reverse));
margin-inline-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-x-reverse)));
}
}
}
.md\:border-0 {
@media (width >= 48rem) {
border-style: var(--tw-border-style);
border-width: 0px;
}
}
.md\:bg-transparent {
@media (width >= 48rem) {
background-color: transparent;
}
}
.md\:bg-white {
@media (width >= 48rem) {
background-color: var(--color-white);
}
}
.md\:p-0 {
@media (width >= 48rem) {
padding: calc(var(--spacing) * 0);
@ -3060,6 +3085,11 @@
line-height: var(--tw-leading, var(--text-xs--line-height));
}
}
.md\:text-blue-700 {
@media (width >= 48rem) {
color: var(--color-blue-700);
}
}
.md\:hover\:bg-transparent {
@media (width >= 48rem) {
&:hover {
@ -3069,6 +3099,15 @@
}
}
}
.md\:hover\:text-blue-700 {
@media (width >= 48rem) {
&:hover {
@media (hover: hover) {
color: var(--color-blue-700);
}
}
}
}
.md\:hover\:text-primary-700 {
@media (width >= 48rem) {
&:hover {
@ -4045,6 +4084,20 @@
}
}
}
.md\:dark\:bg-gray-900 {
@media (width >= 48rem) {
&:where(.dark, .dark *) {
background-color: var(--color-gray-900);
}
}
}
.md\:dark\:text-blue-500 {
@media (width >= 48rem) {
&:where(.dark, .dark *) {
color: var(--color-blue-500);
}
}
}
.md\:dark\:hover\:bg-transparent {
@media (width >= 48rem) {
&:where(.dark, .dark *) {
@ -4056,6 +4109,17 @@
}
}
}
.md\:dark\:hover\:text-blue-500 {
@media (width >= 48rem) {
&:where(.dark, .dark *) {
&:hover {
@media (hover: hover) {
color: var(--color-blue-500);
}
}
}
}
}
}
@property --tw-translate-x {
syntax: "*";

View File

@ -27,3 +27,21 @@ sl-details.small-details::part(base) {
color: var(--color-gray-50);
}
}
/* sl-tab-group::part(base), */
/* sl-tab-group::part(body), */
/* sl-tab-panel::part(base), */
/* sl-tab-panel::part(body), */
/* sl-tab-panel { */
/* height: 100%; */
/* overflow: hidden; */
/* } */
sl-tab-group.master-pane-tabs::part(base),
sl-tab-group.master-pane-tabs::part(body),
sl-tab-group.master-pane-tabs sl-tab-panel::part(base),
sl-tab-group.master-pane-tabs sl-tab-panel::part(body),
sl-tab-group.master-pane-tabs sl-tab-panel {
height: 100%;
overflow: hidden;
}

View File

@ -1,4 +1,4 @@
const sidebar = document.getElementById("sidebar");
const sidebar = document.getElementById("master-pane");
if (sidebar) {
const toggleSidebarMobile = (

View File

@ -51,7 +51,7 @@ def upgrade() -> None:
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
sa.UniqueConstraint('name', name="uq_client_name")
)
op.create_table('client_access_policy',
sa.Column('id', sa.Uuid(), nullable=False),

View File

@ -30,6 +30,7 @@ def upgrade() -> None:
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)
)
batch_op.add_column(sa.Column("parent_id", sa.Uuid(), nullable=True))
batch_op.drop_constraint("uq_client_name")
batch_op.create_unique_constraint("uq_client_name_version", ["name", "version"])
batch_op.create_foreign_key(
"fk_client_parent", "client", ["parent_id"], ["id"], ondelete="SET NULL"

View File

@ -29,6 +29,7 @@ from .schemas import (
ClientView,
ClientQueryResult,
ClientPolicyUpdate,
ClientReference,
)
@ -61,6 +62,7 @@ class ClientOperations:
self,
client: FlexID,
version: int | None = None,
include_deleted: bool = False,
) -> uuid.UUID | None:
"""Get client ID."""
if self._client_id:
@ -76,6 +78,7 @@ class ClientOperations:
self.session,
client_name,
version=version,
include_deleted=include_deleted,
)
if not client_id:
return None
@ -84,17 +87,26 @@ class ClientOperations:
return client_id
async def _get_client(
self,
client: FlexID,
version: int | None = None,
self, client: FlexID, version: int | None = None, include_deleted: bool = False
) -> Client | None:
"""Get client."""
client_id = await self.get_client_id(client, version=version)
if client.type is IdType.ID:
client_id = uuid.UUID(client.value)
else:
client_id = await self.get_client_id(
client, version=version, include_deleted=include_deleted
)
if not client_id:
return None
db_client = await get_client_by_id(self.session, client_id)
db_client = await get_client_by_id(
self.session, client_id, include_deleted=include_deleted
)
return db_client
async def get_clients_terse(self) -> list[ClientReference]:
"""Get a list of client id and names"""
return await get_client_references(self.session)
async def get_client(
self,
client: FlexID,
@ -115,7 +127,25 @@ class ClientOperations:
raise HTTPException(
status_code=400, detail="Error: A client already exists with this name."
)
deleted_id = await resolve_client_id(
self.session, create_model.name, include_deleted=True
)
client = create_model.to_client()
if deleted_id:
# Some other client had this name before, let's make it a new version
LOG.info(
"Client %s had this name before, we're creating a new version.",
deleted_id,
)
return await self.new_client_version(
FlexID.id(deleted_id),
public_key=create_model.public_key,
name=create_model.name,
description=create_model.description,
from_deleted=True,
)
if system_client:
statement = query_active_clients().where(Client.is_system.is_(True))
results = await self.session.scalars(statement)
@ -181,10 +211,12 @@ class ClientOperations:
public_key: str,
name: str | None = None,
description: str | None = None,
from_deleted: bool = False,
) -> ClientView:
"""Update a client to a new version."""
current_client = await self._get_client(client)
current_client = await self._get_client(client, include_deleted=from_deleted)
if not current_client:
LOG.info("Could not find previous version.")
raise HTTPException(status_code=404, detail="Client not found.")
new_client = await create_new_client_version(
self.session, current_client, public_key
@ -343,6 +375,23 @@ async def get_clients(
)
async def get_client_references(
session: AsyncSession,
) -> list[ClientReference]:
"""Get a list of client names and IDs."""
query = (
select(Client)
.where(Client.is_active.is_(True))
.where(Client.is_deleted.is_not(True))
.where(Client.is_system.is_not(True))
)
clients = await session.scalars(query)
references: list[ClientReference] = []
for client in clients.all():
references.append(ClientReference(id=client.id, name=client.name))
return references
async def new_client_version(
session: AsyncSession,
client_id: uuid.UUID,

View File

@ -17,6 +17,7 @@ from sshecret_backend.api.clients.schemas import (
ClientView,
ClientPolicyUpdate,
ClientPolicyView,
ClientReference,
)
from sshecret_backend.api.clients import operations
from sshecret_backend.api.clients.operations import ClientOperations
@ -46,6 +47,15 @@ def create_client_router(get_db_session: AsyncDBSessionDep) -> APIRouter:
client_op = ClientOperations(session, request)
return await client_op.create_client(client)
@router.get("/clients/terse/")
async def get_clients_terse(
request: Request,
session: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[ClientReference]:
"""Get a list of client ids and names."""
client_op = ClientOperations(session, request)
return await client_op.get_clients_terse()
@router.get("/internal/system_client/", include_in_schema=False)
async def get_system_client(
request: Request,

View File

@ -70,6 +70,12 @@ class ClientView(BaseModel):
return view
class ClientReference(BaseModel):
"""A list of client names and IDs."""
id: uuid.UUID
name: str
class ClientQueryResult(BaseModel):
"""Result class for queries towards the client list."""

View File

@ -1,6 +1,7 @@
"""Common helpers."""
import re
import logging
from typing import Self
import uuid
from dataclasses import dataclass, field
@ -14,6 +15,7 @@ from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sshecret_backend.models import Client, ClientAccessPolicy
LOG = logging.getLogger(__name__)
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}$"
)
@ -165,7 +167,7 @@ async def create_new_client_version(
for policy in current_client.policies:
copied_policy = ClientAccessPolicy(
client=new_client,
address=policy.source,
source=policy.source,
)
session.add(copied_policy)

View File

@ -150,7 +150,7 @@ async def audit_new_client_version(
message="Client data updated",
data={
"new_client_id": str(new_client.id),
"new_client_version": new_client.version,
"new_client_version": str(new_client.version),
},
)
await _write_audit_log(session, request, entry, commit)

View File

@ -6,7 +6,6 @@ admin and sshd library do not need to implement the same
import logging
from typing import Any, Literal, Self, override
import uuid
import httpx
from pydantic import TypeAdapter
@ -17,6 +16,7 @@ from .models import (
AuditListResult,
AuditLog,
Client,
ClientReference,
ClientSecret,
ClientQueryResult,
ClientFilter,
@ -324,7 +324,7 @@ class SshecretBackend(BaseBackend):
if description:
data["description"] = description
path = "/api/v1/clients/"
response = await self._post(path, json=data)
await self._post(path, json=data)
async def create_system_client(self, name: str, public_key: str) -> Client:
"""Create system client."""
@ -356,6 +356,13 @@ class SshecretBackend(BaseBackend):
return clients
async def get_client_terse(self) -> list[ClientReference]:
"""Get just the names and IDs of all clients."""
path = "/api/v1/clients/terse/"
response = await self._get(path)
ReferenceFactory = TypeAdapter(list[ClientReference])
return ReferenceFactory.validate_python(response.json())
async def get_client_count(self, filter: ClientFilter | None = None) -> int:
"""Get a count of the clients optionally filtered."""
query_results = await self.query_clients(filter)
@ -367,7 +374,13 @@ class SshecretBackend(BaseBackend):
"""Query clients."""
params: dict[str, str] = {}
if filter:
params = filter.get_params(exclude_offset=False, exclude_limit=False)
exclude_limit = False
if filter.limit == -1:
exclude_limit = True
params = filter.get_params(
exclude_offset=False, exclude_limit=exclude_limit
)
try:
results = await self._get("/api/v1/clients/", params=params)

6
uv.lock generated
View File

@ -381,16 +381,16 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.115.12"
version = "0.115.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236 }
sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164 },
{ url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514 },
]
[package.optional-dependencies]