Map verified Studio subjects to isolated workspaces

#5
by basyx - opened
Files changed (1) hide show
  1. app/security/tenancy.py +56 -0
app/security/tenancy.py CHANGED
@@ -100,6 +100,62 @@ class TenantService:
100
  membership_role=membership.role,
101
  )
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  async def bind_api_key(
104
  self, *, api_key_id: str, workspace_id: str, user_id: str
105
  ) -> TenantPrincipal:
 
100
  membership_role=membership.role,
101
  )
102
 
103
+ async def resolve_studio_identity(
104
+ self, *, subject: str, email: str, display_name: str
105
+ ) -> TenantPrincipal:
106
+ """Resolve a verified Supabase subject to its own backend workspace."""
107
+ if not subject or len(subject) > 255:
108
+ raise UnauthorizedError
109
+ external_subject = f"supabase:{subject}"
110
+ async with self.database.session() as session:
111
+ user = await session.scalar(select(User).where(User.subject == external_subject))
112
+ if user is not None:
113
+ if user.status != "active":
114
+ raise ForbiddenError
115
+ membership = await session.scalar(
116
+ select(WorkspaceMembership).where(
117
+ WorkspaceMembership.user_id == user.id,
118
+ WorkspaceMembership.status == "active",
119
+ ).order_by(WorkspaceMembership.created_at.asc())
120
+ )
121
+ if membership is None:
122
+ raise ForbiddenError
123
+ workspace = await session.get(Workspace, membership.workspace_id)
124
+ if workspace is None or workspace.status != "active":
125
+ raise ForbiddenError
126
+ return TenantPrincipal(workspace.id, user.id, membership.id, membership.role)
127
+
128
+ user_id, workspace_id, membership_id = str(uuid4()), str(uuid4()), str(uuid4())
129
+ safe_name = (display_name or email or "Studio user").strip()[:255] or "Studio user"
130
+ user = User(id=user_id, subject=external_subject, display_name=safe_name,
131
+ metadata_json={"identity_provider": "supabase"})
132
+ workspace = Workspace(id=workspace_id, slug=f"studio-{user_id.replace('-', '')}",
133
+ name=f"{safe_name}'s workspace"[:255],
134
+ metadata_json={"provisioned_from": "studio_auth"})
135
+ membership = WorkspaceMembership(id=membership_id, workspace_id=workspace_id,
136
+ user_id=user_id, role="admin", status="active")
137
+ session.add_all([user, workspace, membership])
138
+ try:
139
+ await session.commit()
140
+ except IntegrityError:
141
+ await session.rollback()
142
+ user = await session.scalar(select(User).where(User.subject == external_subject))
143
+ if user is None or user.status != "active":
144
+ raise
145
+ membership = await session.scalar(
146
+ select(WorkspaceMembership).where(
147
+ WorkspaceMembership.user_id == user.id,
148
+ WorkspaceMembership.status == "active",
149
+ ).order_by(WorkspaceMembership.created_at.asc())
150
+ )
151
+ if membership is None:
152
+ raise ForbiddenError
153
+ workspace = await session.get(Workspace, membership.workspace_id)
154
+ if workspace is None or workspace.status != "active":
155
+ raise ForbiddenError
156
+ return TenantPrincipal(workspace.id, user.id, membership.id, membership.role)
157
+ return TenantPrincipal(workspace_id, user_id, membership_id, "admin")
158
+
159
  async def bind_api_key(
160
  self, *, api_key_id: str, workspace_id: str, user_id: str
161
  ) -> TenantPrincipal: