mdb-engine 0.1.6__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. mdb_engine/__init__.py +104 -11
  2. mdb_engine/auth/ARCHITECTURE.md +112 -0
  3. mdb_engine/auth/README.md +648 -11
  4. mdb_engine/auth/__init__.py +136 -29
  5. mdb_engine/auth/audit.py +592 -0
  6. mdb_engine/auth/base.py +252 -0
  7. mdb_engine/auth/casbin_factory.py +264 -69
  8. mdb_engine/auth/config_helpers.py +7 -6
  9. mdb_engine/auth/cookie_utils.py +3 -7
  10. mdb_engine/auth/csrf.py +373 -0
  11. mdb_engine/auth/decorators.py +3 -10
  12. mdb_engine/auth/dependencies.py +47 -50
  13. mdb_engine/auth/helpers.py +3 -3
  14. mdb_engine/auth/integration.py +53 -80
  15. mdb_engine/auth/jwt.py +2 -6
  16. mdb_engine/auth/middleware.py +77 -34
  17. mdb_engine/auth/oso_factory.py +18 -38
  18. mdb_engine/auth/provider.py +270 -171
  19. mdb_engine/auth/rate_limiter.py +504 -0
  20. mdb_engine/auth/restrictions.py +8 -24
  21. mdb_engine/auth/session_manager.py +14 -29
  22. mdb_engine/auth/shared_middleware.py +600 -0
  23. mdb_engine/auth/shared_users.py +759 -0
  24. mdb_engine/auth/token_store.py +14 -28
  25. mdb_engine/auth/users.py +54 -113
  26. mdb_engine/auth/utils.py +213 -15
  27. mdb_engine/cli/commands/generate.py +545 -9
  28. mdb_engine/cli/commands/validate.py +3 -7
  29. mdb_engine/cli/utils.py +3 -3
  30. mdb_engine/config.py +7 -21
  31. mdb_engine/constants.py +65 -0
  32. mdb_engine/core/README.md +117 -6
  33. mdb_engine/core/__init__.py +39 -7
  34. mdb_engine/core/app_registration.py +22 -41
  35. mdb_engine/core/app_secrets.py +290 -0
  36. mdb_engine/core/connection.py +18 -9
  37. mdb_engine/core/encryption.py +223 -0
  38. mdb_engine/core/engine.py +1057 -93
  39. mdb_engine/core/index_management.py +12 -16
  40. mdb_engine/core/manifest.py +459 -150
  41. mdb_engine/core/ray_integration.py +435 -0
  42. mdb_engine/core/seeding.py +10 -18
  43. mdb_engine/core/service_initialization.py +12 -23
  44. mdb_engine/core/types.py +2 -5
  45. mdb_engine/database/README.md +140 -17
  46. mdb_engine/database/__init__.py +17 -6
  47. mdb_engine/database/abstraction.py +25 -37
  48. mdb_engine/database/connection.py +11 -18
  49. mdb_engine/database/query_validator.py +367 -0
  50. mdb_engine/database/resource_limiter.py +204 -0
  51. mdb_engine/database/scoped_wrapper.py +713 -196
  52. mdb_engine/dependencies.py +426 -0
  53. mdb_engine/di/__init__.py +34 -0
  54. mdb_engine/di/container.py +248 -0
  55. mdb_engine/di/providers.py +205 -0
  56. mdb_engine/di/scopes.py +139 -0
  57. mdb_engine/embeddings/README.md +54 -24
  58. mdb_engine/embeddings/__init__.py +31 -24
  59. mdb_engine/embeddings/dependencies.py +37 -154
  60. mdb_engine/embeddings/service.py +11 -25
  61. mdb_engine/exceptions.py +92 -0
  62. mdb_engine/indexes/README.md +30 -13
  63. mdb_engine/indexes/__init__.py +1 -0
  64. mdb_engine/indexes/helpers.py +1 -1
  65. mdb_engine/indexes/manager.py +50 -114
  66. mdb_engine/memory/README.md +2 -2
  67. mdb_engine/memory/__init__.py +1 -2
  68. mdb_engine/memory/service.py +30 -87
  69. mdb_engine/observability/README.md +4 -2
  70. mdb_engine/observability/__init__.py +26 -9
  71. mdb_engine/observability/health.py +8 -9
  72. mdb_engine/observability/metrics.py +32 -12
  73. mdb_engine/repositories/__init__.py +34 -0
  74. mdb_engine/repositories/base.py +325 -0
  75. mdb_engine/repositories/mongo.py +233 -0
  76. mdb_engine/repositories/unit_of_work.py +166 -0
  77. mdb_engine/routing/README.md +1 -1
  78. mdb_engine/routing/__init__.py +1 -3
  79. mdb_engine/routing/websockets.py +25 -60
  80. mdb_engine-0.2.0.dist-info/METADATA +313 -0
  81. mdb_engine-0.2.0.dist-info/RECORD +96 -0
  82. mdb_engine-0.1.6.dist-info/METADATA +0 -213
  83. mdb_engine-0.1.6.dist-info/RECORD +0 -75
  84. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/WHEEL +0 -0
  85. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/entry_points.txt +0 -0
  86. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/licenses/LICENSE +0 -0
  87. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/top_level.txt +0 -0
