Skip to content

Authoring an adapter

An adapter is the provider-specific counterpart to the substrate. Substrate ships once and is provider-independent; each adapter ships separately and carries all the mechanics of credential issuance and revocation for one target system (Cloudflare, GitHub App PEM, AWS STS, GCP WIF, Vault, a database role, etc.). This page is the 30-minute-sketch guide — reading it plus the schemas reference should be enough to start writing a new adapter feature doc.

Two mechanisms. Everything else is composable from the substrate.

On merge of an approved elevation PR, the adapter mints a scoped, time-boxed credential for the declared scope. Three hard requirements:

  • Scoped to the request. The credential carries only the write capability the PR’s scope field declares — no broader surface, no convenient-maximum fallback. Many providers grant coarse-grained permissions (e.g. Cloudflare’s Pages:Edit covers all Pages operations on the account); the adapter ships with the provider’s achievable minimum as the floor, not as a reason to over-scope.
  • TTL-bounded. expires_on (or platform-equivalent) set to now + min(requested_ttl, tier.max_ttl). The minimum is load-bearing — the tier’s max_ttl is the ceiling regardless of what the request asked for.
  • Correlation-tagged. The credential’s name field (or whatever per-provider identifier can be set at mint time) includes the substrate’s correlation ID: canon-elevation-pr-<N>-<short_sha>. This is what lets the provider’s own audit log join back to the substrate’s audit branch.

Post-apply, the adapter explicitly destroys the credential. Belt-and-braces with platform-level expiry: the explicit destroy is the primary mechanism; expires_on is the safety net for cases where the explicit destroy fails (workflow killed mid-run, network partition, etc.). Both layers compose — the credential is unusable because it is both destroyed and expired.

The substrate treats scope as an opaque map or list. Each adapter validates its own scope shape:

  • Shape checks. Required keys, allowed values, nested-structure correctness.
  • Permission mapping. Translates the scope payload into provider-specific permission identifiers (e.g. Cloudflare permission-group UUIDs, AWS IAM policy ARNs, Vault policy paths).
  • Over-permission rejection. A scope declaring more than the declared tier permits for this adapter fails — the adapter is the last line of defence before the credential is minted.

What an adapter consumes from the substrate

Section titled “What an adapter consumes from the substrate”

Three inputs, all already materialised by substrate infrastructure:

  • Parsed PR frontmatter. The validator parses the PR body and (on success) publishes the structured elevation-request fields. The adapter’s apply workflow reads scope, ttl, risk_tier from the merged PR’s frontmatter to mint the credential.
  • Tier-to-environment mapping. The reusable elevation-tier-router workflow resolves a declared risk_tier to the GitHub Actions environment name for that tier. Medium/high tiers return the tier’s environment; low tiers return empty string (no environment gate).
  • Correlation ID format. canon-elevation-pr-<N>-<short_sha>. The adapter computes this from the merged PR’s number + short SHA and embeds it in the provider credential’s name field.

The adapter does not consume or parse the policy config directly — tier bounds are already enforced by the validator; the adapter trusts that a merged PR has passed policy checks.

Three lifecycle events per elevation. All land on the audit orphan branch at YYYY/MM/DD/<pr-number>-<short-sha>/<event>-<sequence>.json, GPG-signed by the writer identity.

Emitted immediately after the credential is minted, before apply runs.

Required fields (see schemas reference for field semantics): correlation_id, pr_number, pr_sha, event: "issued", at, actor, target_adapter, scope, ttl_seconds, provider_token_id, apply_run_id, result: "success". A failure to mint produces an issued record with result: "failure" rather than an absent record.

Emitted after the apply step completes (success or failure).

Required fields: same shape as issued, with event: "applied". The apply_run_id is the same as the issued record’s (both events occur in the same workflow run). result reflects the apply outcome — a failed apply produces applied-<N>.json with result: "failure", not a silent absence.

Emitted after the credential is destroyed.

Required fields: same shape. event: "revoked". result: "success" if the destroy call succeeded (or if platform-side expires_on is known to have fired); result: "failure" if the destroy attempt errored and the adapter is relying on expires_on as the fallback.

Example — a hypothetical AWS STS adapter

Section titled “Example — a hypothetical AWS STS adapter”
{
"correlation_id": "canon-elevation-pr-101-deadbee",
"pr_number": 101,
"pr_sha": "deadbee",
"event": "issued",
"at": "2026-05-10T09:14:22Z",
"actor": "github-app-id:12345",
"target_adapter": "aws-sts",
"scope": {
"role_arn": "arn:aws:iam::123456789012:role/canon-elevation-s3-write",
"session_name": "canon-elevation-pr-101-deadbee",
"inline_policy": { "Version": "2012-10-17", "Statement": [/* ... */] }
},
"ttl_seconds": 1800,
"provider_token_id": "ASIA...",
"apply_run_id": "15000000042",
"result": "success"
}

