10xgraph-api 0.7.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.
- 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
- 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
- 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
- 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
- 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
- 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
- agentflow_cli/__init__.py +69 -0
- tenxgraph_api/__init__.py +23 -0
- tenxgraph_api/cli/__init__.py +6 -0
- tenxgraph_api/cli/capabilities.py +127 -0
- tenxgraph_api/cli/commands/__init__.py +49 -0
- tenxgraph_api/cli/commands/api.py +262 -0
- tenxgraph_api/cli/commands/audit.py +213 -0
- tenxgraph_api/cli/commands/build.py +305 -0
- tenxgraph_api/cli/commands/config.py +71 -0
- tenxgraph_api/cli/commands/demo.py +93 -0
- tenxgraph_api/cli/commands/eval.py +909 -0
- tenxgraph_api/cli/commands/init.py +531 -0
- tenxgraph_api/cli/commands/skills.py +532 -0
- tenxgraph_api/cli/commands/test.py +113 -0
- tenxgraph_api/cli/commands/version.py +47 -0
- tenxgraph_api/cli/config_editor/__init__.py +15 -0
- tenxgraph_api/cli/config_editor/schema.py +566 -0
- tenxgraph_api/cli/config_editor/server.py +240 -0
- tenxgraph_api/cli/config_editor/static/app.css +2 -0
- tenxgraph_api/cli/config_editor/static/app.js +3 -0
- tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
- tenxgraph_api/cli/config_editor/static/index.html +20 -0
- tenxgraph_api/cli/config_editor/store.py +120 -0
- tenxgraph_api/cli/config_editor/validation.py +377 -0
- tenxgraph_api/cli/constants.py +112 -0
- tenxgraph_api/cli/context.py +46 -0
- tenxgraph_api/cli/core/__init__.py +0 -0
- tenxgraph_api/cli/core/animations.py +550 -0
- tenxgraph_api/cli/core/config.py +279 -0
- tenxgraph_api/cli/core/output.py +625 -0
- tenxgraph_api/cli/core/prompts.py +220 -0
- tenxgraph_api/cli/core/screen.py +287 -0
- tenxgraph_api/cli/core/steps.py +621 -0
- tenxgraph_api/cli/core/theme.py +242 -0
- tenxgraph_api/cli/core/validation.py +251 -0
- tenxgraph_api/cli/exceptions.py +129 -0
- tenxgraph_api/cli/logger.py +84 -0
- tenxgraph_api/cli/main.py +936 -0
- tenxgraph_api/cli/templates/__init__.py +0 -0
- tenxgraph_api/cli/templates/defaults.py +844 -0
- tenxgraph_api/cli/templates/dev/.env.example +1 -0
- tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
- tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
- tenxgraph_api/cli/templates/prod/.env.example +75 -0
- tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
- tenxgraph_api/cli/templates/prod/.python-version +1 -0
- tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
- tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
- tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
- tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
- tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
- tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
- tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
- tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
- tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
- tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
- tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
- tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
- tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
- tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
- tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
- tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
- tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
- tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
- tenxgraph_api/py.typed +0 -0
- tenxgraph_api/src/__init__.py +0 -0
- tenxgraph_api/src/app/__init__.py +13 -0
- tenxgraph_api/src/app/core/__init__.py +15 -0
- tenxgraph_api/src/app/core/auth/__init__.py +0 -0
- tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
- tenxgraph_api/src/app/core/auth/authorization.py +379 -0
- tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
- tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
- tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
- tenxgraph_api/src/app/core/auth/permissions.py +354 -0
- tenxgraph_api/src/app/core/auth/request_config.py +130 -0
- tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
- tenxgraph_api/src/app/core/config/__init__.py +0 -0
- tenxgraph_api/src/app/core/config/graph_config.py +532 -0
- tenxgraph_api/src/app/core/config/media_settings.py +72 -0
- tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
- tenxgraph_api/src/app/core/config/settings.py +202 -0
- tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
- tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
- tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
- tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
- tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
- tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
- tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
- tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
- tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
- tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
- tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
- tenxgraph_api/src/app/core/utils/__init__.py +14 -0
- tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
- tenxgraph_api/src/app/loader.py +516 -0
- tenxgraph_api/src/app/main.py +180 -0
- tenxgraph_api/src/app/routers/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
- tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
- tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
- tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
- tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
- tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
- tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
- tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
- tenxgraph_api/src/app/routers/evals/router.py +38 -0
- tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
- tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
- tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
- tenxgraph_api/src/app/routers/graph/router.py +821 -0
- tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
- tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
- tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
- tenxgraph_api/src/app/routers/media/__init__.py +506 -0
- tenxgraph_api/src/app/routers/media/router.py +242 -0
- tenxgraph_api/src/app/routers/media/schemas.py +39 -0
- tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ping/router.py +28 -0
- tenxgraph_api/src/app/routers/setup_router.py +60 -0
- tenxgraph_api/src/app/routers/store/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/router.py +200 -0
- tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
- tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
- tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
- tenxgraph_api/src/app/tasks/__init__.py +0 -0
- tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
- tenxgraph_api/src/app/utils/__init__.py +12 -0
- tenxgraph_api/src/app/utils/callable_helper.py +26 -0
- tenxgraph_api/src/app/utils/media/__init__.py +15 -0
- tenxgraph_api/src/app/utils/media/extractor.py +172 -0
- tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
- tenxgraph_api/src/app/utils/parse_output.py +17 -0
- tenxgraph_api/src/app/utils/response_helper.py +129 -0
- tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
- tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
- tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
- tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
- tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
- tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
- tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
- tenxgraph_api/src/app/worker.py +91 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from fastapi import Request, Response
|
|
4
|
+
from fastapi.security import HTTPAuthorizationCredentials
|
|
5
|
+
|
|
6
|
+
from tenxgraph_api.src.app.core import logger
|
|
7
|
+
from tenxgraph_api.src.app.core.auth.base_auth import BaseAuth
|
|
8
|
+
from tenxgraph_api.src.app.core.config.settings import Settings, get_settings
|
|
9
|
+
from tenxgraph_api.src.app.core.exceptions import UserAccountError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import jwt
|
|
14
|
+
except ImportError: # pragma: no cover
|
|
15
|
+
jwt = None # type: ignore[assignment]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# RFC 7518 3.2: an HMAC key must be at least as long as the hash output (256 bits for HS256).
|
|
19
|
+
MIN_HMAC_BYTES = 32
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_jwt_settings(settings: Settings) -> None:
|
|
23
|
+
"""Refuse a guessable HMAC secret at startup (production), warn about it otherwise.
|
|
24
|
+
|
|
25
|
+
A short HS* secret can be brute-forced offline from any one token, after which anyone can
|
|
26
|
+
mint a token for any ``user_id``.
|
|
27
|
+
"""
|
|
28
|
+
algorithm = (settings.JWT_ALGORITHM or "").upper()
|
|
29
|
+
secret = settings.JWT_SECRET_KEY or ""
|
|
30
|
+
if not algorithm.startswith("HS") or len(secret.encode()) >= MIN_HMAC_BYTES:
|
|
31
|
+
return
|
|
32
|
+
message = (
|
|
33
|
+
f"JWT_SECRET_KEY is shorter than {MIN_HMAC_BYTES} bytes, which is too weak for "
|
|
34
|
+
f"{algorithm}. Generate one with: "
|
|
35
|
+
"python -c 'import secrets; print(secrets.token_urlsafe(48))'"
|
|
36
|
+
)
|
|
37
|
+
if settings.MODE == "production":
|
|
38
|
+
raise ValueError(message)
|
|
39
|
+
logger.warning(message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _unauthorized(message: str, error_code: str) -> UserAccountError:
|
|
43
|
+
return UserAccountError(message=message, error_code=error_code, status_code=401)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class JwtAuth(BaseAuth):
|
|
47
|
+
def authenticate(
|
|
48
|
+
self,
|
|
49
|
+
request: Request,
|
|
50
|
+
response: Response,
|
|
51
|
+
credential: HTTPAuthorizationCredentials,
|
|
52
|
+
) -> dict[str, Any] | None:
|
|
53
|
+
"""No authentication is required, so return None."""
|
|
54
|
+
"""
|
|
55
|
+
Get the current user based on the provided HTTP
|
|
56
|
+
Authorization credentials.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
res (Response): The response object to set headers if needed.
|
|
60
|
+
credential (HTTPAuthorizationCredentials): The HTTP Authorization
|
|
61
|
+
credentials obtained from the request.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
UserSchema: A UserSchema object containing the decoded user information.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
HTTPException: If the credentials are missing.
|
|
68
|
+
UserAccountError: If there are token verification errors such as
|
|
69
|
+
RevokedIdTokenError,
|
|
70
|
+
UserDisabledError,
|
|
71
|
+
InvalidIdTokenError,
|
|
72
|
+
or any other unexpected exceptions.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
if credential is None:
|
|
76
|
+
raise _unauthorized("Invalid token, please login again", "REVOKED_TOKEN")
|
|
77
|
+
|
|
78
|
+
settings = get_settings()
|
|
79
|
+
jwt_secret_key = settings.JWT_SECRET_KEY
|
|
80
|
+
jwt_algorithm = settings.JWT_ALGORITHM
|
|
81
|
+
|
|
82
|
+
token = credential.credentials
|
|
83
|
+
|
|
84
|
+
if jwt is None:
|
|
85
|
+
raise ImportError(
|
|
86
|
+
"PyJWT is required for JWT authentication. "
|
|
87
|
+
'Install with `pip install "10xgraph-api[jwt]"`'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if jwt_secret_key is None or jwt_algorithm is None:
|
|
91
|
+
raise UserAccountError(
|
|
92
|
+
message="JWT settings are not configured",
|
|
93
|
+
error_code="JWT_SETTINGS_NOT_CONFIGURED",
|
|
94
|
+
status_code=500,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
required = ["exp"]
|
|
98
|
+
if settings.JWT_ISSUER:
|
|
99
|
+
required.append("iss")
|
|
100
|
+
if settings.JWT_AUDIENCE:
|
|
101
|
+
required.append("aud")
|
|
102
|
+
try:
|
|
103
|
+
decoded_token = jwt.decode(
|
|
104
|
+
token,
|
|
105
|
+
jwt_secret_key,
|
|
106
|
+
algorithms=[jwt_algorithm],
|
|
107
|
+
issuer=settings.JWT_ISSUER or None,
|
|
108
|
+
audience=settings.JWT_AUDIENCE or None,
|
|
109
|
+
options={"require": required},
|
|
110
|
+
)
|
|
111
|
+
except jwt.ExpiredSignatureError:
|
|
112
|
+
raise _unauthorized("Token has expired, please login again", "EXPIRED_TOKEN")
|
|
113
|
+
except jwt.InvalidTokenError as err:
|
|
114
|
+
logger.warning("JWT rejected: %s", type(err).__name__)
|
|
115
|
+
raise _unauthorized("Invalid token, please login again", "INVALID_TOKEN")
|
|
116
|
+
|
|
117
|
+
response.headers["WWW-Authenticate"] = 'Bearer realm="auth_required"'
|
|
118
|
+
|
|
119
|
+
# check if user_id exists in the token
|
|
120
|
+
if "user_id" not in decoded_token:
|
|
121
|
+
raise _unauthorized("Invalid token, user_id missing", "INVALID_TOKEN")
|
|
122
|
+
return decoded_token
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Scalable thread-ownership resolution with a two-tier cache.
|
|
2
|
+
|
|
3
|
+
Thread ownership is *immutable* -- a thread's owner is set when the thread is created and
|
|
4
|
+
never changes; only deletion removes it. That makes the owner safe to cache aggressively, so
|
|
5
|
+
after the first lookup an authorization check costs nothing (an in-process hit) instead of a
|
|
6
|
+
database round-trip per request.
|
|
7
|
+
|
|
8
|
+
Cache tiers:
|
|
9
|
+
|
|
10
|
+
- **L1**: a bounded in-process LRU (per worker) with a short TTL.
|
|
11
|
+
- **L2** (optional): a shared Redis, so workers/instances do not each re-hit the database.
|
|
12
|
+
|
|
13
|
+
Deleting a thread evicts it from this worker's L1 and from L2, but another worker's L1 cannot
|
|
14
|
+
be reached. A deleted id can be created again by a different user, so a stale L1 entry would
|
|
15
|
+
keep granting the old owner access on that worker. The L1 TTL bounds how long that lasts; the
|
|
16
|
+
L2 TTL covers an eviction that failed to reach Redis.
|
|
17
|
+
|
|
18
|
+
Only *positive* results (a known owner) are cached. A ``None`` result (the thread does not
|
|
19
|
+
exist yet) is **never** cached: a nonexistent thread can become owned by the very next
|
|
20
|
+
request, and a cached ``None`` would let a later caller be treated as the creator of a thread
|
|
21
|
+
someone else just made (a time-of-check/time-of-use hole).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import logging
|
|
28
|
+
import time
|
|
29
|
+
from collections import OrderedDict
|
|
30
|
+
from collections.abc import Awaitable, Callable
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("tenxgraph_api.authorization")
|
|
34
|
+
|
|
35
|
+
# Sentinel signalling the backing checkpointer cannot resolve ownership at all
|
|
36
|
+
# (``aget_thread_owner`` raised ``NotImplementedError``). Propagated so the caller can
|
|
37
|
+
# degrade gracefully instead of treating every thread as unowned.
|
|
38
|
+
OwnerLookup = Callable[[str], Awaitable["str | int | None"]]
|
|
39
|
+
|
|
40
|
+
DEFAULT_L1_TTL_SECONDS = 30.0
|
|
41
|
+
DEFAULT_L2_TTL_SECONDS = 3600
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ThreadOwnershipResolver:
|
|
45
|
+
"""Resolve (and cache) the owning ``user_id`` for a ``thread_id``.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
lookup: async ``(thread_id) -> user_id | None`` backing lookup (typically
|
|
49
|
+
``checkpointer.aget_thread_owner``). May raise ``NotImplementedError`` if the
|
|
50
|
+
checkpointer cannot resolve ownership; that propagates uncached.
|
|
51
|
+
redis: optional async Redis-like client (``get``/``set``/``delete`` coroutines).
|
|
52
|
+
When absent the resolver is L1-only. Redis errors degrade to the DB lookup and
|
|
53
|
+
never fail a request.
|
|
54
|
+
max_size: L1 LRU capacity (entries).
|
|
55
|
+
redis_prefix: key namespace for L2 entries.
|
|
56
|
+
l1_ttl: seconds an L1 entry is trusted; bounds staleness after a delete elsewhere.
|
|
57
|
+
l2_ttl: seconds an L2 entry lives in Redis.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
lookup: OwnerLookup,
|
|
63
|
+
*,
|
|
64
|
+
redis: object | None = None,
|
|
65
|
+
max_size: int = 10_000,
|
|
66
|
+
redis_prefix: str = "af:authz:owner",
|
|
67
|
+
l1_ttl: float = DEFAULT_L1_TTL_SECONDS,
|
|
68
|
+
l2_ttl: int = DEFAULT_L2_TTL_SECONDS,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._lookup = lookup
|
|
71
|
+
self._redis = redis
|
|
72
|
+
self._max_size = max(1, max_size)
|
|
73
|
+
self._prefix = redis_prefix
|
|
74
|
+
self._l1_ttl = l1_ttl
|
|
75
|
+
self._l2_ttl = l2_ttl
|
|
76
|
+
# key -> (owner, monotonic expiry)
|
|
77
|
+
self._l1: OrderedDict[str, tuple[str, float]] = OrderedDict()
|
|
78
|
+
self._lock = asyncio.Lock()
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------ public
|
|
81
|
+
|
|
82
|
+
async def owner_of(self, thread_id: str | int) -> str | None:
|
|
83
|
+
"""Return the owning ``user_id`` for ``thread_id``, or None if it has no owner.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
NotImplementedError: if the backing checkpointer cannot resolve ownership.
|
|
87
|
+
"""
|
|
88
|
+
key = str(thread_id)
|
|
89
|
+
|
|
90
|
+
cached = await self._l1_get(key)
|
|
91
|
+
if cached is not None:
|
|
92
|
+
return cached
|
|
93
|
+
|
|
94
|
+
from_redis = await self._redis_get(key)
|
|
95
|
+
if from_redis is not None:
|
|
96
|
+
await self._l1_put(key, from_redis)
|
|
97
|
+
return from_redis
|
|
98
|
+
|
|
99
|
+
owner = await self._lookup(key) # may raise NotImplementedError -> propagate
|
|
100
|
+
if owner is None:
|
|
101
|
+
return None # never cache negatives (TOCTOU on new threads)
|
|
102
|
+
|
|
103
|
+
owner_str = str(owner)
|
|
104
|
+
await self._l1_put(key, owner_str)
|
|
105
|
+
await self._redis_set(key, owner_str)
|
|
106
|
+
return owner_str
|
|
107
|
+
|
|
108
|
+
async def evict(self, thread_id: str | int) -> None:
|
|
109
|
+
"""Drop any cached owner for ``thread_id`` (call when a thread is deleted)."""
|
|
110
|
+
key = str(thread_id)
|
|
111
|
+
async with self._lock:
|
|
112
|
+
self._l1.pop(key, None)
|
|
113
|
+
await self._redis_delete(key)
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------ L1
|
|
116
|
+
|
|
117
|
+
async def _l1_get(self, key: str) -> str | None:
|
|
118
|
+
async with self._lock:
|
|
119
|
+
entry = self._l1.get(key)
|
|
120
|
+
if entry is None:
|
|
121
|
+
return None
|
|
122
|
+
owner, expires_at = entry
|
|
123
|
+
if time.monotonic() >= expires_at:
|
|
124
|
+
del self._l1[key]
|
|
125
|
+
return None
|
|
126
|
+
self._l1.move_to_end(key) # mark most-recently-used
|
|
127
|
+
return owner
|
|
128
|
+
|
|
129
|
+
async def _l1_put(self, key: str, owner: str) -> None:
|
|
130
|
+
async with self._lock:
|
|
131
|
+
self._l1[key] = (owner, time.monotonic() + self._l1_ttl)
|
|
132
|
+
self._l1.move_to_end(key)
|
|
133
|
+
while len(self._l1) > self._max_size:
|
|
134
|
+
self._l1.popitem(last=False) # evict least-recently-used
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------ L2 (Redis)
|
|
137
|
+
|
|
138
|
+
def _redis_key(self, key: str) -> str:
|
|
139
|
+
return f"{self._prefix}:{key}"
|
|
140
|
+
|
|
141
|
+
async def _redis_get(self, key: str) -> str | None:
|
|
142
|
+
if self._redis is None:
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
value = await self._redis.get(self._redis_key(key)) # type: ignore[attr-defined]
|
|
146
|
+
except Exception as exc: # degrade to DB; never fail the request
|
|
147
|
+
logger.debug("Ownership L2 get failed for %s: %s", key, exc)
|
|
148
|
+
return None
|
|
149
|
+
if value is None:
|
|
150
|
+
return None
|
|
151
|
+
return value.decode() if isinstance(value, bytes | bytearray) else str(value)
|
|
152
|
+
|
|
153
|
+
async def _redis_set(self, key: str, owner: str) -> None:
|
|
154
|
+
if self._redis is None:
|
|
155
|
+
return
|
|
156
|
+
try:
|
|
157
|
+
# evict() removes the entry on delete; the TTL covers an eviction that failed.
|
|
158
|
+
await self._redis.set( # type: ignore[attr-defined]
|
|
159
|
+
self._redis_key(key), owner, ex=self._l2_ttl
|
|
160
|
+
)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
logger.debug("Ownership L2 set failed for %s: %s", key, exc)
|
|
163
|
+
|
|
164
|
+
async def _redis_delete(self, key: str) -> None:
|
|
165
|
+
if self._redis is None:
|
|
166
|
+
return
|
|
167
|
+
try:
|
|
168
|
+
await self._redis.delete(self._redis_key(key)) # type: ignore[attr-defined]
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
logger.debug("Ownership L2 delete failed for %s: %s", key, exc)
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified authentication and authorization dependency for FastAPI endpoints.
|
|
3
|
+
|
|
4
|
+
This module provides a reusable dependency that combines authentication and
|
|
5
|
+
authorization checks, reducing code duplication across routers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import email.message
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any, NoReturn
|
|
11
|
+
|
|
12
|
+
from fastapi import HTTPException, Response, WebSocket, WebSocketException
|
|
13
|
+
from fastapi.security import HTTPAuthorizationCredentials
|
|
14
|
+
from injectq.integrations import InjectAPI
|
|
15
|
+
from starlette.requests import HTTPConnection
|
|
16
|
+
|
|
17
|
+
from tenxgraph_api.src.app.core import logger
|
|
18
|
+
from tenxgraph_api.src.app.core.auth.auth_backend import BaseAuth
|
|
19
|
+
from tenxgraph_api.src.app.core.auth.authorization import AuthorizationBackend
|
|
20
|
+
from tenxgraph_api.src.app.core.auth.request_config import normalize_thread_id
|
|
21
|
+
from tenxgraph_api.src.app.core.config.graph_config import GraphConfig
|
|
22
|
+
from tenxgraph_api.src.app.core.exceptions.general_exception import GeneralException
|
|
23
|
+
from tenxgraph_api.src.app.core.utils.log_sanitizer import sanitize_for_logging
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Sec-WebSocket-Protocol sentinel carrying the bearer token for browser WebSocket clients.
|
|
27
|
+
# The client offers two subprotocols: this sentinel followed by the raw JWT, e.g.
|
|
28
|
+
# new WebSocket(url, ["10xgraph-bearer", "<jwt>"])
|
|
29
|
+
# The token rides in a request header, so -- unlike ``?token=`` -- it never lands in URLs,
|
|
30
|
+
# access logs, or browser history. The server must echo the sentinel on accept() (see
|
|
31
|
+
# ``ws_bearer_subprotocol``) or browsers fail the handshake.
|
|
32
|
+
WS_BEARER_SUBPROTOCOL = "10xgraph-bearer"
|
|
33
|
+
# Pre-rename sentinel, still sent by released clients; accepted until 2.0.
|
|
34
|
+
LEGACY_WS_BEARER_SUBPROTOCOL = "agentflow-bearer"
|
|
35
|
+
_BEARER_SUBPROTOCOLS = (WS_BEARER_SUBPROTOCOL, LEGACY_WS_BEARER_SUBPROTOCOL)
|
|
36
|
+
|
|
37
|
+
# A bearer-carrying Sec-WebSocket-Protocol offer is exactly [sentinel, token].
|
|
38
|
+
_BEARER_SUBPROTOCOL_PARTS = 2
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _subprotocols(connection: HTTPConnection) -> list[str]:
|
|
42
|
+
header = connection.headers.get("sec-websocket-protocol")
|
|
43
|
+
if not header:
|
|
44
|
+
return []
|
|
45
|
+
return [p.strip() for p in header.split(",") if p.strip()]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ws_bearer_subprotocol(connection: HTTPConnection) -> str | None:
|
|
49
|
+
"""Return the subprotocol to echo on ``accept()`` when the client used it for the token.
|
|
50
|
+
|
|
51
|
+
Browsers require the server to confirm one of the offered subprotocols; when the bearer
|
|
52
|
+
sentinel was offered, accept with it. Returns ``None`` otherwise (plain ``accept()``).
|
|
53
|
+
"""
|
|
54
|
+
parts = _subprotocols(connection)
|
|
55
|
+
if parts and parts[0] in _BEARER_SUBPROTOCOLS:
|
|
56
|
+
return parts[0]
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _extract_credential(
|
|
61
|
+
connection: HTTPConnection,
|
|
62
|
+
) -> HTTPAuthorizationCredentials | None:
|
|
63
|
+
"""Extract bearer credentials from a request or WebSocket connection.
|
|
64
|
+
|
|
65
|
+
Mirrors ``HTTPBearer(auto_error=False)`` but works for both HTTP and WebSocket routes
|
|
66
|
+
(FastAPI cannot inject ``HTTPBearer`` on a WebSocket route). Token sources, most secure
|
|
67
|
+
first:
|
|
68
|
+
|
|
69
|
+
1. ``Authorization: Bearer <token>`` header -- for non-browser clients.
|
|
70
|
+
2. ``Sec-WebSocket-Protocol: 10xgraph-bearer, <token>`` (legacy: ``agentflow-bearer``) --
|
|
71
|
+
browser-settable and kept out of URLs/logs; the preferred browser mechanism.
|
|
72
|
+
3. ``?token=<token>`` query parameter -- last-resort fallback; the token is exposed in
|
|
73
|
+
URLs/access logs/history, so prefer (2) for browser clients.
|
|
74
|
+
"""
|
|
75
|
+
authorization = connection.headers.get("Authorization")
|
|
76
|
+
if authorization:
|
|
77
|
+
scheme, _, token = authorization.partition(" ")
|
|
78
|
+
if scheme.lower() == "bearer" and token:
|
|
79
|
+
return HTTPAuthorizationCredentials(scheme=scheme, credentials=token)
|
|
80
|
+
|
|
81
|
+
# [sentinel, token] -- the two subprotocols a browser client offers to carry the bearer.
|
|
82
|
+
parts = _subprotocols(connection)
|
|
83
|
+
if len(parts) >= _BEARER_SUBPROTOCOL_PARTS and parts[0] in _BEARER_SUBPROTOCOLS and parts[1]:
|
|
84
|
+
return HTTPAuthorizationCredentials(scheme="Bearer", credentials=parts[1])
|
|
85
|
+
|
|
86
|
+
ws_token = connection.query_params.get("token")
|
|
87
|
+
if ws_token:
|
|
88
|
+
is_ws = False
|
|
89
|
+
if hasattr(connection, "scope") and isinstance(connection.scope, dict):
|
|
90
|
+
is_ws = connection.scope.get("type") == "websocket"
|
|
91
|
+
if not is_ws:
|
|
92
|
+
from fastapi import WebSocket
|
|
93
|
+
|
|
94
|
+
is_ws = isinstance(connection, WebSocket)
|
|
95
|
+
if is_ws:
|
|
96
|
+
return HTTPAuthorizationCredentials(scheme="Bearer", credentials=ws_token)
|
|
97
|
+
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# RFC 6455 close code 1008 "Policy Violation" -- the right signal for an auth/authz
|
|
102
|
+
# rejection at the WebSocket handshake.
|
|
103
|
+
WS_POLICY_VIOLATION = 1008
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_json_content_type(content_type: str | None) -> bool:
|
|
107
|
+
"""Whether FastAPI parses a body with this content type as JSON.
|
|
108
|
+
|
|
109
|
+
Mirrors ``fastapi.routing``: a missing content type, ``application/json`` and any
|
|
110
|
+
``application/*+json`` are all parsed as JSON. Reading fewer types here would let a
|
|
111
|
+
request skip the thread check while the route still receives its body.
|
|
112
|
+
"""
|
|
113
|
+
if not content_type:
|
|
114
|
+
return True
|
|
115
|
+
message = email.message.Message()
|
|
116
|
+
message["content-type"] = content_type
|
|
117
|
+
if message.get_content_maintype() != "application":
|
|
118
|
+
return False
|
|
119
|
+
subtype = message.get_content_subtype()
|
|
120
|
+
return subtype == "json" or subtype.endswith("+json")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _reject(connection: HTTPConnection, status_code: int, detail: str) -> NoReturn:
|
|
124
|
+
"""Reject a connection with the error type appropriate to its protocol.
|
|
125
|
+
|
|
126
|
+
FastAPI translates an ``HTTPException`` into a response only on HTTP routes; on a
|
|
127
|
+
WebSocket route it propagates unhandled and tears the socket down abruptly (close
|
|
128
|
+
1006) with a server-side error log. Raise ``WebSocketException`` (close 1008) there
|
|
129
|
+
instead so the client sees a clean policy-violation rejection.
|
|
130
|
+
"""
|
|
131
|
+
if isinstance(connection, WebSocket):
|
|
132
|
+
raise WebSocketException(code=WS_POLICY_VIOLATION, reason=detail)
|
|
133
|
+
raise HTTPException(status_code=status_code, detail=detail)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class RequirePermission:
|
|
137
|
+
"""
|
|
138
|
+
FastAPI dependency that combines authentication and authorization.
|
|
139
|
+
|
|
140
|
+
This class-based dependency verifies user authentication and checks
|
|
141
|
+
authorization in a single step, reducing boilerplate code in endpoints.
|
|
142
|
+
|
|
143
|
+
Usage:
|
|
144
|
+
@router.post("/v1/resource")
|
|
145
|
+
async def endpoint(
|
|
146
|
+
user: dict = Depends(RequirePermission("resource", "action"))
|
|
147
|
+
):
|
|
148
|
+
# User is authenticated and authorized
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
resource: Resource type being accessed (e.g., "graph", "checkpointer", "store")
|
|
153
|
+
action: Action being performed (e.g., "invoke", "read", "write", "delete")
|
|
154
|
+
extract_resource_id: Optional function to extract resource_id from request
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(
|
|
158
|
+
self,
|
|
159
|
+
resource: str,
|
|
160
|
+
action: str,
|
|
161
|
+
extract_resource_id: Callable[[HTTPConnection], str | None] | None = None,
|
|
162
|
+
):
|
|
163
|
+
"""
|
|
164
|
+
Initialize the permission requirement.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
resource: Resource type (graph, checkpointer, store)
|
|
168
|
+
action: Action type (invoke, stream, read, write, delete, etc.)
|
|
169
|
+
extract_resource_id: Optional callable that extracts resource_id from request
|
|
170
|
+
"""
|
|
171
|
+
self.resource = resource
|
|
172
|
+
self.action = action
|
|
173
|
+
self.extract_resource_id_fn = extract_resource_id
|
|
174
|
+
|
|
175
|
+
async def __call__( # noqa: PLR0912
|
|
176
|
+
self,
|
|
177
|
+
connection: HTTPConnection,
|
|
178
|
+
response: Response,
|
|
179
|
+
config: GraphConfig = InjectAPI(GraphConfig),
|
|
180
|
+
auth_backend: BaseAuth = InjectAPI(BaseAuth),
|
|
181
|
+
authz: AuthorizationBackend = InjectAPI(AuthorizationBackend),
|
|
182
|
+
) -> dict[str, Any]:
|
|
183
|
+
"""
|
|
184
|
+
Verify authentication and authorization.
|
|
185
|
+
|
|
186
|
+
``connection`` is typed as ``HTTPConnection`` (the common base of ``Request``
|
|
187
|
+
and ``WebSocket``) so this dependency resolves on both HTTP and WebSocket
|
|
188
|
+
routes; FastAPI cannot inject a ``Request`` on a WebSocket route.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
dict: User information if authenticated and authorized
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
HTTPException: 401/403 on HTTP routes when auth or authz fails.
|
|
195
|
+
WebSocketException: close 1008 on WebSocket routes for the same failures
|
|
196
|
+
(FastAPI does not translate an HTTPException on a WS handshake).
|
|
197
|
+
"""
|
|
198
|
+
# Extract bearer credentials from the Authorization header, with a
|
|
199
|
+
# ``?token=`` query fallback for browser WebSocket clients (see
|
|
200
|
+
# _extract_credential). Works for both HTTP and WebSocket connections.
|
|
201
|
+
credential = _extract_credential(connection)
|
|
202
|
+
|
|
203
|
+
# Step 1: Check if auth is configured
|
|
204
|
+
backend = config.auth_config()
|
|
205
|
+
|
|
206
|
+
# If auth is not configured, skip authentication and authorization entirely
|
|
207
|
+
if not backend:
|
|
208
|
+
logger.debug(
|
|
209
|
+
f"Auth not configured, skipping auth/authz for {self.resource}:{self.action}"
|
|
210
|
+
)
|
|
211
|
+
return {}
|
|
212
|
+
|
|
213
|
+
# Step 2: Authentication (reusing verify_current_user logic)
|
|
214
|
+
user = {}
|
|
215
|
+
if not auth_backend:
|
|
216
|
+
logger.error("Auth backend is not configured")
|
|
217
|
+
user = {}
|
|
218
|
+
else:
|
|
219
|
+
try:
|
|
220
|
+
user_result = auth_backend.authenticate(
|
|
221
|
+
connection,
|
|
222
|
+
response,
|
|
223
|
+
credential,
|
|
224
|
+
)
|
|
225
|
+
except HTTPException as exc:
|
|
226
|
+
# JWT/custom backends signal auth failure with HTTPException; convert it
|
|
227
|
+
# to a clean WebSocket close on WS routes (see _reject).
|
|
228
|
+
_reject(connection, exc.status_code, str(exc.detail))
|
|
229
|
+
except GeneralException as exc:
|
|
230
|
+
# JwtAuth raises UserAccountError (401). HTTP routes render it through its
|
|
231
|
+
# exception handler; a WebSocket has no handler, so close it cleanly.
|
|
232
|
+
if isinstance(connection, WebSocket):
|
|
233
|
+
_reject(connection, exc.status_code, exc.message)
|
|
234
|
+
raise
|
|
235
|
+
if user_result and "user_id" not in user_result:
|
|
236
|
+
logger.error("Authentication failed: 'user_id' not found in user info")
|
|
237
|
+
user = user_result or {}
|
|
238
|
+
|
|
239
|
+
# Step 3: Extract resource_id if available
|
|
240
|
+
resource_id = None
|
|
241
|
+
if self.extract_resource_id_fn:
|
|
242
|
+
resource_id = self.extract_resource_id_fn(connection)
|
|
243
|
+
else:
|
|
244
|
+
resource_id = self._extract_resource_id_from_path(connection)
|
|
245
|
+
if resource_id is None:
|
|
246
|
+
resource_id = await self._extract_resource_id_from_body(connection)
|
|
247
|
+
|
|
248
|
+
# Step 4: Scope check -- does this identity carry the permission for this
|
|
249
|
+
# endpoint at all? The required scope is "<resource>:<action>". Scopes come from
|
|
250
|
+
# the authenticated identity (user["scopes"], populated by the auth backend / JWT
|
|
251
|
+
# claims). If the identity declares no scopes, we stay permissive (backward
|
|
252
|
+
# compatible) -- once scopes are issued, they are enforced.
|
|
253
|
+
required_scope = f"{self.resource}:{self.action}"
|
|
254
|
+
scopes_fn = getattr(authz, "scopes_for", None)
|
|
255
|
+
granted_scopes = scopes_fn(user) if callable(scopes_fn) else None
|
|
256
|
+
if granted_scopes is None and isinstance(user, dict):
|
|
257
|
+
granted_scopes = user.get("scopes") # fallback for backends without scopes_for
|
|
258
|
+
if granted_scopes is not None and required_scope not in granted_scopes:
|
|
259
|
+
logger.warning(f"Missing scope '{required_scope}' for user {user.get('user_id')}")
|
|
260
|
+
_reject(connection, 403, f"Missing required scope: {required_scope}")
|
|
261
|
+
|
|
262
|
+
# Step 5: Object-level authorization (ownership etc.)
|
|
263
|
+
if not await authz.authorize(
|
|
264
|
+
user,
|
|
265
|
+
self.resource,
|
|
266
|
+
self.action,
|
|
267
|
+
resource_id=resource_id,
|
|
268
|
+
):
|
|
269
|
+
logger.warning(
|
|
270
|
+
f"Authorization failed for user {user.get('user_id')} "
|
|
271
|
+
f"on {self.resource}:{self.action}"
|
|
272
|
+
)
|
|
273
|
+
_reject(
|
|
274
|
+
connection,
|
|
275
|
+
403,
|
|
276
|
+
f"Not authorized to {self.action} {self.resource}",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Step 6: Stamp the trusted authz block into the user object. Every service copies
|
|
280
|
+
# this user into config["user"], so the isolation policy + scopes reach every
|
|
281
|
+
# downstream call (checkpointer, store, graph execution). Overwrites anything the
|
|
282
|
+
# client sent -- not hijackable.
|
|
283
|
+
if isinstance(user, dict) and user.get("user_id"):
|
|
284
|
+
from tenxgraph.core.authz import build_authz
|
|
285
|
+
|
|
286
|
+
scope_fn = getattr(authz, "isolation_scope", None)
|
|
287
|
+
iso_scope = scope_fn() if callable(scope_fn) else "none"
|
|
288
|
+
user["authz"] = build_authz(
|
|
289
|
+
user["user_id"],
|
|
290
|
+
scope=iso_scope,
|
|
291
|
+
scopes=granted_scopes if granted_scopes is not None else [],
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Log successful auth/authz (with sanitized user info)
|
|
295
|
+
logger.debug(
|
|
296
|
+
f"Auth/Authz success for {self.resource}:{self.action}, "
|
|
297
|
+
f"user: {sanitize_for_logging(user)}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
return user
|
|
301
|
+
|
|
302
|
+
def _extract_resource_id_from_path(self, connection: HTTPConnection) -> str | None:
|
|
303
|
+
"""
|
|
304
|
+
Extract resource ID from request path parameters.
|
|
305
|
+
|
|
306
|
+
Looks for common patterns like thread_id, memory_id in path params.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
connection: FastAPI request or WebSocket connection
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Resource ID as string, or None if not found
|
|
313
|
+
"""
|
|
314
|
+
# Check path parameters
|
|
315
|
+
path_params = connection.path_params
|
|
316
|
+
|
|
317
|
+
# Common resource ID patterns
|
|
318
|
+
for param_name in ["thread_id", "memory_id", "namespace"]:
|
|
319
|
+
if param_name in path_params:
|
|
320
|
+
return str(path_params[param_name])
|
|
321
|
+
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
async def _extract_resource_id_from_body(self, connection: HTTPConnection) -> str | None:
|
|
325
|
+
"""Extract the thread ID a request body targets.
|
|
326
|
+
|
|
327
|
+
The body is read exactly when FastAPI would parse it as JSON, and the ID is normalised
|
|
328
|
+
the same way the services normalise it, so the thread checked here is the thread that
|
|
329
|
+
runs. Routes read either the root ``thread_id`` (stop, fix) or ``config.thread_id``
|
|
330
|
+
(invoke, stream); a body that names two different threads is rejected rather than
|
|
331
|
+
letting the check and the service pick different ones.
|
|
332
|
+
"""
|
|
333
|
+
from starlette.requests import Request
|
|
334
|
+
|
|
335
|
+
if not isinstance(connection, Request):
|
|
336
|
+
return None
|
|
337
|
+
if not _is_json_content_type(connection.headers.get("content-type")):
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
body = await connection.json()
|
|
342
|
+
except Exception as exc:
|
|
343
|
+
# FastAPI rejects a malformed body itself before the route runs.
|
|
344
|
+
logger.debug("Failed to parse request body for thread_id: %s", exc)
|
|
345
|
+
return None
|
|
346
|
+
if not isinstance(body, dict):
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
root_id = normalize_thread_id(body.get("thread_id"))
|
|
350
|
+
cfg = body.get("config")
|
|
351
|
+
config_id = normalize_thread_id(cfg.get("thread_id")) if isinstance(cfg, dict) else None
|
|
352
|
+
if root_id and config_id and root_id != config_id:
|
|
353
|
+
_reject(connection, 400, "thread_id and config.thread_id must match")
|
|
354
|
+
return root_id or config_id
|