Add optional Studio identity signing configuration

#4
by basyx - opened
app/security/middleware.py CHANGED
@@ -1,6 +1,10 @@
1
  from __future__ import annotations
2
 
 
 
 
3
  import time
 
4
  from typing import Any
5
  from uuid import uuid4
6
 
@@ -17,6 +21,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
 
@@ -54,8 +59,59 @@ class APIKeyAuthenticationMiddleware(BaseHTTPMiddleware):
54
  bytes_uploaded = self._content_length(request.headers.get("content-length"))
55
  bytes_downloaded = 0
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)
 
1
  from __future__ import annotations
2
 
3
+ import hashlib
4
+ import hmac
5
+ import re
6
  import time
7
+ from dataclasses import replace
8
  from typing import Any
9
  from uuid import uuid4
10
 
 
21
  from app.security.policy import ScopePolicy
22
  from app.security.rate_limit import APIKeyRateLimiter, RateLimitLease
23
  from app.security.service import APIKeyService
24
+ from app.security.scopes import effective_scopes
25
 
26
  logger = get_logger(__name__)
27
 
 
59
  bytes_uploaded = self._content_length(request.headers.get("content-length"))
60
  bytes_downloaded = 0
61
  try:
62
+ identity_header_names = (
63
+ "x-mr-studio-subject", "x-mr-studio-email", "x-mr-studio-name",
64
+ "x-mr-studio-timestamp", "x-mr-studio-signature",
65
+ )
66
+ identity_values = {name: request.headers.get(name) for name in identity_header_names}
67
+ has_identity_headers = any(value is not None for value in identity_values.values())
68
+ studio_identity: tuple[str, str, str] | None = None
69
+ if has_identity_headers:
70
+ secret = self.settings.studio_identity_secret.get_secret_value() if self.settings.studio_identity_secret else ""
71
+ if len(secret) < 32 or any(not value for value in identity_values.values()):
72
+ raise UnauthorizedError
73
+ subject = identity_values["x-mr-studio-subject"] or ""
74
+ email = identity_values["x-mr-studio-email"] or ""
75
+ name = identity_values["x-mr-studio-name"] or ""
76
+ timestamp = identity_values["x-mr-studio-timestamp"] or ""
77
+ supplied_signature = identity_values["x-mr-studio-signature"] or ""
78
+ if not re.fullmatch(r"[0-9a-fA-F-]{36}", subject):
79
+ raise UnauthorizedError
80
+ try:
81
+ issued_at = int(timestamp)
82
+ except ValueError as exc:
83
+ raise UnauthorizedError from exc
84
+ if abs(int(time.time()) - issued_at) > 60:
85
+ raise UnauthorizedError
86
+ expected_signature = hmac.new(
87
+ secret.encode("utf-8"),
88
+ "\n".join((timestamp, subject, email, name)).encode("utf-8"),
89
+ hashlib.sha256,
90
+ ).hexdigest()
91
+ if not hmac.compare_digest(expected_signature, supplied_signature):
92
+ raise UnauthorizedError
93
+ studio_identity = (subject, email, name)
94
+
95
  api_key = self._bearer_token(request.headers.get("authorization"))
96
  context = await self.api_keys.authenticate(api_key)
97
+ if studio_identity is not None:
98
+ subject, email, name = studio_identity
99
+ principal = await self.api_keys.tenants.resolve_studio_identity(
100
+ subject=subject, email=email, display_name=name
101
+ )
102
+ # The service credential authenticates only the trusted server hop.
103
+ # End-user scopes are derived from the persisted membership role, never browser input.
104
+ studio_role = "developer" if principal.membership_role in {"admin", "owner"} else "viewer"
105
+ context = replace(
106
+ context,
107
+ key_name=f"Studio user: {email or subject[:8]}",
108
+ role=studio_role,
109
+ scopes=effective_scopes(studio_role, (), self.settings.auth_role_scopes),
110
+ workspace_id=principal.workspace_id,
111
+ user_id=principal.user_id,
112
+ membership_id=principal.membership_id,
113
+ membership_role=principal.membership_role,
114
+ )
115
  request.state.auth = context
116
  context_token = auth_context.set(context)
117
  required_scope = await self.policy.required_scope(request)
app/security/tenancy.py CHANGED
@@ -100,6 +100,62 @@ class TenantService:
100
  membership_role=membership.role,
101
  )
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  async def bind_api_key(
104
  self, *, api_key_id: str, workspace_id: str, user_id: str
105
  ) -> TenantPrincipal:
 
100
  membership_role=membership.role,
101
  )
102
 
103
+ async def resolve_studio_identity(
104
+ self, *, subject: str, email: str, display_name: str
105
+ ) -> TenantPrincipal:
106
+ """Resolve a verified Supabase subject to its own backend workspace."""
107
+ if not subject or len(subject) > 255:
108
+ raise UnauthorizedError
109
+ external_subject = f"supabase:{subject}"
110
+ async with self.database.session() as session:
111
+ user = await session.scalar(select(User).where(User.subject == external_subject))
112
+ if user is not None:
113
+ if user.status != "active":
114
+ raise ForbiddenError
115
+ membership = await session.scalar(
116
+ select(WorkspaceMembership).where(
117
+ WorkspaceMembership.user_id == user.id,
118
+ WorkspaceMembership.status == "active",
119
+ ).order_by(WorkspaceMembership.created_at.asc())
120
+ )
121
+ if membership is None:
122
+ raise ForbiddenError
123
+ workspace = await session.get(Workspace, membership.workspace_id)
124
+ if workspace is None or workspace.status != "active":
125
+ raise ForbiddenError
126
+ return TenantPrincipal(workspace.id, user.id, membership.id, membership.role)
127
+
128
+ user_id, workspace_id, membership_id = str(uuid4()), str(uuid4()), str(uuid4())
129
+ safe_name = (display_name or email or "Studio user").strip()[:255] or "Studio user"
130
+ user = User(id=user_id, subject=external_subject, display_name=safe_name,
131
+ metadata_json={"identity_provider": "supabase"})
132
+ workspace = Workspace(id=workspace_id, slug=f"studio-{user_id.replace('-', '')}",
133
+ name=f"{safe_name}'s workspace"[:255],
134
+ metadata_json={"provisioned_from": "studio_auth"})
135
+ membership = WorkspaceMembership(id=membership_id, workspace_id=workspace_id,
136
+ user_id=user_id, role="admin", status="active")
137
+ session.add_all([user, workspace, membership])
138
+ try:
139
+ await session.commit()
140
+ except IntegrityError:
141
+ await session.rollback()
142
+ user = await session.scalar(select(User).where(User.subject == external_subject))
143
+ if user is None or user.status != "active":
144
+ raise
145
+ membership = await session.scalar(
146
+ select(WorkspaceMembership).where(
147
+ WorkspaceMembership.user_id == user.id,
148
+ WorkspaceMembership.status == "active",
149
+ ).order_by(WorkspaceMembership.created_at.asc())
150
+ )
151
+ if membership is None:
152
+ raise ForbiddenError
153
+ workspace = await session.get(Workspace, membership.workspace_id)
154
+ if workspace is None or workspace.status != "active":
155
+ raise ForbiddenError
156
+ return TenantPrincipal(workspace.id, user.id, membership.id, membership.role)
157
+ return TenantPrincipal(workspace_id, user_id, membership_id, "admin")
158
+
159
  async def bind_api_key(
160
  self, *, api_key_id: str, workspace_id: str, user_id: str
161
  ) -> TenantPrincipal: