Spaces:
Running
Running
Add verified Studio identity authorization
#7
by basyx - opened
- app/projects/api.py +12 -9
- app/projects/repositories/approval_repository.py +27 -6
- app/projects/repositories/collaboration_repository.py +30 -5
- app/projects/services/approval_service.py +7 -7
- app/projects/services/collaboration_service.py +19 -4
- app/security/cli.py +67 -0
- app/security/middleware.py +27 -0
- app/security/studio_identity.py +90 -0
- app/security/tenancy.py +32 -0
- docs/STUDIO_IDENTITY_SETUP.md +55 -0
- tests/test_approval_workspace_scope.py +38 -0
- tests/test_project_collaboration_authorization.py +47 -17
- tests/test_studio_identity.py +228 -0
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,
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
|
| 5 |
-
|
| 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 |
-
|
| 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()
|