Add ISSUE_CORE_API_KEY auth to IssueCoreRestSink

Issue-core requires a shared ingestion key on POST /issues/. The REST sink
now sends Authorization: Bearer using ISSUE_CORE_API_KEY and fails fast
when the key is missing under ISSUE_SINK_TYPE=rest.

Updates .env.example, emission boundary docs, and unit tests for the
header contract and missing-key error.
This commit is contained in:
2026-06-18 22:30:13 +02:00
parent 2078915854
commit a08bd1684f
4 changed files with 56 additions and 8 deletions

View File

@@ -18,7 +18,9 @@ STATE_HUB_URL=http://127.0.0.1:8000
# Repo scoping — used by the repo-scoping context adapter. Binds {} on failure. # Repo scoping — used by the repo-scoping context adapter. Binds {} on failure.
REPO_SCOPING_URL=http://127.0.0.1:8020 REPO_SCOPING_URL=http://127.0.0.1:8020
# Issue Core — task emission backend. # Issue Core — task emission backend.
ISSUE_CORE_URL=http://127.0.0.1:8010 ISSUE_CORE_URL=http://127.0.0.1:8765
# Shared ingestion key — must match issue-core's ISSUE_CORE_API_KEY.
ISSUE_CORE_API_KEY=
# Sink type: 'rest' (POST to issue-core) or 'null' (discard, for dry-run). # Sink type: 'rest' (POST to issue-core) or 'null' (discard, for dry-run).
ISSUE_SINK_TYPE=rest ISSUE_SINK_TYPE=rest

View File

@@ -11,7 +11,9 @@ The current authoritative boundary is the issue-core REST API:
POST {ISSUE_CORE_URL}/issues/ POST {ISSUE_CORE_URL}/issues/
``` ```
`IssueCoreRestSink` sends this payload: `IssueCoreRestSink` authenticates with the shared `ISSUE_CORE_API_KEY` env var
(same value as the issue-core server) via `Authorization: Bearer <key>` and
sends this payload:
```json ```json
{ {
@@ -52,7 +54,7 @@ task reference before it can replace `IssueCoreRestSink`.
Weekly SBOM staleness is safe to evaluate in dry-run mode because the rule Weekly SBOM staleness is safe to evaluate in dry-run mode because the rule
contract is deterministic and tested. Do not enable it against the real REST sink contract is deterministic and tested. Do not enable it against the real REST sink
until issue-core credentials, endpoint reachability, and duplicate-handling are until `ISSUE_CORE_API_KEY`, endpoint reachability, and duplicate-handling are
verified in the target environment. verified in the target environment.
## Verification ## Verification

View File

@@ -20,7 +20,8 @@ from activity_core.rules.models import TaskRef, TaskSpec
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ISSUE_CORE_URL = os.environ.get("ISSUE_CORE_URL", "http://127.0.0.1:8010") ISSUE_CORE_URL = os.environ.get("ISSUE_CORE_URL", "http://127.0.0.1:8765")
ISSUE_CORE_API_KEY_ENV = "ISSUE_CORE_API_KEY"
ISSUE_SINK_TYPE = os.environ.get("ISSUE_SINK_TYPE", "rest") ISSUE_SINK_TYPE = os.environ.get("ISSUE_SINK_TYPE", "rest")
@@ -30,10 +31,30 @@ class IssueSink(ABC):
class IssueCoreRestSink(IssueSink): class IssueCoreRestSink(IssueSink):
"""POSTs to issue-core REST API. Config: ISSUE_CORE_URL env var.""" """POSTs to issue-core REST API.
def __init__(self, base_url: str = ISSUE_CORE_URL) -> None: Config: ISSUE_CORE_URL and ISSUE_CORE_API_KEY env vars (shared key with
the issue-core server).
"""
def __init__(
self,
base_url: str = ISSUE_CORE_URL,
api_key: str | None = None,
) -> None:
self._base_url = base_url.rstrip("/") self._base_url = base_url.rstrip("/")
if api_key is not None:
self._api_key = api_key.strip()
else:
self._api_key = os.environ.get(ISSUE_CORE_API_KEY_ENV, "").strip()
def _auth_headers(self) -> dict[str, str]:
if not self._api_key:
raise RuntimeError(
f"{ISSUE_CORE_API_KEY_ENV} is not set. "
"Required when ISSUE_SINK_TYPE=rest."
)
return {"Authorization": f"Bearer {self._api_key}"}
def emit(self, task_spec: TaskSpec) -> TaskRef: def emit(self, task_spec: TaskSpec) -> TaskRef:
payload = { payload = {
@@ -48,7 +69,12 @@ class IssueCoreRestSink(IssueSink):
"triggering_event_id": task_spec.triggering_event_id, "triggering_event_id": task_spec.triggering_event_id,
"activity_definition_id": task_spec.activity_definition_id, "activity_definition_id": task_spec.activity_definition_id,
} }
resp = httpx.post(f"{self._base_url}/issues/", json=payload, timeout=10.0) resp = httpx.post(
f"{self._base_url}/issues/",
json=payload,
headers=self._auth_headers(),
timeout=10.0,
)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
return TaskRef( return TaskRef(

View File

@@ -34,7 +34,7 @@ def test_issue_core_rest_sink_posts_task_contract(monkeypatch) -> None:
monkeypatch.setattr(httpx, "post", fake_post) monkeypatch.setattr(httpx, "post", fake_post)
ref = IssueCoreRestSink("http://issue-core.test/").emit(TaskSpec( ref = IssueCoreRestSink("http://issue-core.test/", api_key="test-key").emit(TaskSpec(
title="Run SBOM rescan for activity-core", title="Run SBOM rescan for activity-core",
description="SBOM is older than 30 days.", description="SBOM is older than 30 days.",
target_repo="activity-core", target_repo="activity-core",
@@ -67,12 +67,30 @@ def test_issue_core_rest_sink_posts_task_contract(monkeypatch) -> None:
"triggering_event_id": "scheduled", "triggering_event_id": "scheduled",
"activity_definition_id": "activity-1", "activity_definition_id": "activity-1",
}, },
"headers": {"Authorization": "Bearer test-key"},
"timeout": 10.0, "timeout": 10.0,
} }
] ]
assert "review_required" not in posts[0]["json"] assert "review_required" not in posts[0]["json"]
def test_issue_core_rest_sink_requires_api_key() -> None:
sink = IssueCoreRestSink("http://issue-core.test/", api_key="")
with pytest.raises(RuntimeError, match="ISSUE_CORE_API_KEY"):
sink.emit(TaskSpec(
title="t",
description="",
target_repo="activity-core",
priority="low",
labels=[],
due_in_days=None,
source_type="rule",
source_id="r",
triggering_event_id="e",
activity_definition_id="a",
))
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_emit_tasks_raises_when_sink_fails(monkeypatch) -> None: async def test_emit_tasks_raises_when_sink_fails(monkeypatch) -> None:
class FailingSink: class FailingSink: