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 %} +