ms-tau-sdk 1.4.0.dev28__tar.gz → 2.0.0.dev29__tar.gz
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.
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/CHANGELOG.md +34 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/PKG-INFO +11 -1
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/README.md +8 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/pyproject.toml +3 -1
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/agent_skills/tau_a2a_runtime_adapter/SKILL.md +3 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/agent_skills/tau_local_development/SKILL.md +4 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/agent_skills/tau_repository_integration/SKILL.md +18 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/a2a.py +65 -1
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/chat.py +8 -1
- ms_tau_sdk-2.0.0.dev29/src/ms_tau_sdk/api/request_identity.py +284 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/sessions.py +5 -1
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/app.py +4 -0
- ms_tau_sdk-2.0.0.dev29/src/ms_tau_sdk/backend/assertions.py +400 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/logging.py +28 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/provenance.py +26 -11
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/settings.py +74 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/.gitignore +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/__init__.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/app.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/cli.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/config.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/env_file.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/logs.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/proxy.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/state.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/static/BULMA-LICENSE.txt +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/static/app.js +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/static/board.css +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/static/bulma.min.css +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/packages/tau-board/src/ms_tau_board/static/index.html +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/__init__.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/agent_skills/tau_project_customization/SKILL.md +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/__init__.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/conversations.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/dependencies.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/health.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/inspection.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/local_chat.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/api/models.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/application.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/__init__.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/auth.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/client.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/local.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/mcp.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/models.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/backend/routes.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/cli.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/errors.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/__init__.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/a2a_failure.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/chat_history.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/providers/__init__.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/providers/definitions.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/providers/factory.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/providers/tau_compat.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/resources/__init__.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/resources/loader.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/resources/prompts/review-code-repository.md +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/__init__.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/events.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/extensions.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/failures.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/live_turns.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/manager.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/observability.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/session.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/runtime/task_context.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/sessions/__init__.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/sessions/storage.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/skills.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/tools/__init__.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/tools/skill_read.py +0 -0
- {ms_tau_sdk-1.4.0.dev28 → ms_tau_sdk-2.0.0.dev29}/src/ms_tau_sdk/tools/task_control.py +0 -0
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.0 — 2026-10-05
|
|
4
|
+
|
|
5
|
+
- `create_app()` installs request identity, so a hosted Agent starts on a platform launcher that
|
|
6
|
+
requires it. The application declares it in `app.state.mainsequence_request_identity`:
|
|
7
|
+
`{"installed": True, "mode": "assertion", "public_ingress": ()}` when Main Sequence hosts the
|
|
8
|
+
runtime and `"mode": "local"` otherwise. Hosted means `MAINSEQUENCE_CALLER_AUTH_MODE=assertion`
|
|
9
|
+
or any of `APP_NAME`, `FASTAPI_PUBLIC_BASE_URL`, `MAINSEQUENCE_CALLER_ASSERTION_ISSUER` or
|
|
10
|
+
`MAINSEQUENCE_CALLER_ASSERTION_JWKS_URL` set, the launcher's own rule. A hosted runtime admits a
|
|
11
|
+
request only with the platform's signed assertion in `X-MainSequence-Caller-Assertion`: a
|
|
12
|
+
platform assertion on `/internal/*`, a caller assertion on every other route, `/health`,
|
|
13
|
+
`/ready` and `/version` included. It verifies the Ed25519 signature against the key set at
|
|
14
|
+
`MAINSEQUENCE_CALLER_ASSERTION_JWKS_URL`, refreshed once for an unknown `kid`, plus the exact
|
|
15
|
+
type, the exact claim set, the issuer, the release in `APP_NAME`, the Environment in
|
|
16
|
+
`MAINSEQUENCE_ORGANIZATION_ENVIRONMENT_UID`, and a lifetime of at most 300 seconds. A missing,
|
|
17
|
+
invalid, duplicated or wrong-kind assertion gets 401, and a key set that cannot be fetched gets
|
|
18
|
+
503. The runtime no longer takes a caller from `X-User-UID` in hosted mode: a user turn is
|
|
19
|
+
stamped with the verified `sub`, while Agent-caller provenance still comes from the gateway's
|
|
20
|
+
`X-Caller-*` headers. Handlers read the verified caller from `request.state.user`. A hosted
|
|
21
|
+
configuration that lacks a setting fails at `create_app()` and names it, and local mode refuses
|
|
22
|
+
to start with the hosting settings. Outside hosting nothing changes. The SDK now depends directly
|
|
23
|
+
on PyJWT (`crypto`) and `cryptography`, and still not on `mainsequence`. Upgrade note: callers of
|
|
24
|
+
a hosted runtime must go through the platform, which forwards the assertion, and platform probes
|
|
25
|
+
use the launcher's own endpoints. See ADR 0019
|
|
26
|
+
([#59](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/59)).
|
|
27
|
+
- A hosted runtime lets only a session's owner or an Organization admin address that session. The
|
|
28
|
+
owner is the User the platform recorded as the session's `created_by_user_uid`, compared with the
|
|
29
|
+
verified caller; an Organization admin is a caller whose assertion says `is_organization_admin`.
|
|
30
|
+
Anyone else gets 403 before the runtime acts, on chat, the session model, session cancellation,
|
|
31
|
+
A2A Message send and stream, Task get, cancel, subscribe and list, and the extended Agent Card,
|
|
32
|
+
over REST and JSON-RPC alike. A Task list without `contextId` returns only Tasks of sessions the
|
|
33
|
+
caller may address. The platform's own `/internal/*` calls are not subject to the check, and
|
|
34
|
+
local mode keeps its own owner scope. See ADR 0019
|
|
35
|
+
([#60](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/60)).
|
|
36
|
+
|
|
3
37
|
## 1.4.0 — 2026-10-05
|
|
4
38
|
|
|
5
39
|
- A managed runtime can prove its runtime credential with a projected workload identity token
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ms-tau-sdk
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0.dev29
|
|
4
4
|
Summary: Workspace-bound Tau application primitives for Main Sequence projects
|
|
5
5
|
Project-URL: Changelog, https://github.com/mainsequence-sdk/ms-tau-sdk/blob/development/CHANGELOG.md
|
|
6
6
|
Project-URL: Documentation, https://github.com/mainsequence-sdk/ms-tau-sdk/tree/development/docs
|
|
@@ -14,12 +14,14 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
14
14
|
Requires-Python: >=3.13
|
|
15
15
|
Requires-Dist: anyio>=4.10
|
|
16
16
|
Requires-Dist: concurrent-log-handler<1,>=0.9.28
|
|
17
|
+
Requires-Dist: cryptography>=42
|
|
17
18
|
Requires-Dist: fastapi>=0.116
|
|
18
19
|
Requires-Dist: httpx>=0.28
|
|
19
20
|
Requires-Dist: jsonschema<5,>=4.25
|
|
20
21
|
Requires-Dist: mcp<2,>=1.27
|
|
21
22
|
Requires-Dist: pydantic-settings>=2.10
|
|
22
23
|
Requires-Dist: pydantic>=2.11
|
|
24
|
+
Requires-Dist: pyjwt[crypto]<3,>=2.10.1
|
|
23
25
|
Requires-Dist: python-multipart>=0.0.20
|
|
24
26
|
Requires-Dist: structlog<27,>=26.1
|
|
25
27
|
Requires-Dist: tau-ai==0.4.2
|
|
@@ -121,6 +123,14 @@ from ms_tau_sdk import create_app
|
|
|
121
123
|
app = create_app()
|
|
122
124
|
```
|
|
123
125
|
|
|
126
|
+
When Main Sequence hosts the runtime, `create_app()` admits a request only with the platform's
|
|
127
|
+
signed assertion in `X-MainSequence-Caller-Assertion`, never on gateway identity headers, and lets
|
|
128
|
+
only a session's owner or an Organization admin address that session. It declares this request
|
|
129
|
+
identity to the platform launcher in `app.state.mainsequence_request_identity`; the launcher serves
|
|
130
|
+
only an application that declares it, so serve the application `create_app()` returns. Outside
|
|
131
|
+
hosting, requests are handled as before. See
|
|
132
|
+
[request identity](docs/reference/runtime-contract.md#request-identity).
|
|
133
|
+
|
|
124
134
|
Job-hosted batch execution is an [accepted design](docs/adrs/0015-job-hosted-batch-execution.md)
|
|
125
135
|
with implementation pending. It will run this SDK's configured Tau composition for one assignment
|
|
126
136
|
inside a project Job, close it, and exit without starting Uvicorn. The Job continues to own its
|
|
@@ -90,6 +90,14 @@ from ms_tau_sdk import create_app
|
|
|
90
90
|
app = create_app()
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
When Main Sequence hosts the runtime, `create_app()` admits a request only with the platform's
|
|
94
|
+
signed assertion in `X-MainSequence-Caller-Assertion`, never on gateway identity headers, and lets
|
|
95
|
+
only a session's owner or an Organization admin address that session. It declares this request
|
|
96
|
+
identity to the platform launcher in `app.state.mainsequence_request_identity`; the launcher serves
|
|
97
|
+
only an application that declares it, so serve the application `create_app()` returns. Outside
|
|
98
|
+
hosting, requests are handled as before. See
|
|
99
|
+
[request identity](docs/reference/runtime-contract.md#request-identity).
|
|
100
|
+
|
|
93
101
|
Job-hosted batch execution is an [accepted design](docs/adrs/0015-job-hosted-batch-execution.md)
|
|
94
102
|
with implementation pending. It will run this SDK's configured Tau composition for one assignment
|
|
95
103
|
inside a project Job, close it, and exit without starting Uvicorn. The Job continues to own its
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ms-tau-sdk"
|
|
7
|
-
version = "
|
|
7
|
+
version = "2.0.0.dev29"
|
|
8
8
|
description = "Workspace-bound Tau application primitives for Main Sequence projects"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
@@ -18,12 +18,14 @@ classifiers = [
|
|
|
18
18
|
dependencies = [
|
|
19
19
|
"anyio>=4.10",
|
|
20
20
|
"concurrent-log-handler>=0.9.28,<1",
|
|
21
|
+
"cryptography>=42",
|
|
21
22
|
"fastapi>=0.116",
|
|
22
23
|
"httpx>=0.28",
|
|
23
24
|
"jsonschema>=4.25,<5",
|
|
24
25
|
"mcp>=1.27,<2",
|
|
25
26
|
"pydantic>=2.11",
|
|
26
27
|
"pydantic-settings>=2.10",
|
|
28
|
+
"pyjwt[crypto]>=2.10.1,<3",
|
|
27
29
|
"python-multipart>=0.0.20",
|
|
28
30
|
"structlog>=26.1,<27",
|
|
29
31
|
"tau-ai==0.4.2",
|
|
@@ -25,6 +25,9 @@ The SDK owns:
|
|
|
25
25
|
- preserving each tool's canonical name and metadata through host normalization;
|
|
26
26
|
- privately attaching active caller-session proof when a tool advertises
|
|
27
27
|
`mainsequence.ai/requires-caller-session-proof/v1: true`;
|
|
28
|
+
- verifying the platform's signed caller or platform assertion on every inbound request of a
|
|
29
|
+
hosted runtime, and admitting a request to a session or its Tasks only for the session's owner
|
|
30
|
+
or an Organization admin;
|
|
28
31
|
- translating inbound A2A requests into the shared TAU runtime; and
|
|
29
32
|
- translating runtime events and results into validated A2A responses.
|
|
30
33
|
|
|
@@ -35,6 +35,10 @@ The SDK looks for the CLI in `MAINSEQUENCE_CLI`, then beside the Python interpre
|
|
|
35
35
|
CLI. Do not add token lines to `.env`. A pair found there still works, but it is deprecated and
|
|
36
36
|
startup logs a warning. `mainsequence refresh-token` run in that directory removes the lines.
|
|
37
37
|
|
|
38
|
+
Do not set `APP_NAME`, `FASTAPI_PUBLIC_BASE_URL`, `MAINSEQUENCE_CALLER_AUTH_MODE=assertion`, or the
|
|
39
|
+
`MAINSEQUENCE_CALLER_ASSERTION_*` variables. The platform sets them when it hosts a runtime, and
|
|
40
|
+
local mode refuses to start with them.
|
|
41
|
+
|
|
38
42
|
Set `MAINSEQUENCE_ENDPOINT` only for a non-default platform endpoint. The CLI session must be for
|
|
39
43
|
the same endpoint. Local mode binds to `127.0.0.1:8787` by default. Treat an explicit public bind
|
|
40
44
|
as privileged exposure: each accepted request can use the authenticated user's live Main Sequence
|
|
@@ -44,6 +44,20 @@ Both paths construct the same application. The project owns the dependency lock,
|
|
|
44
44
|
deployable artifact, system packages, and any surrounding ASGI composition. There is no separate
|
|
45
45
|
TAU image, overlay, or SDK-owned deployment.
|
|
46
46
|
|
|
47
|
+
`create_app()` installs the runtime's request identity. When Main Sequence hosts the runtime, the
|
|
48
|
+
application admits only requests that carry the platform's signed assertion, lets only a session's
|
|
49
|
+
owner or an Organization admin address that session, and declares all this in
|
|
50
|
+
`app.state.mainsequence_request_identity`. The platform launcher serves an application only with
|
|
51
|
+
that declaration, read from the object it serves. Therefore:
|
|
52
|
+
|
|
53
|
+
- export the object `create_app()` returns as the module's `app`. An application that mounts it
|
|
54
|
+
inside another one declares nothing, and the launcher refuses it;
|
|
55
|
+
- do not add middleware or routes that take the caller from `X-User-UID` or other gateway headers,
|
|
56
|
+
and do not install a second request-identity integration; and
|
|
57
|
+
- do not set `APP_NAME`, `FASTAPI_PUBLIC_BASE_URL`, or the `MAINSEQUENCE_CALLER_*` variables in the
|
|
58
|
+
repository. The platform sets them when it hosts the runtime, and local mode refuses to start
|
|
59
|
+
with them.
|
|
60
|
+
|
|
47
61
|
## Job-hosted batch execution
|
|
48
62
|
|
|
49
63
|
ADR 0015 accepts a Python batch entry point, but it is **not implemented in this SDK version**.
|
|
@@ -106,7 +120,11 @@ When an ASGI shim exists, also import it without starting a second application i
|
|
|
106
120
|
|
|
107
121
|
```bash
|
|
108
122
|
uv run python -c "from api.tau.main import app; print(type(app).__name__)"
|
|
123
|
+
uv run python -c "from api.tau.main import app; print(app.state.mainsequence_request_identity)"
|
|
109
124
|
```
|
|
110
125
|
|
|
126
|
+
The second command prints `{'installed': True, 'mode': 'local', 'public_ingress': ()}` outside
|
|
127
|
+
hosting.
|
|
128
|
+
|
|
111
129
|
Do not require the `mainsequence` Python distribution in the TAU dependency graph. Authentication
|
|
112
130
|
and Main Sequence transport are implemented directly by `ms-tau-sdk`.
|
|
@@ -70,6 +70,12 @@ from ms_tau_sdk.runtime.task_context import (
|
|
|
70
70
|
from ms_tau_sdk.settings import TauSDKSettings
|
|
71
71
|
|
|
72
72
|
from .dependencies import backend, runtime_manager, settings
|
|
73
|
+
from .request_identity import (
|
|
74
|
+
SessionAccessDeniedError,
|
|
75
|
+
require_session_access,
|
|
76
|
+
sessions_the_caller_may_address,
|
|
77
|
+
verified_user_uid,
|
|
78
|
+
)
|
|
73
79
|
|
|
74
80
|
router = APIRouter()
|
|
75
81
|
BackendDep = Annotated[MainSequenceClient, Depends(backend)]
|
|
@@ -471,7 +477,31 @@ def _a2a_turn_provenance(
|
|
|
471
477
|
"actorKind": "agent",
|
|
472
478
|
"actorUid": f"local-a2a-client-{config.workspace_digest}",
|
|
473
479
|
}
|
|
474
|
-
return turn_provenance_from_request(
|
|
480
|
+
return turn_provenance_from_request(
|
|
481
|
+
"a2a",
|
|
482
|
+
request.headers,
|
|
483
|
+
verified_user_uid=verified_user_uid(config),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _requested_context_id(body: dict[str, Any]) -> str:
|
|
488
|
+
"""Return the session a Message request names, before any of its parts is prepared."""
|
|
489
|
+
|
|
490
|
+
message = body.get("message")
|
|
491
|
+
return str(message.get("contextId") or "").strip() if isinstance(message, dict) else ""
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
async def _require_task_session_access(
|
|
495
|
+
client: MainSequenceClient,
|
|
496
|
+
task: AgentTask,
|
|
497
|
+
*,
|
|
498
|
+
checked: str = "",
|
|
499
|
+
) -> None:
|
|
500
|
+
"""Admit the request to the session of ``task`` unless that session was already checked."""
|
|
501
|
+
|
|
502
|
+
session_uid = task.agent_session_uid or task.context_id
|
|
503
|
+
if not session_uid or session_uid != checked:
|
|
504
|
+
await require_session_access(client, session_uid)
|
|
475
505
|
|
|
476
506
|
|
|
477
507
|
def _output_contract(body: dict[str, Any]) -> StrictJsonContract:
|
|
@@ -2204,6 +2234,9 @@ async def message_send(
|
|
|
2204
2234
|
provenance = _a2a_turn_provenance(config, request)
|
|
2205
2235
|
except CallerIdentityError as error:
|
|
2206
2236
|
return _caller_identity_rejection(request, error) # type: ignore[return-value]
|
|
2237
|
+
requested_context_id = _requested_context_id(body)
|
|
2238
|
+
if requested_context_id:
|
|
2239
|
+
await require_session_access(client, requested_context_id)
|
|
2207
2240
|
message, prompt = _request_parts(body, config)
|
|
2208
2241
|
history_length = _body_history_length(body)
|
|
2209
2242
|
_bind_a2a_context(
|
|
@@ -2233,6 +2266,7 @@ async def message_send(
|
|
|
2233
2266
|
detail="A Task continuation requires configuration.responseKind 'task'",
|
|
2234
2267
|
)
|
|
2235
2268
|
existing_task = await client.get_task_by_protocol_id(continuation_task_id)
|
|
2269
|
+
await _require_task_session_access(client, existing_task, checked=requested_context_id)
|
|
2236
2270
|
continued_message = _task_message_for_backend(
|
|
2237
2271
|
message,
|
|
2238
2272
|
context_id=existing_task.context_id,
|
|
@@ -2300,6 +2334,8 @@ async def message_send(
|
|
|
2300
2334
|
)
|
|
2301
2335
|
task = await _task_with_history(client, task, history_length=history_length)
|
|
2302
2336
|
return {"task": _task_payload(task, history_length=history_length)}
|
|
2337
|
+
# An existing Task with this ID is answered only to a caller who may address its session.
|
|
2338
|
+
await _require_task_session_access(client, task, checked=requested_context_id)
|
|
2303
2339
|
if config.local_mode and task.status == "submitted":
|
|
2304
2340
|
recovered_prompt, recovered_contract, recovered_provenance = (
|
|
2305
2341
|
_recovery_execution_contract(task, config)
|
|
@@ -2367,6 +2403,9 @@ async def message_stream(
|
|
|
2367
2403
|
a2a_extensions=a2a_extensions,
|
|
2368
2404
|
streaming=True,
|
|
2369
2405
|
)
|
|
2406
|
+
requested_context_id = _requested_context_id(body)
|
|
2407
|
+
if requested_context_id:
|
|
2408
|
+
await require_session_access(client, requested_context_id)
|
|
2370
2409
|
message, prompt = _request_parts(body, config)
|
|
2371
2410
|
history_length = _body_history_length(body)
|
|
2372
2411
|
_bind_a2a_context(
|
|
@@ -2389,6 +2428,7 @@ async def message_stream(
|
|
|
2389
2428
|
)
|
|
2390
2429
|
task = creation.task
|
|
2391
2430
|
if not creation.created:
|
|
2431
|
+
await _require_task_session_access(client, task, checked=requested_context_id)
|
|
2392
2432
|
if config.local_mode and task.status == "submitted":
|
|
2393
2433
|
recovered_prompt, recovered_contract, recovered_provenance = (
|
|
2394
2434
|
_recovery_execution_contract(task, config)
|
|
@@ -2428,10 +2468,22 @@ async def list_tasks(
|
|
|
2428
2468
|
historyLength: int | None = Query(default=None, ge=0),
|
|
2429
2469
|
) -> dict[str, Any]:
|
|
2430
2470
|
history_length = _history_length(historyLength)
|
|
2471
|
+
if contextId:
|
|
2472
|
+
await require_session_access(client, contextId)
|
|
2431
2473
|
tasks = await client.list_tasks(
|
|
2432
2474
|
context_id=contextId or "",
|
|
2433
2475
|
history_length=history_length,
|
|
2434
2476
|
)
|
|
2477
|
+
if not contextId:
|
|
2478
|
+
# A list across sessions keeps only the Tasks of sessions the caller may address.
|
|
2479
|
+
visible = await sessions_the_caller_may_address(
|
|
2480
|
+
client,
|
|
2481
|
+
(task.agent_session_uid or task.context_id for task in tasks),
|
|
2482
|
+
)
|
|
2483
|
+
if visible is not None:
|
|
2484
|
+
tasks = [
|
|
2485
|
+
task for task in tasks if (task.agent_session_uid or task.context_id) in visible
|
|
2486
|
+
]
|
|
2435
2487
|
return {"tasks": [_task_payload(task, history_length=history_length) for task in tasks]}
|
|
2436
2488
|
|
|
2437
2489
|
|
|
@@ -2442,6 +2494,7 @@ async def cancel_task(
|
|
|
2442
2494
|
manager: RuntimeManagerDep,
|
|
2443
2495
|
) -> dict[str, Any]:
|
|
2444
2496
|
task = await client.get_task_by_protocol_id(task_id)
|
|
2497
|
+
await _require_task_session_access(client, task)
|
|
2445
2498
|
await manager.cancel(task.context_id)
|
|
2446
2499
|
return {"task": _task_payload(await client.cancel_task(task.uid))}
|
|
2447
2500
|
|
|
@@ -2454,6 +2507,7 @@ async def subscribe_task(
|
|
|
2454
2507
|
after_sequence: int | None = Query(default=None, alias="afterSequence", ge=0),
|
|
2455
2508
|
) -> StreamingResponse | JSONResponse:
|
|
2456
2509
|
snapshot = await client.get_task_snapshot_by_protocol_id(task_id)
|
|
2510
|
+
await _require_task_session_access(client, snapshot.task)
|
|
2457
2511
|
if snapshot.task.status in TERMINAL:
|
|
2458
2512
|
return _unsupported_operation_rest_error(
|
|
2459
2513
|
"SubscribeToTask is unavailable for a terminal Task; use GetTask."
|
|
@@ -2474,6 +2528,7 @@ async def get_task(
|
|
|
2474
2528
|
) -> dict[str, Any]:
|
|
2475
2529
|
history_length = _history_length(historyLength)
|
|
2476
2530
|
task = await client.get_task_by_protocol_id(task_id, history_length=history_length)
|
|
2531
|
+
await _require_task_session_access(client, task)
|
|
2477
2532
|
return {"task": _task_payload(task, history_length=history_length)}
|
|
2478
2533
|
|
|
2479
2534
|
|
|
@@ -2513,6 +2568,7 @@ async def extended_agent_card(
|
|
|
2513
2568
|
resolved_uid = agent_session_uid or session_uid or context_id
|
|
2514
2569
|
if not resolved_uid:
|
|
2515
2570
|
raise HTTPException(status_code=400, detail="agent_session_uid is required")
|
|
2571
|
+
await require_session_access(client, resolved_uid)
|
|
2516
2572
|
envelope = await client.get_agent_card(resolved_uid)
|
|
2517
2573
|
payload = envelope.model_dump(mode="json")
|
|
2518
2574
|
payload["agent_card"] = _effective_agent_card(envelope.agent_card)
|
|
@@ -2590,6 +2646,9 @@ async def json_rpc(
|
|
|
2590
2646
|
a2a_extensions=a2a_extensions,
|
|
2591
2647
|
streaming=True,
|
|
2592
2648
|
)
|
|
2649
|
+
requested_context_id = _requested_context_id(params)
|
|
2650
|
+
if requested_context_id:
|
|
2651
|
+
await require_session_access(client, requested_context_id)
|
|
2593
2652
|
message, prompt = _request_parts(params, config)
|
|
2594
2653
|
history_length = _body_history_length(params)
|
|
2595
2654
|
_bind_a2a_context(
|
|
@@ -2613,6 +2672,7 @@ async def json_rpc(
|
|
|
2613
2672
|
)
|
|
2614
2673
|
task = creation.task
|
|
2615
2674
|
if not creation.created:
|
|
2675
|
+
await _require_task_session_access(client, task, checked=requested_context_id)
|
|
2616
2676
|
if config.local_mode and task.status == "submitted":
|
|
2617
2677
|
recovered_prompt, recovered_contract, recovered_provenance = (
|
|
2618
2678
|
_recovery_execution_contract(task, config)
|
|
@@ -2669,6 +2729,7 @@ async def json_rpc(
|
|
|
2669
2729
|
)
|
|
2670
2730
|
elif method in {"SubscribeToTask", "tasks/subscribe"}:
|
|
2671
2731
|
snapshot = await client.get_task_snapshot_by_protocol_id(task_id())
|
|
2732
|
+
await _require_task_session_access(client, snapshot.task)
|
|
2672
2733
|
if snapshot.task.status in TERMINAL:
|
|
2673
2734
|
return _unsupported_operation_json_rpc_error(
|
|
2674
2735
|
request_id,
|
|
@@ -2698,6 +2759,9 @@ async def json_rpc(
|
|
|
2698
2759
|
"error": {"code": -32601, "message": "Method not found"},
|
|
2699
2760
|
}
|
|
2700
2761
|
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
2762
|
+
except SessionAccessDeniedError:
|
|
2763
|
+
# Refused access to a session is answered as HTTP 403 on every transport.
|
|
2764
|
+
raise
|
|
2701
2765
|
except HTTPException as error:
|
|
2702
2766
|
return {
|
|
2703
2767
|
"jsonrpc": "2.0",
|
|
@@ -22,6 +22,7 @@ from ms_tau_sdk.runtime.provenance import (
|
|
|
22
22
|
|
|
23
23
|
from .dependencies import runtime_manager
|
|
24
24
|
from .models import ChatRequest
|
|
25
|
+
from .request_identity import require_session_access, verified_user_uid
|
|
25
26
|
|
|
26
27
|
router = APIRouter(prefix="/api")
|
|
27
28
|
RuntimeManagerDep = Annotated[SessionRuntimeManager, Depends(runtime_manager)]
|
|
@@ -61,7 +62,11 @@ async def chat(
|
|
|
61
62
|
}
|
|
62
63
|
else:
|
|
63
64
|
try:
|
|
64
|
-
provenance = turn_provenance_from_request(
|
|
65
|
+
provenance = turn_provenance_from_request(
|
|
66
|
+
"chat",
|
|
67
|
+
request.headers,
|
|
68
|
+
verified_user_uid=verified_user_uid(manager.settings),
|
|
69
|
+
)
|
|
65
70
|
except CallerIdentityError as error:
|
|
66
71
|
logger.warning(
|
|
67
72
|
"turn.caller_identity_rejected",
|
|
@@ -77,6 +82,8 @@ async def chat(
|
|
|
77
82
|
session_uid = manager.settings.local_session_uid(body.session_uid)
|
|
78
83
|
elif body.session_uid:
|
|
79
84
|
session_uid = body.session_uid
|
|
85
|
+
if manager.settings.request_identity_mode == "assertion":
|
|
86
|
+
await require_session_access(manager.backend, session_uid)
|
|
80
87
|
else:
|
|
81
88
|
raise HTTPException(status_code=422, detail="sessionUid is required")
|
|
82
89
|
try:
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Request identity: who calls the runtime, and which sessions that caller may address.
|
|
2
|
+
|
|
3
|
+
``create_app()`` declares request identity for the platform launcher in
|
|
4
|
+
``app.state.mainsequence_request_identity``. In hosted mode
|
|
5
|
+
(``TauSDKSettings.request_identity_mode == "assertion"``) it also installs
|
|
6
|
+
``RequestIdentityMiddleware``, which admits a request only with the platform's signed assertion of
|
|
7
|
+
the kind its route requires: a platform assertion on ``/internal/*``, a caller assertion on every
|
|
8
|
+
other route. Handlers read the verified caller with ``current_caller()``, or from
|
|
9
|
+
``request.state.user`` and ``request.state.user_uid``.
|
|
10
|
+
|
|
11
|
+
A request that addresses an existing session must also come from the session's owner or from an
|
|
12
|
+
Organization admin; see ``require_session_access()``. Outside hosted mode nothing here changes how
|
|
13
|
+
requests are handled.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from contextvars import ContextVar
|
|
20
|
+
|
|
21
|
+
import structlog
|
|
22
|
+
from fastapi import FastAPI, HTTPException
|
|
23
|
+
from starlette.responses import JSONResponse
|
|
24
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
25
|
+
from starlette.websockets import WebSocketClose
|
|
26
|
+
|
|
27
|
+
from ms_tau_sdk.backend.assertions import (
|
|
28
|
+
ASSERTION_HEADER,
|
|
29
|
+
AssertionKeysUnavailableError,
|
|
30
|
+
AssertionKind,
|
|
31
|
+
AssertionVerifier,
|
|
32
|
+
InvalidAssertionError,
|
|
33
|
+
VerifiedCaller,
|
|
34
|
+
)
|
|
35
|
+
from ms_tau_sdk.backend.client import MainSequenceClient
|
|
36
|
+
from ms_tau_sdk.backend.models import AgentSession
|
|
37
|
+
from ms_tau_sdk.errors import SessionNotFoundError
|
|
38
|
+
from ms_tau_sdk.logging import bind_request_identity_log_fields
|
|
39
|
+
from ms_tau_sdk.runtime.provenance import CallerIdentityError
|
|
40
|
+
from ms_tau_sdk.settings import TauSDKSettings
|
|
41
|
+
|
|
42
|
+
logger = structlog.get_logger(__name__)
|
|
43
|
+
|
|
44
|
+
# The platform launcher reads this declaration from the application state before it serves the
|
|
45
|
+
# application. Its name and keys are the launcher's.
|
|
46
|
+
REQUEST_IDENTITY_STATE_ATTRIBUTE = "mainsequence_request_identity"
|
|
47
|
+
INTERNAL_ROUTE_PREFIX = "/internal"
|
|
48
|
+
_ASSERTION_HEADER_NAME = ASSERTION_HEADER.lower().encode("latin-1")
|
|
49
|
+
_REJECTION_DETAIL: dict[AssertionKind, str] = {
|
|
50
|
+
"caller": "A valid caller assertion is required.",
|
|
51
|
+
"platform": "A valid platform assertion is required.",
|
|
52
|
+
}
|
|
53
|
+
_UNAVAILABLE_DETAIL = "Caller authentication is unavailable."
|
|
54
|
+
SESSION_ACCESS_DENIED_DETAIL = (
|
|
55
|
+
"Only the session's owner or an Organization admin can address this session."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
_current_caller: ContextVar[VerifiedCaller | None] = ContextVar(
|
|
59
|
+
"ms_tau_sdk_verified_caller",
|
|
60
|
+
default=None,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SessionAccessDeniedError(HTTPException):
|
|
65
|
+
"""A verified caller addressed a session that it neither owns nor administers."""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
super().__init__(status_code=403, detail=SESSION_ACCESS_DENIED_DETAIL)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def current_caller() -> VerifiedCaller | None:
|
|
72
|
+
"""Return the caller that the current request's verified caller assertion names.
|
|
73
|
+
|
|
74
|
+
It is None outside hosted mode and on the platform's own calls to ``/internal/*``.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
return _current_caller.get()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def verified_user_uid(settings: TauSDKSettings) -> str | None:
|
|
81
|
+
"""Return the verified caller's User UID in hosted mode, and None otherwise.
|
|
82
|
+
|
|
83
|
+
In hosted mode a request without a verified caller raises ``CallerIdentityError``: the
|
|
84
|
+
caller is never taken from a header instead.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
if settings.request_identity_mode != "assertion":
|
|
88
|
+
return None
|
|
89
|
+
caller = current_caller()
|
|
90
|
+
if caller is None:
|
|
91
|
+
raise CallerIdentityError((ASSERTION_HEADER,))
|
|
92
|
+
return caller.uid
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def require_session_access(
|
|
96
|
+
client: MainSequenceClient,
|
|
97
|
+
session_uid: str,
|
|
98
|
+
) -> AgentSession | None:
|
|
99
|
+
"""Admit the current request to ``session_uid`` only for its owner or an Organization admin.
|
|
100
|
+
|
|
101
|
+
The owner is the User the platform recorded as the session's creator,
|
|
102
|
+
``created_by_user_uid``. The check applies in hosted mode only and raises
|
|
103
|
+
``SessionAccessDeniedError`` (403). It returns the session when it had to read it.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
if client.settings.request_identity_mode != "assertion":
|
|
107
|
+
return None
|
|
108
|
+
caller = current_caller()
|
|
109
|
+
if caller is None or not session_uid:
|
|
110
|
+
raise SessionAccessDeniedError()
|
|
111
|
+
if caller.is_organization_admin:
|
|
112
|
+
return None
|
|
113
|
+
session = await client.get_session(session_uid)
|
|
114
|
+
if session.created_by_user_uid != caller.uid:
|
|
115
|
+
raise SessionAccessDeniedError()
|
|
116
|
+
return session
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def sessions_the_caller_may_address(
|
|
120
|
+
client: MainSequenceClient,
|
|
121
|
+
session_uids: Iterable[str],
|
|
122
|
+
) -> set[str] | None:
|
|
123
|
+
"""Return which of ``session_uids`` the current caller may address.
|
|
124
|
+
|
|
125
|
+
None means every session: outside hosted mode, or for an Organization admin. A session that
|
|
126
|
+
no longer exists is left out.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
if client.settings.request_identity_mode != "assertion":
|
|
130
|
+
return None
|
|
131
|
+
caller = current_caller()
|
|
132
|
+
if caller is None:
|
|
133
|
+
return set()
|
|
134
|
+
if caller.is_organization_admin:
|
|
135
|
+
return None
|
|
136
|
+
allowed: set[str] = set()
|
|
137
|
+
for session_uid in set(session_uids):
|
|
138
|
+
if not session_uid:
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
session = await client.get_session(session_uid)
|
|
142
|
+
except SessionNotFoundError:
|
|
143
|
+
continue
|
|
144
|
+
if session.created_by_user_uid == caller.uid:
|
|
145
|
+
allowed.add(session_uid)
|
|
146
|
+
return allowed
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _route_path(scope: Scope) -> str:
|
|
150
|
+
"""Return the path the router matches, the same way Starlette's routing derives it."""
|
|
151
|
+
|
|
152
|
+
path = str(scope.get("path") or "")
|
|
153
|
+
root_path = str(scope.get("root_path") or "")
|
|
154
|
+
if root_path and path.startswith(root_path) and path != root_path:
|
|
155
|
+
if path[len(root_path)] == "/":
|
|
156
|
+
return path[len(root_path) :]
|
|
157
|
+
return path
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def required_assertion(scope: Scope) -> AssertionKind:
|
|
161
|
+
"""Name the assertion a route requires: a platform assertion on ``/internal/*`` only."""
|
|
162
|
+
|
|
163
|
+
path = _route_path(scope)
|
|
164
|
+
internal = path == INTERNAL_ROUTE_PREFIX or path.startswith(INTERNAL_ROUTE_PREFIX + "/")
|
|
165
|
+
return "platform" if internal else "caller"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _assertion(scope: Scope) -> str:
|
|
169
|
+
values = [
|
|
170
|
+
value for name, value in scope.get("headers", ()) if name.lower() == _ASSERTION_HEADER_NAME
|
|
171
|
+
]
|
|
172
|
+
if len(values) > 1:
|
|
173
|
+
raise InvalidAssertionError("The request carries more than one assertion")
|
|
174
|
+
token = values[0].decode("latin-1").strip() if values else ""
|
|
175
|
+
if not token:
|
|
176
|
+
raise InvalidAssertionError("The request carries no assertion")
|
|
177
|
+
return token
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class RequestIdentityMiddleware:
|
|
181
|
+
"""Admit a hosted runtime's requests only with the platform's signed assertion."""
|
|
182
|
+
|
|
183
|
+
def __init__(self, app: ASGIApp, *, verifier: AssertionVerifier) -> None:
|
|
184
|
+
self.app = app
|
|
185
|
+
self.verifier = verifier
|
|
186
|
+
|
|
187
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
188
|
+
if scope["type"] not in {"http", "websocket"}:
|
|
189
|
+
await self.app(scope, receive, send)
|
|
190
|
+
return
|
|
191
|
+
state = scope.setdefault("state", {})
|
|
192
|
+
if scope["type"] == "http" and scope.get("method") == "OPTIONS":
|
|
193
|
+
state.update(user=None, user_uid=None, auth_outcome="not_applicable")
|
|
194
|
+
bind_request_identity_log_fields(scope, user_uid=None, auth_outcome="not_applicable")
|
|
195
|
+
await self.app(scope, receive, send)
|
|
196
|
+
return
|
|
197
|
+
kind = required_assertion(scope)
|
|
198
|
+
try:
|
|
199
|
+
verified = await self.verifier.verify(_assertion(scope), kind=kind)
|
|
200
|
+
except AssertionKeysUnavailableError as error:
|
|
201
|
+
await self._deny(scope, receive, send, kind=kind, status_code=503, reason=str(error))
|
|
202
|
+
return
|
|
203
|
+
except InvalidAssertionError as error:
|
|
204
|
+
await self._deny(scope, receive, send, kind=kind, status_code=401, reason=str(error))
|
|
205
|
+
return
|
|
206
|
+
caller = verified.caller
|
|
207
|
+
user_uid = caller.uid if caller is not None else None
|
|
208
|
+
state.update(
|
|
209
|
+
user=caller,
|
|
210
|
+
user_uid=user_uid,
|
|
211
|
+
auth_outcome="authenticated",
|
|
212
|
+
resource_release_uid=self.verifier.resource_release_uid,
|
|
213
|
+
organization_environment_uid=self.verifier.organization_environment_uid,
|
|
214
|
+
)
|
|
215
|
+
bind_request_identity_log_fields(scope, user_uid=user_uid, auth_outcome="authenticated")
|
|
216
|
+
token = _current_caller.set(caller)
|
|
217
|
+
try:
|
|
218
|
+
await self.app(scope, receive, send)
|
|
219
|
+
finally:
|
|
220
|
+
_current_caller.reset(token)
|
|
221
|
+
|
|
222
|
+
async def _deny(
|
|
223
|
+
self,
|
|
224
|
+
scope: Scope,
|
|
225
|
+
receive: Receive,
|
|
226
|
+
send: Send,
|
|
227
|
+
*,
|
|
228
|
+
kind: AssertionKind,
|
|
229
|
+
status_code: int,
|
|
230
|
+
reason: str,
|
|
231
|
+
) -> None:
|
|
232
|
+
outcome = "unavailable" if status_code == 503 else "rejected"
|
|
233
|
+
scope["state"].update(user=None, user_uid=None, auth_outcome=outcome)
|
|
234
|
+
bind_request_identity_log_fields(scope, user_uid=None, auth_outcome=outcome)
|
|
235
|
+
logger.warning(
|
|
236
|
+
"request_identity.rejected",
|
|
237
|
+
message="Request identity rejected a request",
|
|
238
|
+
required_assertion=kind,
|
|
239
|
+
reason=reason,
|
|
240
|
+
status_code=status_code,
|
|
241
|
+
)
|
|
242
|
+
if scope["type"] == "websocket":
|
|
243
|
+
await WebSocketClose(code=1013 if status_code == 503 else 1008)(scope, receive, send)
|
|
244
|
+
return
|
|
245
|
+
response = JSONResponse(
|
|
246
|
+
status_code=status_code,
|
|
247
|
+
content={
|
|
248
|
+
"detail": _UNAVAILABLE_DETAIL if status_code == 503 else _REJECTION_DETAIL[kind]
|
|
249
|
+
},
|
|
250
|
+
headers={"Cache-Control": "no-store"},
|
|
251
|
+
)
|
|
252
|
+
await response(scope, receive, send)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def install_request_identity(app: FastAPI, settings: TauSDKSettings) -> None:
|
|
256
|
+
"""Declare request identity for the platform launcher and, when hosted, enforce it.
|
|
257
|
+
|
|
258
|
+
Call it before any other middleware is added, so that it is the innermost one.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
mode = settings.request_identity_mode
|
|
262
|
+
if mode == "assertion":
|
|
263
|
+
app.add_middleware(
|
|
264
|
+
RequestIdentityMiddleware,
|
|
265
|
+
verifier=AssertionVerifier.from_settings(settings),
|
|
266
|
+
)
|
|
267
|
+
setattr(
|
|
268
|
+
app.state,
|
|
269
|
+
REQUEST_IDENTITY_STATE_ATTRIBUTE,
|
|
270
|
+
{"installed": True, "mode": mode, "public_ingress": ()},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
__all__ = [
|
|
275
|
+
"REQUEST_IDENTITY_STATE_ATTRIBUTE",
|
|
276
|
+
"RequestIdentityMiddleware",
|
|
277
|
+
"SessionAccessDeniedError",
|
|
278
|
+
"current_caller",
|
|
279
|
+
"install_request_identity",
|
|
280
|
+
"require_session_access",
|
|
281
|
+
"required_assertion",
|
|
282
|
+
"sessions_the_caller_may_address",
|
|
283
|
+
"verified_user_uid",
|
|
284
|
+
]
|