Enforce named key model and operation scopes

This commit is contained in:
welsberr 2026-05-01 11:01:10 -04:00
parent 9a1a6f49af
commit 960aa11d93
5 changed files with 322 additions and 3 deletions

View File

@ -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

View File

@ -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.

View File

@ -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)

View File

@ -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(

View File

@ -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