diff --git a/taosmd/http_server.py b/taosmd/http_server.py index 59acb838..bea0387f 100644 --- a/taosmd/http_server.py +++ b/taosmd/http_server.py @@ -621,6 +621,7 @@ def _make_handler(data_dir, runner: _ServiceLoop, verifier=None, _registry_url, revoked_token=_registry_admin_token, expected_iss=registry_auth.REGISTRY_ISS, + human_iss=registry_auth.CONTROLLER_ISS, ) _grants_verifier = registry_auth.grants_verifier_from_url( _registry_url, @@ -1446,14 +1447,16 @@ def _handle_a2a_send(self) -> None: # 401/403. In verify-and-warn mode (default) failures are logged as # a WARNING but the message is accepted, allowing operators to observe # violations before enabling hard enforcement. + # Human principals (sub starting with user-) are always rejected on + # auth failure regardless of mode: missing credential is the only + # tolerated class during migration. + sender = from_ + _is_human = False if _registry_verifier is not None: from . import registry_auth # noqa: PLC0415 - optional path auth = self.headers.get("Authorization", "") token = auth[len("Bearer "):].strip() if auth.startswith("Bearer ") else "" - # Compute warn_reason (None = auth passed) and the status/message - # to use in enforce mode. We collect these without returning early - # so the enforce vs. warn decision is made in one place below. warn_reason: str | None = None _reject_status: int = 403 _reject_msg: str = "" @@ -1464,19 +1467,24 @@ def _handle_a2a_send(self) -> None: _reject_msg = "registry auth: Bearer token required" else: try: - _registry_verifier.authorize(token, from_) + claims = _registry_verifier.authorize(token, from_) + sender = claims["sub"] + _is_human = registry_auth._is_human_sub(sender) except registry_auth.AuthError as exc: - warn_reason = str(exc) - _reject_status = 403 - _reject_msg = f"registry auth: {exc}" + # Presented-but-failing credentials are always rejected + # (both modes), regardless of principal type. Missing + # credential is the only class tolerated during migration. + self._send_json(403, {"error": f"registry auth: {exc}"}) + return # Grant check: token proves identity; grant proves permission. - if warn_reason is None and _grants_verifier is not None: + # Humans are not in the registry, so they have no grants. + if warn_reason is None and _grants_verifier is not None and not _is_human: try: - if not _grants_verifier.has_grant(from_): + if not _grants_verifier.has_grant(sender): warn_reason = "no a2a_send grant" _reject_status = 403 - _reject_msg = f"registry auth: no active grant for {from_!r}" + _reject_msg = f"registry auth: no active grant for {sender!r}" except registry_auth.AuthError as exc: warn_reason = str(exc) _reject_status = 403 @@ -1489,11 +1497,11 @@ def _handle_a2a_send(self) -> None: return logger.warning( "a2a verify-and-warn: accepting unverified post from %r: %s", - from_, warn_reason, + sender, warn_reason, ) result = runner.run( service.a2a_send( - sender=from_, body=body_text, + sender=sender, body=body_text, thread=thread, reply_to=reply_to, refs=refs, blocks=blocks, data_dir=data_dir, diff --git a/taosmd/registry_auth.py b/taosmd/registry_auth.py index 85b7a7e2..a8bc8614 100644 --- a/taosmd/registry_auth.py +++ b/taosmd/registry_auth.py @@ -30,11 +30,20 @@ # pins this so a token from any other issuer is rejected. REGISTRY_ISS = "taos-registry" +# The literal ``iss`` the controller mints into human assertions. The bus pins +# this so a registry token cannot spoof a human identity (and vice versa). +CONTROLLER_ISS = "taos-controller" + class AuthError(Exception): """Raised when a token fails verification or the auth policy.""" +def _is_human_sub(sub: str) -> bool: + """Return True if the sub is a human canonical id (user-* convention).""" + return sub.startswith("user-") + + def _require_jwt(): try: import jwt # noqa: PLC0415 @@ -58,14 +67,19 @@ def decode_and_verify(token: str, public_key: str) -> dict: def authorize_sender(token: str, claimed_from: str, *, public_key: str, - revoked: set[str], expected_iss: str | None = None) -> dict: + revoked: set[str], expected_iss: str | None = None, + human_iss: str | None = None) -> dict: """Authorise a bus sender. Returns the verified claims or raises AuthError. Policy (after the EdDSA signature check): - * the token must carry a ``sub`` (the agent canonical_id); + * the token must carry a ``sub`` (the agent or human canonical_id); * ``sub`` must equal the message ``from`` (no impersonation); - * ``sub`` must not be in the registry revocation set; - * when ``expected_iss`` is set, ``iss`` must match it (issuer pinning). + * for human principals (sub starting with ``user-``): + - the token must carry a valid ``iss`` matching ``human_iss`` when set; + - revocation is not checked (humans are not in the registry); + * for agent principals: + - ``sub`` must not be in the registry revocation set; + - when ``expected_iss`` is set, ``iss`` must match it (issuer pinning). """ claims = decode_and_verify(token, public_key) sub = claims.get("sub") @@ -73,10 +87,17 @@ def authorize_sender(token: str, claimed_from: str, *, public_key: str, raise AuthError("token has no 'sub' (canonical_id) claim") if sub != claimed_from: raise AuthError(f"token sub {sub!r} does not match from {claimed_from!r}") - if sub in revoked: - raise AuthError(f"canonical_id {sub!r} is revoked") - if expected_iss is not None and claims.get("iss") != expected_iss: - raise AuthError(f"token iss {claims.get('iss')!r} != expected {expected_iss!r}") + if _is_human_sub(sub): + if human_iss is not None and claims.get("iss") != human_iss: + raise AuthError( + f"human token iss {claims.get('iss')!r} != expected {human_iss!r}" + ) + # Humans are not in the registry: skip revocation check. + else: + if sub in revoked: + raise AuthError(f"canonical_id {sub!r} is revoked") + if expected_iss is not None and claims.get("iss") != expected_iss: + raise AuthError(f"token iss {claims.get('iss')!r} != expected {expected_iss!r}") return claims @@ -100,12 +121,14 @@ class RegistryVerifier: def __init__(self, *, pubkey_loader, revoked_loader, refresh_interval: float = 300.0, clock=time.time, - expected_iss: str | None = None): + expected_iss: str | None = None, + human_iss: str | None = None): self._pubkey_loader = pubkey_loader self._revoked_loader = revoked_loader self._refresh_interval = refresh_interval self._clock = clock self._expected_iss = expected_iss + self._human_iss = human_iss self._pubkey: str | None = None self._revoked: set[str] = set() self._revoked_fetched_at: float | None = None @@ -142,6 +165,7 @@ def authorize(self, token: str, claimed_from: str) -> dict: token, claimed_from, public_key=self._get_pubkey(), revoked=self._get_revoked(), expected_iss=self._expected_iss, + human_iss=self._human_iss, ) @@ -337,7 +361,8 @@ def grants_verifier_from_url(base_url: str, *, refresh_interval: float = 300.0, def verifier_from_url(base_url: str, *, refresh_interval: float = 300.0, opener=_http_get, clock=time.time, expected_iss: str | None = REGISTRY_ISS, - revoked_token: str | None = None) -> "RegistryVerifier": + revoked_token: str | None = None, + human_iss: str | None = CONTROLLER_ISS) -> "RegistryVerifier": """Build a :class:`RegistryVerifier` that fetches from a registry base URL. The HTTP getter is injectable (``opener``) so callers/tests can supply @@ -346,11 +371,16 @@ def verifier_from_url(base_url: str, *, refresh_interval: float = 300.0, ``revoked_token`` is the taOS local/admin token sent as a Bearer header on the revoked-feed poll (the #710 contract moved it behind admin auth). The pubkey endpoint stays public and is fetched without a token. + + ``human_iss`` is the expected ``iss`` for human principal assertions. When + set, any token whose ``sub`` starts with ``user-`` must carry this issuer. """ base = base_url.rstrip("/") return RegistryVerifier( pubkey_loader=lambda: parse_pubkey_response(opener(base + PUBKEY_PATH)), revoked_loader=lambda: parse_revoked_response( opener(base + REVOKED_PATH, token=revoked_token)), - refresh_interval=refresh_interval, clock=clock, expected_iss=expected_iss, + refresh_interval=refresh_interval, clock=clock, + expected_iss=expected_iss, + human_iss=human_iss, ) diff --git a/tests/test_http_server_trust_enforcement.py b/tests/test_http_server_trust_enforcement.py index e56f9de7..d7066ad3 100644 --- a/tests/test_http_server_trust_enforcement.py +++ b/tests/test_http_server_trust_enforcement.py @@ -105,6 +105,28 @@ def fake_opener(url, timeout=5.0, token=None): return verifier, gv +def _make_human_verifier(): + """Build (registry_verifier, grants_verifier) pair configured for humans.""" + def fake_opener(url, timeout=5.0, token=None): + if url.endswith(registry_auth.PUBKEY_PATH): + return json.dumps({"pubkey": PUB_PEM}) + if url.endswith(registry_auth.REVOKED_PATH): + return json.dumps([]) + if url.endswith(registry_auth.GRANTS_PATH): + return json.dumps({"grants": []}) + raise ValueError(f"unexpected url: {url}") + + verifier = registry_auth.verifier_from_url( + "http://reg.test", opener=fake_opener, + expected_iss=registry_auth.REGISTRY_ISS, + human_iss=registry_auth.CONTROLLER_ISS, + ) + gv = registry_auth.grants_verifier_from_url( + "http://reg.test", opener=fake_opener, + ) + return verifier, gv + + @pytest.fixture def warn_server(tmp_path, monkeypatch): """Server with verifiers wired in but a2a_auth_enforce NOT set (default=False). @@ -173,12 +195,11 @@ def test_warn_no_token_accepted(warn_server, caplog): def test_warn_invalid_token_accepted(warn_server, caplog): - """Invalid token: message accepted and warning logged in warn mode.""" + """Invalid token: message rejected with 403 regardless of mode.""" import logging with caplog.at_level(logging.WARNING, logger="taosmd.http_server"): status, body = _post_send(warn_server, "any-agent", "hello", token="not-a-jwt") - assert status == 200, body - assert any("verify-and-warn" in r.message for r in caplog.records) + assert status == 403, body def test_warn_valid_token_no_grant_accepted(warn_server, caplog): @@ -330,3 +351,84 @@ def test_api_still_up_when_dashboard_hidden(tmp_path, monkeypatch): finally: httpd.shutdown() httpd.service_loop.close() + + +# --------------------------------------------------------------------------- +# Human principal support (unified-chat slice 3) +# --------------------------------------------------------------------------- + +@pytest.fixture +def human_warn_server(tmp_path, monkeypatch): + """Server with human-capable verifier, a2a_auth_enforce NOT set (default). + + Auth failures are rejected 403 in both modes (human policy). + """ + data_dir = tmp_path / "data" + data_dir.mkdir() + monkeypatch.setattr(taosmd_api, "_stores_cache", {}) + + verifier, gv = _make_human_verifier() + httpd = http_server.make_server( + "127.0.0.1", 0, data_dir=str(data_dir), + verifier=verifier, grants_verifier=gv, + ) + httpd.service_loop.run(taosmd_api._ensure_stores(str(data_dir))) + host, port = httpd.server_address[:2] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield f"http://{host}:{port}" + finally: + httpd.shutdown() + httpd.service_loop.close() + + +def test_human_assertion_sub_mismatch_rejected_in_warn_mode(human_warn_server): + """Human assertion whose sub does not match from is rejected 403 even in + warn mode (fail-first: on master this would be 200).""" + token = pyjwt.encode( + {"sub": "user-123", "iss": registry_auth.CONTROLLER_ISS}, + PRIV_PEM, algorithm="EdDSA", + ) + status, body = _post_send(human_warn_server, "user-456", "hello", token=token) + assert status == 403 + + +def test_human_assertion_sub_match_accredited(human_warn_server): + """Valid human assertion with matching sub is accepted.""" + token = pyjwt.encode( + {"sub": "user-123", "iss": registry_auth.CONTROLLER_ISS}, + PRIV_PEM, algorithm="EdDSA", + ) + status, body = _post_send(human_warn_server, "user-123", "hello", token=token) + assert status == 200, body + + +def test_agent_jwt_claiming_human_id_rejected_in_warn_mode(human_warn_server): + """An agent JWT (iss=taos-registry) with a user-* sub is rejected 403.""" + token = pyjwt.encode( + {"sub": "user-123", "iss": registry_auth.REGISTRY_ISS}, + PRIV_PEM, algorithm="EdDSA", + ) + status, body = _post_send(human_warn_server, "user-123", "hello", token=token) + assert status == 403 + + +def test_human_assertion_claiming_agent_id_rejected_in_warn_mode(human_warn_server): + """A human assertion (iss=taos-controller) with an agent sub is rejected 403.""" + token = pyjwt.encode( + {"sub": "agent-1", "iss": registry_auth.CONTROLLER_ISS}, + PRIV_PEM, algorithm="EdDSA", + ) + status, body = _post_send(human_warn_server, "agent-1", "hello", token=token) + assert status == 403 + + +def test_human_missing_token_accepted_in_warn_mode(human_warn_server, caplog): + """Missing token for a human principal is accepted with warning in warn mode.""" + import logging + with caplog.at_level(logging.WARNING, logger="taosmd.http_server"): + status, body = _post_send(human_warn_server, "user-123", "hello") + assert status == 200, body + assert any("verify-and-warn" in r.message and "missing Bearer token" in r.message + for r in caplog.records) diff --git a/tests/test_registry_auth.py b/tests/test_registry_auth.py index e1aa0ba7..ee643045 100644 --- a/tests/test_registry_auth.py +++ b/tests/test_registry_auth.py @@ -402,3 +402,103 @@ def fake_urlopen(req, timeout=None): monkeypatch.setattr(registry_auth.urllib.request, "urlopen", fake_urlopen) registry_auth._http_get("http://reg/x") assert captured["auth"] is None + + +# --- Human principal support (unified-chat slice 3) ------------------------- + + +def test_authorize_human_sender_accepts_matching_sub(): + priv_pem, pub_pem = _keypair() + token = _sign(priv_pem, {"sub": "user-123", "iss": "taos-controller"}) + + claims = registry_auth.authorize_sender( + token, "user-123", public_key=pub_pem, revoked=set(), + human_iss="taos-controller", + ) + + assert claims["sub"] == "user-123" + + +def test_authorize_human_sender_rejects_sub_mismatch(): + priv_pem, pub_pem = _keypair() + token = _sign(priv_pem, {"sub": "user-123", "iss": "taos-controller"}) + + with pytest.raises(registry_auth.AuthError): + registry_auth.authorize_sender( + token, "user-456", public_key=pub_pem, revoked=set(), + human_iss="taos-controller", + ) + + +def test_authorize_human_sender_skips_revocation_check(): + priv_pem, pub_pem = _keypair() + token = _sign(priv_pem, {"sub": "user-123", "iss": "taos-controller"}) + + claims = registry_auth.authorize_sender( + token, "user-123", public_key=pub_pem, revoked={"user-123"}, + human_iss="taos-controller", + ) + + assert claims["sub"] == "user-123" + + +def test_authorize_human_sender_rejects_wrong_issuer(): + priv_pem, pub_pem = _keypair() + token = _sign(priv_pem, {"sub": "user-123", "iss": "wrong-issuer"}) + + with pytest.raises(registry_auth.AuthError): + registry_auth.authorize_sender( + token, "user-123", public_key=pub_pem, revoked=set(), + human_iss="taos-controller", + ) + + +def test_authorize_sender_rejects_agent_jwt_claiming_human_id(): + priv_pem, pub_pem = _keypair() + token = _sign(priv_pem, {"sub": "user-123", "iss": registry_auth.REGISTRY_ISS}) + + with pytest.raises(registry_auth.AuthError): + registry_auth.authorize_sender( + token, "user-123", public_key=pub_pem, revoked=set(), + human_iss="taos-controller", + ) + + +def test_authorize_sender_rejects_human_assertion_claiming_agent_id(): + priv_pem, pub_pem = _keypair() + token = _sign(priv_pem, {"sub": "agent-1", "iss": registry_auth.CONTROLLER_ISS}) + + with pytest.raises(registry_auth.AuthError): + registry_auth.authorize_sender( + token, "agent-1", public_key=pub_pem, revoked=set(), + expected_iss=registry_auth.REGISTRY_ISS, + ) + + +def test_decode_and_verify_is_single_entry_point_for_both_principal_types(): + import unittest.mock + + priv_pem, pub_pem = _keypair() + human_token = _sign(priv_pem, {"sub": "user-1", "iss": "taos-controller"}) + agent_token = _sign(priv_pem, {"sub": "agent-1", "iss": "taos-registry"}) + + calls = [] + orig = registry_auth.decode_and_verify + + def wrapper(token, public_key): + calls.append(token) + return orig(token, public_key) + + with unittest.mock.patch.object( + registry_auth, "decode_and_verify", wrapper + ): + registry_auth.authorize_sender( + human_token, "user-1", public_key=pub_pem, revoked=set(), + human_iss="taos-controller", + ) + registry_auth.authorize_sender( + agent_token, "agent-1", public_key=pub_pem, revoked=set(), + expected_iss="taos-registry", + ) + + assert calls == [human_token, agent_token]