mainsequence 9.0.8.dev43__tar.gz → 9.0.8.dev45__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 (103) hide show
  1. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/PKG-INFO +1 -1
  2. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +48 -21
  3. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/config.py +3 -7
  4. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/models_user.py +24 -0
  5. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/utils.py +61 -19
  6. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/code_repository_context.py +3 -3
  7. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/logconf.py +29 -10
  8. mainsequence-9.0.8.dev45/mainsequence/runtime_credential_exchange.py +168 -0
  9. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/server/caller_assertions.py +26 -2
  10. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/server/fastapi.py +5 -1
  11. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence.egg-info/PKG-INFO +1 -1
  12. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence.egg-info/SOURCES.txt +2 -0
  13. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/pyproject.toml +1 -1
  14. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_auth_precedence.py +1 -0
  15. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_caller_assertions.py +41 -0
  16. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_cli_secure_storage.py +17 -0
  17. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_code_repository_runtime_context.py +13 -2
  18. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_fastapi_request_identity.py +19 -0
  19. mainsequence-9.0.8.dev45/tests/test_runtime_credential_exchange.py +383 -0
  20. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/LICENSE +0 -0
  21. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/README.md +0 -0
  22. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/agent_scaffold/AGENTS.md +0 -0
  23. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
  24. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
  25. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  26. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
  27. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
  28. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
  29. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
  30. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
  31. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/__init__.py +0 -0
  32. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/__main__.py +0 -0
  33. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/_request_identity.py +0 -0
  34. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/bootstrap.py +0 -0
  35. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/__init__.py +0 -0
  36. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/api.py +0 -0
  37. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/browser_auth.py +0 -0
  38. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/cli.py +0 -0
  39. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/code_repository_status.py +0 -0
  40. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/docker_utils.py +0 -0
  41. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/doctor.py +0 -0
  42. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/local_ops.py +0 -0
  43. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/model_filters.py +0 -0
  44. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/pydantic_cli.py +0 -0
  45. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/sdk_utils.py +0 -0
  46. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/ssh_utils.py +0 -0
  47. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/cli/ui.py +0 -0
  48. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/__init__.py +0 -0
  49. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/_transport.py +0 -0
  50. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/agent_runtime_models.py +0 -0
  51. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/base.py +0 -0
  52. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/client.py +0 -0
  53. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/compute_validation.py +0 -0
  54. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/exceptions.py +0 -0
  55. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/github_issues.py +0 -0
  56. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/inference.py +0 -0
  57. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/models_foundry.py +0 -0
  58. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/models_helpers.py +0 -0
  59. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/observability.py +0 -0
  60. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/client/value_sets.py +0 -0
  61. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/code_repository_skills.py +0 -0
  62. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/defaults.py +0 -0
  63. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/instrumentation/__init__.py +0 -0
  64. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/instrumentation/utils.py +0 -0
  65. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/repository_identity_security.py +0 -0
  66. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/runtime_flags.py +0 -0
  67. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/scaffold_skills.py +0 -0
  68. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence/server/__init__.py +0 -0
  69. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence.egg-info/dependency_links.txt +0 -0
  70. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence.egg-info/entry_points.txt +0 -0
  71. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence.egg-info/requires.txt +0 -0
  72. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/mainsequence.egg-info/top_level.txt +0 -0
  73. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/setup.cfg +0 -0
  74. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_auth_bootstrap.py +0 -0
  75. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_canonical_api_route_contract.py +0 -0
  76. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_cli_auth_contract.py +0 -0
  77. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_cli_browser_auth.py +0 -0
  78. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_client.py +0 -0
  79. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_code_repository_branch_contract.py +0 -0
  80. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_code_repository_documentation_contract.py +0 -0
  81. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_code_repository_skills.py +0 -0
  82. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_dependency_extras.py +0 -0
  83. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_dev_release_version.py +0 -0
  84. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_environment_resource_context.py +0 -0
  85. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_environment_response_contracts.py +0 -0
  86. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_github_issues_client.py +0 -0
  87. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_http_retry_budget.py +0 -0
  88. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_independent_context.py +0 -0
  89. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_inference_client.py +0 -0
  90. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_instrumentation.py +0 -0
  91. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_job_client.py +0 -0
  92. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_local_ops.py +0 -0
  93. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_logconf.py +0 -0
  94. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_models_user_request_bound_auth.py +0 -0
  95. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_notification_uid_contract.py +0 -0
  96. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_observability_client.py +0 -0
  97. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_scaffold_skills.py +0 -0
  98. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_secret_client_model.py +0 -0
  99. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_source_ownership.py +0 -0
  100. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_ssh_utils.py +0 -0
  101. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_tolerant_response_reading.py +0 -0
  102. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/tests/test_tolerant_value_sets.py +0 -0
  103. {mainsequence-9.0.8.dev43 → mainsequence-9.0.8.dev45}/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.8.dev43
