Enforce named key model and operation scopes
This commit is contained in:
parent
9a1a6f49af
commit
960aa11d93
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue