Skip to content

Commit cb6eb1d

Browse files
committed
feat: add JWT auth for registry mutations
1 parent df8c61c commit cb6eb1d

7 files changed

Lines changed: 136 additions & 15 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ MVP Python implementation of an open control plane for agent tool governance. It
1515
- Metrics and events: Prometheus metrics at `/metrics` and SIEM-friendly JSON events for every tool call (decision, reason, redacted request/response).
1616
- Registry/RBAC: in-memory registry tracks tools/agents; tools can declare `allowed_roles` and the SDK enforces actor roles from context (`roles`/`actor_roles`) before OPA.
1717
- Registry API: list tools at `/registry/tools` and agents at `/registry/agents`.
18-
- Registry persistence: registry saved to `registry.json` by default (override with `OCPA_REGISTRY_PATH`), loaded on startup; create via `/registry/tools` and `/registry/agents` (validates side_effect_level and tool references). Mutations require bearer token `Authorization: Bearer $OCPA_ADMIN_TOKEN` and roles header `X-OCPA-Roles` containing `OCPA_ADMIN_ROLE`. Server uses FastAPI lifespan hooks for startup.
18+
- Registry persistence: registry saved to `registry.json` by default (override with `OCPA_REGISTRY_PATH`), loaded on startup; create via `/registry/tools` and `/registry/agents` (validates side_effect_level and tool references). Mutations require JWT bearer token (HS256 via `OCPA_JWT_SECRET`) and roles header `X-OCPA-Roles` containing `OCPA_ADMIN_ROLE`. Server uses FastAPI lifespan hooks for startup.
1919
- Policy RBAC: Rego now checks `allowed_roles` against actor `roles` in context; read-only/low/high rules still apply.
2020

2121
## Getting Started

example_app/api.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pydantic import BaseModel, Field
99

1010
from ocpa import AgentMetadata, ToolMetadata
11+
from ocpa.auth import verify_bearer_token
1112
from ocpa.sdk import PolicyDenied, invoke_tool, write_abom
1213
from ocpa.registry import registry
1314
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
@@ -126,15 +127,5 @@ def create_agent(
126127

127128

128129
def _require_admin(auth_header: Optional[str], roles_header: Optional[str]) -> None:
129-
if not auth_header or not auth_header.startswith("Bearer "):
130-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing bearer token")
131-
token = auth_header.removeprefix("Bearer ").strip()
132-
expected_token = os.environ.get("OCPA_ADMIN_TOKEN")
133130
required_role = os.environ.get("OCPA_ADMIN_ROLE", "admin")
134-
if not expected_token:
135-
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="admin token not configured")
136-
if token != expected_token:
137-
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="invalid admin token")
138-
roles_list = [role.strip() for role in (roles_header or "").split(",") if role.strip()]
139-
if required_role and required_role not in roles_list:
140-
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="missing required admin role")
131+
verify_bearer_token(auth_header, required_role, role_claim=os.environ.get("OCPA_ROLE_CLAIM", "roles"))

ocpa/auth.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import List, Optional
5+
6+
import jwt
7+
from fastapi import HTTPException, status
8+
9+
10+
def verify_bearer_token(
11+
auth_header: Optional[str],
12+
required_role: Optional[str],
13+
role_claim: str = "roles",
14+
) -> None:
15+
if not auth_header or not auth_header.startswith("Bearer "):
16+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing bearer token")
17+
token = auth_header.removeprefix("Bearer ").strip()
18+
secret = os.environ.get("OCPA_JWT_SECRET")
19+
if not secret:
20+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="jwt secret not configured")
21+
audience = os.environ.get("OCPA_JWT_AUDIENCE")
22+
issuer = os.environ.get("OCPA_JWT_ISSUER")
23+
options = {"verify_aud": bool(audience)}
24+
try:
25+
payload = jwt.decode(
26+
token,
27+
secret,
28+
algorithms=["HS256"],
29+
audience=audience,
30+
issuer=issuer,
31+
options=options,
32+
)
33+
except jwt.ExpiredSignatureError:
34+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token expired") from None
35+
except jwt.InvalidTokenError as exc:
36+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"invalid token: {exc}") from None
37+
if required_role:
38+
roles = payload.get(role_claim, [])
39+
if isinstance(roles, str):
40+
roles = [roles]
41+
if required_role not in roles:
42+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="missing required admin role")

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ dependencies = [
1919
"uvicorn[standard]>=0.23.0",
2020
"prometheus-client>=0.20.0",
2121
"typer>=0.12.0",
22-
"httpx>=0.27.0"
22+
"httpx>=0.27.0",
23+
"PyJWT>=2.8.0"
2324
]
2425

2526
[project.optional-dependencies]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ uvicorn[standard]>=0.23.0
1010
prometheus-client>=0.20.0
1111
typer>=0.12.0
1212
httpx>=0.27.0
13+
PyJWT>=2.8.0

