Skip to content

Commit cc7a2f0

Browse files
committed
Akhil check this carefully - RBAC and workspace API
1 parent d67fc8c commit cc7a2f0

67 files changed

Lines changed: 3373 additions & 494 deletions

Some content is hidden

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

apps/backend/app/api/router.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from app.api.routes.url_shortener.api import router as url_shortener_router
2424
from app.api.routes.dns_lookup.api import router as dns_lookup_router
2525
from app.api.routes.audit_log.api import router as audit_log_router
26+
from app.api.routes.workspaces.api import router as workspaces_router
2627

2728
api_router = APIRouter()
2829
api_router.include_router(health_router)
@@ -48,3 +49,4 @@
4849
api_router.include_router(url_shortener_router)
4950
api_router.include_router(dns_lookup_router)
5051
api_router.include_router(audit_log_router)
52+
api_router.include_router(workspaces_router)

apps/backend/app/api/routes/api_client/api.py

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,28 @@
1313
ApiClientHistoryCreate,
1414
ApiClientHistoryOut,
1515
)
16-
from app.api.routes.auth.services import get_current_uid
16+
from app.api.routes.workspaces.deps import WorkspaceContext, require_tool
17+
18+
# Every api-client endpoint requires the active role to have the api-client tool.
19+
# `require_tool` resolves the workspace from X-Workspace-Id, verifies membership,
20+
# and 403s if the role can't use this tool. This is the authoritative server gate.
21+
TOOL = "/app/api-client"
1722

1823
router = APIRouter(prefix="/api-client", tags=["api-client"])
1924
router.include_router(collections_delta.router)
2025

2126

2227
@router.get("/collections", response_model=list[ApiClientCollectionOut], summary="List API client collections")
23-
async def list_collections(uid: str = Depends(get_current_uid)) -> list[ApiClientCollectionOut]:
24-
return await api_client_svc.list_collections(uid=uid)
28+
async def list_collections(ctx: WorkspaceContext = Depends(require_tool(TOOL))) -> list[ApiClientCollectionOut]:
29+
return await api_client_svc.list_collections(uid=ctx.uid, workspace_id=ctx.workspace_id)
2530

2631

2732
@router.post("/collections", response_model=ApiClientCollectionOut, summary="Create API client collection")
2833
async def create_collection(
2934
body: ApiClientCollectionCreate,
30-
uid: str = Depends(get_current_uid),
35+
ctx: WorkspaceContext = Depends(require_tool(TOOL)),
3136
) -> ApiClientCollectionOut:
32-
return await api_client_svc.create_collection(uid, body)
37+
return await api_client_svc.create_collection(ctx.uid, ctx.workspace_id, body)
3338

3439

