fix(v2): scope and redact execution events
This commit is contained in:
@@ -244,6 +244,10 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
raise Problem(403, "csrf_failed", "CSRF validation failed.")
|
raise Problem(403, "csrf_failed", "CSRF validation failed.")
|
||||||
return user, scopes, cookie_auth
|
return user, scopes, cookie_auth
|
||||||
|
|
||||||
|
def enforce_scope(scopes: set[str], required: str) -> None:
|
||||||
|
if "*" not in scopes and required not in scopes:
|
||||||
|
raise Problem(403, "insufficient_scope", "Required scope is missing.")
|
||||||
|
|
||||||
async def audit(
|
async def audit(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -767,8 +771,9 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
async def enqueue_execution(
|
async def enqueue_execution(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
db: Annotated[AsyncSession, Depends(session)],
|
db: Annotated[AsyncSession, Depends(session)],
|
||||||
_: Annotated[tuple[User, set[str], bool], Depends(require)],
|
identity: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
enforce_scope(identity[1], "execution:control")
|
||||||
try:
|
try:
|
||||||
execution = await enqueue(db, job_id)
|
execution = await enqueue(db, job_id)
|
||||||
except EnqueueError as error:
|
except EnqueueError as error:
|
||||||
@@ -793,8 +798,9 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
async def get_execution(
|
async def get_execution(
|
||||||
execution_id: str,
|
execution_id: str,
|
||||||
db: Annotated[AsyncSession, Depends(session)],
|
db: Annotated[AsyncSession, Depends(session)],
|
||||||
_: Annotated[tuple[User, set[str], bool], Depends(actor)],
|
identity: Annotated[tuple[User, set[str], bool], Depends(actor)],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
enforce_scope(identity[1], "execution:read")
|
||||||
execution = await db.get(Execution, execution_id)
|
execution = await db.get(Execution, execution_id)
|
||||||
if execution is None:
|
if execution is None:
|
||||||
raise Problem(404, "resource_not_found", "Execution was not found.")
|
raise Problem(404, "resource_not_found", "Execution was not found.")
|
||||||
@@ -804,8 +810,9 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
async def cancel_execution(
|
async def cancel_execution(
|
||||||
execution_id: str,
|
execution_id: str,
|
||||||
db: Annotated[AsyncSession, Depends(session)],
|
db: Annotated[AsyncSession, Depends(session)],
|
||||||
_: Annotated[tuple[User, set[str], bool], Depends(require)],
|
identity: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
enforce_scope(identity[1], "execution:control")
|
||||||
execution = await request_cancellation(db, execution_id)
|
execution = await request_cancellation(db, execution_id)
|
||||||
if execution is None:
|
if execution is None:
|
||||||
raise Problem(409, "cancellation_not_allowed", "Execution cannot be cancelled.")
|
raise Problem(409, "cancellation_not_allowed", "Execution cannot be cancelled.")
|
||||||
@@ -815,8 +822,9 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
async def retry_execution(
|
async def retry_execution(
|
||||||
execution_id: str,
|
execution_id: str,
|
||||||
db: Annotated[AsyncSession, Depends(session)],
|
db: Annotated[AsyncSession, Depends(session)],
|
||||||
_: Annotated[tuple[User, set[str], bool], Depends(require)],
|
identity: Annotated[tuple[User, set[str], bool], Depends(require)],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
enforce_scope(identity[1], "execution:control")
|
||||||
try:
|
try:
|
||||||
execution = await retry(db, execution_id)
|
execution = await retry(db, execution_id)
|
||||||
except EnqueueError as error:
|
except EnqueueError as error:
|
||||||
@@ -829,8 +837,9 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
async def execution_events(
|
async def execution_events(
|
||||||
execution_id: str,
|
execution_id: str,
|
||||||
db: Annotated[AsyncSession, Depends(session)],
|
db: Annotated[AsyncSession, Depends(session)],
|
||||||
_: Annotated[tuple[User, set[str], bool], Depends(actor)],
|
identity: Annotated[tuple[User, set[str], bool], Depends(actor)],
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
|
enforce_scope(identity[1], "execution:read")
|
||||||
execution = await db.get(Execution, execution_id)
|
execution = await db.get(Execution, execution_id)
|
||||||
if execution is None:
|
if execution is None:
|
||||||
raise Problem(404, "resource_not_found", "Execution was not found.")
|
raise Problem(404, "resource_not_found", "Execution was not found.")
|
||||||
|
|||||||
@@ -169,9 +169,25 @@ async def retry(db: AsyncSession, execution_id: str) -> Execution | None:
|
|||||||
return execution
|
return execution
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_progress(value: object) -> object:
|
||||||
|
"""Apply common redaction plus execution-specific path redaction recursively."""
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
safe: dict[str, object] = {}
|
||||||
|
for key, item in value.items():
|
||||||
|
normalized = str(key).lower()
|
||||||
|
if any(marker in normalized for marker in ("path", "secret", "token", "password")):
|
||||||
|
safe[str(key)] = "[REDACTED]"
|
||||||
|
else:
|
||||||
|
safe[str(key)] = _redact_progress(item)
|
||||||
|
return safe
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_redact_progress(item) for item in value]
|
||||||
|
return redact(value)
|
||||||
|
|
||||||
|
|
||||||
def public_event(execution: Execution) -> dict[str, object]:
|
def public_event(execution: Execution) -> dict[str, object]:
|
||||||
"""Return redacted progress suitable for polling or SSE."""
|
"""Return redacted progress suitable for polling or SSE."""
|
||||||
progress = redact(execution.progress)
|
progress = _redact_progress(execution.progress)
|
||||||
return {
|
return {
|
||||||
"id": execution.id,
|
"id": execution.id,
|
||||||
"state": execution.state,
|
"state": execution.state,
|
||||||
|
|||||||
@@ -115,9 +115,7 @@ async def test_local_source_probe_archive_and_repository_targeted_job(
|
|||||||
await client.get(f"/api/v2/executions/{execution_id}", headers=token_headers)
|
await client.get(f"/api/v2/executions/{execution_id}", headers=token_headers)
|
||||||
).status_code == 403
|
).status_code == 403
|
||||||
assert (
|
assert (
|
||||||
await client.post(
|
await client.post(f"/api/v2/executions/{execution_id}/cancel", headers=token_headers)
|
||||||
f"/api/v2/executions/{execution_id}/cancel", headers=token_headers
|
|
||||||
)
|
|
||||||
).status_code == 403
|
).status_code == 403
|
||||||
async with app.state.sessions() as db:
|
async with app.state.sessions() as db:
|
||||||
assert await claim(db, execution_id, "expired-worker") is not None
|
assert await claim(db, execution_id, "expired-worker") is not None
|
||||||
|
|||||||
Reference in New Issue
Block a user