Spaces:
Running
Running
Validate signed Studio identity and apply per-user tenant context
#6
by basyx - opened
- 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)
|