Skip to content

Commit ef5ab60

Browse files
authored
Implement app ID and name filtering in the list cloud apps API, enforce organization and user scope checks, and add a new endpoint for renaming applications. Enhance error handling for app creation and management operations. Update database schema to enforce unique constraints on app names within organization and user scopes (#355)
1 parent 075d67d commit ef5ab60

File tree

5 files changed

+620
-50
lines changed

5 files changed

+620
-50
lines changed

core/api.py

Lines changed: 225 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,11 @@ async def generate_cloud_uri(
10921092
status_code=403,
10931093
detail="You can only create apps for your own account unless you have admin permissions",
10941094
)
1095+
if not request.org_id:
1096+
raise HTTPException(
1097+
status_code=400,
1098+
detail="org_id is required when creating apps without an existing app token",
1099+
)
10951100
if not app_id:
10961101
app_id = str(uuid.uuid4())
10971102
except jwt.InvalidTokenError as e:
@@ -1191,6 +1196,16 @@ async def generate_cloud_uri(
11911196
async def list_cloud_apps(
11921197
org_id: Optional[str] = Query(default=None, description="Filter apps by organization ID"),
11931198
user_id: Optional[str] = Query(default=None, description="Filter apps by creator"),
1199+
app_id_filter: Optional[str] = Query(
1200+
default=None,
1201+
description="JSON filter expression for app IDs (supports $and/$or/$not/$nor and $eq/$ne/$gt/$gte/$lt/$lte/"
1202+
"$in/$nin/$exists/$regex/$contains).",
1203+
),
1204+
app_name_filter: Optional[str] = Query(
1205+
default=None,
1206+
description="JSON filter expression for app name (supports $and/$or/$not/$nor and $eq/$ne/$gt/$gte/$lt/$lte/"
1207+
"$in/$nin/$exists/$regex/$contains).",
1208+
),
11941209
limit: int = Query(default=100, ge=1, le=500),
11951210
offset: int = Query(default=0, ge=0),
11961211
authorization: Optional[str] = Header(default=None),
@@ -1207,6 +1222,7 @@ async def list_cloud_apps(
12071222

12081223
token_user_id: Optional[str] = None
12091224
token_permissions: List[str] = []
1225+
token_app_id: Optional[str] = None
12101226

12111227
if not is_admin_call:
12121228
if not authorization:
@@ -1233,26 +1249,65 @@ async def list_cloud_apps(
12331249

12341250
token_user_id = payload.get("user_id")
12351251
token_permissions = payload.get("permissions", []) or []
1252+
token_app_id = payload.get("app_id")
12361253

12371254
if not is_admin_call:
12381255
if user_id and user_id != token_user_id and "admin" not in token_permissions:
12391256
raise HTTPException(status_code=403, detail="Cannot list apps for another user")
12401257
if not user_id:
12411258
user_id = token_user_id
12421259

1260+
def _parse_filter_payload(value: Optional[str], field_name: str) -> Optional[Any]:
1261+
if not value:
1262+
return None
1263+
try:
1264+
parsed = json.loads(value)
1265+
except json.JSONDecodeError as exc:
1266+
raise HTTPException(status_code=400, detail=f"{field_name} must be valid JSON") from exc
1267+
if not isinstance(parsed, (dict, list)):
1268+
raise HTTPException(status_code=400, detail=f"{field_name} must be a JSON object or array")
1269+
return parsed
1270+
1271+
parsed_app_name_filter = _parse_filter_payload(app_name_filter, "app_name_filter")
1272+
parsed_app_id_filter = _parse_filter_payload(app_id_filter, "app_id_filter")
1273+
12431274
try:
12441275
from core.services.user_service import UserService
12451276

12461277
user_service = UserService()
12471278
await user_service.initialize()
1279+
1280+
resolved_org_id = org_id
1281+
if not is_admin_call:
1282+
token_org_id: Optional[str] = None
1283+
if token_app_id:
1284+
token_app = await user_service.get_app_by_id(token_app_id)
1285+
token_org_id = token_app.get("org_id") if token_app else None
1286+
else:
1287+
if not resolved_org_id:
1288+
raise HTTPException(status_code=400, detail="org_id is required when listing apps without an app")
1289+
1290+
if token_org_id:
1291+
if resolved_org_id and resolved_org_id != token_org_id:
1292+
raise HTTPException(status_code=403, detail="Cannot list apps for another organization")
1293+
resolved_org_id = token_org_id
1294+
user_id = None
1295+
elif token_app_id:
1296+
resolved_org_id = None
1297+
else:
1298+
user_id = None
12481299
apps = await user_service.list_apps(
1249-
org_id=org_id,
1300+
org_id=resolved_org_id,
12501301
user_id=user_id,
1302+
app_id_filter=parsed_app_id_filter,
1303+
name_filter=parsed_app_name_filter,
12511304
limit=limit,
12521305
offset=offset,
12531306
strict_org_scope=not is_admin_call,
12541307
)
12551308
return {"apps": apps, "count": len(apps)}
1309+
except InvalidMetadataFilterError as exc:
1310+
raise HTTPException(status_code=400, detail=str(exc)) from exc
12561311
except HTTPException:
12571312
raise
12581313
except Exception as exc: # pragma: no cover - server failure
@@ -1283,6 +1338,7 @@ async def delete_cloud_app(
12831338
raise
12841339

12851340
user_id: Optional[str] = None
1341+
token_app_id: Optional[str] = None
12861342

12871343
if not is_admin_call:
12881344
# Require Bearer token if no admin secret
@@ -1299,36 +1355,41 @@ async def delete_cloud_app(
12991355
try:
13001356
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
13011357
user_id = payload.get("user_id")
1358+
token_app_id = payload.get("app_id")
13021359
await ensure_app_is_active(
1303-
payload.get("app_id"),
1360+
token_app_id,
13041361
token_version=payload.get("token_version"),
13051362
redis_pool=redis_pool,
13061363
)
13071364
except jwt.InvalidTokenError as exc:
13081365
raise HTTPException(status_code=401, detail=str(exc)) from exc
1366+
if not token_app_id:
1367+
raise HTTPException(status_code=403, detail="App-scoped token required to delete applications")
13091368

13101369
logger.info(f"Deleting app {app_name} for user {user_id} (admin_call={is_admin_call})")
13111370

13121371
from sqlalchemy import delete as sa_delete
13131372
from sqlalchemy import select
13141373

13151374
from core.models.apps import AppModel
1316-
from core.services.user_service import UserService
13171375

13181376
# 1) Resolve app_id from apps table ----------------------------------
13191377
async with document_service.db.async_session() as session:
13201378
if is_admin_call:
13211379
# Admin call: look up by name only
13221380
stmt = select(AppModel).where(AppModel.name == app_name)
13231381
else:
1324-
# User call: look up by user_id and name
1325-
stmt = select(AppModel).where(AppModel.user_id == user_id, AppModel.name == app_name)
1382+
# App-scoped token call: look up by app_id only
1383+
stmt = select(AppModel).where(AppModel.app_id == token_app_id)
13261384
res = await session.execute(stmt)
13271385
app_row = res.scalar_one_or_none()
13281386

13291387
if app_row is None:
13301388
raise HTTPException(status_code=404, detail="Application not found")
13311389

1390+
if not is_admin_call and app_row.name != app_name:
1391+
raise HTTPException(status_code=400, detail="Application name does not match token")
1392+
13321393
app_id = app_row.app_id
13331394
# For admin calls, get user_id from the app record
13341395
effective_user_id = user_id if user_id else str(app_row.user_id)
@@ -1398,11 +1459,6 @@ async def delete_cloud_app(
13981459
await clear_app_active_cache(app_id, redis_pool=redis_pool)
13991460
await mark_app_revoked(app_id, redis_pool=redis_pool)
14001461

1401-
# 5) Update user_limits --------------------------------------------
1402-
user_service = UserService()
1403-
await user_service.initialize()
1404-
await user_service.unregister_app(effective_user_id, app_id)
1405-
14061462
return {
14071463
"app_name": app_name,
14081464
"status": "deleted",
@@ -1430,6 +1486,7 @@ async def rotate_app_token(
14301486
raise
14311487

14321488
user_id: Optional[str] = None
1489+
token_app_id: Optional[str] = None
14331490
if not is_admin_call:
14341491
if not authorization:
14351492
raise HTTPException(
@@ -1444,34 +1501,44 @@ async def rotate_app_token(
14441501
try:
14451502
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
14461503
user_id = payload.get("user_id")
1504+
token_app_id = payload.get("app_id")
14471505
await ensure_app_is_active(
1448-
payload.get("app_id"),
1506+
token_app_id,
14491507
token_version=payload.get("token_version"),
14501508
redis_pool=redis_pool,
14511509
)
14521510
except jwt.InvalidTokenError as exc:
14531511
raise HTTPException(status_code=401, detail=str(exc)) from exc
1512+
if not token_app_id:
1513+
raise HTTPException(status_code=403, detail="App-scoped token required to rotate tokens")
1514+
1515+
if app_id and app_id != token_app_id:
1516+
raise HTTPException(status_code=403, detail="Cannot rotate token for another app")
1517+
app_id = token_app_id
14541518

14551519
from sqlalchemy import select
14561520

14571521
from core.models.apps import AppModel
14581522
from core.services.user_service import UserService
14591523

14601524
async with document_service.db.async_session() as session:
1461-
if app_id:
1462-
stmt = select(AppModel).where(AppModel.app_id == app_id)
1525+
if is_admin_call:
1526+
if app_id:
1527+
stmt = select(AppModel).where(AppModel.app_id == app_id)
1528+
else:
1529+
stmt = select(AppModel).where(AppModel.name == app_name)
14631530
else:
1464-
stmt = select(AppModel).where(AppModel.name == app_name)
1465-
1466-
if not is_admin_call:
1467-
stmt = stmt.where(AppModel.user_id == user_id)
1531+
stmt = select(AppModel).where(AppModel.app_id == app_id)
14681532

14691533
res = await session.execute(stmt)
14701534
app_row = res.scalar_one_or_none()
14711535

14721536
if app_row is None:
14731537
raise HTTPException(status_code=404, detail="Application not found")
14741538

1539+
if app_name and app_row.name != app_name:
1540+
raise HTTPException(status_code=400, detail="Application name does not match token")
1541+
14751542
effective_user_id = user_id
14761543
if not effective_user_id:
14771544
effective_user_id = str(app_row.user_id) if app_row.user_id else app_row.created_by_user_id
@@ -1525,6 +1592,147 @@ async def rotate_app_token(
15251592
}
15261593

15271594

1595+
@app.patch("/apps/rename")
1596+
async def rename_cloud_app(
1597+
app_id: Optional[str] = Query(default=None, description="Application ID to rename"),
1598+
app_name: Optional[str] = Query(default=None, description="Current application name to rename"),
1599+
new_name: str = Query(..., description="New application name"),
1600+
authorization: Optional[str] = Header(default=None),
1601+
admin_secret: Optional[str] = Header(default=None, alias="X-Morphik-Admin-Secret"),
1602+
redis_pool: Optional[arq.ArqRedis] = Depends(get_optional_redis_pool),
1603+
) -> Dict[str, Any]:
1604+
"""Rename an existing cloud application."""
1605+
if not app_id and not app_name:
1606+
raise HTTPException(status_code=400, detail="app_id or app_name is required")
1607+
1608+
cleaned_name = new_name.strip()
1609+
if not cleaned_name:
1610+
raise HTTPException(status_code=400, detail="new_name is required")
1611+
cleaned_name = cleaned_name.replace(" ", "_").lower()
1612+
1613+
try:
1614+
is_admin_call = _validate_admin_secret(admin_secret)
1615+
except HTTPException:
1616+
raise
1617+
1618+
user_id: Optional[str] = None
1619+
token_app_id: Optional[str] = None
1620+
if not is_admin_call:
1621+
if not authorization:
1622+
raise HTTPException(
1623+
status_code=401,
1624+
detail="Missing authorization header",
1625+
headers={"WWW-Authenticate": "Bearer"},
1626+
)
1627+
if not authorization.startswith("Bearer "):
1628+
raise HTTPException(status_code=401, detail="Invalid authorization header")
1629+
1630+
token = authorization[7:]
1631+
try:
1632+
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
1633+
user_id = payload.get("user_id")
1634+
token_app_id = payload.get("app_id")
1635+
await ensure_app_is_active(
1636+
token_app_id,
1637+
token_version=payload.get("token_version"),
1638+
redis_pool=redis_pool,
1639+
)
1640+
except jwt.InvalidTokenError as exc:
1641+
raise HTTPException(status_code=401, detail=str(exc)) from exc
1642+
1643+
if not user_id:
1644+
raise HTTPException(status_code=401, detail="Token is missing user_id")
1645+
if not token_app_id:
1646+
raise HTTPException(status_code=403, detail="App-scoped token required to rename applications")
1647+
1648+
if app_id and app_id != token_app_id:
1649+
raise HTTPException(status_code=403, detail="Cannot rename another application")
1650+
app_id = token_app_id
1651+
1652+
from sqlalchemy import select
1653+
from sqlalchemy.exc import MultipleResultsFound
1654+
1655+
from core.models.apps import AppModel
1656+
from core.services.user_service import UserService
1657+
1658+
def _rename_uri(existing_uri: str, name: str) -> str:
1659+
if not existing_uri:
1660+
logger.warning("Missing app URI; leaving URI unchanged")
1661+
return existing_uri
1662+
1663+
try:
1664+
rest = existing_uri.split("morphik://", 1)[1]
1665+
_, rest = rest.split(":", 1)
1666+
token, domain = rest.split("@", 1)
1667+
except (IndexError, ValueError):
1668+
logger.warning("Unexpected app URI format; leaving URI unchanged: %s", existing_uri)
1669+
return existing_uri
1670+
1671+
return f"morphik://{name}:{token}@{domain}"
1672+
1673+
async with document_service.db.async_session() as session:
1674+
if is_admin_call:
1675+
if app_id:
1676+
stmt = select(AppModel).where(AppModel.app_id == app_id)
1677+
else:
1678+
stmt = select(AppModel).where(AppModel.name == app_name)
1679+
else:
1680+
stmt = select(AppModel).where(AppModel.app_id == app_id)
1681+
1682+
try:
1683+
res = await session.execute(stmt)
1684+
app_row = res.scalar_one_or_none()
1685+
except MultipleResultsFound as exc:
1686+
raise HTTPException(status_code=409, detail="Multiple apps matched; use app_id to rename") from exc
1687+
1688+
if app_row is None:
1689+
raise HTTPException(status_code=404, detail="Application not found")
1690+
1691+
if app_name and app_row.name != app_name:
1692+
raise HTTPException(status_code=400, detail="Application name does not match token")
1693+
1694+
if app_row.name != cleaned_name:
1695+
name_stmt = select(AppModel.app_id).where(AppModel.name == cleaned_name)
1696+
if app_row.org_id:
1697+
name_stmt = name_stmt.where(AppModel.org_id == app_row.org_id)
1698+
elif app_row.user_id:
1699+
name_stmt = name_stmt.where(AppModel.user_id == app_row.user_id)
1700+
name_stmt = name_stmt.where(AppModel.app_id != app_row.app_id)
1701+
1702+
res = await session.execute(name_stmt)
1703+
if res.scalar_one_or_none() is not None:
1704+
raise HTTPException(status_code=409, detail=f"App with name '{cleaned_name}' already exists")
1705+
1706+
app_row.name = cleaned_name
1707+
app_row.uri = _rename_uri(app_row.uri, cleaned_name)
1708+
await session.commit()
1709+
1710+
app_id_value = app_row.app_id
1711+
app_name_value = app_row.name
1712+
app_uri_value = app_row.uri
1713+
org_id_value = app_row.org_id
1714+
created_by_user_id_value = app_row.created_by_user_id
1715+
app_user_id_value = str(app_row.user_id) if app_row.user_id else None
1716+
1717+
# Sync to control plane for dashboard visibility (user_id is optional)
1718+
effective_user_id = user_id or app_user_id_value or created_by_user_id_value
1719+
user_service = UserService()
1720+
await user_service._sync_app_to_control_plane(
1721+
app_id=app_id_value,
1722+
user_id=effective_user_id,
1723+
org_id=org_id_value,
1724+
created_by_user_id=created_by_user_id_value,
1725+
name=app_name_value,
1726+
uri=app_uri_value,
1727+
)
1728+
1729+
return {
1730+
"app_id": app_id_value,
1731+
"app_name": app_name_value,
1732+
"uri": app_uri_value,
1733+
}
1734+
1735+
15281736
@app.get("/chats", response_model=List[Dict[str, Any]])
15291737
async def list_chat_conversations(
15301738
auth: AuthContext = Depends(verify_token),

core/database/postgres_database.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,20 @@ async def initialize(self):
159159
await conn.execute(
160160
text("ALTER TABLE apps " "ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0")
161161
)
162+
await conn.execute(
163+
text(
164+
"CREATE UNIQUE INDEX IF NOT EXISTS apps_org_name_unique "
165+
"ON apps (org_id, name) "
166+
"WHERE org_id IS NOT NULL"
167+
)
168+
)
169+
await conn.execute(
170+
text(
171+
"CREATE UNIQUE INDEX IF NOT EXISTS apps_user_name_unique "
172+
"ON apps (user_id, name) "
173+
"WHERE org_id IS NULL AND user_id IS NOT NULL"
174+
)
175+
)
162176

163177
logger.info("PostgreSQL initialization complete")
164178
self._initialized = True

0 commit comments

Comments
 (0)