Backend fixed and features
This commit is contained in:
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)],
|
||||
|
||||
@ -168,6 +168,7 @@ class TokenData(BaseModel):
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()}")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
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 %}
|
||||
{% endblock master %}
|
||||
|
||||
{% 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">
|
||||
{% block master %}
|
||||
<p>Master list goes here</p>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Detail (loaded by HTMX or inline) -->
|
||||
<div id="detail-pane" class="bg-white rounded shadow p-4">
|
||||
{% block detail %}
|
||||
<p>Select an item from the list to view details.</p>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</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 %}
|
||||
{% 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 %}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 %}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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">
|
||||
{% include '/clients/partials/tree.html.j2' %}
|
||||
</div>
|
||||
{% 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">
|
||||
{% include '/clients/partials/tree.html.j2' %}
|
||||
</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>
|
||||
|
||||
|
||||
{% block detail %}
|
||||
</sl-tab-group>
|
||||
|
||||
<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>
|
||||
{% include '/clients/partials/drawer_create.html.j2' %}
|
||||
{% endblock %}
|
||||
{% endblock master %}
|
||||
|
||||
{% block local_scripts %}
|
||||
<script>
|
||||
{% include '/clients/partials/tree_event.js' %}
|
||||
{% include '/admin/partials/master.js' %}
|
||||
</script>
|
||||
{% endblock local_scripts %}
|
||||
|
||||
@ -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' %}
|
||||
|
||||
@ -1,41 +1,46 @@
|
||||
<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">
|
||||
{% for item in clients %}
|
||||
<sl-tree-item
|
||||
id="client-{{ item.id }}"
|
||||
data-node-type="client"
|
||||
data-client-id="{{ item.id }}"
|
||||
data-client-name="{{ item.name }}"
|
||||
{% if client and client.id == item.id %}
|
||||
selected
|
||||
{% endif %}
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<sl-tree class="w-full">
|
||||
{% for item in clients %}
|
||||
<sl-tree-item
|
||||
id="client-{{ item.id }}"
|
||||
data-node-type="client"
|
||||
data-client-id="{{ item.id }}"
|
||||
data-client-name="{{ item.name }}"
|
||||
{% if client and client.id == item.id %}
|
||||
selected
|
||||
{% endif %}
|
||||
|
||||
>
|
||||
<sl-icon name="person-fill-lock"> </sl-icon>
|
||||
<span class="px-2">{{item.name}}</span>
|
||||
{% for secret in item.secrets %}
|
||||
<sl-tree-item
|
||||
id="client-{{ item.name }}-secret-{{ secret }}"
|
||||
data-node-type="secret"
|
||||
data-secret-client-name="{{ item.name }}"
|
||||
data-secret-name="{{ secret }}"
|
||||
>
|
||||
<sl-icon name="file-lock2"> </sl-icon>
|
||||
<span class="px-2">{{ secret }}</span>
|
||||
</sl-tree-item>
|
||||
{% endfor %}
|
||||
</sl-tree-item>
|
||||
{% endfor %}
|
||||
</sl-tree>
|
||||
</div>
|
||||
|
||||
>
|
||||
<sl-icon name="person-fill-lock"> </sl-icon>
|
||||
<span class="px-2">{{item.name}}</span>
|
||||
{% for secret in item.secrets %}
|
||||
<sl-tree-item
|
||||
id="client-{{ item.name }}-secret-{{ secret }}"
|
||||
data-node-type="secret"
|
||||
data-secret-client-name="{{ item.name }}"
|
||||
data-secret-name="{{ secret }}"
|
||||
>
|
||||
<sl-icon name="file-lock2"> </sl-icon>
|
||||
<span class="px-2">{{ secret }}</span>
|
||||
</sl-tree-item>
|
||||
{% endfor %}
|
||||
</sl-tree-item>
|
||||
{% endfor %}
|
||||
</sl-tree>
|
||||
{% if pages %}
|
||||
<div class="mt-4 text-center flex items-center flex-col border-t border-gray-100">
|
||||
<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 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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: "*";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const sidebar = document.getElementById("master-pane");
|
||||
|
||||
if (sidebar) {
|
||||
const toggleSidebarMobile = (
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 not client_id:
|
||||
return None
|
||||
db_client = await get_client_by_id(self.session, client_id)
|
||||
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, 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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
6
uv.lock
generated
@ -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]
|
||||
|
||||
Reference in New Issue
Block a user