diff --git a/packages/sshecret-admin/src/sshecret_admin/api/endpoints/auth.py b/packages/sshecret-admin/src/sshecret_admin/api/endpoints/auth.py index c5b8aaa..8f4b8b8 100644 --- a/packages/sshecret-admin/src/sshecret_admin/api/endpoints/auth.py +++ b/packages/sshecret-admin/src/sshecret_admin/api/endpoints/auth.py @@ -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 diff --git a/packages/sshecret-admin/src/sshecret_admin/api/endpoints/clients.py b/packages/sshecret-admin/src/sshecret_admin/api/endpoints/clients.py index bf3303e..ab550b7 100644 --- a/packages/sshecret-admin/src/sshecret_admin/api/endpoints/clients.py +++ b/packages/sshecret-admin/src/sshecret_admin/api/endpoints/clients.py @@ -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 diff --git a/packages/sshecret-admin/src/sshecret_admin/api/router.py b/packages/sshecret-admin/src/sshecret_admin/api/router.py index 89cf62d..24e146d 100644 --- a/packages/sshecret-admin/src/sshecret_admin/api/router.py +++ b/packages/sshecret-admin/src/sshecret_admin/api/router.py @@ -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)], diff --git a/packages/sshecret-admin/src/sshecret_admin/auth/models.py b/packages/sshecret-admin/src/sshecret_admin/auth/models.py index 7c543f1..3fafdd5 100644 --- a/packages/sshecret-admin/src/sshecret_admin/auth/models.py +++ b/packages/sshecret-admin/src/sshecret_admin/auth/models.py @@ -168,6 +168,7 @@ class TokenData(BaseModel): class Token(BaseModel): access_token: str + refresh_token: str token_type: str diff --git a/packages/sshecret-admin/src/sshecret_admin/core/app.py b/packages/sshecret-admin/src/sshecret_admin/core/app.py index 06a7119..31f93a3 100644 --- a/packages/sshecret-admin/src/sshecret_admin/core/app.py +++ b/packages/sshecret-admin/src/sshecret_admin/core/app.py @@ -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( diff --git a/packages/sshecret-admin/src/sshecret_admin/core/cli.py b/packages/sshecret-admin/src/sshecret_admin/core/cli.py index 69baffa..8bdfbca 100644 --- a/packages/sshecret-admin/src/sshecret_admin/core/cli.py +++ b/packages/sshecret-admin/src/sshecret_admin/core/cli.py @@ -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()}") diff --git a/packages/sshecret-admin/src/sshecret_admin/core/settings.py b/packages/sshecret-admin/src/sshecret_admin/core/settings.py index ac497cc..21189ff 100644 --- a/packages/sshecret-admin/src/sshecret_admin/core/settings.py +++ b/packages/sshecret-admin/src/sshecret_admin/core/settings.py @@ -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: diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/base/master-detail.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/base/master-detail.html.j2 index a34f100..c264f5a 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/base/master-detail.html.j2 +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/base/master-detail.html.j2 @@ -1,27 +1,26 @@ -{% extends 'base/page.html.j2' %} +{% extends "/base/navbar.html.j2" %} +{% block content %} -{% block title %}Clients{% endblock %} +
+ +
+
+ {% block detail %} + {% include '/base/partials/breadcrumbs.html.j2' %} +
+

Select an item to view details

+
+ {% endblock detail %} +
+
{% endblock %} diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/base/partials/scripts.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/base/partials/scripts.html.j2 index db5ecc9..0738358 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/base/partials/scripts.html.j2 +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/base/partials/scripts.html.j2 @@ -8,7 +8,7 @@ {% endblock local_scripts %} - - - diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/clients/index.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/clients/index.html.j2 index 374e222..8e94d81 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/clients/index.html.j2 +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/clients/index.html.j2 @@ -1,23 +1,33 @@ -{% extends 'base/master-detail-email.html.j2' %} -{% block title %}Clients{% endblock %} +{% extends "/base/master-detail.html.j2" %} {% block master %} -
- {% include '/clients/partials/tree.html.j2' %} -
-{% endblock %} + + Clients + Secrets + Audit + + +
+ {% include '/clients/partials/tree.html.j2' %} +
+
+ +
+
+
+ + +
+
+
-{% block detail %} +
-
-