Scope is adapter-shaped (role ARN + session name + optional inline-policy narrowing) rather than the Cloudflare-shaped example on the schemas page. The substrate treats the payload as opaque.

Copy the frontmatter + section outline below as the starting point for a new adapter feature. Replace <provider>, <mechanism>, etc. The feature doc lives at internal/features/jit-escalation-<provider>-adapter.md.

---
name: "JIT escalation <provider> adapter — <short description>"
status: draft
owner: <owner>
added: <YYYY-MM-DD>
rubric_version: "<current>"
criteria_advanced:
- "PL4-least-privilege"
- "PL3-deployment-cicd"
related_recipes:
- "canon/recipes/gitops-jit-privilege-elevation.md"
related_integrations: []
depends_on:
- "internal/features/jit-escalation-substrate.md"
- "<any target-system feature this adapter depends on>"
tags: [infrastructure, jit-elevation, adapter, <provider>, draft]
---
# JIT escalation <provider> adapter — <short description>
## Why
<What RW-at-rest gap does this adapter close? Which concrete feature or
workflow stops being "operator mints + applies + revokes" once this
adapter ships?>
## Scope
**In scope.**
- **<Provider> bootstrap identity.** <Long-lived minimal-scope credential;
where it lives; what it can and cannot do directly.>
- **Adapter module.** <Code that consumes a merged elevation PR's
metadata and mints / revokes credentials.>
- **Apply step.** <How `tofu apply` or equivalent consumes the runtime
credential.>
- **Post-apply destroy.** <Non-skippable destroy step + belt-and-braces
platform expiry.>
- **Audit-event emission.** <`issued` / `applied` / `revoked` records
written to the orphan `audit` branch.>
**Out of scope.**
- **The substrate.** <Reference the substrate feature this adapter
consumes it, doesn't rebuild it.>
- **<Provider>-specific scope granularity beyond what the platform
permits.** <Document the achievable minimum the adapter cannot
scope narrower than the provider allows.>
## Prerequisites
### Pre-scaffold discovery (agent-doable)
- [ ] Confirm <provider>'s expiry enforcement: <hard platform invalidation
or soft / cacheable?>
- [ ] Confirm the correlation-tag format survives concurrent issuance +
fast turnaround on this provider's uniqueness rules.
- [ ] Inventory the provider's permission / role model — map scope
payloads to platform-specific identifiers.
### Human-required phase-0 steps
1. **Issue the bootstrap identity.** <Exact UI / API steps. Where the
resulting secret lands (which GHA environment, which secret name).
Rotation cadence.>
## Acceptance criteria
1. **Bootstrap identity is minimally scoped.** Verified by attempting a
direct mutation that the adapter is meant to mediate and confirming
403 / permission-denied.
2. **Merged elevation PR mints a runtime credential with correct
attributes.** Scope, TTL, and correlation tag match the PR's
declared fields.
3. **Scope validation blocks over-permissioned requests.**
4. **Apply runs with the runtime credential.**
5. **Post-apply destroy fires reliably.** Credential is unusable after
apply, verified by attempting a post-apply mutation.
6. **Audit events land on the `audit` branch.** `issued`, `applied`,
`revoked` records present with correct shape.
## Dependencies
- Feature dependency: [`features/jit-escalation-substrate.md`](...) —
substrate must be shipped.
- <Target-system feature>.
## Dogfood targets
- **`PL4-least-privilege`.** <Mechanism: routine state is read-only;
writes require the substrate-mediated elevation; credential is
scoped, TTL-bounded, correlation-tagged.>
- **`PL3-deployment-cicd`.** <Mechanism: apply path is fully automated
from merge through destroy; no out-of-band operator steps.>

Adapter apply workflows consume the reusable elevation-tier-router to resolve the declared tier’s environment, then route the apply job through it. The broad shape:

