keycardai-temporal 0.1.0__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.
- keycardai_temporal-0.1.0/.gitignore +194 -0
- keycardai_temporal-0.1.0/PKG-INFO +167 -0
- keycardai_temporal-0.1.0/README.md +137 -0
- keycardai_temporal-0.1.0/pyproject.toml +141 -0
- keycardai_temporal-0.1.0/src/keycardai/temporal/__init__.py +481 -0
- keycardai_temporal-0.1.0/tests/test_history_hygiene.py +262 -0
- keycardai_temporal-0.1.0/tests/test_interceptor.py +696 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# poetry
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
102
|
+
#poetry.lock
|
|
103
|
+
|
|
104
|
+
# pdm
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
106
|
+
#pdm.lock
|
|
107
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
108
|
+
# in version control.
|
|
109
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
110
|
+
.pdm.toml
|
|
111
|
+
|
|
112
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
113
|
+
__pypackages__/
|
|
114
|
+
|
|
115
|
+
# Celery stuff
|
|
116
|
+
celerybeat-schedule
|
|
117
|
+
celerybeat.pid
|
|
118
|
+
|
|
119
|
+
# SageMath parsed files
|
|
120
|
+
*.sage.py
|
|
121
|
+
|
|
122
|
+
# Environments
|
|
123
|
+
.env
|
|
124
|
+
.venv
|
|
125
|
+
.venv-leg
|
|
126
|
+
env/
|
|
127
|
+
venv/
|
|
128
|
+
ENV/
|
|
129
|
+
env.bak/
|
|
130
|
+
venv.bak/
|
|
131
|
+
|
|
132
|
+
# Spyder project settings
|
|
133
|
+
.spyderproject
|
|
134
|
+
.spyproject
|
|
135
|
+
|
|
136
|
+
# Rope project settings
|
|
137
|
+
.ropeproject
|
|
138
|
+
|
|
139
|
+
# mkdocs documentation
|
|
140
|
+
/site
|
|
141
|
+
|
|
142
|
+
# mypy
|
|
143
|
+
.mypy_cache/
|
|
144
|
+
.dmypy.json
|
|
145
|
+
dmypy.json
|
|
146
|
+
|
|
147
|
+
# Pyre type checker
|
|
148
|
+
.pyre/
|
|
149
|
+
|
|
150
|
+
# pytype static type analyzer
|
|
151
|
+
.pytype/
|
|
152
|
+
|
|
153
|
+
# Cython debug symbols
|
|
154
|
+
cython_debug/
|
|
155
|
+
|
|
156
|
+
# PyCharm
|
|
157
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
158
|
+
# be added to the global gitignore or merged into this project gitignore. For a PyCharm
|
|
159
|
+
# project, uncomment below!
|
|
160
|
+
#.idea/
|
|
161
|
+
|
|
162
|
+
# VS Code
|
|
163
|
+
.vscode/
|
|
164
|
+
|
|
165
|
+
# OS generated files
|
|
166
|
+
.DS_Store
|
|
167
|
+
.DS_Store?
|
|
168
|
+
._*
|
|
169
|
+
.Spotlight-V100
|
|
170
|
+
.Trashes
|
|
171
|
+
ehthumbs.db
|
|
172
|
+
Thumbs.db
|
|
173
|
+
|
|
174
|
+
# Backup files
|
|
175
|
+
*~
|
|
176
|
+
*.bak
|
|
177
|
+
*.backup
|
|
178
|
+
*.old
|
|
179
|
+
*.orig
|
|
180
|
+
|
|
181
|
+
# Temporary files
|
|
182
|
+
*.tmp
|
|
183
|
+
*.temp
|
|
184
|
+
|
|
185
|
+
# IDE files
|
|
186
|
+
*.swp
|
|
187
|
+
*.swo
|
|
188
|
+
*~
|
|
189
|
+
|
|
190
|
+
# Local development
|
|
191
|
+
.local/
|
|
192
|
+
|
|
193
|
+
# Claude Code
|
|
194
|
+
CLAUDE.md
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: keycardai-temporal
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Temporal integration for Keycard: per-call token minting for activities, with nothing in workflow history
|
|
5
|
+
Project-URL: Homepage, https://github.com/keycardai/python-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/keycardai/python-sdk
|
|
7
|
+
Project-URL: Documentation, https://docs.keycardai.com
|
|
8
|
+
Project-URL: Issues, https://github.com/keycardai/python-sdk/issues
|
|
9
|
+
Author-email: Keycard <support@keycard.ai>
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: activities,authentication,keycard,oauth,temporal,token-exchange,workflows
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: keycardai-oauth>=0.26.0
|
|
25
|
+
Requires-Dist: temporalio>=1.32.0
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest-asyncio>=1.1.0; extra == 'test'
|
|
28
|
+
Requires-Dist: pytest>=8.4.1; extra == 'test'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# keycardai-temporal
|
|
32
|
+
|
|
33
|
+
Per-call Keycard token minting for Temporal Python workers, built on `keycardai-oauth`.
|
|
34
|
+
|
|
35
|
+
An activity declares the resource it needs with `@grant(resource)`, the worker's `KeycardInterceptor` mints a fresh token for every activity execution, and `access()` returns it inside the activity. Nothing is written to workflow history.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install keycardai-temporal
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from datetime import timedelta
|
|
45
|
+
|
|
46
|
+
from temporalio import activity, workflow
|
|
47
|
+
from temporalio.worker import Worker
|
|
48
|
+
|
|
49
|
+
from keycardai.temporal import KeycardInterceptor, access, grant
|
|
50
|
+
|
|
51
|
+
LEDGER = "https://ledger.example.com"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@grant(LEDGER)
|
|
55
|
+
@activity.defn
|
|
56
|
+
async def post_entry(order_id: str) -> str:
|
|
57
|
+
token = access().access_token # fresh for this execution only
|
|
58
|
+
... # call the ledger with the token
|
|
59
|
+
return "posted"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@workflow.defn
|
|
63
|
+
class SettlementWorkflow:
|
|
64
|
+
@workflow.run
|
|
65
|
+
async def run(self, order_id: str) -> str:
|
|
66
|
+
return await workflow.execute_activity(
|
|
67
|
+
post_entry, order_id, start_to_close_timeout=timedelta(seconds=30)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def main(client):
|
|
72
|
+
interceptor = KeycardInterceptor("https://<zone-id>.keycard.cloud")
|
|
73
|
+
async with Worker(
|
|
74
|
+
client,
|
|
75
|
+
task_queue="settlement",
|
|
76
|
+
workflows=[SettlementWorkflow],
|
|
77
|
+
activities=[post_entry],
|
|
78
|
+
interceptors=[interceptor],
|
|
79
|
+
):
|
|
80
|
+
...
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Credential discovery
|
|
84
|
+
|
|
85
|
+
With no `credential` argument, `KeycardInterceptor` calls `keycardai.oauth.server.discover_credential()`, the SDK-wide environment convention:
|
|
86
|
+
|
|
87
|
+
- `KEYCARD_CLIENT_ID` and `KEYCARD_CLIENT_SECRET` together build a `ClientSecret`.
|
|
88
|
+
- A token file named by `KEYCARD_EKS_WORKLOAD_IDENTITY_TOKEN_FILE`, `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE`, `AWS_WEB_IDENTITY_TOKEN_FILE`, or `AZURE_FEDERATED_TOKEN_FILE` builds a `WorkloadIdentity`.
|
|
89
|
+
- `KEYCARD_APPLICATION_CREDENTIAL_TYPE` (`client_secret` or `workload_identity`; `eks_workload_identity` is a legacy alias) names the type to use, and wins over everything else in the environment.
|
|
90
|
+
|
|
91
|
+
When the environment can build more than one credential and the type variable does not choose between them, the worker fails at startup with `GrantConfigurationError` instead of guessing. EKS IRSA injects `AWS_WEB_IDENTITY_TOKEN_FILE` into pods automatically, so a worker meant to use a client secret on EKS must set `KEYCARD_APPLICATION_CREDENTIAL_TYPE=client_secret`. Any `keycardai.oauth.server.ApplicationCredential` can also be passed explicitly, which skips discovery entirely.
|
|
92
|
+
|
|
93
|
+
## Keycard setup
|
|
94
|
+
|
|
95
|
+
In your Keycard zone, once:
|
|
96
|
+
|
|
97
|
+
1. Create an application for the worker with a credential (a `ClientSecret`, or any workload identity credential the worker's platform supports).
|
|
98
|
+
2. Create the resource the activities will call. For plain client-credentials issuance, the Zone Provider is enough as its credential provider: nothing exchanges from its tokens.
|
|
99
|
+
3. Add the resource to the application's dependencies. App-only issuance needs the dependency; with no user there is no consent step.
|
|
100
|
+
4. For on-behalf-of activities, one more piece of zone topology: the exchange rule requires the exchanging application to provide the resource the subject token is audienced to, so the worker's application needs a small anchor resource of its own, with user sessions audienced to it.
|
|
101
|
+
|
|
102
|
+
The resource identifier in `@grant(...)` must match the console registration byte for byte; a trailing character difference reads as a different resource and policy denies it.
|
|
103
|
+
|
|
104
|
+
## Identity modes
|
|
105
|
+
|
|
106
|
+
- `@grant(resource)`: the application acts as itself (client credentials). Requires a `ClientSecret` credential.
|
|
107
|
+
- `@grant(resource, subject_from=...)`: the application acts on behalf of a user. The activity input carries an identity reference (a user id, never a token). The interceptor's `subject_token_provider`, an application-supplied session lookup, returns that user's current session token, and an RFC 8693 exchange turns it into a token for the resource.
|
|
108
|
+
- `@grant(resource, subject_from=..., impersonate=True)`: impersonation, for workflows that outlive the user's session. The located value is a stable user identifier (email or oid) sent directly to the zone, which mints a short-lived substitute-user token. No session lookup runs and no `subject_token_provider` is needed. This is a different trust model from delegation: the worker asserts who the user is, and zone policy is the control. It requires a confidential client, application consent set to implicit, the resource declared as a dependency of the application, a prior delegated grant established by the user for the resource, and zone policy that explicitly permits the application to impersonate (forbidden by default). Prefer live delegation whenever the user's session is still expected to exist.
|
|
109
|
+
|
|
110
|
+
### Locating the identity reference
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from typing import Annotated
|
|
114
|
+
from dataclasses import dataclass
|
|
115
|
+
|
|
116
|
+
from keycardai.temporal import Subject, grant
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class Order:
|
|
121
|
+
order_id: str
|
|
122
|
+
approver_id: Annotated[str, Subject()]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@grant(LEDGER) # Subject() marker, validated at decoration time
|
|
126
|
+
async def approve(order: Order) -> None: ...
|
|
127
|
+
|
|
128
|
+
@grant(LEDGER, subject_from="approver_id") # parameter name ...
|
|
129
|
+
async def approve(order_id: str, approver_id: str) -> None: ...
|
|
130
|
+
|
|
131
|
+
@grant(LEDGER, subject_from="order.approver_id") # ... or a dotted path into one
|
|
132
|
+
async def approve(order: dict) -> None: ...
|
|
133
|
+
|
|
134
|
+
@grant(LEDGER, subject_from=lambda order: order["approver_id"]) # sync callable escape hatch
|
|
135
|
+
async def approve(order: dict) -> None: ...
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Use one strategy per activity: a `Subject()` marker together with `subject_from` is rejected at decoration time.
|
|
139
|
+
|
|
140
|
+
The worker supplies the session lookup:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
async def session_token_for(approver_id: str) -> str:
|
|
144
|
+
return await sessions.current_token(approver_id)
|
|
145
|
+
|
|
146
|
+
interceptor = KeycardInterceptor(
|
|
147
|
+
"https://<zone-id>.keycard.cloud",
|
|
148
|
+
subject_token_provider=session_token_for,
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Design notes
|
|
153
|
+
|
|
154
|
+
- Tokens never touch durable state. Workflow history is replayable and permanent, so unlike header-based context-propagation interceptors, nothing is written to activity headers, arguments, or return values. The token exists only inside one execution's context. This is also why on-behalf-of activities receive an identity reference instead of a token: the session lookup and exchange happen at the edge, inside the execution, so a session revoked mid-workflow is never replayed from state.
|
|
155
|
+
- A mint failure raises before the activity body runs, and there is no fallback path. Tokens live in the SDK's shared `AccessContext` (the same container `keycardai-mcp` uses), but where that idiom is non-throwing, the interceptor converts recorded errors into raises on purpose: in Temporal, raising is the error channel.
|
|
156
|
+
- Transient mint failures are retryable; permanent denials are not. Network failures and unclassified errors let the activity retry policy govern what happens next, while `access_denied`, `insufficient_authorization`, and `invalid_client` raise `ApplicationError(type="KeycardAccessDenied", non_retryable=True)` immediately. Misdeclarations surface as `GrantConfigurationError`, retryable by default so a worker redeploy with the fix lets the next retry succeed; list `"GrantConfigurationError"` in the retry policy's `non_retryable_error_types` to give up sooner.
|
|
157
|
+
- No token caching, per Keycard's credential rules; per-call mint is the contract. One OAuth client is created per worker and reused; only the tokens are fresh.
|
|
158
|
+
- The package wraps its own `keycardai` imports in `workflow.unsafe.imports_passed_through()`, the idiom from Temporal's sentry sample, so consumers import it normally even in files that define workflows.
|
|
159
|
+
- Works with async activities and with sync activities on the thread-pool executor (the Temporal SDK copies contextvars into the thread). Sync activities on a process-pool executor are not supported because contextvars do not cross processes.
|
|
160
|
+
|
|
161
|
+
## Tests
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
cd packages/temporal && uv run --extra test pytest tests/ -v
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`tests/test_interceptor.py` drives the interceptor chain directly with the OAuth client stubbed. `tests/test_history_hygiene.py` runs a real workflow against a local Temporal dev server (downloaded by `temporalio` on first use), then scans the recorded history, including base64-decoded payloads, and asserts the minted token appears nowhere. Neither needs a Keycard zone.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# keycardai-temporal
|
|
2
|
+
|
|
3
|
+
Per-call Keycard token minting for Temporal Python workers, built on `keycardai-oauth`.
|
|
4
|
+
|
|
5
|
+
An activity declares the resource it needs with `@grant(resource)`, the worker's `KeycardInterceptor` mints a fresh token for every activity execution, and `access()` returns it inside the activity. Nothing is written to workflow history.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install keycardai-temporal
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from datetime import timedelta
|
|
15
|
+
|
|
16
|
+
from temporalio import activity, workflow
|
|
17
|
+
from temporalio.worker import Worker
|
|
18
|
+
|
|
19
|
+
from keycardai.temporal import KeycardInterceptor, access, grant
|
|
20
|
+
|
|
21
|
+
LEDGER = "https://ledger.example.com"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@grant(LEDGER)
|
|
25
|
+
@activity.defn
|
|
26
|
+
async def post_entry(order_id: str) -> str:
|
|
27
|
+
token = access().access_token # fresh for this execution only
|
|
28
|
+
... # call the ledger with the token
|
|
29
|
+
return "posted"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@workflow.defn
|
|
33
|
+
class SettlementWorkflow:
|
|
34
|
+
@workflow.run
|
|
35
|
+
async def run(self, order_id: str) -> str:
|
|
36
|
+
return await workflow.execute_activity(
|
|
37
|
+
post_entry, order_id, start_to_close_timeout=timedelta(seconds=30)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def main(client):
|
|
42
|
+
interceptor = KeycardInterceptor("https://<zone-id>.keycard.cloud")
|
|
43
|
+
async with Worker(
|
|
44
|
+
client,
|
|
45
|
+
task_queue="settlement",
|
|
46
|
+
workflows=[SettlementWorkflow],
|
|
47
|
+
activities=[post_entry],
|
|
48
|
+
interceptors=[interceptor],
|
|
49
|
+
):
|
|
50
|
+
...
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Credential discovery
|
|
54
|
+
|
|
55
|
+
With no `credential` argument, `KeycardInterceptor` calls `keycardai.oauth.server.discover_credential()`, the SDK-wide environment convention:
|
|
56
|
+
|
|
57
|
+
- `KEYCARD_CLIENT_ID` and `KEYCARD_CLIENT_SECRET` together build a `ClientSecret`.
|
|
58
|
+
- A token file named by `KEYCARD_EKS_WORKLOAD_IDENTITY_TOKEN_FILE`, `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE`, `AWS_WEB_IDENTITY_TOKEN_FILE`, or `AZURE_FEDERATED_TOKEN_FILE` builds a `WorkloadIdentity`.
|
|
59
|
+
- `KEYCARD_APPLICATION_CREDENTIAL_TYPE` (`client_secret` or `workload_identity`; `eks_workload_identity` is a legacy alias) names the type to use, and wins over everything else in the environment.
|
|
60
|
+
|
|
61
|
+
When the environment can build more than one credential and the type variable does not choose between them, the worker fails at startup with `GrantConfigurationError` instead of guessing. EKS IRSA injects `AWS_WEB_IDENTITY_TOKEN_FILE` into pods automatically, so a worker meant to use a client secret on EKS must set `KEYCARD_APPLICATION_CREDENTIAL_TYPE=client_secret`. Any `keycardai.oauth.server.ApplicationCredential` can also be passed explicitly, which skips discovery entirely.
|
|
62
|
+
|
|
63
|
+
## Keycard setup
|
|
64
|
+
|
|
65
|
+
In your Keycard zone, once:
|
|
66
|
+
|
|
67
|
+
1. Create an application for the worker with a credential (a `ClientSecret`, or any workload identity credential the worker's platform supports).
|
|
68
|
+
2. Create the resource the activities will call. For plain client-credentials issuance, the Zone Provider is enough as its credential provider: nothing exchanges from its tokens.
|
|
69
|
+
3. Add the resource to the application's dependencies. App-only issuance needs the dependency; with no user there is no consent step.
|
|
70
|
+
4. For on-behalf-of activities, one more piece of zone topology: the exchange rule requires the exchanging application to provide the resource the subject token is audienced to, so the worker's application needs a small anchor resource of its own, with user sessions audienced to it.
|
|
71
|
+
|
|
72
|
+
The resource identifier in `@grant(...)` must match the console registration byte for byte; a trailing character difference reads as a different resource and policy denies it.
|
|
73
|
+
|
|
74
|
+
## Identity modes
|
|
75
|
+
|
|
76
|
+
- `@grant(resource)`: the application acts as itself (client credentials). Requires a `ClientSecret` credential.
|
|
77
|
+
- `@grant(resource, subject_from=...)`: the application acts on behalf of a user. The activity input carries an identity reference (a user id, never a token). The interceptor's `subject_token_provider`, an application-supplied session lookup, returns that user's current session token, and an RFC 8693 exchange turns it into a token for the resource.
|
|
78
|
+
- `@grant(resource, subject_from=..., impersonate=True)`: impersonation, for workflows that outlive the user's session. The located value is a stable user identifier (email or oid) sent directly to the zone, which mints a short-lived substitute-user token. No session lookup runs and no `subject_token_provider` is needed. This is a different trust model from delegation: the worker asserts who the user is, and zone policy is the control. It requires a confidential client, application consent set to implicit, the resource declared as a dependency of the application, a prior delegated grant established by the user for the resource, and zone policy that explicitly permits the application to impersonate (forbidden by default). Prefer live delegation whenever the user's session is still expected to exist.
|
|
79
|
+
|
|
80
|
+
### Locating the identity reference
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from typing import Annotated
|
|
84
|
+
from dataclasses import dataclass
|
|
85
|
+
|
|
86
|
+
from keycardai.temporal import Subject, grant
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Order:
|
|
91
|
+
order_id: str
|
|
92
|
+
approver_id: Annotated[str, Subject()]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@grant(LEDGER) # Subject() marker, validated at decoration time
|
|
96
|
+
async def approve(order: Order) -> None: ...
|
|
97
|
+
|
|
98
|
+
@grant(LEDGER, subject_from="approver_id") # parameter name ...
|
|
99
|
+
async def approve(order_id: str, approver_id: str) -> None: ...
|
|
100
|
+
|
|
101
|
+
@grant(LEDGER, subject_from="order.approver_id") # ... or a dotted path into one
|
|
102
|
+
async def approve(order: dict) -> None: ...
|
|
103
|
+
|
|
104
|
+
@grant(LEDGER, subject_from=lambda order: order["approver_id"]) # sync callable escape hatch
|
|
105
|
+
async def approve(order: dict) -> None: ...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Use one strategy per activity: a `Subject()` marker together with `subject_from` is rejected at decoration time.
|
|
109
|
+
|
|
110
|
+
The worker supplies the session lookup:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
async def session_token_for(approver_id: str) -> str:
|
|
114
|
+
return await sessions.current_token(approver_id)
|
|
115
|
+
|
|
116
|
+
interceptor = KeycardInterceptor(
|
|
117
|
+
"https://<zone-id>.keycard.cloud",
|
|
118
|
+
subject_token_provider=session_token_for,
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Design notes
|
|
123
|
+
|
|
124
|
+
- Tokens never touch durable state. Workflow history is replayable and permanent, so unlike header-based context-propagation interceptors, nothing is written to activity headers, arguments, or return values. The token exists only inside one execution's context. This is also why on-behalf-of activities receive an identity reference instead of a token: the session lookup and exchange happen at the edge, inside the execution, so a session revoked mid-workflow is never replayed from state.
|
|
125
|
+
- A mint failure raises before the activity body runs, and there is no fallback path. Tokens live in the SDK's shared `AccessContext` (the same container `keycardai-mcp` uses), but where that idiom is non-throwing, the interceptor converts recorded errors into raises on purpose: in Temporal, raising is the error channel.
|
|
126
|
+
- Transient mint failures are retryable; permanent denials are not. Network failures and unclassified errors let the activity retry policy govern what happens next, while `access_denied`, `insufficient_authorization`, and `invalid_client` raise `ApplicationError(type="KeycardAccessDenied", non_retryable=True)` immediately. Misdeclarations surface as `GrantConfigurationError`, retryable by default so a worker redeploy with the fix lets the next retry succeed; list `"GrantConfigurationError"` in the retry policy's `non_retryable_error_types` to give up sooner.
|
|
127
|
+
- No token caching, per Keycard's credential rules; per-call mint is the contract. One OAuth client is created per worker and reused; only the tokens are fresh.
|
|
128
|
+
- The package wraps its own `keycardai` imports in `workflow.unsafe.imports_passed_through()`, the idiom from Temporal's sentry sample, so consumers import it normally even in files that define workflows.
|
|
129
|
+
- Works with async activities and with sync activities on the thread-pool executor (the Temporal SDK copies contextvars into the thread). Sync activities on a process-pool executor are not supported because contextvars do not cross processes.
|
|
130
|
+
|
|
131
|
+
## Tests
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
cd packages/temporal && uv run --extra test pytest tests/ -v
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`tests/test_interceptor.py` drives the interceptor chain directly with the OAuth client stubbed. `tests/test_history_hygiene.py` runs a real workflow against a local Temporal dev server (downloaded by `temporalio` on first use), then scans the recorded history, including base64-decoded payloads, and asserts the minted token appears nowhere. Neither needs a Keycard zone.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "keycardai-temporal"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Temporal integration for Keycard: per-call token minting for activities, with nothing in workflow history"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Keycard", email = "support@keycard.ai" }]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"keycardai-oauth>=0.26.0",
|
|
11
|
+
"temporalio>=1.32.0",
|
|
12
|
+
]
|
|
13
|
+
keywords = ["temporal", "workflows", "activities", "oauth", "token-exchange", "authentication", "keycard"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Topic :: Security",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
test = [
|
|
30
|
+
"pytest>=8.4.1",
|
|
31
|
+
"pytest-asyncio>=1.1.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/keycardai/python-sdk"
|
|
36
|
+
Repository = "https://github.com/keycardai/python-sdk"
|
|
37
|
+
Documentation = "https://docs.keycardai.com"
|
|
38
|
+
Issues = "https://github.com/keycardai/python-sdk/issues"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["hatchling", "uv-dynamic-versioning"]
|
|
42
|
+
build-backend = "hatchling.build"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.version]
|
|
45
|
+
source = "uv-dynamic-versioning"
|
|
46
|
+
|
|
47
|
+
[tool.uv-dynamic-versioning]
|
|
48
|
+
vcs = "git"
|
|
49
|
+
pattern = "(?P<base>\\d+\\.\\d+\\.\\d+)-keycardai-temporal"
|
|
50
|
+
style = "pep440"
|
|
51
|
+
|
|
52
|
+
[[tool.uv.index]]
|
|
53
|
+
name = "testpypi"
|
|
54
|
+
url = "https://test.pypi.org/simple/"
|
|
55
|
+
publish-url = "https://test.pypi.org/legacy/"
|
|
56
|
+
explicit = true
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.wheel]
|
|
59
|
+
packages = ["src/keycardai"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
line-length = 88
|
|
63
|
+
target-version = "py310"
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint]
|
|
66
|
+
select = [
|
|
67
|
+
"E", # pycodestyle errors
|
|
68
|
+
"W", # pycodestyle warnings
|
|
69
|
+
"F", # pyflakes
|
|
70
|
+
"I", # isort
|
|
71
|
+
"B", # flake8-bugbear
|
|
72
|
+
"C4", # flake8-comprehensions
|
|
73
|
+
"UP", # pyupgrade
|
|
74
|
+
]
|
|
75
|
+
ignore = [
|
|
76
|
+
"E501", # line too long, we'll handle case by case
|
|
77
|
+
]
|
|
78
|
+
isort = { combine-as-imports = true, known-first-party = ["keycardai"] }
|
|
79
|
+
|
|
80
|
+
[tool.ruff.lint.per-file-ignores]
|
|
81
|
+
"tests/**/*.py" = ["T20"]
|
|
82
|
+
|
|
83
|
+
[tool.mypy]
|
|
84
|
+
strict = true
|
|
85
|
+
disallow_incomplete_defs = false
|
|
86
|
+
disallow_untyped_defs = false
|
|
87
|
+
disallow_untyped_calls = false
|
|
88
|
+
|
|
89
|
+
[[tool.mypy.overrides]]
|
|
90
|
+
module = "tests.*"
|
|
91
|
+
disallow_untyped_defs = false
|
|
92
|
+
|
|
93
|
+
[tool.coverage.run]
|
|
94
|
+
source = ["tests", "src/keycardai"]
|
|
95
|
+
|
|
96
|
+
[tool.coverage.report]
|
|
97
|
+
show_missing = true
|
|
98
|
+
exclude_also = [
|
|
99
|
+
"pragma: no cover",
|
|
100
|
+
"if TYPE_CHECKING:",
|
|
101
|
+
"@abc.abstractmethod",
|
|
102
|
+
"raise NotImplementedError",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
[tool.pytest.ini_options]
|
|
106
|
+
testpaths = ["tests"]
|
|
107
|
+
addopts = "-ra -q"
|
|
108
|
+
asyncio_mode = "auto"
|
|
109
|
+
|
|
110
|
+
[tool.commitizen]
|
|
111
|
+
name = "cz_customize"
|
|
112
|
+
version = "0.1.0"
|
|
113
|
+
tag_format = "${version}-keycardai-temporal"
|
|
114
|
+
ignored_tag_formats = ["${version}-*"]
|
|
115
|
+
update_changelog_on_bump = true
|
|
116
|
+
bump_message = "bump: keycardai-temporal $current_version → $new_version"
|
|
117
|
+
major_version_zero = true
|
|
118
|
+
|
|
119
|
+
[tool.commitizen.customize]
|
|
120
|
+
# Scoped bump detection: without bump_pattern/bump_map commitizen falls back to
|
|
121
|
+
# its default classifier, which matches any `feat(...)!:` regardless of scope.
|
|
122
|
+
changelog_pattern = "^(feat|fix|refactor|perf|test|revert)\\(keycardai-temporal\\)(!)?:"
|
|
123
|
+
bump_pattern = "^((?:feat|fix|refactor|perf|revert|BREAKING[\\- ]CHANGE)\\(keycardai-temporal\\)!?):"
|
|
124
|
+
|
|
125
|
+
[tool.commitizen.customize.bump_map]
|
|
126
|
+
"^.+\\(keycardai-temporal\\)!$" = "MAJOR"
|
|
127
|
+
"^BREAKING[\\- ]CHANGE" = "MAJOR"
|
|
128
|
+
"^feat" = "MINOR"
|
|
129
|
+
"^fix" = "PATCH"
|
|
130
|
+
"^refactor" = "PATCH"
|
|
131
|
+
"^perf" = "PATCH"
|
|
132
|
+
"^revert" = "PATCH"
|
|
133
|
+
|
|
134
|
+
[tool.commitizen.customize.bump_map_major_version_zero]
|
|
135
|
+
"^.+\\(keycardai-temporal\\)!$" = "MINOR"
|
|
136
|
+
"^BREAKING[\\- ]CHANGE" = "MINOR"
|
|
137
|
+
"^feat" = "MINOR"
|
|
138
|
+
"^fix" = "PATCH"
|
|
139
|
+
"^refactor" = "PATCH"
|
|
140
|
+
"^perf" = "PATCH"
|
|
141
|
+
"^revert" = "PATCH"
|