Bind multiple queue worker identities, one token each (WP-0039-T01)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 5s
Build and Publish Container Image / build-and-push (push) Successful in 39s

ACTIVITY_CORE_WORKERS maps worker_id=ENV_NAME, where each token env must be
ACTIVITY_CORE_WORKER_TOKEN[_SUFFIX]. Without the map, the legacy single pair
behaves exactly as before. Duplicate identities, missing or shared tokens, a
token equal to the operator token, and an unlisted legacy identity all fail
worker mutations closed with 503. Operator/SSO reads keep working.

Declare per-identity OpenBao paths and an ExternalSecret, not yet applied.
The policy, seeding and rollout are waiting tasks T02-T04, answering
secrets-engine SECRETS-WP-0009-T03 and SECRETS-WP-0011-T04.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 151606@bnt-lap001
Assistant-Session: 3c0a4ad5-bb8b-4bf7-b9f0-fa5f29204e48
This commit is contained in:
tegwick 2026-09-23 17:38:41 +02:00
parent 6002d5c5f6
commit b4a7a84211
6 changed files with 400 additions and 29 deletions

View file

@ -91,16 +91,21 @@ explicit is better for ops):
| `OPS_RUN_MAX_ATTEMPTS` | `3` | Optional | | `OPS_RUN_MAX_ATTEMPTS` | `3` | Optional |
| `OPS_RUN_SLA_HOURS` | `1` | Status stuck threshold | | `OPS_RUN_SLA_HOURS` | `1` | Status stuck threshold |
| `ACTIVITY_CORE_WORKER_ID` | `rein-aharness@railiance01` | Exact non-secret identity bound to the worker token | | `ACTIVITY_CORE_WORKER_ID` | `rein-aharness@railiance01` | Exact non-secret identity bound to the worker token |
| `ACTIVITY_CORE_WORKERS` | `rein-aharness@railiance01=ACTIVITY_CORE_WORKER_TOKEN,rein-aharness-metered@railiance01=ACTIVITY_CORE_WORKER_TOKEN_METERED` | Optional token-to-identity map (ACTIVITY-WP-0039); when set it is authoritative |
Worker auth (required before external claim): Worker auth (required before external claim):
```bash Worker tokens are held in OpenBao, one path per identity, field `token`:
# Generate once; store in secret — do not commit `platform/workloads/activity-core/ops-run-workers/<identity-slug>`. The
WORKER_TOKEN="$(openssl rand -hex 24)" ExternalSecret `k8s/railiance/15-externalsecret-worker-tokens.yaml` merges them
kubectl -n activity-core patch secret actcore-runtime-secret --type merge \ into `actcore-runtime-secret`. Do not generate a token by hand or patch it into
-p "{\"stringData\":{\"ACTIVITY_CORE_WORKER_TOKEN\":\"${WORKER_TOKEN}\"}}" the Secret. Minting is founder-attended OpenBao work (ACTIVITY-WP-0039-T03).
# Record token in operator secret store (OpenBao / password manager), not chat. Until that rollout, production still carries the earlier hand-set
``` `ACTIVITY_CORE_WORKER_TOKEN`.
Each token binds exactly one identity. Shared tokens, a token equal to the
operator token, and identities missing from `ACTIVITY_CORE_WORKERS` fail closed
with 503.
Apply the ConfigMap identity and Secret token in the same rollout. If the token Apply the ConfigMap identity and Secret token in the same rollout. If the token
is present without `ACTIVITY_CORE_WORKER_ID`, worker mutations fail with 503; is present without `ACTIVITY_CORE_WORKER_ID`, worker mutations fail with 503;

View file

