mainsequence 9.0.24__tar.gz → 9.0.25__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 (112) hide show
  1. {mainsequence-9.0.24/mainsequence.egg-info → mainsequence-9.0.25}/PKG-INFO +1 -1
  2. {mainsequence-9.0.24 → mainsequence-9.0.25}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +4 -3
  3. {mainsequence-9.0.24 → mainsequence-9.0.25}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +3 -2
  4. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/_request_identity.py +29 -0
  5. mainsequence-9.0.25/mainsequence/_team_memberships.py +54 -0
  6. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/models_user.py +141 -12
  7. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/server/caller_assertions.py +28 -15
  8. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/server/fastapi.py +29 -2
  9. {mainsequence-9.0.24 → mainsequence-9.0.25/mainsequence.egg-info}/PKG-INFO +1 -1
  10. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence.egg-info/SOURCES.txt +2 -0
  11. {mainsequence-9.0.24 → mainsequence-9.0.25}/pyproject.toml +1 -1
  12. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_caller_assertions.py +33 -8
  13. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_fastapi_mcp.py +0 -1
  14. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_fastapi_request_identity.py +0 -5
  15. mainsequence-9.0.25/tests/test_team_memberships.py +432 -0
  16. {mainsequence-9.0.24 → mainsequence-9.0.25}/LICENSE +0 -0
  17. {mainsequence-9.0.24 → mainsequence-9.0.25}/README.md +0 -0
  18. {mainsequence-9.0.24 → mainsequence-9.0.25}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
  19. {mainsequence-9.0.24 → mainsequence-9.0.25}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
  20. {mainsequence-9.0.24 → mainsequence-9.0.25}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  21. {mainsequence-9.0.24 → mainsequence-9.0.25}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
  22. {mainsequence-9.0.24 → mainsequence-9.0.25}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
  23. {mainsequence-9.0.24 → mainsequence-9.0.25}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
  24. {mainsequence-9.0.24 → mainsequence-9.0.25}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
  25. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/__init__.py +0 -0
  26. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/__main__.py +0 -0
  27. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/_transport.py +0 -0
  28. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/bootstrap.py +0 -0
  29. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/__init__.py +0 -0
  30. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/api.py +0 -0
  31. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/browser_auth.py +0 -0
  32. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/cli.py +0 -0
  33. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/code_repository_status.py +0 -0
  34. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/config.py +0 -0
  35. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/docker_utils.py +0 -0
  36. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/doctor.py +0 -0
  37. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/local_ops.py +0 -0
  38. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/model_filters.py +0 -0
  39. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/pydantic_cli.py +0 -0
  40. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/sdk_utils.py +0 -0
  41. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/ssh_utils.py +0 -0
  42. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/cli/ui.py +0 -0
  43. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/__init__.py +0 -0
  44. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/agent_runtime_models.py +0 -0
  45. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/base.py +0 -0
  46. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/client.py +0 -0
  47. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/compute_validation.py +0 -0
  48. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/exceptions.py +0 -0
  49. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/github_issues.py +0 -0
  50. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/inference.py +0 -0
  51. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/models_foundry.py +0 -0
  52. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/models_helpers.py +0 -0
  53. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/observability.py +0 -0
  54. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/utils.py +0 -0
  55. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/client/value_sets.py +0 -0
  56. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/code_repository_context.py +0 -0
  57. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/code_repository_skills.py +0 -0
  58. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/defaults.py +0 -0
  59. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/instrumentation/__init__.py +0 -0
  60. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/instrumentation/utils.py +0 -0
  61. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/logconf.py +0 -0
  62. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/repository_identity_security.py +0 -0
  63. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/runtime_credential_exchange.py +0 -0
  64. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/runtime_flags.py +0 -0
  65. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/scaffold_skills.py +0 -0
  66. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/server/__init__.py +0 -0
  67. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/server/_mcp_skills_upstream/__init__.py +0 -0
  68. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/server/_mcp_skills_upstream/server.py +0 -0
  69. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/server/_mcp_skills_upstream/shared.py +0 -0
  70. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence/server/mcp_skills.py +0 -0
  71. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence.egg-info/dependency_links.txt +0 -0
  72. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence.egg-info/entry_points.txt +0 -0
  73. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence.egg-info/requires.txt +0 -0
  74. {mainsequence-9.0.24 → mainsequence-9.0.25}/mainsequence.egg-info/top_level.txt +0 -0
  75. {mainsequence-9.0.24 → mainsequence-9.0.25}/setup.cfg +0 -0
  76. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_auth_bootstrap.py +0 -0
  77. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_auth_precedence.py +0 -0
  78. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_canonical_api_route_contract.py +0 -0
  79. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_cli_auth_contract.py +0 -0
  80. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_cli_browser_auth.py +0 -0
  81. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_cli_request_budget.py +0 -0
  82. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_cli_secure_storage.py +0 -0
  83. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_client.py +0 -0
  84. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_code_repository_branch_contract.py +0 -0
  85. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_code_repository_documentation_contract.py +0 -0
  86. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_code_repository_runtime_context.py +0 -0
  87. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_code_repository_skills.py +0 -0
  88. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_credential_repr.py +0 -0
  89. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_dependency_extras.py +0 -0
  90. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_dev_release_version.py +0 -0
  91. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_environment_resource_context.py +0 -0
  92. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_environment_response_contracts.py +0 -0
  93. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_github_issues_client.py +0 -0
  94. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_http_retry_budget.py +0 -0
  95. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_independent_context.py +0 -0
  96. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_inference_client.py +0 -0
  97. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_instrumentation.py +0 -0
  98. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_job_client.py +0 -0
  99. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_local_ops.py +0 -0
  100. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_logconf.py +0 -0
  101. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_mcp_skills.py +0 -0
  102. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_models_user_request_bound_auth.py +0 -0
  103. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_notification_uid_contract.py +0 -0
  104. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_observability_client.py +0 -0
  105. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_runtime_credential_exchange.py +0 -0
  106. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_scaffold_skills.py +0 -0
  107. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_secret_client_model.py +0 -0
  108. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_source_ownership.py +0 -0
  109. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_ssh_utils.py +0 -0
  110. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_tolerant_response_reading.py +0 -0
  111. {mainsequence-9.0.24 → mainsequence-9.0.25}/tests/test_tolerant_value_sets.py +0 -0
  112. {mainsequence-9.0.24 → mainsequence-9.0.25}/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.24
3
+ Version: 9.0.25
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -167,8 +167,9 @@ a person: the requester. The platform signs the requester into the caller
167
167
  assertion it sends to the API, and the SDK verifies it before the handler runs.
168
168
  `User.get_logged_user()` still returns the caller, the acting application.
169
169
  `User.get_requester()` returns the requester, the checked delegation, with the
170
- person's own `uid`, `team_uids` and `is_organization_admin`, or `None` when the
171
- call carries no delegation. A requester-bound call carries the person's
170
+ person's own `uid` and `is_organization_admin`, or `None` when the call carries
171
+ no delegation. `in_teams(team_uids)` on either identity asks the platform which
172
+ of those teams the person belongs to; `team_uids` is deprecated. A requester-bound call carries the person's
172
173
  ordinary permissions, including administrative ones.
173
174
 
174
175
  Each handler decides whether it needs a person. One that needs a person fails
@@ -191,7 +192,7 @@ def require_requester() -> RequestUserIdentity:
191
192
  @app.get("/team-reports/{team_uid}")
192
193
  def team_report(team_uid: str) -> dict[str, str]:
193
194
  requester = require_requester()
194
- if not (requester.is_organization_admin or team_uid in requester.team_uids):
195
+ if not (requester.is_organization_admin or requester.in_teams([team_uid])):
195
196
  raise HTTPException(status_code=403, detail="The requester cannot read this report.")
196
197
  return {"team_uid": team_uid, "requested_by": requester.uid}
197
198
 
@@ -271,8 +271,9 @@ The requester comes only from the platform. An application never accepts a
271
271
  person's UID from a request body, header or query parameter, never parses the
272
272
  assertion, headers or MCP `_meta` itself, and never lets a caller choose whom
273
273
  it acts for. A FastAPI application that receives a requester-bound call reads
274
- the requester with `User.get_requester()`, the person's own `uid`, `team_uids`
275
- and `is_organization_admin`, and authorizes reads and writes against that
274
+ the requester with `User.get_requester()`, the person's own `uid` and
275
+ `is_organization_admin`, checks the person's teams with `in_teams()`, and
276
+ authorizes reads and writes against that
276
277
  person's object- and operation-specific permissions. Each handler or tool
277
278
  decides whether it needs a person. See
278
279
  `.agents/skills/mainsequence/application_surfaces/api_surfaces/SKILL.md`.
@@ -24,6 +24,15 @@ class _RequestScope:
24
24
  # assertion; None on any other request. The user stays the caller, the
25
25
  # acting application (ADR-0036, requester-bound calls).
26
26
  requester: Any = None
27
+ # The release and Environment the verifier trusts, from deployment
28
+ # configuration, never from the request. They key membership answers (#225).
29
+ release_uid: str | None = None
30
+ environment_uid: str | None = None
31
+ # in_teams() answers within this request, and the application's cache of
32
+ # answers across requests with its lifetime (ADR-0036, #225).
33
+ team_memberships: dict = field(default_factory=dict, repr=False)
34
+ team_membership_cache: Any = field(default=None, repr=False)
35
+ team_membership_cache_seconds: float = 0
27
36
 
28
37
 