3
+ Version: 9.0.8.dev45
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -92,46 +92,71 @@ Record:
92
92
  - whether the API uses backend transport or a contract-defined direct
93
93
  development transport
94
94
 
95
- Authenticated FastAPI routes receive the human caller from the Main Sequence
96
- platform through injected request state. No SDK authentication setup is
97
- required in repository code:
95
+ Install the SDK's request-identity integration once when creating the FastAPI
96
+ application. Inspect the existing application factory first: the generated
97
+ platform template already calls `install_request_identity(app)`, and a second
98
+ installation raises an error. For an application without that setup:
98
99
 
99
100
  ```python
100
- from fastapi import FastAPI, Request
101
+ from fastapi import FastAPI
102
+ from mainsequence.client import User
103
+ from mainsequence.server.fastapi import install_request_identity
101
104
 
102
105
 
103
106
  app = FastAPI()
107
+ install_request_identity(app)
104
108
 
105
109
 
106
110
  @app.get("/me")
107
- def get_me(request: Request) -> dict[str, str | None]:
108
- return {
109
- "uid": request.state.user_uid,
110
- "username": request.state.user.username,
111
- }
111
+ def get_me() -> dict[str, str | None]:
112
+ user = User.get_logged_user()
113
+ return {"uid": user.uid, "username": user.username}
112
114
  ```
113
115
 
114
- Protected route code must not detect local versus deployed execution, parse
115
- authentication headers, or resolve the request user itself. Consume the
116
- platform-injected human caller identity:
116
+ Use `User.get_logged_user()` in authenticated synchronous or asynchronous
117
+ handlers and their shared services. The middleware verifies the caller before
118
+ the handler, binds one isolated request context, and invalidates it on
119
+ completion, errors, or cancellation. Repeated getter calls do not perform
120
+ additional authentication requests.
117
121
 
118
- - `request.state.user` contains canonical `uid` and optional `username`
119
- - `request.state.user_uid` is the canonical public user UUID
120
- - `request.state.user_id` does not exist
122
+ The same installation handles both HTTP execution modes:
123
+
124
+ - Local execution without hosted deployment markers validates the incoming
125
+ Bearer token against `MAINSEQUENCE_ENDPOINT/api/v1/users/me/`.
126
+ - Platform-hosted execution verifies the gateway's signed caller assertion
127
+ using deployment-owned issuer, public-key, release, and Environment settings.
128
+ Hosted configuration cannot select local mode, and failed proof never falls
129
+ back to UID headers or the process's credentials.
130
+
131
+ Route code must not select the mode, parse authentication headers, or implement
132
+ another caller resolver. The launcher checks the application's installation
133
+ declaration before serving; it does not install the middleware itself.
134
+
135
+ The identity contains canonical `uid` and optional `username`; signed HTTP
136
+ proofs contain no username. `request.state.user` and `request.state.user_uid`
137
+ remain projections of the same identity, not a separate resolver. Outside an
138
+ authenticated request, including public routes, `User.get_logged_user()` raises
139
+ `RequestIdentityError`.
121
140
 
122
141
  On a protected route, this identity is the human making the current HTTP
123
142
  request. It is not the release creator, deployment owner, runtime workload
