Add trusted header auth and publisher shell
This commit is contained in:
parent
89e6a4d78c
commit
96551c2788
8 changed files with 569 additions and 19 deletions
130
repub/web.py
130
repub/web.py
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue