Spaces:
Running
Add verified Studio identity authorization (#7)
Browse files- Add verified Studio identity authorization (8ce9df18095dfcf741801d791643e4562c0d67fb)
- Resolve Studio subjects through active memberships (64d5cff23a23dd9810ec3bac00b9dcbd6fb2d567)
- Add Studio identity HMAC verifier (00c3273205d40650fd696fe6a5342fa54f6ddce9)
- Test Studio identity signature validation (5bceb55f5a456c6e086cfe83995f398dace91118)
- Enforce role-limited Studio authorization (c8a03912905d61e4b068453fd932bf3d3bc5fa9d)
- Use membership-constrained Studio context (3d2941fbc1c0c3b2b394e7477cbd015b0c934092)
- Test Studio identity and privilege boundaries (85cfd93573829c0a4d9ed909cc769d509dd59e4b)
- Test Studio tenant membership resolution (b97b02f73a33c92e46427a2244d5206811c871c6)
- Organize Studio identity regression test imports (a760c151a2641ff55f12be447ffc77d915a3e823)
- Match Studio HMAC canonicalization (a4fde0f17460628ababf1a5dc3d2f092d690acaa)
- Align identity tests with Studio signature format (e446305c6a659b2909b63ec65959ae8cffde354f)
- app/security/middleware.py +27 -0
- app/security/studio_identity.py +90 -0
- app/security/tenancy.py +32 -0
- tests/test_studio_identity.py +167 -0
|
@@ -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)
|
|
@@ -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 |
+
)
|
|
@@ -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:
|
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import hmac
|
| 5 |
+
from uuid import uuid4
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from app.security.context import AuthContext
|
| 10 |
+
from app.security.database import SecurityDatabase
|
| 11 |
+
from app.security.errors import ForbiddenError
|
| 12 |
+
from app.security.models import User, Workspace, WorkspaceMembership
|
| 13 |
+
from app.security.studio_identity import constrain_context_to_membership, verify_studio_identity
|
| 14 |
+
from app.security.tenancy import TenantPrincipal, TenantService
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
SECRET = "a" * 64
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def signed_headers(*, timestamp: int = 1_800_000_000, subject: str = "supabase-user-1",
|
| 21 |
+
email: str = "user@example.com", name: str = "Studio User") -> dict[str, str]:
|
| 22 |
+
signature = hmac.new(
|
| 23 |
+
SECRET.encode(),
|
| 24 |
+
"\n".join((str(timestamp), subject, email, name)).encode(),
|
| 25 |
+
hashlib.sha256,
|
| 26 |
+
).hexdigest()
|
| 27 |
+
return {
|
| 28 |
+
"x-mr-studio-subject": subject,
|
| 29 |
+
"x-mr-studio-email": email,
|
| 30 |
+
"x-mr-studio-name": name,
|
| 31 |
+
"x-mr-studio-timestamp": str(timestamp),
|
| 32 |
+
"x-mr-studio-signature": signature,
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_valid_studio_identity_signature_is_accepted() -> None:
|
| 37 |
+
identity = verify_studio_identity(signed_headers(), SECRET, now=1_800_000_000)
|
| 38 |
+
assert identity.subject == "supabase-user-1"
|
| 39 |
+
assert identity.email == "user@example.com"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@pytest.mark.parametrize("changed_header,changed_value", [
|
| 43 |
+
("x-mr-studio-subject", "another-user"),
|
| 44 |
+
("x-mr-studio-email", "attacker@example.com"),
|
| 45 |
+
("x-mr-studio-name", "Administrator"),
|
| 46 |
+
])
|
| 47 |
+
def test_tampered_identity_fields_are_rejected(changed_header: str, changed_value: str) -> None:
|
| 48 |
+
headers = signed_headers()
|
| 49 |
+
headers[changed_header] = changed_value
|
| 50 |
+
with pytest.raises(ValueError, match="signature"):
|
| 51 |
+
verify_studio_identity(headers, SECRET, now=1_800_000_000)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_expired_identity_is_rejected() -> None:
|
| 55 |
+
with pytest.raises(ValueError, match="expired"):
|
| 56 |
+
verify_studio_identity(signed_headers(timestamp=1_800_000_000), SECRET, now=1_800_000_301)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_incomplete_envelope_and_short_secret_are_rejected() -> None:
|
| 60 |
+
with pytest.raises(ValueError, match="incomplete"):
|
| 61 |
+
verify_studio_identity({"x-mr-studio-subject": "user"}, SECRET, now=1_800_000_000)
|
| 62 |
+
with pytest.raises(ValueError, match="not configured"):
|
| 63 |
+
verify_studio_identity(signed_headers(), "too-short", now=1_800_000_000)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_shared_admin_key_cannot_escalate_viewer_membership() -> None:
|
| 67 |
+
context = AuthContext(
|
| 68 |
+
api_key_id="key", key_name="Studio proxy", key_prefix="mp_live_12345678",
|
| 69 |
+
environment="live", role="admin", scopes=frozenset({"admin", "projects:delete", "projects:read"}),
|
| 70 |
+
requests_per_minute=100, concurrent_jobs=10, uploads_per_hour=20,
|
| 71 |
+
processing_bytes_per_day=1000, expires_at=None,
|
| 72 |
+
)
|
| 73 |
+
principal = TenantPrincipal(
|
| 74 |
+
workspace_id="workspace-a", user_id="user-a", membership_id="membership-a",
|
| 75 |
+
membership_role="member",
|
| 76 |
+
)
|
| 77 |
+
scoped = constrain_context_to_membership(context, principal)
|
| 78 |
+
assert scoped.workspace_id == "workspace-a"
|
| 79 |
+
assert scoped.user_id == "user-a"
|
| 80 |
+
assert "admin" not in scoped.scopes
|
| 81 |
+
assert "projects:delete" not in scoped.scopes
|
| 82 |
+
assert "projects:read" in scoped.scopes
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_unknown_membership_role_fails_closed() -> None:
|
| 86 |
+
context = AuthContext(
|
| 87 |
+
api_key_id="key", key_name="Studio proxy", key_prefix="mp_live_12345678",
|
| 88 |
+
environment="live", role="admin", scopes=frozenset({"admin"}),
|
| 89 |
+
requests_per_minute=100, concurrent_jobs=10, uploads_per_hour=20,
|
| 90 |
+
processing_bytes_per_day=1000, expires_at=None,
|
| 91 |
+
)
|
| 92 |
+
principal = TenantPrincipal(
|
| 93 |
+
workspace_id="workspace-a", user_id="user-a", membership_id="membership-a",
|
| 94 |
+
membership_role="superuser",
|
| 95 |
+
)
|
| 96 |
+
with pytest.raises(ValueError, match="Unsupported"):
|
| 97 |
+
constrain_context_to_membership(context, principal)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
async def _seed_subject(database: SecurityDatabase, subject: str, workspace_count: int) -> str:
|
| 102 |
+
user_id = str(uuid4())
|
| 103 |
+
async with database.session() as session:
|
| 104 |
+
user = User(id=user_id, subject=subject, display_name="Test user")
|
| 105 |
+
rows = [user]
|
| 106 |
+
for index in range(workspace_count):
|
| 107 |
+
workspace_id = str(uuid4())
|
| 108 |
+
rows.append(
|
| 109 |
+
Workspace(
|
| 110 |
+
id=workspace_id,
|
| 111 |
+
slug=f"test-{index}-{uuid4().hex[:8]}",
|
| 112 |
+
name=f"Workspace {index}",
|
| 113 |
+
)
|
| 114 |
+
)
|
| 115 |
+
rows.append(
|
| 116 |
+
WorkspaceMembership(
|
| 117 |
+
id=str(uuid4()),
|
| 118 |
+
workspace_id=workspace_id,
|
| 119 |
+
user_id=user_id,
|
| 120 |
+
role="member",
|
| 121 |
+
status="active",
|
| 122 |
+
)
|
| 123 |
+
)
|
| 124 |
+
session.add_all(rows)
|
| 125 |
+
await session.commit()
|
| 126 |
+
return user_id
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
async def test_studio_subject_resolves_one_active_membership(tmp_path) -> None:
|
| 130 |
+
database = SecurityDatabase(
|
| 131 |
+
f"sqlite+aiosqlite:///{tmp_path / 'studio-tenant.db'}", auto_migrate=True
|
| 132 |
+
)
|
| 133 |
+
await database.initialize()
|
| 134 |
+
try:
|
| 135 |
+
user_id = await _seed_subject(database, "supabase-subject-one", 1)
|
| 136 |
+
principal = await TenantService(database).resolve_studio_subject("supabase-subject-one")
|
| 137 |
+
assert principal.user_id == user_id
|
| 138 |
+
assert principal.workspace_id
|
| 139 |
+
assert principal.membership_id
|
| 140 |
+
assert principal.membership_role == "member"
|
| 141 |
+
finally:
|
| 142 |
+
await database.close()
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
async def test_studio_subject_fails_closed_for_ambiguous_memberships(tmp_path) -> None:
|
| 146 |
+
database = SecurityDatabase(
|
| 147 |
+
f"sqlite+aiosqlite:///{tmp_path / 'studio-ambiguous.db'}", auto_migrate=True
|
| 148 |
+
)
|
| 149 |
+
await database.initialize()
|
| 150 |
+
try:
|
| 151 |
+
await _seed_subject(database, "supabase-subject-many", 2)
|
| 152 |
+
with pytest.raises(ForbiddenError):
|
| 153 |
+
await TenantService(database).resolve_studio_subject("supabase-subject-many")
|
| 154 |
+
finally:
|
| 155 |
+
await database.close()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
async def test_unknown_studio_subject_is_not_auto_provisioned(tmp_path) -> None:
|
| 159 |
+
database = SecurityDatabase(
|
| 160 |
+
f"sqlite+aiosqlite:///{tmp_path / 'studio-unknown.db'}", auto_migrate=True
|
| 161 |
+
)
|
| 162 |
+
await database.initialize()
|
| 163 |
+
try:
|
| 164 |
+
with pytest.raises(ForbiddenError):
|
| 165 |
+
await TenantService(database).resolve_studio_subject("not-provisioned")
|
| 166 |
+
finally:
|
| 167 |
+
await database.close()
|