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.
Files changed (203) hide show
  1. 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
  2. 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
  3. 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
  4. 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
  5. 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
  6. 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
  7. agentflow_cli/__init__.py +69 -0
  8. tenxgraph_api/__init__.py +23 -0
  9. tenxgraph_api/cli/__init__.py +6 -0
  10. tenxgraph_api/cli/capabilities.py +127 -0
  11. tenxgraph_api/cli/commands/__init__.py +49 -0
  12. tenxgraph_api/cli/commands/api.py +262 -0
  13. tenxgraph_api/cli/commands/audit.py +213 -0
  14. tenxgraph_api/cli/commands/build.py +305 -0
  15. tenxgraph_api/cli/commands/config.py +71 -0
  16. tenxgraph_api/cli/commands/demo.py +93 -0
  17. tenxgraph_api/cli/commands/eval.py +909 -0
  18. tenxgraph_api/cli/commands/init.py +531 -0
  19. tenxgraph_api/cli/commands/skills.py +532 -0
  20. tenxgraph_api/cli/commands/test.py +113 -0
  21. tenxgraph_api/cli/commands/version.py +47 -0
  22. tenxgraph_api/cli/config_editor/__init__.py +15 -0
  23. tenxgraph_api/cli/config_editor/schema.py +566 -0
  24. tenxgraph_api/cli/config_editor/server.py +240 -0
  25. tenxgraph_api/cli/config_editor/static/app.css +2 -0
  26. tenxgraph_api/cli/config_editor/static/app.js +3 -0
  27. tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
  28. tenxgraph_api/cli/config_editor/static/index.html +20 -0
  29. tenxgraph_api/cli/config_editor/store.py +120 -0
  30. tenxgraph_api/cli/config_editor/validation.py +377 -0
  31. tenxgraph_api/cli/constants.py +112 -0
  32. tenxgraph_api/cli/context.py +46 -0
  33. tenxgraph_api/cli/core/__init__.py +0 -0
  34. tenxgraph_api/cli/core/animations.py +550 -0
  35. tenxgraph_api/cli/core/config.py +279 -0
  36. tenxgraph_api/cli/core/output.py +625 -0
  37. tenxgraph_api/cli/core/prompts.py +220 -0
  38. tenxgraph_api/cli/core/screen.py +287 -0
  39. tenxgraph_api/cli/core/steps.py +621 -0
  40. tenxgraph_api/cli/core/theme.py +242 -0
  41. tenxgraph_api/cli/core/validation.py +251 -0
  42. tenxgraph_api/cli/exceptions.py +129 -0
  43. tenxgraph_api/cli/logger.py +84 -0
  44. tenxgraph_api/cli/main.py +936 -0
  45. tenxgraph_api/cli/templates/__init__.py +0 -0
  46. tenxgraph_api/cli/templates/defaults.py +844 -0
  47. tenxgraph_api/cli/templates/dev/.env.example +1 -0
  48. tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
  49. tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
  50. tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
  51. tenxgraph_api/cli/templates/prod/.env.example +75 -0
  52. tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
  53. tenxgraph_api/cli/templates/prod/.python-version +1 -0
  54. tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
  55. tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
  56. tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
  57. tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
  58. tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
  59. tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
  60. tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
  61. tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
  62. tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
  63. tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
  64. tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
  65. tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
  66. tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
  67. tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
  68. tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
  69. tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
  70. tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
  71. tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
  72. tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
  73. tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
  74. tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
  75. tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
  76. tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
  77. tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
  78. tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
  79. tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
  80. tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
  81. tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
  82. tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
  83. tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
  84. tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
  85. tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
  86. tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
  87. tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
  88. tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
  89. tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
  90. tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
  91. tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
  92. tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
  93. tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
  94. tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
  95. tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
  96. tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
  97. tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
  98. tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
  99. tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
  100. tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
  101. tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
  102. tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
  103. tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
  104. tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
  105. tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
  106. tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
  107. tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
  108. tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
  109. tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
  110. tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
  111. tenxgraph_api/py.typed +0 -0
  112. tenxgraph_api/src/__init__.py +0 -0
  113. tenxgraph_api/src/app/__init__.py +13 -0
  114. tenxgraph_api/src/app/core/__init__.py +15 -0
  115. tenxgraph_api/src/app/core/auth/__init__.py +0 -0
  116. tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
  117. tenxgraph_api/src/app/core/auth/authorization.py +379 -0
  118. tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
  119. tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
  120. tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
  121. tenxgraph_api/src/app/core/auth/permissions.py +354 -0
  122. tenxgraph_api/src/app/core/auth/request_config.py +130 -0
  123. tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
  124. tenxgraph_api/src/app/core/config/__init__.py +0 -0
  125. tenxgraph_api/src/app/core/config/graph_config.py +532 -0
  126. tenxgraph_api/src/app/core/config/media_settings.py +72 -0
  127. tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
  128. tenxgraph_api/src/app/core/config/settings.py +202 -0
  129. tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
  130. tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
  131. tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
  132. tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
  133. tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
  134. tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
  135. tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
  136. tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
  137. tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
  138. tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
  139. tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
  140. tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
  141. tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
  142. tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
  143. tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
  144. tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
  145. tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
  146. tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
  147. tenxgraph_api/src/app/core/utils/__init__.py +14 -0
  148. tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
  149. tenxgraph_api/src/app/loader.py +516 -0
  150. tenxgraph_api/src/app/main.py +180 -0
  151. tenxgraph_api/src/app/routers/__init__.py +4 -0
  152. tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
  153. tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
  154. tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
  155. tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
  156. tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
  157. tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
  158. tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
  159. tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
  160. tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
  161. tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
  162. tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
  163. tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
  164. tenxgraph_api/src/app/routers/evals/router.py +38 -0
  165. tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
  166. tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
  167. tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
  168. tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
  169. tenxgraph_api/src/app/routers/graph/router.py +821 -0
  170. tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
  171. tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
  172. tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
  173. tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
  174. tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
  175. tenxgraph_api/src/app/routers/media/__init__.py +506 -0
  176. tenxgraph_api/src/app/routers/media/router.py +242 -0
  177. tenxgraph_api/src/app/routers/media/schemas.py +39 -0
  178. tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
  179. tenxgraph_api/src/app/routers/ping/router.py +28 -0
  180. tenxgraph_api/src/app/routers/setup_router.py +60 -0
  181. tenxgraph_api/src/app/routers/store/__init__.py +4 -0
  182. tenxgraph_api/src/app/routers/store/router.py +200 -0
  183. tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
  184. tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
  185. tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
  186. tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
  187. tenxgraph_api/src/app/tasks/__init__.py +0 -0
  188. tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
  189. tenxgraph_api/src/app/utils/__init__.py +12 -0
  190. tenxgraph_api/src/app/utils/callable_helper.py +26 -0
  191. tenxgraph_api/src/app/utils/media/__init__.py +15 -0
  192. tenxgraph_api/src/app/utils/media/extractor.py +172 -0
  193. tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
  194. tenxgraph_api/src/app/utils/parse_output.py +17 -0
  195. tenxgraph_api/src/app/utils/response_helper.py +129 -0
  196. tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
  197. tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
  198. tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
  199. tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
  200. tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
  201. tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
  202. tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
  203. 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