Click an item to view details

-
-{% include '/clients/partials/drawer_create.html.j2' %} -{% endblock %} +{% endblock master %} {% block local_scripts %} {% endblock local_scripts %} diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/clients/partials/tree.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/clients/partials/tree.html.j2 index d376a76..9812b97 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/clients/partials/tree.html.j2 +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/clients/partials/tree.html.j2 @@ -1,7 +1,7 @@ {# This is the master block #} -
-
+
+

Client List

-
+
@@ -57,8 +57,7 @@
-
{% include '/clients/partials/tree_items.html.j2' %} -
+{% include '/clients/partials/drawer_create.html.j2' %} diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/clients/partials/tree_items.html.j2 b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/clients/partials/tree_items.html.j2 index e5af766..896b201 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/templates/clients/partials/tree_items.html.j2 +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/templates/clients/partials/tree_items.html.j2 @@ -1,41 +1,46 @@ -
+
{% if more_results %} {{more_results}} more results. Narrow search to show them... {% endif %} - - {% for item in clients %} - + + {% for item in clients %} + + + {{item.name}} + {% for secret in item.secrets %} + + + {{ secret }} + + {% endfor %} + + {% endfor %} + +
- > - - {{item.name}} - {% for secret in item.secrets %} - - - {{ secret }} - - {% endfor %} - - {% endfor %} - {% if pages %} -
- - Showing {{ pages.offset + 1 }} to {{ pages.limit }} of {{ results.total_results }} Entries - - {% include 'clients/partials/pagination.html.j2' %} +
+
+ + Showing {{ pages.offset + 1 }} to {{ pages.limit }} of {{ results.total_results }} Entries + + {% include 'clients/partials/pagination.html.j2' %} +
{% endif %}
diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/views/clients.py b/packages/sshecret-admin/src/sshecret_admin/frontend/views/clients.py index 0265e65..9fb8ef8 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/views/clients.py +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/views/clients.py @@ -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, diff --git a/packages/sshecret-admin/src/sshecret_admin/frontend/views/index.py b/packages/sshecret-admin/src/sshecret_admin/frontend/views/index.py index e9c2c68..b4f3490 100644 --- a/packages/sshecret-admin/src/sshecret_admin/frontend/views/index.py +++ b/packages/sshecret-admin/src/sshecret_admin/frontend/views/index.py @@ -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 diff --git a/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py b/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py index 29bfab0..2d3064b 100644 --- a/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py +++ b/packages/sshecret-admin/src/sshecret_admin/services/admin_backend.py @@ -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 diff --git a/packages/sshecret-admin/src/sshecret_admin/services/models.py b/packages/sshecret-admin/src/sshecret_admin/services/models.py index 9d20f11..7dc5a62 100644 --- a/packages/sshecret-admin/src/sshecret_admin/services/models.py +++ b/packages/sshecret-admin/src/sshecret_admin/services/models.py @@ -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 diff --git a/packages/sshecret-admin/src/sshecret_admin/static/css/main.css b/packages/sshecret-admin/src/sshecret_admin/static/css/main.css index 46717ec..25da399 100644 --- a/packages/sshecret-admin/src/sshecret_admin/static/css/main.css +++ b/packages/sshecret-admin/src/sshecret_admin/static/css/main.css @@ -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: "*"; diff --git a/packages/sshecret-admin/src/sshecret_admin/static/css/style.css b/packages/sshecret-admin/src/sshecret_admin/static/css/style.css index 5fe2654..77e51b5 100644 --- a/packages/sshecret-admin/src/sshecret_admin/static/css/style.css +++ b/packages/sshecret-admin/src/sshecret_admin/static/css/style.css @@ -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; +} diff --git a/packages/sshecret-admin/src/sshecret_admin/static/js/sidebar.js b/packages/sshecret-admin/src/sshecret_admin/static/js/sidebar.js index df52738..af32450 100644 --- a/packages/sshecret-admin/src/sshecret_admin/static/js/sidebar.js +++ b/packages/sshecret-admin/src/sshecret_admin/static/js/sidebar.js @@ -1,4 +1,4 @@ -const sidebar = document.getElementById("sidebar"); +const sidebar = document.getElementById("master-pane"); if (sidebar) { const toggleSidebarMobile = ( diff --git a/packages/sshecret-backend/migrations/versions/06af53cdf350_initial.py b/packages/sshecret-backend/migrations/versions/06af53cdf350_initial.py index 1df4bbc..3acdd93 100644 --- a/packages/sshecret-backend/migrations/versions/06af53cdf350_initial.py +++ b/packages/sshecret-backend/migrations/versions/06af53cdf350_initial.py @@ -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), diff --git a/packages/sshecret-backend/migrations/versions/c251311b64c9_make_client_object_better.py b/packages/sshecret-backend/migrations/versions/c251311b64c9_make_client_object_better.py index 7a1c71b..5a88243 100644 --- a/packages/sshecret-backend/migrations/versions/c251311b64c9_make_client_object_better.py +++ b/packages/sshecret-backend/migrations/versions/c251311b64c9_make_client_object_better.py @@ -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" diff --git a/packages/sshecret-backend/src/sshecret_backend/api/clients/operations.py b/packages/sshecret-backend/src/sshecret_backend/api/clients/operations.py index 59c8fbd..24fc1ba 100644 --- a/packages/sshecret-backend/src/sshecret_backend/api/clients/operations.py +++ b/packages/sshecret-backend/src/sshecret_backend/api/clients/operations.py @@ -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, diff --git a/packages/sshecret-backend/src/sshecret_backend/api/clients/router.py b/packages/sshecret-backend/src/sshecret_backend/api/clients/router.py index 16eb3b7..ee15aa0 100644 --- a/packages/sshecret-backend/src/sshecret_backend/api/clients/router.py +++ b/packages/sshecret-backend/src/sshecret_backend/api/clients/router.py @@ -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, diff --git a/packages/sshecret-backend/src/sshecret_backend/api/clients/schemas.py b/packages/sshecret-backend/src/sshecret_backend/api/clients/schemas.py index c7cb199..280a8a2 100644 --- a/packages/sshecret-backend/src/sshecret_backend/api/clients/schemas.py +++ b/packages/sshecret-backend/src/sshecret_backend/api/clients/schemas.py @@ -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.""" diff --git a/packages/sshecret-backend/src/sshecret_backend/api/common.py b/packages/sshecret-backend/src/sshecret_backend/api/common.py index b973fea..98f26c5 100644 --- a/packages/sshecret-backend/src/sshecret_backend/api/common.py +++ b/packages/sshecret-backend/src/sshecret_backend/api/common.py @@ -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) diff --git a/packages/sshecret-backend/src/sshecret_backend/audit.py b/packages/sshecret-backend/src/sshecret_backend/audit.py index ca88c24..a030fd2 100644 --- a/packages/sshecret-backend/src/sshecret_backend/audit.py +++ b/packages/sshecret-backend/src/sshecret_backend/audit.py @@ -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) diff --git a/src/sshecret/backend/api.py b/src/sshecret/backend/api.py index 441ac80..2ee3581 100644 --- a/src/sshecret/backend/api.py +++ b/src/sshecret/backend/api.py @@ -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) diff --git a/uv.lock b/uv.lock index 5ab25a6..6707451 100644 --- a/uv.lock +++ b/uv.lock @@ -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]