Add trusted header auth and publisher shell
All checks were successful
buildbot/nix-eval Build done.
buildbot/nix-build Build done.
buildbot/nix-effects Build done.

This commit is contained in:
Abel Luck 2026-06-01 18:11:23 +02:00
parent 89e6a4d78c
commit 96551c2788
8 changed files with 569 additions and 19 deletions

View file

@ -2,11 +2,12 @@ from __future__ import annotations
import asyncio
import hashlib
from collections.abc import AsyncGenerator, Awaitable, Callable
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
from contextlib import suppress
from datetime import timedelta
from functools import wraps
from pathlib import Path
from typing import TypedDict, cast
from typing import Any, TypedDict, cast
from urllib.parse import urlparse
import htpy as h
@ -17,6 +18,13 @@ from htpy import Renderable
from peewee import IntegrityError
from quart import Quart, Response, request, send_from_directory, url_for
from repub.auth_headers import (
AUTH_MODE_DISABLED,
AUTH_MODE_TRUSTED_HEADERS,
AuthRole,
load_auth_mode,
load_trusted_identity,
)
from repub.datastar import RefreshBroker, TabStateStore, render_stream
from repub.jobs import (
COMPLETED_EXECUTION_PAGE_SIZE,
@ -44,6 +52,7 @@ from repub.pages import (
dashboard_page_with_data,
edit_source_page,
execution_logs_page,
publisher_page,
runs_page,
settings_page,
shim_page,
@ -143,11 +152,14 @@ def create_app(*, dev_mode: bool = False) -> Quart:
app.config.setdefault("REPUB_LOG_DIR", DEFAULT_LOG_DIR)
app.config.setdefault("REPUB_FEEDS_DIR", DEFAULT_FEEDS_DIR)
app.config["REPUB_DEV_MODE"] = dev_mode
app.config["REPUB_AUTH_MODE"] = load_auth_mode()
app.extensions[REFRESH_BROKER_KEY] = RefreshBroker()
app.extensions[JOB_RUNTIME_KEY] = None
app.extensions[TAB_STATE_STORE_KEY] = TabStateStore()
app.extensions[TAB_STATE_CLEANER_TASK_KEY] = None
app.extensions[SHUTDOWN_EVENT_KEY] = None
admin_required = _require_role(app, "admin")
publisher_required = _require_role(app, "publisher")
@app.get("/feeds/<path:feed_path>")
async def published_feed(feed_path: str) -> Response:
@ -177,6 +189,16 @@ def create_app(*, dev_mode: bool = False) -> Quart:
response = await send_from_directory(str(STATIC_DIR), requested_filename)
return response
@app.get("/publisher")
@publisher_required
async def publisher_home() -> Response:
return _shim_page_response(current_path="/publisher")
@app.get("/admin/publisher")
@admin_required
async def admin_publisher_home() -> Response:
return _shim_page_response(current_path="/admin/publisher")
@app.get("/")
@app.get("/sources")
@app.get("/sources/create")
@ -184,51 +206,62 @@ def create_app(*, dev_mode: bool = False) -> Quart:
@app.get("/runs")
@app.get("/settings")
@app.get("/job/<int:job_id>/execution/<int:execution_id>/logs")
@admin_required
async def page_shim(
slug: str | None = None,
job_id: int | None = None,
execution_id: int | None = None,
) -> Response:
del slug, job_id, execution_id
body, etag = _render_shim_page(
stylesheet_href=versioned_static_asset_href("app.css"),
datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"),
current_path=request.path,
)
if request.if_none_match.contains(etag):
response = Response(status=304)
response.set_etag(etag)
return response
response = Response(body, mimetype="text/html")
response.set_etag(etag)
return response
return _shim_page_response(current_path=request.path)
@app.post("/")
@admin_required
async def dashboard_patch() -> DatastarResponse:
return await _page_patch_response(app, lambda _tab_id: render_dashboard(app))
@app.post("/publisher")
@publisher_required
async def publisher_patch() -> DatastarResponse:
return await _page_patch_response(
app,
lambda _tab_id: render_publisher(current_path="/publisher"),
)
@app.post("/admin/publisher")
@admin_required
async def admin_publisher_patch() -> DatastarResponse:
return await _page_patch_response(
app,
lambda _tab_id: render_publisher(current_path="/admin/publisher"),
)
@app.post("/sources")
@admin_required
async def sources_patch() -> DatastarResponse:
return await _page_patch_response(app, lambda _tab_id: render_sources(app))
@app.post("/sources/create")
@admin_required
async def create_source_patch() -> DatastarResponse:
return await _page_patch_response(
app, lambda _tab_id: render_create_source(app)
)
@app.post("/sources/<string:slug>/edit")
@admin_required
async def edit_source_patch(slug: str) -> DatastarResponse:
return await _page_patch_response(
app, lambda _tab_id: render_edit_source(slug, app)
)
@app.post("/settings")
@admin_required
async def settings_patch() -> DatastarResponse:
return await _page_patch_response(app, lambda _tab_id: render_settings(app))
@app.post("/actions/sources/create")
@admin_required
async def create_source_action() -> DatastarResponse:
signals = cast(dict[str, object], await read_signals())
source, error = validate_source_form(
@ -254,6 +287,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return DatastarResponse(SSE.redirect("/sources"))
@app.post("/actions/sources/<string:slug>/edit")
@admin_required
async def edit_source_action(slug: str) -> DatastarResponse:
signals = cast(dict[str, object], await read_signals())
source, error = validate_source_form(
@ -279,6 +313,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return DatastarResponse(SSE.redirect("/sources"))
@app.post("/actions/sources/<string:slug>/delete")
@admin_required
async def delete_source_action(slug: str) -> Response:
delete_source(slug)
get_job_runtime(app).sync_jobs()
@ -286,6 +321,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return Response(status=204)
@app.post("/actions/settings")
@admin_required
async def update_settings_action() -> DatastarResponse:
signals = cast(dict[str, object], await read_signals())
settings, error = validate_settings_form(signals)
@ -301,6 +337,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return DatastarResponse(SSE.redirect("/settings"))
@app.post("/runs")
@admin_required
async def runs_patch() -> DatastarResponse:
return await _page_patch_response(
app,
@ -308,6 +345,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
)
@app.post("/actions/runs/completed-page/<int:page>")
@admin_required
async def set_completed_runs_page_action(page: int) -> Response:
signals = await _read_optional_signals()
tab_id = _read_tab_id(signals)
@ -322,12 +360,14 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return Response(status=204)
@app.post("/actions/jobs/<int:job_id>/run-now")
@admin_required
async def run_job_now_action(job_id: int) -> Response:
get_job_runtime(app).run_job_now(job_id, reason="manual")
trigger_refresh(app)
return Response(status=204)
@app.post("/actions/jobs/<int:job_id>/toggle-enabled")
@admin_required
async def toggle_job_enabled_action(job_id: int) -> Response:
enabled = load_job_enabled(job_id)
if enabled is not None:
@ -336,6 +376,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return Response(status=204)
@app.post("/actions/jobs/<int:job_id>/delete")
@admin_required
async def delete_job_action(job_id: int) -> Response:
delete_job_source(job_id)
get_job_runtime(app).sync_jobs()
@ -343,34 +384,40 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return Response(status=204)
@app.post("/actions/executions/<int:execution_id>/cancel")
@admin_required
async def cancel_execution_action(execution_id: int) -> Response:
get_job_runtime(app).request_execution_cancel(execution_id)
trigger_refresh(app)
return Response(status=204)
@app.post("/actions/queued-executions/<int:execution_id>/cancel")
@admin_required
async def cancel_queued_execution_action(execution_id: int) -> Response:
get_job_runtime(app).cancel_queued_execution(execution_id)
trigger_refresh(app)
return Response(status=204)
@app.post("/actions/queued-executions/<int:execution_id>/move-up")
@admin_required
async def move_queued_execution_up_action(execution_id: int) -> Response:
get_job_runtime(app).move_queued_execution(execution_id, direction="up")
return Response(status=204)
@app.post("/actions/queued-executions/<int:execution_id>/move-down")
@admin_required
async def move_queued_execution_down_action(execution_id: int) -> Response:
get_job_runtime(app).move_queued_execution(execution_id, direction="down")
return Response(status=204)
@app.post("/actions/completed-executions/clear")
@admin_required
async def clear_completed_executions_action() -> Response:
clear_completed_executions(log_dir=app.config["REPUB_LOG_DIR"])
trigger_refresh(app)
return Response(status=204)
@app.post("/job/<int:job_id>/execution/<int:execution_id>/logs")
@admin_required
async def logs_patch(job_id: int, execution_id: int) -> DatastarResponse:
async def render() -> Renderable:
return await render_execution_logs(
@ -400,6 +447,55 @@ def create_app(*, dev_mode: bool = False) -> Quart:
return app
def _shim_page_response(*, current_path: str) -> Response:
body, etag = _render_shim_page(
stylesheet_href=versioned_static_asset_href("app.css"),
datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"),
current_path=current_path,
)
if request.if_none_match.contains(etag):
response = Response(status=304)
response.set_etag(etag)
return response
response = Response(body, mimetype="text/html")
response.set_etag(etag)
return response
def _require_role(
app: Quart, *roles: AuthRole
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
def decorate(
handler: Callable[..., Awaitable[Any]],
) -> Callable[..., Awaitable[Any]]:
@wraps(handler)
async def wrapped(*args: object, **kwargs: object) -> Any:
failure = _authorization_failure(app, roles)
if failure is not None:
return failure
return await handler(*args, **kwargs)
return wrapped
return decorate
def _authorization_failure(app: Quart, roles: Sequence[AuthRole]) -> Response | None:
auth_mode = cast(str, app.config["REPUB_AUTH_MODE"])
if auth_mode == AUTH_MODE_DISABLED:
return None
if auth_mode != AUTH_MODE_TRUSTED_HEADERS:
return Response(status=401)
identity = load_trusted_identity(cast(Mapping[str, str], request.headers))
if identity is None:
return Response(status=401)
if identity.role not in roles:
return Response(status=403)
return None
def get_refresh_broker(app: Quart) -> RefreshBroker:
return cast(RefreshBroker, app.extensions[REFRESH_BROKER_KEY])
@ -442,6 +538,10 @@ async def render_dashboard(app: Quart | None = None) -> Renderable:
)
async def render_publisher(*, current_path: str) -> Renderable:
return publisher_page(current_path=current_path)
async def render_sources(app: Quart | None = None) -> Renderable:
if app is None:
return sources_page()