3540
@router.patch(
@@ -40,27 +45,27 @@ async def create_collection(
4045
async def patch_collection(
4146
collection_id: str,
4247
body: ApiClientCollectionUpdate,
43-
uid: str = Depends(get_current_uid),
48+
ctx: WorkspaceContext = Depends(require_tool(TOOL)),
4449
) -> ApiClientCollectionOut:
45-
return await api_client_svc.patch_collection(uid, collection_id, body)
50+
return await api_client_svc.patch_collection(ctx.uid, ctx.workspace_id, collection_id, body)
4651

4752

4853
@router.delete("/collections/{collection_id}", status_code=204, summary="Delete API client collection")
49-
async def delete_collection(collection_id: str, uid: str = Depends(get_current_uid)) -> None:
50-
await api_client_svc.delete_collection(uid, collection_id)
54+
async def delete_collection(collection_id: str, ctx: WorkspaceContext = Depends(require_tool(TOOL))) -> None:
55+
await api_client_svc.delete_collection(ctx.uid, ctx.workspace_id, collection_id)
5156

5257

5358
@router.get("/environments", response_model=list[ApiClientEnvironmentOut], summary="List API client environments")
54-
async def list_environments(uid: str = Depends(get_current_uid)) -> list[ApiClientEnvironmentOut]:
55-
return await api_client_svc.list_environments(uid=uid)
59+
async def list_environments(ctx: WorkspaceContext = Depends(require_tool(TOOL))) -> list[ApiClientEnvironmentOut]:
60+
return await api_client_svc.list_environments(uid=ctx.uid, workspace_id=ctx.workspace_id)
5661

5762

5863
@router.post("/environments", response_model=ApiClientEnvironmentOut, summary="Create API client environment")
5964
async def create_environment(
6065
body: ApiClientEnvironmentCreate,
61-
uid: str = Depends(get_current_uid),
66+
ctx: WorkspaceContext = Depends(require_tool(TOOL)),
6267
) -> ApiClientEnvironmentOut:
63-
return await api_client_svc.create_environment(uid, body)
68+
return await api_client_svc.create_environment(ctx.uid, ctx.workspace_id, body)
6469

6570

6671
@router.patch(
@@ -71,40 +76,40 @@ async def create_environment(
7176
async def patch_environment(
7277
environment_id: str,
7378
body: ApiClientEnvironmentUpdate,
74-
uid: str = Depends(get_current_uid),
79+
ctx: WorkspaceContext = Depends(require_tool(TOOL)),
7580
) -> ApiClientEnvironmentOut:
76-
return await api_client_svc.patch_environment(uid, environment_id, body)
81+
return await api_client_svc.patch_environment(ctx.uid, ctx.workspace_id, environment_id, body)
7782

7883

7984
@router.delete("/environments/{environment_id}", status_code=204, summary="Delete API client environment")
80-
async def delete_environment(environment_id: str, uid: str = Depends(get_current_uid)) -> None:
81-
await api_client_svc.delete_environment(uid, environment_id)
85+
async def delete_environment(environment_id: str, ctx: WorkspaceContext = Depends(require_tool(TOOL))) -> None:
86+
await api_client_svc.delete_environment(ctx.uid, ctx.workspace_id, environment_id)
8287

8388

8489
@router.get("/history", response_model=list[ApiClientHistoryOut], summary="List API client request history")
8590
async def list_history(
86-
uid: str = Depends(get_current_uid),
91+
ctx: WorkspaceContext = Depends(require_tool(TOOL)),
8792
limit: int = Query(default=HISTORY_MAX_ITEMS, ge=1, le=HISTORY_MAX_ITEMS),
8893
) -> list[ApiClientHistoryOut]:
89-
return await api_client_svc.list_history(uid=uid, limit=limit)
94+
return await api_client_svc.list_history(uid=ctx.uid, workspace_id=ctx.workspace_id, limit=limit)
9095

9196

9297
@router.post("/history", response_model=ApiClientHistoryOut, summary="Append API client history entry")
9398
async def create_history(
9499
body: ApiClientHistoryCreate,
95100
background_tasks: BackgroundTasks,
96-
uid: str = Depends(get_current_uid),
101+
ctx: WorkspaceContext = Depends(require_tool(TOOL)),
97102
) -> ApiClientHistoryOut:
98-
entry = await api_client_svc.create_history(uid, body)
99-
background_tasks.add_task(api_client_svc.trim_history, uid)
103+
entry = await api_client_svc.create_history(ctx.uid, ctx.workspace_id, body)
104+
background_tasks.add_task(api_client_svc.trim_history, ctx.uid, ctx.workspace_id)
100105
return entry
101106

102107

103108
@router.delete("/history/clear", status_code=204, summary="Clear all API client history")
104-
async def clear_history(uid: str = Depends(get_current_uid)) -> None:
105-
await api_client_svc.clear_history(uid)
109+
async def clear_history(ctx: WorkspaceContext = Depends(require_tool(TOOL))) -> None:
110+
await api_client_svc.clear_history(ctx.uid, ctx.workspace_id)
106111

107112

108113
@router.delete("/history/{entry_id}", status_code=204, summary="Delete one history entry")
109-
async def delete_history_entry(entry_id: str, uid: str = Depends(get_current_uid)) -> None:
110-
await api_client_svc.delete_history_entry(uid, entry_id)
114+
async def delete_history_entry(entry_id: str, ctx: WorkspaceContext = Depends(require_tool(TOOL))) -> None:
115+
await api_client_svc.delete_history_entry(ctx.uid, ctx.workspace_id, entry_id)

apps/backend/app/api/routes/api_client/collections_delta.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@
2727
Op,
2828
UpdateItemOp,
2929
)
30-
from app.api.routes.auth.services import get_current_uid
30+
from app.api.routes.workspaces.deps import WorkspaceContext, require_tool
3131
from app.core.cache import bump_version
3232
from app.database import db_manager
3333
from app.utils.collection_name import API_CLIENT_COLLECTIONS
3434

35+
# ponytail: cache scope stays "user"; Phase 2 (multi-member) must key invalidation on workspace_id.
36+
TOOL = "/app/api-client"
37+
3538
router = APIRouter()
3639

3740

@@ -200,13 +203,14 @@ def _apply_move(
200203

201204
async def apply_collection_delta(
202205
uid: str,
206+
workspace_id: str,
203207
collection_id: str,
204208
ops: list[Op],
205209
) -> ApiClientCollectionOut:
206210
oid = _parse_oid(collection_id, kind="collection")
207211

208212
# Fetch + ownership check BEFORE any mutation
209-
doc = await db_manager.find_one(API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid})
213+
doc = await db_manager.find_one(API_CLIENT_COLLECTIONS, {"_id": oid, "workspace_id": workspace_id})
210214
if not doc:
211215
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.")
212216

@@ -245,7 +249,7 @@ async def apply_collection_delta(
245249
try:
246250
updated_doc = await db_manager.find_one_and_update(
247251
API_CLIENT_COLLECTIONS,
248-
{"_id": oid, "created_by": uid},
252+
{"_id": oid, "workspace_id": workspace_id},
249253
{"$set": {"items": items}},
250254
return_document=ReturnDocument.AFTER,
251255
)
@@ -274,7 +278,7 @@ async def apply_collection_delta(
274278
async def apply_delta(
275279
collection_id: str,
276280
body: ApplyDeltaRequest,
277-
uid: str = Depends(get_current_uid),
281+
ctx: WorkspaceContext = Depends(require_tool(TOOL)),
278282
) -> ApplyDeltaResponse:
279-
collection = await apply_collection_delta(uid, collection_id, body.ops)
283+
collection = await apply_collection_delta(ctx.uid, ctx.workspace_id, collection_id, body.ops)
280284
return ApplyDeltaResponse(collection=collection)

apps/backend/app/api/routes/api_client/services.py

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
)
2727
from app.utils.crud import safe_delete_one, safe_insert, safe_update_one
2828

29+
# ponytail: cache scope stays "user"; Phase 2 (multi-member) must key invalidation on workspace_id.
30+
2931
HISTORY_TRIM_BATCH_SIZE = 500
3032

3133

@@ -58,80 +60,80 @@ def _env_to_out(doc: dict[str, Any]) -> ApiClientEnvironmentOut:
5860

5961

6062
@cached(ns="api_client", ttl=300, scope="user")
61-
async def list_collections(*, uid: str) -> list[ApiClientCollectionOut]:
63+
async def list_collections(*, uid: str, workspace_id: str) -> list[ApiClientCollectionOut]:
6264
docs = await db_manager.find(
6365
API_CLIENT_COLLECTIONS,
64-
{"created_by": uid},
66+
{"workspace_id": workspace_id},
6567
{"_id": 1, "name": 1, "items": 1},
6668
sort=[("name", 1), ("_id", 1)],
6769
)
6870
return [_collection_to_out(d) for d in docs]
6971

7072

71-
async def create_collection(uid: str, body: ApiClientCollectionCreate) -> ApiClientCollectionOut:
72-
doc: dict[str, Any] = {"created_by": uid, "name": body.name, "items": []}
73+
async def create_collection(uid: str, workspace_id: str, body: ApiClientCollectionCreate) -> ApiClientCollectionOut:
74+
doc: dict[str, Any] = {"workspace_id": workspace_id, "created_by": uid, "name": body.name, "items": []}
7375
await safe_insert(API_CLIENT_COLLECTIONS, doc, name="Collection")
7476
await bump_version(ns="api_client", uid=uid)
7577
return _collection_to_out(doc)
7678

7779

78-
async def patch_collection(uid: str, collection_id: str, body: ApiClientCollectionUpdate) -> ApiClientCollectionOut:
80+
async def patch_collection(uid: str, workspace_id: str, collection_id: str, body: ApiClientCollectionUpdate) -> ApiClientCollectionOut:
7981
oid = _parse_oid(collection_id, kind="collection")
8082
patch = body.model_dump(exclude_unset=True)
8183
if not patch:
82-
doc = await db_manager.find_one(API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid})
84+
doc = await db_manager.find_one(API_CLIENT_COLLECTIONS, {"_id": oid, "workspace_id": workspace_id})
8385
if not doc:
8486
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.")
8587
return _collection_to_out(doc)
8688
doc = await safe_update_one(
87-
API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid}, patch, name="Collection"
89+
API_CLIENT_COLLECTIONS, {"_id": oid, "workspace_id": workspace_id}, patch, name="Collection"
8890
)
8991
await bump_version(ns="api_client", uid=uid)
9092
return _collection_to_out(doc)
9193

9294

93-
async def delete_collection(uid: str, collection_id: str) -> None:
95+
async def delete_collection(uid: str, workspace_id: str, collection_id: str) -> None:
9496
oid = _parse_oid(collection_id, kind="collection")
95-
await safe_delete_one(API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid}, name="Collection")
97+
await safe_delete_one(API_CLIENT_COLLECTIONS, {"_id": oid, "workspace_id": workspace_id}, name="Collection")
9698
await bump_version(ns="api_client", uid=uid)
9799

98100

99101
@cached(ns="api_client", ttl=300, scope="user")
100-
async def list_environments(*, uid: str) -> list[ApiClientEnvironmentOut]:
102+
async def list_environments(*, uid: str, workspace_id: str) -> list[ApiClientEnvironmentOut]:
101103
docs = await db_manager.find(
102104
API_CLIENT_ENVIRONMENTS,
103-
{"created_by": uid},
105+
{"workspace_id": workspace_id},
104106
{"_id": 1, "name": 1, "variables": 1},
105107
sort=[("name", 1), ("_id", 1)],
106108
)
107109
return [_env_to_out(d) for d in docs]
108110

109111

110-
async def create_environment(uid: str, body: ApiClientEnvironmentCreate) -> ApiClientEnvironmentOut:
111-
doc: dict[str, Any] = {"created_by": uid, "name": body.name, "variables": []}
112+
async def create_environment(uid: str, workspace_id: str, body: ApiClientEnvironmentCreate) -> ApiClientEnvironmentOut:
113+
doc: dict[str, Any] = {"workspace_id": workspace_id, "created_by": uid, "name": body.name, "variables": []}
112114
await safe_insert(API_CLIENT_ENVIRONMENTS, doc, name="Environment")
113115
await bump_version(ns="api_client", uid=uid)
114116
return _env_to_out(doc)
115117

116118

117-
async def patch_environment(uid: str, environment_id: str, body: ApiClientEnvironmentUpdate) -> ApiClientEnvironmentOut:
119+
async def patch_environment(uid: str, workspace_id: str, environment_id: str, body: ApiClientEnvironmentUpdate) -> ApiClientEnvironmentOut:
118120
oid = _parse_oid(environment_id, kind="environment")
119121
patch = body.model_dump(exclude_unset=True)
120122
if not patch:
121-
doc = await db_manager.find_one(API_CLIENT_ENVIRONMENTS, {"_id": oid, "created_by": uid})
123+
doc = await db_manager.find_one(API_CLIENT_ENVIRONMENTS, {"_id": oid, "workspace_id": workspace_id})
122124
if not doc:
123125
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Environment not found.")
124126
return _env_to_out(doc)
125127
doc = await safe_update_one(
126-
API_CLIENT_ENVIRONMENTS, {"_id": oid, "created_by": uid}, patch, name="Environment"
128+
API_CLIENT_ENVIRONMENTS, {"_id": oid, "workspace_id": workspace_id}, patch, name="Environment"
127129
)
128130
await bump_version(ns="api_client", uid=uid)
129131
return _env_to_out(doc)
130132

131133

132-
async def delete_environment(uid: str, environment_id: str) -> None:
134+
async def delete_environment(uid: str, workspace_id: str, environment_id: str) -> None:
133135
oid = _parse_oid(environment_id, kind="environment")
134-
await safe_delete_one(API_CLIENT_ENVIRONMENTS, {"_id": oid, "created_by": uid}, name="Environment")
136+
await safe_delete_one(API_CLIENT_ENVIRONMENTS, {"_id": oid, "workspace_id": workspace_id}, name="Environment")
135137
await bump_version(ns="api_client", uid=uid)
136138

137139

@@ -154,8 +156,8 @@ def _history_doc_to_out(doc: dict[str, Any]) -> ApiClientHistoryOut:
154156
)
155157

