beadhub 0.1.0__py3-none-any.whl
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.
- beadhub/__init__.py +12 -0
- beadhub/api.py +260 -0
- beadhub/auth.py +101 -0
- beadhub/aweb_context.py +65 -0
- beadhub/aweb_introspection.py +70 -0
- beadhub/beads_sync.py +514 -0
- beadhub/cli.py +330 -0
- beadhub/config.py +65 -0
- beadhub/db.py +129 -0
- beadhub/defaults/invariants/01-tracking-bdh-only.md +11 -0
- beadhub/defaults/invariants/02-communication-mail-first.md +36 -0
- beadhub/defaults/invariants/03-communication-chat.md +60 -0
- beadhub/defaults/invariants/04-identity-no-impersonation.md +17 -0
- beadhub/defaults/invariants/05-collaborate.md +12 -0
- beadhub/defaults/roles/backend.md +55 -0
- beadhub/defaults/roles/coordinator.md +44 -0
- beadhub/defaults/roles/frontend.md +77 -0
- beadhub/defaults/roles/implementer.md +73 -0
- beadhub/defaults/roles/reviewer.md +56 -0
- beadhub/defaults/roles/startup-expert.md +93 -0
- beadhub/defaults.py +262 -0
- beadhub/events.py +704 -0
- beadhub/internal_auth.py +121 -0
- beadhub/jsonl.py +68 -0
- beadhub/logging.py +62 -0
- beadhub/migrations/beads/001_initial.sql +70 -0
- beadhub/migrations/beads/002_search_indexes.sql +20 -0
- beadhub/migrations/server/001_initial.sql +279 -0
- beadhub/names.py +33 -0
- beadhub/notifications.py +275 -0
- beadhub/pagination.py +125 -0
- beadhub/presence.py +495 -0
- beadhub/rate_limit.py +152 -0
- beadhub/redis_client.py +11 -0
- beadhub/roles.py +35 -0
- beadhub/routes/__init__.py +1 -0
- beadhub/routes/agents.py +303 -0
- beadhub/routes/bdh.py +655 -0
- beadhub/routes/beads.py +778 -0
- beadhub/routes/claims.py +141 -0
- beadhub/routes/escalations.py +471 -0
- beadhub/routes/init.py +348 -0
- beadhub/routes/mcp.py +338 -0
- beadhub/routes/policies.py +833 -0
- beadhub/routes/repos.py +538 -0
- beadhub/routes/status.py +568 -0
- beadhub/routes/subscriptions.py +362 -0
- beadhub/routes/workspaces.py +1642 -0
- beadhub/workspace_config.py +202 -0
- beadhub-0.1.0.dist-info/METADATA +254 -0
- beadhub-0.1.0.dist-info/RECORD +54 -0
- beadhub-0.1.0.dist-info/WHEEL +4 -0
- beadhub-0.1.0.dist-info/entry_points.txt +2 -0
- beadhub-0.1.0.dist-info/licenses/LICENSE +21 -0
beadhub/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from .api import create_app
|
|
4
|
+
from .cli import app
|
|
5
|
+
from .db import DatabaseInfra
|
|
6
|
+
|
|
7
|
+
__version__ = version("beadhub")
|
|
8
|
+
__all__ = ["__version__", "create_app", "DatabaseInfra", "app", "main"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
app()
|
beadhub/api.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from aweb.routes.auth import router as aweb_auth_router
|
|
8
|
+
from aweb.routes.chat import router as aweb_chat_router
|
|
9
|
+
from aweb.routes.messages import router as aweb_messages_router
|
|
10
|
+
from aweb.routes.projects import router as aweb_projects_router
|
|
11
|
+
from aweb.routes.reservations import router as aweb_reservations_router
|
|
12
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
from redis.asyncio import Redis
|
|
15
|
+
from redis.asyncio import from_url as async_redis_from_url
|
|
16
|
+
|
|
17
|
+
from .config import get_settings
|
|
18
|
+
from .db import DatabaseInfra
|
|
19
|
+
from .db import db_infra as default_db_infra
|
|
20
|
+
from .logging import configure_logging
|
|
21
|
+
from .routes.agents import router as agents_router
|
|
22
|
+
from .routes.bdh import router as bdh_router
|
|
23
|
+
from .routes.beads import router as beads_router
|
|
24
|
+
from .routes.claims import router as claims_router
|
|
25
|
+
from .routes.escalations import router as escalations_router
|
|
26
|
+
from .routes.init import router as init_router
|
|
27
|
+
from .routes.mcp import router as mcp_router
|
|
28
|
+
from .routes.policies import router as policies_router
|
|
29
|
+
from .routes.status import router as status_router
|
|
30
|
+
from .routes.subscriptions import router as subscriptions_router
|
|
31
|
+
from .routes.workspaces import router as workspaces_router
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make_standalone_lifespan():
|
|
37
|
+
"""Create lifespan for standalone mode (creates own DB and Redis connections)."""
|
|
38
|
+
|
|
39
|
+
@asynccontextmanager
|
|
40
|
+
async def lifespan(app: FastAPI):
|
|
41
|
+
json_format = os.getenv("BEADHUB_LOG_JSON", "true").lower() == "true"
|
|
42
|
+
settings = get_settings()
|
|
43
|
+
configure_logging(log_level=settings.log_level, json_format=json_format)
|
|
44
|
+
logger.info("Starting BeadHub server (standalone mode)")
|
|
45
|
+
|
|
46
|
+
redis: Redis | None = None
|
|
47
|
+
redis_connected = False
|
|
48
|
+
db_initialized = False
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
# Phase 1: Initialize all resources (don't set app.state yet)
|
|
52
|
+
redis = await async_redis_from_url(settings.redis_url, decode_responses=True)
|
|
53
|
+
await redis.ping()
|
|
54
|
+
redis_connected = True
|
|
55
|
+
logger.info("Connected to Redis")
|
|
56
|
+
|
|
57
|
+
await default_db_infra.initialize()
|
|
58
|
+
db_initialized = True
|
|
59
|
+
logger.info("Database initialized")
|
|
60
|
+
|
|
61
|
+
# Phase 2: Only assign to app.state after ALL initialization succeeds
|
|
62
|
+
app.state.redis = redis
|
|
63
|
+
app.state.db = default_db_infra
|
|
64
|
+
|
|
65
|
+
except Exception:
|
|
66
|
+
# Log which phase failed
|
|
67
|
+
if not redis_connected:
|
|
68
|
+
logger.exception("Failed to connect to Redis")
|
|
69
|
+
elif not db_initialized:
|
|
70
|
+
logger.exception("Failed to initialize database")
|
|
71
|
+
|
|
72
|
+
# Clean up any initialized resources on failure
|
|
73
|
+
if db_initialized:
|
|
74
|
+
await default_db_infra.close()
|
|
75
|
+
if redis is not None:
|
|
76
|
+
await redis.aclose()
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
yield
|
|
81
|
+
finally:
|
|
82
|
+
logger.info("Shutting down BeadHub server")
|
|
83
|
+
await redis.aclose()
|
|
84
|
+
await default_db_infra.close()
|
|
85
|
+
|
|
86
|
+
return lifespan
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _make_library_lifespan(db_infra: DatabaseInfra, redis: Redis):
|
|
90
|
+
"""Create lifespan for library mode (uses externally provided connections)."""
|
|
91
|
+
|
|
92
|
+
@asynccontextmanager
|
|
93
|
+
async def lifespan(app: FastAPI):
|
|
94
|
+
json_format = os.getenv("BEADHUB_LOG_JSON", "true").lower() == "true"
|
|
95
|
+
log_level = os.getenv("BEADHUB_LOG_LEVEL", "info")
|
|
96
|
+
configure_logging(log_level=log_level, json_format=json_format)
|
|
97
|
+
logger.info("Starting BeadHub server (library mode)")
|
|
98
|
+
|
|
99
|
+
# Use externally provided connections - no initialization needed
|
|
100
|
+
app.state.redis = redis
|
|
101
|
+
app.state.db = db_infra
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
yield
|
|
105
|
+
finally:
|
|
106
|
+
# Don't close connections in library mode - caller manages them
|
|
107
|
+
logger.info("BeadHub server stopping (library mode)")
|
|
108
|
+
|
|
109
|
+
return lifespan
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def create_app(
|
|
113
|
+
*,
|
|
114
|
+
db_infra: Optional[DatabaseInfra] = None,
|
|
115
|
+
redis: Optional[Redis] = None,
|
|
116
|
+
serve_frontend: bool = True,
|
|
117
|
+
enable_bootstrap_routes: bool = True,
|
|
118
|
+
) -> FastAPI:
|
|
119
|
+
"""Create BeadHub FastAPI application.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
db_infra: External DatabaseInfra instance (library mode).
|
|
123
|
+
If None, creates own connections (standalone mode).
|
|
124
|
+
redis: External async Redis client (library mode).
|
|
125
|
+
If None, creates own connection (standalone mode).
|
|
126
|
+
serve_frontend: If True, serve the dashboard frontend from /frontend/dist.
|
|
127
|
+
Set to False when embedding in another app that serves its own UI.
|
|
128
|
+
enable_bootstrap_routes: If True, expose bootstrap routes such as `/v1/init`.
|
|
129
|
+
Embedded/proxy deployments should set this to False.
|
|
130
|
+
|
|
131
|
+
Library mode requires both db_infra and redis to be provided.
|
|
132
|
+
Standalone mode requires neither (will create its own).
|
|
133
|
+
|
|
134
|
+
Examples:
|
|
135
|
+
Standalone mode (simple deployment)::
|
|
136
|
+
|
|
137
|
+
app = create_app()
|
|
138
|
+
# Run with: uvicorn beadhub.api:create_app --factory
|
|
139
|
+
|
|
140
|
+
Library mode (embedding in another FastAPI app)::
|
|
141
|
+
|
|
142
|
+
from beadhub.api import create_app
|
|
143
|
+
from beadhub.db import DatabaseInfra
|
|
144
|
+
from redis.asyncio import Redis
|
|
145
|
+
|
|
146
|
+
# Initialize shared infrastructure
|
|
147
|
+
db_infra = DatabaseInfra()
|
|
148
|
+
await db_infra.initialize()
|
|
149
|
+
redis = await Redis.from_url("redis://localhost:6379")
|
|
150
|
+
|
|
151
|
+
# Create BeadHub app with shared connections
|
|
152
|
+
beadhub_app = create_app(db_infra=db_infra, redis=redis)
|
|
153
|
+
|
|
154
|
+
# Mount under your main app
|
|
155
|
+
main_app.mount("/beadhub", beadhub_app)
|
|
156
|
+
"""
|
|
157
|
+
# Validate mode consistency
|
|
158
|
+
if (db_infra is None) != (redis is None):
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"Library mode requires both db_infra and redis, or neither for standalone mode"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
library_mode = db_infra is not None
|
|
164
|
+
|
|
165
|
+
# Validate db_infra is initialized in library mode
|
|
166
|
+
if library_mode:
|
|
167
|
+
assert db_infra is not None # Type narrowing for mypy
|
|
168
|
+
if not db_infra.is_initialized:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
"db_infra must be initialized before passing to create_app() in library mode. "
|
|
171
|
+
"Call 'await db_infra.initialize()' before creating the app."
|
|
172
|
+
)
|
|
173
|
+
assert redis is not None # Required when db_infra is provided
|
|
174
|
+
lifespan = _make_library_lifespan(db_infra, redis)
|
|
175
|
+
else:
|
|
176
|
+
lifespan = _make_standalone_lifespan()
|
|
177
|
+
|
|
178
|
+
app = FastAPI(title="BeadHub OSS Core", version="0.1.0", lifespan=lifespan)
|
|
179
|
+
|
|
180
|
+
@app.get("/health", tags=["internal"])
|
|
181
|
+
async def health(request: Request) -> dict:
|
|
182
|
+
checks = {}
|
|
183
|
+
healthy = True
|
|
184
|
+
|
|
185
|
+
# Check Redis
|
|
186
|
+
try:
|
|
187
|
+
redis: Redis = request.app.state.redis
|
|
188
|
+
await redis.ping()
|
|
189
|
+
checks["redis"] = "ok"
|
|
190
|
+
except Exception as e:
|
|
191
|
+
checks["redis"] = f"error: {e}"
|
|
192
|
+
healthy = False
|
|
193
|
+
|
|
194
|
+
# Check Database
|
|
195
|
+
try:
|
|
196
|
+
db_infra: DatabaseInfra = request.app.state.db
|
|
197
|
+
db = db_infra.get_manager("server")
|
|
198
|
+
await db.fetch_value("SELECT 1")
|
|
199
|
+
checks["database"] = "ok"
|
|
200
|
+
except Exception as e:
|
|
201
|
+
checks["database"] = f"error: {e}"
|
|
202
|
+
healthy = False
|
|
203
|
+
|
|
204
|
+
return {"status": "ok" if healthy else "unhealthy", "checks": checks}
|
|
205
|
+
|
|
206
|
+
# aweb protocol routes (BeadHub is an aweb server).
|
|
207
|
+
# Note: BeadHub overrides `/v1/init` with an extended init endpoint.
|
|
208
|
+
if enable_bootstrap_routes:
|
|
209
|
+
app.include_router(init_router)
|
|
210
|
+
app.include_router(aweb_auth_router)
|
|
211
|
+
app.include_router(aweb_chat_router)
|
|
212
|
+
app.include_router(aweb_messages_router)
|
|
213
|
+
app.include_router(aweb_projects_router)
|
|
214
|
+
app.include_router(aweb_reservations_router)
|
|
215
|
+
|
|
216
|
+
# beadhub endpoints.
|
|
217
|
+
app.include_router(bdh_router)
|
|
218
|
+
app.include_router(agents_router)
|
|
219
|
+
app.include_router(beads_router)
|
|
220
|
+
app.include_router(claims_router)
|
|
221
|
+
app.include_router(escalations_router)
|
|
222
|
+
app.include_router(policies_router)
|
|
223
|
+
app.include_router(status_router)
|
|
224
|
+
app.include_router(subscriptions_router)
|
|
225
|
+
app.include_router(workspaces_router)
|
|
226
|
+
app.include_router(mcp_router)
|
|
227
|
+
|
|
228
|
+
# Serve frontend dashboard if available and enabled
|
|
229
|
+
if serve_frontend:
|
|
230
|
+
# Look for frontend dist relative to this file's location
|
|
231
|
+
frontend_dist = Path(__file__).parent.parent.parent / "frontend" / "dist"
|
|
232
|
+
if frontend_dist.exists():
|
|
233
|
+
# Serve static assets
|
|
234
|
+
app.mount(
|
|
235
|
+
"/assets",
|
|
236
|
+
StaticFiles(directory=frontend_dist / "assets"),
|
|
237
|
+
name="assets",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Serve index.html for all unmatched routes (SPA routing)
|
|
241
|
+
from fastapi.responses import FileResponse
|
|
242
|
+
|
|
243
|
+
@app.get("/{full_path:path}", include_in_schema=False)
|
|
244
|
+
async def serve_spa(full_path: str):
|
|
245
|
+
# API routes are handled by routers registered before this catch-all
|
|
246
|
+
# This only catches non-API paths for SPA routing
|
|
247
|
+
index_path = frontend_dist / "index.html"
|
|
248
|
+
if index_path.exists():
|
|
249
|
+
return FileResponse(index_path)
|
|
250
|
+
raise HTTPException(status_code=404, detail="Frontend not found")
|
|
251
|
+
|
|
252
|
+
logger.info("Frontend dashboard enabled at /")
|
|
253
|
+
else:
|
|
254
|
+
logger.debug("Frontend not found at %s, skipping", frontend_dist)
|
|
255
|
+
|
|
256
|
+
return app
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# Module-level app for uvicorn: `uvicorn beadhub.api:app`
|
|
260
|
+
app = create_app()
|
beadhub/auth.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any, Optional, Protocol
|
|
5
|
+
|
|
6
|
+
from fastapi import HTTPException, Request
|
|
7
|
+
|
|
8
|
+
from beadhub.aweb_introspection import get_identity_from_auth
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DatabaseLike(Protocol):
|
|
12
|
+
def get_manager(self, name: str = "server") -> Any: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_workspace_id(workspace_id: str) -> str:
|
|
16
|
+
"""Validate workspace_id is a valid UUID string and return normalized format."""
|
|
17
|
+
if workspace_id is None:
|
|
18
|
+
raise ValueError("workspace_id cannot be empty")
|
|
19
|
+
workspace_id = str(workspace_id).strip()
|
|
20
|
+
if not workspace_id:
|
|
21
|
+
raise ValueError("workspace_id cannot be empty")
|
|
22
|
+
try:
|
|
23
|
+
return str(uuid.UUID(workspace_id))
|
|
24
|
+
except ValueError:
|
|
25
|
+
raise ValueError("Invalid workspace_id format")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def get_workspace_project_id(
|
|
29
|
+
db: DatabaseLike,
|
|
30
|
+
workspace_id: str,
|
|
31
|
+
) -> Optional[str]:
|
|
32
|
+
"""Return project_id for workspace_id or None if not found."""
|
|
33
|
+
try:
|
|
34
|
+
ws_uuid = uuid.UUID(workspace_id)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
server_db = db.get_manager("server")
|
|
39
|
+
row = await server_db.fetch_one(
|
|
40
|
+
"""
|
|
41
|
+
SELECT project_id
|
|
42
|
+
FROM {{tables.workspaces}}
|
|
43
|
+
WHERE workspace_id = $1 AND deleted_at IS NULL
|
|
44
|
+
""",
|
|
45
|
+
ws_uuid,
|
|
46
|
+
)
|
|
47
|
+
if not row:
|
|
48
|
+
return None
|
|
49
|
+
return str(row["project_id"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def verify_workspace_access(
|
|
53
|
+
request: Request,
|
|
54
|
+
workspace_id: str,
|
|
55
|
+
db: DatabaseLike,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""Verify workspace_id belongs to the authenticated project, return project_id.
|
|
58
|
+
|
|
59
|
+
This enforces the invariant documented in `security-patterns.md`:
|
|
60
|
+
- project scope is derived from auth (API key or signed proxy context)
|
|
61
|
+
- workspace_id is validated and must belong to that project
|
|
62
|
+
- in direct Bearer mode, workspace-scoped operations MUST use the caller's identity
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
workspace_id = validate_workspace_id(workspace_id)
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
raise HTTPException(status_code=422, detail=str(e))
|
|
68
|
+
|
|
69
|
+
identity = await get_identity_from_auth(request, db)
|
|
70
|
+
project_id = identity.project_id
|
|
71
|
+
|
|
72
|
+
server_db = db.get_manager("server")
|
|
73
|
+
row = await server_db.fetch_one(
|
|
74
|
+
"""
|
|
75
|
+
SELECT project_id, deleted_at
|
|
76
|
+
FROM {{tables.workspaces}}
|
|
77
|
+
WHERE workspace_id = $1
|
|
78
|
+
""",
|
|
79
|
+
uuid.UUID(workspace_id),
|
|
80
|
+
)
|
|
81
|
+
if not row:
|
|
82
|
+
raise HTTPException(status_code=404, detail="Workspace not found")
|
|
83
|
+
if row.get("deleted_at") is not None:
|
|
84
|
+
raise HTTPException(status_code=410, detail="Workspace was deleted")
|
|
85
|
+
|
|
86
|
+
ws_project_id = str(row["project_id"])
|
|
87
|
+
if ws_project_id != project_id:
|
|
88
|
+
raise HTTPException(
|
|
89
|
+
status_code=403,
|
|
90
|
+
detail="Workspace not found or does not belong to your project",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# If auth provides an agent/actor identity, workspace-scoped operations must use it.
|
|
94
|
+
# This is enforced after existence checks so ghost workspaces still return 404/410.
|
|
95
|
+
if identity.agent_id is not None and identity.agent_id != workspace_id:
|
|
96
|
+
raise HTTPException(
|
|
97
|
+
status_code=403,
|
|
98
|
+
detail="workspace_id does not match API key identity",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return project_id
|
beadhub/aweb_context.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from aweb.auth import parse_bearer_token, verify_bearer_token_details
|
|
7
|
+
from fastapi import HTTPException, Request
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class AwebIdentity:
|
|
12
|
+
project_id: str
|
|
13
|
+
project_slug: str
|
|
14
|
+
project_name: str
|
|
15
|
+
agent_id: str
|
|
16
|
+
alias: str
|
|
17
|
+
human_name: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def resolve_aweb_identity(request: Request, db) -> AwebIdentity:
|
|
21
|
+
"""Resolve the caller's aweb identity context.
|
|
22
|
+
|
|
23
|
+
BeadHub implements the aweb protocol and owns the aweb schema.
|
|
24
|
+
"""
|
|
25
|
+
token = parse_bearer_token(request)
|
|
26
|
+
if token is None:
|
|
27
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
28
|
+
|
|
29
|
+
details = await verify_bearer_token_details(db, token, manager_name="aweb")
|
|
30
|
+
project_id = (details.get("project_id") or "").strip()
|
|
31
|
+
agent_id = (details.get("agent_id") or "").strip()
|
|
32
|
+
if not project_id or not agent_id:
|
|
33
|
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
34
|
+
|
|
35
|
+
aweb_db = db.get_manager("aweb")
|
|
36
|
+
agent = await aweb_db.fetch_one(
|
|
37
|
+
"""
|
|
38
|
+
SELECT alias, human_name
|
|
39
|
+
FROM {{tables.agents}}
|
|
40
|
+
WHERE agent_id = $1 AND deleted_at IS NULL
|
|
41
|
+
""",
|
|
42
|
+
UUID(agent_id),
|
|
43
|
+
)
|
|
44
|
+
if not agent:
|
|
45
|
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
46
|
+
|
|
47
|
+
project = await aweb_db.fetch_one(
|
|
48
|
+
"""
|
|
49
|
+
SELECT slug, name
|
|
50
|
+
FROM {{tables.projects}}
|
|
51
|
+
WHERE project_id = $1 AND deleted_at IS NULL
|
|
52
|
+
""",
|
|
53
|
+
UUID(project_id),
|
|
54
|
+
)
|
|
55
|
+
if not project:
|
|
56
|
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
57
|
+
|
|
58
|
+
return AwebIdentity(
|
|
59
|
+
project_id=project_id,
|
|
60
|
+
project_slug=project["slug"],
|
|
61
|
+
project_name=project.get("name") or "",
|
|
62
|
+
agent_id=agent_id,
|
|
63
|
+
alias=agent["alias"],
|
|
64
|
+
human_name=agent.get("human_name") or "",
|
|
65
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from aweb.auth import DatabaseLike, parse_bearer_token, verify_bearer_token_details
|
|
6
|
+
from fastapi import HTTPException, Request
|
|
7
|
+
|
|
8
|
+
from .internal_auth import parse_internal_auth_context
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class AuthIdentity:
|
|
13
|
+
project_id: str
|
|
14
|
+
agent_id: str | None
|
|
15
|
+
api_key_id: str | None
|
|
16
|
+
user_id: str | None
|
|
17
|
+
auth_mode: str # "proxy" or "bearer"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def get_identity_from_auth(request: Request, db: DatabaseLike) -> AuthIdentity:
|
|
21
|
+
"""Resolve the authenticated identity context for BeadHub requests.
|
|
22
|
+
|
|
23
|
+
Priority order:
|
|
24
|
+
1) Trusted proxy/wrapper auth context (`X-BH-Auth` + `X-Project-ID`)
|
|
25
|
+
2) Local aweb Bearer API key (default; BeadHub implements the aweb protocol)
|
|
26
|
+
"""
|
|
27
|
+
internal = parse_internal_auth_context(request)
|
|
28
|
+
if internal is not None:
|
|
29
|
+
principal_type = (internal.get("principal_type") or "").strip()
|
|
30
|
+
principal_id = (internal.get("principal_id") or "").strip() or None
|
|
31
|
+
actor_id = (internal.get("actor_id") or "").strip() or None
|
|
32
|
+
return AuthIdentity(
|
|
33
|
+
project_id=internal["project_id"],
|
|
34
|
+
agent_id=actor_id,
|
|
35
|
+
api_key_id=principal_id if principal_type == "k" else None,
|
|
36
|
+
user_id=principal_id if principal_type == "u" else None,
|
|
37
|
+
auth_mode="proxy",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
token = parse_bearer_token(request)
|
|
41
|
+
if token is None:
|
|
42
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
43
|
+
|
|
44
|
+
details = await verify_bearer_token_details(db, token, manager_name="aweb")
|
|
45
|
+
project_id = (details.get("project_id") or "").strip()
|
|
46
|
+
if not project_id:
|
|
47
|
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
48
|
+
|
|
49
|
+
agent_id = (details.get("agent_id") or "").strip() or None
|
|
50
|
+
api_key_id = (details.get("api_key_id") or "").strip() or None
|
|
51
|
+
user_id = (details.get("user_id") or "").strip() or None
|
|
52
|
+
return AuthIdentity(
|
|
53
|
+
project_id=project_id,
|
|
54
|
+
agent_id=agent_id,
|
|
55
|
+
api_key_id=api_key_id,
|
|
56
|
+
user_id=user_id,
|
|
57
|
+
auth_mode="bearer",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def get_project_from_auth(request: Request, db: DatabaseLike) -> str:
|
|
62
|
+
"""Resolve the authenticated project_id for BeadHub requests.
|
|
63
|
+
|
|
64
|
+
Priority order:
|
|
65
|
+
1) Trusted proxy/wrapper auth context (`X-BH-Auth` + `X-Project-ID`)
|
|
66
|
+
2) Local aweb auth (default; BeadHub implements the aweb protocol)
|
|
67
|
+
"""
|
|
68
|
+
# 1) Proxy headers (signed internal context)
|
|
69
|
+
identity = await get_identity_from_auth(request, db)
|
|
70
|
+
return identity.project_id
|