Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
b6c3b34
feat(web): add a connector access hook for team-editable connectors
AlexLiu190625 Aug 24, 2026
64356a7
feat(web): add the edit right on team-linked MCP connectors
AlexLiu190625 Aug 24, 2026
8442dd8
fix(web): capture the pre-rename name after the row lock, not before
AlexLiu190625 Aug 24, 2026
71255a4
feat(web): add the edit right on team-linked Custom API connectors
AlexLiu190625 Aug 24, 2026
aabdac4
feat(web): report the team access verdict consistently across connect…
AlexLiu190625 Aug 24, 2026
a81b7c1
test(web): describe each test class by the invariant it covers
AlexLiu190625 Aug 24, 2026
21db8e4
test(web): pin the platform-admin case where the two connector kinds …
AlexLiu190625 Aug 24, 2026
4c4b93c
fix(web): degrade the verdict decoration after a commit and skip it w…
AlexLiu190625 Aug 24, 2026
84a6a03
fix(web): degrade a per-row verdict failure inside the apps listing loop
AlexLiu190625 Aug 24, 2026
2c681cb
test(web): pin the query cost of the list endpoint's access-hook budg…
AlexLiu190625 Aug 24, 2026
a1af5f4
test(web): pin the rename call's scope against an outsider's own agen…
AlexLiu190625 Aug 24, 2026
bf5ca76
refactor(connector-scope): batch the connector access seam and type i…
AlexLiu190625 Aug 25, 2026
8d54722
test(mcp): pin the apps listing's own access-hook call budget
AlexLiu190625 Aug 25, 2026
f093c86
fix(connector-scope): restore the shared session after a failed team …
AlexLiu190625 Aug 25, 2026
9663931
fix(mcp): degrade a verdict failure on read paths and keep writes fai…
AlexLiu190625 Aug 25, 2026
bf4cd77
fix(custom-api): lock the definition row before propagating a rename
AlexLiu190625 Aug 25, 2026
5e7cad6
ci(migrations): trigger the connector lock suites from their producti…
AlexLiu190625 Aug 25, 2026
1ee7a48
test(mcp): cover the standalone matrix for both constructible populat…
AlexLiu190625 Aug 25, 2026
a950882
test(mcp): pin the reported subject when an admin inspects another user
AlexLiu190625 Aug 25, 2026
d7eb382
test(mcp): make the admin-subject test actually catch a leaked bypass
AlexLiu190625 Aug 25, 2026
25f1543
fix(mcp): stop the apps listing's degradation log from crashing on a …
AlexLiu190625 Aug 25, 2026
8b616d0
fix(web): run the Custom API write routes off the event loop
AlexLiu190625 Aug 26, 2026
2f1da4c
fix(web): take the Custom API definition lock before the link row on …
AlexLiu190625 Aug 26, 2026
b4b11a0
fix(web): check connector access answer key types before membership
AlexLiu190625 Aug 26, 2026
5477064
fix(web): refuse a denying stand-in's PUT instead of reporting an emp…
AlexLiu190625 Aug 26, 2026
87f3aef
fix(web): revalidate the team verdict under the definition row lock
AlexLiu190625 Aug 26, 2026
912a42f
test(web): fix a call-count test that never reached the code it claim…
AlexLiu190625 Aug 26, 2026
3fdad5c
test(web): assert standalone parity for the Custom API legs too
AlexLiu190625 Aug 26, 2026
df5ea7e
test(web): pin the degraded listing's query cost on a failing hook
AlexLiu190625 Aug 26, 2026
f58204b
docs(web): state the coherence the two connector team hooks must keep
AlexLiu190625 Aug 26, 2026
683d06f
docs(web): update two test module docstrings to name the coverage add…
AlexLiu190625 Aug 26, 2026
00677fd
test(connector-team-edit): capture literal values before zero-side-ef…
AlexLiu190625 Aug 26, 2026
1886761
test(connector-team-edit): pin the platform-admin recheck exemption
AlexLiu190625 Aug 26, 2026
1f71018
docs(mcp): sync two comments with the recheck this batch added
AlexLiu190625 Aug 26, 2026
48b0a9a
fix(web): keep every route that reaches the connector team seam off t…
AlexLiu190625 Aug 27, 2026
7a1f454
fix(web): translate the rename hook's typed error on the Custom API P…
AlexLiu190625 Aug 27, 2026
4bc71f2
fix(web): report the verdict the write was authorized on
AlexLiu190625 Aug 27, 2026
a721c84
fix(web): restore the shared session at the seam's single hook door
AlexLiu190625 Aug 27, 2026
2011168
test(web): reset the seam's own hooks by snapshot instead of clearing…
AlexLiu190625 Aug 27, 2026
c6df5ba
test(web): cover the access validator's verdict-value arm
AlexLiu190625 Aug 27, 2026
b460f3b
test(web): exercise a caller who holds both a personal row and a team…
AlexLiu190625 Aug 27, 2026
85ea5b3
test(web): let the session-fault suite fail without the production re…
AlexLiu190625 Aug 27, 2026
45c74f1
fix(web): restore the shared session when a hook's answer is rejected…
AlexLiu190625 Aug 27, 2026
14c52c4
docs(web): state the shape the hook door deliberately leaves uncovered
AlexLiu190625 Aug 27, 2026
10c113d
fix(mcp): never let a team verdict grant edit on a platform-catalog row
AlexLiu190625 Aug 28, 2026
b810cef
docs(web): name the isolation level the post-lock verdict recheck ass…
AlexLiu190625 Aug 28, 2026
38756c4
docs(custom-api): say what the global lock order does not cover
AlexLiu190625 Aug 28, 2026
e2737cb
test(mcp): assert something that can fail after a .one() lookup
AlexLiu190625 Aug 28, 2026
41e2a49
test(web): the coroutine exemption must carry an await that is not th…
AlexLiu190625 Aug 28, 2026
d4a59e3
test(mcp): cover personal-row downgrade in get_mcp_servers listing
AlexLiu190625 Aug 28, 2026
6f6a0dc
docs(mcp): fix stale cost description in _server_catalog_keys docstring
AlexLiu190625 Aug 28, 2026
6064285
docs(mcp): scope three comments to what the code actually guarantees
AlexLiu190625 Aug 28, 2026
df5a204
docs(mcp): align the downgrade docstring with its call site's orderin…
AlexLiu190625 Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/test-migrations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ on:
- 'tests/web/test_user_oauth_actor_ownership.py'
- 'tests/shared/postgres_disposable.py'
- 'tests/web/services/checkpoint_anchor_shared.py'
- 'tests/web/api/test_mcp_server_edit_lock_postgresql.py'
Comment thread
AlexLiu190625 marked this conversation as resolved.
pull_request:
branches: [main]
# Required by the merge queue: without this the two required contexts below
Expand Down Expand Up @@ -134,6 +135,7 @@ jobs:
tests/web/test_user_oauth_actor_ownership.py
tests/shared/postgres_disposable.py
tests/web/services/checkpoint_anchor_shared.py
tests/web/api/test_mcp_server_edit_lock_postgresql.py
)

