mainsequence 9.0.11__tar.gz → 9.0.12.dev51__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.
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/PKG-INFO +1 -1
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +7 -0
- mainsequence-9.0.12.dev51/mainsequence/_request_identity.py +98 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/base.py +12 -2
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/models_user.py +33 -3
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/server/caller_assertions.py +2 -1
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/server/fastapi.py +19 -2
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/PKG-INFO +1 -1
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/pyproject.toml +1 -1
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_fastapi_request_identity.py +141 -1
- mainsequence-9.0.11/mainsequence/_request_identity.py +0 -44
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/LICENSE +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/README.md +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/AGENTS.md +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/__init__.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/__main__.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/bootstrap.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/__init__.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/api.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/browser_auth.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/cli.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/code_repository_status.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/config.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/docker_utils.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/doctor.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/local_ops.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/model_filters.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/pydantic_cli.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/sdk_utils.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/ssh_utils.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/ui.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/__init__.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/_transport.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/agent_runtime_models.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/client.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/compute_validation.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/exceptions.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/github_issues.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/inference.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/models_foundry.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/models_helpers.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/observability.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/utils.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/value_sets.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/code_repository_context.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/code_repository_skills.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/defaults.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/instrumentation/__init__.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/instrumentation/utils.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/logconf.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/repository_identity_security.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/runtime_credential_exchange.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/runtime_flags.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/scaffold_skills.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/server/__init__.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/SOURCES.txt +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/dependency_links.txt +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/entry_points.txt +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/requires.txt +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/top_level.txt +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/setup.cfg +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_auth_bootstrap.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_auth_precedence.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_caller_assertions.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_canonical_api_route_contract.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_cli_auth_contract.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_cli_browser_auth.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_cli_secure_storage.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_client.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_code_repository_branch_contract.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_code_repository_documentation_contract.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_code_repository_runtime_context.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_code_repository_skills.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_credential_repr.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_dependency_extras.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_dev_release_version.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_environment_resource_context.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_environment_response_contracts.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_github_issues_client.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_http_retry_budget.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_independent_context.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_inference_client.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_instrumentation.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_job_client.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_local_ops.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_logconf.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_models_user_request_bound_auth.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_notification_uid_contract.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_observability_client.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_runtime_credential_exchange.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_scaffold_skills.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_secret_client_model.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_source_ownership.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_ssh_utils.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_tolerant_response_reading.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_tolerant_value_sets.py +0 -0
- {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_user_application_facts.py +0 -0
|
@@ -147,6 +147,13 @@ person, read `User.get_by_uid(uid)`: a workload identity has `identity_type`
|
|
|
147
147
|
`"workload"`. Caller authentication is not resource authorization; apply the
|
|
148
148
|
endpoint's application policy using the verified user UID.
|
|
149
149
|
|
|
150
|
+
To list the people, Teams and workloads the caller can see, for example to
|
|
151
|
+
offer sharing candidates, read inside `reads_as_caller()` from
|
|
152
|
+
`mainsequence.server.fastapi`: `User.filter`, `User.get_by_uid`, `Team.filter`
|
|
153
|
+
and `Team.get_by_uid` then answer with the caller's visibility, and workload
|
|
154
|
+
rows carry `managed_by_caller`. Outside a signed HTTP request it raises
|
|
155
|
+
`RequestIdentityError`; it never reads as the application instead.
|
|
156
|
+
|
|
150
157
|
WebSockets retain the platform's gateway ticket/header contract rather than
|
|
151
158
|
the HTTP Bearer/assertion flow; the getter is available while the authenticated
|
|
152
159
|
connection is active. See the version-matched
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Private lifecycle shared by the request integration and the public user getter."""
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
CALLER_ASSERTION_HEADER = "X-MainSequence-Caller-Assertion"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RequestIdentityError(RuntimeError):
|
|
13
|
+
"""No authenticated caller is bound to the current request."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class _RequestScope:
|
|
18
|
+
user: Any = None
|
|
19
|
+
active: bool = True
|
|
20
|
+
# The verified assertion of a hosted HTTP request, kept only so that
|
|
21
|
+
# reads_as_caller() can present it (ADR-0036, amended for #198).
|
|
22
|
+
caller_assertion: str | None = field(default=None, repr=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_current_scope: ContextVar[_RequestScope | None] = ContextVar(
|
|
26
|
+
"mainsequence_request_identity", default=None
|
|
27
|
+
)
|
|
28
|
+
# The request scope whose caller the directory reads answer as, inside reads_as_caller().
|
|
29
|
+
_caller_reads: ContextVar[_RequestScope | None] = ContextVar(
|
|
30
|
+
"mainsequence_caller_reads", default=None
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@contextmanager
|
|
35
|
+
def _request_scope():
|
|
36
|
+
scope = _RequestScope()
|
|
37
|
+
token = _current_scope.set(scope)
|
|
38
|
+
try:
|
|
39
|
+
yield scope
|
|
40
|
+
finally:
|
|
41
|
+
# Copied child-task contexts hold this same frame and must also expire.
|
|
42
|
+
scope.active = False
|
|
43
|
+
scope.user = None
|
|
44
|
+
scope.caller_assertion = None
|
|
45
|
+
_current_scope.reset(token)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_request_identity():
|
|
49
|
+
scope = _current_scope.get()
|
|
50
|
+
if scope is None or not scope.active or scope.user is None:
|
|
51
|
+
raise RequestIdentityError(
|
|
52
|
+
"No authenticated request user is available. Install request identity "
|
|
53
|
+
"once in the application and call User.get_logged_user() inside a request."
|
|
54
|
+
)
|
|
55
|
+
return scope.user
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def _reads_as_caller():
|
|
60
|
+
scope = _current_scope.get()
|
|
61
|
+
if scope is None or not scope.active or scope.user is None:
|
|
62
|
+
raise RequestIdentityError(
|
|
63
|
+
"Reading as the caller needs an authenticated request. Install request "
|
|
64
|
+
"identity once in the application and call reads_as_caller() inside a request."
|
|
65
|
+
)
|
|
66
|
+
if scope.caller_assertion is None:
|
|
67
|
+
raise RequestIdentityError(
|
|
68
|
+
"This request carries no caller assertion. Reading as the caller needs a "
|
|
69
|
+
"hosted HTTP request in assertion mode, not local mode or a WebSocket."
|
|
70
|
+
)
|
|
71
|
+
token = _caller_reads.set(scope)
|
|
72
|
+
try:
|
|
73
|
+
yield
|
|
74
|
+
finally:
|
|
75
|
+
_caller_reads.reset(token)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _origin(url: str) -> tuple[str, str | None, int | None]:
|
|
79
|
+
parts = urlsplit(url)
|
|
80
|
+
scheme = parts.scheme.lower()
|
|
81
|
+
return scheme, parts.hostname, parts.port or {"http": 80, "https": 443}.get(scheme)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _caller_read_headers(url: str, endpoint_url: str) -> dict[str, str]:
|
|
85
|
+
"""The header a directory read sends to ``url``: none outside reads_as_caller()."""
|
|
86
|
+
scope = _caller_reads.get()
|
|
87
|
+
if scope is None:
|
|
88
|
+
return {}
|
|
89
|
+
if not scope.active or scope.caller_assertion is None:
|
|
90
|
+
raise RequestIdentityError(
|
|
91
|
+
"The request that reads_as_caller() belongs to has ended; its caller "
|
|
92
|
+
"assertion is no longer available."
|
|
93
|
+
)
|
|
94
|
+
if _origin(url) != _origin(endpoint_url):
|
|
95
|
+
raise RequestIdentityError(
|
|
96
|
+
"The caller assertion is sent only to the configured platform endpoint."
|
|
97
|
+
)
|
|
98
|
+
return {CALLER_ASSERTION_HEADER: scope.caller_assertion}
|
|
@@ -180,6 +180,11 @@ class BaseObjectOrm:
|
|
|
180
180
|
def _sdk_owned_query_context(cls, operation: str) -> dict[str, Any]:
|
|
181
181
|
return {}
|
|
182
182
|
|
|
183
|
+
@classmethod
|
|
184
|
+
def _read_request_headers(cls, url: str) -> dict[str, str]:
|
|
185
|
+
"""Headers a filter or detail read adds to its own request to ``url``."""
|
|
186
|
+
return {}
|
|
187
|
+
|
|
183
188
|
@classmethod
|
|
184
189
|
def _sdk_owned_create_context(cls, operation: str) -> dict[str, Any]:
|
|
185
190
|
return {}
|
|
@@ -540,12 +545,13 @@ class BaseObjectOrm:
|
|
|
540
545
|
|
|
541
546
|
while next_url:
|
|
542
547
|
req_payload = {"params": params} if params else {}
|
|
548
|
+
headers = cls._read_request_headers(next_url)
|
|
543
549
|
r = make_request(
|
|
544
550
|
s=cls.build_session(),
|
|
545
551
|
loaders=cls.LOADERS,
|
|
546
552
|
r_type="GET",
|
|
547
553
|
url=next_url,
|
|
548
|
-
payload=req_payload,
|
|
554
|
+
payload={**req_payload, "headers": headers} if headers else req_payload,
|
|
549
555
|
time_out=timeout,
|
|
550
556
|
)
|
|
551
557
|
raise_for_response(r, payload=req_payload)
|
|
@@ -610,12 +616,16 @@ class BaseObjectOrm:
|
|
|
610
616
|
**cls._sdk_owned_query_context(operation),
|
|
611
617
|
}
|
|
612
618
|
|
|
619
|
+
payload = {"params": params} # neede to pass special serializer
|
|
620
|
+
headers = cls._read_request_headers(detail_url)
|
|
621
|
+
if headers:
|
|
622
|
+
payload["headers"] = headers
|
|
613
623
|
r = make_request(
|
|
614
624
|
s=cls.build_session(),
|
|
615
625
|
loaders=cls.LOADERS,
|
|
616
626
|
r_type="GET",
|
|
617
627
|
url=detail_url,
|
|
618
|
-
payload=
|
|
628
|
+
payload=payload,
|
|
619
629
|
time_out=timeout,
|
|
620
630
|
)
|
|
621
631
|
raise_for_response(r)
|
|
@@ -6,7 +6,11 @@ from uuid import UUID
|
|
|
6
6
|
|
|
7
7
|
from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator, model_validator
|
|
8
8
|
|
|
9
|
-
from mainsequence._request_identity import
|
|
9
|
+
from mainsequence._request_identity import (
|
|
10
|
+
RequestIdentityError,
|
|
11
|
+
_caller_read_headers,
|
|
12
|
+
_get_request_identity,
|
|
13
|
+
)
|
|
10
14
|
from mainsequence.defaults import STANDARD_BACKEND_URL
|
|
11
15
|
|
|
12
16
|
from .base import (
|
|
@@ -336,7 +340,18 @@ class TeamMembershipUpdateResult(BasePydanticModel):
|
|
|
336
340
|
)
|
|
337
341
|
|
|
338
342
|
|
|
339
|
-
class
|
|
343
|
+
class _CallerDirectoryReadMixin:
|
|
344
|
+
"""Inside ``reads_as_caller()``, ``filter`` and ``get`` reads answer as the
|
|
345
|
+
request's caller (ADR-0036, amended for #198)."""
|
|
346
|
+
|
|
347
|
+
@classmethod
|
|
348
|
+
def _read_request_headers(cls, url: str) -> dict[str, str]:
|
|
349
|
+
return _caller_read_headers(url, cls.get_object_url())
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class Team(
|
|
353
|
+
_CallerDirectoryReadMixin, PermissionManagedObjectMixin, BasePydanticModel, UserApiBaseObjectOrm
|
|
354
|
+
):
|
|
340
355
|
ENDPOINT: ClassVar[str] = "teams"
|
|
341
356
|
FILTERSET_FIELDS: ClassVar[dict[str, list[str]] | None] = {
|
|
342
357
|
"uid": ["exact", "in"],
|
|
@@ -960,7 +975,7 @@ class ShareableAccessState(BasePydanticModel):
|
|
|
960
975
|
)
|
|
961
976
|
|
|
962
977
|
|
|
963
|
-
class User(UserApiBaseObjectOrm, BasePydanticModel):
|
|
978
|
+
class User(_CallerDirectoryReadMixin, UserApiBaseObjectOrm, BasePydanticModel):
|
|
964
979
|
"""A platform User: a person, or the workload identity a deployed Job,
|
|
965
980
|
FastAPI release or Agent runs as.
|
|
966
981
|
|
|
@@ -974,6 +989,10 @@ class User(UserApiBaseObjectOrm, BasePydanticModel):
|
|
|
974
989
|
The reverse direction is ``workload_user_uid`` on ``Job``,
|
|
975
990
|
``ResourceRelease`` and ``Agent``: the UID of the User that workload runs
|
|
976
991
|
as, to pass to ``User.get_by_uid`` and to sharing calls.
|
|
992
|
+
|
|
993
|
+
Inside ``mainsequence.server.fastapi.reads_as_caller()``, ``User.filter``
|
|
994
|
+
and ``User.get_by_uid`` answer with what the request's caller may see, and
|
|
995
|
+
workload rows carry ``managed_by_caller``.
|
|
977
996
|
"""
|
|
978
997
|
|
|
979
998
|
# Platform facts, not application authorization policy. None means the
|
|
@@ -986,6 +1005,7 @@ class User(UserApiBaseObjectOrm, BasePydanticModel):
|
|
|
986
1005
|
"uid": ["exact", "in"],
|
|
987
1006
|
"email": ["exact", "contains", "in"],
|
|
988
1007
|
"identity_type": ["exact"],
|
|
1008
|
+
"search": ["exact"],
|
|
989
1009
|
}
|
|
990
1010
|
FILTER_VALUE_NORMALIZERS: ClassVar[dict[str, str]] = {
|
|
991
1011
|
"uid": "uid",
|
|
@@ -994,6 +1014,7 @@ class User(UserApiBaseObjectOrm, BasePydanticModel):
|
|
|
994
1014
|
"email__contains": "str",
|
|
995
1015
|
"email__in": "str",
|
|
996
1016
|
"identity_type": "str",
|
|
1017
|
+
"search": "str",
|
|
997
1018
|
}
|
|
998
1019
|
|
|
999
1020
|
id: int | None = Field(
|
|
@@ -1038,6 +1059,15 @@ class User(UserApiBaseObjectOrm, BasePydanticModel):
|
|
|
1038
1059
|
description="UID of the Agent a workload identity belongs to; None otherwise.",
|
|
1039
1060
|
examples=[None],
|
|
1040
1061
|
)
|
|
1062
|
+
managed_by_caller: bool | None = Field(
|
|
1063
|
+
None,
|
|
1064
|
+
title="Managed By Caller",
|
|
1065
|
+
description=(
|
|
1066
|
+
"On a workload identity read inside `reads_as_caller()`: whether the "
|
|
1067
|
+
"request's caller manages that workload. None when the row does not carry it."
|
|
1068
|
+
),
|
|
1069
|
+
examples=[True],
|
|
1070
|
+
)
|
|
1041
1071
|
profile_picture: str | None = Field(
|
|
1042
1072
|
None,
|
|
1043
1073
|
title="Profile Picture URL",
|
|
@@ -16,7 +16,8 @@ import requests
|
|
|
16
16
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
17
17
|
from jwt import InvalidKeyError, InvalidTokenError
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
from mainsequence._request_identity import CALLER_ASSERTION_HEADER as ASSERTION_HEADER
|
|
20
|
+
|
|
20
21
|
ASSERTION_TYPE = "mainsequence-caller-assertion+jwt"
|
|
21
22
|
ASSERTION_ALGORITHM = "EdDSA"
|
|
22
23
|
MAX_ASSERTION_SECONDS = 300
|
|
@@ -6,11 +6,12 @@ import asyncio
|
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
8
|
import re
|
|
9
|
+
from contextlib import contextmanager
|
|
9
10
|
from uuid import UUID
|
|
10
11
|
|
|
11
12
|
import requests
|
|
12
13
|
|
|
13
|
-
from mainsequence._request_identity import _request_scope
|
|
14
|
+
from mainsequence._request_identity import _reads_as_caller, _request_scope
|
|
14
15
|
from mainsequence.client.models_user import RequestUserIdentity
|
|
15
16
|
|
|
16
17
|
from .caller_assertions import (
|
|
@@ -220,6 +221,7 @@ class _RequestIdentityMiddleware:
|
|
|
220
221
|
)
|
|
221
222
|
state["resource_release_uid"] = caller.release_uid
|
|
222
223
|
state["organization_environment_uid"] = caller.environment_uid
|
|
224
|
+
context.caller_assertion = assertion
|
|
223
225
|
else:
|
|
224
226
|
user = await asyncio.to_thread(_local_user, scope)
|
|
225
227
|
context.user = user
|
|
@@ -267,4 +269,19 @@ def install_request_identity(app):
|
|
|
267
269
|
)
|
|
268
270
|
|
|
269
271
|
|
|
270
|
-
|
|
272
|
+
@contextmanager
|
|
273
|
+
def reads_as_caller():
|
|
274
|
+
"""Read the directory as the request's caller instead of as the application.
|
|
275
|
+
|
|
276
|
+
Inside the block, ``User.filter``, ``User.get_by_uid``, ``Team.filter`` and
|
|
277
|
+
``Team.get_by_uid`` present the caller assertion the request arrived with,
|
|
278
|
+
and the platform answers with what the caller may see. No other call sends
|
|
279
|
+
it. Raises ``RequestIdentityError`` outside an authenticated request or when
|
|
280
|
+
the request carries no assertion (local mode, WebSockets); it never reads as
|
|
281
|
+
the application instead. See ADR-0036.
|
|
282
|
+
"""
|
|
283
|
+
with _reads_as_caller():
|
|
284
|
+
yield
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
__all__ = ["install_request_identity", "reads_as_caller"]
|
|
@@ -11,13 +11,14 @@ from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
|
11
11
|
from fastapi import FastAPI, Request, WebSocket
|
|
12
12
|
from fastapi.testclient import TestClient
|
|
13
13
|
|
|
14
|
+
import mainsequence.client.base as base_mod
|
|
14
15
|
from mainsequence.client import RequestIdentityError, User
|
|
15
16
|
from mainsequence.server.caller_assertions import (
|
|
16
17
|
ASSERTION_HEADER,
|
|
17
18
|
ASSERTION_TYPE,
|
|
18
19
|
CallerAssertionVerifier,
|
|
19
20
|
)
|
|
20
|
-
from mainsequence.server.fastapi import install_request_identity
|
|
21
|
+
from mainsequence.server.fastapi import install_request_identity, reads_as_caller
|
|
21
22
|
|
|
22
23
|
USER = str(uuid4())
|
|
23
24
|
OTHER = str(uuid4())
|
|
@@ -372,3 +373,142 @@ def test_websocket_invalid_identity_cannot_reach_app(headers, denial_extension):
|
|
|
372
373
|
assert messages[0]["code"] == 1008
|
|
373
374
|
with pytest.raises(RequestIdentityError):
|
|
374
375
|
User.get_logged_user()
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class _Page:
|
|
379
|
+
status_code = 200
|
|
380
|
+
|
|
381
|
+
def __init__(self, results=()):
|
|
382
|
+
self._results = list(results)
|
|
383
|
+
|
|
384
|
+
def json(self):
|
|
385
|
+
return {"results": self._results, "next": None}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_sync_route_reads_as_its_caller(signed, monkeypatch):
|
|
389
|
+
token, _, _ = signed
|
|
390
|
+
proof = token()
|
|
391
|
+
workload = str(uuid4())
|
|
392
|
+
sent = []
|
|
393
|
+
|
|
394
|
+
def _fake_make_request(*, s, loaders, r_type, url, payload=None, time_out=None):
|
|
395
|
+
sent.append(payload)
|
|
396
|
+
row = {
|
|
397
|
+
"uid": workload,
|
|
398
|
+
"identity_type": "workload",
|
|
399
|
+
"is_active": True,
|
|
400
|
+
"managed_by_caller": True,
|
|
401
|
+
}
|
|
402
|
+
return _Page([row])
|
|
403
|
+
|
|
404
|
+
monkeypatch.setattr(base_mod, "make_request", _fake_make_request)
|
|
405
|
+
|
|
406
|
+
def handler(q: str):
|
|
407
|
+
with reads_as_caller():
|
|
408
|
+
rows = User.filter(identity_type="workload", search=q)
|
|
409
|
+
return [{"uid": row.uid, "managed": row.managed_by_caller} for row in rows]
|
|
410
|
+
|
|
411
|
+
with TestClient(app_for(handler, path="/candidates")) as client:
|
|
412
|
+
response = client.get(
|
|
413
|
+
"/candidates", params={"q": "prices"}, headers={ASSERTION_HEADER: proof}
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
assert response.json() == [{"uid": workload, "managed": True}]
|
|
417
|
+
assert sent == [
|
|
418
|
+
{
|
|
419
|
+
"params": {"identity_type": "workload", "search": "prices"},
|
|
420
|
+
"headers": {ASSERTION_HEADER: proof},
|
|
421
|
+
}
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_concurrent_requests_present_their_own_assertions(signed, monkeypatch):
|
|
426
|
+
from mainsequence.server.fastapi import _RequestIdentityMiddleware
|
|
427
|
+
|
|
428
|
+
token, _, verifier = signed
|
|
429
|
+
proofs = {USER: token(USER), OTHER: token(OTHER)}
|
|
430
|
+
presented = {USER: [], OTHER: []}
|
|
431
|
+
entered = 0
|
|
432
|
+
release = asyncio.Event()
|
|
433
|
+
|
|
434
|
+
def _fake_make_request(*, s, loaders, r_type, url, payload=None, time_out=None):
|
|
435
|
+
presented[User.get_logged_user().uid].append((payload or {}).get("headers"))
|
|
436
|
+
return _Page()
|
|
437
|
+
|
|
438
|
+
monkeypatch.setattr(base_mod, "make_request", _fake_make_request)
|
|
439
|
+
|
|
440
|
+
async def application(scope, receive, send):
|
|
441
|
+
nonlocal entered
|
|
442
|
+
entered += 1
|
|
443
|
+
if entered == 2:
|
|
444
|
+
release.set()
|
|
445
|
+
await release.wait()
|
|
446
|
+
with reads_as_caller():
|
|
447
|
+
User.filter(search="prices")
|
|
448
|
+
await asyncio.sleep(0) # the other request reads while this one is inside
|
|
449
|
+
User.filter(search="prices")
|
|
450
|
+
User.filter(search="prices")
|
|
451
|
+
await send({"type": "http.response.start", "status": 200, "headers": []})
|
|
452
|
+
await send({"type": "http.response.body", "body": b""})
|
|
453
|
+
|
|
454
|
+
runtime = _RequestIdentityMiddleware(
|
|
455
|
+
application, mode="assertion", verifier=verifier, public_ingress=(), routes=()
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
async def run():
|
|
459
|
+
async def request(user):
|
|
460
|
+
scope = {
|
|
461
|
+
"type": "http",
|
|
462
|
+
"method": "GET",
|
|
463
|
+
"path": "/",
|
|
464
|
+
"headers": [(ASSERTION_HEADER.lower().encode(), proofs[user].encode())],
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async def receive():
|
|
468
|
+
return {"type": "http.request", "body": b""}
|
|
469
|
+
|
|
470
|
+
async def send(message):
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
await runtime(scope, receive, send)
|
|
474
|
+
|
|
475
|
+
await asyncio.gather(request(USER), request(OTHER))
|
|
476
|
+
|
|
477
|
+
asyncio.run(run())
|
|
478
|
+
for user in (USER, OTHER):
|
|
479
|
+
assert presented[user] == [{ASSERTION_HEADER: proofs[user]}] * 2 + [None]
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def test_reads_as_caller_needs_a_signed_http_request(monkeypatch):
|
|
483
|
+
monkeypatch.setenv("MAINSEQUENCE_ENDPOINT", ISSUER)
|
|
484
|
+
response = Mock(status_code=200)
|
|
485
|
+
response.json.return_value = {"uid": USER, "username": "local"}
|
|
486
|
+
monkeypatch.setattr("mainsequence.server.fastapi.requests.get", Mock(return_value=response))
|
|
487
|
+
|
|
488
|
+
def handler():
|
|
489
|
+
with pytest.raises(RequestIdentityError, match="no caller assertion"):
|
|
490
|
+
with reads_as_caller():
|
|
491
|
+
pass
|
|
492
|
+
return {"uid": User.get_logged_user().uid}
|
|
493
|
+
|
|
494
|
+
async def ws(websocket: WebSocket):
|
|
495
|
+
await websocket.accept()
|
|
496
|
+
with pytest.raises(RequestIdentityError, match="no caller assertion"):
|
|
497
|
+
with reads_as_caller():
|
|
498
|
+
pass
|
|
499
|
+
await websocket.send_json({"uid": User.get_logged_user().uid})
|
|
500
|
+
await websocket.close()
|
|
501
|
+
|
|
502
|
+
app = FastAPI()
|
|
503
|
+
app.get("/me")(handler)
|
|
504
|
+
app.websocket("/ws")(ws)
|
|
505
|
+
install_request_identity(app)
|
|
506
|
+
client = TestClient(app)
|
|
507
|
+
|
|
508
|
+
local = client.get("/me", headers={"Authorization": "Bearer local-token"})
|
|
509
|
+
assert local.json() == {"uid": USER}
|
|
510
|
+
with client.websocket_connect("/ws", headers={"X-User-UID": USER}) as connection:
|
|
511
|
+
assert connection.receive_json() == {"uid": USER}
|
|
512
|
+
with pytest.raises(RequestIdentityError, match="authenticated request"):
|
|
513
|
+
with reads_as_caller():
|
|
514
|
+
pass
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
"""Private lifecycle shared by the request integration and the public user getter."""
|
|
2
|
-
|
|
3
|
-
from contextlib import contextmanager
|
|
4
|
-
from contextvars import ContextVar
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class RequestIdentityError(RuntimeError):
|
|
10
|
-
"""No authenticated caller is bound to the current request."""
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@dataclass
|
|
14
|
-
class _RequestScope:
|
|
15
|
-
user: Any = None
|
|
16
|
-
active: bool = True
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
_current_scope: ContextVar[_RequestScope | None] = ContextVar(
|
|
20
|
-
"mainsequence_request_identity", default=None
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@contextmanager
|
|
25
|
-
def _request_scope():
|
|
26
|
-
scope = _RequestScope()
|
|
27
|
-
token = _current_scope.set(scope)
|
|
28
|
-
try:
|
|
29
|
-
yield scope
|
|
30
|
-
finally:
|
|
31
|
-
# Copied child-task contexts hold this same frame and must also expire.
|
|
32
|
-
scope.active = False
|
|
33
|
-
scope.user = None
|
|
34
|
-
_current_scope.reset(token)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _get_request_identity():
|
|
38
|
-
scope = _current_scope.get()
|
|
39
|
-
if scope is None or not scope.active or scope.user is None:
|
|
40
|
-
raise RequestIdentityError(
|
|
41
|
-
"No authenticated request user is available. Install request identity "
|
|
42
|
-
"once in the application and call User.get_logged_user() inside a request."
|
|
43
|
-
)
|
|
44
|
-
return scope.user
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/code_repository_status.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/agent_runtime_models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/repository_identity_security.py
RENAMED
|
File without changes
|
{mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/runtime_credential_exchange.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_canonical_api_route_contract.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_code_repository_branch_contract.py
RENAMED
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_code_repository_runtime_context.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_environment_resource_context.py
RENAMED
|
File without changes
|
{mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_environment_response_contracts.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_models_user_request_bound_auth.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|