@@ -12,9 +12,12 @@ from typing import Any, Dict, Optional
12
12
 
13
13
  from fastapi import FastAPI
14
14
 
15
- from .config_defaults import (CORS_DEFAULTS, OBSERVABILITY_DEFAULTS,
16
- SECURITY_CONFIG_DEFAULTS,
17
- TOKEN_MANAGEMENT_DEFAULTS)
15
+ from .config_defaults import (
16
+ CORS_DEFAULTS,
17
+ OBSERVABILITY_DEFAULTS,
18
+ SECURITY_CONFIG_DEFAULTS,
19
+ TOKEN_MANAGEMENT_DEFAULTS,
20
+ )
18
21
  from .config_helpers import merge_config_with_defaults
19
22
  from .helpers import initialize_token_management
20
23
 
@@ -45,8 +48,7 @@ def _has_cors_middleware(app: FastAPI) -> bool:
45
48
  middleware_cls = middleware[0]
46
49
  # Check if it's CORSMiddleware or a subclass
47
50
  if middleware_cls == CORSMiddleware or (
48
- hasattr(middleware_cls, "__name__")
49
- and "CORS" in middleware_cls.__name__
51
+ hasattr(middleware_cls, "__name__") and "CORS" in middleware_cls.__name__
50
52
  ):
51
53
  return True
52
54
  return False