case "$EVENT_NAME" in
Expand Down Expand Up @@ -437,6 +439,13 @@ jobs:
env:
XAGENT_TEST_POSTGRES_URL: postgresql://xagent:xagent@localhost:5432/xagent_test

- name: Test MCP server edit row lock (Postgres-only)
if: needs.detect-migration-changes.outputs.should-test == 'true'
run: |
pytest tests/web/api/test_mcp_server_edit_lock_postgresql.py -m postgresql -q
env:
XAGENT_TEST_POSTGRES_URL: postgresql://xagent:xagent@localhost:5432/xagent_test

migrations-summary:
name: Migrations Summary
runs-on: ubuntu-latest
Expand Down
187 changes: 149 additions & 38 deletions src/xagent/web/api/custom_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@

import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, cast

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session

from ...core.tools.adapters.vibe.connector_runtime import (
ConnectorRuntimeError,
validate_runtime_config_declaration,
)
from ...core.utils.encryption import encrypt_value
Expand All @@ -22,6 +23,10 @@
from ..models.database import get_db
from ..models.user import User

if TYPE_CHECKING:
from ..services.connector_team_scope import ConnectorAccess
from .mcp import _TeamOwnedUserApi

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -111,7 +116,7 @@ class Config:

def _db_api_to_response(
api: CustomApi,
user_api: UserCustomApi,
user_api: "UserCustomApi | _TeamOwnedUserApi",
) -> CustomApiResponse:
"""Convert database CustomApi to response model with masked env values."""