124
- principal, or runtime target. Pass request state explicitly to shared services;
125
- do not use `User.get_logged_user()` as a FastAPI entry point. Platform-injected identity is
126
- not resource authorization; use `request.state.user_uid` when applying the
127
- endpoint's authorization policy.
143
+ principal, or the process account returned by `User.get_authenticated_user_details()`.
144
+ Caller authentication is not resource authorization; apply the endpoint's
145
+ application policy using the verified user UID.
146
+
147
+ WebSockets retain the platform's gateway ticket/header contract rather than
148
+ the HTTP Bearer/assertion flow; the getter is available while the authenticated
149
+ connection is active. See the version-matched
150
+ [FastAPI identity documentation](https://mainsequence-sdk.github.io/mainsequence-sdk/knowledge/fastapi/)
151
+ for the lifecycle and trust contract.
128
152
 
129
153
  ### Public provider callbacks and webhooks
130
154
 
131
155
  An external OAuth redirect or webhook cannot supply a Main Sequence Bearer token.
132
156
  For a FastAPI release that needs one, use the platform's exact `public_ingress`
133
157
  policy. CORS configuration and an app-declared route alone do not make a path
134
- public. Ordinary routes remain Bearer authenticated.
158
+ public. Ordinary routes still require an authenticated caller through the
159
+ request-identity integration.
135
160
 
136
161
  1. Implement the provider handler in the FastAPI app loaded by the workflow's
137
162
  `source_path`. Declare only its exact `GET` or `POST` method and literal
@@ -166,7 +191,9 @@ public. Ordinary routes remain Bearer authenticated.
166
191
  4. Send a provider-style request without a Main Sequence Bearer token to the
167
192
  exact public pair. Check that an unlisted path or wrong method is denied.
168
193
  An admitted request has `request.state.user` and `user_uid` set to `None`
169
- and `request.state.auth_outcome == "public_ingress"`. The handler must verify
194
+ and `request.state.auth_outcome == "public_ingress"`;
195
+ `User.get_logged_user()` raises `RequestIdentityError` in that anonymous
196
+ scope. The handler must verify
170
197
  OAuth state and exchange codes, or verify the webhook signature against the
171
198
  original body and reject replays. Public admission supplies no User or
172
199
  provider identity. Keep provider secrets and live codes out of workflow YAML,
@@ -29,6 +29,7 @@ from keyring import core as keyring_core
29
29
  from keyring import errors as keyring_errors
30
30
 
31
31
  from mainsequence.defaults import CANONICAL_BACKEND_ENV, STANDARD_BACKEND_URL
32
+ from mainsequence.runtime_credential_exchange import runtime_credential_configured
32
33
 
33
34
  APP_NAME = "MainSequenceCLI"
34
35
 
@@ -804,13 +805,8 @@ def session_report() -> dict:
804
805
  refresh_expires_at = token_expiry(refresh)
805
806
 
806
807
  if auth_mode == "runtime_credential":
807
- authenticated = bool(
808
- access
809
- or (
810
- (os.environ.get("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID") or "").strip()
811
- and (os.environ.get("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET") or "").strip()
812
- )
813
- )
808
+ # The credential is an ID with a bootstrap secret or an identity token file.
809
+ authenticated = bool(access or runtime_credential_configured())
814
810
  session_expires_at = None
815
811
  elif refresh:
816
812
  authenticated = refresh_expires_at is None or refresh_expires_at > now
@@ -37,6 +37,22 @@ class RequestUserIdentity(BaseModel):
37
37
  title="Username",
38
38
  description="Optional display name authenticated by the runtime gateway.",
39
39
  )
40
+ team_uids: tuple[str, ...] = Field(
41
+ (),
42
+ title="Team UIDs",
43
+ description=(
44
+ "Canonical UIDs of the caller's active teams, as signed by the platform "
45
+ "in the caller assertion. Empty when the request carries no such facts."
46
+ ),
47
+ )
48
+ is_organization_admin: bool = Field(
49
+ False,
50
+ title="Organization admin",
51
+ description=(
52
+ "Whether the platform signed the caller as an admin of the application's "
53
+ "Organization. False when the request carries no such facts."
54
+ ),
55
+ )
40
56
 
41
57
  @field_validator("uid", mode="before")
42
58
  @classmethod
@@ -46,6 +62,14 @@ class RequestUserIdentity(BaseModel):
46
62
  except (AttributeError, TypeError, ValueError) as exc:
47
63
  raise ValueError("Request user UID must be a valid UUID.") from exc
48
64
 
65
+ @field_validator("team_uids", mode="before")
66
+ @classmethod
67
+ def _canonicalize_team_uids(cls, value: Any) -> tuple[str, ...]:
68
+ try:
69
+ return tuple(str(UUID(str(item).strip())) for item in value or ())
70
+ except (AttributeError, TypeError, ValueError) as exc:
71
+ raise ValueError("Team UIDs must be valid UUIDs.") from exc
72
+
49
73
  @field_validator("username", mode="before")
50
74
  @classmethod
51
75
  def _normalize_username(cls, value: Any) -> str | None:
@@ -21,6 +21,14 @@ from requests.structures import CaseInsensitiveDict
21
21
 
22
22
  from mainsequence.defaults import resolve_backend_endpoint
23
23
  from mainsequence.logconf import logger
24
+ from mainsequence.runtime_credential_exchange import (
25
+ RUNTIME_CREDENTIAL_ID_ENV,
26
+ RUNTIME_CREDENTIAL_SECRET_ENV,
27
+ RUNTIME_IDENTITY_TOKEN_FILE_ENV,
28
+ RuntimeIdentityTokenError,
29
+ exchange_runtime_credential,
30
+ identity_token_file_from_environment,
31
+ )
24
32
 
25
33
  from ._transport import DEFAULT_ALLOWED_METHODS as DEFAULT_ALLOWED_METHODS
26
34
  from ._transport import DEFAULT_STATUS_FORCELIST as DEFAULT_STATUS_FORCELIST
@@ -309,6 +317,17 @@ class SessionJWTAuthProvider(BaseAuthProvider):
309
317
 
310
318
  @dataclass
311
319
  class RuntimeCredentialAuthProvider(BaseAuthProvider):
320
+ """
321
+ Exchange the runtime credential the platform injected for short-lived access tokens.
322
+
323
+ The proof is the projected workload identity token when
324
+ `MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE` is set: the file is read for every
325
+ exchange, and `MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET` is never read or sent.
326
+ Otherwise the proof is that bootstrap secret. The token is never kept on the
327
+ provider. Throttled (`429`) and temporarily unavailable (`503`) exchanges are
328
+ retried within the timeout budget; a rejected one (`401`) fails at once.
329
+ """
330
+
312
331
  credential_id: str | None = None
313
332
  credential_secret: str | None = None
314
333
  token_url: str | None = None
@@ -316,13 +335,18 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
316
335
  refresh_skew_seconds: int = 30
317
336
  timeout: tuple[float, float] = DEFAULT_TIMEOUT
318
337
  expires_at: float | None = None
338
+ identity_token_file: str | None = None
319
339
  _lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
320
340
 
321
341
  def __post_init__(self):
322
342
  if self.credential_id is None:
323
- self.credential_id = os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID")
324
- if self.credential_secret is None:
325
- self.credential_secret = os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET")
343
+ self.credential_id = os.getenv(RUNTIME_CREDENTIAL_ID_ENV)
344
+ if self.identity_token_file is None:
345
+ self.identity_token_file = identity_token_file_from_environment()
346
+ else:
347
+ self.identity_token_file = str(self.identity_token_file).strip() or None
348
+ if self.credential_secret is None and not self.identity_token_file:
349
+ self.credential_secret = os.getenv(RUNTIME_CREDENTIAL_SECRET_ENV)
326
350
  if not self.token_url:
327
351
  self.token_url = f"{API_ENDPOINT}/runtime-credentials/token/"
328
352
 
@@ -344,20 +368,48 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
344
368
 
345
369
  return exp <= int(time.time()) + self.refresh_skew_seconds
346
370
 
347
- def _require_credentials(self) -> tuple[str, str]:
371
+ @staticmethod
372
+ def _log_exchange_retry(status: int, delay: float) -> None:
373
+ logger.warning(f"Runtime credential exchange returned {status}; retrying in {delay:.1f} s.")
374
+
375
+ def _exchange(self) -> requests.Response:
348
376
  credential_id = (self.credential_id or "").strip()
349
- credential_secret = (self.credential_secret or "").strip()
377
+ identity_token_file = self.identity_token_file
378
+ credential_secret = None if identity_token_file else (self.credential_secret or "").strip()
350
379
  if not credential_id:
351
380
  raise AuthError(
352
381
  "MAINSEQUENCE_RUNTIME_CREDENTIAL_ID is required when "
353
382
  "MAINSEQUENCE_AUTH_MODE=runtime_credential."
354
383
  )
355
- if not credential_secret:
384
+ if not identity_token_file and not credential_secret:
356
385
  raise AuthError(
357
386
  "MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET is required when "
358
- "MAINSEQUENCE_AUTH_MODE=runtime_credential."
387
+ "MAINSEQUENCE_AUTH_MODE=runtime_credential and "
388
+ f"{RUNTIME_IDENTITY_TOKEN_FILE_ENV} is not set."
359
389
  )
