Authorize operation details by admin scope and isolate scoped clients
This commit is contained in:
@@ -815,11 +815,12 @@ def create_app(settings=None, start_worker=True):
|
|||||||
tags=["Operations"],
|
tags=["Operations"],
|
||||||
dependencies=[Depends(require("history:read"))],
|
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 [
|
return [
|
||||||
service.operation(x["id"])
|
service.operation(x["id"])
|
||||||
for x in db.all(
|
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):
|
def operation(operation_id: str, request: Request):
|
||||||
op = service.operation(operation_id)
|
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)
|
raise FlowError(E.FORBIDDEN, "Operation belongs to another client", 403)
|
||||||
return op
|
return op
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ def require(scope):
|
|||||||
account = session_user(request)
|
account = session_user(request)
|
||||||
if account:
|
if account:
|
||||||
request.state.client_id = "user:" + account["username"]
|
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
|
return request.state.client_id
|
||||||
raise FlowError(E.UNAUTHORIZED, "Frame Flow API token required", 401)
|
raise FlowError(E.UNAUTHORIZED, "Frame Flow API token required", 401)
|
||||||
digest = hashlib.sha256(credentials.credentials.encode()).hexdigest()
|
digest = hashlib.sha256(credentials.credentials.encode()).hexdigest()
|
||||||
@@ -57,6 +59,7 @@ def require(scope):
|
|||||||
E.FORBIDDEN, "Token scope does not allow this action", 403
|
E.FORBIDDEN, "Token scope does not allow this action", 403
|
||||||
)
|
)
|
||||||
request.state.client_id = row["client_id"]
|
request.state.client_id = row["client_id"]
|
||||||
|
request.state.permissions = permissions
|
||||||
# Avoid writing last_used on every image or poll.
|
# Avoid writing last_used on every image or poll.
|
||||||
if not row["last_used_at"] or (
|
if not row["last_used_at"] or (
|
||||||
now_iso()[:16] != row["last_used_at"][:16]
|
now_iso()[:16] != row["last_used_at"][:16]
|
||||||
|
|||||||
@@ -125,3 +125,19 @@ def test_openapi_describes_cookie_auth_and_docs_csrf(client):
|
|||||||
"security"
|
"security"
|
||||||
]
|
]
|
||||||
assert "X-CSRF-Token" in client.get("/docs").text
|
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()
|
||||||
|
]
|
||||||
|
|||||||
@@ -399,3 +399,27 @@ def test_docs_offline_resources(client):
|
|||||||
assert "cdn.jsdelivr" not in html
|
assert "cdn.jsdelivr" not in html
|
||||||
assert client.get("/ui/swagger-ui-bundle.js").status_code == 200
|
assert client.get("/ui/swagger-ui-bundle.js").status_code == 200
|
||||||
assert client.get("/ui/swagger-ui.css").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
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user