156158

157-
async def trim_history(uid: str) -> None:
158-
filt = {"created_by": uid}
159+
async def trim_history(uid: str, workspace_id: str) -> None:
160+
filt = {"workspace_id": workspace_id}
159161
stale_docs = await db_manager.find(
160162
API_CLIENT_HISTORY,
161163
filt,
@@ -167,20 +169,21 @@ async def trim_history(uid: str) -> None:
167169
if not stale_docs:
168170
return
169171
ids = [d["_id"] for d in stale_docs]
170-
await db_manager.delete_many(API_CLIENT_HISTORY, {"_id": {"$in": ids}, "created_by": uid})
172+
await db_manager.delete_many(API_CLIENT_HISTORY, {"_id": {"$in": ids}, "workspace_id": workspace_id})
171173
await bump_version(ns="api_client", uid=uid)
172174

173175

174176
@cached(ns="api_client", ttl=300, scope="user")
175-
async def list_history(*, uid: str, limit: int = HISTORY_MAX_ITEMS) -> list[ApiClientHistoryOut]:
177+
async def list_history(*, uid: str, workspace_id: str, limit: int = HISTORY_MAX_ITEMS) -> list[ApiClientHistoryOut]:
176178
lim = max(1, min(limit, HISTORY_MAX_ITEMS))
177-
docs = await db_manager.find(API_CLIENT_HISTORY, {"created_by": uid}, sort=[("timestamp", -1)], limit=lim)
179+
docs = await db_manager.find(API_CLIENT_HISTORY, {"workspace_id": workspace_id}, sort=[("timestamp", -1)], limit=lim)
178180
return [_history_doc_to_out(d) for d in docs]
179181

180182

181-
async def create_history(uid: str, body: ApiClientHistoryCreate) -> ApiClientHistoryOut:
183+
async def create_history(uid: str, workspace_id: str, body: ApiClientHistoryCreate) -> ApiClientHistoryOut:
182184
ts = body.timestamp if body.timestamp is not None else int(time.time() * 1000)
183185
doc: dict[str, Any] = {
186+
"workspace_id": workspace_id,
184187
"created_by": uid,
185188
"method": body.method,
186189
"url": body.url,
@@ -197,15 +200,15 @@ async def create_history(uid: str, body: ApiClientHistoryCreate) -> ApiClientHis
197200
return _history_doc_to_out(doc)
198201

199202

200-
async def delete_history_entry(uid: str, entry_id: str) -> None:
203+
async def delete_history_entry(uid: str, workspace_id: str, entry_id: str) -> None:
201204
oid = _parse_oid(entry_id, kind="history")
202-
await safe_delete_one(API_CLIENT_HISTORY, {"_id": oid, "created_by": uid}, name="History entry")
205+
await safe_delete_one(API_CLIENT_HISTORY, {"_id": oid, "workspace_id": workspace_id}, name="History entry")
203206
await bump_version(ns="api_client", uid=uid)
204207

205208

206-
async def clear_history(uid: str) -> None:
209+
async def clear_history(uid: str, workspace_id: str) -> None:
207210
try:
208-
await db_manager.delete_many(API_CLIENT_HISTORY, {"created_by": uid})
211+
await db_manager.delete_many(API_CLIENT_HISTORY, {"workspace_id": workspace_id})
209212
except PyMongoError as exc:
210213
raise HTTPException(
211214
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to clear history."

apps/backend/app/api/routes/auth/api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ async def update_profile(
186186
current_user: Annotated[UserProfileResponse, Depends(get_current_user)],
187187
) -> UserProfileResponse:
188188
updates = {}
189+
if payload.persona is not None:
190+
updates["persona"] = payload.persona or None
191+
189192
if payload.github_username is not None:
190193
updates["github_username"] = payload.github_username
191194

apps/backend/app/api/routes/auth/schema.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,12 @@ class UserProfileResponse(BaseModel):
108108
certifications: list[Certification] = Field(default_factory=list)
109109
portfolio_settings: PortfolioSettings | None = None
110110
personal_info: PersonalInfo | None = None
111+
persona: str | None = None
111112
onboarding_completed: bool = False
112113

113114

114115
class UpdateProfileRequest(BaseModel):
116+
persona: str | None = None
115117
github_username: str | None = None
116118
username: str | None = None
117119
bio: str | None = None

apps/backend/app/api/routes/auth/services.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,5 +129,6 @@ async def get_current_user(
129129
certifications=[Certification(**c) for c in doc.get("certifications") or []],
130130
portfolio_settings=doc.get("portfolio_settings"),
131131
personal_info=PersonalInfo(**doc["personal_info"]) if doc.get("personal_info") else None,
132+
persona=doc.get("persona"),
132133
onboarding_completed=bool(doc.get("onboarding_completed", False)),
133134
)

0 commit comments

Comments
 (0)