Expand Down Expand Up @@ -259,30 +264,113 @@ async def create_custom_api(
return _db_api_to_response(new_api, user_api)


@custom_api_router.get("/{api_id}", response_model=CustomApiResponse)
async def get_custom_api(
def _resolve_custom_api_for_request(
db: Session,
user_id: int,
api_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> CustomApiResponse:
"""Get a specific Custom API by ID."""
*,
skip_resolution_when: "Callable[[UserCustomApi], bool] | None" = None,
) -> "tuple[UserCustomApi | _TeamOwnedUserApi, CustomApi, ConnectorAccess | None]":
"""Resolve the caller's association, the definition row, and the
caller's team access verdict, for ``GET``/``PUT /api/custom-apis/{id}``.

Looks up the caller's own personal link row first, with the same query
both routes have always run. When that row exists and its ``custom_api``
relationship resolves, the association and the definition row both come
from it and nothing else runs. When it does not -- no row, or a row
whose relationship is unexpectedly empty -- the definition row is
looked up on its own -- a team-owned API's shared row must still be
found even though this caller has no personal link to it -- and the
caller's team access verdict decides what happens next:

- no working personal row and no team access (``access is None``) ->
404, the same outcome every caller without an association has
always gotten.
- no working personal row but the caller's team links the API -> the
existing ``_TeamOwnedUserApi`` stand-in takes the association's
place, the same stand-in the aggregate connector list already
constructs for this case.

``skip_resolution_when`` lets a caller declare when its own working
personal row already decides the answer on its own, so resolving a
verdict would only add an unnecessary hook call: ``get_custom_api``
passes a predicate that is always true, because it never reads the
verdict at all and a personal row -- owner or not -- already decides
what it returns; ``update_custom_api`` passes one that checks
``can_edit``, because only an owner's ``can_edit=True`` decides the
edit answer on its own -- a non-owner's ``can_edit=False`` personal row
does not, since a granting team verdict can still widen it. Left
unset (the default), resolution is never skipped, which is what a
caller with no working personal row always needs -- the verdict is the
gate there and must stay fail-closed.

Raises ``ConnectorRuntimeError`` when access resolution itself fails;
callers translate that into an ``HTTPException``.
"""
from ..services.connector_team_scope import resolve_connector_access_or_raise
from .mcp import _TeamOwnedUserApi

user_api = (
db.query(UserCustomApi)
.filter(
UserCustomApi.custom_api_id == api_id,
UserCustomApi.user_id == current_user.id,
UserCustomApi.user_id == user_id,
)
.first()
)
if user_api is not None and user_api.custom_api is not None:
api: Optional[CustomApi] = user_api.custom_api
else:
user_api = None
api = db.query(CustomApi).filter(CustomApi.id == api_id).first()

if not user_api or not user_api.custom_api:
already_decided = user_api is not None and (
skip_resolution_when is not None and skip_resolution_when(user_api)
)

access: "ConnectorAccess | None" = None
if api is not None and not already_decided:
access = resolve_connector_access_or_raise(
db, int(user_id), "custom_api", int(api.id)
)

if user_api is None and access is None:
Comment thread
AlexLiu190625 marked this conversation as resolved.
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Custom API not found",
)

return _db_api_to_response(user_api.custom_api, user_api)
resolved_user_api: "UserCustomApi | _TeamOwnedUserApi" = (
user_api if user_api is not None else _TeamOwnedUserApi(int(user_id))
)
return resolved_user_api, cast(CustomApi, api), access


@custom_api_router.get("/{api_id}", response_model=CustomApiResponse)
async def get_custom_api(
Comment thread
AlexLiu190625 marked this conversation as resolved.
Outdated
api_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> CustomApiResponse:
"""Get a specific Custom API by ID."""

try:
# This route never reads the verdict at all (see _db_api_to_response),
# so a working personal row -- owner or not -- always already
# decides everything this route returns; resolving one would only
# add an unnecessary hook call.
user_api, api, _team_access = _resolve_custom_api_for_request(
db,
int(current_user.id),
api_id,
skip_resolution_when=lambda _user_api: True,
)
except ConnectorRuntimeError as exc:
raise HTTPException(
status_code=exc.status_code, detail=exc.safe_message
) from exc

return _db_api_to_response(api, user_api)


@custom_api_router.put("/{api_id}", response_model=CustomApiResponse)
Expand All @@ -294,29 +382,50 @@ async def update_custom_api(
) -> CustomApiResponse:
"""Update an existing Custom API."""

user_api = (
db.query(UserCustomApi)
.filter(
UserCustomApi.custom_api_id == api_id,
UserCustomApi.user_id == current_user.id,
try:
# An owner's can_edit=True already decides the edit answer on its
# own (below), so resolving a verdict for that row would only add
# an unnecessary hook call; a non-owner's can_edit=False personal
# row does not decide it, since a granting team verdict can still
# widen it.
user_api, api, team_access = _resolve_custom_api_for_request(
db,
int(current_user.id),
api_id,
skip_resolution_when=lambda ua: bool(ua.can_edit),
)
.first()
)

if not user_api or not user_api.custom_api:
except ConnectorRuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Custom API not found",
)
status_code=exc.status_code, detail=exc.safe_message
) from exc

if not user_api.can_edit:
is_stand_in = not isinstance(user_api, UserCustomApi)
can_edit = bool(user_api.can_edit) or bool(
team_access is not None and team_access.can_edit
)
if not can_edit:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to edit this Custom API",
)

