Validate signed Studio identity and apply per-user tenant context

#6
by basyx - opened
Files changed (1) hide show
  1. app/security/middleware.py +55 -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,58 @@ 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 narrowed to the developer role.
104
+ context = replace(
105
+ context,
106
+ key_name=f"Studio user: {email or subject[:8]}",
107
+ role="developer",
108
+ scopes=effective_scopes("developer", (), self.settings.auth_role_scopes),
109
+ workspace_id=principal.workspace_id,
110
+ user_id=principal.user_id,
111
+ membership_id=principal.membership_id,
112
+ membership_role=principal.membership_role,
113
+ )
114
  request.state.auth = context
115
  context_token = auth_context.set(context)
116
  required_scope = await self.policy.required_scope(request)