name: <provider>-apply
on:
pull_request:
types: [closed]
branches: [main]
jobs:
route:
if: github.event.pull_request.merged == true
uses: ./.github/workflows/elevation-tier-router.yml
with:
pr_body: ${{ github.event.pull_request.body }}
apply:
needs: route
runs-on: ubuntu-latest
environment: ${{ needs.route.outputs.environment }} # empty string = no gate
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@<sha> # SHA-pin; respect repo's pinning policy
# Parse the merged PR's elevation frontmatter.
- name: Parse elevation request
id: elevation
run: |
pnpm --filter @canon/elevation-validator exec \
tsx src/cli.ts validate \
--body /tmp/pr-body.md \
--policy .github/elevation-policy.yml \
--print-parsed
env:
PR_BODY: ${{ github.event.pull_request.body }}
# ... materialise PR_BODY safely (env + printf '%s' ... > file).
# Adapter-specific: mint the runtime credential.
- name: Mint credential
id: mint
run: |
# Call provider API / Terraform resource to mint a scoped,
# TTL-bounded, correlation-tagged credential.
# Emit `issued` audit event.
- name: Emit issued event
run: |
# Compose JSON per the audit-event schema;
# commit + push to origin/audit.
# Run the apply.
- name: Apply
run: |
# tofu apply / provider-specific apply with the runtime credential.
# Emit `applied` audit event (success or failure).
- name: Emit applied event
if: always()
run: |
# Record result: success | failure based on the apply step's outcome.
# Destroy the credential — non-skippable.
- name: Destroy credential
if: always()
run: |
# Provider destroy call. On failure, rely on expires_on as fallback.
# Emit `revoked` audit event.
- name: Emit revoked event
if: always()
run: |
# Record result based on the destroy step's outcome.

Key shape choices:

  • environment: ${{ needs.route.outputs.environment }}. An empty string on low tiers means no environment-reviewer gate; a non-empty string (e.g. infrastructure) on medium/high fires the environment’s required_reviewers gate before the job runs.
  • if: always() on emit steps. Audit events land even when the preceding step failed — a failed apply produces an applied record with result: failure, not an absent record.
  • Environment-variable + printf for PR body. The PR body is untrusted content that can carry shell metacharacters or heredoc sentinels. Route it through an env var and printf '%s' rather than heredoc or direct interpolation; this keeps shell tokenisation off the untrusted content.

Walking example — a Vault dynamic-credentials adapter

Section titled “Walking example — a Vault dynamic-credentials adapter”

Suppose the canon grows a Vault deployment and you want a jit-escalation-vault-adapter that mints database credentials for a dynamic-secrets-enabled PostgreSQL role.

Human phase-0. Operator configures a Vault policy that grants the GitHub App’s workload identity the ability to call vault write database/creds/<role-name> and vault lease revoke <lease_id> on elevation-eligible roles only. Vault policy ARN / policy name goes into a GHA environment variable (not secret — Vault’s auth is via workload identity, not static token).

Mint mechanism. vault write database/creds/<role> returns a lease ID + credentials. The adapter captures the lease ID (this is the provider_token_id for the audit record) and the credential for the apply step. expires_on is the lease’s TTL — Vault enforces this server-side.

Revocation mechanism. vault lease revoke <lease_id> destroys the credential explicitly. Vault’s lease expiry is the belt-and-braces fallback if the explicit revoke fails.

Scope shape. scope: { role: "canon-db-migrations-write", postgres_db: "canon_production" } — map keys name the Vault role and the target database. Adapter-level validation checks the role is in an allowlist (e.g. only canon-db-migrations-write and canon-db-hotfix-write are elevation-eligible; canon-db-superuser-write is never callable through this adapter).

Audit emission. issued after vault write, applied after the database migration runs, revoked after vault lease revoke. provider_token_id is the lease ID; the provider-side audit corroboration lives in Vault’s own audit log, joined by the lease ID embedded via Vault’s request metadata if the deployment supports it, or via a sidecar lookup if not.

The skeleton-template above, with these substitutions, is roughly what a jit-escalation-vault-adapter feature doc would start as.

To retrieve the joined PR + event record for a past elevation:

Terminal window
pnpm --filter @canon/elevation-validator exec tsx src/cli.ts \
audit-join --pr <N> --repo <path>

The --repo argument is the path to a local checkout; the join reads the local audit branch (preferring origin/audit, falling back to local audit) for event records and calls the GitHub API for PR metadata. Run git fetch origin audit first to ensure the local ref is current.

See the schemas reference for the return shape, including the missing_phases field that surfaces incomplete lifecycles.

The canon’s first adapter is the Cloudflare adapter, tracked at internal/features/jit-escalation-cf-adapter.md (view on GitHub). Read it alongside this guide for a concrete instance of the shape — bootstrap token + mint step + apply + destroy + audit emission, all provider-specific but structurally matching the skeletons above.

The canon’s wired substrate instance is documented at internal/integrations/canon-jit-escalation-substrate.md — that’s the integration-level record of the particular substrate files shipped, the required-check context name, and the one-time operator runbook items. Distinct from this page, which is the reusable pattern.