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 # pyright: reportUnusedFunction=false
import logging import logging
from typing import Annotated from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, Form, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession 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 from sshecret_admin.core.dependencies import AdminDependencies
LOG = logging.getLogger(__name__) 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: def create_router(dependencies: AdminDependencies) -> APIRouter:
"""Create auth router.""" """Create auth router."""
app = APIRouter() app = APIRouter()
@app.post("/token") @app.post("/token")
async def login_for_access_token( async def login_for_access_token(
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)], session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token: ) -> Token:
"""Login user and generate 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: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password", detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
token_data: dict[str, str] = {"sub": user.username}
access_token = create_access_token( access_token = create_access_token(
dependencies.settings, 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 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 # pyright: reportUnusedFunction=false
import logging import logging
from typing import Annotated 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.core.dependencies import AdminDependencies
from sshecret_admin.services import AdminBackend from sshecret_admin.services import AdminBackend
from sshecret_admin.services.models import ( from sshecret_admin.services.models import (
ClientCreate, ClientCreate,
ClientListParams,
UpdateKeyModel, UpdateKeyModel,
UpdateKeyResponse, UpdateKeyResponse,
UpdatePoliciesRequest, UpdatePoliciesRequest,
) )
from sshecret.backend.models import ClientQueryResult, ClientReference, FilterType
LOG = logging.getLogger(__name__) 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: def create_router(dependencies: AdminDependencies) -> APIRouter:
"""Create clients router.""" """Create clients router."""
app = APIRouter(dependencies=[Depends(dependencies.get_current_active_user)]) app = APIRouter(dependencies=[Depends(dependencies.get_current_active_user)])
@ -31,6 +59,23 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
clients = await admin.get_clients() clients = await admin.get_clients()
return 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/") @app.post("/clients/")
async def create_client( async def create_client(
new_client: ClientCreate, new_client: ClientCreate,
@ -41,26 +86,66 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
if new_client.sources: if new_client.sources:
sources = [str(source) for source in new_client.sources] sources = [str(source) for source in new_client.sources]
client = await admin.create_client( 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 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( async def delete_client(
name: str, id: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None: ) -> None:
"""Delete a client.""" """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( async def delete_secret_from_client(
name: str, id: str,
secret_name: str, secret_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None: ) -> None:
"""Delete a secret from a client.""" """Delete a secret from a client."""
client = await admin.get_client(name) client = await admin.get_client(("id", id))
if not client: if not client:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" 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.") LOG.debug("Client does not have requested secret. No action to perform.")
return None 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( async def update_client_policies(
name: str, id: str,
updated: UpdatePoliciesRequest, updated: UpdatePoliciesRequest,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Client: ) -> Client:
"""Update the client access policies.""" """Update the client access policies."""
client = await admin.get_client(name) client = await admin.get_client(("id", id))
if not client: if not client:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" 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) LOG.debug("Old policies: %r. New: %r", client.policies, updated.sources)
addresses: list[str] = [str(source) for source in updated.sources] addresses: list[str] = [str(source) for source in updated.sources]
await admin.update_client_sources(name, addresses) await admin.update_client_sources(("id", id), addresses)
client = await admin.get_client(name) client = await admin.get_client(("id", id))
assert client is not None, "Critical: The client disappeared after update!" assert client is not None, "Critical: The client disappeared after update!"
return client return client
@app.put("/clients/{name}/public-key") @app.put("/clients/{id}/public-key")
async def update_client_public_key( async def update_client_public_key(
name: str, id: str,
updated: UpdateKeyModel, updated: UpdateKeyModel,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> UpdateKeyResponse: ) -> UpdateKeyResponse:
@ -107,18 +192,20 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
be resolved first, and re-encrypted using the new key. be resolved first, and re-encrypted using the new key.
""" """
# Let's first ensure that the key is actually updated. # 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( return UpdateKeyResponse(
public_key=updated.public_key, updated_secrets=updated_secrets 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( async def add_secret_to_client(
name: str, id: str,
secret_name: str, secret_name: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None: ) -> None:
"""Add secret to a client.""" """Add secret to a client."""
await admin.create_client_secret(name, secret_name) await admin.create_client_secret(("id", id), secret_name)
return app return app

View File

@ -27,7 +27,7 @@ API_VERSION = "v1"
def create_router(dependencies: BaseDependencies) -> APIRouter: def create_router(dependencies: BaseDependencies) -> APIRouter:
"""Create clients router.""" """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( async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)], token: Annotated[str, Depends(oauth2_scheme)],

View File

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

View File

@ -13,6 +13,8 @@ from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sshecret_backend.db import DatabaseSessionManager from sshecret_backend.db import DatabaseSessionManager
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
@ -68,6 +70,14 @@ def create_admin_app(
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key) 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) @app.exception_handler(RequestValidationError)
async def validation_exception_handler( async def validation_exception_handler(

View File

@ -2,8 +2,10 @@
import asyncio import asyncio
import code import code
import json
import logging import logging
from collections.abc import Awaitable from collections.abc import Awaitable
from pathlib import Path
from typing import Any, cast from typing import Any, cast
import click import click
@ -13,6 +15,7 @@ from sqlalchemy import select, create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sshecret_admin.auth.authentication import hash_password from sshecret_admin.auth.authentication import hash_password
from sshecret_admin.auth.models import AuthProvider, PasswordDB, User 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.core.settings import AdminServerSettings
from sshecret_admin.services.admin_backend import AdminBackend 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()" banner = "Sshecret-admin REPL\nAdmin backend API bound to 'admin'. Run async functions with run()"
console = code.InteractiveConsole(locals=locals, local_exit=True) console = code.InteractiveConsole(locals=locals, local_exit=True)
console.interact(banner=banner, exitmsg="Bye!") 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 debug: bool = False
password_manager_directory: Path | None = None password_manager_directory: Path | None = None
oidc: OidcSettings | None = None oidc: OidcSettings | None = None
frontend_origin: str = Field(default="*")
@property @property
def admin_db(self) -> URL: 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 %} <div class="flex h-[calc(100vh-3.5rem)] overflow-hidden">
<aside id="master-pane"
{% block page_content %} 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">
<!-- 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">
{% block master %} {% block master %}
<p>Master list goes here</p> {% endblock master %}
{% endblock %}
</div>
<!-- Detail (loaded by HTMX or inline) --> </aside>
<div id="detail-pane" class="bg-white rounded shadow p-4">
<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 %} {% block detail %}
<p>Select an item from the list to view details.</p> {% include '/base/partials/breadcrumbs.html.j2' %}
{% endblock %} <div>
<p class="p-4 text-gray-500 dark:text-gray-200">Select an item to view details</p>
</div> </div>
{% endblock detail %}
</div>
</section>
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

@ -1,23 +1,33 @@
{% extends 'base/master-detail-email.html.j2' %} {% extends "/base/master-detail.html.j2" %}
{% block title %}Clients{% endblock %}
{% block master %} {% 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' %} {% include '/clients/partials/tree.html.j2' %}
</div> </div>
{% endblock %} </sl-tab-panel>
<sl-tab-panel name="secrets">
<div id="secrets-tree" class="flex flex-col flex-1 w-full h-full">
{% 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>
</div> </div>
{% include '/clients/partials/drawer_create.html.j2' %} </sl-tab-panel>
{% endblock %}
<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 %} {% block local_scripts %}
<script> <script>
{% include '/clients/partials/tree_event.js' %} {% include '/admin/partials/master.js' %}
</script> </script>
{% endblock local_scripts %} {% endblock local_scripts %}

View File

@ -1,7 +1,7 @@
{# This is the master block #} {# This is the master block #}
<div class="flowbite-init-target"> <div class="flowbite-init-target flex flex-col h-full min-h-0">
<div class="tree-header grid grid-cols-2 place-content-between mb-2"> <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> <h1 class="text-lg font-semibold text-gray-900 dark:text-white">Client List</h1>
<div class="flex"> <div class="flex">
<div <div
@ -30,7 +30,7 @@
></sl-icon-button> ></sl-icon-button>
</div> </div>
</div> </div>
<div class="col-span-full"> <div class="col-span-full"> <!-- was: col-span-full -->
<div class="relative"> <div class="relative">
<div class="border-b border-gray-200 py-2"> <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> <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>
</div> </div>
<div id="client-tree-items">
{% include '/clients/partials/tree_items.html.j2' %} {% include '/clients/partials/tree_items.html.j2' %}
</div>
</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 %} {% if more_results %}
<span class="text-gray-400 text-xs italic mt-4">{{more_results}} more results. Narrow search to show them...</span> <span class="text-gray-400 text-xs italic mt-4">{{more_results}} more results. Narrow search to show them...</span>
{% endif %} {% endif %}
<sl-tree class="full-height-tree"> <div class="flex-1 overflow-y-auto">
<sl-tree class="w-full">
{% for item in clients %} {% for item in clients %}
<sl-tree-item <sl-tree-item
id="client-{{ item.id }}" id="client-{{ item.id }}"
@ -30,12 +31,16 @@
</sl-tree-item> </sl-tree-item>
{% endfor %} {% endfor %}
</sl-tree> </sl-tree>
</div>
{% if pages %} {% 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"> <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 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> </span>
{% include 'clients/partials/pagination.html.j2' %} {% include 'clients/partials/pagination.html.j2' %}
</div> </div>
</div>
{% endif %} {% endif %}
</div> </div>

View File

@ -79,39 +79,60 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
templates = dependencies.templates templates = dependencies.templates
@app.get("/clients/") @app.get("/clients/")
async def get_client_tree( async def get_test_page(
request: Request, request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)], current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)], admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Response: ) -> Response:
"""Get client tree view.""" """Get test page."""
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/")] breadcrumbs = [("clients", "/clients/")]
LOG.info("Results %r", results)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"clients/index.html.j2", "admin/index.html.j2",
{ {
"breadcrumbs": breadcrumbs, "breadcrumbs": breadcrumbs,
"page_title": "Clients", "page_title": "Clients",
"offset": offset,
"pages": paginate,
"clients": results.clients,
"user": current_user, "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}") @app.get("/clients/page/{page}")
async def get_client_page( async def get_client_page(
request: Request, request: Request,

View File

@ -3,7 +3,7 @@
# pyright: reportUnusedFunction=false # pyright: reportUnusedFunction=false
import logging import logging
from typing import Annotated 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 fastapi.responses import RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession

View File

@ -16,7 +16,7 @@ from sshecret.backend import (
Operation, Operation,
SubSystem, 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.backend.api import AuditAPI, KeySpec
from sshecret.crypto import encrypt_string, load_public_key from sshecret.crypto import encrypt_string, load_public_key
@ -121,6 +121,10 @@ class AdminBackend:
except Exception as e: except Exception as e:
raise BackendUnavailableError() from 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( async def query_clients(
self, filter: ClientFilter | None = None self, filter: ClientFilter | None = None
) -> ClientQueryResult: ) -> ClientQueryResult:
@ -496,7 +500,7 @@ class AdminBackend:
secret_mapping = await self.backend.get_secret(idname) secret_mapping = await self.backend.get_secret(idname)
if secret_mapping: 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 return secret_view

View File

@ -1,7 +1,8 @@
"""Models for the API.""" """Models for the API."""
import secrets import secrets
from typing import Annotated, Literal from typing import Annotated, Literal, Self
import uuid
from pydantic import ( from pydantic import (
AfterValidator, AfterValidator,
BaseModel, BaseModel,
@ -9,6 +10,7 @@ from pydantic import (
Field, Field,
IPvAnyAddress, IPvAnyAddress,
IPvAnyNetwork, IPvAnyNetwork,
model_validator,
) )
from sshecret.crypto import validate_public_key from sshecret.crypto import validate_public_key
from sshecret.backend.models import ClientReference from sshecret.backend.models import ClientReference
@ -35,7 +37,9 @@ class SecretView(BaseModel):
name: str name: str
secret: str | None secret: str | None
group: str | None = 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): class UpdateKeyModel(BaseModel):
@ -62,6 +66,7 @@ class ClientCreate(BaseModel):
"""Model to create a client.""" """Model to create a client."""
name: str name: str
description: str | None = None
public_key: Annotated[str, AfterValidator(public_key_validator)] public_key: Annotated[str, AfterValidator(public_key_validator)]
sources: list[IPvAnyAddress | IPvAnyNetwork] = Field(default_factory=list) sources: list[IPvAnyAddress | IPvAnyNetwork] = Field(default_factory=list)
@ -163,3 +168,31 @@ class ClientSecretGroupList(BaseModel):
ungrouped: list[SecretClientMapping] = Field(default_factory=list) ungrouped: list[SecretClientMapping] = Field(default_factory=list)
groups: list[ClientSecretGroup] = 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 { .h-11 {
height: calc(var(--spacing) * 11); height: calc(var(--spacing) * 11);
} }
.h-14 {
height: calc(var(--spacing) * 14);
}
.h-16 { .h-16 {
height: calc(var(--spacing) * 16); height: calc(var(--spacing) * 16);
} }
@ -880,6 +883,9 @@
.flex-1 { .flex-1 {
flex: 1; flex: 1;
} }
.flex-none {
flex: none;
}
.flex-shrink { .flex-shrink {
flex-shrink: 1; 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 { .md\:border-0 {
@media (width >= 48rem) { @media (width >= 48rem) {
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 0px; 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 { .md\:p-0 {
@media (width >= 48rem) { @media (width >= 48rem) {
padding: calc(var(--spacing) * 0); padding: calc(var(--spacing) * 0);
@ -3060,6 +3085,11 @@
line-height: var(--tw-leading, var(--text-xs--line-height)); 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 { .md\:hover\:bg-transparent {
@media (width >= 48rem) { @media (width >= 48rem) {
&:hover { &: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 { .md\:hover\:text-primary-700 {
@media (width >= 48rem) { @media (width >= 48rem) {
&:hover { &: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 { .md\:dark\:hover\:bg-transparent {
@media (width >= 48rem) { @media (width >= 48rem) {
&:where(.dark, .dark *) { &: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 { @property --tw-translate-x {
syntax: "*"; syntax: "*";

View File

@ -27,3 +27,21 @@ sl-details.small-details::part(base) {
color: var(--color-gray-50); 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) { if (sidebar) {
const toggleSidebarMobile = ( 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('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.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name') sa.UniqueConstraint('name', name="uq_client_name")
) )
op.create_table('client_access_policy', op.create_table('client_access_policy',
sa.Column('id', sa.Uuid(), nullable=False), 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) sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)
) )
batch_op.add_column(sa.Column("parent_id", sa.Uuid(), 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_unique_constraint("uq_client_name_version", ["name", "version"])
batch_op.create_foreign_key( batch_op.create_foreign_key(
"fk_client_parent", "client", ["parent_id"], ["id"], ondelete="SET NULL" "fk_client_parent", "client", ["parent_id"], ["id"], ondelete="SET NULL"

View File

@ -29,6 +29,7 @@ from .schemas import (
ClientView, ClientView,
ClientQueryResult, ClientQueryResult,
ClientPolicyUpdate, ClientPolicyUpdate,
ClientReference,
) )
@ -61,6 +62,7 @@ class ClientOperations:
self, self,
client: FlexID, client: FlexID,
version: int | None = None, version: int | None = None,
include_deleted: bool = False,
) -> uuid.UUID | None: ) -> uuid.UUID | None:
"""Get client ID.""" """Get client ID."""
if self._client_id: if self._client_id:
@ -76,6 +78,7 @@ class ClientOperations:
self.session, self.session,
client_name, client_name,
version=version, version=version,
include_deleted=include_deleted,
) )
if not client_id: if not client_id:
return None return None
@ -84,17 +87,26 @@ class ClientOperations:
return client_id return client_id
async def _get_client( async def _get_client(
self, self, client: FlexID, version: int | None = None, include_deleted: bool = False
client: FlexID,
version: int | None = None,
) -> Client | None: ) -> Client | None:
"""Get client.""" """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: if not client_id:
return None 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 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( async def get_client(
self, self,
client: FlexID, client: FlexID,
@ -115,7 +127,25 @@ class ClientOperations:
raise HTTPException( raise HTTPException(
status_code=400, detail="Error: A client already exists with this name." 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() 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: if system_client:
statement = query_active_clients().where(Client.is_system.is_(True)) statement = query_active_clients().where(Client.is_system.is_(True))
results = await self.session.scalars(statement) results = await self.session.scalars(statement)
@ -181,10 +211,12 @@ class ClientOperations:
public_key: str, public_key: str,
name: str | None = None, name: str | None = None,
description: str | None = None, description: str | None = None,
from_deleted: bool = False,
) -> ClientView: ) -> ClientView:
"""Update a client to a new version.""" """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: if not current_client:
LOG.info("Could not find previous version.")
raise HTTPException(status_code=404, detail="Client not found.") raise HTTPException(status_code=404, detail="Client not found.")
new_client = await create_new_client_version( new_client = await create_new_client_version(
self.session, current_client, public_key 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( async def new_client_version(
session: AsyncSession, session: AsyncSession,
client_id: uuid.UUID, client_id: uuid.UUID,

View File

@ -17,6 +17,7 @@ from sshecret_backend.api.clients.schemas import (
ClientView, ClientView,
ClientPolicyUpdate, ClientPolicyUpdate,
ClientPolicyView, ClientPolicyView,
ClientReference,
) )
from sshecret_backend.api.clients import operations from sshecret_backend.api.clients import operations
from sshecret_backend.api.clients.operations import ClientOperations 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) client_op = ClientOperations(session, request)
return await client_op.create_client(client) 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) @router.get("/internal/system_client/", include_in_schema=False)
async def get_system_client( async def get_system_client(
request: Request, request: Request,

View File

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

View File

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

View File

@ -150,7 +150,7 @@ async def audit_new_client_version(
message="Client data updated", message="Client data updated",
data={ data={
"new_client_id": str(new_client.id), "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) 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 import logging
from typing import Any, Literal, Self, override from typing import Any, Literal, Self, override
import uuid
import httpx import httpx
from pydantic import TypeAdapter from pydantic import TypeAdapter
@ -17,6 +16,7 @@ from .models import (
AuditListResult, AuditListResult,
AuditLog, AuditLog,
Client, Client,
ClientReference,
ClientSecret, ClientSecret,
ClientQueryResult, ClientQueryResult,
ClientFilter, ClientFilter,
@ -324,7 +324,7 @@ class SshecretBackend(BaseBackend):
if description: if description:
data["description"] = description data["description"] = description
path = "/api/v1/clients/" 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: async def create_system_client(self, name: str, public_key: str) -> Client:
"""Create system client.""" """Create system client."""
@ -356,6 +356,13 @@ class SshecretBackend(BaseBackend):
return clients 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: async def get_client_count(self, filter: ClientFilter | None = None) -> int:
"""Get a count of the clients optionally filtered.""" """Get a count of the clients optionally filtered."""
query_results = await self.query_clients(filter) query_results = await self.query_clients(filter)
@ -367,7 +374,13 @@ class SshecretBackend(BaseBackend):
"""Query clients.""" """Query clients."""
params: dict[str, str] = {} params: dict[str, str] = {}
if filter: 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: try:
results = await self._get("/api/v1/clients/", params=params) results = await self._get("/api/v1/clients/", params=params)

6
uv.lock generated
View File

@ -381,16 +381,16 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.115.12" version = "0.115.14"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "starlette" }, { name = "starlette" },
{ name = "typing-extensions" }, { 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 = [ 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] [package.optional-dependencies]