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.
Files changed (105) hide show
  1. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/PKG-INFO +1 -1
  2. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +7 -0
  3. mainsequence-9.0.12.dev51/mainsequence/_request_identity.py +98 -0
  4. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/base.py +12 -2
  5. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/models_user.py +33 -3
  6. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/server/caller_assertions.py +2 -1
  7. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/server/fastapi.py +19 -2
  8. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/PKG-INFO +1 -1
  9. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/pyproject.toml +1 -1
  10. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_fastapi_request_identity.py +141 -1
  11. mainsequence-9.0.11/mainsequence/_request_identity.py +0 -44
  12. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/LICENSE +0 -0
  13. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/README.md +0 -0
  14. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/AGENTS.md +0 -0
  15. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
  16. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
  17. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  18. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
  19. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
  20. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
  21. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
  22. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
  23. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/__init__.py +0 -0
  24. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/__main__.py +0 -0
  25. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/bootstrap.py +0 -0
  26. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/__init__.py +0 -0
  27. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/api.py +0 -0
  28. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/browser_auth.py +0 -0
  29. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/cli.py +0 -0
  30. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/code_repository_status.py +0 -0
  31. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/config.py +0 -0
  32. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/docker_utils.py +0 -0
  33. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/doctor.py +0 -0
  34. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/local_ops.py +0 -0
  35. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/model_filters.py +0 -0
  36. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/pydantic_cli.py +0 -0
  37. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/sdk_utils.py +0 -0
  38. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/ssh_utils.py +0 -0
  39. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/cli/ui.py +0 -0
  40. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/__init__.py +0 -0
  41. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/_transport.py +0 -0
  42. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/agent_runtime_models.py +0 -0
  43. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/client.py +0 -0
  44. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/compute_validation.py +0 -0
  45. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/exceptions.py +0 -0
  46. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/github_issues.py +0 -0
  47. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/inference.py +0 -0
  48. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/models_foundry.py +0 -0
  49. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/models_helpers.py +0 -0
  50. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/observability.py +0 -0
  51. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/utils.py +0 -0
  52. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/client/value_sets.py +0 -0
  53. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/code_repository_context.py +0 -0
  54. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/code_repository_skills.py +0 -0
  55. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/defaults.py +0 -0
  56. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/instrumentation/__init__.py +0 -0
  57. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/instrumentation/utils.py +0 -0
  58. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/logconf.py +0 -0
  59. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/repository_identity_security.py +0 -0
  60. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/runtime_credential_exchange.py +0 -0
  61. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/runtime_flags.py +0 -0
  62. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/scaffold_skills.py +0 -0
  63. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence/server/__init__.py +0 -0
  64. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/SOURCES.txt +0 -0
  65. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/dependency_links.txt +0 -0
  66. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/entry_points.txt +0 -0
  67. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/requires.txt +0 -0
  68. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/mainsequence.egg-info/top_level.txt +0 -0
  69. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/setup.cfg +0 -0
  70. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_auth_bootstrap.py +0 -0
  71. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_auth_precedence.py +0 -0
  72. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_caller_assertions.py +0 -0
  73. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_canonical_api_route_contract.py +0 -0
  74. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_cli_auth_contract.py +0 -0
  75. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_cli_browser_auth.py +0 -0
  76. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_cli_secure_storage.py +0 -0
  77. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_client.py +0 -0
  78. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_code_repository_branch_contract.py +0 -0
  79. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_code_repository_documentation_contract.py +0 -0
  80. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_code_repository_runtime_context.py +0 -0
  81. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_code_repository_skills.py +0 -0
  82. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_credential_repr.py +0 -0
  83. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_dependency_extras.py +0 -0
  84. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_dev_release_version.py +0 -0
  85. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_environment_resource_context.py +0 -0
  86. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_environment_response_contracts.py +0 -0
  87. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_github_issues_client.py +0 -0
  88. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_http_retry_budget.py +0 -0
  89. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_independent_context.py +0 -0
  90. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_inference_client.py +0 -0
  91. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_instrumentation.py +0 -0
  92. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_job_client.py +0 -0
  93. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_local_ops.py +0 -0
  94. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_logconf.py +0 -0
  95. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_models_user_request_bound_auth.py +0 -0
  96. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_notification_uid_contract.py +0 -0
  97. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_observability_client.py +0 -0
  98. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_runtime_credential_exchange.py +0 -0
  99. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_scaffold_skills.py +0 -0
  100. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_secret_client_model.py +0 -0
  101. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_source_ownership.py +0 -0
  102. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_ssh_utils.py +0 -0
  103. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_tolerant_response_reading.py +0 -0
  104. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_tolerant_value_sets.py +0 -0
  105. {mainsequence-9.0.11 → mainsequence-9.0.12.dev51}/tests/test_user_application_facts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.11
3
+ Version: 9.0.12.dev51
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -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={"params": params}, # neede to pass special serializer
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 RequestIdentityError, _get_request_identity
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 Team(PermissionManagedObjectMixin, BasePydanticModel, UserApiBaseObjectOrm):
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
- ASSERTION_HEADER = "X-MainSequence-Caller-Assertion"
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
- __all__ = ["install_request_identity"]
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"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.11
3
+ Version: 9.0.12.dev51
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mainsequence"
7
- version = "9.0.11"
7
+ version = "9.0.12.dev51"
8
8
  description = "Main Sequence SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -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