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.
- mdb_engine/__init__.py +104 -11
- mdb_engine/auth/ARCHITECTURE.md +112 -0
- mdb_engine/auth/README.md +648 -11
- mdb_engine/auth/__init__.py +136 -29
- mdb_engine/auth/audit.py +592 -0
- mdb_engine/auth/base.py +252 -0
- mdb_engine/auth/casbin_factory.py +264 -69
- mdb_engine/auth/config_helpers.py +7 -6
- mdb_engine/auth/cookie_utils.py +3 -7
- mdb_engine/auth/csrf.py +373 -0
- mdb_engine/auth/decorators.py +3 -10
- mdb_engine/auth/dependencies.py +47 -50
- mdb_engine/auth/helpers.py +3 -3
- mdb_engine/auth/integration.py +53 -80
- mdb_engine/auth/jwt.py +2 -6
- mdb_engine/auth/middleware.py +77 -34
- mdb_engine/auth/oso_factory.py +18 -38
- mdb_engine/auth/provider.py +270 -171
- mdb_engine/auth/rate_limiter.py +504 -0
- mdb_engine/auth/restrictions.py +8 -24
- mdb_engine/auth/session_manager.py +14 -29
- mdb_engine/auth/shared_middleware.py +600 -0
- mdb_engine/auth/shared_users.py +759 -0
- mdb_engine/auth/token_store.py +14 -28
- mdb_engine/auth/users.py +54 -113
- mdb_engine/auth/utils.py +213 -15
- mdb_engine/cli/commands/generate.py +545 -9
- mdb_engine/cli/commands/validate.py +3 -7
- mdb_engine/cli/utils.py +3 -3
- mdb_engine/config.py +7 -21
- mdb_engine/constants.py +65 -0
- mdb_engine/core/README.md +117 -6
- mdb_engine/core/__init__.py +39 -7
- mdb_engine/core/app_registration.py +22 -41
- mdb_engine/core/app_secrets.py +290 -0
- mdb_engine/core/connection.py +18 -9
- mdb_engine/core/encryption.py +223 -0
- mdb_engine/core/engine.py +1057 -93
- mdb_engine/core/index_management.py +12 -16
- mdb_engine/core/manifest.py +459 -150
- mdb_engine/core/ray_integration.py +435 -0
- mdb_engine/core/seeding.py +10 -18
- mdb_engine/core/service_initialization.py +12 -23
- mdb_engine/core/types.py +2 -5
- mdb_engine/database/README.md +140 -17
- mdb_engine/database/__init__.py +17 -6
- mdb_engine/database/abstraction.py +25 -37
- mdb_engine/database/connection.py +11 -18
- mdb_engine/database/query_validator.py +367 -0
- mdb_engine/database/resource_limiter.py +204 -0
- mdb_engine/database/scoped_wrapper.py +713 -196
- mdb_engine/dependencies.py +426 -0
- mdb_engine/di/__init__.py +34 -0
- mdb_engine/di/container.py +248 -0
- mdb_engine/di/providers.py +205 -0
- mdb_engine/di/scopes.py +139 -0
- mdb_engine/embeddings/README.md +54 -24
- mdb_engine/embeddings/__init__.py +31 -24
- mdb_engine/embeddings/dependencies.py +37 -154
- mdb_engine/embeddings/service.py +11 -25
- mdb_engine/exceptions.py +92 -0
- mdb_engine/indexes/README.md +30 -13
- mdb_engine/indexes/__init__.py +1 -0
- mdb_engine/indexes/helpers.py +1 -1
- mdb_engine/indexes/manager.py +50 -114
- mdb_engine/memory/README.md +2 -2
- mdb_engine/memory/__init__.py +1 -2
- mdb_engine/memory/service.py +30 -87
- mdb_engine/observability/README.md +4 -2
- mdb_engine/observability/__init__.py +26 -9
- mdb_engine/observability/health.py +8 -9
- mdb_engine/observability/metrics.py +32 -12
- mdb_engine/repositories/__init__.py +34 -0
- mdb_engine/repositories/base.py +325 -0
- mdb_engine/repositories/mongo.py +233 -0
- mdb_engine/repositories/unit_of_work.py +166 -0
- mdb_engine/routing/README.md +1 -1
- mdb_engine/routing/__init__.py +1 -3
- mdb_engine/routing/websockets.py +25 -60
- mdb_engine-0.2.0.dist-info/METADATA +313 -0
- mdb_engine-0.2.0.dist-info/RECORD +96 -0
- mdb_engine-0.1.6.dist-info/METADATA +0 -213
- mdb_engine-0.1.6.dist-info/RECORD +0 -75
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/WHEEL +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/top_level.txt +0 -0
mdb_engine/auth/integration.py
CHANGED
|
@@ -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 (
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
mdb_engine/auth/middleware.py
CHANGED
|
@@ -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
|
-
-
|
|
28
|
-
- Security headers
|
|
29
|
-
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
#
|
|
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(
|
mdb_engine/auth/oso_factory.py
CHANGED
|
@@ -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,
|
|
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[
|
|
122
|
-
initial_policies: Optional[
|
|
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:
|
|
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.
|
|
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.
|
|
288
|
+
logger.exception(f"❌ OSO Cloud configuration error for app '{app_slug}': {e}")
|
|
309
289
|
return None
|
|
310
290
|
except (
|
|
311
291
|
ImportError,
|