Skip to content

Commit a95e427

Browse files
committed
feat: add workspace operations control plane
1 parent 1bdc0d8 commit a95e427

70 files changed

Lines changed: 5702 additions & 397 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTEXT.md

Lines changed: 340 additions & 2 deletions
Large diffs are not rendered by default.

TODOS.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,35 @@
5151
- **Cons:** Payload schema changes must update both request-capture assertions and expected transcript evidence.
5252
- **Context:** Capability/status surfaces remain blocked by default because each run still needs user configuration and upstream projection input, but the backend delivery path is now executable.
5353
- **Depends on / blocked by:** Future webhook payload schema or notifier security changes.
54+
55+
## Consolidated follow-ups from completed goals
56+
57+
### Activate or remove model-provider failover
58+
59+
- **Status:** `ProviderResolver.resolve_with_fallback()` is implemented and tested but has no production caller.
60+
- **What:** Either route one explicit provider role through the resolver or remove the unused failover surface.
61+
- **Why:** Defaults and cooldown currently imply runtime failover that the application does not perform.
62+
63+
### Live-test the model-provider UI
64+
65+
- **Status:** Type checking, linting, and production build passed; create/edit/delete, sync, connection test, defaults ordering, and toast behavior were not exercised against a live backend.
66+
- **What:** Run one focused full-stack acceptance pass and record or fix the observed result.
67+
- **Why:** Compile-time checks do not validate mutation wiring or response handling.
68+
69+
### Expand Browser Act support only from real demand
70+
71+
- **Status:** 78 vendored packs exist, but only 2 have manifests; the interpreter does not URL-encode parameters or model arithmetic pagination.
72+
- **What:** Add manifests and interpreter features when onboarding a concrete pack, starting with correct URL encoding for any URL-bound parameter.
73+
- **Why:** Bulk-promising support for every vendored pack would overstate runtime capability; demand-led onboarding keeps each claim testable.
74+
75+
### Execute capability probes
76+
77+
- **Status:** Capability manifests expose probe names, but the workflow capability layer does not execute richer runtime probes.
78+
- **What:** Add probe execution when a concrete capability needs environment-level readiness evidence.
79+
- **Why:** A declared probe name alone cannot prove that a capability is runnable in the current deployment.
80+
81+
### Add guarded production tool executors
82+
83+
- **Status:** `workflow.external-tool.capability` accepts only the deterministic fixture executor seam.
84+
- **What:** Add a real executor adapter only when onboarding the first production external Tool Capability.
85+
- **Why:** Imported tools should remain blocked until a guarded, observable executor exists; speculative adapters are unnecessary.

backend/api/v1/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77
browser_act,
88
browsers,
99
chat,
10+
consumer_grants,
1011
control,
1112
cookies,
1213
dashboard,
14+
identity,
1315
model_defaults,
1416
nodes,
1517
notifications,
18+
operations_agents,
19+
operations_inbox,
1620
plan_ir,
1721
plans,
1822
presets,
@@ -28,6 +32,7 @@
2832
webhooks,
2933
workers,
3034
workflows,
35+
workspaces,
3136
)
3237

3338
v1_router = APIRouter(prefix="/api/v1")
@@ -37,6 +42,7 @@
3742
v1_router.include_router(browsers.router)
3843
v1_router.include_router(chat.router)
3944
v1_router.include_router(control.router)
45+
v1_router.include_router(consumer_grants.router)
4046
v1_router.include_router(cookies.router)
4147
v1_router.include_router(model_defaults.router)
4248
v1_router.include_router(nodes.router)
@@ -54,6 +60,10 @@
5460
v1_router.include_router(webhooks.router)
5561
v1_router.include_router(workflows.router)
5662
v1_router.include_router(notifications.router)
63+
v1_router.include_router(operations_inbox.router)
64+
v1_router.include_router(operations_agents.router)
5765
v1_router.include_router(workers.router)
5866
v1_router.include_router(dashboard.router)
5967
v1_router.include_router(system.router)
68+
v1_router.include_router(identity.router)
69+
v1_router.include_router(workspaces.router)

