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 Current verification result after adding the Foundation roadmap, config profile
scaffold, named client key storage, opt-in named auth, admin key endpoints, and 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 ```text
61 passed 66 passed
``` ```
## Known Constraints ## Known Constraints

View File

@ -210,6 +210,11 @@ Acceptance:
Goal: let Foundation keys be limited to approved roles, models, and operations. 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: Tasks:
- Add allowed models and allowed operations to named keys. - Add allowed models and allowed operations to named keys.

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from fnmatch import fnmatchcase
from fastapi import HTTPException, Request, status from fastapi import HTTPException, Request, status
@ -108,3 +109,61 @@ def require_admin_auth(request: Request) -> ClientContext:
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="admin access required", 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 import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import JSONResponse, StreamingResponse 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 .chat import ProxyError, _prepare_chat_upstream, proxy_chat_completion, proxy_embeddings, proxy_transcription, stream_chat_completion
from .config import ControlConfig, load_config from .config import ControlConfig, load_config
from .keys import generate_api_key, hash_api_key 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") route_metadata = _route_audit_metadata(reg, body.get("model"), kind="chat")
input_bytes = len(json.dumps(body, separators=(",", ":")).encode("utf-8")) input_bytes = len(json.dumps(body, separators=(",", ":")).encode("utf-8"))
try: try:
authorize_client_request(request, operation="chat", model=body.get("model"))
if body.get("stream"): if body.get("stream"):
# Resolve route eagerly so ProxyError is raised before streaming starts. # Resolve route eagerly so ProxyError is raised before streaming starts.
service, upstream_body = _prepare_chat_upstream(body, registry=reg) 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"}}, content={"error": {"message": str(exc), "type": "geniehive_error", "code": "chat_proxy_error"}},
headers={"X-Request-Id": request_id}, 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: except UpstreamError as exc:
status_code = exc.status_code or 502 status_code = exc.status_code or 502
_audit_request( _audit_request(
@ -356,6 +374,7 @@ def create_app(
route_metadata = _route_audit_metadata(reg, body.get("model"), kind="embeddings") route_metadata = _route_audit_metadata(reg, body.get("model"), kind="embeddings")
input_bytes = len(json.dumps(body, separators=(",", ":")).encode("utf-8")) input_bytes = len(json.dumps(body, separators=(",", ":")).encode("utf-8"))
try: try:
authorize_client_request(request, operation="embeddings", model=body.get("model"))
response = await proxy_embeddings( response = await proxy_embeddings(
body, body,
registry=reg, registry=reg,
@ -392,6 +411,23 @@ def create_app(
content={"error": {"message": str(exc), "type": "geniehive_error", "code": "embeddings_proxy_error"}}, content={"error": {"message": str(exc), "type": "geniehive_error", "code": "embeddings_proxy_error"}},
headers={"X-Request-Id": request_id}, 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: except UpstreamError as exc:
status_code = exc.status_code or 502 status_code = exc.status_code or 502
_audit_request( _audit_request(
@ -426,6 +462,7 @@ def create_app(
started_at = time.time() started_at = time.time()
route_metadata = _route_audit_metadata(request.app.state.registry, model, kind="transcription") route_metadata = _route_audit_metadata(request.app.state.registry, model, kind="transcription")
try: try:
authorize_client_request(request, operation="transcription", model=model)
response = await proxy_transcription( response = await proxy_transcription(
model=model, model=model,
file=file, file=file,
@ -465,6 +502,22 @@ def create_app(
content={"error": {"message": str(exc), "type": "geniehive_error", "code": "transcription_proxy_error"}}, content={"error": {"message": str(exc), "type": "geniehive_error", "code": "transcription_proxy_error"}},
headers={"X-Request-Id": request_id}, 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: except UpstreamError as exc:
status_code = exc.status_code or 502 status_code = exc.status_code or 502
_audit_request( _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