Add verified Studio identity authorization

#7
by basyx - opened
app/projects/api.py CHANGED
@@ -66,8 +66,8 @@ async def update_notification_preference(request: Request, payload: dict[str, An
66
 
67
  @router.post("/workspace/invitations", response_model=InvitationResponse)
68
  async def invite_member(request: Request, payload: InvitationCreate) -> InvitationResponse:
69
- workspace_id, _, _, _ = _identity(request)
70
- return await request.app.state.container.collaboration.invite_member(workspace_id, payload.email, payload.role)
71
 
72
  @router.patch("/workspace/teams/{team_id}", response_model=TeamResponse)
73
  async def update_team(request: Request, team_id: str, payload: TeamBase) -> TeamResponse:
@@ -82,7 +82,8 @@ async def archive_team(request: Request, team_id: str) -> Response:
82
 
83
  @router.get("/workspace/workflows/{workflow_id}/requests", response_model=list[ApprovalRequest])
84
  async def list_approval_requests(request: Request, workflow_id: str) -> list[ApprovalRequest]:
85
- return await request.app.state.container.approval.list_requests(workflow_id)
 
86
 
87
 
88
  @router.post("/workspace/requests/{request_id}/approve", response_model=ApprovalRequest)
@@ -90,16 +91,16 @@ async def approve_request(
90
  request: Request,
91
  request_id: str
92
  ) -> ApprovalRequest:
93
- _, user_id, _, _ = _identity(request)
94
- return await request.app.state.container.approval.approve_request(request_id, user_id)
95
 
96
  @router.post("/workspace/requests/{request_id}/reject", response_model=ApprovalRequest)
97
  async def reject_request(
98
  request: Request,
99
  request_id: str
100
  ) -> ApprovalRequest:
101
- _, user_id, _, _ = _identity(request)
102
- return await request.app.state.container.approval.reject_request(request_id, user_id)
103
 
104
  @router.post("/workspace/requests/{request_id}/comments", response_model=ReviewComment)
105
  async def add_review_comment(
@@ -327,7 +328,8 @@ async def list_project_collaborators(
327
  request: Request,
328
  project_id: UUID
329
  ) -> list[MemberResponse]:
330
- return await request.app.state.container.collaboration.list_project_collaborators(str(project_id))
 
331
 
332
  @router.post("/{project_id}/collaborators", response_model=MemberResponse)
333
  async def add_project_collaborator(
@@ -347,7 +349,8 @@ async def remove_project_collaborator(
347
  project_id: UUID,
348
  user_id: str
349
  ) -> Response:
350
- await request.app.state.container.collaboration.remove_project_collaborator(str(project_id), user_id)
 
351
  return Response(status_code=status.HTTP_204_NO_CONTENT)
352
 
353
 
 
66
 
67
  @router.post("/workspace/invitations", response_model=InvitationResponse)
68
  async def invite_member(request: Request, payload: InvitationCreate) -> InvitationResponse:
69
+ workspace_id, actor_user_id, _, _ = _identity(request)
70
+ return await request.app.state.container.collaboration.invite_member(workspace_id, actor_user_id, payload.email, payload.role)
71
 
72
  @router.patch("/workspace/teams/{team_id}", response_model=TeamResponse)
73
  async def update_team(request: Request, team_id: str, payload: TeamBase) -> TeamResponse:
 
82
 
83
  @router.get("/workspace/workflows/{workflow_id}/requests", response_model=list[ApprovalRequest])
84
  async def list_approval_requests(request: Request, workflow_id: str) -> list[ApprovalRequest]:
85
+ workspace_id, _, _, _ = _identity(request)
86
+ return await request.app.state.container.approval.list_requests(workspace_id, workflow_id)
87
 
88
 
89
  @router.post("/workspace/requests/{request_id}/approve", response_model=ApprovalRequest)
 
91
  request: Request,
92
  request_id: str
93
  ) -> ApprovalRequest:
94
+ workspace_id, user_id, _, _ = _identity(request)
95
+ return await request.app.state.container.approval.approve_request(workspace_id, request_id, user_id)
96
 
97
  @router.post("/workspace/requests/{request_id}/reject", response_model=ApprovalRequest)
98
  async def reject_request(
99
  request: Request,
100
  request_id: str
101
  ) -> ApprovalRequest:
102
+ workspace_id, user_id, _, _ = _identity(request)
103
+ return await request.app.state.container.approval.reject_request(workspace_id, request_id, user_id)
104
 
105
  @router.post("/workspace/requests/{request_id}/comments", response_model=ReviewComment)
106
  async def add_review_comment(
 
328
  request: Request,
329
  project_id: UUID
330
  ) -> list[MemberResponse]:
331
+ workspace_id, _, _, _ = _identity(request)
332
+ return await request.app.state.container.collaboration.list_project_collaborators(workspace_id, str(project_id))
333
 
334
  @router.post("/{project_id}/collaborators", response_model=MemberResponse)
335
  async def add_project_collaborator(
 
349
  project_id: UUID,
350
  user_id: str
351
  ) -> Response:
352
+ workspace_id, _, _, _ = _identity(request)
353
+ await request.app.state.container.collaboration.remove_project_collaborator(workspace_id, str(project_id), user_id)
354
  return Response(status_code=status.HTTP_204_NO_CONTENT)
355
 
356
 
app/projects/repositories/approval_repository.py CHANGED
@@ -30,9 +30,14 @@ class ApprovalRepository:
30
  await session.refresh(request)
31
  return request
32
 
33
- async def update_request_status(self, request_id: str, status: str) -> ApprovalRequest:
34
  async with self.database.session() as session:
35
- request = await session.get(ApprovalRequest, request_id)
 
 
 
 
 
36
  if not request:
37
  raise Exception("Request not found")
38
  request.status = status
@@ -40,15 +45,28 @@ class ApprovalRepository:
40
  await session.refresh(request)
41
  return request
42
 
43
- async def get_request(self, request_id: str) -> ApprovalRequest:
44
  async with self.database.session() as session:
45
- request = await session.get(ApprovalRequest, request_id)
 
 
 
 
 
46
  if not request:
47
  raise Exception("Request not found")
48
  return request
49
 
50
  async def add_review_comment(self, request_id: str, user_id: str, workspace_id: str, content: str) -> ReviewComment:
51
  async with self.database.session() as session:
 
 
 
 
 
 
 
 
52
  comment = ReviewComment(
53
  request_id=request_id,
54
  user_id=user_id,
@@ -60,10 +78,13 @@ class ApprovalRepository:
60
  await session.refresh(comment)
61
  return comment
62
 
63
- async def list_requests(self, workflow_id: str) -> list[ApprovalRequest]:
64
  async with self.database.session() as session:
65
  result = await session.scalars(
66
- select(ApprovalRequest).where(ApprovalRequest.workflow_id == workflow_id)
 
 
 
67
  )
68
  return list(result.all())
69
 
 
30
  await session.refresh(request)
31
  return request
32
 
33
+ async def update_request_status(self, workspace_id: str, request_id: str, status: str) -> ApprovalRequest:
34
  async with self.database.session() as session:
35
+ request = await session.scalar(
36
+ select(ApprovalRequest).where(
37
+ ApprovalRequest.id == request_id,
38
+ ApprovalRequest.workspace_id == workspace_id,
39
+ )
40
+ )
41
  if not request:
42
  raise Exception("Request not found")
43
  request.status = status
 
45
  await session.refresh(request)
46
  return request
47
 
48
+ async def get_request(self, workspace_id: str, request_id: str) -> ApprovalRequest:
49
  async with self.database.session() as session:
50
+ request = await session.scalar(
51
+ select(ApprovalRequest).where(
52
+ ApprovalRequest.id == request_id,
53
+ ApprovalRequest.workspace_id == workspace_id,
54
+ )
55
+ )
56
  if not request:
57
  raise Exception("Request not found")
58
  return request
59
 
60
  async def add_review_comment(self, request_id: str, user_id: str, workspace_id: str, content: str) -> ReviewComment:
61
  async with self.database.session() as session:
62
+ request = await session.scalar(
63
+ select(ApprovalRequest).where(
64
+ ApprovalRequest.id == request_id,
65
+ ApprovalRequest.workspace_id == workspace_id,
66
+ )
67
+ )
68
+ if request is None:
69
+ raise ValueError("Approval request was not found in this workspace.")
70
  comment = ReviewComment(
71
  request_id=request_id,
72
  user_id=user_id,
 
78
  await session.refresh(comment)
79
  return comment
80
 
81
+ async def list_requests(self, workspace_id: str, workflow_id: str) -> list[ApprovalRequest]:
82
  async with self.database.session() as session:
83
  result = await session.scalars(
84
+ select(ApprovalRequest).where(
85
+ ApprovalRequest.workspace_id == workspace_id,
86
+ ApprovalRequest.workflow_id == workflow_id,
87
+ )
88
  )
89
  return list(result.all())
90
 
app/projects/repositories/collaboration_repository.py CHANGED
@@ -5,6 +5,7 @@ from app.security.database import SecurityDatabase
5
  from sqlalchemy import select, func
6
  from app.projects.models.collaboration import Team, Invitation, ProjectCollaborator, CollaborationActivity
7
  from app.security.models import WorkspaceMembership
 
8
 
9
  class CollaborationRepository:
10
  def __init__(self, database: SecurityDatabase) -> None:
@@ -148,15 +149,35 @@ class CollaborationRepository:
148
  )
149
  return list(result.all())
150
 
151
- async def list_project_collaborators(self, project_id: str) -> list[ProjectCollaborator]:
152
  async with self.database.session() as session:
153
  result = await session.scalars(
154
- select(ProjectCollaborator).where(ProjectCollaborator.project_id == project_id)
 
 
 
 
 
 
155
  )
156
  return list(result.all())
157
 
158
  async def add_project_collaborator(self, workspace_id: str, project_id: str, user_id: str, role: str) -> ProjectCollaborator:
159
  async with self.database.session() as session:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  collaborator = ProjectCollaborator(
161
  workspace_id=workspace_id,
162
  project_id=project_id,
@@ -168,12 +189,16 @@ class CollaborationRepository:
168
  await session.refresh(collaborator)
169
  return collaborator
170
 
171
- async def remove_project_collaborator(self, project_id: str, user_id: str) -> None:
172
  async with self.database.session() as session:
173
  collaborator = await session.scalar(
174
- select(ProjectCollaborator).where(
 
 
 
175
  ProjectCollaborator.project_id == project_id,
176
- ProjectCollaborator.user_id == user_id
 
177
  )
178
  )
179
  if collaborator:
 
5
  from sqlalchemy import select, func
6
  from app.projects.models.collaboration import Team, Invitation, ProjectCollaborator, CollaborationActivity
7
  from app.security.models import WorkspaceMembership
8
+ from app.projects.models.project import Project
9
 
10
  class CollaborationRepository:
11
  def __init__(self, database: SecurityDatabase) -> None:
 
149
  )
150
  return list(result.all())
151
 
152
+ async def list_project_collaborators(self, workspace_id: str, project_id: str) -> list[ProjectCollaborator]:
153
  async with self.database.session() as session:
154
  result = await session.scalars(
155
+ select(ProjectCollaborator)
156
+ .join(Project, Project.id == ProjectCollaborator.project_id)
157
+ .where(
158
+ ProjectCollaborator.workspace_id == workspace_id,
159
+ ProjectCollaborator.project_id == project_id,
160
+ Project.workspace_id == workspace_id,
161
+ )
162
  )
163
  return list(result.all())
164
 
165
  async def add_project_collaborator(self, workspace_id: str, project_id: str, user_id: str, role: str) -> ProjectCollaborator:
166
  async with self.database.session() as session:
167
+ project = await session.scalar(
168
+ select(Project).where(Project.id == project_id, Project.workspace_id == workspace_id)
169
+ )
170
+ membership = await session.scalar(
171
+ select(WorkspaceMembership).where(
172
+ WorkspaceMembership.workspace_id == workspace_id,
173
+ WorkspaceMembership.user_id == user_id,
174
+ WorkspaceMembership.status == "active",
175
+ )
176
+ )
177
+ if project is None or membership is None:
178
+ raise ValueError("Project and collaborator must belong to the authenticated workspace.")
179
+ if role not in {"viewer", "editor", "admin", "member"}:
180
+ raise ValueError("Unsupported project collaborator role.")
181
  collaborator = ProjectCollaborator(
182
  workspace_id=workspace_id,
183
  project_id=project_id,
 
189
  await session.refresh(collaborator)
190
  return collaborator
191
 
192
+ async def remove_project_collaborator(self, workspace_id: str, project_id: str, user_id: str) -> None:
193
  async with self.database.session() as session:
194
  collaborator = await session.scalar(
195
+ select(ProjectCollaborator)
196
+ .join(Project, Project.id == ProjectCollaborator.project_id)
197
+ .where(
198
+ ProjectCollaborator.workspace_id == workspace_id,
199
  ProjectCollaborator.project_id == project_id,
200
+ ProjectCollaborator.user_id == user_id,
201
+ Project.workspace_id == workspace_id,
202
  )
203
  )
204
  if collaborator:
app/projects/services/approval_service.py CHANGED
@@ -15,8 +15,8 @@ class ApprovalService:
15
  state = await self.editor_repository.get_state(workspace_id, project_id)
16
  return await self.repository.create_request(workspace_id, workflow_id, project_id, user_id, state.revision)
17
 
18
- async def approve_request(self, request_id: str, actor_user_id: str) -> ApprovalRequest:
19
- request = await self.repository.get_request(request_id)
20
  if request.created_by == actor_user_id:
21
  raise Exception("Separation of duties: Cannot approve your own submission.")
22
 
@@ -25,13 +25,13 @@ class ApprovalService:
25
  if state.revision != request.editor_revision:
26
  raise Exception("Content has changed since request; approval is stale.")
27
 
28
- return await self.repository.update_request_status(request_id, "approved")
29
 
30
- async def reject_request(self, request_id: str, actor_user_id: str) -> ApprovalRequest:
31
- return await self.repository.update_request_status(request_id, "rejected")
32
 
33
  async def add_comment(self, request_id: str, user_id: str, workspace_id: str, content: str) -> ReviewComment:
34
  return await self.repository.add_review_comment(request_id, user_id, workspace_id, content)
35
 
36
- async def list_requests(self, workflow_id: str) -> list[ApprovalRequest]:
37
- return await self.repository.list_requests(workflow_id)
 
15
  state = await self.editor_repository.get_state(workspace_id, project_id)
16
  return await self.repository.create_request(workspace_id, workflow_id, project_id, user_id, state.revision)
17
 
18
+ async def approve_request(self, workspace_id: str, request_id: str, actor_user_id: str) -> ApprovalRequest:
19
+ request = await self.repository.get_request(workspace_id, request_id)
20
  if request.created_by == actor_user_id:
21
  raise Exception("Separation of duties: Cannot approve your own submission.")
22
 
 
25
  if state.revision != request.editor_revision:
26
  raise Exception("Content has changed since request; approval is stale.")
27
 
28
+ return await self.repository.update_request_status(workspace_id, request_id, "approved")
29
 
30
+ async def reject_request(self, workspace_id: str, request_id: str, actor_user_id: str) -> ApprovalRequest:
31
+ return await self.repository.update_request_status(workspace_id, request_id, "rejected")
32
 
33
  async def add_comment(self, request_id: str, user_id: str, workspace_id: str, content: str) -> ReviewComment:
34
  return await self.repository.add_review_comment(request_id, user_id, workspace_id, content)
35
 
36
+ async def list_requests(self, workspace_id: str, workflow_id: str) -> list[ApprovalRequest]:
37
+ return await self.repository.list_requests(workspace_id, workflow_id)
app/projects/services/collaboration_service.py CHANGED
@@ -33,7 +33,10 @@ class CollaborationService:
33
  async def archive_team(self, workspace_id: str, team_id: str) -> None:
34
  await self.repository.archive_team(workspace_id, team_id)
35
 
36
- async def invite_member(self, workspace_id: str, email: str, role: str) -> InvitationResponse:
 
 
 
37
  if role not in {"admin", "member"}:
38
  raise ValueError("Invitation role must be admin or member.")
39
  token = secrets.token_urlsafe(32)
@@ -101,20 +104,32 @@ class CollaborationService:
101
  if actor_user_id == target_user_id and new_role == 'admin':
102
  raise CollaborationUnauthorizedError("Cannot elevate your own privileges.")
103
 
104
- # Perform update
 
 
 
 
 
 
 
 
 
105
  await self.repository.update_member_role(workspace_id, target_user_id, new_role)
106
 
107
  async def get_membership(self, workspace_id: str, user_id: str) -> WorkspaceMembership | None:
108
  return await self.repository.get_membership(workspace_id, user_id)
109
 
110
- async def list_project_collaborators(self, project_id: str) -> list[MemberResponse]:
111
- collaborators = await self.repository.list_project_collaborators(project_id)
112
  return [MemberResponse(id=c.id, workspace_id=c.workspace_id, user_id=c.user_id, role=c.role, created_at=c.created_at.isoformat()) for c in collaborators]
113
 
114
  async def add_project_collaborator(self, workspace_id: str, project_id: str, user_id: str, role: str) -> MemberResponse:
115
  collaborator = await self.repository.add_project_collaborator(workspace_id, project_id, user_id, role)
116
  return MemberResponse(id=collaborator.id, workspace_id=collaborator.workspace_id, user_id=collaborator.user_id, role=collaborator.role, created_at=collaborator.created_at.isoformat())
117
 
 
 
 
118
  async def record_activity(self, workspace_id: str, user_id: str, action: str, entity_id: str, entity_type: str, metadata: dict[str, Any]) -> None:
119
  await self.repository.record_activity(workspace_id, user_id, action, entity_id, entity_type, metadata)
120
 
 
33
  async def archive_team(self, workspace_id: str, team_id: str) -> None:
34
  await self.repository.archive_team(workspace_id, team_id)
35
 
36
+ async def invite_member(self, workspace_id: str, actor_user_id: str, email: str, role: str) -> InvitationResponse:
37
+ actor_membership = await self.repository.get_membership(workspace_id, actor_user_id)
38
+ if actor_membership is None or actor_membership.status != "active" or actor_membership.role not in {"owner", "admin"}:
39
+ raise CollaborationUnauthorizedError("Only workspace owners or admins can invite members.")
40
  if role not in {"admin", "member"}:
41
  raise ValueError("Invitation role must be admin or member.")
42
  token = secrets.token_urlsafe(32)
 
104
  if actor_user_id == target_user_id and new_role == 'admin':
105
  raise CollaborationUnauthorizedError("Cannot elevate your own privileges.")
106
 
107
+ if new_role not in {"admin", "member"}:
108
+ raise ValueError("Member role must be admin or member.")
109
+ target_membership = await self.repository.get_membership(workspace_id, target_user_id)
110
+ if target_membership is None:
111
+ raise TeamMemberNotFoundError("Member not found.")
112
+ if target_membership.role == "admin" and new_role != "admin":
113
+ admin_count = await self.repository.count_workspace_admins(workspace_id)
114
+ if admin_count <= 1:
115
+ raise CollaborationUnauthorizedError("Cannot demote the last administrator.")
116
+ # Perform update only after validating the actor, target, and allowed role.
117
  await self.repository.update_member_role(workspace_id, target_user_id, new_role)
118
 
119
  async def get_membership(self, workspace_id: str, user_id: str) -> WorkspaceMembership | None:
120
  return await self.repository.get_membership(workspace_id, user_id)
121
 
122
+ async def list_project_collaborators(self, workspace_id: str, project_id: str) -> list[MemberResponse]:
123
+ collaborators = await self.repository.list_project_collaborators(workspace_id, project_id)
124
  return [MemberResponse(id=c.id, workspace_id=c.workspace_id, user_id=c.user_id, role=c.role, created_at=c.created_at.isoformat()) for c in collaborators]
125
 
126
  async def add_project_collaborator(self, workspace_id: str, project_id: str, user_id: str, role: str) -> MemberResponse:
127
  collaborator = await self.repository.add_project_collaborator(workspace_id, project_id, user_id, role)
128
  return MemberResponse(id=collaborator.id, workspace_id=collaborator.workspace_id, user_id=collaborator.user_id, role=collaborator.role, created_at=collaborator.created_at.isoformat())
129
 
130
+ async def remove_project_collaborator(self, workspace_id: str, project_id: str, user_id: str) -> None:
131
+ await self.repository.remove_project_collaborator(workspace_id, project_id, user_id)
132
+
133
  async def record_activity(self, workspace_id: str, user_id: str, action: str, entity_id: str, entity_type: str, metadata: dict[str, Any]) -> None:
134
  await self.repository.record_activity(workspace_id, user_id, action, entity_id, entity_type, metadata)
135
 
app/security/cli.py CHANGED
@@ -3,12 +3,16 @@ from __future__ import annotations
3
  import argparse
4
  import asyncio
5
  import json
 
 
 
6
 
7
  from app.core.config import get_settings
8
  from app.security.database import SecurityDatabase
9
  from app.security.schemas import APIKeyCreate, APIKeyView
10
  from app.security.service import APIKeyService
11
  from app.security.tenancy import TenantService
 
12
 
13
 
14
  async def _create(arguments: argparse.Namespace) -> None:
@@ -33,6 +37,58 @@ async def _create(arguments: argparse.Namespace) -> None:
33
  await database.close()
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def _bootstrap(arguments: argparse.Namespace) -> None:
37
  material = APIKeyService.generate_material(arguments.environment)
38
  print("Store the API key in your password manager. It will not be shown again.\n")
@@ -49,6 +105,15 @@ def main() -> None:
49
  "generate-bootstrap", help="Generate a key and hash-only bootstrap settings"
50
  )
51
  bootstrap.add_argument("--environment", choices=("live", "test"), default="live")
 
 
 
 
 
 
 
 
 
52
  create = commands.add_parser("create", help="Create a key directly in the configured database")
53
  create.add_argument("--name", required=True)
54
  create.add_argument("--environment", choices=("live", "test"), default="live")
@@ -57,6 +122,8 @@ def main() -> None:
57
  arguments = parser.parse_args()
58
  if arguments.command == "generate-bootstrap":
59
  _bootstrap(arguments)
 
 
60
  else:
61
  asyncio.run(_create(arguments))
62
 
 
3
  import argparse
4
  import asyncio
5
  import json
6
+ from uuid import uuid4
7
+
8
+ from sqlalchemy import select
9
 
10
  from app.core.config import get_settings
11
  from app.security.database import SecurityDatabase
12
  from app.security.schemas import APIKeyCreate, APIKeyView
13
  from app.security.service import APIKeyService
14
  from app.security.tenancy import TenantService
15
+ from app.security.models import User, Workspace, WorkspaceMembership
16
 
17
 
18
  async def _create(arguments: argparse.Namespace) -> None:
 
37
  await database.close()
38
 
39
 
40
+
41
+ async def _provision_studio_user(arguments: argparse.Namespace) -> None:
42
+ """Explicit operator-driven onboarding; never grant access from a browser request."""
43
+ settings = get_settings()
44
+ database = SecurityDatabase(settings.database_url, auto_migrate=settings.security_auto_migrate)
45
+ await database.initialize()
46
+ try:
47
+ async with database.session() as session:
48
+ existing = await session.scalar(select(User).where(User.subject == arguments.subject))
49
+ if existing is not None:
50
+ raise SystemExit("That identity subject is already provisioned; refusing to reassign it.")
51
+ workspace = await session.scalar(
52
+ select(Workspace).where(Workspace.slug == arguments.workspace_slug)
53
+ )
54
+ if workspace is not None:
55
+ raise SystemExit("Workspace slug already exists; refusing to attach an initial owner implicitly.")
56
+ user_id = str(uuid4())
57
+ workspace_id = str(uuid4())
58
+ user = User(
59
+ id=user_id,
60
+ subject=arguments.subject,
61
+ display_name=arguments.display_name,
62
+ metadata_json={"provisioned_by": "security-cli"},
63
+ )
64
+ workspace = Workspace(
65
+ id=workspace_id,
66
+ slug=arguments.workspace_slug,
67
+ name=arguments.workspace_name,
68
+ metadata_json={"provisioned_by": "security-cli"},
69
+ )
70
+ membership = WorkspaceMembership(
71
+ id=str(uuid4()),
72
+ workspace_id=workspace_id,
73
+ user_id=user_id,
74
+ role=arguments.role,
75
+ status="active",
76
+ )
77
+ session.add_all([user, workspace, membership])
78
+ await session.commit()
79
+ print(json.dumps({
80
+ "user_id": user_id,
81
+ "subject": arguments.subject,
82
+ "workspace_id": workspace_id,
83
+ "workspace_slug": arguments.workspace_slug,
84
+ "membership_id": membership.id,
85
+ "role": arguments.role,
86
+ "status": "active",
87
+ }, indent=2))
88
+ finally:
89
+ await database.close()
90
+
91
+
92
  def _bootstrap(arguments: argparse.Namespace) -> None:
93
  material = APIKeyService.generate_material(arguments.environment)
94
  print("Store the API key in your password manager. It will not be shown again.\n")
 
105
  "generate-bootstrap", help="Generate a key and hash-only bootstrap settings"
106
  )
107
  bootstrap.add_argument("--environment", choices=("live", "test"), default="live")
108
+ provision = commands.add_parser(
109
+ "provision-studio-user",
110
+ help="Explicitly provision a verified Supabase subject and initial workspace membership",
111
+ )
112
+ provision.add_argument("--subject", required=True, help="Exact Supabase auth user UUID/subject")
113
+ provision.add_argument("--display-name", required=True)
114
+ provision.add_argument("--workspace-slug", required=True)
115
+ provision.add_argument("--workspace-name", required=True)
116
+ provision.add_argument("--role", choices=("owner", "admin", "member"), default="owner")
117
  create = commands.add_parser("create", help="Create a key directly in the configured database")
118
  create.add_argument("--name", required=True)
119
  create.add_argument("--environment", choices=("live", "test"), default="live")
 
122
  arguments = parser.parse_args()
123
  if arguments.command == "generate-bootstrap":
124
  _bootstrap(arguments)
125
+ elif arguments.command == "provision-studio-user":
126
+ asyncio.run(_provision_studio_user(arguments))
127
  else:
128
  asyncio.run(_create(arguments))
129
 
app/security/middleware.py CHANGED
@@ -17,6 +17,7 @@ from app.security.errors import ForbiddenError, RateLimitError, UnauthorizedErro
17
  from app.security.policy import ScopePolicy
18
  from app.security.rate_limit import APIKeyRateLimiter, RateLimitLease
19
  from app.security.service import APIKeyService
 
20
 
21
  logger = get_logger(__name__)
22
 
@@ -56,6 +57,32 @@ class APIKeyAuthenticationMiddleware(BaseHTTPMiddleware):
56
  try:
57
  api_key = self._bearer_token(request.headers.get("authorization"))
58
  context = await self.api_keys.authenticate(api_key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  request.state.auth = context
60
  context_token = auth_context.set(context)
61
  required_scope = await self.policy.required_scope(request)
 
17
  from app.security.policy import ScopePolicy
18
  from app.security.rate_limit import APIKeyRateLimiter, RateLimitLease
19
  from app.security.service import APIKeyService
20
+ from app.security.studio_identity import constrain_context_to_membership, verify_studio_identity
21
 
22
  logger = get_logger(__name__)
23
 
 
57
  try:
58
  api_key = self._bearer_token(request.headers.get("authorization"))
59
  context = await self.api_keys.authenticate(api_key)
60
+ studio_header_names = (
61
+ "x-mr-studio-subject",
62
+ "x-mr-studio-email",
63
+ "x-mr-studio-name",
64
+ "x-mr-studio-timestamp",
65
+ "x-mr-studio-signature",
66
+ )
67
+ if any(request.headers.get(name) is not None for name in studio_header_names):
68
+ secret = (
69
+ self.settings.studio_identity_secret.get_secret_value()
70
+ if self.settings.studio_identity_secret
71
+ else ""
72
+ )
73
+ try:
74
+ identity = verify_studio_identity(request.headers, secret)
75
+ except ValueError as exc:
76
+ logger.warning("rejected Studio identity envelope: %s", str(exc))
77
+ raise UnauthorizedError from exc
78
+ principal = await self.api_keys.tenants.resolve_studio_subject(identity.subject)
79
+ try:
80
+ # The shared key authenticates transport only; persisted
81
+ # membership capabilities constrain its effective scopes.
82
+ context = constrain_context_to_membership(context, principal)
83
+ except ValueError as exc:
84
+ logger.warning("rejected Studio membership role: %s", str(exc))
85
+ raise ForbiddenError from exc
86
  request.state.auth = context
87
  context_token = auth_context.set(context)
88
  required_scope = await self.policy.required_scope(request)
app/security/studio_identity.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ import re
5
+ import time
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, replace
8
+
9
+ from app.security.context import AuthContext
10
+ from app.security.scopes import DEFAULT_ROLE_SCOPES
11
+ from app.security.tenancy import TenantPrincipal
12
+
13
+ _HEX_SHA256 = re.compile(r"^[a-f0-9]{64}$")
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class StudioIdentity:
18
+ subject: str
19
+ email: str
20
+ name: str
21
+ timestamp: int
22
+
23
+
24
+ def verify_studio_identity(
25
+ headers: Mapping[str, str],
26
+ secret: str,
27
+ *,
28
+ now: float | None = None,
29
+ max_age_seconds: int = 300,
30
+ ) -> StudioIdentity:
31
+ """Verify the Studio proxy's HMAC envelope; never trust unsigned identity headers."""
32
+ names = {
33
+ "subject": "x-mr-studio-subject",
34
+ "email": "x-mr-studio-email",
35
+ "name": "x-mr-studio-name",
36
+ "timestamp": "x-mr-studio-timestamp",
37
+ "signature": "x-mr-studio-signature",
38
+ }
39
+ values = {key: (headers.get(header) or "").strip() for key, header in names.items()}
40
+ if not secret or len(secret.encode("utf-8")) < 32:
41
+ raise ValueError("Studio identity verification is not configured.")
42
+ if any(not value for value in values.values()):
43
+ raise ValueError("Studio identity envelope is incomplete.")
44
+ subject, email, name, timestamp_text, signature = (
45
+ values["subject"], values["email"], values["name"], values["timestamp"], values["signature"]
46
+ )
47
+ if len(subject) > 255 or any(ord(ch) < 32 for ch in subject):
48
+ raise ValueError("Studio subject is malformed.")
49
+ if len(email) > 320 or len(name) > 160:
50
+ raise ValueError("Studio profile fields exceed their limits.")
51
+ if not timestamp_text.isascii() or not timestamp_text.isdecimal() or len(timestamp_text) > 12:
52
+ raise ValueError("Studio timestamp is malformed.")
53
+ if not _HEX_SHA256.fullmatch(signature):
54
+ raise ValueError("Studio signature is malformed.")
55
+ timestamp = int(timestamp_text)
56
+ current = time.time() if now is None else now
57
+ if abs(current - timestamp) > max_age_seconds:
58
+ raise ValueError("Studio identity envelope has expired.")
59
+ canonical = "\n".join((timestamp_text, subject, email, name)).encode("utf-8")
60
+ expected = hmac.digest(secret.encode("utf-8"), canonical, "sha256").hex()
61
+ if not hmac.compare_digest(expected, signature):
62
+ raise ValueError("Studio identity signature is invalid.")
63
+ return StudioIdentity(subject=subject, email=email, name=name, timestamp=timestamp)
64
+
65
+
66
+ def constrain_context_to_membership(
67
+ context: AuthContext, principal: TenantPrincipal
68
+ ) -> AuthContext:
69
+ """Intersect API-key scopes with persisted workspace-role capabilities."""
70
+ raw_role = principal.membership_role.strip().lower()
71
+ role_map = {
72
+ "owner": "developer",
73
+ "admin": "developer",
74
+ "developer": "developer",
75
+ "operator": "operator",
76
+ "viewer": "viewer",
77
+ "member": "viewer",
78
+ }
79
+ effective_role = role_map.get(raw_role)
80
+ if effective_role is None:
81
+ raise ValueError("Unsupported workspace membership role.")
82
+ return replace(
83
+ context,
84
+ role=effective_role,
85
+ scopes=frozenset(context.scopes.intersection(DEFAULT_ROLE_SCOPES[effective_role])),
86
+ workspace_id=principal.workspace_id,
87
+ user_id=principal.user_id,
88
+ membership_id=principal.membership_id,
89
+ membership_role=raw_role,
90
+ )
app/security/tenancy.py CHANGED
@@ -100,6 +100,38 @@ 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_subject(self, subject: str) -> TenantPrincipal:
104
+ """Resolve a verified identity only when it has exactly one active workspace."""
105
+ async with self.database.session() as session:
106
+ user = await session.scalar(
107
+ select(User).where(User.subject == subject, User.status == "active")
108
+ )
109
+ if user is None:
110
+ raise ForbiddenError
111
+ rows = list(
112
+ (
113
+ await session.execute(
114
+ select(WorkspaceMembership, Workspace)
115
+ .join(Workspace, Workspace.id == WorkspaceMembership.workspace_id)
116
+ .where(
117
+ WorkspaceMembership.user_id == user.id,
118
+ WorkspaceMembership.status == "active",
119
+ Workspace.status == "active",
120
+ )
121
+ )
122
+ ).all()
123
+ )
124
+ # Never guess a workspace when membership is absent or ambiguous.
125
+ if len(rows) != 1:
126
+ raise ForbiddenError
127
+ membership, workspace = rows[0]
128
+ return TenantPrincipal(
129
+ workspace_id=workspace.id,
130
+ user_id=user.id,
131
+ membership_id=membership.id,
132
+ membership_role=membership.role,
133
+ )
134
+
135
  async def bind_api_key(
136
  self, *, api_key_id: str, workspace_id: str, user_id: str
137
  ) -> TenantPrincipal:
docs/STUDIO_IDENTITY_SETUP.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MediaRouter Studio identity and tenant isolation
2
+
3
+ MediaRouter Studio authenticates users with Supabase on the Next.js server and forwards a short-lived, signed identity envelope to MediaRouter. The shared MediaRouter API token is a server-to-server transport credential; it must never be exposed to browser code.
4
+
5
+ ## Required configuration
6
+
7
+ Set these values in both environments:
8
+
9
+ - **Studio / Vercel:** `STUDIO_IDENTITY_SECRET` and `MEDIAROUTER_API_TOKEN`. Keep them server-only; do not use a `NEXT_PUBLIC_` prefix.
10
+ - **MediaRouter / Hugging Face Space:** `STUDIO_IDENTITY_SECRET` with the exact same secret value, plus the existing API authentication and production database configuration.
11
+
12
+ Use a cryptographically random secret of at least 32 bytes. Generate one with a password manager or a secure local random generator. Do not paste it into source control, logs, or chat. Restart/redeploy both services after setting it.
13
+
14
+ The Studio proxy signs the UTF-8 bytes of four newline-separated values in this order: timestamp (Unix seconds), Supabase user ID, normalized email, display name. It uses HMAC-SHA256 and sends the lowercase hexadecimal signature in `x-mr-studio-signature`. The backend validates the signature in constant time and rejects envelopes more than 300 seconds from server time.
15
+
16
+ ## Tenant onboarding is explicit
17
+
18
+ A valid Supabase session proves identity, but it does **not** automatically grant a MediaRouter workspace. Before a user can use tenant-backed Studio APIs, the backend security database must contain:
19
+
20
+ 1. An active `users` row whose `subject` exactly matches the Supabase user ID.
21
+ 2. Exactly one active `workspace_memberships` row for that user, linked to an active `workspaces` row.
22
+ 3. A supported membership role: `owner`, `admin`, `developer`, `operator`, `viewer`, or `member`.
23
+
24
+ Users with no active membership or more than one active workspace membership are denied. This is deliberate: the current Studio identity envelope has no workspace selector, and the backend must not guess a tenant. Do not solve onboarding by trusting a workspace ID from a browser request or by assigning every newly signed-in user an owner role.
25
+
26
+ For a controlled first-user bootstrap, an operator with access to the backend's configured security database can run the explicit CLI command from the backend project environment:
27
+
28
+ ```sh
29
+ python -m app.security.cli provision-studio-user \
30
+ --subject "<exact Supabase auth user ID>" \
31
+ --display-name "<display name>" \
32
+ --workspace-slug "<unique-lowercase-workspace-slug>" \
33
+ --workspace-name "<workspace name>" \
34
+ --role owner
35
+ ```
36
+
37
+ This command refuses to reassign an already-provisioned subject or reuse an existing workspace slug. It requires direct operator/database access and must not be exposed as an unauthenticated HTTP endpoint. For subsequent members, use the authorized invitation workflow; invitation acceptance still needs to be wired to the identity provider before invitations alone grant access.
38
+
39
+ ## Authorization behavior
40
+
41
+ The backend treats the shared API key as transport authentication only when a signed Studio identity is present. It resolves the Supabase subject against persisted backend membership, replaces the key's tenant identity with that persisted principal, and intersects API-key scopes with the membership role's default scopes. Workspace owners/admins are mapped to the backend `developer` capability set, not the global API `admin` role. Viewer/member access is restricted to the viewer capability set.
42
+
43
+ If any Studio identity header is present, the complete envelope and signature are required. The Studio proxy also fails closed when it cannot validate a Supabase session; it must not forward the shared token as a fallback for an unidentified browser request.
44
+
45
+ ## Production verification checklist
46
+
47
+ - [ ] Set the same random `STUDIO_IDENTITY_SECRET` in Vercel and the HF Space.
48
+ - [ ] Confirm `MEDIAROUTER_API_TOKEN` is only a server-side Vercel environment variable.
49
+ - [ ] Provision one explicit backend user and workspace membership for each Studio user, or implement an authorized invitation flow.
50
+ - [ ] Use a non-superuser tenant database role and verify forced RLS policies on production tables; a passing application-level check alone is not proof of database isolation.
51
+ - [ ] Test with two distinct Studio accounts: create/read/update/delete a project and access an asset/job in account A, then confirm account B cannot read, mutate, cancel, or download A's resources.
52
+ - [ ] Test expired and tampered signatures, disabled users/memberships/workspaces, and users with zero or multiple active memberships.
53
+ - [ ] Run the backend unit/integration tests and inspect production logs for denied cross-tenant access before merging/deploying.
54
+
55
+ This document describes the intended configuration and checks; it is not evidence that the live deployment has been configured or that the checklist has passed.
tests/test_approval_workspace_scope.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import AsyncMock
4
+
5
+ import pytest
6
+
7
+ from app.projects.repositories.approval_repository import ApprovalRepository
8
+ from app.projects.services.approval_service import ApprovalService
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ async def test_approval_repository_queries_are_workspace_scoped() -> None:
13
+ repository = ApprovalRepository(None)
14
+ # Repository calls require a real database; this regression test protects
15
+ # the service contract so routes cannot omit the authenticated workspace.
16
+ repository.list_requests = AsyncMock(return_value=[])
17
+ service = ApprovalService(repository, None)
18
+ result = await service.list_requests("workspace-a", "workflow-a")
19
+ assert result == []
20
+ repository.list_requests.assert_awaited_once_with("workspace-a", "workflow-a")
21
+
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_approval_actions_pass_workspace_to_repository() -> None:
25
+ repository = ApprovalRepository(None)
26
+ request = type("Request", (), {
27
+ "created_by": "submitter",
28
+ "project_id": "project-a",
29
+ "editor_revision": 1,
30
+ "workspace_id": "workspace-a",
31
+ })()
32
+ repository.get_request = AsyncMock(return_value=request)
33
+ repository.update_request_status = AsyncMock(return_value=request)
34
+ editor = type("Editor", (), {"get_state": AsyncMock(return_value=type("State", (), {"revision": 1})())})()
35
+ service = ApprovalService(repository, editor)
36
+ await service.approve_request("workspace-a", "request-a", "reviewer")
37
+ repository.get_request.assert_awaited_once_with("workspace-a", "request-a")
38
+ repository.update_request_status.assert_awaited_once_with("workspace-a", "request-a", "approved")
tests/test_project_collaboration_authorization.py CHANGED
@@ -1,23 +1,53 @@
1
  from __future__ import annotations
2
 
3
- import unittest
4
 
5
- from app.projects.repositories.collaboration_repository import CollaborationRepository
6
- from app.projects.services.collaboration_service import CollaborationService
7
-
8
-
9
- class CollaborationAuthorizationTests(unittest.TestCase):
10
- def test_project_collaborator_list_requires_project_workspace(self) -> None:
11
- repository = CollaborationRepository(None)
12
- service = CollaborationService(repository)
13
 
14
- with self.assertRaises(Exception):
15
- service.list_project_collaborators("project-from-another-workspace")
16
-
17
- def test_project_collaborator_removal_requires_project_workspace(self) -> None:
18
- repository = CollaborationRepository(None)
19
- service = CollaborationService(repository)
20
 
21
- with self.assertRaises(Exception):
22
- service.remove_project_collaborator("project-from-another-workspace", "user-id")
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ from unittest.mock import AsyncMock
4
 
5
+ import pytest
 
 
 
 
 
 
 
6
 
7
+ from app.projects.services.collaboration_service import CollaborationService
 
 
 
 
 
8
 
 
 
9
 
10
+ @pytest.mark.asyncio
11
+ async def test_project_collaborator_listing_passes_authenticated_workspace() -> None:
12
+ repository = type("Repository", (), {})()
13
+ repository.list_project_collaborators = AsyncMock(return_value=[])
14
+ service = CollaborationService(repository)
15
+ assert await service.list_project_collaborators("workspace-a", "project-a") == []
16
+ repository.list_project_collaborators.assert_awaited_once_with("workspace-a", "project-a")
17
+
18
+
19
+ @pytest.mark.asyncio
20
+ async def test_project_collaborator_removal_passes_authenticated_workspace() -> None:
21
+ repository = type("Repository", (), {})()
22
+ repository.remove_project_collaborator = AsyncMock()
23
+ service = CollaborationService(repository)
24
+ await service.remove_project_collaborator("workspace-a", "project-a", "user-b")
25
+ repository.remove_project_collaborator.assert_awaited_once_with(
26
+ "workspace-a", "project-a", "user-b"
27
+ )
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_invitation_requires_workspace_admin_or_owner() -> None:
32
+ repository = type("Repository", (), {})()
33
+ repository.get_membership = AsyncMock(return_value=type(
34
+ "Membership", (), {"role": "member", "status": "active"}
35
+ )())
36
+ service = CollaborationService(repository)
37
+ with pytest.raises(Exception, match="Only workspace owners or admins"):
38
+ await service.invite_member("workspace-a", "user-a", "new@example.com", "member")
39
+ repository.invite_member_with_hash = AsyncMock()
40
+ repository.invite_member_with_hash.assert_not_awaited()
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_member_role_changes_reject_privileged_unknown_roles() -> None:
45
+ repository = type("Repository", (), {})()
46
+ repository.get_membership = AsyncMock(return_value=type(
47
+ "Membership", (), {"role": "admin", "status": "active", "user_id": "admin-a"}
48
+ )())
49
+ repository.update_member_role = AsyncMock()
50
+ service = CollaborationService(repository)
51
+ with pytest.raises(ValueError, match="Member role must be admin or member"):
52
+ await service.update_member_role("workspace-a", "admin-a", "user-b", "owner")
53
+ repository.update_member_role.assert_not_awaited()
tests/test_studio_identity.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ from uuid import uuid4
6
+
7
+ import pytest
8
+ from sqlalchemy import select
9
+
10
+ from app.security.context import AuthContext
11
+ from app.security.database import SecurityDatabase
12
+ from app.security.errors import ForbiddenError
13
+ from app.security.models import User, Workspace, WorkspaceMembership
14
+ from app.security.studio_identity import constrain_context_to_membership, verify_studio_identity
15
+ from app.security.tenancy import TenantPrincipal, TenantService
16
+
17
+
18
+ SECRET = "a" * 64
19
+
20
+
21
+ def signed_headers(
22
+ *,
23
+ timestamp: int = 1_800_000_000,
24
+ subject: str = "supabase-user-1",
25
+ email: str = "user@example.com",
26
+ name: str = "Studio User",
27
+ ) -> dict[str, str]:
28
+ signature = hmac.new(
29
+ SECRET.encode(),
30
+ "\n".join((str(timestamp), subject, email, name)).encode(),
31
+ hashlib.sha256,
32
+ ).hexdigest()
33
+ return {
34
+ "x-mr-studio-subject": subject,
35
+ "x-mr-studio-email": email,
36
+ "x-mr-studio-name": name,
37
+ "x-mr-studio-timestamp": str(timestamp),
38
+ "x-mr-studio-signature": signature,
39
+ }
40
+
41
+
42
+ def test_valid_studio_identity_signature_is_accepted() -> None:
43
+ identity = verify_studio_identity(signed_headers(), SECRET, now=1_800_000_000)
44
+ assert identity.subject == "supabase-user-1"
45
+ assert identity.email == "user@example.com"
46
+
47
+
48
+ @pytest.mark.parametrize(
49
+ ("changed_header", "changed_value"),
50
+ [
51
+ ("x-mr-studio-subject", "another-user"),
52
+ ("x-mr-studio-email", "attacker@example.com"),
53
+ ("x-mr-studio-name", "Administrator"),
54
+ ],
55
+ )
56
+ def test_tampered_identity_fields_are_rejected(changed_header: str, changed_value: str) -> None:
57
+ headers = signed_headers()
58
+ headers[changed_header] = changed_value
59
+ with pytest.raises(ValueError, match="signature"):
60
+ verify_studio_identity(headers, SECRET, now=1_800_000_000)
61
+
62
+
63
+ def test_expired_identity_is_rejected() -> None:
64
+ with pytest.raises(ValueError, match="expired"):
65
+ verify_studio_identity(
66
+ signed_headers(timestamp=1_800_000_000), SECRET, now=1_800_000_301
67
+ )
68
+
69
+
70
+ def test_incomplete_envelope_and_short_secret_are_rejected() -> None:
71
+ with pytest.raises(ValueError, match="incomplete"):
72
+ verify_studio_identity({"x-mr-studio-subject": "user"}, SECRET, now=1_800_000_000)
73
+ with pytest.raises(ValueError, match="not configured"):
74
+ verify_studio_identity(signed_headers(), "too-short", now=1_800_000_000)
75
+
76
+
77
+ def test_shared_admin_key_cannot_escalate_viewer_membership() -> None:
78
+ context = AuthContext(
79
+ api_key_id="key",
80
+ key_name="Studio proxy",
81
+ key_prefix="mp_live_12345678",
82
+ environment="live",
83
+ role="admin",
84
+ scopes=frozenset({"admin", "projects:delete", "projects:read"}),
85
+ requests_per_minute=100,
86
+ concurrent_jobs=10,
87
+ uploads_per_hour=20,
88
+ processing_bytes_per_day=1000,
89
+ expires_at=None,
90
+ )
91
+ principal = TenantPrincipal(
92
+ workspace_id="workspace-a",
93
+ user_id="user-a",
94
+ membership_id="membership-a",
95
+ membership_role="member",
96
+ )
97
+ scoped = constrain_context_to_membership(context, principal)
98
+ assert scoped.workspace_id == "workspace-a"
99
+ assert scoped.user_id == "user-a"
100
+ assert scoped.membership_role == "member"
101
+ assert "admin" not in scoped.scopes
102
+ assert "projects:delete" not in scoped.scopes
103
+ assert "projects:read" in scoped.scopes
104
+
105
+
106
+ def test_unknown_membership_role_fails_closed() -> None:
107
+ context = AuthContext(
108
+ api_key_id="key",
109
+ key_name="Studio proxy",
110
+ key_prefix="mp_live_12345678",
111
+ environment="live",
112
+ role="admin",
113
+ scopes=frozenset({"admin"}),
114
+ requests_per_minute=100,
115
+ concurrent_jobs=10,
116
+ uploads_per_hour=20,
117
+ processing_bytes_per_day=1000,
118
+ expires_at=None,
119
+ )
120
+ principal = TenantPrincipal(
121
+ workspace_id="workspace-a",
122
+ user_id="user-a",
123
+ membership_id="membership-a",
124
+ membership_role="superuser",
125
+ )
126
+ with pytest.raises(ValueError, match="Unsupported"):
127
+ constrain_context_to_membership(context, principal)
128
+
129
+
130
+ async def _seed_subject(
131
+ database: SecurityDatabase, subject: str, workspace_count: int
132
+ ) -> tuple[str, list[str]]:
133
+ user_id = str(uuid4())
134
+ workspace_ids: list[str] = []
135
+ async with database.session() as session:
136
+ rows = [User(id=user_id, subject=subject, display_name="Test user")]
137
+ for index in range(workspace_count):
138
+ workspace_id = str(uuid4())
139
+ workspace_ids.append(workspace_id)
140
+ rows.append(
141
+ Workspace(
142
+ id=workspace_id,
143
+ slug=f"test-{index}-{uuid4().hex[:8]}",
144
+ name=f"Workspace {index}",
145
+ )
146
+ )
147
+ rows.append(
148
+ WorkspaceMembership(
149
+ id=str(uuid4()),
150
+ workspace_id=workspace_id,
151
+ user_id=user_id,
152
+ role="member",
153
+ status="active",
154
+ )
155
+ )
156
+ session.add_all(rows)
157
+ await session.commit()
158
+ return user_id, workspace_ids
159
+
160
+
161
+ async def _database(tmp_path, filename: str) -> SecurityDatabase:
162
+ database = SecurityDatabase(
163
+ f"sqlite+aiosqlite:///{tmp_path / filename}", auto_migrate=True
164
+ )
165
+ await database.initialize()
166
+ return database
167
+
168
+
169
+ async def test_studio_subject_resolves_one_active_membership(tmp_path) -> None:
170
+ database = await _database(tmp_path, "studio-tenant.db")
171
+ try:
172
+ user_id, workspace_ids = await _seed_subject(database, "supabase-subject-one", 1)
173
+ principal = await TenantService(database).resolve_studio_subject("supabase-subject-one")
174
+ assert principal.user_id == user_id
175
+ assert principal.workspace_id == workspace_ids[0]
176
+ assert principal.membership_id
177
+ assert principal.membership_role == "member"
178
+ finally:
179
+ await database.close()
180
+
181
+
182
+ async def test_studio_subject_fails_closed_for_ambiguous_memberships(tmp_path) -> None:
183
+ database = await _database(tmp_path, "studio-ambiguous.db")
184
+ try:
185
+ await _seed_subject(database, "supabase-subject-many", 2)
186
+ with pytest.raises(ForbiddenError):
187
+ await TenantService(database).resolve_studio_subject("supabase-subject-many")
188
+ finally:
189
+ await database.close()
190
+
191
+
192
+ async def test_unknown_studio_subject_is_not_auto_provisioned(tmp_path) -> None:
193
+ database = await _database(tmp_path, "studio-unknown.db")
194
+ try:
195
+ with pytest.raises(ForbiddenError):
196
+ await TenantService(database).resolve_studio_subject("not-provisioned")
197
+ finally:
198
+ await database.close()
199
+
200
+
201
+ @pytest.mark.parametrize("inactive_entity", ["user", "workspace", "membership"])
202
+ async def test_inactive_identity_or_membership_is_denied(tmp_path, inactive_entity: str) -> None:
203
+ database = await _database(tmp_path, f"inactive-{inactive_entity}.db")
204
+ try:
205
+ user_id, workspace_ids = await _seed_subject(
206
+ database, f"supabase-inactive-{inactive_entity}", 1
207
+ )
208
+ async with database.session() as session:
209
+ if inactive_entity == "user":
210
+ entity = await session.get(User, user_id)
211
+ elif inactive_entity == "workspace":
212
+ entity = await session.get(Workspace, workspace_ids[0])
213
+ else:
214
+ entity = await session.scalar(
215
+ select(WorkspaceMembership).where(
216
+ WorkspaceMembership.user_id == user_id,
217
+ WorkspaceMembership.workspace_id == workspace_ids[0],
218
+ )
219
+ )
220
+ assert entity is not None
221
+ entity.status = "disabled"
222
+ await session.commit()
223
+ with pytest.raises(ForbiddenError):
224
+ await TenantService(database).resolve_studio_subject(
225
+ f"supabase-inactive-{inactive_entity}"
226
+ )
227
+ finally:
228
+ await database.close()