api = user_api.custom_api
# is_active lives on the personal association row; a caller with no
# personal row (the stand-in) has none to hold it, so a payload
# carrying it must be rejected outright -- writing it onto the
# stand-in would only set a shadowing instance attribute that
# persists nothing, and the response below would then read that
# shadow back and report a change that never happened.
if is_stand_in and api_data.is_active is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No personal connection exists to configure is_active for this API",
)

old_name = str(api.name)
# The row's declared type from here on is loosened for mypy's sake: the
# column-typed attributes below (name, description, env, ...) are all
# mutated directly by this route, exactly as before this gate existed.
mutable_api = cast(Any, api)

# Check name uniqueness if name is changed
if api_data.name and api_data.name != api.name:
Expand All @@ -326,31 +435,33 @@ async def update_custom_api(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Custom API with name '{api_data.name}' already exists",
)
api.name = api_data.name
mutable_api.name = api_data.name
Comment thread
AlexLiu190625 marked this conversation as resolved.

# Update fields
if api_data.description is not None:
api.description = api_data.description
mutable_api.description = api_data.description
if api_data.url is not None:
api.url = api_data.url
mutable_api.url = api_data.url
if api_data.method is not None:
api.method = api_data.method
mutable_api.method = api_data.method
if api_data.headers is not None:
api.headers = api_data.headers
mutable_api.headers = api_data.headers
if api_data.body is not None:
api.body = api_data.body
mutable_api.body = api_data.body

# Process env variables
if api_data.env is not None:
existing_env = api.env if isinstance(api.env, dict) else {}
existing_env: Dict[str, str] = (
mutable_api.env if isinstance(api.env, dict) else {}
)
try:
processed_env = _process_env_vars(api_data.env, existing_env)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid environment variables: {exc}",
) from exc
api.env = processed_env
mutable_api.env = processed_env

fields_set = api_data.model_fields_set
runtime_input_schema = (
Expand All @@ -374,19 +485,19 @@ async def update_custom_api(
runtime_input_schema=runtime_input_schema,
runtime_bindings=runtime_bindings,
allow_delegated_authorization=allow_delegated_authorization,
static_headers=api.headers,
static_headers=mutable_api.headers,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid runtime configuration: {exc}",
) from exc
if "runtime_input_schema" in fields_set:
api.runtime_input_schema = runtime_input_schema
mutable_api.runtime_input_schema = runtime_input_schema
if "runtime_bindings" in fields_set:
api.runtime_bindings = runtime_bindings
mutable_api.runtime_bindings = runtime_bindings
if "allow_delegated_authorization" in fields_set:
api.allow_delegated_authorization = allow_delegated_authorization
mutable_api.allow_delegated_authorization = allow_delegated_authorization

from ..services.connector_team_scope import rename_team_connector
Comment thread
AlexLiu190625 marked this conversation as resolved.

Expand All @@ -401,7 +512,7 @@ async def update_custom_api(

# Update UserCustomApi link
if api_data.is_active is not None:
user_api.is_active = api_data.is_active # type: ignore[assignment]
user_api.is_active = api_data.is_active
Comment thread
AlexLiu190625 marked this conversation as resolved.

db.commit()
db.refresh(api)
Expand Down
Loading
Loading