tests/test_auth.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import os
2+
3+
import pytest
4+
from fastapi import HTTPException
5+
6+
from ocpa.auth import verify_bearer_token
7+
8+
9+
def test_verify_bearer_token_with_role(monkeypatch):
10+
monkeypatch.setenv("OCPA_JWT_SECRET", "secret")
11+
monkeypatch.setenv("OCPA_ADMIN_ROLE", "admin")
12+
import jwt
13+
14+
token = jwt.encode({"roles": ["admin"]}, "secret", algorithm="HS256")
15+
auth_header = f"Bearer {token}"
16+
17+
verify_bearer_token(auth_header, "admin")
18+
19+
20+
def test_verify_bearer_token_missing(monkeypatch):
21+
monkeypatch.setenv("OCPA_JWT_SECRET", "secret")
22+
with pytest.raises(HTTPException):
23+
verify_bearer_token(None, "admin")
24+
25+
26+
def test_verify_bearer_token_expired(monkeypatch):
27+
monkeypatch.setenv("OCPA_JWT_SECRET", "secret")
28+
import jwt, datetime
29+
30+
token = jwt.encode(
31+
{"roles": ["admin"], "exp": datetime.datetime.utcnow() - datetime.timedelta(seconds=1)},
32+
"secret",
33+
algorithm="HS256",
34+
)
35+
auth_header = f"Bearer {token}"
36+
with pytest.raises(HTTPException):
37+
verify_bearer_token(auth_header, "admin")
38+
39+
40+
def test_verify_bearer_token_with_audience_and_issuer(monkeypatch):
41+
monkeypatch.setenv("OCPA_JWT_SECRET", "secret")
42+
monkeypatch.setenv("OCPA_JWT_AUDIENCE", "ocpa")
43+
monkeypatch.setenv("OCPA_JWT_ISSUER", "https://issuer.example.com")
44+
import jwt
45+
46+
token = jwt.encode(
47+
{"roles": ["admin"], "aud": "ocpa", "iss": "https://issuer.example.com"},
48+
"secret",
49+
algorithm="HS256",
50+
)
51+
auth_header = f"Bearer {token}"
52+
verify_bearer_token(auth_header, "admin")
53+
54+
55+
def test_verify_bearer_token_missing_role(monkeypatch):
56+
monkeypatch.setenv("OCPA_JWT_SECRET", "secret")
57+
import jwt
58+
59+
token = jwt.encode({"roles": ["user"]}, "secret", algorithm="HS256")
60+
auth_header = f"Bearer {token}"
61+
with pytest.raises(HTTPException):
62+
verify_bearer_token(auth_header, "admin")
63+
64+
65+
def test_verify_bearer_token_invalid_token(monkeypatch):
66+
monkeypatch.setenv("OCPA_JWT_SECRET", "secret")
67+
# malformed token
68+
with pytest.raises(HTTPException):
69+
verify_bearer_token("Bearer not-a-jwt", "admin")
70+
71+
72+
def test_verify_bearer_token_missing_secret(monkeypatch):
73+
monkeypatch.delenv("OCPA_JWT_SECRET", raising=False)
74+
with pytest.raises(HTTPException):
75+
verify_bearer_token("Bearer abc", "admin")
76+
77+
78+
def test_verify_bearer_token_role_as_string(monkeypatch):
79+
monkeypatch.setenv("OCPA_JWT_SECRET", "secret")
80+
import jwt
81+
82+
token = jwt.encode({"roles": "admin"}, "secret", algorithm="HS256")
83+
auth_header = f"Bearer {token}"
84+
verify_bearer_token(auth_header, "admin")

tests/test_registry_api.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66

77
def test_registry_api_create_tool_and_agent(tmp_path, monkeypatch):
88
monkeypatch.setenv("OCPA_REGISTRY_PATH", str(tmp_path / "registry.json"))
9-
monkeypatch.setenv("OCPA_ADMIN_TOKEN", "secret")
9+
monkeypatch.setenv("OCPA_JWT_SECRET", "secret")
1010
monkeypatch.setenv("OCPA_ADMIN_ROLE", "admin")
11+
import jwt
1112
import ocpa.registry as reg_module
1213

1314
importlib.reload(reg_module)
@@ -17,7 +18,8 @@ def test_registry_api_create_tool_and_agent(tmp_path, monkeypatch):
1718

1819
client = TestClient(api.app)
1920

20-
headers = {"Authorization": "Bearer secret", "X-OCPA-Roles": "admin"}
21+
token = jwt.encode({"roles": ["admin"]}, "secret", algorithm="HS256")
22+
headers = {"Authorization": f"Bearer {token}", "X-OCPA-Roles": "admin"}
2123

2224
resp = client.post(
2325
"/registry/tools",

0 commit comments

Comments
 (0)