mainsequence 9.0.24.dev67__py3-none-any.whl → 9.0.25.dev68__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +4 -3
- agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +3 -2
- mainsequence/_request_identity.py +29 -0
- mainsequence/_team_memberships.py +54 -0
- mainsequence/client/models_user.py +141 -12
- mainsequence/server/caller_assertions.py +28 -15
- mainsequence/server/fastapi.py +29 -2
- {mainsequence-9.0.24.dev67.dist-info → mainsequence-9.0.25.dev68.dist-info}/METADATA +1 -1
- {mainsequence-9.0.24.dev67.dist-info → mainsequence-9.0.25.dev68.dist-info}/RECORD +13 -12
- {mainsequence-9.0.24.dev67.dist-info → mainsequence-9.0.25.dev68.dist-info}/WHEEL +0 -0
- {mainsequence-9.0.24.dev67.dist-info → mainsequence-9.0.25.dev68.dist-info}/entry_points.txt +0 -0
- {mainsequence-9.0.24.dev67.dist-info → mainsequence-9.0.25.dev68.dist-info}/licenses/LICENSE +0 -0
- {mainsequence-9.0.24.dev67.dist-info → mainsequence-9.0.25.dev68.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
171
|
-
|
|
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
|
|
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
|
|
275
|
-
|
|
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 ``
|
|
37
|
-
``
|
|
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
|
-
|
|
61
|
+
signed_team_uids: tuple[str, ...] | None = Field(
|
|
56
62
|
(),
|
|
57
|
-
|
|
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
|
|
61
|
-
"local mode and on WebSockets, which carry no
|
|
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("
|
|
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
|
|
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
|
|
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:
|
|
36
|
-
#
|
|
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
|
|
42
|
-
REQUESTER_CLAIMS = frozenset({"sub", "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
101
|
-
"""The
|
|
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
|
-
|
|
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
|
|
115
|
-
|
|
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=
|
|
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
|
|
mainsequence/server/fastapi.py
CHANGED
|
@@ -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__(
|
|
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,15 +1,16 @@
|
|
|
1
1
|
agent_scaffold/skills/a2a_sdk_execution/SKILL.md,sha256=NcwIQ1QgD4Ku0mG2Hb-Izee8p9fm9LTPHa6gDEWTOSQ,13200
|
|
2
|
-
agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md,sha256=
|
|
2
|
+
agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md,sha256=yyIKgPyyHYM5THOG9vLmxei66wMnOc2NERPAKUpo6pI,24543
|
|
3
3
|
agent_scaffold/skills/mainsequence-inference/SKILL.md,sha256=yv8aAHLQ2FBBSWPIFadY7pGNTwtR-yiR_-UzaGRN9-g,12502
|
|
4
4
|
agent_scaffold/skills/maintenance/bug_auditor/SKILL.md,sha256=8RRZNmC3omhZoxNbmu50MdhBFTQ2CU2THeyNu07Ax18,8659
|
|
5
5
|
agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md,sha256=pNQICoVW6nRr0HfvyB0Q64TdsqCXTWQUuImpBL-4XUQ,18575
|
|
6
6
|
agent_scaffold/skills/maintenance/metatables_transition/SKILL.md,sha256=PisgpytcksJnfZMS6aiPCmahsT3fm8DIwk_cefXA2qU,3759
|
|
7
|
-
agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md,sha256=
|
|
7
|
+
agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md,sha256=i-6Yy6-ANiIdsHbQPasXz4wJwtOqiaYjdOv1VZUqc0w,14846
|
|
8
8
|
agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md,sha256=KfTptbOMO-b8hiVFPgKxRMjmQWZCLB9Sr_kli7kp4hs,16920
|
|
9
9
|
agent_scaffold/skills/sdk_code_repository_execution/SKILL.md,sha256=g4C0Cp5oqQg2LBjT-piWXunmt_hNd5yTK33RMYPAE8I,7313
|
|
10
10
|
mainsequence/__init__.py,sha256=70CbIo7ZG1mzi5uwkqDtVESunBE0RanHjeFg4ptfsA0,385
|
|
11
11
|
mainsequence/__main__.py,sha256=KuAP69uduS2ImrPIgNMzbGBRepZzjqvUONu5vFh4v4A,176
|
|
12
|
-
mainsequence/_request_identity.py,sha256=
|
|
12
|
+
mainsequence/_request_identity.py,sha256=suRjESLA_YY_TAxE2htWHd07gvPhJGr-1GtpFnjaVTc,5543
|
|
13
|
+
mainsequence/_team_memberships.py,sha256=LQsn635Qw2VwhUQ4Ld8qa7utqXsAhO_0IFrf-kqzVk0,1771
|
|
13
14
|
mainsequence/_transport.py,sha256=gFSw5bl4O2o3NwZOk-ueNnTQvySBecyhC2-7OPb-BPk,8425
|
|
14
15
|
mainsequence/bootstrap.py,sha256=AvG55Y46RcshVqJZsyqvUDN62CJg4CDbHu0Bvsbwfzs,2910
|
|
15
16
|
mainsequence/code_repository_context.py,sha256=JRrWjwpnUy8I-OjSd9hHlrcq8-Ow3Zeh_N6cYvmtA3o,28545
|
|
@@ -44,22 +45,22 @@ mainsequence/client/github_issues.py,sha256=pSTUw3UPrmE6wqnpoJctartTaNSq6iu6PQRr
|
|
|
44
45
|
mainsequence/client/inference.py,sha256=tYKHRxLR6mA5iLmgd3qURwweWc2OPQuw0__tD3LQgSI,7748
|
|
45
46
|
mainsequence/client/models_foundry.py,sha256=JwQdPLDSO4dZI-KC0SUdmN6izmNIKMb9IzZlYXy0Mig,36488
|
|
46
47
|
mainsequence/client/models_helpers.py,sha256=CQRjBjx3DsREJLRk0vqFsC-Dcll5lUP2ofu6e5UMvhk,49051
|
|
47
|
-
mainsequence/client/models_user.py,sha256=
|
|
48
|
+
mainsequence/client/models_user.py,sha256=FESX_RYmY_qXXJ-VuZUo_C9Hwx0MrdHP_4b8wb4HycU,52256
|
|
48
49
|
mainsequence/client/observability.py,sha256=_WrDKCaNk_TXmKElKCXC1inork6Exxxe8FRY4m1GI9s,16008
|
|
49
50
|
mainsequence/client/utils.py,sha256=Rk2z-78swO6GepibLlQNdxL1eqYy8vHGrjiZdV3G5CM,31641
|
|
50
51
|
mainsequence/client/value_sets.py,sha256=7TA-mpuAd3hOduNgO1WTQQTuNOGaFgue-VqCsDWKaC8,6181
|
|
51
52
|
mainsequence/instrumentation/__init__.py,sha256=J_9XmfjprElvz0u2t9rTyJT0kFdyKpxdbkdQiaw28_U,316
|
|
52
53
|
mainsequence/instrumentation/utils.py,sha256=b-ihjSioAepeLfMpudM2SqCtvnxMqIXileHx3klBCQU,3377
|
|
53
54
|
mainsequence/server/__init__.py,sha256=BHUAhtdWPDRdxX07NYO_6wrBv5mf0GsXflecO52PmUc,73
|
|
54
|
-
mainsequence/server/caller_assertions.py,sha256=
|
|
55
|
-
mainsequence/server/fastapi.py,sha256=
|
|
55
|
+
mainsequence/server/caller_assertions.py,sha256=AcE9PgkIAu8kMUILSRs6P--wN68DUw5a5D1PC7NQqIs,13116
|
|
56
|
+
mainsequence/server/fastapi.py,sha256=zVPvtlkAZuuD1EgKk_9jPVjfBxOlFjf9aXjDyFlTC54,20250
|
|
56
57
|
mainsequence/server/mcp_skills.py,sha256=-krRwAHCAMipScgGJ19uVkzznhTop1Nz4C_wRY1GUpw,16690
|
|
57
58
|
mainsequence/server/_mcp_skills_upstream/__init__.py,sha256=px283qBMf3z1nxCfN3WHrH9v8Y8jOb1xoBqZMRZcaD8,2694
|
|
58
59
|
mainsequence/server/_mcp_skills_upstream/server.py,sha256=laHsfpoaYX3LcwQ04-GN7r0_C63fG6CzyXDci2rmbHE,7695
|
|
59
60
|
mainsequence/server/_mcp_skills_upstream/shared.py,sha256=JmnX9PoLoyi93nBYnUEPWCdPXor_nQHjHptQDXpFzPs,11978
|
|
60
|
-
mainsequence-9.0.
|
|
61
|
-
mainsequence-9.0.
|
|
62
|
-
mainsequence-9.0.
|
|
63
|
-
mainsequence-9.0.
|
|
64
|
-
mainsequence-9.0.
|
|
65
|
-
mainsequence-9.0.
|
|
61
|
+
mainsequence-9.0.25.dev68.dist-info/licenses/LICENSE,sha256=b6Ecsk8wjsEby3lbSzdljR6hHiFGHBizt9iWV2p1c_I,1074
|
|
62
|
+
mainsequence-9.0.25.dev68.dist-info/METADATA,sha256=WYO7f8YBdthLQ_k9mG8qETYoBZlaKFoXk-j3jbyDWuA,7531
|
|
63
|
+
mainsequence-9.0.25.dev68.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
64
|
+
mainsequence-9.0.25.dev68.dist-info/entry_points.txt,sha256=XGAOq3-mF2lWWpeRJvoaWyMarRcK167mH1g124aGdUs,88
|
|
65
|
+
mainsequence-9.0.25.dev68.dist-info/top_level.txt,sha256=BFeblM_Q9q0cUWBvTxnfWcWQEHtf5XZi9nOS-Gdfwus,28
|
|
66
|
+
mainsequence-9.0.25.dev68.dist-info/RECORD,,
|
|
File without changes
|
{mainsequence-9.0.24.dev67.dist-info → mainsequence-9.0.25.dev68.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{mainsequence-9.0.24.dev67.dist-info → mainsequence-9.0.25.dev68.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|