Skip to main content

keycardai-temporal

Per-call Keycard token minting for Temporal Python workers, built on keycardai-oauth.

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.

pip install keycardai-temporal

Quick start

from datetime import timedelta

from temporalio import activity, workflow
from temporalio.worker import Worker

from keycardai.temporal import KeycardInterceptor, access, grant

LEDGER = "https://ledger.example.com"


@grant(LEDGER)
@activity.defn
async def post_entry(order_id: str) -> str:
    token = access().access_token  # fresh for this execution only
    ...  # call the ledger with the token
    return "posted"


@workflow.defn
class SettlementWorkflow:
    @workflow.run
    async def run(self, order_id: str) -> str:
        return await workflow.execute_activity(
            post_entry, order_id, start_to_close_timeout=timedelta(seconds=30)
        )


async def main(client):
    interceptor = KeycardInterceptor("https://<zone-id>.keycard.cloud")
    async with Worker(
        client,
        task_queue="settlement",
        workflows=[SettlementWorkflow],
        activities=[post_entry],
        interceptors=[interceptor],
    ):
        ...

Credential discovery

With no credential argument, KeycardInterceptor calls keycardai.oauth.server.discover_credential(), the SDK-wide environment convention:

  • KEYCARD_CLIENT_ID and KEYCARD_CLIENT_SECRET together build a ClientSecret.
  • 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.
  • 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.

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.

Keycard setup

In your Keycard zone, once:

  1. Create an application for the worker with a credential (a ClientSecret, or any workload identity credential the worker's platform supports).
  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.
  3. Add the resource to the application's dependencies. App-only issuance needs the dependency; with no user there is no consent step.
  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.

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.

Identity modes

  • @grant(resource): the application acts as itself (client credentials). Requires a ClientSecret credential.
  • @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.
  • @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.

Locating the identity reference

from typing import Annotated
from dataclasses import dataclass

from keycardai.temporal import Subject, grant


@dataclass
class Order:
    order_id: str
    approver_id: Annotated[str, Subject()]


@grant(LEDGER)                                       # Subject() marker, validated at decoration time
async def approve(order: Order) -> None: ...

@grant(LEDGER, subject_from="approver_id")           # parameter name ...
async def approve(order_id: str, approver_id: str) -> None: ...

@grant(LEDGER, subject_from="order.approver_id")     # ... or a dotted path into one
async def approve(order: dict) -> None: ...

@grant(LEDGER, subject_from=lambda order: order["approver_id"])  # sync callable escape hatch
async def approve(order: dict) -> None: ...

Use one strategy per activity: a Subject() marker together with subject_from is rejected at decoration time.

The worker supplies the session lookup:

async def session_token_for(approver_id: str) -> str:
    return await sessions.current_token(approver_id)

interceptor = KeycardInterceptor(
    "https://<zone-id>.keycard.cloud",
    subject_token_provider=session_token_for,
)

Design notes

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

Tests

cd packages/temporal && uv run --extra test pytest tests/ -v

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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

keycardai_temporal-0.1.0.tar.gz (19.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

keycardai_temporal-0.1.0-py3-none-any.whl (10.9 kB view details)

Uploaded Python 3

File details

Details for the file keycardai_temporal-0.1.0.tar.gz.

File metadata

  • Download URL: keycardai_temporal-0.1.0.tar.gz
  • Upload date:
  • Size: 19.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for keycardai_temporal-0.1.0.tar.gz
Algorithm Hash digest
SHA256 ea8c7fbaa2a34de058195084823b264136bf1e8b6facda38c10e890d2e3c2eb4
MD5 c434aaa9c885bb576dd3f27df0101686
BLAKE2b-256 fdc52f13aa32456b463630e748abc5f178c8f82a658f2f2db2e98d2d81ad7f77

See more details on using hashes here.

File details

Details for the file keycardai_temporal-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: keycardai_temporal-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 10.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for keycardai_temporal-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bb7b91ad676f2196c0f890a4ca82ae2d0995686a21673a2d36fc388d79182a72
MD5 bea7ee4cbcb4a3426cf59f2287414971
BLAKE2b-256 664c7298aadafdc9e9de7471ff53b02335e0ae4f2275e350963e9a8ee1c10e7e

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page