backend/api/v1/consumer_grants.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
from datetime import UTC, datetime
2+
3+
from fastapi import APIRouter, Depends, HTTPException, status
4+
from sqlalchemy import select
5+
from sqlalchemy.ext.asyncio import AsyncSession
6+
7+
from backend.database import get_db
8+
from backend.models.consumer_grant import ConsumerGrant
9+
from backend.models.identity import ServiceIdentity
10+
from backend.schemas.common import ApiResponse
11+
from backend.schemas.consumer_grant import (
12+
ConsumerGrantCreate,
13+
ConsumerGrantPatch,
14+
ConsumerGrantRead,
15+
ConsumerGrantRevoke,
16+
)
17+
from backend.security.identity import RequestIdentity, get_request_identity
18+
from backend.security.workspace_rbac import (
19+
WorkspacePermission,
20+
get_workspace_access,
21+
require_permission,
22+
)
23+
24+
router = APIRouter(prefix="/workspaces/{workspace_id}/consumer-grants", tags=["consumer-grants"])
25+
26+
27+
def _read_grant(grant: ConsumerGrant) -> ConsumerGrantRead:
28+
if grant.revoked_at is not None:
29+
grant_status = "revoked"
30+
else:
31+
grant_status = "enabled" if grant.enabled else "disabled"
32+
return ConsumerGrantRead(
33+
id=grant.id,
34+
service_identity_id=grant.service_identity_id,
35+
name=grant.name,
36+
resource_scope=grant.resource_scope,
37+
data_scope=grant.data_scope,
38+
quota=grant.quota,
39+
status=grant_status,
40+
enabled=grant.enabled,
41+
created_by_user_id=grant.created_by_user_id,
42+
revoked_at=grant.revoked_at,
43+
revoked_by_user_id=grant.revoked_by_user_id,
44+
revocation_reason=grant.revocation_reason,
45+
created_at=grant.created_at,
46+
updated_at=grant.updated_at,
47+
)
48+
49+
50+
async def _get_grant(
51+
db: AsyncSession, workspace_id: str, grant_id: str, *, lock: bool = False
52+
) -> ConsumerGrant:
53+
query = (
54+
select(ConsumerGrant)
55+
.join(ServiceIdentity, ServiceIdentity.id == ConsumerGrant.service_identity_id)
56+
.where(ServiceIdentity.workspace_id == workspace_id)
57+
.where(ConsumerGrant.id == grant_id)
58+
)
59+
if lock:
60+
query = query.with_for_update()
61+
grant = await db.scalar(query)
62+
if grant is None:
63+
raise HTTPException(status.HTTP_404_NOT_FOUND, "Consumer Grant not found")
64+
return grant
65+
66+
67+
@router.get("", response_model=ApiResponse[list[ConsumerGrantRead]])
68+
async def list_consumer_grants(
69+
workspace_id: str,
70+
identity: RequestIdentity = Depends(get_request_identity),
71+
db: AsyncSession = Depends(get_db),
72+
) -> ApiResponse:
73+
access = await get_workspace_access(db, workspace_id, identity)
74+
require_permission(access, WorkspacePermission.READ)
75+
grants = (
76+
(
77+
await db.execute(
78+
select(ConsumerGrant)
79+
.join(
80+
ServiceIdentity,
81+
ServiceIdentity.id == ConsumerGrant.service_identity_id,
82+
)
83+
.where(ServiceIdentity.workspace_id == workspace_id)
84+
.order_by(ConsumerGrant.created_at)
85+
)
86+
)
87+
.scalars()
88+
.all()
89+
)
90+
return ApiResponse.ok([_read_grant(grant) for grant in grants])
91+
92+
93+
@router.post("", response_model=ApiResponse[ConsumerGrantRead], status_code=201)
94+
async def create_consumer_grant(
95+
workspace_id: str,
96+
body: ConsumerGrantCreate,
97+
identity: RequestIdentity = Depends(get_request_identity),
98+
db: AsyncSession = Depends(get_db),
99+
) -> ApiResponse:
100+
access = await get_workspace_access(db, workspace_id, identity)
101+
require_permission(access, WorkspacePermission.MANAGE_CONSUMER_GRANTS)
102+
service_identity = await db.scalar(
103+
select(ServiceIdentity)
104+
.where(ServiceIdentity.id == body.service_identity_id)
105+
.where(ServiceIdentity.workspace_id == workspace_id)
106+
)
107+
if service_identity is None:
108+
raise HTTPException(
109+
status.HTTP_422_UNPROCESSABLE_CONTENT,
110+
"Service Identity must belong to Workspace",
111+
)
112+
if service_identity.disabled:
113+
raise HTTPException(
114+
status.HTTP_409_CONFLICT,
115+
"Disabled Service Identity cannot receive a Consumer Grant",
116+
)
117+
existing = await db.scalar(
118+
select(ConsumerGrant)
119+
.where(ConsumerGrant.service_identity_id == service_identity.id)
120+
.where(ConsumerGrant.name == body.name)
121+
)
122+
if existing is not None:
123+
raise HTTPException(status.HTTP_409_CONFLICT, "Consumer Grant name already exists")
124+
125+
grant = ConsumerGrant(
126+
service_identity_id=service_identity.id,
127+
name=body.name,
128+
resource_scope=body.resource_scope.model_dump(mode="json"),
129+
data_scope=body.data_scope.model_dump(mode="json"),
130+
quota=body.quota.model_dump(mode="json"),
131+
created_by_user_id=access.user_id,
132+
)
133+
db.add(grant)
134+
await db.flush()
135+
return ApiResponse.ok(_read_grant(grant))
136+
137+
138+
@router.patch("/{grant_id}", response_model=ApiResponse[ConsumerGrantRead])
139+
async def patch_consumer_grant(
140+
workspace_id: str,
141+
grant_id: str,
142+
body: ConsumerGrantPatch,
143+
identity: RequestIdentity = Depends(get_request_identity),
144+
db: AsyncSession = Depends(get_db),
145+
) -> ApiResponse:
146+
access = await get_workspace_access(db, workspace_id, identity)
147+
require_permission(access, WorkspacePermission.MANAGE_CONSUMER_GRANTS)
148+
grant = await _get_grant(db, workspace_id, grant_id, lock=True)
149+
if grant.revoked_at is not None:
150+
raise HTTPException(status.HTTP_409_CONFLICT, "Revoked Consumer Grant cannot be changed")
151+
grant.enabled = body.enabled
152+
await db.flush()
153+
return ApiResponse.ok(_read_grant(grant))
154+
155+
156+
@router.post("/{grant_id}/revoke", response_model=ApiResponse[ConsumerGrantRead])
157+
async def revoke_consumer_grant(
158+
workspace_id: str,
159+
grant_id: str,
160+
body: ConsumerGrantRevoke,
161+
identity: RequestIdentity = Depends(get_request_identity),
162+
db: AsyncSession = Depends(get_db),
163+
) -> ApiResponse:
164+
access = await get_workspace_access(db, workspace_id, identity)
165+
require_permission(access, WorkspacePermission.MANAGE_CONSUMER_GRANTS)
166+
grant = await _get_grant(db, workspace_id, grant_id, lock=True)
167+
if grant.revoked_at is not None:
168+
raise HTTPException(status.HTTP_409_CONFLICT, "Consumer Grant is already revoked")
169+
grant.enabled = False
170+
grant.revoked_at = datetime.now(UTC)
171+
grant.revoked_by_user_id = access.user_id
172+
grant.revocation_reason = body.reason
173+
await db.flush()
174+
return ApiResponse.ok(_read_grant(grant))

