Skip to content

Commit 26d0959

Browse files
Aldo Ruiz Lunacursoragent
authored andcommitted
Add ConnectedAccount vault and Coupler token delegation API (P1).
Introduce migration 008, connections routes, OAuthAccount sync bridge, and router tests so Coupler can execute delegated SaaS tools without storing refresh tokens in the ATP. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7d7e7f5 commit 26d0959

8 files changed

Lines changed: 538 additions & 1 deletion

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Add connected_accounts and provider_types for Coupler P1 (ADR-002).
2+
3+
Revision ID: 008_connected_accounts
4+
Revises: 007_add_user_entitlements
5+
Create Date: 2026-06-19
6+
"""
7+
8+
import sqlalchemy as sa
9+
from alembic import op
10+
11+
revision = "008_connected_accounts"
12+
down_revision = "007_add_user_entitlements"
13+
branch_labels = None
14+
depends_on = None
15+
16+
17+
def upgrade() -> None:
18+
bind = op.get_bind()
19+
dialect = bind.dialect.name
20+
uuid_type = sa.dialects.postgresql.UUID(as_uuid=True) if dialect == "postgresql" else sa.String(36)
21+
json_type = sa.JSON() if dialect == "sqlite" else sa.dialects.postgresql.JSONB()
22+
23+
op.create_table(
24+
"provider_types",
25+
sa.Column("id", uuid_type, primary_key=True),
26+
sa.Column("type_code", sa.String(50), nullable=False, unique=True),
27+
sa.Column("display_name", sa.String(100), nullable=False),
28+
sa.Column("category", sa.String(50), nullable=False, server_default="integration"),
29+
sa.Column("supports_oauth", sa.String(5), server_default="true"),
30+
sa.Column("is_active", sa.String(5), server_default="true"),
31+
sa.Column("created_at", sa.DateTime(), nullable=True),
32+
sa.Column("updated_at", sa.DateTime(), nullable=True),
33+
)
34+
35+
op.create_table(
36+
"connected_accounts",
37+
sa.Column("id", uuid_type, primary_key=True),
38+
sa.Column("user_id", uuid_type, sa.ForeignKey("users.id"), nullable=False),
39+
sa.Column("organization_id", uuid_type, sa.ForeignKey("organizations.id"), nullable=True),
40+
sa.Column("provider_type", sa.String(50), nullable=False),
41+
sa.Column("provider_name", sa.String(100), nullable=False),
42+
sa.Column("provider_id", sa.String(255), nullable=True),
43+
sa.Column("access_token_encrypted", sa.Text(), nullable=True),
44+
sa.Column("refresh_token_encrypted", sa.Text(), nullable=True),
45+
sa.Column("oauth_scopes", json_type, nullable=True),
46+
sa.Column("oauth_expires_at", sa.DateTime(), nullable=True),
47+
sa.Column("status", sa.String(20), server_default="active"),
48+
sa.Column("metadata", json_type, nullable=True),
49+
sa.Column("last_used_at", sa.DateTime(), nullable=True),
50+
sa.Column("created_at", sa.DateTime(), nullable=True),
51+
sa.Column("updated_at", sa.DateTime(), nullable=True),
52+
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=True),
53+
)
54+
op.create_index("ix_connected_accounts_user_id", "connected_accounts", ["user_id"])
55+
op.create_index("ix_connected_accounts_provider_type", "connected_accounts", ["provider_type"])
56+
57+
op.bulk_insert(
58+
sa.table(
59+
"provider_types",
60+
sa.column("id", uuid_type),
61+
sa.column("type_code", sa.String),
62+
sa.column("display_name", sa.String),
63+
sa.column("category", sa.String),
64+
sa.column("supports_oauth", sa.String),
65+
sa.column("is_active", sa.String),
66+
),
67+
[
68+
{
69+
"id": "00000000-0000-4000-8000-000000000001",
70+
"type_code": "github",
71+
"display_name": "GitHub",
72+
"category": "development",
73+
"supports_oauth": "true",
74+
"is_active": "true",
75+
},
76+
{
77+
"id": "00000000-0000-4000-8000-000000000002",
78+
"type_code": "slack",
79+
"display_name": "Slack",
80+
"category": "communication",
81+
"supports_oauth": "true",
82+
"is_active": "true",
83+
},
84+
],
85+
)
86+
87+
88+
def downgrade() -> None:
89+
op.drop_index("ix_connected_accounts_provider_type", table_name="connected_accounts")
90+
op.drop_index("ix_connected_accounts_user_id", table_name="connected_accounts")
91+
op.drop_table("connected_accounts")
92+
op.drop_table("provider_types")

apps/api/app/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ def _patched_hashpw(password, salt):
9999
from app.routers.v1 import (
100100
billing as billing_v1,
101101
)
102+
from app.routers.v1 import (
103+
connections as connections_v1,
104+
)
102105
from app.routers.v1 import (
103106
devices as devices_v1,
104107
)
@@ -1066,6 +1069,7 @@ def api_status():
10661069
app.include_router(webhooks_v1.router, prefix="/api/v1")
10671070
app.include_router(webhooks_dhanam_v1.router, prefix="/api/v1")
10681071
app.include_router(integrations_v1.router, prefix="/api/v1")
1072+
app.include_router(connections_v1.router, prefix="/api/v1")
10691073
app.include_router(api_keys_v1.router, prefix="/api/v1")
10701074
app.include_router(roles_v1.router, prefix="/api/v1")
10711075

apps/api/app/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,7 @@ class Translation(Base):
969969

970970
# Import enterprise models
971971
from app.models.enterprise import SSOConfiguration, SSOProvider, SSOStatus # noqa: E402
972+
from app.models.connected_account import ConnectedAccount, ProviderType # noqa: E402,F401
972973

973974
# Import compliance models
974975

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""ConnectedAccount model — Universal Keyring (ADR-002, Coupler P1)."""
2+
3+
from __future__ import annotations
4+
5+
import enum
6+
import uuid
7+
from datetime import datetime
8+
9+
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
10+
from sqlalchemy.orm import relationship
11+
12+
from app.models import Base
13+
from app.models.types import GUID as UUID
14+
from app.models.types import JSON as JSONB
15+
from app.models.types import EncryptedString
16+
17+
18+
class ConnectedAccountStatus(str, enum.Enum):
19+
ACTIVE = "active"
20+
REVOKED = "revoked"
21+
EXPIRED = "expired"
22+
PENDING = "pending"
23+
24+
25+
class ProviderType(Base):
26+
__tablename__ = "provider_types"
27+
28+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
29+
type_code = Column(String(50), unique=True, nullable=False)
30+
display_name = Column(String(100), nullable=False)
31+
category = Column(String(50), nullable=False, default="integration")
32+
supports_oauth = Column(String(5), default="true") # stored as string for sqlite compat in tests
33+
is_active = Column(String(5), default="true")
34+
created_at = Column(DateTime, default=datetime.utcnow)
35+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
36+
37+
38+
class ConnectedAccount(Base):
39+
"""Vault-backed delegated SaaS connection for Coupler execute."""
40+
41+
__tablename__ = "connected_accounts"
42+
43+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
44+
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
45+
organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=True)
46+
47+
provider_type = Column(String(50), nullable=False, index=True)
48+
provider_name = Column(String(100), nullable=False)
49+
provider_id = Column(String(255), nullable=True)
50+
51+
access_token_encrypted = Column(EncryptedString())
52+
refresh_token_encrypted = Column(EncryptedString())
53+
oauth_scopes = Column(JSONB, default=list)
54+
oauth_expires_at = Column(DateTime, nullable=True)
55+
56+
status = Column(String(20), default=ConnectedAccountStatus.ACTIVE.value)
57+
metadata = Column(JSONB, default=dict)
58+
last_used_at = Column(DateTime, nullable=True)
59+
60+
created_at = Column(DateTime, default=datetime.utcnow)
61+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
62+
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
63+
64+
user = relationship("User", foreign_keys=[user_id])
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""Coupler connections API — ConnectedAccount vault and token delegation."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import uuid
7+
from datetime import datetime
8+
from typing import Any, Optional
9+
10+
from fastapi import APIRouter, Depends, Header, HTTPException
11+
from pydantic import BaseModel, Field
12+
from sqlalchemy.ext.asyncio import AsyncSession
13+
14+
from app.config import settings
15+
from app.database import get_db
16+
from app.dependencies import get_current_user
17+
from app.models import ActivityLog, User
18+
from app.services.connected_account_service import ConnectedAccountService
19+
20+
logger = logging.getLogger(__name__)
21+
22+
router = APIRouter(prefix="/connections", tags=["Connections"])
23+
24+
25+
class ConnectionSummary(BaseModel):
26+
id: str
27+
provider_type: str
28+
provider_name: str
29+
provider_id: Optional[str] = None
30+
scopes: list[str] = Field(default_factory=list)
31+
status: str
32+
expires_at: Optional[datetime] = None
33+
last_used_at: Optional[datetime] = None
34+
created_at: Optional[datetime] = None
35+
36+
37+
class ConnectionListResponse(BaseModel):
38+
connections: list[ConnectionSummary]
39+
count: int
40+
41+
42+
class TokenDelegationRequest(BaseModel):
43+
purpose: str = "tool_execute"
44+
ttl_seconds: int = Field(default=300, ge=60, le=900)
45+
46+
47+
class TokenDelegationResponse(BaseModel):
48+
access_token: str
49+
token_type: str = "Bearer"
50+
expires_at: str
51+
purpose: str
52+
provider_type: str
53+
scopes: list[str] = Field(default_factory=list)
54+
55+
56+
def _require_atp_service(
57+
x_service_token: Optional[str] = Header(None, alias="X-Service-Token"),
58+
authorization: Optional[str] = Header(None),
59+
) -> None:
60+
expected = getattr(settings, "JANUA_SERVICE_TOKEN", None) or ""
61+
if not expected:
62+
raise HTTPException(status_code=404, detail="not found")
63+
candidate = x_service_token
64+
if not candidate and authorization and authorization.lower().startswith("bearer "):
65+
candidate = authorization[7:].strip()
66+
if not candidate or candidate != expected:
67+
raise HTTPException(status_code=401, detail="invalid_service_credentials")
68+
69+
70+
def _to_summary(conn) -> ConnectionSummary:
71+
return ConnectionSummary(
72+
id=str(conn.id),
73+
provider_type=conn.provider_type,
74+
provider_name=conn.provider_name,
75+
provider_id=conn.provider_id,
76+
scopes=list(conn.oauth_scopes or []),
77+
status=conn.status,
78+
expires_at=conn.oauth_expires_at,
79+
last_used_at=conn.last_used_at,
80+
created_at=conn.created_at,
81+
)
82+
83+
84+
@router.get("", response_model=ConnectionListResponse)
85+
async def list_connections(
86+
db: AsyncSession = Depends(get_db),
87+
current_user: User = Depends(get_current_user),
88+
):
89+
"""List delegated SaaS connections for the authenticated user (no secrets)."""
90+
svc = ConnectedAccountService(db)
91+
connections = await svc.list_for_user(current_user, sync_oauth=True)
92+
summaries = [_to_summary(c) for c in connections]
93+
return ConnectionListResponse(connections=summaries, count=len(summaries))
94+
95+
96+
@router.delete("/{connection_id}")
97+
async def revoke_connection(
98+
connection_id: str,
99+
db: AsyncSession = Depends(get_db),
100+
current_user: User = Depends(get_current_user),
101+
):
102+
svc = ConnectedAccountService(db)
103+
try:
104+
cid = uuid.UUID(connection_id)
105+
except ValueError:
106+
raise HTTPException(status_code=400, detail="invalid_connection_id")
107+
if not await svc.revoke(current_user, cid):
108+
raise HTTPException(status_code=404, detail="connection_not_found")
109+
activity = ActivityLog(
110+
user_id=current_user.id,
111+
action="connection.revoked",
112+
resource_type="connected_account",
113+
resource_id=connection_id,
114+
activity_metadata={"source": "user"},
115+
)
116+
db.add(activity)
117+
await db.commit()
118+
return {"revoked": True, "id": connection_id}
119+
120+
121+
@router.post("/{connection_id}/token", response_model=TokenDelegationResponse)
122+
async def delegate_connection_token(
123+
connection_id: str,
124+
body: TokenDelegationRequest,
125+
db: AsyncSession = Depends(get_db),
126+
x_acting_user_id: str = Header(..., alias="X-Acting-User-Id"),
127+
_: None = Depends(_require_atp_service),
128+
):
129+
"""Issue a short-lived access token for Coupler tool execute (ATP service only)."""
130+
svc = ConnectedAccountService(db)
131+
try:
132+
cid = uuid.UUID(connection_id)
133+
acting_uid = uuid.UUID(x_acting_user_id)
134+
except ValueError:
135+
raise HTTPException(status_code=400, detail="invalid_id")
136+
137+
connection = await svc.get_by_id(cid)
138+
if not connection:
139+
raise HTTPException(status_code=404, detail="connection_not_found")
140+
141+
try:
142+
payload = await svc.delegate_token(
143+
connection,
144+
acting_user_id=acting_uid,
145+
purpose=body.purpose,
146+
ttl_seconds=body.ttl_seconds,
147+
)
148+
except PermissionError:
149+
raise HTTPException(status_code=403, detail="acting_user_mismatch")
150+
except ValueError as e:
151+
raise HTTPException(status_code=404, detail=str(e))
152+
153+
activity = ActivityLog(
154+
user_id=acting_uid,
155+
action="tool.delegation.issued",
156+
resource_type="connected_account",
157+
resource_id=connection_id,
158+
activity_metadata={
159+
"purpose": body.purpose,
160+
"provider_type": connection.provider_type,
161+
"ttl_seconds": body.ttl_seconds,
162+
},
163+
)
164+
db.add(activity)
165+
await db.commit()
166+
167+
return TokenDelegationResponse(**payload)
168+
169+
170+
@router.post("/sync/{provider}")
171+
async def sync_provider_connection(
172+
provider: str,
173+
db: AsyncSession = Depends(get_db),
174+
current_user: User = Depends(get_current_user),
175+
):
176+
"""Explicitly sync a provider connection from linked OAuthAccount."""
177+
if provider not in ("github", "slack"):
178+
raise HTTPException(status_code=400, detail="unsupported_provider")
179+
svc = ConnectedAccountService(db)
180+
await svc.list_for_user(current_user, sync_oauth=True)
181+
connections = await svc.list_for_user(current_user, sync_oauth=False)
182+
matched = [c for c in connections if c.provider_type == provider]
183+
if not matched:
184+
raise HTTPException(
185+
status_code=404,
186+
detail=f"No {provider} connection. Link {provider} via OAuth first.",
187+
)
188+
return {"synced": True, "connection": _to_summary(matched[0])}

0 commit comments

Comments
 (0)