Backend fixed and features
This commit is contained in:
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)],
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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()}")
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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' %}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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: "*";
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
const sidebar = document.getElementById("sidebar");
|
const sidebar = document.getElementById("master-pane");
|
||||||
|
|
||||||
if (sidebar) {
|
if (sidebar) {
|
||||||
const toggleSidebarMobile = (
|
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('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),
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
6
uv.lock
generated
@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user