@ -0,0 +1,44 @@
# Sync ops_run queue worker tokens from OpenBao into actcore-runtime-secret.
#
# NOT YET APPLIED (ACTIVITY-WP-0039). Apply only after T02 (railiance-platform
# adds both exact paths to the activity-core-eso role policy) and T03 (the
# founder seeds both paths). Applying earlier makes the store fail to read,
# or overwrites the hand-set ACTIVITY_CORE_WORKER_TOKEN with a missing value.
#
# Prereqs on railiance01:
# - ClusterSecretStore openbao-activity-core (railiance-platform addon; OpenBao
# Kubernetes auth via ServiceAccount activity-core/activity-core-eso since
# RPF-WP-0045)
#
# One path per worker identity (field: token), so a consumer lane can be
# granted exactly one worker's token:
# rein-aharness@railiance01 -> ACTIVITY_CORE_WORKER_TOKEN
# rein-aharness-metered@railiance01 -> ACTIVITY_CORE_WORKER_TOKEN_METERED
# The identity map is the non-secret ACTIVITY_CORE_WORKERS in
# actcore-runtime-config.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: actcore-ops-run-worker-tokens
namespace: activity-core
labels:
app.kubernetes.io/name: activity-core
app.kubernetes.io/part-of: activity-core
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao-activity-core
target:
name: actcore-runtime-secret
creationPolicy: Merge
deletionPolicy: Retain
data:
- secretKey: ACTIVITY_CORE_WORKER_TOKEN
remoteRef:
key: platform/workloads/activity-core/ops-run-workers/rein-aharness-railiance01
property: token
- secretKey: ACTIVITY_CORE_WORKER_TOKEN_METERED
remoteRef:
key: platform/workloads/activity-core/ops-run-workers/rein-aharness-metered-railiance01
property: token

View file

