Harden ops run identity and leases
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a028de-e2c8-7732-8521-46a7fc5db82f
This commit is contained in:
parent
36161d346f
commit
f0a897e088
13 changed files with 522 additions and 50 deletions
|
|
@ -232,6 +232,7 @@ async def test_complete_persists_only_normalized_glas_evidence() -> None:
|
|||
row = MagicMock()
|
||||
row.state = "claimed"
|
||||
row.claim_owner = "worker-1"
|
||||
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
|
||||
row.result = {}
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
|
@ -275,6 +276,7 @@ async def test_fail_reopen_under_max_attempts(monkeypatch: pytest.MonkeyPatch) -
|
|||
row.id = uuid.uuid4()
|
||||
row.state = "claimed"
|
||||
row.claim_owner = "w1"
|
||||
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
|
||||
row.attempt = 1
|
||||
row.result = {}
|
||||
session = AsyncMock()
|
||||
|
|
@ -297,6 +299,7 @@ async def test_fail_permanent_at_max_attempts(monkeypatch: pytest.MonkeyPatch) -
|
|||
row.id = uuid.uuid4()
|
||||
row.state = "claimed"
|
||||
row.claim_owner = "w1"
|
||||
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
|
||||
row.attempt = 3
|
||||
row.result = {}
|
||||
session = AsyncMock()
|
||||
|
|
@ -316,6 +319,7 @@ async def test_fail_persists_redacted_failure_evidence() -> None:
|
|||
row = MagicMock()
|
||||
row.state = "claimed"
|
||||
row.claim_owner = "worker-1"
|
||||
row.lease_until = datetime.now(timezone.utc) + timedelta(minutes=1)
|
||||
row.attempt = 1
|
||||
row.result = {}
|
||||
session = AsyncMock()
|
||||
|
|
@ -347,6 +351,87 @@ async def test_fail_persists_redacted_failure_evidence() -> None:
|
|||
assert "tool_error" not in row.result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("lease_offset", [None, timedelta(0), timedelta(seconds=-1)])
|
||||
async def test_heartbeat_rejects_missing_or_expired_lease(
|
||||
lease_offset: timedelta | None,
|
||||
) -> None:
|
||||
from activity_core import ops_run_queue as oq
|
||||
|
||||
now = datetime(2026, 8, 23, 10, 0, tzinfo=timezone.utc)
|
||||
row = MagicMock()
|
||||
row.state = "claimed"
|
||||
row.claim_owner = "worker-1"
|
||||
row.lease_until = now + lease_offset if lease_offset is not None else None
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
||||
with patch.object(oq, "_utcnow", return_value=now):
|
||||
heartbeat = await oq.heartbeat_ops_run(
|
||||
session,
|
||||
uuid.uuid4(),
|
||||
worker_id="worker-1",
|
||||
lease_seconds=60,
|
||||
)
|
||||
|
||||
assert heartbeat is None
|
||||
assert row.lease_until == (now + lease_offset if lease_offset is not None else None)
|
||||
session.get.assert_awaited_once()
|
||||
assert session.get.await_args.kwargs == {"with_for_update": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_rejects_expired_lease_without_mutation() -> None:
|
||||
from activity_core import ops_run_queue as oq
|
||||
|
||||
now = datetime(2026, 8, 23, 10, 0, tzinfo=timezone.utc)
|
||||
row = MagicMock()
|
||||
row.state = "claimed"
|
||||
row.claim_owner = "worker-1"
|
||||
row.lease_until = now - timedelta(microseconds=1)
|
||||
row.result = {"before": True}
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
||||
with patch.object(oq, "_utcnow", return_value=now):
|
||||
completed = await oq.complete_ops_run(
|
||||
session,
|
||||
uuid.uuid4(),
|
||||
worker_id="worker-1",
|
||||
result={"ok": True},
|
||||
)
|
||||
|
||||
assert completed is None
|
||||
assert row.state == "claimed"
|
||||
assert row.result == {"before": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fail_rejects_wrong_owner_with_active_lease() -> None:
|
||||
from activity_core import ops_run_queue as oq
|
||||
|
||||
now = datetime(2026, 8, 23, 10, 0, tzinfo=timezone.utc)
|
||||
row = MagicMock()
|
||||
row.state = "claimed"
|
||||
row.claim_owner = "worker-1"
|
||||
row.lease_until = now + timedelta(minutes=1)
|
||||
row.result = {"before": True}
|
||||
session = AsyncMock()
|
||||
session.get = AsyncMock(return_value=row)
|
||||
|
||||
with patch.object(oq, "_utcnow", return_value=now):
|
||||
failed = await oq.fail_ops_run(
|
||||
session,
|
||||
uuid.uuid4(),
|
||||
worker_id="worker-2",
|
||||
error="must not persist",
|
||||
)
|
||||
|
||||
assert failed is None
|
||||
assert row.state == "claimed"
|
||||
assert row.result == {"before": True}
|
||||
|
||||
|
||||
def test_label_filter_any_vs_all() -> None:
|
||||
"""Document labels_mode semantics used by claim_ops_runs."""
|
||||
row_labels = {"automated", "research-brief"}
|
||||
|
|
|
|||
142
tests/test_ops_runs_api.py
Normal file
142
tests/test_ops_runs_api.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""Authentication boundary tests for the ops_run worker API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from activity_core.ops_runs_api import (
|
||||
bind_worker_id,
|
||||
require_worker,
|
||||
require_worker_or_operator,
|
||||
router,
|
||||
)
|
||||
|
||||
|
||||
def _request(headers: dict[str, str] | None = None) -> MagicMock:
|
||||
request = MagicMock()
|
||||
request.headers = headers or {}
|
||||
return request
|
||||
|
||||
|
||||
def _client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_worker_token_binds_configured_identity(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "worker-secret")
|
||||
monkeypatch.setenv("ACTIVITY_CORE_WORKER_ID", "rein-aharness@railiance01")
|
||||
|
||||
authenticated = require_worker(
|
||||
_request(),
|
||||
x_worker_token="worker-secret",
|
||||
)
|
||||
|
||||
assert authenticated == "rein-aharness@railiance01"
|
||||
assert bind_worker_id("rein-aharness@railiance01", authenticated) == authenticated
|
||||
|
||||
|
||||
def test_spoofed_worker_id_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "worker-secret")
|
||||
monkeypatch.setenv("ACTIVITY_CORE_WORKER_ID", "rein-aharness@railiance01")
|
||||
authenticated = require_worker(_request(), x_worker_token="worker-secret")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
bind_worker_id("another-worker", authenticated)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_worker_token_without_identity_fails_closed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "worker-secret")
|
||||
monkeypatch.delenv("ACTIVITY_CORE_WORKER_ID", raising=False)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_worker(_request(), x_worker_token="worker-secret")
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
assert "ACTIVITY_CORE_WORKER_ID" in exc.value.detail
|
||||
|
||||
|
||||
def test_invalid_worker_token_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "worker-secret")
|
||||
monkeypatch.setenv("ACTIVITY_CORE_WORKER_ID", "rein-aharness@railiance01")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_worker(_request(), x_worker_token="wrong")
|
||||
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_operator_or_sso_is_not_worker_identity(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("ACTIVITY_CORE_WORKER_TOKEN", raising=False)
|
||||
monkeypatch.delenv("ACTIVITY_CORE_WORKER_ID", raising=False)
|
||||
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "operator-secret")
|
||||
|
||||
for request, kwargs in (
|
||||
(_request({"Remote-User": "alice"}), {}),
|
||||
(_request(), {"authorization": "Bearer operator-secret"}),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_worker(request, **kwargs)
|
||||
assert exc.value.status_code == 503
|
||||
|
||||
|
||||
def test_claim_endpoint_rejects_operator_and_spoofed_identity(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "worker-secret")
|
||||
monkeypatch.setenv("ACTIVITY_CORE_WORKER_ID", "rein-aharness@railiance01")
|
||||
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "operator-secret")
|
||||
client = _client()
|
||||
body = {"worker_id": "rein-aharness@railiance01", "limit": 1}
|
||||
|
||||
operator = client.post(
|
||||
"/ops-runs/claim",
|
||||
json=body,
|
||||
headers={"X-Operator-Token": "operator-secret"},
|
||||
)
|
||||
spoofed = client.post(
|
||||
"/ops-runs/claim",
|
||||
json={"worker_id": "another-worker", "limit": 1},
|
||||
headers={"X-Worker-Token": "worker-secret"},
|
||||
)
|
||||
|
||||
assert operator.status_code == 401
|
||||
assert spoofed.status_code == 403
|
||||
|
||||
|
||||
def test_unauthenticated_dev_worker_requires_explicit_opt_in(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ACTIVITY_CORE_WORKER_TOKEN", raising=False)
|
||||
monkeypatch.delenv("ACTIVITY_CORE_WORKER_ID", raising=False)
|
||||
monkeypatch.delenv("ACTIVITY_CORE_OPERATOR_TOKEN", raising=False)
|
||||
monkeypatch.delenv("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS", raising=False)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_worker(_request())
|
||||
assert exc.value.status_code == 503
|
||||
|
||||
monkeypatch.setenv("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS", "true")
|
||||
assert require_worker(_request()) == "dev:unauth"
|
||||
assert bind_worker_id("local-worker", "dev:unauth") == "local-worker"
|
||||
|
||||
|
||||
def test_read_auth_no_longer_defaults_open(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("ACTIVITY_CORE_WORKER_TOKEN", raising=False)
|
||||
monkeypatch.delenv("ACTIVITY_CORE_WORKER_ID", raising=False)
|
||||
monkeypatch.delenv("ACTIVITY_CORE_OPERATOR_TOKEN", raising=False)
|
||||
monkeypatch.delenv("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS", raising=False)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_worker_or_operator(_request())
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
Loading…
Add table
Add a link
Reference in a new issue