29
38
  _current_scope: ContextVar[_RequestScope | None] = ContextVar(
@@ -47,6 +56,8 @@ def _request_scope():
47
56
  scope.user = None
48
57
  scope.caller_assertion = None
49
58
  scope.requester = None
59
+ scope.team_memberships = {}
60
+ scope.team_membership_cache = None
50
61
  _current_scope.reset(token)
51
62
 
52
63
 
@@ -64,6 +75,24 @@ def _get_request_identity():
64
75
  return _authenticated_scope("User.get_logged_user()").user
65
76
 
66
77
 
78
+ def _team_membership_scope(person_uid: str) -> _RequestScope:
79
+ """The request whose caller or requester ``person_uid`` is, for in_teams()."""
80
+ scope = _authenticated_scope("in_teams()")
81
+ if scope.caller_assertion is None:
82
+ raise RequestIdentityError(
83
+ "This request carries no caller assertion. Team membership checks need a "
84
+ "hosted HTTP request in assertion mode, not local mode or a WebSocket."
85
+ )
86
+ people = {scope.user.uid}
87
+ if scope.requester is not None:
88
+ people.add(scope.requester.uid)
89
+ if person_uid not in people:
90
+ raise RequestIdentityError(
91
+ "in_teams() answers only for the request's caller or its requester."
92
+ )
93
+ return scope
94
+
95
+
67
96
  def _get_requester():
68
97
  """The requester of the current request; None when it is not requester-bound."""
69
98
  return _authenticated_scope("User.get_requester()").requester
@@ -0,0 +1,54 @@
1
+ """Team membership answers an application may reuse (ADR 0036, #225)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from collections import OrderedDict
8
+
9
+ # At most this many answers per process, and at most this many teams in one
10
+ # call to the platform, which answers no more in one call.
11
+ MAX_CACHED_ANSWERS = 10_000
12
+ MAX_TEAMS_PER_CALL = 100
13
+
14
+ # The trusted release UID, Environment UID, person UID and team UID.
15
+ AnswerKey = tuple[str, str, str, str]
16
+
17
+
18
+ def _now() -> float:
19
+ return time.monotonic()
20
+
21
+
22
+ class TeamMembershipCache:
23
+ """Yes-or-no answers across requests, oldest dropped first.
24
+
25
+ The key starts with the release and Environment the verifier trusts, so an
26
+ answer obtained for one never answers the other.
27
+ """
28
+
29
+ def __init__(self, max_answers: int = MAX_CACHED_ANSWERS):
30
+ self._answers: OrderedDict[AnswerKey, tuple[bool, float]] = OrderedDict()
31
+ self._max_answers = max_answers
32
+ self._lock = threading.Lock()
33
+
34
+ def get(self, key: AnswerKey, *, max_age: float) -> bool | None:
35
+ """The stored answer when it is younger than ``max_age`` seconds."""
36
+ if max_age <= 0:
37
+ return None
38
+ with self._lock:
39
+ entry = self._answers.get(key)
40
+ if entry is None:
41
+ return None
42
+ answer, stored_at = entry
43
+ return answer if _now() - stored_at < max_age else None
44
+
45
+ def put(self, key: AnswerKey, answer: bool) -> None:
46
+ with self._lock:
47
+ self._answers[key] = (answer, _now())
48
+ self._answers.move_to_end(key)
49
+ while len(self._answers) > self._max_answers:
50
+ self._answers.popitem(last=False)
51
+
52
+ def __len__(self) -> int:
53
+ with self._lock:
54
+ return len(self._answers)
@@ -1,17 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import datetime
4
+ import math
5
+ import warnings
6
+ from collections.abc import Iterable
4
7
  from typing import Any, ClassVar, Literal
5
8
  from uuid import UUID
6
9
 
7
10
  from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator, model_validator
8
11
 
9
12
  from mainsequence._request_identity import (
13
+ CALLER_ASSERTION_HEADER,
10
14
  RequestIdentityError,
11
15
  _caller_read_headers,
12
16
  _get_request_identity,
13
17
  _get_requester,
18
+ _team_membership_scope,
14
19
  )
20
+ from mainsequence._team_memberships import MAX_TEAMS_PER_CALL
15
21
  from mainsequence.defaults import STANDARD_BACKEND_URL
16
22
 
17
23
  from .base import (
@@ -33,11 +39,11 @@ class RequestUserIdentity(BaseModel):
33
39
  The caller is a person or a workload identity; ``User.get_by_uid(uid)``
34
40
  reads its User. ``User.get_requester()`` returns the same type for the
35
41
  requester of a requester-bound request, the person the calling
36
- application works for, with that person's own ``team_uids`` and
37
- ``is_organization_admin``.
42
+ application works for, with that person's own ``is_organization_admin``.
43
+ ``in_teams()`` asks the platform which teams either of them belongs to.
38
44
  """
39
45
 
40
- model_config = ConfigDict(extra="forbid", frozen=True)
46
+ model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
41
47
 
42
48
  uid: str = Field(
43
49
  ...,
@@ -52,13 +58,15 @@ class RequestUserIdentity(BaseModel):
52
58
  title="Username",
53
59
  description="Optional display name authenticated by the runtime gateway.",
54
60
  )
55
- team_uids: tuple[str, ...] = Field(
61
+ signed_team_uids: tuple[str, ...] | None = Field(
56
62
  (),
57
- title="Team UIDs",
63
+ alias="team_uids",
64
+ title="Signed team UIDs (deprecated)",
58
65
  description=(
59
66
  "Canonical UIDs of the active teams the platform signed in the caller "
60
- "assertion: the caller's, or for the requester the person's own. Empty in "
61
- "local mode and on WebSockets, which carry no such facts."
67
+ "assertion, None when it signed none. Deprecated: read membership with "
68
+ "in_teams() (#225). Empty in local mode and on WebSockets, which carry no "
69
+ "such facts."
62
70
  ),
63
71
  )
64
72
  is_organization_admin: bool = Field(
@@ -79,14 +87,64 @@ class RequestUserIdentity(BaseModel):
79
87
  except (AttributeError, TypeError, ValueError) as exc:
80
88
  raise ValueError("Request user UID must be a valid UUID.") from exc
81
89
 
82
- @field_validator("team_uids", mode="before")
90
+ @field_validator("signed_team_uids", mode="before")
83
91
  @classmethod
84
- def _canonicalize_team_uids(cls, value: Any) -> tuple[str, ...]:
92
+ def _canonicalize_team_uids(cls, value: Any) -> tuple[str, ...] | None:
93
+ if value is None:
94
+ return None
85
95
  try:
86
- return tuple(str(UUID(str(item).strip())) for item in value or ())
96
+ return tuple(str(UUID(str(item).strip())) for item in value)
87
97
  except (AttributeError, TypeError, ValueError) as exc:
88
98
  raise ValueError("Team UIDs must be valid UUIDs.") from exc
89
99
 
100
+ @property
101
+ def team_uids(self) -> tuple[str, ...]:
102
+ """Deprecated: the team UIDs the platform signed. Use ``in_teams()``.
103
+
104
+ Warns when read. When the caller assertion carried no team list it
105
+ raises ``RequestIdentityError``; it never answers an empty list for
106
+ that. Empty in local mode and on WebSockets, as before (#225).
107
+ """
108
+ warnings.warn(
109
+ "RequestUserIdentity.team_uids is deprecated; check membership with "
110
+ "in_teams() instead.",
111
+ DeprecationWarning,
112
+ stacklevel=2,
113
+ )
114
+ if self.signed_team_uids is None:
115
+ raise RequestIdentityError(
116
+ "The caller assertion carries no team list. Check team membership "
117
+ "with in_teams()."
118
+ )
119
+ return self.signed_team_uids
120
+
121
+ def in_teams(
122
+ self, team_uids: Iterable[str | UUID], *, max_age_seconds: float | None = None
123
+ ) -> frozenset[str]:
124
+ """Which of ``team_uids`` this person is an active member of.
125
+
126
+ Works on ``User.get_logged_user()`` and ``User.get_requester()`` inside
127
+ an authenticated hosted request, and asks the platform for the request's
128
+ caller or requester only. Returns canonical team UIDs; an empty set
129
+ means none. Teams the platform does not count, such as unknown or
130
+ inactive ones, are left out.
131
+
132
+ Reuse is the application's choice (``install_request_identity``):
133
+
134
+ - within one request the same question is asked once;
135
+ - across requests an answer is reused while it is younger than
136
+ ``team_membership_cache_seconds``, or than ``max_age_seconds`` when
137
+ given; ``0``, the default, reuses none across requests;
138
+ - ``max_age_seconds=0`` is a fresh check: it always asks the platform,
139
+ skips both kinds of reuse and stores the new answer. Ask for one
140
+ before acting on a membership that must be current.
141
+
142
+ Raises ``RequestIdentityError`` outside such a request, in local mode
143
+ and on WebSockets, and the SDK's API error when the platform cannot
144
+ answer; it never answers "not a member" because a call failed.
145
+ """
146
+ return _member_team_uids(self.uid, team_uids, max_age_seconds)
147
+
90
148
  @field_validator("username", mode="before")
91
149
  @classmethod
92
150
  def _normalize_username(cls, value: Any) -> str | None:
@@ -1314,8 +1372,9 @@ class User(_CallerDirectoryReadMixin, UserApiBaseObjectOrm, BasePydanticModel):
1314
1372
  an Agent, while it works for a person: the requester. The platform
1315
1373
  signs that person into the caller assertion, and the request identity
1316
1374
  integration verifies it before any handler or tool runs. The result is
1317
- that checked delegation: the person's own ``uid``, ``team_uids`` and
1318
- ``is_organization_admin``, as the platform signed them.
1375
+ that checked delegation: the person's own ``uid`` and
1376
+ ``is_organization_admin``, as the platform signed them, and
1377
+ ``in_teams()`` for the person's teams.
1319
1378
  ``get_logged_user()`` still returns the caller, the acting application.
1320
1379
 
1321
1380
  Returns ``None`` when the request carries no delegation, including
@@ -1347,3 +1406,73 @@ class User(_CallerDirectoryReadMixin, UserApiBaseObjectOrm, BasePydanticModel):
1347
1406
  if requester is not None and not isinstance(requester, RequestUserIdentity):
1348
1407
  raise RequestIdentityError("Invalid request requester context value.")
1349
1408
  return requester
1409
+
1410
+ @classmethod
1411
+ def _team_memberships(cls, person_uid: str, team_uids: list[str], assertion: str) -> set[str]:
1412
+ """Which of ``team_uids`` the platform says the person belongs to (#225)."""
1413
+ response = make_request(
1414
+ s=cls.build_session(),
1415
+ loaders=cls.LOADERS,
1416
+ r_type="GET",
1417
+ url=f"{cls.get_object_url()}/{person_uid}/team-memberships/",
1418
+ payload={
1419
+ "params": {"team_uid__in": ",".join(team_uids)},
1420
+ "headers": {CALLER_ASSERTION_HEADER: assertion},
1421
+ },
1422
+ )
1423
+ if response.status_code != 200:
1424
+ # Never pass the payload: it holds the caller assertion.
1425
+ raise_for_response(response)
1426
+ answered = response.json().get("team_uids")
1427
+ if not isinstance(answered, list):
1428
+ raise ApiError("Invalid team membership answer.", response=response)
1429
+ return set(_canonical_team_uids(answered)) & set(team_uids)
1430
+
1431
+
1432
+ def _canonical_team_uids(team_uids: Iterable[str | UUID]) -> list[str]:
1433
+ if isinstance(team_uids, (str, bytes, UUID)):
1434
+ raise TypeError("in_teams() takes a collection of team UIDs, not a single UID.")
1435
+ try:
1436
+ return sorted({str(UUID(str(team_uid).strip())) for team_uid in team_uids})
1437
+ except (AttributeError, TypeError, ValueError) as exc:
1438
+ raise ValueError("Team UIDs must be valid UUIDs.") from exc
1439
+
1440
+
1441
+ def _member_team_uids(
1442
+ person_uid: str, team_uids: Iterable[str | UUID], max_age_seconds: float | None
1443
+ ) -> frozenset[str]:
1444
+ """RequestUserIdentity.in_teams(): reuse answers as allowed, ask for the rest."""
1445
+ asked = _canonical_team_uids(team_uids)
1446
+ if max_age_seconds is not None and (
1447
+ isinstance(max_age_seconds, bool)
1448
+ or not isinstance(max_age_seconds, (int, float))
1449
+ or not math.isfinite(max_age_seconds)
1450
+ or max_age_seconds < 0
1451
+ ):
1452
+ raise ValueError("max_age_seconds must be None or a non-negative number of seconds.")
1453
+ scope = _team_membership_scope(person_uid)
1454
+ fresh = max_age_seconds == 0
1455
+ lifetime = scope.team_membership_cache_seconds if max_age_seconds is None else max_age_seconds
1456
+ cache = scope.team_membership_cache
1457
+ answers: dict[str, bool] = {}
1458
+ unanswered: list[str] = []
1459
+ for team_uid in asked:
1460
+ key = (scope.release_uid, scope.environment_uid, person_uid, team_uid)
1461
+ reused = None
1462
+ if not fresh:
1463
+ reused = scope.team_memberships.get(key)
1464
+ if reused is None and cache is not None:
1465
+ reused = cache.get(key, max_age=lifetime)
1466
+ if reused is None:
1467
+ unanswered.append(team_uid)
1468
+ else:
1469
+ answers[team_uid] = scope.team_memberships[key] = reused
1470
+ for start in range(0, len(unanswered), MAX_TEAMS_PER_CALL):
1471
+ batch = unanswered[start : start + MAX_TEAMS_PER_CALL]
1472
+ members = User._team_memberships(person_uid, batch, scope.caller_assertion)
1473
+ for team_uid in batch:
1474
+ key = (scope.release_uid, scope.environment_uid, person_uid, team_uid)
1475
+ answers[team_uid] = scope.team_memberships[key] = team_uid in members
1476
+ if cache is not None:
1477
+ cache.put(key, answers[team_uid])
1478
+ return frozenset(team_uid for team_uid, member in answers.items() if member)
@@ -32,14 +32,13 @@ REQUIRED_CLAIMS = frozenset(
32
32
  "iat",
33
33
  "nbf",
34
34
  "exp",
35
- # What the platform states about the caller: its active team UIDs and
36
- # whether it is an admin of the application's Organization.
37
- "team_uids",
35
+ # What the platform states about the caller: whether it is an admin of
36
+ # the application's Organization.
38
37
  "is_organization_admin",
39
38
  }
40
39
  )
41
- # The person a requester-bound call works for, with the person's own facts.
42
- REQUESTER_CLAIMS = frozenset({"sub", "team_uids", "is_organization_admin"})
40
+ # The person a requester-bound call works for, with the person's own admin flag.
41
+ REQUESTER_CLAIMS = frozenset({"sub", "is_organization_admin"})
43
42
 
44
43
 
45
44
  class InvalidCallerAssertion(ValueError):
@@ -64,7 +63,8 @@ class _VerifiedRequester:
64
63
  """The person a requester-bound call works for, as the platform signed it."""
65
64
 
66
65
  user_uid: str
67
- team_uids: tuple[str, ...]
66
+ # None when the assertion carries no team list (#225).
67
+ team_uids: tuple[str, ...] | None
68
68
  is_organization_admin: bool
69
69
 
70
70
 
@@ -75,7 +75,8 @@ class AuthenticatedCaller:
75
75
  environment_uid: str
76
76
  issued_at: int
77
77
  expires_at: int
78
- team_uids: tuple[str, ...]
78
+ # None when the assertion carries no team list (#225).
79
+ team_uids: tuple[str, ...] | None
79
80
  is_organization_admin: bool
80
81
  # The person a requester-bound call works for, None on any other call. The
81
82
  # fields above stay the caller's: the acting application (ADR-0036).
@@ -97,13 +98,24 @@ def _admin_flag(value: object, *, owner: str) -> bool:
97
98
  return value
98
99
 
99
100
 
100
- def _caller_facts(payload: dict) -> tuple[tuple[str, ...], bool]:
101
- """The caller's team UIDs and admin flag, which every assertion carries."""
101
+ def _signed_team_uids(claims: dict, *, owner: str) -> tuple[str, ...] | None:
102
+ """The deprecated ``team_uids`` while the platform still signs it, else None.
103
+
104
+ Membership is checked on demand with ``RequestUserIdentity.in_teams()``
105
+ (#225); a list that is still signed is checked as before.
106
+ """
107
+ if "team_uids" not in claims:
108
+ return None
109
+ return _team_uids(claims["team_uids"], owner=owner)
110
+
111
+
112
+ def _caller_facts(payload: dict) -> tuple[tuple[str, ...] | None, bool]:
113
+ """The caller's admin flag, and its team UIDs while the platform signs them."""
102
114
  # A requester-bound call also carries "requester", which _requester() checks.
103
- if set(payload) - {"requester"} != REQUIRED_CLAIMS:
115
+ if set(payload) - {"requester", "team_uids"} != REQUIRED_CLAIMS:
104
116
  raise InvalidCallerAssertion("Caller assertion claims are invalid.")
105
117
  return (
106
- _team_uids(payload["team_uids"], owner="Caller"),
118
+ _signed_team_uids(payload, owner="Caller"),
107
119
  _admin_flag(payload["is_organization_admin"], owner="Caller"),
108
120
  )
109
121
 
@@ -111,17 +123,18 @@ def _caller_facts(payload: dict) -> tuple[tuple[str, ...], bool]:
111
123
  def _requester(payload: dict) -> _VerifiedRequester | None:
112
124
  """The person a requester-bound call works for, or None on any other call.
113
125
 
114
- The claim has exactly ``sub``, ``team_uids`` and ``is_organization_admin``:
115
- the person's own User UID, active team UIDs and Organization-admin flag.
126
+ The claim has exactly ``sub`` and ``is_organization_admin``, the person's
127
+ own User UID and Organization-admin flag, and may still carry the
128
+ deprecated ``team_uids`` (#225).
116
129
  """
117
130
  if "requester" not in payload:
118
131
  return None
119
132
  claim = payload["requester"]
120
- if not isinstance(claim, dict) or set(claim) != REQUESTER_CLAIMS:
133
+ if not isinstance(claim, dict) or set(claim) - {"team_uids"} != REQUESTER_CLAIMS:
121
134
  raise InvalidCallerAssertion("Caller assertion requester is invalid.")
122
135
  return _VerifiedRequester(
123
136
  user_uid=_canonical_uid(claim["sub"]),
124
- team_uids=_team_uids(claim["team_uids"], owner="Requester"),
137
+ team_uids=_signed_team_uids(claim, owner="Requester"),
125
138
  is_organization_admin=_admin_flag(claim["is_organization_admin"], owner="Requester"),
126
139
  )
127
140
 
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import json
7
+ import math
7
8
  import os
8
9
  import re
9
10
  from contextlib import asynccontextmanager, contextmanager
@@ -18,6 +19,7 @@ from mainsequence._request_identity import (
18
19
  _reads_as_caller,
19
20
  _request_scope,
20
21
  )
22
+ from mainsequence._team_memberships import TeamMembershipCache
21
23
  from mainsequence.client.models_user import RequestUserIdentity
22
24
 
23
25
  from .caller_assertions import (
@@ -175,11 +177,16 @@ async def _deny(scope, receive, send, error):
175
177
 
176
178
 
177
179
  class _RequestIdentityMiddleware:
178
- def __init__(self, app, *, mode, verifier, public_ingress, routes):
180
+ def __init__(
181
+ self, app, *, mode, verifier, public_ingress, routes, team_membership_cache_seconds=0
182
+ ):
179
183
  self.app = app
180
184
  self.mode = mode
181
185
  self.verifier = verifier
182
186
  self.public_ingress = frozenset(public_ingress)
187
+ # Membership answers reused across this application's requests (#225).
188
+ self.team_memberships = TeamMembershipCache()
189
+ self.team_membership_cache_seconds = team_membership_cache_seconds
183
190
  for method, path in public_ingress:
184
191
  if not any(
185
192
  getattr(route, "path", None) == path
@@ -228,6 +235,11 @@ class _RequestIdentityMiddleware:
228
235
  state["resource_release_uid"] = caller.release_uid
229
236
  state["organization_environment_uid"] = caller.environment_uid
230
237
  context.caller_assertion = assertion
238
+ # The verifier's trusted target keys membership answers.
239
+ context.release_uid = caller.release_uid
240
+ context.environment_uid = caller.environment_uid
241
+ context.team_membership_cache = self.team_memberships
242
+ context.team_membership_cache_seconds = self.team_membership_cache_seconds
231
243
  if caller._requester is not None:
232
244
  # The person this call works for, with the person's own facts.
233
245
  context.requester = RequestUserIdentity(
@@ -257,14 +269,28 @@ class _RequestIdentityMiddleware:
257
269
  await self.app(scope, receive, send)
258
270
 
259
271
 
260
- def install_request_identity(app):
272
+ def install_request_identity(app, *, team_membership_cache_seconds=0):
261
273
  """Install once at application creation.
262
274
 
263
275
  Handlers use User.get_logged_user() for the caller and, on a
264
276
  requester-bound call, User.get_requester() for the person it works for.
277
+ ``in_teams()`` on either asks the platform about team membership.
278
+
279
+ ``team_membership_cache_seconds`` is how long a membership answer may be
280
+ reused across requests in this process. ``0``, the default, reuses an
281
+ answer only within the same request. A longer lifetime means fewer calls
282
+ and a membership change seen up to that much later; the choice belongs to
283
+ the application (ADR 0036, #225).
265
284
  """
266
285
  if getattr(app.state, _INSTALLATION, None) is not None:
267
286
  raise RuntimeError("Request identity is already installed.")
287
+ if (
288
+ isinstance(team_membership_cache_seconds, bool)
289
+ or not isinstance(team_membership_cache_seconds, (int, float))
290
+ or not math.isfinite(team_membership_cache_seconds)
291
+ or team_membership_cache_seconds < 0
292
+ ):
293
+ raise ValueError("team_membership_cache_seconds must be a non-negative number.")
268
294
  mode = _mode()
269
295
  public_ingress = _public_routes()
270
296
  verifier = CallerAssertionVerifier.from_environment() if mode == "assertion" else None
@@ -274,6 +300,7 @@ def install_request_identity(app):
274
300
  verifier=verifier,
275
301
  public_ingress=public_ingress,
276
302
  routes=app.routes,
303
+ team_membership_cache_seconds=team_membership_cache_seconds,
277
304
  )
278
305
  setattr(
279
306
  app.state,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.24
3
+ Version: 9.0.25
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -13,6 +13,7 @@ agent_scaffold/skills/sdk_code_repository_execution/SKILL.md
13
13
  mainsequence/__init__.py
14
14
  mainsequence/__main__.py
15
15
  mainsequence/_request_identity.py
16
+ mainsequence/_team_memberships.py
16
17
  mainsequence/_transport.py
17
18
  mainsequence/bootstrap.py
18
19
  mainsequence/code_repository_context.py
@@ -103,6 +104,7 @@ tests/test_scaffold_skills.py
103
104
  tests/test_secret_client_model.py
104
105
  tests/test_source_ownership.py
105
106
  tests/test_ssh_utils.py
107
+ tests/test_team_memberships.py
106
108
  tests/test_tolerant_response_reading.py
107
109
  tests/test_tolerant_value_sets.py
108
110
  tests/test_user_application_facts.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mainsequence"
7
- version = "9.0.24"
7
+ version = "9.0.25"
8
8
  description = "Main Sequence SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -100,16 +100,28 @@ def test_a_caller_in_no_team_who_is_not_an_admin(keys):
100
100
 
101
101
  @pytest.mark.parametrize(
102
102
  "omit",
103
- [("team_uids", "is_organization_admin"), ("team_uids",), ("is_organization_admin",)],
104
- ids=["no_facts", "no_team_uids", "no_admin_flag"],
103
+ [("team_uids", "is_organization_admin"), ("is_organization_admin",)],
104
+ ids=["no_facts", "no_admin_flag"],
105
105
  )
106
- def test_rejects_an_assertion_without_the_callers_facts(keys, omit):
107
- """Every assertion states the caller's teams and admin flag; there is no older shape."""
106
+ def test_rejects_an_assertion_without_the_callers_admin_flag(keys, omit):
107
+ """Every assertion states the caller's admin flag; there is no older shape."""
108
108
  key, public = keys[0]
109
109
  with pytest.raises(InvalidCallerAssertion):
110
110
  verifier(lambda: {"keys": [public]}).verify(token(key, omit=omit))
111
111
 
112
112
 
113
+ def test_accepts_an_assertion_without_a_team_list(keys):
114
+ """The platform stops signing team lists; membership is checked with in_teams (#225)."""
115
+ key, public = keys[0]
116
+
117
+ caller = verifier(lambda: {"keys": [public]}).verify(
118
+ token(key, omit=("team_uids",), is_organization_admin=True)
119
+ )
120
+
121
+ assert caller.team_uids is None
122
+ assert caller.is_organization_admin is True
123
+
124
+
113
125
  @pytest.mark.parametrize(
114
126
  "changes",
115
127
  [
@@ -205,7 +217,6 @@ def test_a_call_that_is_not_requester_bound_has_no_requester(keys):
205
217
  [PERSON, TEAMS, True],
206
218
  {},
207
219
  {"sub": PERSON, "team_uids": TEAMS},
208
- {"sub": PERSON, "is_organization_admin": True},
209
220
  {"team_uids": TEAMS, "is_organization_admin": True},
210
221
  {**REQUESTER, "username": "person"},
211
222
  {**REQUESTER, "requester": {"sub": PERSON}},
@@ -232,7 +243,6 @@ def test_a_call_that_is_not_requester_bound_has_no_requester(keys):
232
243
  "list",
233
244
  "empty",
234
245
  "two_field_without_admin_flag",
235
- "missing_team_uids",
236
246
  "missing_sub",
237
247
  "extra_key",
238
248
  "nested_requester",
@@ -260,20 +270,35 @@ def test_rejects_a_malformed_requester(keys, requester):
260
270
  verifier(lambda: {"keys": [public]}).verify(token(key, requester=requester))
261
271
 
262
272
 
273
+ def test_a_requester_and_caller_without_team_lists_are_accepted(keys):
274
+ """Neither the caller's facts nor the requester's need a team list (#225)."""
275
+ key, public = keys[0]
276
+
277
+ caller = verifier(lambda: {"keys": [public]}).verify(
278
+ token(
279
+ key,
280
+ omit=("team_uids",),
281
+ requester={"sub": PERSON, "is_organization_admin": True},
282
+ )
283
+ )
284
+
285
+ assert caller.team_uids is None
286
+ assert (caller._requester.user_uid, caller._requester.team_uids) == (PERSON, None)
287
+ assert caller._requester.is_organization_admin is True
288
+
289
+
263
290
  @pytest.mark.parametrize(
264
291
  ("changes", "omit"),
265
292
  [
266
293
  ({"extra": "not an identity claim"}, ()),
267
294
  ({"requester_is_organization_admin": True}, ()),
268
295
  ({}, ("team_uids", "is_organization_admin")),
269
- ({}, ("team_uids",)),
270
296
  ({}, ("is_organization_admin",)),
271
297
  ],
272
298
  ids=[
273
299
  "unknown_claim",
274
300
  "requester_admin_claim",
275
301
  "no_caller_facts",
276
- "no_caller_team_uids",
277
302
  "no_caller_admin_flag",
278
303
  ],
279
304
  )
@@ -255,7 +255,6 @@ def test_a_tool_with_an_optional_person_never_combines_grants(signed):
255
255
  REJECTED_ASSERTIONS = {
256
256
  "missing": None,
257
257
  "two_field_requester": dict(requester={"sub": PERSON, "team_uids": []}),
258
- "requester_missing_team_uids": dict(requester={"sub": PERSON, "is_organization_admin": False}),
259
258
  "requester_extra_key": dict(requester={**requester_claim(PERSON), "username": "person"}),
260
259
  "requester_integer_admin_flag": dict(
261
260
  requester=requester_claim(PERSON, is_organization_admin=1)