backend/api/v1/identity.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Request identity endpoint."""
2+
3+
from typing import Annotated
4+
5+
from fastapi import APIRouter, Depends
6+
7+
from backend.schemas.common import ApiResponse
8+
from backend.security.identity import RequestIdentity, get_request_identity
9+
10+
router = APIRouter(prefix="/auth", tags=["auth"])
11+
12+
13+
@router.get("/me", response_model=ApiResponse[dict])
14+
async def read_identity(
15+
identity: Annotated[RequestIdentity, Depends(get_request_identity)],
16+
) -> ApiResponse:
17+
return ApiResponse.ok(
18+
{
19+
"subject": identity.subject,
20+
"email": identity.email,
21+
"name": identity.name,
22+
"is_platform_admin": identity.is_platform_admin,
23+
"auth_method": identity.auth_method,
24+
}
25+
)

backend/api/v1/nodes.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ def _install_script_template(
447447
448448
set -euo pipefail
449449
CENTRAL_API_URL="${{CENTRAL_API_URL:-{central_url}}}"
450-
AGENT_API_TOKEN="${{AGENT_API_TOKEN:-{agent_api_token}}}"
450+
AGENT_API_TOKEN="${{AGENT_API_TOKEN:-${{API_AUTH_TOKEN:-{agent_api_token}}}}}"
451451
AGENT_REGISTER="${{AGENT_REGISTER:-ws}}"
452452
AGENT_PORT="${{AGENT_PORT:-19823}}"
453453
AGENT_ADVERTISE_URL="${{AGENT_ADVERTISE_URL:-}}"
@@ -474,7 +474,10 @@ def _install_script_template(
474474
475475
case "$FLEET_NETWORK_PROVIDER" in
476476
lan|netbird|wireguard|ssh|custom) ;;
477-
*) die "Unknown FLEET_NETWORK_PROVIDER '$FLEET_NETWORK_PROVIDER'. Use lan, netbird, wireguard, ssh, or custom." ;;
477+
*)
478+
die "Unknown FLEET_NETWORK_PROVIDER '$FLEET_NETWORK_PROVIDER'. "\
479+
"Use lan, netbird, wireguard, ssh, or custom."
480+
;;
478481
esac
479482
480483
run_netbird() {{
@@ -543,15 +546,22 @@ def _install_script_template(
543546
;;
544547
wireguard)
545548
info "WireGuard provider selected; assuming the WireGuard interface is already up."
546-
[[ -n "$AGENT_ADVERTISE_URL" ]] || warn "Set AGENT_ADVERTISE_URL to the WireGuard-reachable agent URL when center HTTP callbacks are required."
549+
[[ -n "$AGENT_ADVERTISE_URL" ]] || \
550+
warn "Set AGENT_ADVERTISE_URL to the WireGuard-reachable agent URL "\
551+
"when center HTTP callbacks are required."
547552
;;
548553
ssh)
549554
info "SSH provider selected; assuming the SSH tunnel is already established."
550-
[[ -n "$AGENT_ADVERTISE_URL" ]] || warn "Set AGENT_ADVERTISE_URL to the forwarded agent URL when center HTTP callbacks are required."
555+
[[ -n "$AGENT_ADVERTISE_URL" ]] || \
556+
warn "Set AGENT_ADVERTISE_URL to the forwarded agent URL "\
557+
"when center HTTP callbacks are required."
551558
;;
552559
custom)
553-
info "Custom network provider selected; assuming reachability is managed outside this installer."
554-
[[ -n "$AGENT_ADVERTISE_URL" ]] || warn "Set AGENT_ADVERTISE_URL to the center-reachable agent URL when center HTTP callbacks are required."
560+
info "Custom network provider selected; assuming reachability is managed "\
561+
"outside this installer."
562+
[[ -n "$AGENT_ADVERTISE_URL" ]] || \
563+
warn "Set AGENT_ADVERTISE_URL to the center-reachable agent URL "\
564+
"when center HTTP callbacks are required."
555565
;;
556566
esac
557567
}}

0 commit comments

Comments
 (0)