basyx commited on
Commit
f8d557d
·
1 Parent(s): ee2f2de

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 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:
tests/test_studio_identity.py ADDED
@@ -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()