From 28b154091cbacc06af62e9b0111dcfc28a97012b Mon Sep 17 00:00:00 2001 From: Pigway Date: Tue, 6 Oct 2026 22:27:26 +0800 Subject: [PATCH] Authorize operation details by admin scope and isolate scoped clients --- pigway_frame_flow/api/app.py | 10 +++++++--- pigway_frame_flow/core/auth.py | 3 +++ tests/test_accounts.py | 16 ++++++++++++++++ tests/test_api.py | 24 ++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index da9e0ea..9a988de 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -815,11 +815,12 @@ def create_app(settings=None, start_worker=True): tags=["Operations"], dependencies=[Depends(require("history:read"))], ) - def operations(limit: int = Query(default=50, ge=1, le=200)): + def operations(request: Request, limit: int = Query(default=50, ge=1, le=200)): return [ service.operation(x["id"]) for x in db.all( - "SELECT id FROM operations ORDER BY created_at DESC LIMIT ?", (limit,) + "SELECT id FROM operations WHERE (? OR client_id=?) ORDER BY created_at DESC LIMIT ?", + ("admin" in request.state.permissions, request.state.client_id, limit), ) ] @@ -831,7 +832,10 @@ def create_app(settings=None, start_worker=True): ) def operation(operation_id: str, request: Request): op = service.operation(operation_id) - if request.state.client_id not in ("admin", op["client_id"]): + if ( + "admin" not in request.state.permissions + and request.state.client_id != op["client_id"] + ): raise FlowError(E.FORBIDDEN, "Operation belongs to another client", 403) return op diff --git a/pigway_frame_flow/core/auth.py b/pigway_frame_flow/core/auth.py index e750e42..9500c9f 100644 --- a/pigway_frame_flow/core/auth.py +++ b/pigway_frame_flow/core/auth.py @@ -46,6 +46,8 @@ def require(scope): account = session_user(request) if account: request.state.client_id = "user:" + account["username"] + # Browser accounts currently administer this single-household service. + request.state.permissions = ALL_SCOPES return request.state.client_id raise FlowError(E.UNAUTHORIZED, "Frame Flow API token required", 401) digest = hashlib.sha256(credentials.credentials.encode()).hexdigest() @@ -57,6 +59,7 @@ def require(scope): E.FORBIDDEN, "Token scope does not allow this action", 403 ) request.state.client_id = row["client_id"] + request.state.permissions = permissions # Avoid writing last_used on every image or poll. if not row["last_used_at"] or ( now_iso()[:16] != row["last_used_at"][:16] diff --git a/tests/test_accounts.py b/tests/test_accounts.py index f876918..b05a766 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -125,3 +125,19 @@ def test_openapi_describes_cookie_auth_and_docs_csrf(client): "security" ] assert "X-CSRF-Token" in client.get("/docs").text + + +def test_browser_admin_can_read_other_clients_operation(client, app, service): + operation_id = service.submit( + "render", + {"template": "quote"}, + "living-room", + "scheduler", + "fixture-request", + None, + ) + assert login(client, app).status_code == 200 + assert client.get("/api/v1/operations/" + operation_id).status_code == 200 + assert operation_id in [ + op["operation_id"] for op in client.get("/api/v1/operations").json() + ] diff --git a/tests/test_api.py b/tests/test_api.py index fb0ba3c..faab80d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -399,3 +399,27 @@ def test_docs_offline_resources(client): assert "cdn.jsdelivr" not in html assert client.get("/ui/swagger-ui-bundle.js").status_code == 200 assert client.get("/ui/swagger-ui.css").status_code == 200 + + +def test_scoped_client_operation_isolation(client, service): + token = "restricted-client-fixture" + service.db.execute( + "INSERT INTO clients VALUES(?,?,?,?,?,?,?)", + ( + "reader", + hashlib.sha256(token.encode()).hexdigest(), + "Reader", + encode(["devices:read", "history:read"]), + now_iso(), + None, + 1, + ), + ) + own = service.submit("render", {}, "living-room", "reader", "own-request") + other = service.submit("render", {}, "living-room", "scheduler", "other-request") + client.headers["Authorization"] = "Bearer " + token + assert client.get("/api/v1/operations/" + own).status_code == 200 + assert client.get("/api/v1/operations/" + other).status_code == 403 + assert [op["operation_id"] for op in client.get("/api/v1/operations").json()] == [ + own + ]