360
- return credential_id, credential_secret
390
+ try:
391
+ # One budget for every attempt and wait, inside the caller's budget if any.
392
+ with request_budget(self.timeout) as budget:
393
+
394
+ def post(body: dict[str, str]) -> requests.Response:
395
+ return requests.post(
396
+ self.token_url,
397
+ json=body,
398
+ headers={"Content-Type": "application/json"},
399
+ timeout=budget.timeout(),
400
+ allow_redirects=False,
401
+ )
402
+
403
+ return exchange_runtime_credential(
404
+ post,
405
+ credential_id=credential_id,
406
+ credential_secret=credential_secret,
407
+ identity_token_file=identity_token_file,
408
+ deadline=budget.deadline,
409
+ on_retry=self._log_exchange_retry,
410
+ )
411
+ except RuntimeIdentityTokenError as exc:
412
+ raise AuthError(str(exc)) from None
361
413
 
362
414
  def refresh(
363
415
  self,
@@ -370,17 +422,7 @@ class RuntimeCredentialAuthProvider(BaseAuthProvider):
370
422
  if not force and not self._needs_exchange():
371
423
  return
372
424
 
373
- credential_id, credential_secret = self._require_credentials()
374
- response = requests.post(
375
- self.token_url,
376
- json={
377
- "credential_id": credential_id,
378
- "credential_secret": credential_secret,
379
- },
380
- headers={"Content-Type": "application/json"},
381
- timeout=remaining_timeout(self.timeout),
382
- allow_redirects=False,
383
- )
425
+ response = self._exchange()
384
426
  if response.status_code < 200 or response.status_code >= 300:
385
427
  raise AuthError(
386
428
  f"Runtime credential exchange failed with status {response.status_code}."
@@ -10,6 +10,8 @@ from dataclasses import dataclass, replace
10
10
  from typing import Any, Literal
11
11
  from urllib.parse import urlsplit
12
12
 
13
+ from mainsequence.runtime_credential_exchange import runtime_credential_configured
14
+
13
15
  CodeRepositoryContextStatus = Literal[
14
16
  "resolved",
15
17
  "code_repository_branch_not_registered",
@@ -204,9 +206,7 @@ def _exchange_authenticated_runtime_context_if_configured() -> None:
204
206
  return
205
207
  if (os.getenv("MAINSEQUENCE_AUTH_MODE") or "").strip().lower() != "runtime_credential":
206
208
  return
207
- if not (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID") or "").strip():
208
- return
209
- if not (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET") or "").strip():
209
+ if not runtime_credential_configured():
210
210
  return
211
211
 
212
212
  from mainsequence.client.utils import AuthError, loaders
@@ -5,6 +5,7 @@ import logging
5
5
  import logging.config
6
6
  import os
7
7
  import sys
8
+ import time
8
9
  import traceback
9
10
  from collections.abc import Mapping
10
11
  from importlib.metadata import PackageNotFoundError
@@ -22,6 +23,13 @@ from structlog.stdlib import BoundLogger
22
23
  from .defaults import resolve_backend_endpoint
23
24
  from .instrumentation import OTelJSONRenderer
24
25
  from .repository_identity_security import UNSUPPORTED_SOURCE_IDENTITY_ENV_NAMES
26
+ from .runtime_credential_exchange import (
27
+ RUNTIME_CREDENTIAL_ID_ENV,
28
+ RUNTIME_CREDENTIAL_SECRET_ENV,
29
+ exchange_runtime_credential,
30
+ identity_token_file_from_environment,
31
+ runtime_credential_configured,
32
+ )
25
33
  from .runtime_flags import is_running_in_pod
26
34
 
27
35
  logger = None
@@ -94,20 +102,31 @@ def _request_job_startup_state(*, timeout_s: float = 10.0) -> dict[str, Any]:
94
102
  return headers, False
95
103
 
96
104
  def _exchange_runtime_credential() -> bool:
97
- credential_id = (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_ID") or "").strip()
98
- credential_secret = (os.getenv("MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET") or "").strip()
99
- if not credential_id or not credential_secret:
105
+ if not runtime_credential_configured():
100
106
  return False
107
+ identity_token_file = identity_token_file_from_environment()
108
+ token_url = f"{_backend_base_url()}/api/v1/runtime-credentials/token/"
101
109
 
102
- try:
103
- token_resp = requests.post(
104
- f"{_backend_base_url()}/api/v1/runtime-credentials/token/",
110
+ def _post(body: dict[str, str]) -> requests.Response:
111
+ return requests.post(
112
+ token_url,
105
113
  headers={"Content-Type": "application/json"},
106
- json={
107
- "credential_id": credential_id,
108
- "credential_secret": credential_secret,
109
- },
114
+ json=body,
110
115
  timeout=timeout_s,
116
+ allow_redirects=False,
117
+ )
118
+
119
+ try:
120
+ token_resp = exchange_runtime_credential(
121
+ _post,
122
+ credential_id=(os.getenv(RUNTIME_CREDENTIAL_ID_ENV) or "").strip(),
123
+ credential_secret=(
124
+ None
125
+ if identity_token_file
126
+ else (os.getenv(RUNTIME_CREDENTIAL_SECRET_ENV) or "").strip()
127
+ ),
128
+ identity_token_file=identity_token_file,
129
+ deadline=time.monotonic() + timeout_s,
111
130
  )
112
131
  except Exception:
113
132
  return False
@@ -0,0 +1,168 @@
1
+ """
2
+ The runtime credential exchange, shared by the auth provider and the logger.
3
+
4
+ A runtime started with ``MAINSEQUENCE_AUTH_MODE=runtime_credential`` exchanges its
5
+ credential at ``POST /api/v1/runtime-credentials/token/`` for a short-lived access
6
+ token. The request carries ``credential_id`` and exactly one proof:
7
+
8
+ * ``workload_identity_token`` when ``MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE`` is
9
+ set: the projected token read from that file for every exchange, because the
10
+ file is rotated. The bootstrap secret is then never read or sent, and a missing,
11
+ unreadable or empty file is an error, never a fallback to the secret.
12
+ * ``credential_secret`` otherwise: ``MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET``.
13
+
14
+ The token exists only inside one exchange request. It is not kept on any object,
15
+ written to the environment or a file, logged, or included in an error message.
16
+
17
+ The logger exchanges a runtime credential while it is being configured, so this
18
+ module imports nothing from the SDK.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import time
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ from urllib3.exceptions import InvalidHeader
29
+ from urllib3.util import Retry
30
+
31
+ RUNTIME_CREDENTIAL_ID_ENV = "MAINSEQUENCE_RUNTIME_CREDENTIAL_ID"
32
+ RUNTIME_CREDENTIAL_SECRET_ENV = "MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET"
33
+ RUNTIME_IDENTITY_TOKEN_FILE_ENV = "MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE"
34
+
35
+ # 429: the exchange is throttled. 503: verification is temporarily unavailable.
36
+ # Neither exchanged anything, so the same request may be sent again.
37
+ RETRYABLE_EXCHANGE_STATUSES = frozenset({429, 503})
38
+ EXCHANGE_RETRIES = 3
39
+ EXCHANGE_BACKOFF_FACTOR = 0.5
40
+
41
+ _RETRY_AFTER = Retry(total=0)
42
+
43
+
44
+ class RuntimeIdentityTokenError(Exception):
45
+ """The runtime identity token file cannot be used. The message never holds its content."""
46
+
47
+
48
+ def _environment_value(name: str) -> str:
49
+ return (os.getenv(name) or "").strip()
50
+
51
+
52
+ def identity_token_file_from_environment() -> str | None:
53
+ """Return the path in ``MAINSEQUENCE_RUNTIME_IDENTITY_TOKEN_FILE``, or None when unset."""
54
+ return _environment_value(RUNTIME_IDENTITY_TOKEN_FILE_ENV) or None
55
+
56
+
57
+ def runtime_credential_configured() -> bool:
58
+ """
59
+ Say whether the environment holds a runtime credential to exchange.
60
+
61
+ That is a credential ID and one proof: the identity token file, or else the
62
+ bootstrap secret. Only the presence of the setting is checked. The token file
63
+ is read by the exchange itself, which reports a missing or empty file.
64
+ """
65
+ if not _environment_value(RUNTIME_CREDENTIAL_ID_ENV):
66
+ return False
67
+ if identity_token_file_from_environment():
68
+ return True
69
+ return bool(_environment_value(RUNTIME_CREDENTIAL_SECRET_ENV))
70
+
71
+
72
+ def _read_identity_token(path: str) -> str:
73
+ source = f"The runtime identity token file {path} ({RUNTIME_IDENTITY_TOKEN_FILE_ENV})"
74
+ try:
75
+ with open(path, "rb") as token_file:
76
+ raw = token_file.read()
77
+ except FileNotFoundError:
78
+ raise RuntimeIdentityTokenError(f"{source} does not exist.") from None
79
+ except OSError as exc:
80
+ raise RuntimeIdentityTokenError(
81
+ f"{source} could not be read ({type(exc).__name__})."
82
+ ) from None
83
+ try:
84
+ token = raw.decode("utf-8").strip()
85
+ except UnicodeDecodeError:
86
+ # Raised below, outside this handler: the decode error holds the bytes.
87
+ token = None
88
+ if token is None:
89
+ raise RuntimeIdentityTokenError(f"{source} does not hold a token.")
90
+ if not token:
91
+ raise RuntimeIdentityTokenError(f"{source} is empty.")
92
+ return token
93
+
94
+
95
+ def _request_body(
96
+ credential_id: str,
97
+ credential_secret: str | None,
98
+ identity_token_file: str | None,
99
+ ) -> dict[str, str]:
100
+ if identity_token_file:
101
+ return {
102
+ "credential_id": credential_id,
103
+ "workload_identity_token": _read_identity_token(identity_token_file),
104
+ }
105
+ return {"credential_id": credential_id, "credential_secret": credential_secret or ""}
106
+
107
+
108
+ def _retry_delay(response: Any, attempt: int) -> float:
109
+ delay = min(EXCHANGE_BACKOFF_FACTOR * 2**attempt, Retry.DEFAULT_BACKOFF_MAX)
110
+ retry_after = (getattr(response, "headers", None) or {}).get("Retry-After")
111
+ if retry_after is not None:
112
+ try:
113
+ delay = max(delay, _RETRY_AFTER.parse_retry_after(str(retry_after)))
114
+ except InvalidHeader:
115
+ pass
116
+ return delay
117
+
118
+
119
+ def exchange_runtime_credential(
120
+ send: Callable[[dict[str, str]], Any],
121
+ *,
122
+ credential_id: str,
123
+ credential_secret: str | None,
124
+ identity_token_file: str | None,
125
+ deadline: float,
126
+ on_retry: Callable[[int, float], None] | None = None,
127
+ ) -> Any:
128
+ """
129
+ Send one runtime credential exchange and return the last response.
130
+
131
+ Parameters
132
+ ----------
133
+ send:
134
+ Posts the JSON body it is given to the exchange route and returns the
135
+ response. It is called once per attempt.
136
+ credential_id:
137
+ The runtime credential ID.
138
+ credential_secret:
139
+ The bootstrap secret. Ignored when ``identity_token_file`` is set.
140
+ identity_token_file:
141
+ The projected token file. It is read again for every attempt.
142
+ deadline:
143
+ ``time.monotonic()`` value after which no retry starts.
144
+ on_retry:
145
+ Called with the status and the delay before each retry.
146
+
147
+ A ``429`` or ``503`` answer is retried up to ``EXCHANGE_RETRIES`` times. The
148
+ wait is the exponential backoff or the answer's ``Retry-After``, whichever is
149
+ longer; a wait that would not end before ``deadline`` ends the retries. Any
150
+ other answer, ``401`` included, is returned at once and the proof is never
151
+ switched. ``RuntimeIdentityTokenError`` is raised when the token file cannot
152
+ be used.
153
+ """
154
+ for attempt in range(EXCHANGE_RETRIES + 1):
155
+ response = send(_request_body(credential_id, credential_secret, identity_token_file))
156
+ status = response.status_code
157
+ if status not in RETRYABLE_EXCHANGE_STATUSES or attempt == EXCHANGE_RETRIES:
158
+ return response
159
+ delay = _retry_delay(response, attempt)
160
+ if delay >= deadline - time.monotonic():
161
+ return response
162
+ if on_retry is not None:
163
+ on_retry(status, delay)
164
+ close = getattr(response, "close", None)
165
+ if callable(close):
166
+ close()
167
+ time.sleep(delay)
168
+ raise AssertionError("Unreachable retry state")
@@ -33,6 +33,10 @@ REQUIRED_CLAIMS = frozenset(
33
33
  "exp",
34
34
  }
35
35
  )
36
+ # What the platform states about the caller: active team UIDs and the
37
+ # Organization-admin flag. Sent together, or not at all by a platform that
38
+ # predates them; then the caller has no teams and is not an admin.
39
+ CALLER_FACT_CLAIMS = frozenset({"team_uids", "is_organization_admin"})
36
40
 
37
41
 
38
42
  class InvalidCallerAssertion(ValueError):
@@ -59,6 +63,25 @@ class AuthenticatedCaller:
59
63
  environment_uid: str
60
64
  issued_at: int
61
65
  expires_at: int
66
+ team_uids: tuple[str, ...] = ()
67
+ is_organization_admin: bool = False
68
+
69
+
70
+ def _caller_facts(payload: dict) -> tuple[tuple[str, ...], bool]:
71
+ facts = set(payload) & CALLER_FACT_CLAIMS
72
+ if set(payload) - facts != REQUIRED_CLAIMS or facts not in (set(), set(CALLER_FACT_CLAIMS)):
73
+ raise InvalidCallerAssertion("Caller assertion claims are invalid.")
74
+ if not facts:
75
+ return (), False
76
+ raw_team_uids = payload["team_uids"]
77
+ if not isinstance(raw_team_uids, list):
78
+ raise InvalidCallerAssertion("Caller team UIDs are invalid.")
79
+ team_uids = tuple(_canonical_uid(value) for value in raw_team_uids)
80
+ if list(team_uids) != sorted(set(team_uids)):
81
+ raise InvalidCallerAssertion("Caller team UIDs must be sorted and unique.")
82
+ if type(payload["is_organization_admin"]) is not bool:
83
+ raise InvalidCallerAssertion("Caller admin flag is invalid.")
84
+ return team_uids, payload["is_organization_admin"]
62
85
 
63
86
 
64
87
  class CallerAssertionVerifier:
@@ -206,8 +229,7 @@ class CallerAssertionVerifier:
206
229
  audience=f"urn:mainsequence:fapi:{self.release_uid}",
207
230
  options={"require": list(REQUIRED_CLAIMS)},
208
231
  )
209
- if set(payload) != REQUIRED_CLAIMS:
210
- raise InvalidCallerAssertion("Caller assertion claims are invalid.")
232
+ team_uids, is_organization_admin = _caller_facts(payload)
211
233
  if (
212
234
  payload["aud"] != f"urn:mainsequence:fapi:{self.release_uid}"
213
235
  or _canonical_uid(payload["resource_release_uid"]) != self.release_uid
@@ -226,6 +248,8 @@ class CallerAssertionVerifier:
226
248
  environment_uid=self.environment_uid,
227
249
  issued_at=iat,
228
250
  expires_at=exp,
251
+ team_uids=team_uids,
252
+ is_organization_admin=is_organization_admin,
229
253
  )
230
254
  except (InvalidTokenError, KeyError, TypeError, ValueError) as exc:
231
255
  raise InvalidCallerAssertion("Caller assertion is invalid.") from exc
@@ -213,7 +213,11 @@ class _RequestIdentityMiddleware:
213
213
  elif self.mode == "assertion":
214
214
  assertion = _header(scope, ASSERTION_HEADER.lower().encode()) or ""
215
215
  caller = await asyncio.to_thread(self.verifier.verify, assertion)
216
- user = RequestUserIdentity(uid=caller.user_uid)
216
+ user = RequestUserIdentity(
217
+ uid=caller.user_uid,
218
+ team_uids=caller.team_uids,
219
+ is_organization_admin=caller.is_organization_admin,
220
+ )
217
221
  state["resource_release_uid"] = caller.release_uid
218
222
  state["organization_environment_uid"] = caller.environment_uid
219
223
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.8.dev43
3
+ Version: 9.0.8.dev45
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -20,6 +20,7 @@ mainsequence/code_repository_skills.py
20
20
  mainsequence/defaults.py
21
21
  mainsequence/logconf.py
22
22
  mainsequence/repository_identity_security.py
23
+ mainsequence/runtime_credential_exchange.py
23
24
  mainsequence/runtime_flags.py
24
25
  mainsequence/scaffold_skills.py
25
26
  mainsequence.egg-info/PKG-INFO
@@ -90,6 +91,7 @@ tests/test_logconf.py
90
91
  tests/test_models_user_request_bound_auth.py
91
92
  tests/test_notification_uid_contract.py
92
93
  tests/test_observability_client.py
94
+ tests/test_runtime_credential_exchange.py
93
95
  tests/test_scaffold_skills.py
94
96
  tests/test_secret_client_model.py
95
97
  tests/test_source_ownership.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mainsequence"
7
- version = "9.0.8.dev43"
7
+ version = "9.0.8.dev45"
8
8
  description = "Main Sequence SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"