Spaces:
Running
Running
Add optional Studio identity signing configuration
#4
by basyx - opened
- app/security/middleware.py +56 -0
- app/security/tenancy.py +56 -0
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:
|