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
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
"""BeadHub project policy endpoints.
|
|
2
|
+
|
|
3
|
+
Provides server-backed project policies with versioned bundles containing:
|
|
4
|
+
- Global invariants (guidance for all workspaces)
|
|
5
|
+
- Role playbooks (role-specific guidance)
|
|
6
|
+
- Adapters (tool-specific templates)
|
|
7
|
+
|
|
8
|
+
Security:
|
|
9
|
+
- All reads are project-scoped via `get_project_from_auth`
|
|
10
|
+
- Writes (POST /v1/policies, POST /v1/policies/{id}/activate) require admin permission
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
|
|
22
|
+
from pgdbm import AsyncDatabaseManager
|
|
23
|
+
from pydantic import BaseModel, Field
|
|
24
|
+
|
|
25
|
+
from beadhub.auth import validate_workspace_id
|
|
26
|
+
from beadhub.aweb_introspection import get_identity_from_auth, get_project_from_auth
|
|
27
|
+
|
|
28
|
+
from ..db import DatabaseInfra, get_db_infra
|
|
29
|
+
from ..defaults import get_default_bundle
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
router = APIRouter(prefix="/v1/policies", tags=["policies"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Default policy bundle for new projects.
|
|
37
|
+
# Loaded from markdown files in beadhub/defaults/ for easier editing.
|
|
38
|
+
# This is a backward-compatible alias for code that imports DEFAULT_POLICY_BUNDLE.
|
|
39
|
+
DEFAULT_POLICY_BUNDLE: Dict[str, Any] = get_default_bundle()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PolicyBundle(BaseModel):
|
|
43
|
+
"""Policy bundle containing invariants, roles, and adapters."""
|
|
44
|
+
|
|
45
|
+
invariants: List[Dict[str, Any]] = Field(default_factory=list)
|
|
46
|
+
roles: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
|
|
47
|
+
adapters: Dict[str, Any] = Field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PolicyVersion(BaseModel):
|
|
51
|
+
"""A versioned policy record."""
|
|
52
|
+
|
|
53
|
+
policy_id: str
|
|
54
|
+
project_id: str
|
|
55
|
+
version: int
|
|
56
|
+
bundle: PolicyBundle
|
|
57
|
+
created_by_workspace_id: Optional[str]
|
|
58
|
+
created_at: datetime
|
|
59
|
+
updated_at: datetime
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def get_active_policy(
|
|
63
|
+
db: AsyncDatabaseManager,
|
|
64
|
+
project_id: str,
|
|
65
|
+
*,
|
|
66
|
+
bootstrap_if_missing: bool = True,
|
|
67
|
+
) -> Optional[PolicyVersion]:
|
|
68
|
+
"""
|
|
69
|
+
Get the active policy for a project.
|
|
70
|
+
|
|
71
|
+
If no policy exists and bootstrap_if_missing is True, creates a default
|
|
72
|
+
policy and sets it as active.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
db: The server database manager
|
|
76
|
+
project_id: The project UUID
|
|
77
|
+
bootstrap_if_missing: If True, create default policy when none exists
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The active PolicyVersion, or None if no policy and bootstrap disabled
|
|
81
|
+
"""
|
|
82
|
+
# Check if project has an active policy
|
|
83
|
+
result = await db.fetch_one(
|
|
84
|
+
"""
|
|
85
|
+
SELECT pp.policy_id, pp.project_id, pp.version, pp.bundle_json,
|
|
86
|
+
pp.created_by_workspace_id, pp.created_at, pp.updated_at
|
|
87
|
+
FROM {{tables.projects}} p
|
|
88
|
+
JOIN {{tables.project_policies}} pp ON pp.policy_id = p.active_policy_id
|
|
89
|
+
WHERE p.id = $1
|
|
90
|
+
""",
|
|
91
|
+
project_id,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if result:
|
|
95
|
+
# Parse bundle_json - may be dict or string depending on asyncpg codec
|
|
96
|
+
bundle_data = result["bundle_json"]
|
|
97
|
+
if isinstance(bundle_data, str):
|
|
98
|
+
bundle_data = json.loads(bundle_data)
|
|
99
|
+
|
|
100
|
+
return PolicyVersion(
|
|
101
|
+
policy_id=str(result["policy_id"]),
|
|
102
|
+
project_id=str(result["project_id"]),
|
|
103
|
+
version=result["version"],
|
|
104
|
+
bundle=PolicyBundle(**bundle_data),
|
|
105
|
+
created_by_workspace_id=(
|
|
106
|
+
str(result["created_by_workspace_id"])
|
|
107
|
+
if result["created_by_workspace_id"]
|
|
108
|
+
else None
|
|
109
|
+
),
|
|
110
|
+
created_at=result["created_at"],
|
|
111
|
+
updated_at=result["updated_at"],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if not bootstrap_if_missing:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
# Bootstrap default policy
|
|
118
|
+
logger.info("Bootstrapping default policy for project %s", project_id)
|
|
119
|
+
policy = await create_policy_version(
|
|
120
|
+
db,
|
|
121
|
+
project_id=project_id,
|
|
122
|
+
base_policy_id=None,
|
|
123
|
+
bundle=get_default_bundle(),
|
|
124
|
+
created_by_workspace_id=None,
|
|
125
|
+
)
|
|
126
|
+
await activate_policy(db, project_id=project_id, policy_id=policy.policy_id)
|
|
127
|
+
return policy
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def create_policy_version(
|
|
131
|
+
db: AsyncDatabaseManager,
|
|
132
|
+
*,
|
|
133
|
+
project_id: str,
|
|
134
|
+
base_policy_id: Optional[str],
|
|
135
|
+
bundle: Dict[str, Any],
|
|
136
|
+
created_by_workspace_id: Optional[str],
|
|
137
|
+
) -> PolicyVersion:
|
|
138
|
+
"""
|
|
139
|
+
Create a new policy version for a project.
|
|
140
|
+
|
|
141
|
+
Version numbers are allocated atomically to prevent races. Each new version
|
|
142
|
+
is one greater than the current maximum for the project.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
db: The server database manager
|
|
146
|
+
project_id: The project UUID
|
|
147
|
+
base_policy_id: The policy this version is based on (for audit trail)
|
|
148
|
+
bundle: The policy bundle (invariants, roles, adapters)
|
|
149
|
+
created_by_workspace_id: The workspace creating this version (optional)
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
The created PolicyVersion
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
HTTPException 404: If project doesn't exist
|
|
156
|
+
"""
|
|
157
|
+
# Verify project exists and is not soft-deleted
|
|
158
|
+
project = await db.fetch_one(
|
|
159
|
+
"SELECT id FROM {{tables.projects}} WHERE id = $1 AND deleted_at IS NULL",
|
|
160
|
+
project_id,
|
|
161
|
+
)
|
|
162
|
+
if not project:
|
|
163
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
164
|
+
|
|
165
|
+
# Allocate version atomically by locking the project row, then computing max+1
|
|
166
|
+
# The unique constraint on (project_id, version) provides final safety
|
|
167
|
+
result = await db.fetch_one(
|
|
168
|
+
"""
|
|
169
|
+
WITH locked_project AS (
|
|
170
|
+
SELECT id FROM {{tables.projects}}
|
|
171
|
+
WHERE id = $1 AND deleted_at IS NULL
|
|
172
|
+
FOR UPDATE
|
|
173
|
+
),
|
|
174
|
+
next_version AS (
|
|
175
|
+
SELECT COALESCE(MAX(version), 0) + 1 AS version
|
|
176
|
+
FROM {{tables.project_policies}}
|
|
177
|
+
WHERE project_id = $1
|
|
178
|
+
)
|
|
179
|
+
INSERT INTO {{tables.project_policies}} (project_id, version, bundle_json, created_by_workspace_id)
|
|
180
|
+
SELECT $1, nv.version, $2::jsonb, $3
|
|
181
|
+
FROM next_version nv, locked_project lp
|
|
182
|
+
RETURNING policy_id, project_id, version, bundle_json, created_by_workspace_id, created_at, updated_at
|
|
183
|
+
""",
|
|
184
|
+
project_id,
|
|
185
|
+
json.dumps(bundle),
|
|
186
|
+
created_by_workspace_id,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
logger.info(
|
|
190
|
+
"Created policy version %d for project %s (policy_id=%s)",
|
|
191
|
+
result["version"],
|
|
192
|
+
project_id,
|
|
193
|
+
result["policy_id"],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Parse bundle_json - may be dict or string depending on asyncpg codec
|
|
197
|
+
bundle_data = result["bundle_json"]
|
|
198
|
+
if isinstance(bundle_data, str):
|
|
199
|
+
bundle_data = json.loads(bundle_data)
|
|
200
|
+
|
|
201
|
+
return PolicyVersion(
|
|
202
|
+
policy_id=str(result["policy_id"]),
|
|
203
|
+
project_id=str(result["project_id"]),
|
|
204
|
+
version=result["version"],
|
|
205
|
+
bundle=PolicyBundle(**bundle_data),
|
|
206
|
+
created_by_workspace_id=(
|
|
207
|
+
str(result["created_by_workspace_id"]) if result["created_by_workspace_id"] else None
|
|
208
|
+
),
|
|
209
|
+
created_at=result["created_at"],
|
|
210
|
+
updated_at=result["updated_at"],
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def activate_policy(
|
|
215
|
+
db: AsyncDatabaseManager,
|
|
216
|
+
*,
|
|
217
|
+
project_id: str,
|
|
218
|
+
policy_id: str,
|
|
219
|
+
) -> bool:
|
|
220
|
+
"""
|
|
221
|
+
Set the active policy for a project.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
db: The server database manager
|
|
225
|
+
project_id: The project UUID
|
|
226
|
+
policy_id: The policy UUID to activate
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
True if activation succeeded
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
HTTPException 404: If project or policy doesn't exist
|
|
233
|
+
HTTPException 400: If policy doesn't belong to the project
|
|
234
|
+
"""
|
|
235
|
+
# Verify policy exists and belongs to project
|
|
236
|
+
policy = await db.fetch_one(
|
|
237
|
+
"""
|
|
238
|
+
SELECT policy_id, project_id FROM {{tables.project_policies}}
|
|
239
|
+
WHERE policy_id = $1
|
|
240
|
+
""",
|
|
241
|
+
policy_id,
|
|
242
|
+
)
|
|
243
|
+
if not policy:
|
|
244
|
+
raise HTTPException(status_code=404, detail="Policy not found")
|
|
245
|
+
|
|
246
|
+
if str(policy["project_id"]) != project_id:
|
|
247
|
+
raise HTTPException(
|
|
248
|
+
status_code=400,
|
|
249
|
+
detail="Policy does not belong to this project",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Update project's active policy
|
|
253
|
+
result = await db.fetch_one(
|
|
254
|
+
"""
|
|
255
|
+
UPDATE {{tables.projects}}
|
|
256
|
+
SET active_policy_id = $2
|
|
257
|
+
WHERE id = $1
|
|
258
|
+
RETURNING id
|
|
259
|
+
""",
|
|
260
|
+
project_id,
|
|
261
|
+
policy_id,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if not result:
|
|
265
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
266
|
+
|
|
267
|
+
logger.info("Activated policy %s for project %s", policy_id, project_id)
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _generate_etag(policy_id: str, updated_at: datetime) -> str:
|
|
272
|
+
"""Generate ETag from policy_id and updated_at timestamp."""
|
|
273
|
+
content = f"{policy_id}:{updated_at.isoformat()}"
|
|
274
|
+
return f'"{hashlib.sha256(content.encode()).hexdigest()[:16]}"'
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class Invariant(BaseModel):
|
|
278
|
+
"""A policy invariant."""
|
|
279
|
+
|
|
280
|
+
id: str
|
|
281
|
+
title: str
|
|
282
|
+
body_md: str
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class RolePlaybook(BaseModel):
|
|
286
|
+
"""A role playbook."""
|
|
287
|
+
|
|
288
|
+
title: str
|
|
289
|
+
playbook_md: str
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class SelectedRole(BaseModel):
|
|
293
|
+
"""Selected role information."""
|
|
294
|
+
|
|
295
|
+
role: str
|
|
296
|
+
title: str
|
|
297
|
+
playbook_md: str
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class ActivePolicyResponse(BaseModel):
|
|
301
|
+
"""Response for GET /v1/policies/active."""
|
|
302
|
+
|
|
303
|
+
policy_id: str
|
|
304
|
+
project_id: str
|
|
305
|
+
version: int
|
|
306
|
+
updated_at: datetime
|
|
307
|
+
invariants: List[Invariant]
|
|
308
|
+
roles: Dict[str, RolePlaybook]
|
|
309
|
+
selected_role: Optional[SelectedRole] = None
|
|
310
|
+
adapters: Dict[str, Any] = Field(default_factory=dict)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@router.get("/active")
|
|
314
|
+
async def get_active_policy_endpoint(
|
|
315
|
+
request: Request,
|
|
316
|
+
response: Response,
|
|
317
|
+
role: Optional[str] = Query(
|
|
318
|
+
None,
|
|
319
|
+
description="Role to select. If provided, includes selected_role in response.",
|
|
320
|
+
),
|
|
321
|
+
only_selected: bool = Query(
|
|
322
|
+
False,
|
|
323
|
+
description="If true, return only invariants + selected role (requires role param).",
|
|
324
|
+
),
|
|
325
|
+
if_none_match: Optional[str] = Header(None, alias="If-None-Match"),
|
|
326
|
+
db: DatabaseInfra = Depends(get_db_infra),
|
|
327
|
+
) -> ActivePolicyResponse:
|
|
328
|
+
"""
|
|
329
|
+
Get the active policy for the project.
|
|
330
|
+
|
|
331
|
+
Returns the active policy bundle including invariants, role playbooks, and adapters.
|
|
332
|
+
If no policy exists, bootstraps a default policy and returns it.
|
|
333
|
+
|
|
334
|
+
Supports conditional requests via ETag/If-None-Match for efficient caching.
|
|
335
|
+
|
|
336
|
+
Requires an authenticated project context.
|
|
337
|
+
"""
|
|
338
|
+
project_id = await get_project_from_auth(request, db)
|
|
339
|
+
server_db = db.get_manager("server")
|
|
340
|
+
|
|
341
|
+
# Get or bootstrap active policy
|
|
342
|
+
policy = await get_active_policy(server_db, project_id)
|
|
343
|
+
if not policy:
|
|
344
|
+
# This shouldn't happen since get_active_policy bootstraps by default
|
|
345
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
346
|
+
|
|
347
|
+
# Generate ETag
|
|
348
|
+
etag = _generate_etag(policy.policy_id, policy.updated_at)
|
|
349
|
+
response.headers["ETag"] = etag
|
|
350
|
+
|
|
351
|
+
# Check If-None-Match for conditional GET
|
|
352
|
+
if if_none_match and if_none_match == etag:
|
|
353
|
+
return Response(status_code=304, headers={"ETag": etag})
|
|
354
|
+
|
|
355
|
+
# Validate role selection
|
|
356
|
+
available_roles = list(policy.bundle.roles.keys())
|
|
357
|
+
selected_role_data = None
|
|
358
|
+
|
|
359
|
+
if role:
|
|
360
|
+
if role not in policy.bundle.roles:
|
|
361
|
+
raise HTTPException(
|
|
362
|
+
status_code=400,
|
|
363
|
+
detail=f"Role '{role}' not found. Available roles: {available_roles}",
|
|
364
|
+
)
|
|
365
|
+
role_info = policy.bundle.roles[role]
|
|
366
|
+
selected_role_data = SelectedRole(
|
|
367
|
+
role=role,
|
|
368
|
+
title=role_info.get("title", role),
|
|
369
|
+
playbook_md=role_info.get("playbook_md", ""),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if only_selected and not role:
|
|
373
|
+
raise HTTPException(
|
|
374
|
+
status_code=400,
|
|
375
|
+
detail="only_selected=true requires a role parameter",
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Build response
|
|
379
|
+
invariants = [
|
|
380
|
+
Invariant(
|
|
381
|
+
id=inv.get("id", ""),
|
|
382
|
+
title=inv.get("title", ""),
|
|
383
|
+
body_md=inv.get("body_md", ""),
|
|
384
|
+
)
|
|
385
|
+
for inv in policy.bundle.invariants
|
|
386
|
+
]
|
|
387
|
+
|
|
388
|
+
if only_selected:
|
|
389
|
+
# Return only invariants + selected role
|
|
390
|
+
# Type narrowing: role is guaranteed non-None here (validated at line 370-374)
|
|
391
|
+
assert role is not None
|
|
392
|
+
roles = {role: RolePlaybook(**policy.bundle.roles[role])}
|
|
393
|
+
else:
|
|
394
|
+
roles = {
|
|
395
|
+
k: RolePlaybook(
|
|
396
|
+
title=v.get("title", k),
|
|
397
|
+
playbook_md=v.get("playbook_md", ""),
|
|
398
|
+
)
|
|
399
|
+
for k, v in policy.bundle.roles.items()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return ActivePolicyResponse(
|
|
403
|
+
policy_id=policy.policy_id,
|
|
404
|
+
project_id=policy.project_id,
|
|
405
|
+
version=policy.version,
|
|
406
|
+
updated_at=policy.updated_at,
|
|
407
|
+
invariants=invariants,
|
|
408
|
+
roles=roles,
|
|
409
|
+
selected_role=selected_role_data,
|
|
410
|
+
adapters=policy.bundle.adapters,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# Admin endpoints for policy management
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class CreatePolicyRequest(BaseModel):
|
|
418
|
+
"""Request body for POST /v1/policies."""
|
|
419
|
+
|
|
420
|
+
bundle: PolicyBundle = Field(
|
|
421
|
+
...,
|
|
422
|
+
description="Policy bundle containing invariants, roles, and adapters.",
|
|
423
|
+
)
|
|
424
|
+
base_policy_id: Optional[str] = Field(
|
|
425
|
+
None,
|
|
426
|
+
description="Optional: policy this version is based on (for audit trail).",
|
|
427
|
+
)
|
|
428
|
+
created_by_workspace_id: Optional[str] = Field(
|
|
429
|
+
None,
|
|
430
|
+
description="Optional: workspace_id of the creator (for audit trail).",
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class CreatePolicyResponse(BaseModel):
|
|
435
|
+
"""Response for POST /v1/policies."""
|
|
436
|
+
|
|
437
|
+
policy_id: str
|
|
438
|
+
project_id: str
|
|
439
|
+
version: int
|
|
440
|
+
created: bool = True
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class ActivatePolicyResponse(BaseModel):
|
|
444
|
+
"""Response for POST /v1/policies/{id}/activate."""
|
|
445
|
+
|
|
446
|
+
activated: bool
|
|
447
|
+
active_policy_id: str
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class ResetPolicyResponse(BaseModel):
|
|
451
|
+
"""Response for POST /v1/policies/reset."""
|
|
452
|
+
|
|
453
|
+
reset: bool
|
|
454
|
+
active_policy_id: str
|
|
455
|
+
version: int
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class PolicyHistoryItem(BaseModel):
|
|
459
|
+
"""A policy version in the history list."""
|
|
460
|
+
|
|
461
|
+
policy_id: str
|
|
462
|
+
version: int
|
|
463
|
+
created_at: datetime
|
|
464
|
+
created_by_workspace_id: Optional[str]
|
|
465
|
+
is_active: bool
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class PolicyHistoryResponse(BaseModel):
|
|
469
|
+
"""Response for GET /v1/policies/history."""
|
|
470
|
+
|
|
471
|
+
policies: List[PolicyHistoryItem]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@router.get("/history")
|
|
475
|
+
async def list_policy_history(
|
|
476
|
+
request: Request,
|
|
477
|
+
limit: int = Query(20, ge=1, le=100, description="Max number of versions to return"),
|
|
478
|
+
db: DatabaseInfra = Depends(get_db_infra),
|
|
479
|
+
) -> PolicyHistoryResponse:
|
|
480
|
+
"""
|
|
481
|
+
List policy version history for the project.
|
|
482
|
+
|
|
483
|
+
Returns policy versions ordered by version descending (newest first).
|
|
484
|
+
Each entry indicates whether it's the currently active policy.
|
|
485
|
+
|
|
486
|
+
Requires an authenticated project context.
|
|
487
|
+
"""
|
|
488
|
+
project_id = await get_project_from_auth(request, db)
|
|
489
|
+
server_db = db.get_manager("server")
|
|
490
|
+
|
|
491
|
+
# Ensure the project has a default policy (v1) so newly-registered projects
|
|
492
|
+
# have a consistent policy history surface.
|
|
493
|
+
await get_active_policy(server_db, project_id, bootstrap_if_missing=True)
|
|
494
|
+
|
|
495
|
+
# Get active policy ID for this project
|
|
496
|
+
active_result = await server_db.fetch_one(
|
|
497
|
+
"SELECT active_policy_id FROM {{tables.projects}} WHERE id = $1 AND deleted_at IS NULL",
|
|
498
|
+
project_id,
|
|
499
|
+
)
|
|
500
|
+
active_policy_id = (
|
|
501
|
+
str(active_result["active_policy_id"])
|
|
502
|
+
if active_result and active_result["active_policy_id"]
|
|
503
|
+
else None
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
# Fetch policy versions
|
|
507
|
+
rows = await server_db.fetch_all(
|
|
508
|
+
"""
|
|
509
|
+
SELECT policy_id, version, created_at, created_by_workspace_id
|
|
510
|
+
FROM {{tables.project_policies}}
|
|
511
|
+
WHERE project_id = $1
|
|
512
|
+
ORDER BY version DESC
|
|
513
|
+
LIMIT $2
|
|
514
|
+
""",
|
|
515
|
+
project_id,
|
|
516
|
+
limit,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
policies = [
|
|
520
|
+
PolicyHistoryItem(
|
|
521
|
+
policy_id=str(row["policy_id"]),
|
|
522
|
+
version=row["version"],
|
|
523
|
+
created_at=row["created_at"],
|
|
524
|
+
created_by_workspace_id=(
|
|
525
|
+
str(row["created_by_workspace_id"]) if row["created_by_workspace_id"] else None
|
|
526
|
+
),
|
|
527
|
+
is_active=(str(row["policy_id"]) == active_policy_id),
|
|
528
|
+
)
|
|
529
|
+
for row in rows
|
|
530
|
+
]
|
|
531
|
+
|
|
532
|
+
return PolicyHistoryResponse(policies=policies)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@router.post("")
|
|
536
|
+
async def create_policy_endpoint(
|
|
537
|
+
request: Request,
|
|
538
|
+
payload: CreatePolicyRequest,
|
|
539
|
+
db: DatabaseInfra = Depends(get_db_infra),
|
|
540
|
+
) -> CreatePolicyResponse:
|
|
541
|
+
"""
|
|
542
|
+
Create a new policy version for the project.
|
|
543
|
+
|
|
544
|
+
Requires an authenticated project context.
|
|
545
|
+
|
|
546
|
+
The new policy is NOT automatically activated. Use POST /v1/policies/{id}/activate
|
|
547
|
+
to set it as the active policy.
|
|
548
|
+
"""
|
|
549
|
+
identity = await get_identity_from_auth(request, db)
|
|
550
|
+
project_id = identity.project_id
|
|
551
|
+
server_db = db.get_manager("server")
|
|
552
|
+
|
|
553
|
+
# Convert Pydantic model to dict for storage
|
|
554
|
+
bundle_dict = payload.bundle.model_dump()
|
|
555
|
+
|
|
556
|
+
created_by_workspace_id: Optional[str] = identity.agent_id if identity.agent_id else None
|
|
557
|
+
if payload.created_by_workspace_id:
|
|
558
|
+
try:
|
|
559
|
+
created_by_workspace_id = validate_workspace_id(payload.created_by_workspace_id)
|
|
560
|
+
except ValueError as e:
|
|
561
|
+
raise HTTPException(status_code=422, detail=str(e))
|
|
562
|
+
if identity.agent_id is not None and identity.agent_id != created_by_workspace_id:
|
|
563
|
+
raise HTTPException(
|
|
564
|
+
status_code=403,
|
|
565
|
+
detail="created_by_workspace_id does not match API key identity",
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
workspace = await server_db.fetch_one(
|
|
569
|
+
"""
|
|
570
|
+
SELECT workspace_id
|
|
571
|
+
FROM {{tables.workspaces}}
|
|
572
|
+
WHERE workspace_id = $1 AND project_id = $2 AND deleted_at IS NULL
|
|
573
|
+
""",
|
|
574
|
+
created_by_workspace_id,
|
|
575
|
+
project_id,
|
|
576
|
+
)
|
|
577
|
+
if not workspace:
|
|
578
|
+
raise HTTPException(
|
|
579
|
+
status_code=403,
|
|
580
|
+
detail="Workspace not found or does not belong to your project",
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
# Create the policy version
|
|
584
|
+
policy = await create_policy_version(
|
|
585
|
+
server_db,
|
|
586
|
+
project_id=project_id,
|
|
587
|
+
base_policy_id=payload.base_policy_id,
|
|
588
|
+
bundle=bundle_dict,
|
|
589
|
+
created_by_workspace_id=created_by_workspace_id,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
# Add audit log entry
|
|
593
|
+
await server_db.execute(
|
|
594
|
+
"""
|
|
595
|
+
INSERT INTO {{tables.audit_log}} (project_id, workspace_id, event_type, details)
|
|
596
|
+
VALUES ($1, $2, $3, $4::jsonb)
|
|
597
|
+
""",
|
|
598
|
+
project_id,
|
|
599
|
+
created_by_workspace_id,
|
|
600
|
+
"policy_created",
|
|
601
|
+
json.dumps(
|
|
602
|
+
{
|
|
603
|
+
"project_id": project_id,
|
|
604
|
+
"policy_id": policy.policy_id,
|
|
605
|
+
"version": policy.version,
|
|
606
|
+
"base_policy_id": payload.base_policy_id,
|
|
607
|
+
}
|
|
608
|
+
),
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
logger.info(
|
|
612
|
+
"Policy created via API: project=%s policy_id=%s version=%d",
|
|
613
|
+
project_id,
|
|
614
|
+
policy.policy_id,
|
|
615
|
+
policy.version,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
return CreatePolicyResponse(
|
|
619
|
+
policy_id=policy.policy_id,
|
|
620
|
+
project_id=policy.project_id,
|
|
621
|
+
version=policy.version,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@router.get("/{policy_id}")
|
|
626
|
+
async def get_policy_by_id_endpoint(
|
|
627
|
+
request: Request,
|
|
628
|
+
response: Response,
|
|
629
|
+
policy_id: str,
|
|
630
|
+
db: DatabaseInfra = Depends(get_db_infra),
|
|
631
|
+
) -> ActivePolicyResponse:
|
|
632
|
+
"""
|
|
633
|
+
Get a specific policy version by ID.
|
|
634
|
+
|
|
635
|
+
Used for previewing previous policy versions without activating them.
|
|
636
|
+
Requires an authenticated project context.
|
|
637
|
+
Returns 404 if policy doesn't exist or belongs to a different project.
|
|
638
|
+
"""
|
|
639
|
+
project_id = await get_project_from_auth(request, db)
|
|
640
|
+
server_db = db.get_manager("server")
|
|
641
|
+
|
|
642
|
+
# Fetch the policy, scoped to the project
|
|
643
|
+
result = await server_db.fetch_one(
|
|
644
|
+
"""
|
|
645
|
+
SELECT pp.policy_id, pp.project_id, pp.version, pp.bundle_json,
|
|
646
|
+
pp.created_by_workspace_id, pp.created_at, pp.updated_at
|
|
647
|
+
FROM {{tables.project_policies}} pp
|
|
648
|
+
WHERE pp.policy_id = $1 AND pp.project_id = $2
|
|
649
|
+
""",
|
|
650
|
+
policy_id,
|
|
651
|
+
project_id,
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
if not result:
|
|
655
|
+
raise HTTPException(
|
|
656
|
+
status_code=404,
|
|
657
|
+
detail="Policy not found or does not belong to this project",
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# Parse bundle_json
|
|
661
|
+
bundle_data = result["bundle_json"]
|
|
662
|
+
if isinstance(bundle_data, str):
|
|
663
|
+
bundle_data = json.loads(bundle_data)
|
|
664
|
+
|
|
665
|
+
bundle = PolicyBundle(**bundle_data)
|
|
666
|
+
|
|
667
|
+
# Build response (same shape as GET /active)
|
|
668
|
+
invariants = [
|
|
669
|
+
Invariant(
|
|
670
|
+
id=inv.get("id", ""),
|
|
671
|
+
title=inv.get("title", ""),
|
|
672
|
+
body_md=inv.get("body_md", ""),
|
|
673
|
+
)
|
|
674
|
+
for inv in bundle.invariants
|
|
675
|
+
]
|
|
676
|
+
|
|
677
|
+
roles = {
|
|
678
|
+
k: RolePlaybook(
|
|
679
|
+
title=v.get("title", k),
|
|
680
|
+
playbook_md=v.get("playbook_md", ""),
|
|
681
|
+
)
|
|
682
|
+
for k, v in bundle.roles.items()
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return ActivePolicyResponse(
|
|
686
|
+
policy_id=str(result["policy_id"]),
|
|
687
|
+
project_id=str(result["project_id"]),
|
|
688
|
+
version=result["version"],
|
|
689
|
+
updated_at=result["updated_at"],
|
|
690
|
+
invariants=invariants,
|
|
691
|
+
roles=roles,
|
|
692
|
+
selected_role=None,
|
|
693
|
+
adapters=bundle.adapters,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@router.post("/{policy_id}/activate")
|
|
698
|
+
async def activate_policy_endpoint(
|
|
699
|
+
request: Request,
|
|
700
|
+
policy_id: str,
|
|
701
|
+
db: DatabaseInfra = Depends(get_db_infra),
|
|
702
|
+
) -> ActivatePolicyResponse:
|
|
703
|
+
"""
|
|
704
|
+
Set a policy as the active policy for the project.
|
|
705
|
+
|
|
706
|
+
Requires an authenticated project context.
|
|
707
|
+
"""
|
|
708
|
+
project_id = await get_project_from_auth(request, db)
|
|
709
|
+
server_db = db.get_manager("server")
|
|
710
|
+
|
|
711
|
+
# Get current active policy for audit
|
|
712
|
+
current_active = await server_db.fetch_one(
|
|
713
|
+
"SELECT active_policy_id FROM {{tables.projects}} WHERE id = $1 AND deleted_at IS NULL",
|
|
714
|
+
project_id,
|
|
715
|
+
)
|
|
716
|
+
previous_policy_id = (
|
|
717
|
+
str(current_active["active_policy_id"])
|
|
718
|
+
if current_active and current_active["active_policy_id"]
|
|
719
|
+
else None
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# Activate the policy (validates ownership)
|
|
723
|
+
await activate_policy(
|
|
724
|
+
server_db,
|
|
725
|
+
project_id=project_id,
|
|
726
|
+
policy_id=policy_id,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
# Add audit log entry
|
|
730
|
+
await server_db.execute(
|
|
731
|
+
"""
|
|
732
|
+
INSERT INTO {{tables.audit_log}} (project_id, event_type, details)
|
|
733
|
+
VALUES ($1, $2, $3::jsonb)
|
|
734
|
+
""",
|
|
735
|
+
project_id,
|
|
736
|
+
"policy_activated",
|
|
737
|
+
json.dumps(
|
|
738
|
+
{
|
|
739
|
+
"project_id": project_id,
|
|
740
|
+
"policy_id": policy_id,
|
|
741
|
+
"previous_policy_id": previous_policy_id,
|
|
742
|
+
}
|
|
743
|
+
),
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
logger.info(
|
|
747
|
+
"Policy activated via API: project=%s policy_id=%s (was: %s)",
|
|
748
|
+
project_id,
|
|
749
|
+
policy_id,
|
|
750
|
+
previous_policy_id,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
return ActivatePolicyResponse(
|
|
754
|
+
activated=True,
|
|
755
|
+
active_policy_id=policy_id,
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
@router.post("/reset")
|
|
760
|
+
async def reset_policy_to_default_endpoint(
|
|
761
|
+
request: Request,
|
|
762
|
+
db: DatabaseInfra = Depends(get_db_infra),
|
|
763
|
+
) -> ResetPolicyResponse:
|
|
764
|
+
"""
|
|
765
|
+
Reset the project's policy to the current default bundle.
|
|
766
|
+
|
|
767
|
+
Reloads default invariants and roles from markdown files on disk, creates
|
|
768
|
+
a new policy version, and activates it. Prior versions are preserved.
|
|
769
|
+
|
|
770
|
+
Requires an authenticated project context.
|
|
771
|
+
"""
|
|
772
|
+
project_id = await get_project_from_auth(request, db)
|
|
773
|
+
server_db = db.get_manager("server")
|
|
774
|
+
|
|
775
|
+
current_active = await server_db.fetch_one(
|
|
776
|
+
"SELECT active_policy_id FROM {{tables.projects}} WHERE id = $1 AND deleted_at IS NULL",
|
|
777
|
+
project_id,
|
|
778
|
+
)
|
|
779
|
+
previous_policy_id = (
|
|
780
|
+
str(current_active["active_policy_id"])
|
|
781
|
+
if current_active and current_active["active_policy_id"]
|
|
782
|
+
else None
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
# Reload defaults from disk (atomic, protected by lock)
|
|
786
|
+
try:
|
|
787
|
+
fresh_bundle = get_default_bundle(force_reload=True)
|
|
788
|
+
except Exception as e:
|
|
789
|
+
logger.error("Failed to reload default bundle: %s", e, exc_info=True)
|
|
790
|
+
raise HTTPException(
|
|
791
|
+
status_code=500,
|
|
792
|
+
detail=f"Failed to reload default policy bundle: {e}",
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
policy = await create_policy_version(
|
|
796
|
+
server_db,
|
|
797
|
+
project_id=project_id,
|
|
798
|
+
base_policy_id=previous_policy_id,
|
|
799
|
+
bundle=fresh_bundle,
|
|
800
|
+
created_by_workspace_id=None,
|
|
801
|
+
)
|
|
802
|
+
await activate_policy(server_db, project_id=project_id, policy_id=policy.policy_id)
|
|
803
|
+
|
|
804
|
+
await server_db.execute(
|
|
805
|
+
"""
|
|
806
|
+
INSERT INTO {{tables.audit_log}} (project_id, event_type, details)
|
|
807
|
+
VALUES ($1, $2, $3::jsonb)
|
|
808
|
+
""",
|
|
809
|
+
project_id,
|
|
810
|
+
"policy_reset_to_default",
|
|
811
|
+
json.dumps(
|
|
812
|
+
{
|
|
813
|
+
"project_id": project_id,
|
|
814
|
+
"policy_id": policy.policy_id,
|
|
815
|
+
"version": policy.version,
|
|
816
|
+
"previous_policy_id": previous_policy_id,
|
|
817
|
+
}
|
|
818
|
+
),
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
logger.info(
|
|
822
|
+
"Policy reset to default via API: project=%s policy_id=%s version=%d (was: %s)",
|
|
823
|
+
project_id,
|
|
824
|
+
policy.policy_id,
|
|
825
|
+
policy.version,
|
|
826
|
+
previous_policy_id,
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
return ResetPolicyResponse(
|
|
830
|
+
reset=True,
|
|
831
|
+
active_policy_id=policy.policy_id,
|
|
832
|
+
version=policy.version,
|
|
833
|
+
)
|