From 960aa11d93a7b6507ed472752c0beeb64fba6690 Mon Sep 17 00:00:00 2001 From: welsberr Date: Fri, 1 May 2026 11:01:10 -0400 Subject: [PATCH] Enforce named key model and operation scopes --- docs/foundation_gateway_baseline.md | 4 +- docs/foundation_gateway_roadmap.md | 5 + src/geniehive_control/auth.py | 59 ++++++++ src/geniehive_control/main.py | 55 +++++++- tests/test_control_authorization.py | 202 ++++++++++++++++++++++++++++ 5 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 tests/test_control_authorization.py diff --git a/docs/foundation_gateway_baseline.md b/docs/foundation_gateway_baseline.md index 6e41628..5e8ac49 100644 --- a/docs/foundation_gateway_baseline.md +++ b/docs/foundation_gateway_baseline.md @@ -57,10 +57,10 @@ Expected current result at baseline: all tests pass. Current verification result after adding the Foundation roadmap, config profile scaffold, named client key storage, opt-in named auth, admin key endpoints, and -request audit logging: +request audit logging, and named-key model/operation authorization: ```text -61 passed +66 passed ``` ## Known Constraints diff --git a/docs/foundation_gateway_roadmap.md b/docs/foundation_gateway_roadmap.md index 61107ce..cf04b05 100644 --- a/docs/foundation_gateway_roadmap.md +++ b/docs/foundation_gateway_roadmap.md @@ -210,6 +210,11 @@ Acceptance: Goal: let Foundation keys be limited to approved roles, models, and operations. +Status: implemented for named client keys. Enforcement is controlled by +`authorization.enforce_model_allowlists` and +`authorization.enforce_operation_allowlists`. Static and development auth retain +casual-deployment behavior. + Tasks: - Add allowed models and allowed operations to named keys. diff --git a/src/geniehive_control/auth.py b/src/geniehive_control/auth.py index 642e468..981b0be 100644 --- a/src/geniehive_control/auth.py +++ b/src/geniehive_control/auth.py @@ -2,6 +2,7 @@ from __future__ import annotations import os from dataclasses import dataclass +from fnmatch import fnmatchcase from fastapi import HTTPException, Request, status @@ -108,3 +109,61 @@ def require_admin_auth(request: Request) -> ClientContext: status_code=status.HTTP_403_FORBIDDEN, detail="admin access required", ) + + +def authorize_client_request(request: Request, *, operation: str, model: str | None) -> None: + cfg = request.app.state.cfg + context = getattr(request.state, "client_context", None) + if context is None: + return + # Static and development auth preserve casual-deployment behavior. Foundation + # scoped access is enforced for named keys only. + if context.auth_kind != "named": + return + if cfg.authorization.enforce_operation_allowlists: + _authorize_value( + value=operation, + allowed=context.allowed_operations, + empty_means_no_access=cfg.authorization.empty_allowlist_means_no_access, + denied_detail=f"operation '{operation}' is not allowed for this key", + ) + if cfg.authorization.enforce_model_allowlists: + if not model: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="model is required for model authorization", + ) + _authorize_value( + value=model, + allowed=context.allowed_models, + empty_means_no_access=cfg.authorization.empty_allowlist_means_no_access, + denied_detail=f"model '{model}' is not allowed for this key", + ) + + +def _authorize_value( + *, + value: str, + allowed: tuple[str, ...], + empty_means_no_access: bool, + denied_detail: str, +) -> None: + if not allowed: + if empty_means_no_access: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=denied_detail, + ) + return + if any(_allow_pattern_matches(pattern, value) for pattern in allowed): + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=denied_detail, + ) + + +def _allow_pattern_matches(pattern: str, value: str) -> bool: + if pattern.startswith("role/"): + pattern = pattern.removeprefix("role/") + return fnmatchcase(value, pattern) diff --git a/src/geniehive_control/main.py b/src/geniehive_control/main.py index 9501940..7f17c1f 100644 --- a/src/geniehive_control/main.py +++ b/src/geniehive_control/main.py @@ -11,7 +11,7 @@ from pathlib import Path from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status from fastapi.responses import JSONResponse, StreamingResponse -from .auth import require_admin_auth, require_client_auth, require_node_auth +from .auth import authorize_client_request, require_admin_auth, require_client_auth, require_node_auth from .chat import ProxyError, _prepare_chat_upstream, proxy_chat_completion, proxy_embeddings, proxy_transcription, stream_chat_completion from .config import ControlConfig, load_config from .keys import generate_api_key, hash_api_key @@ -278,6 +278,7 @@ def create_app( route_metadata = _route_audit_metadata(reg, body.get("model"), kind="chat") input_bytes = len(json.dumps(body, separators=(",", ":")).encode("utf-8")) try: + authorize_client_request(request, operation="chat", model=body.get("model")) if body.get("stream"): # Resolve route eagerly so ProxyError is raised before streaming starts. service, upstream_body = _prepare_chat_upstream(body, registry=reg) @@ -328,6 +329,23 @@ def create_app( content={"error": {"message": str(exc), "type": "geniehive_error", "code": "chat_proxy_error"}}, headers={"X-Request-Id": request_id}, ) + except HTTPException as exc: + _audit_request( + request, + request_id=request_id, + operation="chat", + route_metadata=route_metadata, + started_at=started_at, + status_code=exc.status_code, + success=False, + error_type="authorization_error", + input_bytes=input_bytes, + ) + return JSONResponse( + status_code=exc.status_code, + content={"error": {"message": str(exc.detail), "type": "geniehive_error", "code": "authorization_error"}}, + headers={"X-Request-Id": request_id}, + ) except UpstreamError as exc: status_code = exc.status_code or 502 _audit_request( @@ -356,6 +374,7 @@ def create_app( route_metadata = _route_audit_metadata(reg, body.get("model"), kind="embeddings") input_bytes = len(json.dumps(body, separators=(",", ":")).encode("utf-8")) try: + authorize_client_request(request, operation="embeddings", model=body.get("model")) response = await proxy_embeddings( body, registry=reg, @@ -392,6 +411,23 @@ def create_app( content={"error": {"message": str(exc), "type": "geniehive_error", "code": "embeddings_proxy_error"}}, headers={"X-Request-Id": request_id}, ) + except HTTPException as exc: + _audit_request( + request, + request_id=request_id, + operation="embeddings", + route_metadata=route_metadata, + started_at=started_at, + status_code=exc.status_code, + success=False, + error_type="authorization_error", + input_bytes=input_bytes, + ) + return JSONResponse( + status_code=exc.status_code, + content={"error": {"message": str(exc.detail), "type": "geniehive_error", "code": "authorization_error"}}, + headers={"X-Request-Id": request_id}, + ) except UpstreamError as exc: status_code = exc.status_code or 502 _audit_request( @@ -426,6 +462,7 @@ def create_app( started_at = time.time() route_metadata = _route_audit_metadata(request.app.state.registry, model, kind="transcription") try: + authorize_client_request(request, operation="transcription", model=model) response = await proxy_transcription( model=model, file=file, @@ -465,6 +502,22 @@ def create_app( content={"error": {"message": str(exc), "type": "geniehive_error", "code": "transcription_proxy_error"}}, headers={"X-Request-Id": request_id}, ) + except HTTPException as exc: + _audit_request( + request, + request_id=request_id, + operation="transcription", + route_metadata=route_metadata, + started_at=started_at, + status_code=exc.status_code, + success=False, + error_type="authorization_error", + ) + return JSONResponse( + status_code=exc.status_code, + content={"error": {"message": str(exc.detail), "type": "geniehive_error", "code": "authorization_error"}}, + headers={"X-Request-Id": request_id}, + ) except UpstreamError as exc: status_code = exc.status_code or 502 _audit_request( diff --git a/tests/test_control_authorization.py b/tests/test_control_authorization.py new file mode 100644 index 0000000..b4e2d5c --- /dev/null +++ b/tests/test_control_authorization.py @@ -0,0 +1,202 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from geniehive_control.keys import hash_api_key +from geniehive_control.main import create_app +from geniehive_control.models import HostRegistration, RegisteredService +from geniehive_control.upstream import UpstreamClient + + +class _FakeResponse: + def __init__(self, payload: dict, status_code: int = 200) -> None: + self._payload = payload + self.status_code = status_code + self.text = str(payload) + + def json(self) -> dict: + return self._payload + + +class _FakePoster: + async def post(self, url: str, *, json: dict, headers: dict[str, str] | None = None) -> _FakeResponse: + if url.endswith("/v1/embeddings"): + return _FakeResponse({"object": "list", "data": [{"embedding": [0.1, 0.2]}]}) + return _FakeResponse({"object": "chat.completion", "model": json["model"], "choices": []}) + + +def _write_config(tmp_path: Path, *, static_key: bool = False) -> Path: + config_path = tmp_path / "control.yaml" + static_auth = """ + client_api_keys: + - static-key +""" if static_key else "" + config_path.write_text( + f""" +auth: +{static_auth} enable_named_client_keys: true +authorization: + enforce_model_allowlists: true + enforce_operation_allowlists: true + empty_allowlist_means_no_access: true +storage: + sqlite_path: "{tmp_path / 'geniehive.sqlite3'}" +""" + ) + return config_path + + +def _register_services(app) -> None: + app.state.registry.register_host( + HostRegistration( + host_id="atlas-01", + address="127.0.0.1", + services=[ + RegisteredService( + service_id="atlas-01/chat/qwen", + host_id="atlas-01", + kind="chat", + endpoint="http://127.0.0.1:18091", + assets=[{"asset_id": "archive_migrator", "loaded": True}], + state={"health": "healthy", "accept_requests": True}, + observed={"p50_latency_ms": 100}, + ), + RegisteredService( + service_id="atlas-01/embeddings/bge", + host_id="atlas-01", + kind="embeddings", + endpoint="http://127.0.0.1:18092", + assets=[{"asset_id": "bge-small", "loaded": True}], + state={"health": "healthy", "accept_requests": True}, + observed={"p50_latency_ms": 100}, + ), + ], + ) + ) + + +def _create_named_key( + app, + raw_key: str, + *, + allowed_models: list[str], + allowed_operations: list[str], +) -> None: + app.state.registry.create_client_key( + key_id=f"ck_{raw_key}", + key_hash=hash_api_key(raw_key, secret="test-secret"), + display_name="Scoped User", + principal_type="person", + principal_ref="scoped-user", + role="developer", + allowed_models=allowed_models, + allowed_operations=allowed_operations, + ) + + +def test_named_key_allows_scoped_chat_request(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GENIEHIVE_KEY_HASH_SECRET", "test-secret") + app = create_app(_write_config(tmp_path), upstream_client=UpstreamClient(client=_FakePoster())) + _register_services(app) + _create_named_key( + app, + "gh_allowed", + allowed_models=["archive_migrator"], + allowed_operations=["chat"], + ) + client = TestClient(app) + + response = client.post( + "/v1/chat/completions", + headers={"X-Api-Key": "gh_allowed"}, + json={"model": "archive_migrator", "messages": [{"role": "user", "content": "hello"}]}, + ) + + assert response.status_code == 200 + + +def test_named_key_denies_unlisted_operation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GENIEHIVE_KEY_HASH_SECRET", "test-secret") + app = create_app(_write_config(tmp_path), upstream_client=UpstreamClient(client=_FakePoster())) + _register_services(app) + _create_named_key( + app, + "gh_chat_only", + allowed_models=["*"], + allowed_operations=["chat"], + ) + client = TestClient(app) + + response = client.post( + "/v1/embeddings", + headers={"X-Api-Key": "gh_chat_only"}, + json={"model": "bge-small", "input": "hello"}, + ) + + assert response.status_code == 403 + assert response.json()["error"]["code"] == "authorization_error" + + +def test_named_key_denies_unlisted_model(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GENIEHIVE_KEY_HASH_SECRET", "test-secret") + app = create_app(_write_config(tmp_path), upstream_client=UpstreamClient(client=_FakePoster())) + _register_services(app) + _create_named_key( + app, + "gh_archive_only", + allowed_models=["archive_migrator"], + allowed_operations=["chat"], + ) + client = TestClient(app) + + response = client.post( + "/v1/chat/completions", + headers={"X-Api-Key": "gh_archive_only"}, + json={"model": "other_role", "messages": [{"role": "user", "content": "hello"}]}, + ) + + assert response.status_code == 403 + assert response.json()["error"]["code"] == "authorization_error" + + +def test_empty_allowlist_denies_when_configured(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GENIEHIVE_KEY_HASH_SECRET", "test-secret") + app = create_app(_write_config(tmp_path), upstream_client=UpstreamClient(client=_FakePoster())) + _register_services(app) + _create_named_key( + app, + "gh_empty", + allowed_models=[], + allowed_operations=[], + ) + client = TestClient(app) + + response = client.post( + "/v1/chat/completions", + headers={"X-Api-Key": "gh_empty"}, + json={"model": "archive_migrator", "messages": [{"role": "user", "content": "hello"}]}, + ) + + assert response.status_code == 403 + + +def test_static_key_is_not_restricted_by_named_key_allowlists( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GENIEHIVE_KEY_HASH_SECRET", "test-secret") + app = create_app( + _write_config(tmp_path, static_key=True), + upstream_client=UpstreamClient(client=_FakePoster()), + ) + _register_services(app) + client = TestClient(app) + + response = client.post( + "/v1/embeddings", + headers={"X-Api-Key": "static-key"}, + json={"model": "bge-small", "input": "hello"}, + ) + + assert response.status_code == 200