@@ -131,18 +133,12 @@ async def _setup_authorization_provider(
131
133
  try:
132
134
  from .casbin_factory import initialize_casbin_from_manifest
133
135
 
134
- authz_provider = await initialize_casbin_from_manifest(
135
- engine, slug_id, config
136
- )
136
+ authz_provider = await initialize_casbin_from_manifest(engine, slug_id, config)
137
137
  if authz_provider:
138
138
  app.state.authz_provider = authz_provider
139
- logger.info(
140
- f"Authorization provider (Casbin) auto-created for {slug_id}"
141
- )
139
+ logger.info(f"Authorization provider (Casbin) auto-created for {slug_id}")
142
140
  else:
143
- logger.debug(
144
- f"Casbin provider not created for {slug_id} (may not be installed)"
145
- )
141
+ logger.debug(f"Casbin provider not created for {slug_id} (may not be installed)")
146
142
  except (
147
143
  ImportError,
148
144
  AttributeError,
@@ -160,9 +156,7 @@ async def _setup_authorization_provider(
160
156
  authz_provider = await initialize_oso_from_manifest(engine, slug_id, config)
161
157
  if authz_provider:
162
158
  app.state.authz_provider = authz_provider
163
- logger.info(
164
- f"✅ Authorization provider (OSO Cloud) auto-created for {slug_id}"
165
- )
159
+ logger.info(f"✅ Authorization provider (OSO Cloud) auto-created for {slug_id}")
166
160
  else:
167
161
  logger.error(
168
162
  f"❌ OSO Cloud provider not created for {slug_id}. "
@@ -187,9 +181,7 @@ async def _setup_authorization_provider(
187
181
  logger.info(f"Custom provider specified for {slug_id} - manual setup required")
188
182
 
189
183
 
190
- async def _setup_demo_users(
191
- app: FastAPI, engine, slug_id: str, config: Dict[str, Any]
192
- ) -> list:
184
+ async def _setup_demo_users(app: FastAPI, engine, slug_id: str, config: Dict[str, Any]) -> list:
193
185
  """Set up demo users and link with OSO roles if applicable."""
194
186
  auth = config.get("auth", {})
195
187
  users_config = auth.get("users", {})
@@ -260,9 +252,7 @@ async def _setup_demo_users(
260
252
  f"Demo user creation disabled for {slug_id} (demo_user_seed_strategy: disabled)"
261
253
  )
262
254
  elif seed_strategy == "manual":
263
- logger.debug(
264
- f"Demo user creation set to manual for {slug_id} - skipping auto-creation"
265
- )
255
+ logger.debug(f"Demo user creation set to manual for {slug_id} - skipping auto-creation")
266
256
 
267
257
  # Link demo users with OSO initial_roles if auth provider is OSO
268
258
  if hasattr(app.state, "authz_provider") and demo_users:
@@ -283,14 +273,30 @@ async def _setup_demo_users(
283
273
  if role_assignment.get("user") == user_email:
284
274
  role = role_assignment.get("role")
285
275
  resource = role_assignment.get("resource", "documents")
276
+
277
+ # Check if provider is Casbin (uses email as subject for initial_roles)
278
+ is_casbin = hasattr(app.state.authz_provider, "_enforcer")
279
+
286
280
  try:
287
- await app.state.authz_provider.add_role_for_user(
288
- user_email, role, resource
289
- )
290
- logger.info(
291
- f"✅ Assigned role '{role}' on resource '{resource}' "
292
- f"to demo user '{user_email}' for {slug_id}"
293
- )
281
+ if is_casbin:
282
+ # For Casbin, use email as subject to match initial_roles format
283
+ # This ensures consistency with how initial_roles are set up
284
+ await app.state.authz_provider.add_role_for_user(
285
+ user_email, role
286
+ )
287
+ logger.info(
288
+ f"✅ Assigned Casbin role '{role}' "
289
+ f"to demo user '{user_email}' for {slug_id}"
290
+ )
291
+ else:
292
+ # For OSO, use email, role, resource
293
+ await app.state.authz_provider.add_role_for_user(
294
+ user_email, role, resource
295
+ )
296
+ logger.info(
297
+ f"✅ Assigned role '{role}' on resource '{resource}' "
298
+ f"to demo user '{user_email}' for {slug_id}"
299
+ )
294
300
  except (
295
301
  ValueError,
296
302
  TypeError,
@@ -324,15 +330,13 @@ async def _setup_token_management(
324
330
  """Initialize token management (blacklist and session manager)."""
325
331
  if token_management.get("auto_setup", True):
326
332
  try:
327
- db = engine.get_database()
333
+ db = engine.get_scoped_db(slug_id)
328
334
  await initialize_token_management(app, db)
329
335
 
330
336
  # Configure session fingerprinting if session manager exists
331
337
  session_mgr = getattr(app.state, "session_manager", None)
332
338
  if session_mgr:
333
- fingerprinting_config = app.state.security_config.get(
334
- "session_fingerprinting", {}
335
- )
339
+ fingerprinting_config = app.state.security_config.get("session_fingerprinting", {})
336
340
  session_mgr.configure_fingerprinting(
337
341
  enabled=fingerprinting_config.get("enabled", True),
338
342
  strict=fingerprinting_config.get("strict_mode", False),
@@ -355,9 +359,7 @@ async def _setup_security_middleware(
355
359
  app: FastAPI, slug_id: str, security_config: Dict[str, Any]
356
360
  ) -> None:
357
361
  """Set up security middleware (if not already added)."""
358
- if security_config.get("csrf_protection", True) or security_config.get(
359
- "require_https", False
360
- ):
362
+ if security_config.get("csrf_protection", True) or security_config.get("require_https", False):
361
363
  try:
362
364
  from .middleware import SecurityMiddleware
363
365
 
@@ -384,9 +386,7 @@ async def _setup_security_middleware(
384
386
  f"This is normal when using lifespan context managers."
385
387
  )
386
388
  else:
387
- logger.warning(
388
- f"Could not set up security middleware for {slug_id}: {e}"
389
- )
389
+ logger.warning(f"Could not set up security middleware for {slug_id}: {e}")
390
390
  except (AttributeError, TypeError, ValueError, RuntimeError, ImportError) as e:
391
391
  logger.warning(f"Could not set up security middleware for {slug_id}: {e}")
392
392
 
@@ -409,9 +409,7 @@ async def _setup_cors_and_observability(
409
409
  app.state.cors_config = merge_config_with_defaults(cors_config, CORS_DEFAULTS)
410
410
 
411
411
  # Extract and store observability config
412
- observability_config = (
413
- manifest_data.get("observability", {}) if manifest_data else {}
414
- )
412
+ observability_config = manifest_data.get("observability", {}) if manifest_data else {}
415
413
  app.state.observability_config = merge_config_with_defaults(
416
414
  observability_config, OBSERVABILITY_DEFAULTS
417
415
  )
@@ -421,9 +419,7 @@ async def _setup_cors_and_observability(
421
419
  try:
422
420
  # Check if CORS middleware already exists to avoid duplication
423
421
  if _has_cors_middleware(app):
424
- logger.debug(
425
- f"CORS middleware already exists for {slug_id}, skipping addition"
426
- )
422
+ logger.debug(f"CORS middleware already exists for {slug_id}, skipping addition")
427
423
  else:
428
424
  from fastapi.middleware.cors import CORSMiddleware
429
425
 
@@ -431,44 +427,29 @@ async def _setup_cors_and_observability(
431
427
  try:
432
428
  app.add_middleware(
433
429
  CORSMiddleware,
434
- allow_origins=app.state.cors_config.get(
435
- "allow_origins", ["*"]
436
- ),
437
- allow_credentials=app.state.cors_config.get(
438
- "allow_credentials", False
439
- ),
430
+ allow_origins=app.state.cors_config.get("allow_origins", ["*"]),
431
+ allow_credentials=app.state.cors_config.get("allow_credentials", False),
440
432
  allow_methods=app.state.cors_config.get(
441
433
  "allow_methods",
442
434
  ["GET", "POST", "PUT", "DELETE", "PATCH"],
443
435
  ),
444
- allow_headers=app.state.cors_config.get(
445
- "allow_headers", ["*"]
446
- ),
447
- expose_headers=app.state.cors_config.get(
448
- "expose_headers", []
449
- ),
436
+ allow_headers=app.state.cors_config.get("allow_headers", ["*"]),
437
+ expose_headers=app.state.cors_config.get("expose_headers", []),
450
438
  max_age=app.state.cors_config.get("max_age", 3600),
451
439
  )
452
440
  logger.info(f"CORS middleware added for {slug_id}")
453
441
  except (RuntimeError, ValueError) as e:
454
442
  error_msg = str(e).lower()
455
- if (
456
- "cannot add middleware" in error_msg
457
- or "middleware" in error_msg
458
- ):
443
+ if "cannot add middleware" in error_msg or "middleware" in error_msg:
459
444
  logger.debug(
460
445
  f"CORS middleware not added for {slug_id} - "
461
446
  f"app middleware stack already initialized. "
462
447
  f"This is normal when using lifespan context managers."
463
448
  )
464
449
  else:
465
- logger.warning(
466
- f"Could not set up CORS middleware for {slug_id}: {e}"
467
- )
450
+ logger.warning(f"Could not set up CORS middleware for {slug_id}: {e}")
468
451
  else:
469
- logger.warning(
470
- f"CORS middleware not added for {slug_id} - app already started"
471
- )
452
+ logger.warning(f"CORS middleware not added for {slug_id} - app already started")
472
453
  except (AttributeError, TypeError, ValueError, RuntimeError, ImportError) as e:
473
454
  logger.warning(f"Could not set up CORS middleware for {slug_id}: {e}")
474
455
 
@@ -480,9 +461,7 @@ async def _setup_cors_and_observability(
480
461
  from .middleware import StaleSessionMiddleware
481
462
 
482
463
  try:
483
- app.add_middleware(
484
- StaleSessionMiddleware, slug_id=slug_id, engine=engine
485
- )
464
+ app.add_middleware(StaleSessionMiddleware, slug_id=slug_id, engine=engine)
486
465
  logger.info(f"Stale session cleanup middleware added for {slug_id}")
487
466
  except (RuntimeError, ValueError) as e:
488
467
  error_msg = str(e).lower()
@@ -493,13 +472,9 @@ async def _setup_cors_and_observability(
493
472
  f"This is normal when using lifespan context managers."
494
473
  )
495
474
  else:
496
- logger.warning(
497
- f"Could not set up stale session middleware for {slug_id}: {e}"
498
- )
475
+ logger.warning(f"Could not set up stale session middleware for {slug_id}: {e}")
499
476
  except (AttributeError, TypeError, ValueError, RuntimeError, ImportError) as e:
500
- logger.warning(
501
- f"Could not set up stale session middleware for {slug_id}: {e}"
502
- )
477
+ logger.warning(f"Could not set up stale session middleware for {slug_id}: {e}")
503
478
 
504
479
 
505
480
  async def setup_auth_from_manifest(app: FastAPI, engine, slug_id: str) -> bool:
@@ -572,7 +547,5 @@ async def setup_auth_from_manifest(app: FastAPI, engine, slug_id: str) -> bool:
572
547
  KeyError,
573
548
  ConnectionError,
574
549
  ) as e:
575
- logger.error(
576
- f"Error setting up auth from manifest for {slug_id}: {e}", exc_info=True
577
- )
550
+ logger.error(f"Error setting up auth from manifest for {slug_id}: {e}", exc_info=True)
578
551
  return False
mdb_engine/auth/jwt.py CHANGED
@@ -164,9 +164,7 @@ def generate_token_pair(
164
164
  "jti": access_jti,
165
165
  "device_id": device_id,
166
166
  }
167
- access_token = encode_jwt_token(
168
- access_payload, secret_key, expires_in=access_token_ttl
169
- )
167
+ access_token = encode_jwt_token(access_payload, secret_key, expires_in=access_token_ttl)
170
168
 
171
169
  # Generate refresh token
172
170
  refresh_jti = str(uuid.uuid4())
@@ -177,9 +175,7 @@ def generate_token_pair(
177
175
  "email": user_data.get("email"),
178
176
  "device_id": device_id,
179
177
  }
180
- refresh_token = encode_jwt_token(
181
- refresh_payload, secret_key, expires_in=refresh_token_ttl
182
- )
178
+ refresh_token = encode_jwt_token(refresh_payload, secret_key, expires_in=refresh_token_ttl)
183
179
 
184
180
  # Token metadata
185
181
  token_metadata = {
@@ -4,12 +4,18 @@ Security Middleware
4
4
  Middleware for enforcing security settings from manifest configuration.
5
5
 
6
6
  This module is part of MDB_ENGINE - MongoDB Engine.
7
+
8
+ Security Features:
9
+ - HTTPS enforcement in production
10
+ - HSTS (HTTP Strict Transport Security) header
11
+ - Security headers (X-Content-Type-Options, X-Frame-Options, etc.)
12
+ - CSRF token generation (legacy, prefer CSRFMiddleware for new apps)
7
13
  """
8
14
 
9
15
  import logging
10
16
  import os
11
17
  import secrets
12
- from typing import Awaitable, Callable
18
+ from typing import Any, Awaitable, Callable, Dict, Optional
13
19
 
14
20
  from fastapi import HTTPException, Request, Response, status
15
21
  from fastapi.responses import RedirectResponse
@@ -17,6 +23,18 @@ from starlette.middleware.base import BaseHTTPMiddleware
17
23
 
18
24
  logger = logging.getLogger(__name__)
19
25
 
26
+ # Default HSTS settings
27
+ DEFAULT_HSTS_MAX_AGE = 31536000 # 1 year in seconds
28
+
29
+
30
+ def _is_production() -> bool:
31
+ """Check if we're running in production environment."""
32
+ return (
33
+ os.getenv("MDB_ENGINE_ENV", "").lower() == "production"
34
+ or os.getenv("ENVIRONMENT", "").lower() == "production"
35
+ or os.getenv("G_NOME_ENV", "").lower() == "production"
36
+ )
37
+
20
38
 
21
39
  class SecurityMiddleware(BaseHTTPMiddleware):
22
40
  """
@@ -24,9 +42,9 @@ class SecurityMiddleware(BaseHTTPMiddleware):
24
42
 
25
43
  Features:
26
44
  - HTTPS enforcement in production
27
- - CSRF token generation and validation
28
- - Security headers
29
- - Token validation
45
+ - HSTS header for forcing HTTPS
46
+ - Security headers (X-Content-Type-Options, X-Frame-Options, etc.)
47
+ - Legacy CSRF token generation (prefer CSRFMiddleware for new apps)
30
48
  """
31
49
 
32
50
  def __init__(
@@ -35,6 +53,7 @@ class SecurityMiddleware(BaseHTTPMiddleware):
35
53
  require_https: bool = False,
36
54
  csrf_protection: bool = True,
37
55
  security_headers: bool = True,
56
+ hsts_config: Optional[Dict[str, Any]] = None,
38
57
  ):
39
58
  """
40
59
  Initialize security middleware.
@@ -42,38 +61,60 @@ class SecurityMiddleware(BaseHTTPMiddleware):
42
61
  Args:
43
62
  app: FastAPI application
44
63
  require_https: Require HTTPS in production (default: False, auto-detected)
45
- csrf_protection: Enable CSRF protection (default: True)
64
+ csrf_protection: Enable legacy CSRF protection (default: True)
46
65
  security_headers: Add security headers (default: True)
66
+ hsts_config: HSTS configuration dict with keys:
67
+ - enabled: Enable HSTS (default: True in production)
68
+ - max_age: Max-age in seconds (default: 31536000)
69
+ - include_subdomains: Include subdomains (default: True)
70
+ - preload: Add preload directive (default: False)
47
71
  """
48
72
  super().__init__(app)
49
73
  self.require_https = require_https
50
74
  self.csrf_protection = csrf_protection
51
75
  self.security_headers = security_headers
52
76
 
77
+ # HSTS configuration
78
+ self.hsts_config = hsts_config or {}
79
+ self.hsts_enabled = self.hsts_config.get("enabled", True)
80
+ self.hsts_max_age = self.hsts_config.get("max_age", DEFAULT_HSTS_MAX_AGE)
81
+ self.hsts_include_subdomains = self.hsts_config.get("include_subdomains", True)
82
+ self.hsts_preload = self.hsts_config.get("preload", False)
83
+
84
+ def _build_hsts_header(self) -> str:
85
+ """Build the HSTS header value."""
86
+ parts = [f"max-age={self.hsts_max_age}"]
87
+
88
+ if self.hsts_include_subdomains:
89
+ parts.append("includeSubDomains")
90
+
91
+ if self.hsts_preload:
92
+ parts.append("preload")
93
+
94
+ return "; ".join(parts)
95
+
53
96
  async def dispatch(
54
97
  self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
55
98
  ) -> Response:
56
99
  """
57
100
  Process request through security middleware.
58
101
  """
102
+ is_production = _is_production()
103
+ is_https = request.url.scheme == "https"
104
+
59
105
  # Check HTTPS requirement
60
- if self.require_https:
61
- is_production = (
62
- os.getenv("G_NOME_ENV") == "production"
63
- or os.getenv("ENVIRONMENT") == "production"
64
- )
65
- if is_production and request.url.scheme != "https":
66
- if request.method == "GET":
67
- # Redirect to HTTPS
68
- https_url = str(request.url).replace("http://", "https://", 1)
69
- return RedirectResponse(url=https_url, status_code=301)
70
- else:
71
- raise HTTPException(
72
- status_code=status.HTTP_403_FORBIDDEN,
73
- detail="HTTPS required in production",
74
- )
75
-
76
- # Generate CSRF token if not present (for GET requests)
106
+ if self.require_https and is_production and not is_https:
107
+ if request.method == "GET":
108
+ # Redirect to HTTPS
109
+ https_url = str(request.url).replace("http://", "https://", 1)
110
+ return RedirectResponse(url=https_url, status_code=301)
111
+ else:
112
+ raise HTTPException(
113
+ status_code=status.HTTP_403_FORBIDDEN,
114
+ detail="HTTPS required in production",
115
+ )
116
+
117
+ # Generate CSRF token if not present (for GET requests) - legacy support
77
118
  if self.csrf_protection and request.method == "GET":
78
119
  csrf_token = request.cookies.get("csrf_token")
79
120
  if not csrf_token:
@@ -90,19 +131,27 @@ class SecurityMiddleware(BaseHTTPMiddleware):
90
131
  response.headers["X-XSS-Protection"] = "1; mode=block"
91
132
  response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
92
133
 
134
+ # Permissions-Policy (modern replacement for some legacy headers)
135
+ response.headers["Permissions-Policy"] = (
136
+ "accelerometer=(), camera=(), geolocation=(), gyroscope=(), "
137
+ "magnetometer=(), microphone=(), payment=(), usb=()"
138
+ )
139
+
93
140
  # Content Security Policy (basic)
94
141
  if request.url.path.startswith("/api"):
95
142
  response.headers["Content-Security-Policy"] = "default-src 'self'"
96
143
 
97
- # Set CSRF token cookie if generated
144
+ # Add HSTS header in production (only over HTTPS or always if configured)
145
+ if self.hsts_enabled and (is_production or is_https):
146
+ response.headers["Strict-Transport-Security"] = self._build_hsts_header()
147
+
148
+ # Set CSRF token cookie if generated (legacy support)
98
149
  if (
99
150
  self.csrf_protection
100
151
  and request.method == "GET"
101
152
  and not request.cookies.get("csrf_token")
102
153
  ):
103
154
  csrf_token = secrets.token_urlsafe(32)
104
- is_https = request.url.scheme == "https"
105
- is_production = os.getenv("G_NOME_ENV") == "production"
106
155
  response.set_cookie(
107
156
  key="csrf_token",
108
157
  value=csrf_token,
@@ -154,10 +203,7 @@ class StaleSessionMiddleware(BaseHTTPMiddleware):
154
203
  # Check if we need to clear a stale session cookie
155
204
  # Only act if explicitly flagged - this ensures we don't interfere with
156
205
  # apps that don't use get_app_user()
157
- if (
158
- hasattr(request.state, "clear_stale_session")
159
- and request.state.clear_stale_session
160
- ):
206
+ if hasattr(request.state, "clear_stale_session") and request.state.clear_stale_session:
161
207
  try:
162
208
  # Get cookie name from app config
163
209
  cookie_name = None
@@ -197,8 +243,7 @@ class StaleSessionMiddleware(BaseHTTPMiddleware):
197
243
 
198
244
  # Get cookie settings to match how it was set
199
245
  should_use_secure = (
200
- request.url.scheme == "https"
201
- or os.getenv("G_NOME_ENV") == "production"
246
+ request.url.scheme == "https" or os.getenv("G_NOME_ENV") == "production"
202
247
  )
203
248
 
204
249
  # Delete the stale cookie
@@ -208,9 +253,7 @@ class StaleSessionMiddleware(BaseHTTPMiddleware):
208
253
  secure=should_use_secure,
209
254
  samesite="lax",
210
255
  )
211
- logger.debug(
212
- f"Cleared stale session cookie '{cookie_name}' for {self.slug_id}"
213
- )
256
+ logger.debug(f"Cleared stale session cookie '{cookie_name}' for {self.slug_id}")
214
257
  except (ValueError, TypeError, AttributeError, RuntimeError) as e:
215
258
  # Don't fail the request if cookie cleanup fails
216
259
  logger.warning(
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import logging
13
13
  import os
14
- from typing import TYPE_CHECKING, Any, Dict, List, Optional
14
+ from typing import TYPE_CHECKING, Any, Optional
15
15
 
16
16
  if TYPE_CHECKING:
17
17
  from .provider import OsoAdapter
@@ -46,13 +46,11 @@ async def create_oso_cloud_client(
46
46
 
47
47
  # Import OSO Cloud SDK - the class is named "Oso"
48
48
  try:
49
- from oso_cloud import Oso
49
+ from oso_cloud import Oso # type: ignore
50
50
 
51
51
  logger.debug("✅ Imported Oso from oso_cloud")
52
- except ImportError:
53
- raise ImportError(
54
- "OSO Cloud SDK not installed. Install with: pip install oso-cloud"
55
- )
52
+ except ImportError as e:
53
+ raise ImportError("OSO Cloud SDK not installed. Install with: pip install oso-cloud") from e
56
54
 
57
55
  # Get API key from parameter or environment
58
56
  if not api_key:
@@ -81,9 +79,7 @@ async def create_oso_cloud_client(
81
79
 
82
80
  # Note: OSO client creation doesn't actually connect to the server
83
81
  # The connection happens on first API call, so we'll catch errors then
84
- logger.info(
85
- f"✅ OSO Cloud client created successfully (URL: {url or 'default'})"
86
- )
82
+ logger.info(f"✅ OSO Cloud client created successfully (URL: {url or 'default'})")
87
83
  if url:
88
84
  logger.info(f" Using OSO Dev Server at: {url}")
89
85
  return oso_client
@@ -118,8 +114,8 @@ async def create_oso_cloud_client(
118
114
 
119
115
  async def setup_initial_oso_facts(
120
116
  authz_provider: OsoAdapter,
121
- initial_roles: Optional[List[Dict[str, Any]]] = None,
122
- initial_policies: Optional[List[Dict[str, Any]]] = None,
117
+ initial_roles: Optional[list[dict[str, Any]]] = None,
118
+ initial_policies: Optional[list[dict[str, Any]]] = None,
123
119
  ) -> None:
124
120
  """
125
121
  Set up initial roles and policies in OSO Cloud.
@@ -137,28 +133,22 @@ async def setup_initial_oso_facts(
137
133
  try:
138
134
  user = role_assignment.get("user")
139
135
  role = role_assignment.get("role")
140
- resource = role_assignment.get(
141
- "resource", "documents"
142
- ) # Default to "documents"
136
+ resource = role_assignment.get("resource", "documents") # Default to "documents"
143
137
 
144
138
  if user and role:
145
139
  # For OSO Cloud, we add has_role facts with resource context
146
140
  # This supports resource-based authorization
147
141
  await authz_provider.add_role_for_user(user, role, resource)
148
- logger.debug(
149
- f"Added role '{role}' for user '{user}' on resource '{resource}'"
150
- )
142
+ logger.debug(f"Added role '{role}' for user '{user}' on resource '{resource}'")
151
143
  except (ValueError, TypeError, AttributeError, RuntimeError) as e:
152
- logger.warning(
153
- f"Failed to add initial role assignment {role_assignment}: {e}"
154
- )
144
+ logger.warning(f"Failed to add initial role assignment {role_assignment}: {e}")
155
145
 
156
146
  # Note: initial_policies are not used - we use has_role facts instead
157
147
  # The policy derives permissions from roles, not from explicit grants_permission facts
158
148
 
159
149
 
160
150
  async def initialize_oso_from_manifest(
161
- engine, app_slug: str, auth_config: Dict[str, Any]
151
+ engine, app_slug: str, auth_config: dict[str, Any]
162
152
  ) -> Optional[OsoAdapter]:
163
153
  """
164
154
  Initialize OSO Cloud provider from manifest configuration.
@@ -181,9 +171,7 @@ async def initialize_oso_from_manifest(
181
171
 
182
172
  # Only proceed if provider is oso
183
173
  if provider != "oso":
184
- logger.debug(
185
- f"Provider is '{provider}', not 'oso' - skipping OSO initialization"
186
- )
174
+ logger.debug(f"Provider is '{provider}', not 'oso' - skipping OSO initialization")
187
175
  return None
188
176
 
189
177
  logger.info(f"Initializing OSO Cloud provider for app '{app_slug}'...")
@@ -224,24 +212,18 @@ async def initialize_oso_from_manifest(
224
212
  try:
225
213
  import asyncio
226
214
 
227
- from oso_cloud import Value
215
+ from oso_cloud import Value # type: ignore
228
216
 
229
217
  # Try a simple test authorization to verify connection
230
218
  test_actor = Value("User", "test")
231
219
  test_resource = Value("Document", "test")
232
220
  # This might fail, but it tests if the server is responding
233
- await asyncio.to_thread(
234
- oso_client.authorize, test_actor, "read", test_resource
235
- )
221
+ await asyncio.to_thread(oso_client.authorize, test_actor, "read", test_resource)
236
222
  logger.debug("✅ OSO Dev Server connection test successful")
237
223
  except (TimeoutError, OSError, RuntimeError) as test_error:
238
224
  # Type 2: Recoverable - connection test failed, check if it's a connection error
239
225
  error_str = str(test_error).lower()
240
- if (
241
- "connection" in error_str
242
- or "refused" in error_str
243
- or "timeout" in error_str
244
- ):
226
+ if "connection" in error_str or "refused" in error_str or "timeout" in error_str:
245
227
  logger.warning(
246
228
  f"⚠️ OSO Dev Server connection test failed - "
247
229
  f"server might not be ready: {test_error}"
@@ -289,9 +271,7 @@ async def initialize_oso_from_manifest(
289
271
  )
290
272
  logger.info("✅ Initial OSO facts set up successfully")
291
273
  except (ValueError, TypeError, AttributeError, RuntimeError) as e:
292
- logger.warning(
293
- f"⚠️ Failed to set up initial OSO facts: {e}", exc_info=True
294
- )
274
+ logger.warning(f"⚠️ Failed to set up initial OSO facts: {e}", exc_info=True)
295
275
  # Continue anyway - adapter is still usable
296
276
 
297
277
  logger.info(f"✅ OSO Cloud provider initialized for app '{app_slug}'")
@@ -299,13 +279,13 @@ async def initialize_oso_from_manifest(
299
279
  return adapter
300
280
 
301
281
  except ImportError as e:
302
- logger.error(
282
+ logger.exception(
303
283
  f"❌ OSO Cloud SDK not available for app '{app_slug}': {e}. "
304
284
  "Install with: pip install oso-cloud"
305
285
  )
306
286
  return None
307
287
  except ValueError as e:
308
- logger.error(f"❌ OSO Cloud configuration error for app '{app_slug}': {e}")
288
+ logger.exception(f"❌ OSO Cloud configuration error for app '{app_slug}': {e}")
309
289
  return None
310
290
  except (
311
291
  ImportError,