graph-agents-cli 0.3.1__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.
- graph_agents_cli/__init__.py +26 -0
- graph_agents_cli/_api_policy.py +2145 -0
- graph_agents_cli/_approvals.py +400 -0
- graph_agents_cli/_build.py +186 -0
- graph_agents_cli/_build_info.json +7 -0
- graph_agents_cli/_chat_client.py +462 -0
- graph_agents_cli/_click.py +157 -0
- graph_agents_cli/_defaults.py +139 -0
- graph_agents_cli/_experiments.py +64 -0
- graph_agents_cli/_http.py +192 -0
- graph_agents_cli/_output.py +83 -0
- graph_agents_cli/_project.py +462 -0
- graph_agents_cli/_remote.py +220 -0
- graph_agents_cli/_response_schema.py +264 -0
- graph_agents_cli/_runner.py +319 -0
- graph_agents_cli/_skills_check.py +274 -0
- graph_agents_cli/_tools.py +189 -0
- graph_agents_cli/_trust.py +66 -0
- graph_agents_cli/api/__init__.py +15 -0
- graph_agents_cli/api/_changes.py +506 -0
- graph_agents_cli/api/_files.py +658 -0
- graph_agents_cli/api/cmd_api.py +2480 -0
- graph_agents_cli/deploy/__init__.py +15 -0
- graph_agents_cli/deploy/_config.py +171 -0
- graph_agents_cli/deploy/_image.py +128 -0
- graph_agents_cli/deploy/_kube.py +286 -0
- graph_agents_cli/deploy/_modes.py +234 -0
- graph_agents_cli/deploy/_preflight.py +370 -0
- graph_agents_cli/deploy/_values.py +168 -0
- graph_agents_cli/deploy/cmd_deploy.py +1866 -0
- graph_agents_cli/deploy/gitops.py +562 -0
- graph_agents_cli/deploy/local_load.py +273 -0
- graph_agents_cli/dev/__init__.py +13 -0
- graph_agents_cli/dev/cmd_build.py +131 -0
- graph_agents_cli/dev/cmd_install.py +78 -0
- graph_agents_cli/dev/cmd_lint.py +119 -0
- graph_agents_cli/dev/cmd_playground.py +297 -0
- graph_agents_cli/dev/policy_check.py +1287 -0
- graph_agents_cli/eval/__init__.py +22 -0
- graph_agents_cli/eval/_client.py +670 -0
- graph_agents_cli/eval/_common.py +177 -0
- graph_agents_cli/eval/_judge.py +168 -0
- graph_agents_cli/eval/_judge_runner.py +238 -0
- graph_agents_cli/eval/_paths.py +212 -0
- graph_agents_cli/eval/checks.py +581 -0
- graph_agents_cli/eval/cmd_analyze.py +278 -0
- graph_agents_cli/eval/cmd_compare.py +284 -0
- graph_agents_cli/eval/cmd_eval_group.py +80 -0
- graph_agents_cli/eval/cmd_generate.py +558 -0
- graph_agents_cli/eval/cmd_grade.py +466 -0
- graph_agents_cli/eval/cmd_metric.py +156 -0
- graph_agents_cli/eval/cmd_run.py +370 -0
- graph_agents_cli/eval/cmd_submit.py +400 -0
- graph_agents_cli/eval/config.py +435 -0
- graph_agents_cli/eval/dataset.py +350 -0
- graph_agents_cli/eval/gate.py +420 -0
- graph_agents_cli/eval/transcript.py +192 -0
- graph_agents_cli/extension/__init__.py +13 -0
- graph_agents_cli/extension/_compat.py +86 -0
- graph_agents_cli/extension/_loader.py +293 -0
- graph_agents_cli/extension/_manifest.py +135 -0
- graph_agents_cli/extension/_overrides.py +195 -0
- graph_agents_cli/extension/_paths.py +91 -0
- graph_agents_cli/extension/_refs.py +193 -0
- graph_agents_cli/extension/_resolver.py +453 -0
- graph_agents_cli/extension/_schema.py +106 -0
- graph_agents_cli/extension/_spec.py +253 -0
- graph_agents_cli/extension/_sync.py +102 -0
- graph_agents_cli/extension/_trust.py +58 -0
- graph_agents_cli/extension/cmd_extension_add.py +259 -0
- graph_agents_cli/extension/cmd_extension_group.py +57 -0
- graph_agents_cli/extension/cmd_extension_list.py +56 -0
- graph_agents_cli/extension/cmd_extension_remove.py +61 -0
- graph_agents_cli/extension/cmd_extension_update.py +195 -0
- graph_agents_cli/info/__init__.py +13 -0
- graph_agents_cli/info/cmd_info.py +222 -0
- graph_agents_cli/infra/__init__.py +15 -0
- graph_agents_cli/infra/checks.py +1169 -0
- graph_agents_cli/infra/cmd_infra.py +103 -0
- graph_agents_cli/main.py +591 -0
- graph_agents_cli/peer/__init__.py +15 -0
- graph_agents_cli/peer/_generate.py +254 -0
- graph_agents_cli/peer/cmd_peer.py +1151 -0
- graph_agents_cli/run/__init__.py +13 -0
- graph_agents_cli/run/_local_server.py +1157 -0
- graph_agents_cli/run/_signals.py +141 -0
- graph_agents_cli/run/cmd_approvals.py +530 -0
- graph_agents_cli/run/cmd_run.py +1421 -0
- graph_agents_cli/scaffold/__init__.py +19 -0
- graph_agents_cli/scaffold/agents/README.md +24 -0
- graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
- graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
- graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
- graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
- graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
- graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
- graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
- graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
- graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
- graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
- graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
- graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
- graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
- graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
- graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
- graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
- graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
- graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
- graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
- graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
- graph_agents_cli/scaffold/commands/__init__.py +13 -0
- graph_agents_cli/scaffold/commands/create.py +1424 -0
- graph_agents_cli/scaffold/commands/enhance.py +1652 -0
- graph_agents_cli/scaffold/commands/upgrade.py +570 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
- graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
- graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
- graph_agents_cli/scaffold/utils/__init__.py +13 -0
- graph_agents_cli/scaffold/utils/backup.py +212 -0
- graph_agents_cli/scaffold/utils/build_record.py +257 -0
- graph_agents_cli/scaffold/utils/cli_options.py +184 -0
- graph_agents_cli/scaffold/utils/fs.py +83 -0
- graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
- graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
- graph_agents_cli/scaffold/utils/keyedit.py +768 -0
- graph_agents_cli/scaffold/utils/keymerge.py +537 -0
- graph_agents_cli/scaffold/utils/language.py +138 -0
- graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
- graph_agents_cli/scaffold/utils/logging.py +77 -0
- graph_agents_cli/scaffold/utils/manifest.py +292 -0
- graph_agents_cli/scaffold/utils/merge.py +970 -0
- graph_agents_cli/scaffold/utils/merge3.py +216 -0
- graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
- graph_agents_cli/scaffold/utils/remote_template.py +376 -0
- graph_agents_cli/scaffold/utils/template.py +1352 -0
- graph_agents_cli/scaffold/utils/upgrade.py +894 -0
- graph_agents_cli/scaffold/utils/version.py +438 -0
- graph_agents_cli/secrets/__init__.py +15 -0
- graph_agents_cli/secrets/_apply.py +954 -0
- graph_agents_cli/secrets/_required.py +188 -0
- graph_agents_cli/secrets/cmd_secrets.py +211 -0
- graph_agents_cli/setup/__init__.py +13 -0
- graph_agents_cli/setup/_antigravity.py +221 -0
- graph_agents_cli/setup/cmd_auth.py +1030 -0
- graph_agents_cli/setup/cmd_dev_token.py +513 -0
- graph_agents_cli/setup/cmd_setup.py +428 -0
- graph_agents_cli/setup/cmd_update.py +140 -0
- graph_agents_cli/skills/__init__.py +13 -0
- graph_agents_cli/skills/_bundle.py +65 -0
- graph_agents_cli/skills/data/README.md +19 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
- graph_agents_cli/system/__init__.py +15 -0
- graph_agents_cli/system/_apply.py +519 -0
- graph_agents_cli/system/_checks.py +1023 -0
- graph_agents_cli/system/_deploy.py +215 -0
- graph_agents_cli/system/_model.py +363 -0
- graph_agents_cli/system/_system.py +664 -0
- graph_agents_cli/system/_views.py +208 -0
- graph_agents_cli/system/cmd_system.py +423 -0
- graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
- graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
- graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
- graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
- graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
- graph_agents_cli-0.3.1.dist-info/licenses/NOTICE +19 -0
|
@@ -0,0 +1,1986 @@
|
|
|
1
|
+
# Copyright 2026 graph-agents-cli contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Authentication and authorization adapter.
|
|
16
|
+
|
|
17
|
+
One `AuthPolicy` is selected by `AUTH_POLICY` and applied to every surface:
|
|
18
|
+
the chat API and thread routes through the `require(action)` dependency, the
|
|
19
|
+
A2A card and JSON-RPC endpoints through the middleware in `fast_api_app.py`,
|
|
20
|
+
and, under `langgraph-server`, the server's native API (threads, runs,
|
|
21
|
+
assistants, crons, store) through `auth`, a `langgraph_sdk.Auth` object built
|
|
22
|
+
from the same policy and referenced by `langgraph.json`. `langgraph_sdk` and
|
|
23
|
+
`jwt` (PyJWT) are imported lazily, so a runtime or policy that does not use
|
|
24
|
+
them never needs them.
|
|
25
|
+
|
|
26
|
+
Policies:
|
|
27
|
+
* `SharedBearerPolicy` (`shared-bearer`, default): `Authorization: Bearer <API_KEY>`,
|
|
28
|
+
constant-time compare, one principal `shared`, every action allowed.
|
|
29
|
+
* `JwtPolicy` (`jwt`): per-user principals from a verified OIDC/JWT bearer
|
|
30
|
+
token (`AUTH_JWT_*` settings, see `.env.example`): signature from a JWKS
|
|
31
|
+
URL (cached, refetched on an unknown key id) or one PEM public key,
|
|
32
|
+
issuer, audience, `exp`/`nbf`/`iat` with leeway, an algorithm allow-list.
|
|
33
|
+
* `CustomPolicy` (`custom`): interface plus a fail-closed stub in
|
|
34
|
+
`policies/custom.py`, implemented by the project (for example to validate
|
|
35
|
+
an existing application's session cookie).
|
|
36
|
+
|
|
37
|
+
Startup fails closed: `check_startup()` builds the selected policy when the
|
|
38
|
+
app is assembled (`a2a.add_a2a_routes`) and when LangGraph Server loads
|
|
39
|
+
`auth`. An unknown `AUTH_POLICY` stops the process in every environment; a
|
|
40
|
+
policy whose `startup_problems()` reports a misconfiguration stops it outside
|
|
41
|
+
`APP_ENV=dev` (under dev the problem is logged and every request gets 503).
|
|
42
|
+
|
|
43
|
+
LangGraph Server's own meta routes (`/docs`, `/openapi.json`, `/info`,
|
|
44
|
+
`/metrics`) are outside this auth. `langgraph dev` keeps them (LangGraph
|
|
45
|
+
Studio reads `/info`). The server image built from the project's Dockerfile
|
|
46
|
+
sets `"disable_meta": true` in its `LANGGRAPH_HTTP`, which removes them all
|
|
47
|
+
but `/ok`; this app's own `/metrics` (optionally behind `METRICS_TOKEN`) is
|
|
48
|
+
served in their place. A custom image must set the same flag.
|
|
49
|
+
|
|
50
|
+
The retired name `product-session` is still read as `custom`, with a warning.
|
|
51
|
+
|
|
52
|
+
Agents calling agents (delegation). A request may come from another agent
|
|
53
|
+
acting for a user: a `jwt` token carrying the RFC 8693 `act` claim, or a
|
|
54
|
+
principal a `custom` policy marks with `Principal.actor`. The principal's `id`
|
|
55
|
+
stays the subject (the user); `actor` names the agent presenting the request.
|
|
56
|
+
`finalize_principal` runs right after every policy's `authenticate` and applies
|
|
57
|
+
one rule set to all of them: it validates the ids, refuses a chain deeper than
|
|
58
|
+
`AUTH_MAX_DELEGATION_DEPTH` (401) and an agent `AUTH_ALLOWED_ACTORS` does not
|
|
59
|
+
list (403; none is listed by default), keeps only the roles
|
|
60
|
+
`AUTH_DELEGATED_ROLES` lends to agents, and publishes the actor in
|
|
61
|
+
`attributes["@actor"]` (a policy's own `@actor` is never trusted). Ownership
|
|
62
|
+
is by owner key (`Principal.owner_key`): a direct principal owns everything
|
|
63
|
+
done for its subject, a delegated one only what was created under its exact
|
|
64
|
+
subject and actor. Privileged role checks (read-across, admin, role approvers)
|
|
65
|
+
ignore delegated principals.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
import asyncio
|
|
71
|
+
import copy
|
|
72
|
+
import hashlib
|
|
73
|
+
import hmac
|
|
74
|
+
import json
|
|
75
|
+
import logging
|
|
76
|
+
import os
|
|
77
|
+
import re
|
|
78
|
+
import time
|
|
79
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
80
|
+
from contextvars import ContextVar
|
|
81
|
+
from dataclasses import dataclass, field
|
|
82
|
+
from typing import Any, Protocol, runtime_checkable
|
|
83
|
+
from urllib.parse import urlsplit
|
|
84
|
+
|
|
85
|
+
from fastapi import HTTPException, Request
|
|
86
|
+
|
|
87
|
+
from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
|
|
88
|
+
|
|
89
|
+
logger = logging.getLogger(__name__)
|
|
90
|
+
|
|
91
|
+
# Every action a policy may be asked to authorize. `approval.read` lists the
|
|
92
|
+
# approvals of gated API calls and `approval.decide` approves or rejects one;
|
|
93
|
+
# who may decide a given approval is then the policy's `approvers`
|
|
94
|
+
# (`app_utils.approvals.may_decide`).
|
|
95
|
+
ACTIONS: frozenset[str] = frozenset(
|
|
96
|
+
{
|
|
97
|
+
"chat.send",
|
|
98
|
+
"thread.read",
|
|
99
|
+
"thread.list",
|
|
100
|
+
"thread.delete",
|
|
101
|
+
"run.read",
|
|
102
|
+
"a2a.invoke",
|
|
103
|
+
"card.read",
|
|
104
|
+
"approval.read",
|
|
105
|
+
"approval.decide",
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
SHARED_BEARER = "shared-bearer"
|
|
110
|
+
JWT = "jwt"
|
|
111
|
+
CUSTOM = "custom"
|
|
112
|
+
DEFAULT_POLICY = SHARED_BEARER
|
|
113
|
+
# Retired policy names still accepted from AUTH_POLICY.
|
|
114
|
+
LEGACY_ALIASES = {"product-session": CUSTOM}
|
|
115
|
+
|
|
116
|
+
# The one attribute key that may hold secrets: api name -> credential string,
|
|
117
|
+
# forwarded by `app_utils.api_client` for `auth: forward` APIs.
|
|
118
|
+
CREDENTIALS_KEY = "credentials"
|
|
119
|
+
|
|
120
|
+
# Optional secret key of `Principal.hashed_id()` (HMAC-SHA256); unset = plain sha256.
|
|
121
|
+
PRINCIPAL_HASH_SALT_ENV = "PRINCIPAL_HASH_SALT"
|
|
122
|
+
|
|
123
|
+
# Attribute keys starting with `@` belong to the framework (API names, the keys of
|
|
124
|
+
# `credentials`, never start with one). `@actor` is public (persisted with an
|
|
125
|
+
# approval's requester, passed in LangGraph Server run context); the `@` keys under
|
|
126
|
+
# `credentials` are private, like every credential.
|
|
127
|
+
ACTOR_ATTRIBUTE = "@actor"
|
|
128
|
+
SUBJECT_TOKEN_CREDENTIAL = "@subject_token"
|
|
129
|
+
SUBJECT_AUD_CREDENTIAL = "@subject_aud"
|
|
130
|
+
SUBJECT_EXP_CREDENTIAL = "@subject_exp"
|
|
131
|
+
ORIGIN_CREDENTIAL = "@origin"
|
|
132
|
+
# Between the subject and the actor in an owner key: the unit separator, which no
|
|
133
|
+
# valid id holds (ids have no control characters).
|
|
134
|
+
OWNER_KEY_SEPARATOR = "\x1f"
|
|
135
|
+
# `AUTH_MAX_DELEGATION_DEPTH`: how many agents may stand between the user and this one.
|
|
136
|
+
DEFAULT_MAX_DELEGATION_DEPTH = 3
|
|
137
|
+
MIN_DELEGATION_DEPTH = 1
|
|
138
|
+
MAX_DELEGATION_DEPTH = 8
|
|
139
|
+
# `AUTH_ALLOWED_ACTORS=*` allows any agent (the issuer's audience policy alone decides).
|
|
140
|
+
ANY_ACTOR = "*"
|
|
141
|
+
# The actor of a jwt token with no `act` whose client `AUTH_JWT_DIRECT_CLIENTS` does not
|
|
142
|
+
# list: `client:<azp>` (`client:?` when the token names no client).
|
|
143
|
+
CLIENT_ACTOR_PREFIX = "client:"
|
|
144
|
+
UNKNOWN_CLIENT = "?"
|
|
145
|
+
# The RFC 8693 claim that nests earlier actors inside an actor.
|
|
146
|
+
NESTED_ACTOR_CLAIM = "act"
|
|
147
|
+
DEFAULT_JWT_CLIENT_CLAIM = "azp"
|
|
148
|
+
# Read when the client claim is absent (RFC 9068 access tokens).
|
|
149
|
+
FALLBACK_JWT_CLIENT_CLAIM = "client_id"
|
|
150
|
+
|
|
151
|
+
_TRUE = ("1", "true", "yes", "on")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def dev_mode() -> bool:
|
|
155
|
+
"""`APP_ENV` is exactly `dev`. Unset or any other value (`DEV`, ` dev`, `development`)
|
|
156
|
+
is a deployed environment: dev relaxes checks, so only the exact value turns it on."""
|
|
157
|
+
return os.environ.get("APP_ENV") == "dev"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _csv(raw: str | None) -> list[str]:
|
|
161
|
+
return [part.strip() for part in (raw or "").split(",") if part.strip()]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def admin_roles() -> set[str]:
|
|
165
|
+
"""Roles allowed to manage assistants, crons and the store (`AUTH_ADMIN_ROLES`; empty = nobody)."""
|
|
166
|
+
return set(_csv(os.environ.get("AUTH_ADMIN_ROLES")))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def read_across_roles() -> set[str]:
|
|
170
|
+
"""Roles allowed to read other principals' threads (`AUTH_READ_ACROSS_ROLES`)."""
|
|
171
|
+
return set(_csv(os.environ.get("AUTH_READ_ACROSS_ROLES")))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class Actor:
|
|
176
|
+
"""The agent presenting a delegated request. Public: persisted and shown, never secret.
|
|
177
|
+
|
|
178
|
+
`id` is the current actor (the outermost `act.sub` of a jwt token,
|
|
179
|
+
`client:<azp>`, or what a custom policy names); `chain` is every actor,
|
|
180
|
+
current first (earlier agents follow); `client` the token's authorized
|
|
181
|
+
party (`azp`/`client_id`) when it names one.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
id: str
|
|
185
|
+
chain: tuple[str, ...] = ()
|
|
186
|
+
client: str | None = None
|
|
187
|
+
|
|
188
|
+
def public(self) -> dict[str, Any]:
|
|
189
|
+
"""The actor as `attributes["@actor"]` holds it."""
|
|
190
|
+
return {"id": self.id, "chain": list(self.chain or (self.id,)), "client": self.client}
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def from_public(cls, value: Any) -> Actor | None:
|
|
194
|
+
"""An actor from its `public()` form (a persisted requester's), or None when it is not one."""
|
|
195
|
+
if not isinstance(value, Mapping):
|
|
196
|
+
return None
|
|
197
|
+
actor_id = value.get("id")
|
|
198
|
+
if not _valid_id(actor_id):
|
|
199
|
+
return None
|
|
200
|
+
raw = value.get("chain")
|
|
201
|
+
chain = (
|
|
202
|
+
tuple(str(a) for a in raw if _valid_id(a))
|
|
203
|
+
if isinstance(raw, list | tuple)
|
|
204
|
+
else (actor_id,)
|
|
205
|
+
)
|
|
206
|
+
client = value.get("client")
|
|
207
|
+
return cls(
|
|
208
|
+
id=actor_id,
|
|
209
|
+
chain=chain or (actor_id,),
|
|
210
|
+
client=client if isinstance(client, str) else None,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def owner_key_of(subject: str, actor: str | None) -> str:
|
|
215
|
+
"""The owner key of a record kept as two columns: the subject, or subject and actor.
|
|
216
|
+
|
|
217
|
+
Byte-identical to the subject for a direct owner (no actor, or an empty one),
|
|
218
|
+
so every row written before 0.3 keeps its owner.
|
|
219
|
+
"""
|
|
220
|
+
return subject if not actor else f"{subject}{OWNER_KEY_SEPARATOR}{actor}"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass
|
|
224
|
+
class Principal:
|
|
225
|
+
"""Who is calling. `id` is what traces and run records use, hashed.
|
|
226
|
+
|
|
227
|
+
`id` is always the subject: the user a request acts for (or a service
|
|
228
|
+
acting for itself). `actor` is the agent presenting a delegated request on
|
|
229
|
+
the subject's behalf, None for a direct one (the subject itself calls).
|
|
230
|
+
|
|
231
|
+
`attributes` may hold secrets only under `credentials` (api name ->
|
|
232
|
+
credential string). Anything persisted, logged, traced or passed into
|
|
233
|
+
LangGraph Server run context/metadata must use `public_attributes()`.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
id: str
|
|
237
|
+
roles: list[str] = field(default_factory=list)
|
|
238
|
+
permissions: set[str] = field(default_factory=set)
|
|
239
|
+
# Out of repr: `credentials` holds secrets, and a repr ends up in logs and tracebacks.
|
|
240
|
+
attributes: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
241
|
+
actor: Actor | None = None
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def delegated(self) -> bool:
|
|
245
|
+
"""Whether an agent presents this request for the subject (see `actor`)."""
|
|
246
|
+
return self.actor is not None
|
|
247
|
+
|
|
248
|
+
def owner_key(self) -> str:
|
|
249
|
+
"""What owns the threads and tasks this principal creates: the subject, or the
|
|
250
|
+
subject and the actor (`owner_key_of`). Equal to `id` for a direct principal."""
|
|
251
|
+
return owner_key_of(self.id, self.actor.id if self.actor is not None else None)
|
|
252
|
+
|
|
253
|
+
def hashed_id(self) -> str:
|
|
254
|
+
"""The id hashed, first 16 hex characters: what logs, traces and run records carry.
|
|
255
|
+
|
|
256
|
+
HMAC-SHA256 keyed with `PRINCIPAL_HASH_SALT` when that is set, else
|
|
257
|
+
plain sha256 (the default, kept for compatibility). A secret salt stops
|
|
258
|
+
anyone holding logs or traces from confirming a guessed id (an email
|
|
259
|
+
address, say) by hashing it; changing the salt changes every hash, so
|
|
260
|
+
new hashes no longer match older logs and run records.
|
|
261
|
+
"""
|
|
262
|
+
data = self.id.encode("utf-8")
|
|
263
|
+
salt = (os.environ.get(PRINCIPAL_HASH_SALT_ENV) or "").strip()
|
|
264
|
+
if salt:
|
|
265
|
+
return hmac.new(salt.encode("utf-8"), data, hashlib.sha256).hexdigest()[:16]
|
|
266
|
+
return hashlib.sha256(data).hexdigest()[:16]
|
|
267
|
+
|
|
268
|
+
def public_attributes(self) -> dict[str, Any]:
|
|
269
|
+
"""`attributes` without `credentials`: safe to persist, log or trace."""
|
|
270
|
+
return {k: v for k, v in self.attributes.items() if k != CREDENTIALS_KEY}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _valid_id(value: Any) -> bool:
|
|
274
|
+
"""A principal or actor id: 1-`PRINCIPAL_ID_MAX_CHARS` characters, no control characters."""
|
|
275
|
+
return (
|
|
276
|
+
isinstance(value, str)
|
|
277
|
+
and 0 < len(value) <= PRINCIPAL_ID_MAX_CHARS
|
|
278
|
+
and not _CONTROL_CHARS.search(value)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def keep_subject_token(
|
|
283
|
+
principal: Principal, token: str, aud: Any = None, exp: Any = None
|
|
284
|
+
) -> Principal:
|
|
285
|
+
"""Keep the verified inbound bearer, its audience and expiry in the principal's credentials.
|
|
286
|
+
|
|
287
|
+
Under `credentials["@subject_token"]`, `credentials["@subject_aud"]` (a
|
|
288
|
+
tuple) and `credentials["@subject_exp"]` (the `exp` claim, epoch seconds,
|
|
289
|
+
when given): the token an API that acts with the user's own identity is
|
|
290
|
+
called with (`auth: exchange` exchanges it, `auth: forward` with
|
|
291
|
+
`forward_audience` forwards it). `jwt` keeps it only when the loaded
|
|
292
|
+
api-policy has such an API (`subject_token_needed`); a custom policy calls
|
|
293
|
+
this itself. An exchanged token is never kept past the subject token's
|
|
294
|
+
expiry, and a subject token with 10 s or less left is not exchanged.
|
|
295
|
+
Credentials are never persisted, logged or traced (`public_attributes()`
|
|
296
|
+
drops them).
|
|
297
|
+
"""
|
|
298
|
+
if isinstance(aud, str):
|
|
299
|
+
audience: tuple[str, ...] = (aud,)
|
|
300
|
+
elif isinstance(aud, list | tuple):
|
|
301
|
+
audience = tuple(str(a) for a in aud if isinstance(a, str))
|
|
302
|
+
else:
|
|
303
|
+
audience = ()
|
|
304
|
+
credentials = principal.attributes.get(CREDENTIALS_KEY)
|
|
305
|
+
kept = dict(credentials) if isinstance(credentials, Mapping) else {}
|
|
306
|
+
kept[SUBJECT_TOKEN_CREDENTIAL] = token
|
|
307
|
+
kept[SUBJECT_AUD_CREDENTIAL] = audience
|
|
308
|
+
if isinstance(exp, int | float) and not isinstance(exp, bool):
|
|
309
|
+
kept[SUBJECT_EXP_CREDENTIAL] = exp
|
|
310
|
+
else:
|
|
311
|
+
kept.pop(SUBJECT_EXP_CREDENTIAL, None)
|
|
312
|
+
principal.attributes[CREDENTIALS_KEY] = kept
|
|
313
|
+
return principal
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def origin_of(principal: Principal) -> dict[str, Any] | None:
|
|
317
|
+
"""The user's own words a calling agent forwarded (`credentials["@origin"]`), or None.
|
|
318
|
+
|
|
319
|
+
A copy holding `text`, `truncated` and `hops` (the origin extension's
|
|
320
|
+
fields, as `a2a.read_origin` keeps them); None without a text.
|
|
321
|
+
"""
|
|
322
|
+
credentials = principal.attributes.get(CREDENTIALS_KEY)
|
|
323
|
+
origin = credentials.get(ORIGIN_CREDENTIAL) if isinstance(credentials, Mapping) else None
|
|
324
|
+
if not isinstance(origin, Mapping) or not isinstance(origin.get("text"), str):
|
|
325
|
+
return None
|
|
326
|
+
return {key: origin[key] for key in ("text", "truncated", "hops") if key in origin}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def with_origin(principal: Principal, origin: Mapping[str, Any] | None) -> Principal:
|
|
330
|
+
"""`principal` with `origin` as the user's forwarded words (private: never persisted).
|
|
331
|
+
|
|
332
|
+
A new principal, its credentials otherwise the same; `origin` None removes
|
|
333
|
+
any it had.
|
|
334
|
+
"""
|
|
335
|
+
attributes = dict(principal.attributes)
|
|
336
|
+
credentials = attributes.get(CREDENTIALS_KEY)
|
|
337
|
+
kept = dict(credentials) if isinstance(credentials, Mapping) else {}
|
|
338
|
+
if origin is None:
|
|
339
|
+
kept.pop(ORIGIN_CREDENTIAL, None)
|
|
340
|
+
else:
|
|
341
|
+
kept[ORIGIN_CREDENTIAL] = dict(origin)
|
|
342
|
+
if kept:
|
|
343
|
+
attributes[CREDENTIALS_KEY] = kept
|
|
344
|
+
else:
|
|
345
|
+
attributes.pop(CREDENTIALS_KEY, None)
|
|
346
|
+
return Principal(
|
|
347
|
+
id=principal.id,
|
|
348
|
+
roles=list(principal.roles),
|
|
349
|
+
permissions=set(principal.permissions),
|
|
350
|
+
attributes=attributes,
|
|
351
|
+
actor=principal.actor,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def subject_token_needed() -> bool:
|
|
356
|
+
"""Whether the loaded api-policy has an API that acts with the caller's own token.
|
|
357
|
+
|
|
358
|
+
That is an `auth: exchange` API, or an `auth: forward` one with
|
|
359
|
+
`forward_audience`. Without one (and without a readable policy) no
|
|
360
|
+
subject token is kept.
|
|
361
|
+
"""
|
|
362
|
+
try:
|
|
363
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import load_policy
|
|
364
|
+
|
|
365
|
+
apis = load_policy().apis
|
|
366
|
+
except Exception:
|
|
367
|
+
return False
|
|
368
|
+
return any(api.get("auth") == "exchange" or "forward_audience" in api for api in apis.values())
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
# Delegation: one rule set for every policy (`finalize_principal`)
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@dataclass(frozen=True)
|
|
377
|
+
class DelegationSettings:
|
|
378
|
+
"""`AUTH_MAX_DELEGATION_DEPTH`, `AUTH_ALLOWED_ACTORS` and `AUTH_DELEGATED_ROLES`."""
|
|
379
|
+
|
|
380
|
+
max_depth: int = DEFAULT_MAX_DELEGATION_DEPTH
|
|
381
|
+
# The agents that may present a request for a user; `*` in it allows any.
|
|
382
|
+
allowed_actors: frozenset[str] = field(default_factory=lambda: DEFAULT_ALLOWED_ACTORS)
|
|
383
|
+
# The roles a delegated principal keeps (the rest are dropped).
|
|
384
|
+
delegated_roles: frozenset[str] = frozenset()
|
|
385
|
+
|
|
386
|
+
def allows(self, actor_id: str) -> bool:
|
|
387
|
+
return ANY_ACTOR in self.allowed_actors or actor_id in self.allowed_actors
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def delegation_settings(env: Mapping[str, str] | None = None) -> DelegationSettings:
|
|
391
|
+
"""The delegation settings, validated; `SettingsError` names a bad one (a startup error)."""
|
|
392
|
+
env = os.environ if env is None else env
|
|
393
|
+
raw_depth = (env.get("AUTH_MAX_DELEGATION_DEPTH") or "").strip()
|
|
394
|
+
depth = DEFAULT_MAX_DELEGATION_DEPTH
|
|
395
|
+
if raw_depth:
|
|
396
|
+
try:
|
|
397
|
+
depth = int(raw_depth)
|
|
398
|
+
except ValueError:
|
|
399
|
+
raise SettingsError(
|
|
400
|
+
f"AUTH_MAX_DELEGATION_DEPTH={raw_depth!r} is not a whole number."
|
|
401
|
+
) from None
|
|
402
|
+
if not MIN_DELEGATION_DEPTH <= depth <= MAX_DELEGATION_DEPTH:
|
|
403
|
+
raise SettingsError(
|
|
404
|
+
f"AUTH_MAX_DELEGATION_DEPTH={depth} must be from {MIN_DELEGATION_DEPTH} to "
|
|
405
|
+
f"{MAX_DELEGATION_DEPTH}."
|
|
406
|
+
)
|
|
407
|
+
actors = _csv(env.get("AUTH_ALLOWED_ACTORS"))
|
|
408
|
+
for actor in actors:
|
|
409
|
+
if actor != ANY_ACTOR and (not _valid_id(actor) or any(c.isspace() for c in actor)):
|
|
410
|
+
raise SettingsError(
|
|
411
|
+
f"AUTH_ALLOWED_ACTORS: {actor[:40]!r} is not an agent id (1-256 characters, no "
|
|
412
|
+
"whitespace or control characters), or * for any agent."
|
|
413
|
+
)
|
|
414
|
+
roles = _csv(env.get("AUTH_DELEGATED_ROLES"))
|
|
415
|
+
for role in roles:
|
|
416
|
+
if not _valid_id(role):
|
|
417
|
+
raise SettingsError(f"AUTH_DELEGATED_ROLES: {role[:40]!r} is not a role name.")
|
|
418
|
+
return DelegationSettings(
|
|
419
|
+
max_depth=depth,
|
|
420
|
+
allowed_actors=frozenset(actors) or DEFAULT_ALLOWED_ACTORS,
|
|
421
|
+
delegated_roles=frozenset(roles),
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# How `api_client.require_user_mentioned` treats a request an agent presents for the user
|
|
426
|
+
# (`A2A_DELEGATED_MENTIONS`): `origin` (default) needs the id in the user's own words the
|
|
427
|
+
# calling agent forwarded and in its request; `refuse` always refuses; `request` counts the
|
|
428
|
+
# agent's request as the user's words (the 0.2 behaviour, an explicit opt-out).
|
|
429
|
+
MENTIONS_ORIGIN = "origin"
|
|
430
|
+
MENTIONS_REFUSE = "refuse"
|
|
431
|
+
MENTIONS_REQUEST = "request"
|
|
432
|
+
DELEGATED_MENTIONS_MODES = (MENTIONS_ORIGIN, MENTIONS_REFUSE, MENTIONS_REQUEST)
|
|
433
|
+
DEFAULT_DELEGATED_MENTIONS = MENTIONS_ORIGIN
|
|
434
|
+
# `A2A_CALLER_NOTE`: whether the model is told, in one system note, that an agent wrote
|
|
435
|
+
# the request (`on`, default) or not (`off`; the request stays fenced either way).
|
|
436
|
+
CALLER_NOTE_VALUES = ("on", "off")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def delegated_mentions(env: Mapping[str, str] | None = None) -> str:
|
|
440
|
+
"""`A2A_DELEGATED_MENTIONS` (`origin`, `refuse` or `request`); `SettingsError` otherwise."""
|
|
441
|
+
env = os.environ if env is None else env
|
|
442
|
+
value = (env.get("A2A_DELEGATED_MENTIONS") or DEFAULT_DELEGATED_MENTIONS).strip().lower()
|
|
443
|
+
if value not in DELEGATED_MENTIONS_MODES:
|
|
444
|
+
raise SettingsError(
|
|
445
|
+
f"A2A_DELEGATED_MENTIONS={value!r} must be one of {', '.join(DELEGATED_MENTIONS_MODES)}."
|
|
446
|
+
)
|
|
447
|
+
return value
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def caller_note_enabled(env: Mapping[str, str] | None = None) -> bool:
|
|
451
|
+
"""`A2A_CALLER_NOTE` (`on`, the default, or `off`); `SettingsError` otherwise."""
|
|
452
|
+
env = os.environ if env is None else env
|
|
453
|
+
value = (env.get("A2A_CALLER_NOTE") or "on").strip().lower()
|
|
454
|
+
if value not in CALLER_NOTE_VALUES:
|
|
455
|
+
raise SettingsError(f"A2A_CALLER_NOTE={value!r} must be 'on' or 'off'.")
|
|
456
|
+
return value == "on"
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _principal_problem(principal: Principal) -> str | None:
|
|
460
|
+
"""Which id of a principal is invalid (`principal` or `actor`), or None."""
|
|
461
|
+
if not _valid_id(principal.id):
|
|
462
|
+
return "principal"
|
|
463
|
+
actor = principal.actor
|
|
464
|
+
if actor is None:
|
|
465
|
+
return None
|
|
466
|
+
if not _valid_id(actor.id) or not isinstance(actor.chain, tuple):
|
|
467
|
+
return "actor"
|
|
468
|
+
if actor.chain and (actor.chain[0] != actor.id or not all(_valid_id(a) for a in actor.chain)):
|
|
469
|
+
return "actor"
|
|
470
|
+
if actor.client is not None and not _valid_id(actor.client):
|
|
471
|
+
return "actor"
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def finalize_principal(principal: Principal) -> Principal:
|
|
476
|
+
"""Apply the delegation rules to what a policy's `authenticate` returned (every policy).
|
|
477
|
+
|
|
478
|
+
In this order: the ids are valid (1-256 characters, no control
|
|
479
|
+
characters; a `jwt` token that breaks this is refused with 401, a custom
|
|
480
|
+
policy's principal with 500, logged as a bug in the policy); a delegated
|
|
481
|
+
principal's actor chain is at most `AUTH_MAX_DELEGATION_DEPTH` long (401)
|
|
482
|
+
and its actor is listed in `AUTH_ALLOWED_ACTORS` (403); it keeps only the
|
|
483
|
+
roles `AUTH_DELEGATED_ROLES` lends to agents; and its actor is published
|
|
484
|
+
in `attributes["@actor"]`. A direct principal is returned as it came,
|
|
485
|
+
without any `@actor` a policy put there (only this function sets it).
|
|
486
|
+
"""
|
|
487
|
+
try:
|
|
488
|
+
settings = delegation_settings()
|
|
489
|
+
except SettingsError as exc:
|
|
490
|
+
# The startup check refuses to start with this; fail closed if it is reached anyway.
|
|
491
|
+
logger.error("delegation settings are invalid: %s", exc)
|
|
492
|
+
raise HTTPException(
|
|
493
|
+
status_code=503, detail="The server's delegation settings are invalid."
|
|
494
|
+
) from None
|
|
495
|
+
problem = _principal_problem(principal)
|
|
496
|
+
if problem is not None:
|
|
497
|
+
name = policy_name()
|
|
498
|
+
if name == JWT:
|
|
499
|
+
raise _invalid_token(f"invalid {problem} claim")
|
|
500
|
+
logger.error(
|
|
501
|
+
"AUTH_POLICY=%s returned a principal with an invalid %s id (1-%d characters, no "
|
|
502
|
+
"control characters): a bug in the policy; the request is refused",
|
|
503
|
+
name,
|
|
504
|
+
problem,
|
|
505
|
+
PRINCIPAL_ID_MAX_CHARS,
|
|
506
|
+
)
|
|
507
|
+
raise HTTPException(status_code=500, detail="The auth policy returned an invalid caller.")
|
|
508
|
+
attributes = {k: v for k, v in principal.attributes.items() if k != ACTOR_ATTRIBUTE}
|
|
509
|
+
actor = principal.actor
|
|
510
|
+
if actor is None:
|
|
511
|
+
if ACTOR_ATTRIBUTE not in principal.attributes:
|
|
512
|
+
return principal
|
|
513
|
+
return Principal(
|
|
514
|
+
id=principal.id,
|
|
515
|
+
roles=list(principal.roles),
|
|
516
|
+
permissions=set(principal.permissions),
|
|
517
|
+
attributes=attributes,
|
|
518
|
+
)
|
|
519
|
+
chain = actor.chain or (actor.id,)
|
|
520
|
+
if len(chain) > settings.max_depth:
|
|
521
|
+
raise _invalid_token("delegation too deep")
|
|
522
|
+
if not settings.allows(actor.id):
|
|
523
|
+
raise HTTPException(
|
|
524
|
+
status_code=403,
|
|
525
|
+
detail=f"Delegated caller {actor.id} is not allowed here (AUTH_ALLOWED_ACTORS).",
|
|
526
|
+
)
|
|
527
|
+
actor = Actor(id=actor.id, chain=chain, client=actor.client)
|
|
528
|
+
attributes[ACTOR_ATTRIBUTE] = actor.public()
|
|
529
|
+
return Principal(
|
|
530
|
+
id=principal.id,
|
|
531
|
+
roles=[r for r in principal.roles if r in settings.delegated_roles],
|
|
532
|
+
permissions=set(principal.permissions),
|
|
533
|
+
attributes=attributes,
|
|
534
|
+
actor=actor,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def actor_of_attributes(attributes: Any) -> Actor | None:
|
|
539
|
+
"""The actor a principal's (public) attributes carry under `@actor`, or None (direct)."""
|
|
540
|
+
if not isinstance(attributes, Mapping):
|
|
541
|
+
return None
|
|
542
|
+
return Actor.from_public(attributes.get(ACTOR_ATTRIBUTE))
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# The keys of a graph run's context that say who is calling (`agent.AgentContext`).
|
|
546
|
+
RUN_CONTEXT_KEYS = ("principal_id", "roles", "attributes")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def run_context_of(principal: Principal) -> dict[str, Any]:
|
|
550
|
+
"""The run context LangGraph Server runs the graph with for `principal`: who tools act for.
|
|
551
|
+
|
|
552
|
+
Its id, roles and public attributes (`@actor` included; never `credentials`,
|
|
553
|
+
since the server persists run context). `/chat` and A2A send it with each
|
|
554
|
+
run under `langgraph-server`, and the server auth handler puts it on every
|
|
555
|
+
run the native API starts, whatever that request sent.
|
|
556
|
+
"""
|
|
557
|
+
return {
|
|
558
|
+
"principal_id": principal.id,
|
|
559
|
+
"roles": list(principal.roles),
|
|
560
|
+
"attributes": principal.public_attributes(),
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@runtime_checkable
|
|
565
|
+
class AuthPolicy(Protocol):
|
|
566
|
+
async def authenticate(self, request: Request) -> Principal:
|
|
567
|
+
"""Return the caller or raise `HTTPException(401)`."""
|
|
568
|
+
...
|
|
569
|
+
|
|
570
|
+
async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
|
|
571
|
+
"""Allow `action` on `resource` (a thread id or None) or raise `HTTPException(403)`."""
|
|
572
|
+
...
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
class SharedBearerPolicy:
|
|
576
|
+
"""`Authorization: Bearer <API_KEY>`; one anonymous principal; every action allowed.
|
|
577
|
+
|
|
578
|
+
Suitable for internal tools and development. Conversation ownership is not
|
|
579
|
+
enforced because every caller is the same principal.
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
principal_id = "shared"
|
|
583
|
+
|
|
584
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
585
|
+
self._api_key = api_key
|
|
586
|
+
|
|
587
|
+
def expected_key(self) -> str:
|
|
588
|
+
return self._api_key if self._api_key is not None else os.environ.get("API_KEY", "")
|
|
589
|
+
|
|
590
|
+
async def authenticate(self, request: Request) -> Principal:
|
|
591
|
+
expected = self.expected_key()
|
|
592
|
+
if not expected:
|
|
593
|
+
# Fail closed: an unset key must never mean "no auth".
|
|
594
|
+
raise HTTPException(
|
|
595
|
+
status_code=503,
|
|
596
|
+
detail="API_KEY is not configured on the server (AUTH_POLICY=shared-bearer).",
|
|
597
|
+
)
|
|
598
|
+
header = request.headers.get("authorization", "")
|
|
599
|
+
scheme, _, token = header.partition(" ")
|
|
600
|
+
token = token.strip()
|
|
601
|
+
if (
|
|
602
|
+
scheme.lower() != "bearer"
|
|
603
|
+
or not token
|
|
604
|
+
or not hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8"))
|
|
605
|
+
):
|
|
606
|
+
raise HTTPException(
|
|
607
|
+
status_code=401,
|
|
608
|
+
detail="Missing or invalid bearer token.",
|
|
609
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
610
|
+
)
|
|
611
|
+
return Principal(id=self.principal_id, roles=["shared"], permissions=set(ACTIONS))
|
|
612
|
+
|
|
613
|
+
async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
|
|
614
|
+
if action not in ACTIONS:
|
|
615
|
+
raise HTTPException(status_code=403, detail=f"Unknown action {action!r}.")
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# ---------------------------------------------------------------------------
|
|
619
|
+
# JwtPolicy: per-user principals from a verified OIDC/JWT bearer token
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
# Asymmetric algorithms and the key they need: (JWK kty, curve or None).
|
|
623
|
+
ASYMMETRIC_ALGORITHMS: dict[str, tuple[str, str | None]] = {
|
|
624
|
+
"RS256": ("RSA", None),
|
|
625
|
+
"RS384": ("RSA", None),
|
|
626
|
+
"RS512": ("RSA", None),
|
|
627
|
+
"PS256": ("RSA", None),
|
|
628
|
+
"PS384": ("RSA", None),
|
|
629
|
+
"PS512": ("RSA", None),
|
|
630
|
+
"ES256": ("EC", "P-256"),
|
|
631
|
+
"ES384": ("EC", "P-384"),
|
|
632
|
+
"ES512": ("EC", "P-521"),
|
|
633
|
+
"EdDSA": ("OKP", None), # Ed25519 or Ed448
|
|
634
|
+
}
|
|
635
|
+
# Shared-secret algorithms: only with AUTH_JWT_ALLOW_HS=true and AUTH_JWT_SECRET.
|
|
636
|
+
HMAC_ALGORITHMS = ("HS256", "HS384", "HS512")
|
|
637
|
+
DEFAULT_JWT_ALGORITHMS = "RS256,ES256"
|
|
638
|
+
DEFAULT_LEEWAY_S = 60
|
|
639
|
+
DEFAULT_JWKS_CACHE_S = 300
|
|
640
|
+
# A token longer than this is refused before it is parsed.
|
|
641
|
+
JWT_MAX_TOKEN_CHARS = 16_384
|
|
642
|
+
# JWKS fetch: time limit, size limit, and how often an unknown key id (or a
|
|
643
|
+
# failed fetch) may trigger another request to the issuer.
|
|
644
|
+
JWKS_TIMEOUT_S = 5.0
|
|
645
|
+
JWKS_MAX_BYTES = 1_048_576
|
|
646
|
+
JWKS_REFETCH_INTERVAL_S = 30.0
|
|
647
|
+
# When a refresh fails, the last good key set stays usable this much longer
|
|
648
|
+
# than AUTH_JWT_JWKS_CACHE_S; after that requests get 503 until the issuer answers.
|
|
649
|
+
JWKS_STALE_GRACE_S = 3600.0
|
|
650
|
+
HMAC_MIN_SECRET_BYTES = 32
|
|
651
|
+
# Delegated callers (0.3). The jwt claim that names the agent acting for the user
|
|
652
|
+
# (`AUTH_JWT_ACTOR_CLAIM`, RFC 8693 `act`; set it empty to read every token as the
|
|
653
|
+
# user's own, as 0.2 does), and the agents that may act (`AUTH_ALLOWED_ACTORS`, a comma
|
|
654
|
+
# list): none by default, so a delegated caller is refused until it is listed.
|
|
655
|
+
DEFAULT_JWT_ACTOR_CLAIM = "act"
|
|
656
|
+
DEFAULT_ALLOWED_ACTORS: frozenset[str] = frozenset()
|
|
657
|
+
PRINCIPAL_ID_MAX_CHARS = 256
|
|
658
|
+
MAX_ROLES = 256
|
|
659
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
|
|
660
|
+
_LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1")
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _canonical_algorithm(name: str) -> str | None:
|
|
664
|
+
"""`rs256` -> `RS256`, `eddsa` -> `EdDSA`; None for anything not supported."""
|
|
665
|
+
for known in (*ASYMMETRIC_ALGORITHMS, *HMAC_ALGORITHMS):
|
|
666
|
+
if name.lower() == known.lower():
|
|
667
|
+
return known
|
|
668
|
+
return None
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _key_matches_algorithm(algorithm: str, kty: Any, crv: Any = None, key_alg: Any = None) -> bool:
|
|
672
|
+
"""True when a key of type `kty`/`crv` (pinned to `key_alg`, if any) may verify `algorithm`.
|
|
673
|
+
|
|
674
|
+
Keeps the algorithm families apart (an RSA key never verifies ES256, an
|
|
675
|
+
EC P-384 key never verifies ES256) and honours a JWK's own `alg`.
|
|
676
|
+
"""
|
|
677
|
+
spec = ASYMMETRIC_ALGORITHMS.get(algorithm)
|
|
678
|
+
if spec is None:
|
|
679
|
+
return False
|
|
680
|
+
if key_alg is not None and key_alg != algorithm:
|
|
681
|
+
return False
|
|
682
|
+
want_kty, want_crv = spec
|
|
683
|
+
if kty != want_kty:
|
|
684
|
+
return False
|
|
685
|
+
if want_kty == "EC":
|
|
686
|
+
return crv == want_crv
|
|
687
|
+
if want_kty == "OKP":
|
|
688
|
+
return crv in ("Ed25519", "Ed448")
|
|
689
|
+
return True
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _describe_public_key(key: Any) -> tuple[str, str | None]:
|
|
693
|
+
"""(kty, crv) of a `cryptography` public key, in JWK terms."""
|
|
694
|
+
from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, rsa
|
|
695
|
+
|
|
696
|
+
if isinstance(key, rsa.RSAPublicKey):
|
|
697
|
+
return "RSA", None
|
|
698
|
+
if isinstance(key, ec.EllipticCurvePublicKey):
|
|
699
|
+
curves = {"secp256r1": "P-256", "secp384r1": "P-384", "secp521r1": "P-521"}
|
|
700
|
+
return "EC", curves.get(key.curve.name, key.curve.name)
|
|
701
|
+
if isinstance(key, ed25519.Ed25519PublicKey):
|
|
702
|
+
return "OKP", "Ed25519"
|
|
703
|
+
if isinstance(key, ed448.Ed448PublicKey):
|
|
704
|
+
return "OKP", "Ed448"
|
|
705
|
+
return type(key).__name__, None
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _load_public_key(pem: str) -> Any:
|
|
709
|
+
"""A PEM public key or certificate -> a `cryptography` public key. Private keys are refused."""
|
|
710
|
+
from cryptography import x509
|
|
711
|
+
from cryptography.hazmat.primitives import serialization
|
|
712
|
+
|
|
713
|
+
data = pem.strip().replace("\\n", "\n").encode("utf-8")
|
|
714
|
+
if b"PRIVATE KEY" in data:
|
|
715
|
+
raise ValueError("a private key was given; set the issuer's public key")
|
|
716
|
+
try:
|
|
717
|
+
return serialization.load_pem_public_key(data)
|
|
718
|
+
except ValueError:
|
|
719
|
+
return x509.load_pem_x509_certificate(data).public_key()
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _int_setting(
|
|
723
|
+
env: Mapping[str, str], name: str, default: int, low: int, high: int, problems: list[str]
|
|
724
|
+
) -> int:
|
|
725
|
+
raw = (env.get(name) or "").strip()
|
|
726
|
+
if not raw:
|
|
727
|
+
return default
|
|
728
|
+
try:
|
|
729
|
+
value = int(raw)
|
|
730
|
+
except ValueError:
|
|
731
|
+
problems.append(f"{name} must be a whole number of seconds, got {raw!r}")
|
|
732
|
+
return default
|
|
733
|
+
if not low <= value <= high:
|
|
734
|
+
problems.append(f"{name} must be between {low} and {high}, got {value}")
|
|
735
|
+
return default
|
|
736
|
+
return value
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
@dataclass
|
|
740
|
+
class JwtSettings:
|
|
741
|
+
"""The `AUTH_JWT_*` settings, validated. `problems` lists what makes them unusable."""
|
|
742
|
+
|
|
743
|
+
algorithms: tuple[str, ...] = ()
|
|
744
|
+
jwks_url: str | None = None
|
|
745
|
+
public_key: Any = None
|
|
746
|
+
public_key_type: tuple[str, str | None] = ("", None)
|
|
747
|
+
secret: bytes | None = None
|
|
748
|
+
issuer: str | None = None
|
|
749
|
+
audience: tuple[str, ...] = ()
|
|
750
|
+
principal_claim: str = "sub"
|
|
751
|
+
roles_claim: str = "roles"
|
|
752
|
+
# Delegation (RFC 8693): the actor claim ("" = not read, the 0.2 behaviour), the
|
|
753
|
+
# client claim, and the clients of human sign-in (empty = every token without an
|
|
754
|
+
# actor claim is direct).
|
|
755
|
+
actor_claim: str = DEFAULT_JWT_ACTOR_CLAIM
|
|
756
|
+
client_claim: str = DEFAULT_JWT_CLIENT_CLAIM
|
|
757
|
+
direct_clients: frozenset[str] = frozenset()
|
|
758
|
+
leeway_s: int = DEFAULT_LEEWAY_S
|
|
759
|
+
jwks_cache_s: int = DEFAULT_JWKS_CACHE_S
|
|
760
|
+
problems: list[str] = field(default_factory=list)
|
|
761
|
+
warnings: list[str] = field(default_factory=list)
|
|
762
|
+
|
|
763
|
+
@classmethod
|
|
764
|
+
def from_env(cls, env: Mapping[str, str] | None = None) -> JwtSettings:
|
|
765
|
+
env = os.environ if env is None else env
|
|
766
|
+
dev = env.get("APP_ENV") == "dev" # exactly, as dev_mode()
|
|
767
|
+
s = cls()
|
|
768
|
+
problems, warnings = s.problems, s.warnings
|
|
769
|
+
|
|
770
|
+
# Algorithms: an explicit allow-list; `none` never, HS* only when opted in.
|
|
771
|
+
algorithms: list[str] = []
|
|
772
|
+
for name in _csv(env.get("AUTH_JWT_ALGORITHMS") or DEFAULT_JWT_ALGORITHMS):
|
|
773
|
+
canonical = _canonical_algorithm(name)
|
|
774
|
+
if name.lower() == "none":
|
|
775
|
+
problems.append("AUTH_JWT_ALGORITHMS must never include 'none'")
|
|
776
|
+
elif canonical is None:
|
|
777
|
+
problems.append(f"AUTH_JWT_ALGORITHMS: unsupported algorithm {name!r}")
|
|
778
|
+
elif canonical not in algorithms:
|
|
779
|
+
algorithms.append(canonical)
|
|
780
|
+
if not algorithms and not problems:
|
|
781
|
+
problems.append("AUTH_JWT_ALGORITHMS lists no algorithm")
|
|
782
|
+
s.algorithms = tuple(algorithms)
|
|
783
|
+
|
|
784
|
+
hmac_algs = [a for a in algorithms if a in HMAC_ALGORITHMS]
|
|
785
|
+
if hmac_algs:
|
|
786
|
+
secret = env.get("AUTH_JWT_SECRET") or ""
|
|
787
|
+
if (env.get("AUTH_JWT_ALLOW_HS") or "").strip().lower() not in _TRUE:
|
|
788
|
+
problems.append(
|
|
789
|
+
f"AUTH_JWT_ALGORITHMS lists {', '.join(hmac_algs)}: shared-secret "
|
|
790
|
+
"algorithms need AUTH_JWT_ALLOW_HS=true and AUTH_JWT_SECRET"
|
|
791
|
+
)
|
|
792
|
+
elif not secret:
|
|
793
|
+
problems.append("AUTH_JWT_ALLOW_HS=true needs AUTH_JWT_SECRET")
|
|
794
|
+
elif "-----BEGIN" in secret:
|
|
795
|
+
problems.append("AUTH_JWT_SECRET must be a shared secret, not a PEM key")
|
|
796
|
+
elif len(secret.encode("utf-8")) < HMAC_MIN_SECRET_BYTES:
|
|
797
|
+
problems.append(
|
|
798
|
+
f"AUTH_JWT_SECRET must be at least {HMAC_MIN_SECRET_BYTES} bytes long"
|
|
799
|
+
)
|
|
800
|
+
else:
|
|
801
|
+
s.secret = secret.encode("utf-8")
|
|
802
|
+
|
|
803
|
+
# Keys for the asymmetric algorithms: a JWKS URL or one PEM public key.
|
|
804
|
+
asymmetric = [a for a in algorithms if a in ASYMMETRIC_ALGORITHMS]
|
|
805
|
+
jwks_url = (env.get("AUTH_JWT_JWKS_URL") or "").strip()
|
|
806
|
+
pem = (env.get("AUTH_JWT_PUBLIC_KEY") or "").strip()
|
|
807
|
+
if asymmetric:
|
|
808
|
+
if jwks_url and pem:
|
|
809
|
+
problems.append("set AUTH_JWT_JWKS_URL or AUTH_JWT_PUBLIC_KEY, not both")
|
|
810
|
+
elif not jwks_url and not pem:
|
|
811
|
+
problems.append(
|
|
812
|
+
"no verification key: set AUTH_JWT_JWKS_URL (the issuer's JWKS) "
|
|
813
|
+
"or AUTH_JWT_PUBLIC_KEY (a PEM public key)"
|
|
814
|
+
)
|
|
815
|
+
elif jwks_url:
|
|
816
|
+
parts = urlsplit(jwks_url)
|
|
817
|
+
host = (parts.hostname or "").lower()
|
|
818
|
+
insecure_ok = (
|
|
819
|
+
dev
|
|
820
|
+
or host in _LOOPBACK_HOSTS
|
|
821
|
+
or (env.get("AUTH_JWT_JWKS_ALLOW_HTTP") or "").strip().lower() in _TRUE
|
|
822
|
+
)
|
|
823
|
+
if parts.scheme not in ("https", "http") or not host:
|
|
824
|
+
problems.append("AUTH_JWT_JWKS_URL must be an https:// URL")
|
|
825
|
+
elif parts.username or parts.password:
|
|
826
|
+
problems.append("AUTH_JWT_JWKS_URL must not carry credentials")
|
|
827
|
+
elif parts.scheme == "http" and not insecure_ok:
|
|
828
|
+
problems.append(
|
|
829
|
+
"AUTH_JWT_JWKS_URL must use https outside APP_ENV=dev (keys fetched "
|
|
830
|
+
"over plain http can be replaced in transit); set "
|
|
831
|
+
"AUTH_JWT_JWKS_ALLOW_HTTP=true only for a trusted in-cluster issuer"
|
|
832
|
+
)
|
|
833
|
+
else:
|
|
834
|
+
s.jwks_url = jwks_url
|
|
835
|
+
else:
|
|
836
|
+
try:
|
|
837
|
+
key = _load_public_key(pem)
|
|
838
|
+
except Exception as exc: # never echo the key material
|
|
839
|
+
reason = (
|
|
840
|
+
str(exc) if isinstance(exc, ValueError) and "private" in str(exc) else ""
|
|
841
|
+
)
|
|
842
|
+
problems.append(
|
|
843
|
+
"AUTH_JWT_PUBLIC_KEY is not a PEM public key or certificate"
|
|
844
|
+
+ (f" ({reason})" if reason else "")
|
|
845
|
+
)
|
|
846
|
+
else:
|
|
847
|
+
kty, crv = _describe_public_key(key)
|
|
848
|
+
if not any(_key_matches_algorithm(a, kty, crv) for a in asymmetric):
|
|
849
|
+
problems.append(
|
|
850
|
+
f"AUTH_JWT_PUBLIC_KEY is a {kty}{' ' + crv if crv else ''} key, "
|
|
851
|
+
f"which verifies none of AUTH_JWT_ALGORITHMS ({', '.join(asymmetric)})"
|
|
852
|
+
)
|
|
853
|
+
else:
|
|
854
|
+
s.public_key, s.public_key_type = key, (kty, crv)
|
|
855
|
+
|
|
856
|
+
# Issuer and audience: required outside dev.
|
|
857
|
+
s.issuer = (env.get("AUTH_JWT_ISSUER") or "").strip() or None
|
|
858
|
+
s.audience = tuple(_csv(env.get("AUTH_JWT_AUDIENCE")))
|
|
859
|
+
for name, value in (("AUTH_JWT_ISSUER", s.issuer), ("AUTH_JWT_AUDIENCE", s.audience)):
|
|
860
|
+
if value:
|
|
861
|
+
continue
|
|
862
|
+
if dev:
|
|
863
|
+
warnings.append(
|
|
864
|
+
f"{name} is not set: it is not checked (allowed under APP_ENV=dev only)"
|
|
865
|
+
)
|
|
866
|
+
else:
|
|
867
|
+
problems.append(f"{name} is required outside APP_ENV=dev")
|
|
868
|
+
|
|
869
|
+
s.principal_claim = (env.get("AUTH_JWT_PRINCIPAL_CLAIM") or "sub").strip()
|
|
870
|
+
s.roles_claim = (env.get("AUTH_JWT_ROLES_CLAIM") or "roles").strip()
|
|
871
|
+
for name, claim in (
|
|
872
|
+
("AUTH_JWT_PRINCIPAL_CLAIM", s.principal_claim),
|
|
873
|
+
("AUTH_JWT_ROLES_CLAIM", s.roles_claim),
|
|
874
|
+
):
|
|
875
|
+
if not claim or any(not part for part in claim.split(".")):
|
|
876
|
+
problems.append(f"{name} must be a claim name or a dotted path, got {claim!r}")
|
|
877
|
+
|
|
878
|
+
# AUTH_JWT_ACTOR_CLAIM set to nothing turns delegation off (0.2); unset is `act`.
|
|
879
|
+
raw_actor = env.get("AUTH_JWT_ACTOR_CLAIM")
|
|
880
|
+
s.actor_claim = DEFAULT_JWT_ACTOR_CLAIM if raw_actor is None else raw_actor.strip()
|
|
881
|
+
s.client_claim = (env.get("AUTH_JWT_CLIENT_CLAIM") or DEFAULT_JWT_CLIENT_CLAIM).strip()
|
|
882
|
+
for name, claim in (
|
|
883
|
+
("AUTH_JWT_ACTOR_CLAIM", s.actor_claim),
|
|
884
|
+
("AUTH_JWT_CLIENT_CLAIM", s.client_claim),
|
|
885
|
+
):
|
|
886
|
+
if claim and any(not part for part in claim.split(".")):
|
|
887
|
+
problems.append(f"{name} must be a claim name or a dotted path, got {claim!r}")
|
|
888
|
+
if not s.client_claim:
|
|
889
|
+
problems.append("AUTH_JWT_CLIENT_CLAIM must be a claim name or a dotted path")
|
|
890
|
+
s.direct_clients = frozenset(_csv(env.get("AUTH_JWT_DIRECT_CLIENTS")))
|
|
891
|
+
|
|
892
|
+
s.leeway_s = _int_setting(env, "AUTH_JWT_LEEWAY_S", DEFAULT_LEEWAY_S, 0, 600, problems)
|
|
893
|
+
s.jwks_cache_s = _int_setting(
|
|
894
|
+
env, "AUTH_JWT_JWKS_CACHE_S", DEFAULT_JWKS_CACHE_S, 1, 86_400, problems
|
|
895
|
+
)
|
|
896
|
+
return s
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
class JwksUnavailable(Exception):
|
|
900
|
+
"""No usable key set: the JWKS URL did not answer and no recent keys are cached."""
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _usable_signing_jwk(jwk: Any) -> bool:
|
|
904
|
+
if not isinstance(jwk, dict) or jwk.get("kty") not in ("RSA", "EC", "OKP"):
|
|
905
|
+
return False # symmetric (`oct`) keys from a JWKS are never trusted
|
|
906
|
+
if jwk.get("use") not in (None, "sig"):
|
|
907
|
+
return False
|
|
908
|
+
ops = jwk.get("key_ops")
|
|
909
|
+
return ops is None or (isinstance(ops, list) and "verify" in ops)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
class JwksCache:
|
|
913
|
+
"""The issuer's signing keys, fetched from a JWKS URL and cached.
|
|
914
|
+
|
|
915
|
+
Keys are refetched when the cache (`ttl_s`) expires and when a token
|
|
916
|
+
names a key id the cache does not know (key rotation).
|
|
917
|
+
|
|
918
|
+
* Single flight: at most one fetch runs at a time; every request that
|
|
919
|
+
needs it waits for that fetch and shares its result (a rotated key is
|
|
920
|
+
fetched once however many requests carry it).
|
|
921
|
+
* Stale while revalidate: once the cache has expired, requests keep
|
|
922
|
+
verifying with the cached keys while a background fetch refreshes them,
|
|
923
|
+
so a slow or hanging issuer never stalls a request the cached keys can
|
|
924
|
+
verify. A failed refresh keeps the last good keys for `stale_grace_s`
|
|
925
|
+
more; after that requests wait for a fetch and get 503 when it fails.
|
|
926
|
+
* Bounded: fetch attempts, failed ones included, are at most one per
|
|
927
|
+
`refetch_interval_s` (a flood of tokens with random key ids cannot flood
|
|
928
|
+
the issuer), and each has a total deadline of twice `timeout_s`.
|
|
929
|
+
"""
|
|
930
|
+
|
|
931
|
+
def __init__(
|
|
932
|
+
self,
|
|
933
|
+
url: str,
|
|
934
|
+
ttl_s: float,
|
|
935
|
+
*,
|
|
936
|
+
timeout_s: float = JWKS_TIMEOUT_S,
|
|
937
|
+
refetch_interval_s: float = JWKS_REFETCH_INTERVAL_S,
|
|
938
|
+
stale_grace_s: float = JWKS_STALE_GRACE_S,
|
|
939
|
+
clock: Callable[[], float] = time.monotonic,
|
|
940
|
+
) -> None:
|
|
941
|
+
self.url = url
|
|
942
|
+
self.ttl_s = ttl_s
|
|
943
|
+
self.timeout_s = timeout_s
|
|
944
|
+
self.refetch_interval_s = min(refetch_interval_s, ttl_s)
|
|
945
|
+
self.stale_grace_s = stale_grace_s
|
|
946
|
+
self._clock = clock
|
|
947
|
+
self._keys: list[dict[str, Any]] = []
|
|
948
|
+
self._fetched_at: float | None = None
|
|
949
|
+
self._last_attempt: float | None = None
|
|
950
|
+
self._task: asyncio.Task[bool] | None = None
|
|
951
|
+
|
|
952
|
+
def _age(self, now: float) -> float | None:
|
|
953
|
+
return None if self._fetched_at is None else now - self._fetched_at
|
|
954
|
+
|
|
955
|
+
def _fresh(self, now: float) -> bool:
|
|
956
|
+
age = self._age(now)
|
|
957
|
+
return age is not None and age < self.ttl_s
|
|
958
|
+
|
|
959
|
+
def _usable(self, now: float) -> bool:
|
|
960
|
+
age = self._age(now)
|
|
961
|
+
return age is not None and age < self.ttl_s + self.stale_grace_s
|
|
962
|
+
|
|
963
|
+
def _may_attempt(self, now: float) -> bool:
|
|
964
|
+
return self._last_attempt is None or now - self._last_attempt >= self.refetch_interval_s
|
|
965
|
+
|
|
966
|
+
def _in_flight(self) -> asyncio.Task[bool] | None:
|
|
967
|
+
"""The fetch running on this event loop, if any."""
|
|
968
|
+
task = self._task
|
|
969
|
+
if task is None or task.done() or task.get_loop() is not asyncio.get_running_loop():
|
|
970
|
+
return None
|
|
971
|
+
return task
|
|
972
|
+
|
|
973
|
+
def _start_fetch(self) -> asyncio.Task[bool]:
|
|
974
|
+
# No await between the callers' checks and this: one fetch per loop.
|
|
975
|
+
previous_attempt, self._last_attempt = self._last_attempt, self._clock()
|
|
976
|
+
self._task = asyncio.get_running_loop().create_task(self._refresh(previous_attempt))
|
|
977
|
+
return self._task
|
|
978
|
+
|
|
979
|
+
@staticmethod
|
|
980
|
+
async def _join(task: asyncio.Task[bool]) -> bool:
|
|
981
|
+
# Shielded: a request that goes away (client disconnect) does not
|
|
982
|
+
# cancel the fetch other requests are waiting for.
|
|
983
|
+
return await asyncio.shield(task)
|
|
984
|
+
|
|
985
|
+
async def keys(self) -> list[dict[str, Any]]:
|
|
986
|
+
"""The current key set; raises `JwksUnavailable` when there is none to use."""
|
|
987
|
+
now = self._clock()
|
|
988
|
+
if self._fresh(now):
|
|
989
|
+
return self._keys
|
|
990
|
+
task = self._in_flight()
|
|
991
|
+
if task is None and self._may_attempt(now):
|
|
992
|
+
task = self._start_fetch()
|
|
993
|
+
if self._usable(now):
|
|
994
|
+
return self._keys # the refresh (if any) completes in the background
|
|
995
|
+
if task is not None:
|
|
996
|
+
await self._join(task)
|
|
997
|
+
if self._usable(self._clock()):
|
|
998
|
+
return self._keys
|
|
999
|
+
raise JwksUnavailable(self.url)
|
|
1000
|
+
|
|
1001
|
+
async def refresh_for_unknown_kid(self, kid: str | None = None) -> bool:
|
|
1002
|
+
"""Refetch because a token named an unknown key id `kid`.
|
|
1003
|
+
|
|
1004
|
+
True when the key set is worth looking at again: another request's
|
|
1005
|
+
fetch already brought `kid`, or the fetch this call started or joined
|
|
1006
|
+
succeeded. False when rate-limited or the fetch failed.
|
|
1007
|
+
"""
|
|
1008
|
+
if kid is not None and any(k.get("kid") == kid for k in self._keys):
|
|
1009
|
+
return True
|
|
1010
|
+
task = self._in_flight()
|
|
1011
|
+
if task is None:
|
|
1012
|
+
if not self._may_attempt(self._clock()):
|
|
1013
|
+
return False
|
|
1014
|
+
task = self._start_fetch()
|
|
1015
|
+
return await self._join(task)
|
|
1016
|
+
|
|
1017
|
+
async def idle(self) -> None:
|
|
1018
|
+
"""Wait until no fetch is running (for tests and orderly shutdown)."""
|
|
1019
|
+
task = self._in_flight()
|
|
1020
|
+
if task is not None:
|
|
1021
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
1022
|
+
|
|
1023
|
+
async def _refresh(self, previous_attempt: float | None) -> bool:
|
|
1024
|
+
try:
|
|
1025
|
+
# A total deadline too: the per-read timeout alone lets a server that
|
|
1026
|
+
# trickles bytes keep a fetch (and the requests waiting on it) going.
|
|
1027
|
+
keys = await asyncio.wait_for(self._fetch(), timeout=2 * self.timeout_s)
|
|
1028
|
+
except asyncio.CancelledError:
|
|
1029
|
+
# Stopped from outside (the event loop is shutting down), not the
|
|
1030
|
+
# issuer's fault: the next request may try again at once.
|
|
1031
|
+
self._last_attempt = previous_attempt
|
|
1032
|
+
raise
|
|
1033
|
+
except Exception as exc:
|
|
1034
|
+
logger.warning(
|
|
1035
|
+
"jwt: could not fetch the JWKS from %s (%s); %s",
|
|
1036
|
+
self.url,
|
|
1037
|
+
type(exc).__name__,
|
|
1038
|
+
"keeping the previous keys for now"
|
|
1039
|
+
if self._usable(self._clock())
|
|
1040
|
+
else "no signing keys are available",
|
|
1041
|
+
)
|
|
1042
|
+
return False
|
|
1043
|
+
self._keys, self._fetched_at = keys, self._clock()
|
|
1044
|
+
return True
|
|
1045
|
+
|
|
1046
|
+
async def _fetch(self) -> list[dict[str, Any]]:
|
|
1047
|
+
import httpx
|
|
1048
|
+
|
|
1049
|
+
body = bytearray()
|
|
1050
|
+
async with httpx.AsyncClient(timeout=self.timeout_s, follow_redirects=False) as client:
|
|
1051
|
+
async with client.stream(
|
|
1052
|
+
"GET", self.url, headers={"Accept": "application/json"}
|
|
1053
|
+
) as response:
|
|
1054
|
+
if response.status_code != 200:
|
|
1055
|
+
raise ValueError(f"HTTP {response.status_code}")
|
|
1056
|
+
async for chunk in response.aiter_bytes():
|
|
1057
|
+
body += chunk
|
|
1058
|
+
if len(body) > JWKS_MAX_BYTES:
|
|
1059
|
+
raise ValueError("JWKS response too large")
|
|
1060
|
+
data = json.loads(bytes(body))
|
|
1061
|
+
if not isinstance(data, dict) or not isinstance(data.get("keys"), list):
|
|
1062
|
+
raise ValueError("not a JWK set")
|
|
1063
|
+
keys = [k for k in data["keys"] if _usable_signing_jwk(k)]
|
|
1064
|
+
if not keys:
|
|
1065
|
+
raise ValueError("the JWK set has no usable signing keys")
|
|
1066
|
+
return keys
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _claim(claims: Mapping[str, Any], path: str) -> Any:
|
|
1070
|
+
"""A claim by name, or by dotted path (`realm_access.roles`); None when absent.
|
|
1071
|
+
|
|
1072
|
+
A top-level claim whose name itself contains dots (for example a
|
|
1073
|
+
namespaced `https://example.com/roles`) wins over the dotted reading.
|
|
1074
|
+
"""
|
|
1075
|
+
if path in claims:
|
|
1076
|
+
return claims[path]
|
|
1077
|
+
current: Any = claims
|
|
1078
|
+
for part in path.split("."):
|
|
1079
|
+
if not isinstance(current, Mapping) or part not in current:
|
|
1080
|
+
return None
|
|
1081
|
+
current = current[part]
|
|
1082
|
+
return current
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
_ABSENT = object()
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _claim_or_absent(claims: Mapping[str, Any], path: str) -> Any:
|
|
1089
|
+
"""As `_claim`, but `_ABSENT` for a claim the token does not have (a null one is present)."""
|
|
1090
|
+
if path in claims:
|
|
1091
|
+
return claims[path]
|
|
1092
|
+
current: Any = claims
|
|
1093
|
+
for part in path.split("."):
|
|
1094
|
+
if not isinstance(current, Mapping) or part not in current:
|
|
1095
|
+
return _ABSENT
|
|
1096
|
+
current = current[part]
|
|
1097
|
+
return current
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _actor_chain(value: Any) -> tuple[str, ...]:
|
|
1101
|
+
"""The actors of an RFC 8693 `act` claim, current first; 401 for a malformed one.
|
|
1102
|
+
|
|
1103
|
+
Each level is a mapping with a string `sub`, and may nest the earlier actor
|
|
1104
|
+
under `act`. A chain longer than `MAX_DELEGATION_DEPTH` is refused while it
|
|
1105
|
+
is walked.
|
|
1106
|
+
"""
|
|
1107
|
+
chain: list[str] = []
|
|
1108
|
+
node = value
|
|
1109
|
+
while True:
|
|
1110
|
+
if not isinstance(node, Mapping):
|
|
1111
|
+
raise _invalid_token("invalid actor claim")
|
|
1112
|
+
sub = node.get("sub")
|
|
1113
|
+
if not _valid_id(sub):
|
|
1114
|
+
raise _invalid_token("invalid actor claim")
|
|
1115
|
+
chain.append(sub)
|
|
1116
|
+
if len(chain) > MAX_DELEGATION_DEPTH:
|
|
1117
|
+
raise _invalid_token("invalid actor claim")
|
|
1118
|
+
if NESTED_ACTOR_CLAIM not in node:
|
|
1119
|
+
return tuple(chain)
|
|
1120
|
+
node = node[NESTED_ACTOR_CLAIM]
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def actor_from_claims(
|
|
1124
|
+
claims: Mapping[str, Any],
|
|
1125
|
+
*,
|
|
1126
|
+
actor_claim: str = DEFAULT_JWT_ACTOR_CLAIM,
|
|
1127
|
+
client_claim: str = DEFAULT_JWT_CLIENT_CLAIM,
|
|
1128
|
+
direct_clients: frozenset[str] | set[str] = frozenset(),
|
|
1129
|
+
subject: str | None = None,
|
|
1130
|
+
) -> Actor | None:
|
|
1131
|
+
"""The actor of a verified token's claims (None: a direct request), as `jwt` reads it.
|
|
1132
|
+
|
|
1133
|
+
* `actor_claim` present (RFC 8693 `act`, default): delegated. It must be a
|
|
1134
|
+
mapping with a string `sub`, each nested `act` likewise; anything else
|
|
1135
|
+
is refused (401 `invalid actor claim`), never read as a direct request.
|
|
1136
|
+
* absent: direct, unless `direct_clients` is set and the token's client
|
|
1137
|
+
(`client_claim`, default `azp`, else `client_id`) is not in it: then an
|
|
1138
|
+
agent presents it, as `client:<client>` (`client:?` without one). A
|
|
1139
|
+
token whose `subject` is its own client (a service's own token) is direct.
|
|
1140
|
+
* `actor_claim` empty: direct (the 0.2 reading).
|
|
1141
|
+
|
|
1142
|
+
`may_act`, `scope` and other claims are not read. For custom policies that
|
|
1143
|
+
verify tokens themselves; `finalize_principal` checks the rest.
|
|
1144
|
+
"""
|
|
1145
|
+
raw_client = _claim(claims, client_claim) if client_claim else None
|
|
1146
|
+
if raw_client is None:
|
|
1147
|
+
raw_client = claims.get(FALLBACK_JWT_CLIENT_CLAIM)
|
|
1148
|
+
client = raw_client if _valid_id(raw_client) else None
|
|
1149
|
+
if actor_claim:
|
|
1150
|
+
value = _claim_or_absent(claims, actor_claim)
|
|
1151
|
+
if value is not _ABSENT:
|
|
1152
|
+
chain = _actor_chain(value)
|
|
1153
|
+
return Actor(id=chain[0], chain=chain, client=client)
|
|
1154
|
+
if not direct_clients or client in direct_clients:
|
|
1155
|
+
return None
|
|
1156
|
+
if subject is not None and client is not None and subject == client:
|
|
1157
|
+
return None
|
|
1158
|
+
actor_id = f"{CLIENT_ACTOR_PREFIX}{client or UNKNOWN_CLIENT}"
|
|
1159
|
+
return Actor(id=actor_id, chain=(actor_id,), client=client)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _roles_from_claim(value: Any) -> list[str]:
|
|
1163
|
+
"""A list of strings, or one space/comma separated string -> distinct role names."""
|
|
1164
|
+
if isinstance(value, str):
|
|
1165
|
+
candidates: list[Any] = re.split(r"[\s,]+", value)
|
|
1166
|
+
elif isinstance(value, list | tuple):
|
|
1167
|
+
candidates = list(value)
|
|
1168
|
+
else:
|
|
1169
|
+
return []
|
|
1170
|
+
roles: list[str] = []
|
|
1171
|
+
for item in candidates:
|
|
1172
|
+
if not isinstance(item, str):
|
|
1173
|
+
continue
|
|
1174
|
+
role = item.strip()
|
|
1175
|
+
if (
|
|
1176
|
+
role
|
|
1177
|
+
and role not in roles
|
|
1178
|
+
and len(role) <= PRINCIPAL_ID_MAX_CHARS
|
|
1179
|
+
and "," not in role
|
|
1180
|
+
and not _CONTROL_CHARS.search(role)
|
|
1181
|
+
):
|
|
1182
|
+
roles.append(role)
|
|
1183
|
+
if len(roles) >= MAX_ROLES:
|
|
1184
|
+
break
|
|
1185
|
+
return roles
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _invalid_token(reason: str) -> HTTPException:
|
|
1189
|
+
"""401 with the RFC 6750 challenge. `reason` is a fixed phrase, never token content."""
|
|
1190
|
+
return HTTPException(
|
|
1191
|
+
status_code=401,
|
|
1192
|
+
detail=f"Invalid bearer token: {reason}.",
|
|
1193
|
+
headers={"WWW-Authenticate": f'Bearer error="invalid_token", error_description="{reason}"'},
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _bearer_token(request: Request) -> str:
|
|
1198
|
+
scheme, _, token = (request.headers.get("authorization") or "").partition(" ")
|
|
1199
|
+
token = token.strip()
|
|
1200
|
+
if scheme.lower() != "bearer" or not token:
|
|
1201
|
+
raise HTTPException(
|
|
1202
|
+
status_code=401,
|
|
1203
|
+
detail="Missing bearer token.",
|
|
1204
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
1205
|
+
)
|
|
1206
|
+
return token
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
JWT_NOT_CONFIGURED = (
|
|
1210
|
+
"AUTH_POLICY=jwt is not configured on the server; every request is refused "
|
|
1211
|
+
"until it is (see the server log)."
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
class JwtPolicy:
|
|
1216
|
+
"""Per-user principals from a verified OIDC/JWT bearer token.
|
|
1217
|
+
|
|
1218
|
+
The principal id is the `AUTH_JWT_PRINCIPAL_CLAIM` claim (default `sub`)
|
|
1219
|
+
and the roles come from `AUTH_JWT_ROLES_CLAIM` (default `roles`; a
|
|
1220
|
+
dotted path, a list or a space/comma separated string). The actor, for a
|
|
1221
|
+
token an agent presents for the user, is read from the RFC 8693 actor
|
|
1222
|
+
claim (`AUTH_JWT_ACTOR_CLAIM`, default `act`; see `actor_from_claims`). Every action is
|
|
1223
|
+
allowed to an authenticated principal; conversation ownership is enforced
|
|
1224
|
+
per thread (`app_utils.threads`, the LangGraph Server owner filters).
|
|
1225
|
+
Nothing from the token is logged or put in an error message.
|
|
1226
|
+
"""
|
|
1227
|
+
|
|
1228
|
+
def __init__(self, settings: JwtSettings | None = None) -> None:
|
|
1229
|
+
self.settings = settings if settings is not None else JwtSettings.from_env()
|
|
1230
|
+
s = self.settings
|
|
1231
|
+
self.jwks = JwksCache(s.jwks_url, s.jwks_cache_s) if s.jwks_url and not s.problems else None
|
|
1232
|
+
for warning in s.warnings:
|
|
1233
|
+
logger.warning("AUTH_POLICY=jwt: %s", warning)
|
|
1234
|
+
if s.problems:
|
|
1235
|
+
logger.error(
|
|
1236
|
+
"AUTH_POLICY=jwt is misconfigured: %s. Every request is refused (503) until "
|
|
1237
|
+
"it is fixed.",
|
|
1238
|
+
"; ".join(s.problems),
|
|
1239
|
+
)
|
|
1240
|
+
elif s.actor_claim and _csv(os.environ.get("AUTH_ALLOWED_ACTORS")) and not s.direct_clients:
|
|
1241
|
+
logger.warning(
|
|
1242
|
+
"AUTH_POLICY=jwt: delegation is recognised only by the %s claim; if your "
|
|
1243
|
+
"issuer's exchanged tokens carry none, set AUTH_JWT_DIRECT_CLIENTS",
|
|
1244
|
+
s.actor_claim,
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
def startup_problems(self) -> list[str]:
|
|
1248
|
+
return list(self.settings.problems)
|
|
1249
|
+
|
|
1250
|
+
async def authenticate(self, request: Request) -> Principal:
|
|
1251
|
+
if self.settings.problems:
|
|
1252
|
+
raise HTTPException(status_code=503, detail=JWT_NOT_CONFIGURED)
|
|
1253
|
+
token = _bearer_token(request)
|
|
1254
|
+
claims = await self.verify(token)
|
|
1255
|
+
principal = self.principal_from_claims(claims)
|
|
1256
|
+
if subject_token_needed():
|
|
1257
|
+
# An API acts with the user's own token (exchanged, or forwarded to its audience).
|
|
1258
|
+
keep_subject_token(principal, token, claims.get("aud"), claims.get("exp"))
|
|
1259
|
+
return principal
|
|
1260
|
+
|
|
1261
|
+
async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
|
|
1262
|
+
if action not in ACTIONS:
|
|
1263
|
+
raise HTTPException(status_code=403, detail=f"Unknown action {action!r}.")
|
|
1264
|
+
|
|
1265
|
+
async def verify(self, token: str) -> dict[str, Any]:
|
|
1266
|
+
"""The token's claims after every check; raises 401 (or 503 when keys are unavailable)."""
|
|
1267
|
+
import jwt
|
|
1268
|
+
|
|
1269
|
+
s = self.settings
|
|
1270
|
+
if len(token) > JWT_MAX_TOKEN_CHARS:
|
|
1271
|
+
raise _invalid_token("token too large")
|
|
1272
|
+
try:
|
|
1273
|
+
header = jwt.get_unverified_header(token)
|
|
1274
|
+
except (jwt.PyJWTError, ValueError, TypeError):
|
|
1275
|
+
raise _invalid_token("malformed token") from None
|
|
1276
|
+
algorithm = header.get("alg")
|
|
1277
|
+
if not isinstance(algorithm, str) or algorithm not in s.algorithms:
|
|
1278
|
+
raise _invalid_token("signing algorithm not allowed")
|
|
1279
|
+
if "crit" in header:
|
|
1280
|
+
raise _invalid_token("unsupported critical header")
|
|
1281
|
+
kid = header.get("kid")
|
|
1282
|
+
if kid is not None and not isinstance(kid, str):
|
|
1283
|
+
raise _invalid_token("malformed token")
|
|
1284
|
+
key = await self._key_for(algorithm, kid)
|
|
1285
|
+
try:
|
|
1286
|
+
claims = jwt.decode(
|
|
1287
|
+
token,
|
|
1288
|
+
key=key,
|
|
1289
|
+
algorithms=[algorithm],
|
|
1290
|
+
audience=list(s.audience) or None,
|
|
1291
|
+
issuer=s.issuer,
|
|
1292
|
+
leeway=s.leeway_s,
|
|
1293
|
+
options={
|
|
1294
|
+
"require": ["exp"],
|
|
1295
|
+
"verify_aud": bool(s.audience),
|
|
1296
|
+
"verify_iss": bool(s.issuer),
|
|
1297
|
+
},
|
|
1298
|
+
)
|
|
1299
|
+
except jwt.ExpiredSignatureError:
|
|
1300
|
+
raise _invalid_token("token expired") from None
|
|
1301
|
+
except jwt.ImmatureSignatureError:
|
|
1302
|
+
raise _invalid_token("token not yet valid") from None
|
|
1303
|
+
except jwt.InvalidAudienceError:
|
|
1304
|
+
raise _invalid_token("wrong audience") from None
|
|
1305
|
+
except jwt.InvalidIssuerError:
|
|
1306
|
+
raise _invalid_token("wrong issuer") from None
|
|
1307
|
+
except jwt.MissingRequiredClaimError:
|
|
1308
|
+
raise _invalid_token("missing required claim") from None
|
|
1309
|
+
except jwt.InvalidSignatureError:
|
|
1310
|
+
raise _invalid_token("invalid signature") from None
|
|
1311
|
+
except (jwt.PyJWTError, ValueError, TypeError, OverflowError):
|
|
1312
|
+
raise _invalid_token("invalid token") from None
|
|
1313
|
+
if not isinstance(claims, dict):
|
|
1314
|
+
raise _invalid_token("invalid token")
|
|
1315
|
+
return claims
|
|
1316
|
+
|
|
1317
|
+
async def _key_for(self, algorithm: str, kid: str | None) -> Any:
|
|
1318
|
+
s = self.settings
|
|
1319
|
+
if algorithm in HMAC_ALGORITHMS:
|
|
1320
|
+
return s.secret
|
|
1321
|
+
if s.public_key is not None:
|
|
1322
|
+
if not _key_matches_algorithm(algorithm, *s.public_key_type):
|
|
1323
|
+
raise _invalid_token("signing algorithm does not match the key")
|
|
1324
|
+
return s.public_key
|
|
1325
|
+
if self.jwks is None: # unreachable with valid settings; fail closed anyway
|
|
1326
|
+
raise HTTPException(status_code=503, detail=JWT_NOT_CONFIGURED)
|
|
1327
|
+
keys = await self._jwks_keys()
|
|
1328
|
+
jwk = self._select(keys, algorithm, kid)
|
|
1329
|
+
if jwk is None and kid is not None and all(k.get("kid") != kid for k in keys):
|
|
1330
|
+
# Possibly a rotated key: refetch (one fetch shared by every request
|
|
1331
|
+
# carrying it, rate-limited) and look again.
|
|
1332
|
+
if await self.jwks.refresh_for_unknown_kid(kid):
|
|
1333
|
+
keys = await self._jwks_keys()
|
|
1334
|
+
jwk = self._select(keys, algorithm, kid)
|
|
1335
|
+
if jwk is None:
|
|
1336
|
+
raise _invalid_token("unknown signing key")
|
|
1337
|
+
from jwt.algorithms import get_default_algorithms
|
|
1338
|
+
|
|
1339
|
+
try:
|
|
1340
|
+
return get_default_algorithms()[algorithm].from_jwk(jwk)
|
|
1341
|
+
except Exception:
|
|
1342
|
+
raise _invalid_token("unusable signing key") from None
|
|
1343
|
+
|
|
1344
|
+
async def _jwks_keys(self) -> list[dict[str, Any]]:
|
|
1345
|
+
assert self.jwks is not None
|
|
1346
|
+
try:
|
|
1347
|
+
return await self.jwks.keys()
|
|
1348
|
+
except JwksUnavailable:
|
|
1349
|
+
raise HTTPException(
|
|
1350
|
+
status_code=503,
|
|
1351
|
+
detail="The token issuer's signing keys are unavailable; try again later.",
|
|
1352
|
+
) from None
|
|
1353
|
+
|
|
1354
|
+
@staticmethod
|
|
1355
|
+
def _select(
|
|
1356
|
+
keys: list[dict[str, Any]], algorithm: str, kid: str | None
|
|
1357
|
+
) -> dict[str, Any] | None:
|
|
1358
|
+
matching = [
|
|
1359
|
+
k
|
|
1360
|
+
for k in keys
|
|
1361
|
+
if _key_matches_algorithm(algorithm, k.get("kty"), k.get("crv"), k.get("alg"))
|
|
1362
|
+
]
|
|
1363
|
+
if kid is not None:
|
|
1364
|
+
matching = [k for k in matching if k.get("kid") == kid]
|
|
1365
|
+
return matching[0] if matching else None
|
|
1366
|
+
# No key id: only unambiguous when exactly one key fits the algorithm.
|
|
1367
|
+
return matching[0] if len(matching) == 1 else None
|
|
1368
|
+
|
|
1369
|
+
def principal_from_claims(self, claims: Mapping[str, Any]) -> Principal:
|
|
1370
|
+
s = self.settings
|
|
1371
|
+
raw = _claim(claims, s.principal_claim)
|
|
1372
|
+
if isinstance(raw, bool) or not isinstance(raw, str | int):
|
|
1373
|
+
raise _invalid_token("missing principal claim")
|
|
1374
|
+
principal_id = str(raw).strip()
|
|
1375
|
+
if (
|
|
1376
|
+
not principal_id
|
|
1377
|
+
or len(principal_id) > PRINCIPAL_ID_MAX_CHARS
|
|
1378
|
+
or _CONTROL_CHARS.search(principal_id)
|
|
1379
|
+
):
|
|
1380
|
+
raise _invalid_token("invalid principal claim")
|
|
1381
|
+
return Principal(
|
|
1382
|
+
id=principal_id,
|
|
1383
|
+
roles=_roles_from_claim(_claim(claims, s.roles_claim)),
|
|
1384
|
+
permissions=set(ACTIONS),
|
|
1385
|
+
actor=actor_from_claims(
|
|
1386
|
+
claims,
|
|
1387
|
+
actor_claim=s.actor_claim,
|
|
1388
|
+
client_claim=s.client_claim,
|
|
1389
|
+
direct_clients=s.direct_clients,
|
|
1390
|
+
subject=principal_id,
|
|
1391
|
+
),
|
|
1392
|
+
)
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
# ---------------------------------------------------------------------------
|
|
1396
|
+
# Policy selection
|
|
1397
|
+
# ---------------------------------------------------------------------------
|
|
1398
|
+
|
|
1399
|
+
_policies: dict[str, AuthPolicy] = {}
|
|
1400
|
+
_warned_aliases: set[str] = set()
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def policy_name() -> str:
|
|
1404
|
+
"""The selected policy; a retired name is read as its replacement (warned once)."""
|
|
1405
|
+
name = (os.environ.get("AUTH_POLICY") or DEFAULT_POLICY).strip().lower()
|
|
1406
|
+
alias = LEGACY_ALIASES.get(name)
|
|
1407
|
+
if alias is None:
|
|
1408
|
+
return name
|
|
1409
|
+
if name not in _warned_aliases:
|
|
1410
|
+
_warned_aliases.add(name)
|
|
1411
|
+
logger.warning(
|
|
1412
|
+
"AUTH_POLICY=%s is deprecated and read as %s; set AUTH_POLICY=%s.", name, alias, alias
|
|
1413
|
+
)
|
|
1414
|
+
return alias
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def get_policy() -> AuthPolicy:
|
|
1418
|
+
"""The policy instance for `AUTH_POLICY` (built once per name)."""
|
|
1419
|
+
name = policy_name()
|
|
1420
|
+
policy = _policies.get(name)
|
|
1421
|
+
if policy is None:
|
|
1422
|
+
from {{cookiecutter.agent_directory}}.policies import build_policy
|
|
1423
|
+
|
|
1424
|
+
policy = build_policy(name)
|
|
1425
|
+
_policies[name] = policy
|
|
1426
|
+
return policy
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def reset_policy_cache() -> None:
|
|
1430
|
+
"""For tests that switch `AUTH_POLICY` (or its settings)."""
|
|
1431
|
+
_policies.clear()
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def check_startup() -> AuthPolicy:
|
|
1435
|
+
"""Build the selected policy and refuse to start on a configuration that cannot work.
|
|
1436
|
+
|
|
1437
|
+
An unknown `AUTH_POLICY` raises in every environment. A policy may expose
|
|
1438
|
+
`startup_problems() -> list[str]`; any problem raises outside `APP_ENV=dev`
|
|
1439
|
+
(under dev it is logged and requests get 503 until it is fixed). So do the
|
|
1440
|
+
APIs of api-policy.yaml that act with the caller's identity where they
|
|
1441
|
+
cannot (`token_exchange.startup_problems`: an `auth: exchange` API under
|
|
1442
|
+
shared-bearer or langgraph-server, or without `TOKEN_EXCHANGE_URL`,
|
|
1443
|
+
`TOKEN_EXCHANGE_CLIENT_ID` and `TOKEN_EXCHANGE_CLIENT_SECRET`; an
|
|
1444
|
+
`auth: forward` API under shared-bearer, under jwt without
|
|
1445
|
+
`forward_audience`, or under langgraph-server; under dev they are logged,
|
|
1446
|
+
the app starts and the calls to those APIs fail).
|
|
1447
|
+
"""
|
|
1448
|
+
policy = get_policy()
|
|
1449
|
+
probe = getattr(policy, "startup_problems", None)
|
|
1450
|
+
problems = list(probe()) if callable(probe) else []
|
|
1451
|
+
from {{cookiecutter.agent_directory}}.app_utils.token_exchange import (
|
|
1452
|
+
startup_problems as propagation_problems,
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
propagation = propagation_problems(policy_name())
|
|
1456
|
+
if dev_mode():
|
|
1457
|
+
for problem in propagation:
|
|
1458
|
+
logger.error("api-policy: %s (APP_ENV=dev: starting anyway)", problem)
|
|
1459
|
+
return policy
|
|
1460
|
+
if problems:
|
|
1461
|
+
raise RuntimeError(
|
|
1462
|
+
f"AUTH_POLICY={policy_name()} is misconfigured, refusing to start: "
|
|
1463
|
+
+ "; ".join(problems)
|
|
1464
|
+
)
|
|
1465
|
+
if propagation:
|
|
1466
|
+
raise RuntimeError(
|
|
1467
|
+
"api-policy.yaml cannot work with this configuration, refusing to start: "
|
|
1468
|
+
+ "; ".join(propagation)
|
|
1469
|
+
)
|
|
1470
|
+
return policy
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def _as_http_exception(exc: Exception) -> HTTPException:
|
|
1474
|
+
if isinstance(exc, HTTPException):
|
|
1475
|
+
return exc
|
|
1476
|
+
if isinstance(exc, NotImplementedError):
|
|
1477
|
+
return HTTPException(
|
|
1478
|
+
status_code=503,
|
|
1479
|
+
detail=str(exc) or "The configured AUTH_POLICY is not implemented.",
|
|
1480
|
+
)
|
|
1481
|
+
raise exc
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def require(action: str) -> Callable[[Request], Awaitable[Principal]]:
|
|
1485
|
+
"""FastAPI dependency: authenticate, then authorize `action` on the route's thread.
|
|
1486
|
+
|
|
1487
|
+
Usage: ``principal: Principal = Depends(require("chat.send"))``.
|
|
1488
|
+
"""
|
|
1489
|
+
if action not in ACTIONS:
|
|
1490
|
+
raise ValueError(f"Unknown action {action!r}; expected one of {sorted(ACTIONS)}")
|
|
1491
|
+
|
|
1492
|
+
async def dependency(request: Request) -> Principal:
|
|
1493
|
+
policy = get_policy()
|
|
1494
|
+
try:
|
|
1495
|
+
principal = finalize_principal(await policy.authenticate(request))
|
|
1496
|
+
await policy.authorize(principal, action, request.path_params.get("thread_id"))
|
|
1497
|
+
except (HTTPException, NotImplementedError) as exc:
|
|
1498
|
+
raise _as_http_exception(exc) from exc
|
|
1499
|
+
request.state.principal = principal
|
|
1500
|
+
return principal
|
|
1501
|
+
|
|
1502
|
+
dependency.__name__ = f"require_{action.replace('.', '_')}"
|
|
1503
|
+
return dependency
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
async def authorize_action(principal: Principal, action: str, resource: str | None = None) -> None:
|
|
1507
|
+
"""Authorize `action` for an authenticated principal (as `require`: 403, or 503).
|
|
1508
|
+
|
|
1509
|
+
For decisions taken outside a route of their own, such as an approval
|
|
1510
|
+
decided over A2A: the endpoint authorized `a2a.invoke`, the decision
|
|
1511
|
+
needs `approval.decide` as well.
|
|
1512
|
+
"""
|
|
1513
|
+
if action not in ACTIONS:
|
|
1514
|
+
raise ValueError(f"Unknown action {action!r}; expected one of {sorted(ACTIONS)}")
|
|
1515
|
+
try:
|
|
1516
|
+
await get_policy().authorize(principal, action, resource)
|
|
1517
|
+
except (HTTPException, NotImplementedError) as exc:
|
|
1518
|
+
raise _as_http_exception(exc) from exc
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
async def authenticate_and_authorize(
|
|
1522
|
+
request: Request, action: str, resource: str | None = None
|
|
1523
|
+
) -> Principal:
|
|
1524
|
+
"""Same check as `require`, for code paths outside FastAPI's dependency system."""
|
|
1525
|
+
policy = get_policy()
|
|
1526
|
+
try:
|
|
1527
|
+
principal = finalize_principal(await policy.authenticate(request))
|
|
1528
|
+
await policy.authorize(principal, action, resource)
|
|
1529
|
+
except (HTTPException, NotImplementedError) as exc:
|
|
1530
|
+
raise _as_http_exception(exc) from exc
|
|
1531
|
+
return principal
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
# ---------------------------------------------------------------------------
|
|
1535
|
+
# LangGraph Server auth handler (langgraph.json "auth": {"path": ".../auth.py:auth"})
|
|
1536
|
+
# ---------------------------------------------------------------------------
|
|
1537
|
+
|
|
1538
|
+
ROLE_PERMISSION_PREFIX = "role:"
|
|
1539
|
+
# The delegated caller's actor, carried beside the roles in the server's user
|
|
1540
|
+
# permissions (`actor:<id>`; none for a direct caller).
|
|
1541
|
+
ACTOR_PERMISSION_PREFIX = "actor:"
|
|
1542
|
+
# The key of the server's user dict holding the caller's run context (`run_context_of`),
|
|
1543
|
+
# which the handlers put on every native run.
|
|
1544
|
+
RUN_CONTEXT_USER_KEY = "run_context"
|
|
1545
|
+
|
|
1546
|
+
# Where `build_sdk_auth` leaves the policy's 401 challenge (`WWW-Authenticate`)
|
|
1547
|
+
# in the request's ASGI state: LangGraph Server drops the headers of an auth
|
|
1548
|
+
# error, and `middleware.AuthErrorMiddleware` puts them back.
|
|
1549
|
+
AUTH_CHALLENGE_STATE_KEY = "auth_challenge"
|
|
1550
|
+
|
|
1551
|
+
# The native API's thread copy (`POST /threads/{thread_id}/copy`).
|
|
1552
|
+
_THREAD_COPY_PATH = re.compile(r"(?:^|/)threads/[^/]+/copy/?$")
|
|
1553
|
+
# Whether the request being authorized is a thread copy. Set by the
|
|
1554
|
+
# authenticate handler for every request it sees (the resource handlers run
|
|
1555
|
+
# later in the same request, in the same context).
|
|
1556
|
+
_THREAD_COPY: ContextVar[bool] = ContextVar("thread_copy", default=False)
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
# Run settings that start a run from a given checkpoint (a replay) instead of the latest.
|
|
1560
|
+
_CHECKPOINT_KEYS = ("checkpoint_id", "checkpoint", "checkpoint_ns", "checkpoint_map")
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _replays(kwargs: Any) -> bool:
|
|
1564
|
+
"""Whether a native run re-runs a thread's pending step: it has no input, or
|
|
1565
|
+
starts from a checkpoint (in `config.configurable` or `context`, where the
|
|
1566
|
+
server puts `checkpoint_id` and `checkpoint`)."""
|
|
1567
|
+
if not isinstance(kwargs, Mapping):
|
|
1568
|
+
return True
|
|
1569
|
+
if kwargs.get("input") is None:
|
|
1570
|
+
return True
|
|
1571
|
+
config = kwargs.get("config")
|
|
1572
|
+
configurable = config.get("configurable") if isinstance(config, Mapping) else None
|
|
1573
|
+
for settings in (configurable, kwargs.get("context")):
|
|
1574
|
+
if isinstance(settings, Mapping) and any(
|
|
1575
|
+
settings.get(key) not in (None, "") for key in _CHECKPOINT_KEYS
|
|
1576
|
+
):
|
|
1577
|
+
return True
|
|
1578
|
+
return False
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _is_thread_copy(request: Any) -> bool:
|
|
1582
|
+
scope = getattr(request, "scope", None) or {}
|
|
1583
|
+
path = scope.get("path") or ""
|
|
1584
|
+
return scope.get("method") == "POST" and bool(_THREAD_COPY_PATH.search(path))
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def _stash_challenge(request: Any, headers: Mapping[str, str] | None) -> None:
|
|
1588
|
+
"""Keep a 401's `WWW-Authenticate` in the request state (see `AuthErrorMiddleware`)."""
|
|
1589
|
+
challenge = next(
|
|
1590
|
+
(v for k, v in (headers or {}).items() if k.lower() == "www-authenticate"), None
|
|
1591
|
+
)
|
|
1592
|
+
scope = getattr(request, "scope", None)
|
|
1593
|
+
if challenge and isinstance(scope, dict):
|
|
1594
|
+
state = scope.setdefault("state", {})
|
|
1595
|
+
if isinstance(state, dict):
|
|
1596
|
+
state[AUTH_CHALLENGE_STATE_KEY] = challenge
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
def build_sdk_auth() -> Any:
|
|
1600
|
+
"""A `langgraph_sdk.Auth` whose handlers delegate to the selected policy.
|
|
1601
|
+
|
|
1602
|
+
`@auth.authenticate` runs the policy's `authenticate`. Resource rules for
|
|
1603
|
+
the server's native API:
|
|
1604
|
+
|
|
1605
|
+
* threads (and their runs): `{principal_id, tenant}` is written into the
|
|
1606
|
+
thread metadata at creation (the caller's id; `tenant` None, since a
|
|
1607
|
+
native API caller cannot vouch for one) and every read, search, update,
|
|
1608
|
+
delete and run is filtered to the caller's own threads. A role listed in
|
|
1609
|
+
`AUTH_READ_ACROSS_ROLES` relaxes only the read and search filters
|
|
1610
|
+
(read-across roles may *read* others' threads, never change them), and
|
|
1611
|
+
an update can never change a thread's `principal_id` or `tenant`.
|
|
1612
|
+
A delegated caller (an agent acting for the user: `actor:<id>` in its
|
|
1613
|
+
permissions) also stamps `actor` and is filtered to the threads of its
|
|
1614
|
+
own subject and actor; the filters are containment filters, so the
|
|
1615
|
+
subject's own (direct) filter matches the threads its agents started
|
|
1616
|
+
for it. Nobody stamps, adopts or strips an actor by sending metadata,
|
|
1617
|
+
and a delegated caller's roles never read across or administer.
|
|
1618
|
+
A copy (`POST /threads/{id}/copy`) is a write: it creates a thread that
|
|
1619
|
+
keeps the source's metadata, owner included, so its source must be the
|
|
1620
|
+
caller's own thread whatever the caller's roles. A thread that recorded
|
|
1621
|
+
approvals of gated API calls is not copied (403): the copy would carry
|
|
1622
|
+
its tool calls without their approvals.
|
|
1623
|
+
* every run gets the caller's own run context (`run_context_of`: its id,
|
|
1624
|
+
roles and public attributes, `@actor` included), whatever the request
|
|
1625
|
+
sent in `context` or `config.configurable`: tools act only for the
|
|
1626
|
+
authenticated caller, with its roles, and see the agent presenting a
|
|
1627
|
+
delegated request. `authenticate` publishes it in the server's user
|
|
1628
|
+
(`run_context`); a user without it (or with another's) gets one built
|
|
1629
|
+
from its identity and permissions, never from the request.
|
|
1630
|
+
* a run that carries a `command` (a resume of a paused run) is refused:
|
|
1631
|
+
a run paused for the approval of a gated API call resumes only through
|
|
1632
|
+
the app's approval routes, which check who may decide (the app's
|
|
1633
|
+
approvals ledger refuses a forged resume anyway). A new run on a thread
|
|
1634
|
+
whose approval is still pending is refused with 409, as `/chat` does.
|
|
1635
|
+
A run without input, or from a checkpoint (`checkpoint_id`,
|
|
1636
|
+
`checkpoint`: a replay), is refused (403) on a thread that recorded
|
|
1637
|
+
approvals or waits on a gated call: it would run a paused step's tool
|
|
1638
|
+
calls again without a decision (the API client refuses a call an
|
|
1639
|
+
approval was asked for anyway).
|
|
1640
|
+
* the raw principal id is kept only in the thread metadata, where the
|
|
1641
|
+
owner filters need it. The server merges thread metadata into every
|
|
1642
|
+
run's metadata (and from there into traced config metadata and
|
|
1643
|
+
checkpoint metadata); a run on an existing thread therefore carries the
|
|
1644
|
+
hashed id (`Principal.hashed_id()`) under `principal_id` instead.
|
|
1645
|
+
* assistants, crons, store: any authenticated principal may read (read,
|
|
1646
|
+
search, get, list_namespaces); create, update, put and delete are
|
|
1647
|
+
allowed only to a role listed in `AUTH_ADMIN_ROLES` (empty = nobody).
|
|
1648
|
+
The assistant `/chat` runs on is therefore changeable only by admins.
|
|
1649
|
+
Store reads are not per-user: a graph that writes per-user data into
|
|
1650
|
+
the store must scope its namespaces by principal.
|
|
1651
|
+
* anything else the server dispatches (a resource or action added by a
|
|
1652
|
+
later server version): denied (default deny).
|
|
1653
|
+
|
|
1654
|
+
These handlers cover the native API called from outside the app; the
|
|
1655
|
+
custom routes' loopback SDK calls bypass the server's auth middleware, so
|
|
1656
|
+
`chat.py` enforces the thread rule in-app.
|
|
1657
|
+
|
|
1658
|
+
The server answers an auth error with a bare 401/403 (without the policy's
|
|
1659
|
+
`WWW-Authenticate` challenge) and turns any other status (a policy's 503)
|
|
1660
|
+
into a 500; `middleware.AuthErrorMiddleware`, installed by `fast_api_app.py`
|
|
1661
|
+
under langgraph-server, restores both. It relies on the server's default
|
|
1662
|
+
middleware order (no `"middleware_order": "auth_first"` in langgraph.json).
|
|
1663
|
+
"""
|
|
1664
|
+
from langgraph_sdk import Auth
|
|
1665
|
+
|
|
1666
|
+
check_startup()
|
|
1667
|
+
auth = Auth()
|
|
1668
|
+
|
|
1669
|
+
def _permissions_of(ctx: Any) -> list[str]:
|
|
1670
|
+
perms = getattr(ctx.user, "permissions", None) or getattr(ctx, "permissions", None) or []
|
|
1671
|
+
return [str(p) for p in perms]
|
|
1672
|
+
|
|
1673
|
+
def _roles_of(ctx: Any) -> set[str]:
|
|
1674
|
+
return {
|
|
1675
|
+
p[len(ROLE_PERMISSION_PREFIX) :]
|
|
1676
|
+
for p in _permissions_of(ctx)
|
|
1677
|
+
if p.startswith(ROLE_PERMISSION_PREFIX)
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
def _actor_of(ctx: Any) -> str | None:
|
|
1681
|
+
"""The delegated caller's actor (its `actor:` permission), None for a direct caller."""
|
|
1682
|
+
for p in _permissions_of(ctx):
|
|
1683
|
+
if p.startswith(ACTOR_PERMISSION_PREFIX):
|
|
1684
|
+
return p[len(ACTOR_PERMISSION_PREFIX) :]
|
|
1685
|
+
return None
|
|
1686
|
+
|
|
1687
|
+
def _is_studio(ctx: Any) -> bool:
|
|
1688
|
+
# The Studio user exists only under `langgraph dev` (or LangSmith-hosted
|
|
1689
|
+
# auth); it is trusted as an admin under APP_ENV=dev only.
|
|
1690
|
+
return isinstance(ctx.user, Auth.types.StudioUser) and dev_mode()
|
|
1691
|
+
|
|
1692
|
+
def _reads_across(ctx: Any) -> bool:
|
|
1693
|
+
# A delegated caller's roles never read across (whatever AUTH_DELEGATED_ROLES lends).
|
|
1694
|
+
return _actor_of(ctx) is None and bool(_roles_of(ctx) & read_across_roles())
|
|
1695
|
+
|
|
1696
|
+
def _owner_filter(ctx: Any) -> dict[str, Any] | None:
|
|
1697
|
+
"""Read filter: none for a read-across role, else the caller's own threads.
|
|
1698
|
+
|
|
1699
|
+
The server's copy reads its source with this filter; a copy is a
|
|
1700
|
+
write, so there the filter is the caller's own threads for every role.
|
|
1701
|
+
"""
|
|
1702
|
+
if _reads_across(ctx) and not _THREAD_COPY.get():
|
|
1703
|
+
return None # no filter: may read across principals
|
|
1704
|
+
return _strict_owner_filter(ctx)
|
|
1705
|
+
|
|
1706
|
+
def _strict_owner_filter(ctx: Any) -> dict[str, Any]:
|
|
1707
|
+
"""Write filter: always the caller's own threads (read-across is read-only).
|
|
1708
|
+
|
|
1709
|
+
A delegated caller's also name its actor: it reaches only the threads
|
|
1710
|
+
started under its own subject and actor. A thread without `actor`
|
|
1711
|
+
(a direct one, or one created before 0.3) never matches that filter.
|
|
1712
|
+
"""
|
|
1713
|
+
actor = _actor_of(ctx)
|
|
1714
|
+
if actor is None:
|
|
1715
|
+
return {"principal_id": ctx.user.identity}
|
|
1716
|
+
return {"principal_id": ctx.user.identity, "actor": actor}
|
|
1717
|
+
|
|
1718
|
+
def _stamp_actor(ctx: Any, metadata: dict[str, Any]) -> None:
|
|
1719
|
+
"""The caller's actor in metadata the app stamps: set for a delegated caller,
|
|
1720
|
+
removed for a direct one (no caller chooses it)."""
|
|
1721
|
+
actor = _actor_of(ctx)
|
|
1722
|
+
if actor is None:
|
|
1723
|
+
metadata.pop("actor", None)
|
|
1724
|
+
else:
|
|
1725
|
+
metadata["actor"] = actor
|
|
1726
|
+
|
|
1727
|
+
def _require_admin(ctx: Any) -> bool:
|
|
1728
|
+
if _is_studio(ctx) or (_actor_of(ctx) is None and _roles_of(ctx) & admin_roles()):
|
|
1729
|
+
return True
|
|
1730
|
+
raise Auth.exceptions.HTTPException(
|
|
1731
|
+
status_code=403,
|
|
1732
|
+
detail=f"{ctx.resource}.{ctx.action} is allowed only to AUTH_ADMIN_ROLES.",
|
|
1733
|
+
)
|
|
1734
|
+
|
|
1735
|
+
def _metadata_of(value: Any) -> dict[str, Any]:
|
|
1736
|
+
"""The request's metadata dict, created when absent; refuses what cannot be stamped."""
|
|
1737
|
+
metadata = value.setdefault("metadata", {}) if isinstance(value, dict) else None
|
|
1738
|
+
if not isinstance(metadata, dict):
|
|
1739
|
+
raise Auth.exceptions.HTTPException(
|
|
1740
|
+
status_code=403, detail="Thread ownership metadata could not be set."
|
|
1741
|
+
)
|
|
1742
|
+
return metadata
|
|
1743
|
+
|
|
1744
|
+
def _caller_run_context(ctx: Any) -> dict[str, Any]:
|
|
1745
|
+
"""The caller's run context (a copy): as `authenticate` published it in the
|
|
1746
|
+
user, else built from the user's identity and permissions. Never the request's."""
|
|
1747
|
+
user = ctx.user
|
|
1748
|
+
getter = getattr(user, "get", None)
|
|
1749
|
+
published = (
|
|
1750
|
+
getter(RUN_CONTEXT_USER_KEY)
|
|
1751
|
+
if callable(getter)
|
|
1752
|
+
else getattr(user, RUN_CONTEXT_USER_KEY, None)
|
|
1753
|
+
)
|
|
1754
|
+
actor = _actor_of(ctx)
|
|
1755
|
+
if isinstance(published, Mapping) and published.get("principal_id") == user.identity:
|
|
1756
|
+
published_actor = actor_of_attributes(published.get("attributes"))
|
|
1757
|
+
if (published_actor.id if published_actor is not None else None) == actor:
|
|
1758
|
+
return {key: copy.deepcopy(published.get(key)) for key in RUN_CONTEXT_KEYS}
|
|
1759
|
+
return {
|
|
1760
|
+
"principal_id": user.identity,
|
|
1761
|
+
"roles": [
|
|
1762
|
+
p[len(ROLE_PERMISSION_PREFIX) :]
|
|
1763
|
+
for p in _permissions_of(ctx)
|
|
1764
|
+
if p.startswith(ROLE_PERMISSION_PREFIX)
|
|
1765
|
+
],
|
|
1766
|
+
"attributes": {} if actor is None else {ACTOR_ATTRIBUTE: Actor(id=actor).public()},
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
def _stamp_run_context(ctx: Any, value: Any) -> None:
|
|
1770
|
+
"""Put the caller's run context on a run, over whatever the request sent.
|
|
1771
|
+
|
|
1772
|
+
In `context` (what the graph runs with; all three keys, so an
|
|
1773
|
+
assistant's context cannot add any either) and in
|
|
1774
|
+
`config.configurable`, which the server fills from it.
|
|
1775
|
+
"""
|
|
1776
|
+
kwargs = value.setdefault("kwargs", {}) if isinstance(value, dict) else None
|
|
1777
|
+
if not isinstance(kwargs, dict):
|
|
1778
|
+
raise Auth.exceptions.HTTPException(
|
|
1779
|
+
status_code=403, detail="The run's caller context could not be set."
|
|
1780
|
+
)
|
|
1781
|
+
caller = _caller_run_context(ctx)
|
|
1782
|
+
sent = kwargs.get("context")
|
|
1783
|
+
kwargs["context"] = {**(sent if isinstance(sent, dict) else {}), **caller}
|
|
1784
|
+
config = kwargs.get("config")
|
|
1785
|
+
configurable = config.get("configurable") if isinstance(config, dict) else None
|
|
1786
|
+
if isinstance(configurable, dict):
|
|
1787
|
+
for key in RUN_CONTEXT_KEYS:
|
|
1788
|
+
if key in configurable:
|
|
1789
|
+
configurable[key] = copy.deepcopy(caller[key])
|
|
1790
|
+
|
|
1791
|
+
@auth.authenticate
|
|
1792
|
+
async def authenticate(request: Any) -> dict[str, Any]:
|
|
1793
|
+
_THREAD_COPY.set(_is_thread_copy(request))
|
|
1794
|
+
policy = get_policy()
|
|
1795
|
+
try:
|
|
1796
|
+
principal = finalize_principal(await policy.authenticate(request))
|
|
1797
|
+
except HTTPException as exc:
|
|
1798
|
+
if exc.status_code == 401:
|
|
1799
|
+
_stash_challenge(request, exc.headers)
|
|
1800
|
+
raise Auth.exceptions.HTTPException(
|
|
1801
|
+
status_code=exc.status_code, detail=str(exc.detail), headers=exc.headers
|
|
1802
|
+
) from exc
|
|
1803
|
+
except NotImplementedError as exc:
|
|
1804
|
+
raise Auth.exceptions.HTTPException(status_code=503, detail=str(exc)) from exc
|
|
1805
|
+
# A policy's own `actor:` permission is never trusted: only the actor it set does.
|
|
1806
|
+
permissions = sorted(
|
|
1807
|
+
p for p in principal.permissions if not str(p).startswith(ACTOR_PERMISSION_PREFIX)
|
|
1808
|
+
) + [f"{ROLE_PERMISSION_PREFIX}{r}" for r in principal.roles]
|
|
1809
|
+
if principal.actor is not None:
|
|
1810
|
+
permissions.append(f"{ACTOR_PERMISSION_PREFIX}{principal.actor.id}")
|
|
1811
|
+
return {
|
|
1812
|
+
"identity": principal.id,
|
|
1813
|
+
"display_name": principal.id,
|
|
1814
|
+
"is_authenticated": True,
|
|
1815
|
+
"permissions": permissions,
|
|
1816
|
+
# What a native run's tools act for (public attributes only: no credentials).
|
|
1817
|
+
RUN_CONTEXT_USER_KEY: run_context_of(principal),
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
# -- default deny: whatever has no rule below -----------------------------
|
|
1821
|
+
|
|
1822
|
+
@auth.on
|
|
1823
|
+
async def deny_by_default(ctx: Any, value: Any) -> bool:
|
|
1824
|
+
if _is_studio(ctx):
|
|
1825
|
+
return True
|
|
1826
|
+
raise Auth.exceptions.HTTPException(
|
|
1827
|
+
status_code=403, detail=f"{ctx.resource}.{ctx.action} is not allowed."
|
|
1828
|
+
)
|
|
1829
|
+
|
|
1830
|
+
# -- threads (and runs on them): owner-scoped ------------------------------
|
|
1831
|
+
|
|
1832
|
+
@auth.on.threads.create
|
|
1833
|
+
async def on_threads_create(ctx: Any, value: Any) -> dict[str, Any] | None:
|
|
1834
|
+
if isinstance(value, dict) and "metadata" not in value and not _THREAD_COPY.get():
|
|
1835
|
+
# The server creates a thread without metadata only for a copy,
|
|
1836
|
+
# which keeps its source's metadata (owner included) and ignores
|
|
1837
|
+
# what is stamped here. Not recognised as a copy, its source may
|
|
1838
|
+
# not have been limited to the caller's own threads: fail closed
|
|
1839
|
+
# for the roles whose reads are not.
|
|
1840
|
+
if _reads_across(ctx):
|
|
1841
|
+
raise Auth.exceptions.HTTPException(
|
|
1842
|
+
status_code=403,
|
|
1843
|
+
detail="Read-across roles may read other principals' threads, not copy them.",
|
|
1844
|
+
)
|
|
1845
|
+
metadata = _metadata_of(value)
|
|
1846
|
+
metadata["principal_id"] = ctx.user.identity
|
|
1847
|
+
metadata["tenant"] = None
|
|
1848
|
+
_stamp_actor(ctx, metadata)
|
|
1849
|
+
return _strict_owner_filter(ctx)
|
|
1850
|
+
|
|
1851
|
+
@auth.on.threads.read
|
|
1852
|
+
async def on_threads_read(ctx: Any, value: Any) -> dict[str, Any] | None:
|
|
1853
|
+
thread_id = value.get("thread_id") if isinstance(value, dict) else None
|
|
1854
|
+
if _THREAD_COPY.get() and thread_id is not None and not _is_studio(ctx):
|
|
1855
|
+
# The server's copy reads its source first. A copy keeps the
|
|
1856
|
+
# source's tool calls but not its approvals (they belong to the
|
|
1857
|
+
# source, and go when it is deleted): refused where calls were gated.
|
|
1858
|
+
from {{cookiecutter.agent_directory}}.app_utils.chat import RUNTIME
|
|
1859
|
+
|
|
1860
|
+
refusal = await RUNTIME.copy_refusal(str(thread_id))
|
|
1861
|
+
if refusal:
|
|
1862
|
+
raise Auth.exceptions.HTTPException(
|
|
1863
|
+
status_code=403,
|
|
1864
|
+
detail=f"This thread cannot be copied: {refusal}.",
|
|
1865
|
+
)
|
|
1866
|
+
return _owner_filter(ctx)
|
|
1867
|
+
|
|
1868
|
+
@auth.on.threads.search
|
|
1869
|
+
async def on_threads_search(ctx: Any, value: Any) -> dict[str, Any] | None:
|
|
1870
|
+
return _owner_filter(ctx)
|
|
1871
|
+
|
|
1872
|
+
@auth.on.threads.update
|
|
1873
|
+
async def on_threads_update(ctx: Any, value: Any) -> dict[str, Any] | None:
|
|
1874
|
+
metadata = value.get("metadata") if isinstance(value, dict) else None
|
|
1875
|
+
if isinstance(metadata, dict):
|
|
1876
|
+
# Ownership is not transferable: an owner cannot hand a thread
|
|
1877
|
+
# (and its content) to another principal, tenant or actor, and a
|
|
1878
|
+
# direct owner cannot strip the actor its agent's thread has.
|
|
1879
|
+
metadata["principal_id"] = ctx.user.identity
|
|
1880
|
+
metadata.pop("tenant", None)
|
|
1881
|
+
_stamp_actor(ctx, metadata)
|
|
1882
|
+
return _strict_owner_filter(ctx)
|
|
1883
|
+
|
|
1884
|
+
@auth.on.threads.delete
|
|
1885
|
+
async def on_threads_delete(ctx: Any, value: Any) -> dict[str, Any] | None:
|
|
1886
|
+
return _strict_owner_filter(ctx)
|
|
1887
|
+
|
|
1888
|
+
@auth.on.threads.create_run
|
|
1889
|
+
async def on_threads_create_run(ctx: Any, value: Any) -> dict[str, Any] | None:
|
|
1890
|
+
kwargs = value.get("kwargs") if isinstance(value, dict) else None
|
|
1891
|
+
if isinstance(kwargs, dict) and kwargs.get("command") and not _is_studio(ctx):
|
|
1892
|
+
# A command resumes a paused run: a gated API call waits there for a
|
|
1893
|
+
# human decision, which only the app's approval routes may deliver
|
|
1894
|
+
# (they check who may decide). The app's own loopback calls do not
|
|
1895
|
+
# pass through here.
|
|
1896
|
+
raise Auth.exceptions.HTTPException(
|
|
1897
|
+
status_code=403,
|
|
1898
|
+
detail="Resuming a run is done through the app's approval routes "
|
|
1899
|
+
"(POST /threads/{thread_id}/approvals/{approval_id}).",
|
|
1900
|
+
)
|
|
1901
|
+
thread_id = value.get("thread_id") if isinstance(value, dict) else None
|
|
1902
|
+
if thread_id is not None and not _is_studio(ctx):
|
|
1903
|
+
# Imported here: chat.py imports this module.
|
|
1904
|
+
from {{cookiecutter.agent_directory}}.app_utils.chat import RUNTIME
|
|
1905
|
+
|
|
1906
|
+
if await RUNTIME.pending_approvals(str(thread_id)):
|
|
1907
|
+
# A thread waiting for an approval takes no new run (as /chat's
|
|
1908
|
+
# 409 approval_pending): it would abandon the pending approval.
|
|
1909
|
+
raise Auth.exceptions.HTTPException(
|
|
1910
|
+
status_code=409,
|
|
1911
|
+
detail="approval_pending: a gated API call on this thread waits for a "
|
|
1912
|
+
"decision; decide it first (GET /threads/{thread_id}/approvals).",
|
|
1913
|
+
)
|
|
1914
|
+
if _replays(kwargs):
|
|
1915
|
+
refusal = await RUNTIME.replay_refusal(str(thread_id))
|
|
1916
|
+
if refusal:
|
|
1917
|
+
# Continued without input or replayed from a checkpoint, a
|
|
1918
|
+
# paused step runs its tool calls again with no decision.
|
|
1919
|
+
raise Auth.exceptions.HTTPException(
|
|
1920
|
+
status_code=403,
|
|
1921
|
+
detail=f"A run without input, or from a checkpoint, is refused here: "
|
|
1922
|
+
f"{refusal}. Send a new message instead (POST /chat), and decide "
|
|
1923
|
+
"approvals through POST /threads/{thread_id}/approvals/{approval_id}.",
|
|
1924
|
+
)
|
|
1925
|
+
metadata = _metadata_of(value)
|
|
1926
|
+
if value.get("thread_id") is None or value.get("if_not_exists") == "create":
|
|
1927
|
+
# The run may create its thread, whose metadata is then the run's
|
|
1928
|
+
# config metadata overlaid with this metadata: stamp both owner
|
|
1929
|
+
# keys. The raw id is the ownership stamp the filters compare, so
|
|
1930
|
+
# it is needed here (and this run's metadata carries it too).
|
|
1931
|
+
metadata["principal_id"] = ctx.user.identity
|
|
1932
|
+
else:
|
|
1933
|
+
# The thread exists and keeps its own stamp (the filter below
|
|
1934
|
+
# checks it). The server copies the run's metadata, thread
|
|
1935
|
+
# metadata merged in, into traces and checkpoints: override the
|
|
1936
|
+
# raw id there with the hashed one.
|
|
1937
|
+
metadata["principal_id"] = Principal(id=str(ctx.user.identity)).hashed_id()
|
|
1938
|
+
metadata["tenant"] = None
|
|
1939
|
+
_stamp_actor(ctx, metadata)
|
|
1940
|
+
if not _is_studio(ctx):
|
|
1941
|
+
# Who the run's tools act for is the caller: a request cannot name
|
|
1942
|
+
# another principal, other roles, or drop (or add) an actor.
|
|
1943
|
+
_stamp_run_context(ctx, value)
|
|
1944
|
+
return _strict_owner_filter(ctx)
|
|
1945
|
+
|
|
1946
|
+
# -- assistants, crons, store: read for everyone, change for admins --------
|
|
1947
|
+
|
|
1948
|
+
@auth.on.assistants.read
|
|
1949
|
+
@auth.on.assistants.search
|
|
1950
|
+
@auth.on.crons.read
|
|
1951
|
+
@auth.on.crons.search
|
|
1952
|
+
async def allow_read(ctx: Any, value: Any) -> bool:
|
|
1953
|
+
return True
|
|
1954
|
+
|
|
1955
|
+
@auth.on.assistants.create
|
|
1956
|
+
@auth.on.assistants.update
|
|
1957
|
+
@auth.on.assistants.delete
|
|
1958
|
+
@auth.on.crons.create
|
|
1959
|
+
@auth.on.crons.update
|
|
1960
|
+
@auth.on.crons.delete
|
|
1961
|
+
async def admin_only(ctx: Any, value: Any) -> bool:
|
|
1962
|
+
return _require_admin(ctx)
|
|
1963
|
+
|
|
1964
|
+
@auth.on.store(actions=["get", "search", "list_namespaces"])
|
|
1965
|
+
async def allow_store_read(ctx: Any, value: Any) -> bool:
|
|
1966
|
+
return True
|
|
1967
|
+
|
|
1968
|
+
@auth.on.store(actions=["put", "delete"])
|
|
1969
|
+
async def admin_only_store(ctx: Any, value: Any) -> bool:
|
|
1970
|
+
return _require_admin(ctx)
|
|
1971
|
+
|
|
1972
|
+
return auth
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
_sdk_auth: Any = None
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def __getattr__(name: str) -> Any:
|
|
1979
|
+
# Reading `auth` from this module (langgraph.json `auth.path`) builds the
|
|
1980
|
+
# SDK object on first use, so importing the module never requires langgraph_sdk.
|
|
1981
|
+
if name == "auth":
|
|
1982
|
+
global _sdk_auth
|
|
1983
|
+
if _sdk_auth is None:
|
|
1984
|
+
_sdk_auth = build_sdk_auth()
|
|
1985
|
+
return _sdk_auth
|
|
1986
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|