Backend fixed and features

This commit is contained in:
2025-07-05 16:01:08 +02:00
parent 3ef659be61
commit 880d556542
29 changed files with 567 additions and 156 deletions

View File

@ -2,39 +2,86 @@
# pyright: reportUnusedFunction=false
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

View File

@ -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

View File

@ -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)],