@ -45,6 +45,10 @@ workers insert claimable `ops_runs` on emit. Full railiance checklist:
harness claim client. The non-secret `ACTIVITY_CORE_WORKER_ID` in the runtime harness claim client. The non-secret `ACTIVITY_CORE_WORKER_ID` in the runtime
ConfigMap binds that credential to `rein-aharness@railiance01`; deploy both ConfigMap binds that credential to `rein-aharness@railiance01`; deploy both
settings together. settings together.
Additional worker identities use the non-secret `ACTIVITY_CORE_WORKERS` map,
with one OpenBao-held token per identity synced by
`15-externalsecret-worker-tokens.yaml`. That file is not applied yet; see
ACTIVITY-WP-0039.
| ExternalSecret | OpenBao path | Secret key | | ExternalSecret | OpenBao path | Secret key |
| --- | --- | --- | | --- | --- | --- |

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import hmac import hmac
import os import os
import re
import uuid import uuid
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime from datetime import datetime
@ -38,6 +39,8 @@ _get_db: Callable[[], async_sessionmaker[AsyncSession]] | None = None
WORKER_TOKEN_ENV = "ACTIVITY_CORE_WORKER_TOKEN" WORKER_TOKEN_ENV = "ACTIVITY_CORE_WORKER_TOKEN"
WORKER_ID_ENV = "ACTIVITY_CORE_WORKER_ID" WORKER_ID_ENV = "ACTIVITY_CORE_WORKER_ID"
WORKERS_ENV = "ACTIVITY_CORE_WORKERS"
_WORKER_TOKEN_ENV_RE = re.compile(r"ACTIVITY_CORE_WORKER_TOKEN(?:_[A-Z0-9]+)*")
def bind_ops_runs_deps( def bind_ops_runs_deps(
@ -54,6 +57,8 @@ def _db() -> async_sessionmaker[AsyncSession]:
def _worker_token_configured() -> bool: def _worker_token_configured() -> bool:
if (os.environ.get(WORKERS_ENV) or "").strip():
return True
return bool((os.environ.get(WORKER_TOKEN_ENV) or "").strip()) return bool((os.environ.get(WORKER_TOKEN_ENV) or "").strip())
@ -62,6 +67,77 @@ def configured_worker_id() -> str | None:
return value or None return value or None
class WorkerConfigError(Exception):
"""Worker credential configuration is incomplete or ambiguous."""
def worker_credentials() -> list[tuple[str, str]]:
"""Return ``(worker_id, token)`` pairs, each token bound to one identity.
``ACTIVITY_CORE_WORKERS`` is a non-secret, comma-separated list of
``worker_id=ENV_NAME`` entries. Each ENV_NAME must be
``ACTIVITY_CORE_WORKER_TOKEN`` or ``ACTIVITY_CORE_WORKER_TOKEN_<SUFFIX>`` and
hold that worker's token. Without it, the legacy single pair
``ACTIVITY_CORE_WORKER_ID`` + ``ACTIVITY_CORE_WORKER_TOKEN`` applies.
Duplicate identities, missing or shared tokens, and a token equal to the
operator token raise ``WorkerConfigError`` so callers fail closed.
"""
raw = (os.environ.get(WORKERS_ENV) or "").strip()
if not raw:
token = (os.environ.get(WORKER_TOKEN_ENV) or "").strip()
if not token:
return []
worker_id = configured_worker_id()
if not worker_id:
raise WorkerConfigError(f"worker identity not configured ({WORKER_ID_ENV})")
pairs = [(worker_id, token)]
else:
pairs = []
for entry in raw.split(","):
entry = entry.strip()
if not entry:
continue
worker_id, sep, env_name = (part.strip() for part in entry.partition("="))
if not sep or not worker_id or _WORKER_TOKEN_ENV_RE.fullmatch(env_name) is None:
raise WorkerConfigError(f"invalid {WORKERS_ENV} entry")
token = (os.environ.get(env_name) or "").strip()
if not token:
raise WorkerConfigError(f"worker token not configured ({env_name})")
pairs.append((worker_id, token))
if not pairs:
raise WorkerConfigError(f"{WORKERS_ENV} lists no workers")
legacy_id = configured_worker_id()
if legacy_id and legacy_id not in {worker_id for worker_id, _ in pairs}:
raise WorkerConfigError(f"{WORKER_ID_ENV} is not listed in {WORKERS_ENV}")
ids = [worker_id for worker_id, _ in pairs]
tokens = [token for _, token in pairs]
if len(set(ids)) != len(ids):
raise WorkerConfigError("duplicate worker identity")
if len(set(tokens)) != len(tokens):
raise WorkerConfigError("worker tokens must not be shared")
operator = (os.environ.get("ACTIVITY_CORE_OPERATOR_TOKEN") or "").strip()
if operator and operator in tokens:
raise WorkerConfigError("worker token must differ from the operator token")
return pairs
def _authenticate_worker_token(token: str | None) -> str | None:
"""Return the identity bound to ``token``; raise 503 on bad configuration."""
try:
pairs = worker_credentials()
except WorkerConfigError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
if not token:
return None
matched = None
for worker_id, expected in pairs:
# Compare against every entry so timing does not reveal the position.
if hmac.compare_digest(token, expected):
matched = worker_id
return matched
def _extract_worker_token( def _extract_worker_token(
*, *,
x_worker_token: str | None, x_worker_token: str | None,
@ -85,18 +161,15 @@ def require_worker_or_operator(
worker_tok = _extract_worker_token( worker_tok = _extract_worker_token(
x_worker_token=x_worker_token, authorization=authorization x_worker_token=x_worker_token, authorization=authorization
) )
expected_worker = (os.environ.get(WORKER_TOKEN_ENV) or "").strip() worker_id = None
if ( if worker_tok:
expected_worker try:
and worker_tok worker_id = _authenticate_worker_token(worker_tok)
and hmac.compare_digest(worker_tok, expected_worker) except HTTPException:
): # Broken worker config must not lock out operator/SSO reads;
worker_id = configured_worker_id() # worker-only mutations still fail closed in require_worker.
if not worker_id: worker_id = None
raise HTTPException( if worker_id:
status_code=503,
detail=f"worker identity not configured ({WORKER_ID_ENV})",
)
return f"worker:{worker_id}" return f"worker:{worker_id}"
sso = extract_sso_principal(request) sso = extract_sso_principal(request)
@ -125,22 +198,16 @@ def require_worker(
x_worker_token: str | None = None, x_worker_token: str | None = None,
authorization: str | None = None, authorization: str | None = None,
) -> str: ) -> str:
"""Return the configured worker identity for a valid worker credential.""" """Return the identity bound to a valid worker credential."""
del request # reserved for future mTLS/proxy-bound worker principals del request # reserved for future mTLS/proxy-bound worker principals
expected_worker = (os.environ.get(WORKER_TOKEN_ENV) or "").strip()
worker_tok = _extract_worker_token( worker_tok = _extract_worker_token(
x_worker_token=x_worker_token, authorization=authorization x_worker_token=x_worker_token, authorization=authorization
) )
if expected_worker: if _worker_token_configured():
if not worker_tok or not hmac.compare_digest(worker_tok, expected_worker): worker_id = _authenticate_worker_token(worker_tok)
raise HTTPException(status_code=401, detail="worker auth required")
worker_id = configured_worker_id()
if not worker_id: if not worker_id:
raise HTTPException( raise HTTPException(status_code=401, detail="worker auth required")
status_code=503,
detail=f"worker identity not configured ({WORKER_ID_ENV})",
)
return worker_id return worker_id
if allow_unauth_mutations() and not operator_token_configured(): if allow_unauth_mutations() and not operator_token_configured():

View file

@ -165,3 +165,113 @@ def test_close_response_distinguishes_reconciled_and_refusal_codes(
_close_response(CloseOpsRunOutcome("expired_lease", row)) _close_response(CloseOpsRunOutcome("expired_lease", row))
assert exc.value.status_code == 409 assert exc.value.status_code == 409
assert exc.value.detail["code"] == "expired_lease" assert exc.value.detail["code"] == "expired_lease"
def _clear_worker_env(monkeypatch: pytest.MonkeyPatch) -> None:
for name in (
"ACTIVITY_CORE_WORKERS",
"ACTIVITY_CORE_WORKER_ID",
"ACTIVITY_CORE_WORKER_TOKEN",
"ACTIVITY_CORE_WORKER_TOKEN_METERED",
"ACTIVITY_CORE_OPERATOR_TOKEN",
"ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS",
):
monkeypatch.delenv(name, raising=False)
def _two_workers(monkeypatch: pytest.MonkeyPatch) -> None:
_clear_worker_env(monkeypatch)
monkeypatch.setenv(
"ACTIVITY_CORE_WORKERS",
"rein-aharness@railiance01=ACTIVITY_CORE_WORKER_TOKEN,"
"rein-aharness-metered@railiance01=ACTIVITY_CORE_WORKER_TOKEN_METERED",
)
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "loop-secret")
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN_METERED", "metered-secret")
def test_each_worker_token_binds_exactly_its_own_identity(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_two_workers(monkeypatch)
loop = require_worker(_request(), x_worker_token="loop-secret")
metered = require_worker(_request(), authorization="Bearer metered-secret")
assert loop == "rein-aharness@railiance01"
assert metered == "rein-aharness-metered@railiance01"
with pytest.raises(HTTPException) as exc:
bind_worker_id("rein-aharness@railiance01", metered)
assert exc.value.status_code == 403
with pytest.raises(HTTPException) as exc:
require_worker(_request(), x_worker_token="unknown")
assert exc.value.status_code == 401
def test_claim_endpoint_rejects_cross_identity_body(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_two_workers(monkeypatch)
response = _client().post(
"/ops-runs/claim",
json={"worker_id": "rein-aharness@railiance01", "limit": 1},
headers={"X-Worker-Token": "metered-secret"},
)
assert response.status_code == 403
@pytest.mark.parametrize(
("workers", "env", "fragment"),
[
("a=ACTIVITY_CORE_WORKER_TOKEN,b=ACTIVITY_CORE_WORKER_TOKEN_METERED",
{"ACTIVITY_CORE_WORKER_TOKEN": "same", "ACTIVITY_CORE_WORKER_TOKEN_METERED": "same"},
"shared"),
("a=ACTIVITY_CORE_WORKER_TOKEN,a=ACTIVITY_CORE_WORKER_TOKEN_METERED",
{"ACTIVITY_CORE_WORKER_TOKEN": "x", "ACTIVITY_CORE_WORKER_TOKEN_METERED": "y"},
"duplicate"),
("a=ACTIVITY_CORE_WORKER_TOKEN,b=ACTIVITY_CORE_WORKER_TOKEN_METERED",
{"ACTIVITY_CORE_WORKER_TOKEN": "x"},
"ACTIVITY_CORE_WORKER_TOKEN_METERED"),
("a=ACTIVITY_CORE_OPERATOR_TOKEN",
{"ACTIVITY_CORE_OPERATOR_TOKEN": "op"},
"invalid"),
("a=ACTIVITY_CORE_WORKER_TOKEN",
{"ACTIVITY_CORE_WORKER_TOKEN": "op", "ACTIVITY_CORE_OPERATOR_TOKEN": "op"},
"operator"),
("a=ACTIVITY_CORE_WORKER_TOKEN",
{"ACTIVITY_CORE_WORKER_TOKEN": "x", "ACTIVITY_CORE_WORKER_ID": "b"},
"not listed"),
],
)
def test_ambiguous_worker_config_fails_closed(
monkeypatch: pytest.MonkeyPatch,
workers: str,
env: dict[str, str],
fragment: str,
) -> None:
_clear_worker_env(monkeypatch)
monkeypatch.setenv("ACTIVITY_CORE_WORKERS", workers)
for name, value in env.items():
monkeypatch.setenv(name, value)
with pytest.raises(HTTPException) as exc:
require_worker(_request(), x_worker_token="x")
assert exc.value.status_code == 503
assert fragment in exc.value.detail
def test_broken_worker_config_does_not_lock_out_operator(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clear_worker_env(monkeypatch)
monkeypatch.setenv("ACTIVITY_CORE_WORKERS", "a=ACTIVITY_CORE_WORKER_TOKEN_MISSING")
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "operator-secret")
principal = require_worker_or_operator(
_request(), authorization="Bearer operator-secret"
)
assert principal == "operator:token"

View file

@ -0,0 +1,141 @@
---
id: ACTIVITY-WP-0039
type: workplan
title: "Multiple queue worker identities with OpenBao-custodied tokens"
domain: infotech
repo: activity-core
status: active
flavor: implementation
owner: claude-code
topic_slug: activity-core
priority: high
created: "2026-09-23"
updated: "2026-09-23"
related:
- ACT-ADR-005
- ACTIVITY-WP-0036
- SECRETS-WP-0009-T03
- SECRETS-WP-0011-T04
- RPF-WP-0045
---
# Multiple Queue Worker Identities with OpenBao-Custodied Tokens
## Origin
Two requests from secrets-engine, both approved on their side on 2026-09-23:
- Message `914853d9` (SECRETS-WP-0009-T03) asks for more than one
authenticated queue worker. The spend-admitted `rein-aharness metered-once`
owner should claim as `rein-aharness-metered@railiance01`, separate from the
running `rein-aharness@railiance01` claim loop. This keeps paid-run
attribution distinct at the API.
- Message `6e694682` (SECRETS-WP-0011-T04) asks for the worker token to be held
in OpenBao and synced by the existing `openbao-activity-core` store, instead
of the hand-generated `openssl rand` value in
`docs/deploy-ops-run-queue-railiance.md`. secrets-engine will catalog a
read-only lane for the metered worker's path only. It will never read, copy
or mint the token.
WP-0036 bound one token to one configured identity. This workplan keeps that
guarantee and extends it to a set of identities.
## Boundary rules
- Each token is bound to exactly one worker identity. The body `worker_id`
must still match it. No token is shared, and nothing falls back implicitly.
- Worker tokens never equal the operator token, and operator/SSO credentials
stay out of worker mutations (WP-0036).
- One OpenBao path per worker identity, so a consumer lane can be granted
exactly one worker's token.
- Minting and writing a token is founder-attended OpenBao work. The store's
role policy belongs to railiance-platform. No token value passes through
Git, State Hub, chat, or an agent shell.
## Token-to-identity map in the worker API
```task
id: ACTIVITY-WP-0039-T01
status: done
priority: high
```
Add `ACTIVITY_CORE_WORKERS`, a non-secret comma-separated list of
`worker_id=ENV_NAME` entries. Each ENV_NAME must be `ACTIVITY_CORE_WORKER_TOKEN`
or `ACTIVITY_CORE_WORKER_TOKEN_<SUFFIX>`. When the list is unset, the legacy
`ACTIVITY_CORE_WORKER_ID` + `ACTIVITY_CORE_WORKER_TOKEN` pair behaves exactly as
before. Worker mutations fail closed with 503 when the configuration is
ambiguous. That covers a duplicate identity, a missing or shared token, a token
equal to the operator token, an env name outside the pattern, and a legacy
identity missing from the list. A broken worker configuration does not lock out
operator or SSO reads.
Implemented in `src/activity_core/ops_runs_api.py` (`worker_credentials`).
Tests in `tests/test_ops_runs_api.py` cover per-identity binding, a
cross-identity body rejected with 403, an unknown token rejected with 401, each
fail-closed case, and operator access under broken worker configuration.
## Declare OpenBao paths and the ExternalSecret
```task
id: ACTIVITY-WP-0039-T02
status: wait
priority: high
```
Paths, one per identity, each with field `token`:
- `platform/workloads/activity-core/ops-run-workers/rein-aharness-railiance01`
- `platform/workloads/activity-core/ops-run-workers/rein-aharness-metered-railiance01`
`k8s/railiance/15-externalsecret-worker-tokens.yaml` merges them into
`actcore-runtime-secret` as `ACTIVITY_CORE_WORKER_TOKEN` and
`ACTIVITY_CORE_WORKER_TOKEN_METERED`. The manifest is in the repo but not
applied.
Waiting on railiance-platform to add both exact paths to the
`activity-core-eso` role policy (RPF-WP-0045 pattern), and on secrets-engine to
confirm the metered path for its catalog.
## Seed the tokens in OpenBao
```task
id: ACTIVITY-WP-0039-T03
status: wait
priority: high
```
Founder-attended, through `warden access openbao-platform-admin-login --exec`
(orientation section 5):
- **Metered worker:** mint a fresh random value directly into its path. No
value exists today.
- **Claim-loop worker:** the founder chooses between two options.
- (a) Move the current value into OpenBao. The running claim loop needs no
change, but the value was hand-generated outside custody.
- (b) Mint a fresh value and update the rein-aharness claim-loop
configuration on railiance01 (user tegwick) in the same window. This means
one coordinated restart.
Done when both paths hold a value and no value has been printed or logged.
## Roll out and prove both identities
```task
id: ACTIVITY-WP-0039-T04
status: wait
priority: high
```
Needs the founder's go-ahead (`ADMINISTER @ realm:kubernetes/railiance01`,
`activation=APPROVED`) and a CPU headroom check first (orientation section 4).
Apply the ExternalSecret, add `ACTIVITY_CORE_WORKERS` to
`actcore-runtime-config`, deploy the new image, and restart only the API.
Prove four things:
- The claim loop still polls with HTTP 200.
- The metered token authenticates as `rein-aharness-metered@railiance01`.
- A metered token paired with the loop identity is rejected with HTTP 403.
- The previous hand-set Secret key is now owned by ESO.
Report the revision to secrets-engine on threads `914853d9` and `6e694682`.