agentplane-registry 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentplane_registry-0.0.1/.gitignore +34 -0
- agentplane_registry-0.0.1/PKG-INFO +43 -0
- agentplane_registry-0.0.1/README.md +18 -0
- agentplane_registry-0.0.1/pyproject.toml +36 -0
- agentplane_registry-0.0.1/src/agentplane_registry/__init__.py +8 -0
- agentplane_registry-0.0.1/src/agentplane_registry/api.py +252 -0
- agentplane_registry-0.0.1/src/agentplane_registry/app.py +60 -0
- agentplane_registry-0.0.1/src/agentplane_registry/auth.py +139 -0
- agentplane_registry-0.0.1/src/agentplane_registry/db.py +137 -0
- agentplane_registry-0.0.1/src/agentplane_registry/embeddings.py +42 -0
- agentplane_registry-0.0.1/src/agentplane_registry/health.py +158 -0
- agentplane_registry-0.0.1/src/agentplane_registry/search.py +159 -0
- agentplane_registry-0.0.1/src/agentplane_registry/settings.py +29 -0
- agentplane_registry-0.0.1/src/agentplane_registry/tracing.py +52 -0
- agentplane_registry-0.0.1/src/agentplane_registry/urlcheck.py +32 -0
- agentplane_registry-0.0.1/tests/conftest.py +73 -0
- agentplane_registry-0.0.1/tests/test_api.py +104 -0
- agentplane_registry-0.0.1/tests/test_auth_oidc.py +134 -0
- agentplane_registry-0.0.1/tests/test_health.py +125 -0
- agentplane_registry-0.0.1/tests/test_semantic.py +85 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# internal working documents — not part of the public repo
|
|
2
|
+
CLAUDE.md
|
|
3
|
+
SPEC.md
|
|
4
|
+
.claude/
|
|
5
|
+
|
|
6
|
+
# python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.venv/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
|
|
14
|
+
# tooling caches
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.coverage
|
|
19
|
+
htmlcov/
|
|
20
|
+
|
|
21
|
+
# local databases and state
|
|
22
|
+
*.db
|
|
23
|
+
registry.db
|
|
24
|
+
runtime.db
|
|
25
|
+
|
|
26
|
+
# environments / secrets
|
|
27
|
+
.env
|
|
28
|
+
.env.*
|
|
29
|
+
|
|
30
|
+
# editors / OS
|
|
31
|
+
.vscode/
|
|
32
|
+
.idea/
|
|
33
|
+
.DS_Store
|
|
34
|
+
Thumbs.db
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentplane-registry
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: agentplane discovery service (standalone-capable)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: agentplane-core<0.1.0,>=0.0.1
|
|
8
|
+
Requires-Dist: aiosqlite>=0.20
|
|
9
|
+
Requires-Dist: fastapi>=0.115
|
|
10
|
+
Requires-Dist: fastmcp==3.4.4
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: opentelemetry-api>=1.27
|
|
13
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27
|
|
14
|
+
Requires-Dist: opentelemetry-sdk>=1.27
|
|
15
|
+
Requires-Dist: pydantic-settings>=2.4
|
|
16
|
+
Requires-Dist: pyjwt[crypto]>=2.9
|
|
17
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.30
|
|
18
|
+
Requires-Dist: uvicorn>=0.30
|
|
19
|
+
Provides-Extra: postgres
|
|
20
|
+
Requires-Dist: asyncpg>=0.29; extra == 'postgres'
|
|
21
|
+
Requires-Dist: pgvector>=0.3; extra == 'postgres'
|
|
22
|
+
Provides-Extra: semantic
|
|
23
|
+
Requires-Dist: numpy>=1.26; extra == 'semantic'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# agentplane-registry
|
|
27
|
+
|
|
28
|
+
Standalone-capable discovery service for A2A agents and MCP servers:
|
|
29
|
+
register, tag, search (text + optional semantic), health-check.
|
|
30
|
+
|
|
31
|
+
- Runs alone: SQLite by default, auth optional (generic OIDC, any issuer),
|
|
32
|
+
no calls to other agentplane services.
|
|
33
|
+
- Extras: `agentplane-registry[semantic]` (numpy + embeddings via an
|
|
34
|
+
OpenAI-compatible endpoint), `[postgres]` (asyncpg). Without extras the
|
|
35
|
+
service degrades gracefully and announces features via `GET /capabilities`.
|
|
36
|
+
- Stores **gateway URLs only**; private/internal hosts are rejected unless
|
|
37
|
+
`AGENTPLANE_REGISTRY_ALLOW_PRIVATE_URLS=true`.
|
|
38
|
+
|
|
39
|
+
Run it:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
uvicorn --factory agentplane_registry.app:create_app --port 8100
|
|
43
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# agentplane-registry
|
|
2
|
+
|
|
3
|
+
Standalone-capable discovery service for A2A agents and MCP servers:
|
|
4
|
+
register, tag, search (text + optional semantic), health-check.
|
|
5
|
+
|
|
6
|
+
- Runs alone: SQLite by default, auth optional (generic OIDC, any issuer),
|
|
7
|
+
no calls to other agentplane services.
|
|
8
|
+
- Extras: `agentplane-registry[semantic]` (numpy + embeddings via an
|
|
9
|
+
OpenAI-compatible endpoint), `[postgres]` (asyncpg). Without extras the
|
|
10
|
+
service degrades gracefully and announces features via `GET /capabilities`.
|
|
11
|
+
- Stores **gateway URLs only**; private/internal hosts are rejected unless
|
|
12
|
+
`AGENTPLANE_REGISTRY_ALLOW_PRIVATE_URLS=true`.
|
|
13
|
+
|
|
14
|
+
Run it:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
uvicorn --factory agentplane_registry.app:create_app --port 8100
|
|
18
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agentplane-registry"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "agentplane discovery service (standalone-capable)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"agentplane-core>=0.0.1,<0.1.0",
|
|
10
|
+
"fastapi>=0.115",
|
|
11
|
+
"sqlalchemy[asyncio]>=2.0.30",
|
|
12
|
+
"aiosqlite>=0.20",
|
|
13
|
+
"httpx>=0.27",
|
|
14
|
+
"pydantic-settings>=2.4",
|
|
15
|
+
"pyjwt[crypto]>=2.9",
|
|
16
|
+
# exact pin — fastmcp ships breaking changes in minor releases (CLAUDE.md)
|
|
17
|
+
"fastmcp==3.4.4",
|
|
18
|
+
"opentelemetry-api>=1.27",
|
|
19
|
+
"opentelemetry-sdk>=1.27",
|
|
20
|
+
"opentelemetry-exporter-otlp-proto-http>=1.27",
|
|
21
|
+
"uvicorn>=0.30",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
semantic = ["numpy>=1.26"]
|
|
26
|
+
postgres = ["asyncpg>=0.29", "pgvector>=0.3"]
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/agentplane_registry"]
|
|
34
|
+
|
|
35
|
+
[tool.uv.sources]
|
|
36
|
+
agentplane-core = { workspace = true }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""agentplane-registry: standalone-capable discovery service."""
|
|
2
|
+
|
|
3
|
+
from agentplane_registry.app import create_app
|
|
4
|
+
from agentplane_registry.settings import REGISTRY_VERSION, RegistrySettings
|
|
5
|
+
|
|
6
|
+
__version__ = REGISTRY_VERSION
|
|
7
|
+
|
|
8
|
+
__all__ = ["REGISTRY_VERSION", "RegistrySettings", "create_app"]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Registry REST API (SPEC §5.1), prefix ``/api/v1``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Annotated, Literal
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
|
12
|
+
from sqlalchemy import func, select
|
|
13
|
+
from sqlalchemy.exc import IntegrityError
|
|
14
|
+
|
|
15
|
+
from agentplane_core import (
|
|
16
|
+
Capabilities,
|
|
17
|
+
EntryKind,
|
|
18
|
+
HealthStatus,
|
|
19
|
+
Page,
|
|
20
|
+
RegistryEntry,
|
|
21
|
+
RegistryEntryCreate,
|
|
22
|
+
RegistryEntryPatch,
|
|
23
|
+
SearchQuery,
|
|
24
|
+
serialize_card,
|
|
25
|
+
)
|
|
26
|
+
from agentplane_registry.auth import Principal
|
|
27
|
+
from agentplane_registry.db import Database, EntryRow, row_to_entry
|
|
28
|
+
from agentplane_registry.search import RegistrySearch
|
|
29
|
+
from agentplane_registry.settings import REGISTRY_VERSION, RegistrySettings
|
|
30
|
+
from agentplane_registry.urlcheck import is_private_url
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RegistryState:
|
|
35
|
+
"""Shared service state stored on the FastAPI app."""
|
|
36
|
+
|
|
37
|
+
db: Database
|
|
38
|
+
settings: RegistrySettings
|
|
39
|
+
search: RegistrySearch
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _state(request: Request) -> RegistryState:
|
|
43
|
+
state: RegistryState = request.app.state.registry
|
|
44
|
+
return state
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _principal(request: Request) -> Principal:
|
|
48
|
+
principal: Principal = await request.app.state.authenticator.authenticate(request)
|
|
49
|
+
return principal
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
State = Annotated[RegistryState, Depends(_state)]
|
|
53
|
+
Caller = Annotated[Principal, Depends(_principal)]
|
|
54
|
+
|
|
55
|
+
router = APIRouter(prefix="/api/v1")
|
|
56
|
+
health_router = APIRouter()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _visible(row: EntryRow, caller: Principal, auth_mode: str) -> bool:
|
|
60
|
+
if auth_mode == "none" or caller.is_admin:
|
|
61
|
+
return True
|
|
62
|
+
return row.owner == caller.sub
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _check_url(url: str, settings: RegistrySettings) -> None:
|
|
66
|
+
if not settings.allow_private_urls and is_private_url(url):
|
|
67
|
+
raise HTTPException(
|
|
68
|
+
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
69
|
+
detail="url must be a public gateway URL (set ALLOW_PRIVATE_URLS to override)",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.post("/agents", status_code=status.HTTP_201_CREATED, response_model=RegistryEntry)
|
|
74
|
+
async def register_entry(body: RegistryEntryCreate, state: State, caller: Caller) -> RegistryEntry:
|
|
75
|
+
_check_url(body.url, state.settings)
|
|
76
|
+
now = datetime.now(UTC)
|
|
77
|
+
row = EntryRow(
|
|
78
|
+
id=str(uuid.uuid4()),
|
|
79
|
+
kind=body.kind,
|
|
80
|
+
name=body.card.name,
|
|
81
|
+
owner=caller.sub,
|
|
82
|
+
url=body.url,
|
|
83
|
+
card_json=_dump_card(body),
|
|
84
|
+
tags_json=list(body.tags),
|
|
85
|
+
status="starting",
|
|
86
|
+
created_at=now,
|
|
87
|
+
updated_at=now,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
async with state.db.session() as session, session.begin():
|
|
91
|
+
session.add(row)
|
|
92
|
+
except IntegrityError:
|
|
93
|
+
raise HTTPException(
|
|
94
|
+
status.HTTP_409_CONFLICT,
|
|
95
|
+
detail=f"entry named {body.card.name!r} already exists for this owner",
|
|
96
|
+
) from None
|
|
97
|
+
entry = row_to_entry(row)
|
|
98
|
+
await state.search.index(entry)
|
|
99
|
+
return entry
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _dump_card(body: RegistryEntryCreate | RegistryEntryPatch) -> str:
|
|
103
|
+
return json.dumps(serialize_card(body.card), sort_keys=True)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.get("/agents", response_model=Page)
|
|
107
|
+
async def list_entries(
|
|
108
|
+
state: State,
|
|
109
|
+
caller: Caller,
|
|
110
|
+
kind: Annotated[EntryKind | None, Query()] = None,
|
|
111
|
+
status_filter: Annotated[HealthStatus | None, Query(alias="status")] = None,
|
|
112
|
+
tags: Annotated[str, Query(description="comma-separated, AND semantics")] = "",
|
|
113
|
+
owner: Annotated[Literal["me", "all"], Query()] = "me",
|
|
114
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
115
|
+
offset: Annotated[int, Query(ge=0)] = 0,
|
|
116
|
+
) -> Page:
|
|
117
|
+
if owner == "all" and state.settings.auth_mode == "oidc" and not caller.is_admin:
|
|
118
|
+
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="owner=all requires admin")
|
|
119
|
+
stmt = select(EntryRow).order_by(EntryRow.created_at.desc())
|
|
120
|
+
count_stmt = select(func.count()).select_from(EntryRow)
|
|
121
|
+
if state.settings.auth_mode == "oidc" and owner == "me" and not caller.is_admin:
|
|
122
|
+
stmt = stmt.where(EntryRow.owner == caller.sub)
|
|
123
|
+
count_stmt = count_stmt.where(EntryRow.owner == caller.sub)
|
|
124
|
+
if kind is not None:
|
|
125
|
+
stmt = stmt.where(EntryRow.kind == kind)
|
|
126
|
+
count_stmt = count_stmt.where(EntryRow.kind == kind)
|
|
127
|
+
if status_filter is not None:
|
|
128
|
+
stmt = stmt.where(EntryRow.status == status_filter)
|
|
129
|
+
count_stmt = count_stmt.where(EntryRow.status == status_filter)
|
|
130
|
+
|
|
131
|
+
async with state.db.session() as session:
|
|
132
|
+
rows = (await session.execute(stmt)).scalars().all()
|
|
133
|
+
|
|
134
|
+
wanted_tags = {t.strip() for t in tags.split(",") if t.strip()}
|
|
135
|
+
entries = [
|
|
136
|
+
row_to_entry(row) for row in rows if not wanted_tags or wanted_tags <= set(row.tags_json)
|
|
137
|
+
]
|
|
138
|
+
total = len(entries)
|
|
139
|
+
return Page(items=entries[offset : offset + limit], total=total, limit=limit, offset=offset)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@router.get("/agents/search", response_model=Page)
|
|
143
|
+
async def search_entries(
|
|
144
|
+
state: State,
|
|
145
|
+
caller: Caller,
|
|
146
|
+
response: Response,
|
|
147
|
+
q: Annotated[str, Query()] = "",
|
|
148
|
+
tags: Annotated[str, Query()] = "",
|
|
149
|
+
kind: Annotated[EntryKind | None, Query()] = None,
|
|
150
|
+
status_filter: Annotated[HealthStatus | None, Query(alias="status")] = None,
|
|
151
|
+
semantic: Annotated[bool, Query()] = False,
|
|
152
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
153
|
+
offset: Annotated[int, Query(ge=0)] = 0,
|
|
154
|
+
) -> Page:
|
|
155
|
+
owner_filter: str | None = None
|
|
156
|
+
if state.settings.auth_mode == "oidc" and not caller.is_admin:
|
|
157
|
+
owner_filter = caller.sub
|
|
158
|
+
if semantic and not state.search.semantic_enabled:
|
|
159
|
+
response.headers["X-Degraded"] = "semantic"
|
|
160
|
+
semantic = False
|
|
161
|
+
query = SearchQuery(
|
|
162
|
+
q=q,
|
|
163
|
+
tags=[t.strip() for t in tags.split(",") if t.strip()],
|
|
164
|
+
kind=kind,
|
|
165
|
+
status=status_filter,
|
|
166
|
+
semantic=semantic,
|
|
167
|
+
owner=owner_filter,
|
|
168
|
+
limit=limit,
|
|
169
|
+
offset=offset,
|
|
170
|
+
)
|
|
171
|
+
return await state.search.search(query)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@router.get("/agents/{entry_id}", response_model=RegistryEntry)
|
|
175
|
+
async def get_entry(entry_id: uuid.UUID, state: State, caller: Caller) -> RegistryEntry:
|
|
176
|
+
row = await _load_visible(entry_id, state, caller)
|
|
177
|
+
return row_to_entry(row)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _load_visible(entry_id: uuid.UUID, state: RegistryState, caller: Principal) -> EntryRow:
|
|
181
|
+
async with state.db.session() as session:
|
|
182
|
+
row = await session.get(EntryRow, str(entry_id))
|
|
183
|
+
if row is None or not _visible(row, caller, state.settings.auth_mode):
|
|
184
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="entry not found")
|
|
185
|
+
return row
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _require_owner(row: EntryRow, caller: Principal, auth_mode: str) -> None:
|
|
189
|
+
if auth_mode == "oidc" and not caller.is_admin and row.owner != caller.sub:
|
|
190
|
+
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="owner or admin required")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.put("/agents/{entry_id}", response_model=RegistryEntry)
|
|
194
|
+
async def update_entry(
|
|
195
|
+
entry_id: uuid.UUID, body: RegistryEntryPatch, state: State, caller: Caller
|
|
196
|
+
) -> RegistryEntry:
|
|
197
|
+
row = await _load_visible(entry_id, state, caller)
|
|
198
|
+
_require_owner(row, caller, state.settings.auth_mode)
|
|
199
|
+
if body.url is not None:
|
|
200
|
+
_check_url(body.url, state.settings)
|
|
201
|
+
async with state.db.session() as session, session.begin():
|
|
202
|
+
fresh = await session.get(EntryRow, str(entry_id))
|
|
203
|
+
if fresh is None:
|
|
204
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="entry not found")
|
|
205
|
+
if body.card is not None:
|
|
206
|
+
fresh.card_json = _dump_card(body)
|
|
207
|
+
fresh.name = body.card.name
|
|
208
|
+
if body.url is not None:
|
|
209
|
+
fresh.url = body.url
|
|
210
|
+
if body.tags is not None:
|
|
211
|
+
fresh.tags_json = list(body.tags)
|
|
212
|
+
fresh.updated_at = datetime.now(UTC)
|
|
213
|
+
row = fresh
|
|
214
|
+
entry = row_to_entry(row)
|
|
215
|
+
await state.search.index(entry)
|
|
216
|
+
return entry
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@router.delete("/agents/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
220
|
+
async def delete_entry(entry_id: uuid.UUID, state: State, caller: Caller) -> None:
|
|
221
|
+
row = await _load_visible(entry_id, state, caller)
|
|
222
|
+
_require_owner(row, caller, state.settings.auth_mode)
|
|
223
|
+
async with state.db.session() as session, session.begin():
|
|
224
|
+
fresh = await session.get(EntryRow, str(entry_id))
|
|
225
|
+
if fresh is not None:
|
|
226
|
+
await session.delete(fresh)
|
|
227
|
+
await state.search.remove(entry_id)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@router.get("/capabilities", response_model=Capabilities)
|
|
231
|
+
async def capabilities(state: State) -> Capabilities:
|
|
232
|
+
return Capabilities(
|
|
233
|
+
semantic_search=state.search.semantic_enabled,
|
|
234
|
+
auth=state.settings.auth_mode,
|
|
235
|
+
version=REGISTRY_VERSION,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@health_router.get("/healthz")
|
|
240
|
+
async def healthz() -> dict[str, str]:
|
|
241
|
+
return {"status": "ok"}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@health_router.get("/readyz")
|
|
245
|
+
async def readyz(request: Request) -> dict[str, str]:
|
|
246
|
+
state: RegistryState = request.app.state.registry
|
|
247
|
+
async with state.db.session() as session:
|
|
248
|
+
await session.execute(select(1))
|
|
249
|
+
return {"status": "ready"}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
__all__ = ["RegistryState", "health_router", "router"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Registry app factory: standalone-capable, SQLite by default, auth optional."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
from agentplane_registry.api import RegistryState, health_router, router
|
|
11
|
+
from agentplane_registry.auth import Authenticator
|
|
12
|
+
from agentplane_registry.db import Database
|
|
13
|
+
from agentplane_registry.embeddings import EmbeddingsClient
|
|
14
|
+
from agentplane_registry.health import HealthJob
|
|
15
|
+
from agentplane_registry.search import RegistrySearch
|
|
16
|
+
from agentplane_registry.settings import REGISTRY_VERSION, RegistrySettings
|
|
17
|
+
from agentplane_registry.tracing import setup_tracing
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_app(settings: RegistrySettings | None = None, *, run_health_job: bool = True) -> FastAPI:
|
|
21
|
+
"""Build the registry service; everything optional degrades gracefully."""
|
|
22
|
+
cfg = settings or RegistrySettings()
|
|
23
|
+
db = Database(cfg.db_url)
|
|
24
|
+
embeddings = (
|
|
25
|
+
EmbeddingsClient(cfg.embeddings_base_url, cfg.embeddings_model)
|
|
26
|
+
if cfg.embeddings_base_url and cfg.embeddings_model
|
|
27
|
+
else None
|
|
28
|
+
)
|
|
29
|
+
search = RegistrySearch(db, embeddings)
|
|
30
|
+
health_job = HealthJob(db, cfg)
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
34
|
+
await db.create_all()
|
|
35
|
+
if run_health_job:
|
|
36
|
+
health_job.start()
|
|
37
|
+
try:
|
|
38
|
+
yield
|
|
39
|
+
finally:
|
|
40
|
+
if run_health_job:
|
|
41
|
+
await health_job.stop()
|
|
42
|
+
await db.dispose()
|
|
43
|
+
|
|
44
|
+
app = FastAPI(title="agentplane-registry", version=REGISTRY_VERSION, lifespan=lifespan)
|
|
45
|
+
app.state.registry = RegistryState(db=db, settings=cfg, search=search)
|
|
46
|
+
app.state.authenticator = Authenticator(cfg)
|
|
47
|
+
app.state.health_job = health_job
|
|
48
|
+
app.include_router(router)
|
|
49
|
+
app.include_router(health_router)
|
|
50
|
+
setup_tracing(app, service_name="agentplane-registry")
|
|
51
|
+
return app
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main() -> None: # pragma: no cover - process entrypoint
|
|
55
|
+
import uvicorn # noqa: PLC0415 - optional server dep, only needed for the entrypoint
|
|
56
|
+
|
|
57
|
+
uvicorn.run(create_app(), host="0.0.0.0", port=8100)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["create_app", "main"]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Optional OIDC auth (SPEC §5.5): any issuer, JWKS cache, role mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import jwt
|
|
10
|
+
from fastapi import HTTPException, Request, status
|
|
11
|
+
from jwt.types import Options
|
|
12
|
+
|
|
13
|
+
from agentplane_registry.settings import RegistrySettings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Principal:
|
|
18
|
+
"""Authenticated caller."""
|
|
19
|
+
|
|
20
|
+
sub: str
|
|
21
|
+
roles: frozenset[str] = field(default_factory=frozenset)
|
|
22
|
+
is_admin: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ANONYMOUS = Principal(sub="anonymous", roles=frozenset(), is_admin=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _claim_path(claims: dict[str, object], path: str) -> object:
|
|
29
|
+
value: object = claims
|
|
30
|
+
for part in path.split("."):
|
|
31
|
+
if not isinstance(value, dict):
|
|
32
|
+
return None
|
|
33
|
+
value = value.get(part)
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OidcValidator:
|
|
38
|
+
"""Validates JWTs against a generic OIDC issuer (discovery + JWKS cache)."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
issuer: str,
|
|
43
|
+
audience: str,
|
|
44
|
+
roles_claim: str,
|
|
45
|
+
admin_role: str,
|
|
46
|
+
*,
|
|
47
|
+
jwks_ttl_s: float = 300.0,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._issuer = issuer.rstrip("/")
|
|
50
|
+
self._audience = audience
|
|
51
|
+
self._roles_claim = roles_claim
|
|
52
|
+
self._admin_role = admin_role
|
|
53
|
+
self._jwks_ttl_s = jwks_ttl_s
|
|
54
|
+
self._jwks: dict[str, jwt.PyJWK] = {}
|
|
55
|
+
self._jwks_fetched_at = 0.0
|
|
56
|
+
|
|
57
|
+
async def _refresh_jwks(self) -> None:
|
|
58
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
59
|
+
discovery = await client.get(f"{self._issuer}/.well-known/openid-configuration")
|
|
60
|
+
discovery.raise_for_status()
|
|
61
|
+
jwks_uri = discovery.json().get("jwks_uri")
|
|
62
|
+
if not isinstance(jwks_uri, str):
|
|
63
|
+
raise RuntimeError("issuer discovery returned no jwks_uri")
|
|
64
|
+
jwks_response = await client.get(jwks_uri)
|
|
65
|
+
jwks_response.raise_for_status()
|
|
66
|
+
keys = jwt.PyJWKSet.from_dict(jwks_response.json()).keys
|
|
67
|
+
self._jwks = {key.key_id: key for key in keys if key.key_id}
|
|
68
|
+
self._jwks_fetched_at = time.monotonic()
|
|
69
|
+
|
|
70
|
+
async def _key_for(self, kid: str) -> jwt.PyJWK:
|
|
71
|
+
stale = time.monotonic() - self._jwks_fetched_at > self._jwks_ttl_s
|
|
72
|
+
if kid not in self._jwks or stale:
|
|
73
|
+
await self._refresh_jwks()
|
|
74
|
+
key = self._jwks.get(kid)
|
|
75
|
+
if key is None:
|
|
76
|
+
raise jwt.InvalidTokenError(f"unknown key id {kid!r}")
|
|
77
|
+
return key
|
|
78
|
+
|
|
79
|
+
async def validate(self, token: str) -> Principal:
|
|
80
|
+
header = jwt.get_unverified_header(token)
|
|
81
|
+
kid = header.get("kid")
|
|
82
|
+
if not isinstance(kid, str):
|
|
83
|
+
raise jwt.InvalidTokenError("token has no kid header")
|
|
84
|
+
key = await self._key_for(kid)
|
|
85
|
+
options: Options = {"verify_aud": bool(self._audience)}
|
|
86
|
+
claims = jwt.decode(
|
|
87
|
+
token,
|
|
88
|
+
key=key.key,
|
|
89
|
+
algorithms=["RS256", "ES256"],
|
|
90
|
+
audience=self._audience or None,
|
|
91
|
+
issuer=self._issuer,
|
|
92
|
+
options=options,
|
|
93
|
+
)
|
|
94
|
+
roles_value = _claim_path(claims, self._roles_claim)
|
|
95
|
+
roles = (
|
|
96
|
+
frozenset(str(role) for role in roles_value)
|
|
97
|
+
if isinstance(roles_value, list)
|
|
98
|
+
else frozenset()
|
|
99
|
+
)
|
|
100
|
+
sub = claims.get("sub")
|
|
101
|
+
if not isinstance(sub, str) or not sub:
|
|
102
|
+
raise jwt.InvalidTokenError("token has no sub")
|
|
103
|
+
return Principal(sub=sub, roles=roles, is_admin=self._admin_role in roles)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Authenticator:
|
|
107
|
+
"""Resolves the request principal according to AUTH_MODE."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, settings: RegistrySettings) -> None:
|
|
110
|
+
self.mode = settings.auth_mode
|
|
111
|
+
self._validator: OidcValidator | None = None
|
|
112
|
+
if self.mode == "oidc":
|
|
113
|
+
if not settings.oidc_issuer:
|
|
114
|
+
raise RuntimeError("AUTH_MODE=oidc requires AGENTPLANE_REGISTRY_OIDC_ISSUER")
|
|
115
|
+
self._validator = OidcValidator(
|
|
116
|
+
settings.oidc_issuer,
|
|
117
|
+
settings.oidc_audience,
|
|
118
|
+
settings.roles_claim,
|
|
119
|
+
settings.admin_role,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def authenticate(self, request: Request) -> Principal:
|
|
123
|
+
if self.mode == "none" or self._validator is None:
|
|
124
|
+
return ANONYMOUS
|
|
125
|
+
header = request.headers.get("Authorization", "")
|
|
126
|
+
scheme, _, token = header.partition(" ")
|
|
127
|
+
if scheme.lower() != "bearer" or not token:
|
|
128
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="missing bearer token")
|
|
129
|
+
try:
|
|
130
|
+
return await self._validator.validate(token)
|
|
131
|
+
except jwt.InvalidTokenError as exc:
|
|
132
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
|
133
|
+
except httpx.HTTPError as exc:
|
|
134
|
+
raise HTTPException(
|
|
135
|
+
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"issuer unreachable: {exc}"
|
|
136
|
+
) from exc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = ["ANONYMOUS", "Authenticator", "OidcValidator", "Principal"]
|