From 8a4e9d80cef458e32a7bd7c20e7b84afa9b4135b Mon Sep 17 00:00:00 2001 From: "jonatw-eagle[bot]" <298894443+jonatw-eagle[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:26:35 +0000 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20add=20keyless=20clearance=20foreign?= =?UTF-8?q?-policy=20note=20(Tailscale=20=C3=97=20Roles=20Anywhere=20?= =?UTF-8?q?=C3=97=20STS=20OIDC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/foreign-policy/keyless-clearance.md | 252 +++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/foreign-policy/keyless-clearance.md diff --git a/docs/foreign-policy/keyless-clearance.md b/docs/foreign-policy/keyless-clearance.md new file mode 100644 index 0000000..a7f12e7 --- /dev/null +++ b/docs/foreign-policy/keyless-clearance.md @@ -0,0 +1,252 @@ +# One Ministry, Many Borders +### The openab fleet's foreign policy — agents carry one short-lived document; a single ministry clears them into every service on visa-waiver +*(Target-state doctrine. Today's reality is **split** across several controls — an SSM/KMS scoped vault, Roles Anywhere identities, and octobroker as a GitHub-only sibling. The single unified broker is **not shipped**; see the status note below.)* + +> **Bottom line:** an openab agent holds **one short-lived document** and nothing else — no long-lived AWS key, no standing tailnet auth-key, no GitHub PAT. Every border runs on **visa-waiver**: present a trusted, government-verifiable document, receive a **time-boxed entry stamp on arrival**, walk in. The stamp self-expires. Nothing to hoard, leak, or revoke by hand. + +> **Precise scope of "keyless."** It means **no long-lived shared secret in the app/runtime** — no static access key, auth-key, or PAT in the container. It does **not** mean trust comes from nowhere: the chain still stands on an **X.509 trust anchor**, **IAM role trust policies**, **KMS key policies**, and **broker/tailnet config** — the durable controls that make the short-lived tokens safe. The win is that *the thing sitting in the agent* is short-lived and scoped. (A stricter label would be **"long-lived-secretless."**) + +**How to read this note.** Claims are tagged **[Today]**, **[Proposed]** (RFC `openabdev/octobroker#54`), or **[Vision]** so roadmap is never mistaken for shipped fact. Every metaphor lands back on mechanics — who signs, who verifies, who authorizes, token lifetime, blast radius — via the leg descriptions (§2), the **runbook** (§9), and the **decoder ring** (§10), where every metaphor maps to a named, auditable component. + +**Contents** +1. Doctrine — no bribes at the border +2. The border protocol (Legs A–C): passport → travel document → entry stamp +3. Ways to arrive — the traveller's document is pluggable (4 scenarios + Mermaid) +4. The scoped vault — SSM SecureString + scoped role (does it sharpen issuance?) +5. How openab wires the house favourite today (real Roles Anywhere setup) +6. octobroker — the Ministry of Foreign Affairs (Today / Proposed / Vision) +7. Sequence diagram — one clearance, end to end +8. Why we fly this way +9. Runbook & NOTAMs +10. Decoder ring + +--- + +> **Where openab actually stands today (honest status — read this first).** openab does **not** currently run on octobroker. Its broker surface carries **many** secret/identity types — GitHub App private keys, Tailscale client credentials, AWS identities, service keys — **not just GitHub tokens**, so a GitHub-only broker isn't a drop-in. Today openab implements the *spirit* itself: a **scoped SSM vault** (per-App CMK + per-principal KMS + audit, §4) plus **Roles Anywhere** identities (§5). The **unified ministry** in this note — one ID, one broker for every border — is **[Vision]**, an open design direction, not a shipped state. What makes it plausible is octobroker's **pluggable-provider** refactor (RFC `openabdev/octobroker#54`): if the broker becomes provider-agnostic, a **Roles Anywhere / workload-identity provider** could sit beside GitHub and carry openab's broader secret surface. This is a thesis openab is *thinking through* — offered here to align with octobroker's spirit and invite scrutiny, not as a committed roadmap. + +## 1. Doctrine — no bribes at the border + +The old way to put a machine into AWS was to staple a **long-lived access key** to it — cash taped inside a passport. It never expires, it gets photographed, it drops out in a git commit, and when it's stolen you can't tell which traveller lost it. Same for a static **Tailscale auth-key** or a **GitHub PAT** baked into an agent container. + +openab's rule: **agents carry no long-lived credentials.** Every border runs on **visa-waiver** — a trusted document in, a time-boxed stamp out. The endgame: **one ministry** does all the paperwork, so a traveller shows **one citizen ID** and is cleared into GitHub-land, the tailnet, and AWS alike. + +```mermaid +flowchart LR + A["openab agent
holds ONE short-lived doc"] -->|shows citizen ID| M{{"Ministry of Foreign Affairs
octobroker · default-deny · audited"}} + M -->|"Desk 1 · TODAY"| G["GitHub-land
scoped, short-lived App token"] + M -.->|"Desk 2 · VISION
pluggable-provider RFC"| T["Tailnet / AWS
tagged short-lived token"] +``` + +--- + +## 2. The border protocol (Legs A–C) + +This is the paperwork for the **tailnet/AWS** border. Today it's a manual sequence (§9); the ministry's future Desk 2 would run it for you. + +**Leg A — the passport office (IAM Roles Anywhere).** You don't hand the border cash; you prove citizenship. Your **civil registry** is your own private CA, whose seal AWS keeps as a **trust anchor**. The traveller presents a **birth certificate** — a short-lived **X.509 cert** (private key ideally TPM-sealed) — and `aws_signing_helper` → `rolesanywhere:CreateSession` issues a **passport = short-lived AWS identity**. The passport's name page is the cert's **Common Name**. + +**Leg B — the issuing authority (STS `GetWebIdentityToken`).** The old lore — *"AWS won't issue internationally-recognised travel documents, run your own passport shop"* (self-hosted KMS/Lambda OIDC broker) — is **obsolete as of 2025**. AWS STS is now a recognised issuer (**Outbound Identity Federation**): enable it once and your account gets a unique **issuer URL** with a **public seal registry** (`/.well-known/openid-configuration` + `/jwks.json`). A passport-holder calls `sts:GetWebIdentityToken --audience …` and receives a **signed OIDC JWT** whose chip reads *holder = this role, bound for = the tailnet*. The signing key never leaves AWS's HSM. + +**Leg C — immigration & the visa-waiver (Tailscale WIF).** Your tailnet has signed a **visa-waiver treaty** at **trust-credentials**: *"we recognise documents from issuer X; if holder = role Y bound for us, wave them through as class Z."* Tailscale's **token-exchange** officer fetches your JWKS, verifies the seal, matches `sub`/`aud`, and stamps an **entry class = a tag** with a **short-lived tailnet token**. No standing visa was ever filed. + +--- + +## 3. Ways to arrive — the traveller's document is pluggable + +The freeing part: **the immigration desk never changes.** The treaty, the officer, the entry stamp (the **tag**) are identical no matter how the traveller showed up. Only the **document in hand at the issuing counter** differs. Swapping it is swapping a **`CredentialBackend`** — which is exactly what RFC `openabdev/octobroker#54` makes pluggable. + +```mermaid +flowchart TD + S1["S1 · Off-cloud
X.509 → Roles Anywhere"] --> P["Passport office
rolesanywhere:CreateSession"] + P --> ISS["Issuing authority
sts:GetWebIdentityToken"] + S2["S2 · Inside AWS
native role (Fargate/EC2/Lambda/SSO)"] --> ISS + S3["S3 · Any AWS creds in hand
existing access token"] --> ISS + ISS -->|"OIDC JWT (sub=role, aud=tailnet)"| IMM["Immigration desk
Tailscale trust-credentials + token-exchange"] + S4["S4 · No AWS wanted
native Tailscale OAuth / non-AWS OIDC"] --> IMM + IMM -->|"same stamp, every arrival"| TAG["Entry class = tag
short-lived tailnet token"] +``` + +- **S1 · The diplomatic passport (IAM Roles Anywhere).** *House favourite.* Traveller lives **off-cloud** (on-prem NUC, edge box, another cloud). Presents X.509 from your own CA. The only arrival that is keyless **and** works **anywhere** **and** binds to hardware you control — see §5 for how openab wires it. **[Today]** +- **S2 · The local-province passport (AWS-native identity).** Traveller **already inside AWS** — Fargate task role, EC2 instance profile, Lambda role, SSO. Skips the passport office; walks straight to the issuing counter. Fewest moving parts; often the shortest runway for openab's own Fargate crews. **[Today]** +- **S3 · A valid token already in hand (any AWS creds).** Any SigV4-signable principal can request a travel document — assumed-role creds, an SSO token, even a legacy static key you're migrating off. Best as a **transition**. **[Today]** +- **S4 · Arrive straight at immigration (skip AWS).** Tailscale honours its **own** documents — native OAuth client / auth-key — or a visa-waiver with a **non-AWS** government (GitHub Actions, GCP, Azure OIDC). No AWS detour. Trade-off: you give up the **AWS identity as a universal hub**. **[Today]** + +| Traveller lives… | Document | CA? | AWS? | Keyless? | Desk + tag | +|---|---|---|---|---|---| +| Off-cloud | X.509 → Roles Anywhere *(S1 ★)* | yours | yes | ✔ | **identical** | +| Inside AWS | native AWS role *(S2)* | no | yes | ✔ | **identical** | +| Anywhere w/ AWS creds | existing token *(S3)* | no | yes | ✔/△ | **identical** | +| Anywhere, no AWS | Tailscale/non-AWS OIDC *(S4)* | no | no | ✔ | **identical** | + +--- + +## 4. The scoped vault — SSM SecureString + scoped role + +**Question (jonatw):** if SSM Parameter Store SecureString is a secret carrier, does pairing it with a **scoped role** give *finer, more dynamic* issuance? **Doc-confirmed answer, honestly bounded:** + +- **✔ Finer scoping is real.** SSM SecureString is a **KMS-encrypted** carrier. With **path-based IAM** (`Resource: …:parameter/prefix-*`, `ssm:Recursive`) + a **per-principal KMS grant**, each principal decrypts **only its own slice**. Reading needs both `ssm:GetParameter*` **and** `kms:Decrypt` on the right key — two locks, per principal. +- **⚠️ "Dynamic" needs a caveat.** Parameter Store has **no native rotation** (that's Secrets Manager). Its dynamic levers are **parameter policies** — `Expiration`/TTL + EventBridge notifications (Advanced tier) — and **versioning**. So it's a **scoped, TTL-capable, auditable *static carrier*,** not a rotation engine. +- **🔗 How it sharpens *issuance*.** Put the mint-time inputs (audience / Tailscale client-id / tailnet config) behind scoped SSM paths, and *"which token a role may mint"* becomes *"which SSM path it may decrypt."* That's **scoped minting authority + TTL'd config + full audit** — the "更精緻" is genuine. The truly ephemeral part (the JWT, the tailnet token) still comes from STS/Tailscale, which are short-lived by construction. + +**openab already ships the reference implementation — `SsmSecurityStack` (#97):** GitHub App private keys live as **SSM SecureString** at `/openab/github/app//private-key`, each encrypted by a **per-App CMK**; each agent task role is granted `kms:Decrypt` on **only its own** key; and **EventBridge + SNS alert** on any read by a non-agent-role principal (`anything-but` the legit role ARNs). That is the scoped-vault pattern in production — reuse it to gate mint-time inputs. **[Today]** + +> Fits openab's standing preference: **SSM SecureString over Secrets Manager** — cheaper, KMS-isolated, path-scoped, audited; accept "no native rotation" as the trade and let STS/Tailscale supply the short-lived tokens. + +--- + +## 5. How openab wires the house favourite today (real Roles Anywhere setup) + +Scenario 1, as actually deployed in `EagleReadonlyStack` (#174) — the N200 panel-lead's read-only AWS telescope, which **doubles as the tailnet-minting identity**: + +- **Self-managed fleet CA.** Private key is **cold-stored offline** (not in any repo); only the **public CA cert** is registered as a `CERTIFICATE_BUNDLE` **trust anchor**. +- **Leaf cert:** `CN=eagle-n200`, ~90-day validity, on the N200 host. Key never leaves it. +- **Role trust bound to the CN.** `aws:PrincipalTag/x509Subject/CN == eagle-n200` — the same CA signing *another* leaf can't map into this role. Plus `sts:TagSession` + `sts:SetSourceIdentity` for the Roles Anywhere session tags. +- **Permissions:** `ViewOnlyAccess` baseline + named diagnostics (CloudTrail LookupEvents, Logs read, Cost Explorer read, Batch describe). +- **DENY-5 (read-only, no secrets, no pivot):** `ssm:GetParameter*`, `secretsmanager:GetSecretValue`, `kms:Decrypt`, `s3:GetObject`, `sts:AssumeRole*`. +- **The tailnet grant lives on this role:** `sts:GetWebIdentityToken`. **NOTAM:** this API is **outside the `AssumeRole*` family**, so DENY-5's `sts:AssumeRole*` deny does **not** block it — that's deliberate, but it means the permission must be reviewed on its own. +- **Profile:** 1-hour sessions. **Renewal:** a daily countdown cron flags the leaf's expiry. **Revocation:** disable/delete the trust anchor and every leaf dies at once. + +```python +# EagleReadonlyStack (trimmed; account/ARNs redacted for this public copy) +anchor = ra.CfnTrustAnchor(self, "FleetCaAnchor", name="openab-fleet-ca", enabled=True, + source=ra.CfnTrustAnchor.SourceProperty( + source_type="CERTIFICATE_BUNDLE", + source_data=ra.CfnTrustAnchor.SourceDataProperty(x509_certificate_data=FLEET_CA_PEM))) + +cn = {"StringEquals": {"aws:PrincipalTag/x509Subject/CN": "eagle-n200"}} +role = iam.Role(self, "EagleReadonly", role_name="openab-eagle-readonly", + assumed_by=iam.PrincipalWithConditions(iam.ServicePrincipal("rolesanywhere.amazonaws.com"), cn), + managed_policies=[iam.ManagedPolicy.from_aws_managed_policy_name("job-function/ViewOnlyAccess")]) +role.assume_role_policy.add_statements(iam.PolicyStatement( + actions=["sts:TagSession", "sts:SetSourceIdentity"], + principals=[iam.ServicePrincipal("rolesanywhere.amazonaws.com")], conditions=cn)) + +role.add_to_policy(iam.PolicyStatement(sid="TailscaleWIF", + actions=["sts:GetWebIdentityToken"], resources=[f"arn:aws:sts::{self.account}:self"])) # mint only for self + +role.add_to_policy(iam.PolicyStatement(sid="DenySecretsAndPivot", effect=iam.Effect.DENY, + actions=["ssm:GetParameter","ssm:GetParameters","ssm:GetParametersByPath", + "secretsmanager:GetSecretValue","kms:Decrypt","s3:GetObject", + "sts:AssumeRole","sts:AssumeRoleWithSAML","sts:AssumeRoleWithWebIdentity"], + resources=["*"])) + +profile = ra.CfnProfile(self, "EagleProfile", name="openab-eagle-readonly", + enabled=True, duration_seconds=3600, role_arns=[role.role_arn]) +``` + +> **NOTAM (field-verified):** the audience/duration IAM condition keys (`sts:IdentityTokenAudience`, `sts:DurationSeconds`) were **not present in this API's request context** when flown — a condition lock on them fails **closed**. Until AWS ships them, bound the risk with `resource: sts:::self` + a **single-purpose role**. Residual-exposure notes tracked privately, not here. + +--- + +## 6. octobroker — the Ministry of Foreign Affairs + +- **[Today]** octobroker runs the **GitHub-land** border. Traveller shows a **citizen ID** (`X-Octobroker-Key`) — all it carries — the ministry checks a **default-deny treaty-book** (which repos/tools) and issues a visa. Crucially, it does **not** hand over its own credential and it does **not** strip a broad token down: it **mints a fresh ~1-hour GitHub App installation token, already narrowed at mint time** to the **exact repositories** (via the API's `repositories` param) and a **minimal permission envelope** (a git-push credential gets only `contents:write` — no issues, PRs, Actions, or admin). **GitHub itself enforces both boundaries**, and MCP vs. git tokens are cache- and scope-isolated so a broader token can never satisfy a narrower request. The App private key never leaves the ministry; every issuance is **audit-logged**. The visa is stamped for specific cities and activities *before* it's handed over — no traveller holds a GitHub PAT. *(Verified against `openabdev/octobroker` `src/app_token.rs`.)* +- **[Proposed]** RFC `openabdev/octobroker#54` (pluggable provider abstraction, by antigenius0910, 2026-07-24) extracts three traits — **`CredentialBackend`** / **`UpstreamClient`** / **`PolicyClassifier`** — so GitHub becomes *the first* desk, not the only hardcoded one. Explicitly a **pure refactor, no new provider, no config change, byte-identical wire behaviour.** It ships the **hinges**, not a second border. +- **[Vision]** On those hinges, a **Tailnet/AWS desk** opens beside GitHub: a `CredentialBackend` that runs Legs A–C behind the **same** front counter (one citizen ID), **same** treaty-book (default-deny), **same** stamp log (audit). The fleet's foreign policy collapses to its ideal: **one ID, one ministry, every border on waiver.** **Important honesty:** openab has **not** adopted octobroker — its secret surface is broader than GitHub tokens (see the status note up top), so today it runs its **own** scoped vault + Roles Anywhere. This unified-ministry direction — pluggable broker (`#54`) + Roles Anywhere carrying the full secret surface — is an **open design thesis openab is exploring**, not a plan. The protocol in §2 works by hand today; a pluggable ministry is what *could* one day run it for you. **Cost of pluggability (honest):** every new provider is a new trust-critical surface — each `CredentialBackend` must inherit the *same* default-deny + audit invariants and be reviewed as carefully as the GitHub one. A pluggable broker multiplies the places a missed permission check becomes privilege escape; that review burden is the price of the design. + +--- + +## 7. Sequence — one clearance, end to end + +```mermaid +sequenceDiagram + autonumber + participant Ag as openab agent + participant RA as Roles Anywhere + participant STS as AWS STS (issuer + JWKS) + participant TS as Tailscale + Ag->>RA: present X.509 (CN-bound) + RA-->>Ag: short-lived AWS identity (passport) + Ag->>STS: GetWebIdentityToken(audience = tailnet) + STS-->>Ag: signed OIDC JWT (sub = role, aud = tailnet) + Ag->>TS: token-exchange(JWT) + TS->>STS: fetch /.well-known/jwks.json, verify seal + TS-->>Ag: short-lived token, tagged (entry class) + Ag->>TS: join tailnet (no standing auth-key) +``` + +--- + +## 8. Why we fly this way +- **No bribes at the border** — zero long-lived AWS keys, tailnet auth-keys, or GitHub PATs in agent containers. +- **Documents, not secrets** — every credential short-lived, scoped, government-verifiable, auto-expiring. +- **Managed issuing authority** — AWS STS is the passport office *and* the seal registry; no self-run KMS/Lambda broker to babysit. +- **Scoped vault where secrets must persist** — SSM SecureString + per-principal KMS + audit (§4). +- **One ministry, one ID** — GitHub today, the tailnet/AWS desk on the roadmap (`openabdev/octobroker#54`). + +--- + +## 9. Runbook & NOTAMs + +```bash +# One-time per account: turn STS into a recognised issuer +aws iam enable-outbound-web-identity-federation +aws iam get-outbound-web-identity-federation-info +# → IssuerUrl https://.tokens.sts.global.api.aws +# discovery: /.well-known/openid-configuration jwks: /.well-known/jwks.json + +# Traveller: passport → travel document → entry stamp +# (already holding an AWS identity via S1/S2/S3) +export AWS_STS_REGIONAL_ENDPOINTS=regional # GetWebIdentityToken is NOT on the STS global endpoint (else InvalidAction) +aws sts get-web-identity-token \ + --audience "api.tailscale.com/" \ + --query WebIdentityToken --output text > travel.jwt # sub=, aud= + +# Exchange the JWT for a short-lived, tagged tailnet token +curl -s https://api.tailscale.com/api/v2/oauth/token-exchange \ + -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ + -d subject_token="$(cat travel.jwt)" \ + -d subject_token_type=urn:ietf:params:oauth:token-type:jwt + +# — OR, on Tailscale client v1.90.1+, skip the manual exchange and join directly — +tailscale up --client-id="" --id-token="$(cat travel.jwt)" --advertise-tags="tag:openab-agent" +``` + +**Immigration desk (Tailscale trust-credentials):** register a federated identity — `Issuer` = your account IssuerUrl, `Audience` = `api.tailscale.com/`, `Subject` = the exact role ARN (no loose wildcards), assign `Tags` + minimal `Scopes`. + +**NOTAMs:** `GetWebIdentityToken` sits **outside** `AssumeRole*` (a `Deny AssumeRole*` guard misses it — review separately). The issuing counter is **regional** — the STS *global* endpoint returns `InvalidAction`; call a regional endpoint or set `AWS_STS_REGIONAL_ENDPOINTS=regional`. Audience/duration condition keys not yet in request context → scope by single-purpose role + `resource: self`. Pin `Subject` to the precise role ARN; hand out the narrowest tag/scope. + +**Blast radius & residual risk (honest — from adversarial review).** +- **CA private-key compromise is the sharpest risk.** The Roles Anywhere trust anchor is the root of S1: if the fleet CA private key leaks, an attacker signs an X.509 with `CN=eagle-n200` and impersonates the role — **CN-binding does not stop a CA-key holder** (they simply forge the CN). Mitigation in place: the CA key is **cold-stored offline** (never in a repo or on a networked host). Stronger still: an **HSM**. Contain with **short-lived leaves** + immediate **trust-anchor disable/delete** on suspicion (kills every leaf at once). + - **Hardening option (evaluated).** Back the CA with an **AWS KMS asymmetric key** (~$1/month): the signing key lives in a FIPS 140-2 L3 **HSM and is non-exportable** — there is **no key file to steal**, so this risk largely dissolves. Compromise then requires IAM `kms:Sign` access, which is **CloudTrail-audited and IAM-revocable** — a stronger posture than a cold-stored file, and ~400× cheaper than AWS Private CA (~$400/mo). Tools like `step-ca` support a KMS signer directly (an IAM role with `kms:GetPublicKey` + `kms:Sign` on the CA key). **Trade-off (known):** still **no real-time revocation of individual leaves** — but that's a Roles Anywhere limitation regardless (it honours *imported CRLs only*; no OCSP/CDP callbacks), so lean on **short-lived leaves** for passive revocation and kill the whole CA instantly by disabling the KMS key or the trust anchor. +- **Bearer-token replay window.** The JWT and the tailnet token are bearer credentials — an intercepted, un-expired one is usable until it expires. Keep both lifetimes minimal. *Caveat:* the IAM lever to **enforce** a short cap (`sts:DurationSeconds`) is **not yet available** (see NOTAMs); today the cap is procedural (single-purpose role), not policy-enforced. +- **Audience-lock gap.** Until the audience condition key ships, the minting role may request a token for **any** `aud`. Bound it with `resource: sts:::self` + a single-purpose role, and use **one minting role per audience/tailnet** — never share it. Pin Tailscale's `Subject` match to the exact role ARN so a reused ARN can't cross tailnets. +- **Tag scope.** A tailnet **tag is a door** — keep it minimal (`tag:openab-agent-ro`, not `tag:prod-*`); an over-broad tag spreads privilege past the token's intent. +- **Trust assumption (accepted, not mitigated here).** Verification rests on AWS's HTTPS-served JWKS being authentic — the standard OIDC trust root. JWKS-endpoint compromise or key-rotation lag is an AWS-managed risk, called out for honesty, not something this design removes. + +--- + +## 10. Decoder ring (metaphor → component · maturity) + +| At the border | In the system | | +|---|---|---| +| Traveller / flight crew | openab **agent** | Today | +| Ministry of Foreign Affairs (single counter) | **octobroker** | Today (GitHub) | +| Citizen ID card | **`X-Octobroker-Key`** | Today | +| Treaty-book / stamp log | octobroker **default-deny allowlist + audit** | Today | +| GitHub-land visa | scoped, short-lived **GitHub App token** | Today | +| Interchangeable desks | traits `CredentialBackend`/`UpstreamClient`/`PolicyClassifier` | Proposed (`#54`) | +| Tailnet/AWS desk | a workload-identity `CredentialBackend` | Vision | +| Civil registry / recognised seal | private CA as **Roles Anywhere trust anchor** | Today | +| Birth certificate | short-lived **X.509** (`CN=eagle-n200`, TPM-sealable) | Today | +| Passport office → passport | **`aws_signing_helper` → CreateSession** | Today | +| Issuing authority + seal registry | **AWS STS Outbound Identity Federation** (issuer + JWKS) | Today | +| Machine-readable travel document | signed **OIDC JWT** from `sts:GetWebIdentityToken` | Today | +| Visa-waiver treaty | **Tailscale trust-credentials / federated identity** | Today | +| Immigration officer | **Tailscale token-exchange** endpoint | Today | +| Entry class / short-stay permit | the **tag** + short-lived **tailnet token** (TTL) | Today | +| Scoped customs vault | **SSM SecureString + per-principal KMS + audit** (`#97`) | Today | + +--- + +## Sources (official; facts cross-checked) +- AWS IAM Outbound Web Identity Federation — https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_outbound-federation.html +- AWS STS `GetWebIdentityToken` — https://docs.aws.amazon.com/STS/latest/APIReference/API_GetWebIdentityToken.html +- Tailscale Workload Identity Federation — https://tailscale.com/kb/1317/workload-identity-federation +- IAM Roles Anywhere trust model — https://docs.aws.amazon.com/rolesanywhere/latest/userguide/trust-model.html +- octobroker — https://github.com/openabdev/octobroker +- AWS KMS asymmetric keys (non-exportable, HSM-backed signing) — https://aws.amazon.com/kms/faqs/ +- Roles Anywhere revocation (imported CRL only, no OCSP) — https://docs.aws.amazon.com/rolesanywhere/latest/userguide/trust-model.html + +--- +*Decoder ring keeps the story honest: every metaphor is a named, auditable component, tagged Today / Proposed / Vision. Sensitive identifiers (account, client-id, exact ARNs, cert expiry) are placeholdered for this public flight-deck copy; the canonical, un-redacted version lives in gbrain.* From 9616776d1ba8580234f44374423506f35db13339 Mon Sep 17 00:00:00 2001 From: "jonatw-eagle[bot]" <298894443+jonatw-eagle[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:31:48 +0000 Subject: [PATCH 2/6] docs: translate remaining zh string to English Co-Authored-By: Claude Opus 4.8 --- docs/foreign-policy/keyless-clearance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/foreign-policy/keyless-clearance.md b/docs/foreign-policy/keyless-clearance.md index a7f12e7..331ee56 100644 --- a/docs/foreign-policy/keyless-clearance.md +++ b/docs/foreign-policy/keyless-clearance.md @@ -86,7 +86,7 @@ flowchart TD - **✔ Finer scoping is real.** SSM SecureString is a **KMS-encrypted** carrier. With **path-based IAM** (`Resource: …:parameter/prefix-*`, `ssm:Recursive`) + a **per-principal KMS grant**, each principal decrypts **only its own slice**. Reading needs both `ssm:GetParameter*` **and** `kms:Decrypt` on the right key — two locks, per principal. - **⚠️ "Dynamic" needs a caveat.** Parameter Store has **no native rotation** (that's Secrets Manager). Its dynamic levers are **parameter policies** — `Expiration`/TTL + EventBridge notifications (Advanced tier) — and **versioning**. So it's a **scoped, TTL-capable, auditable *static carrier*,** not a rotation engine. -- **🔗 How it sharpens *issuance*.** Put the mint-time inputs (audience / Tailscale client-id / tailnet config) behind scoped SSM paths, and *"which token a role may mint"* becomes *"which SSM path it may decrypt."* That's **scoped minting authority + TTL'd config + full audit** — the "更精緻" is genuine. The truly ephemeral part (the JWT, the tailnet token) still comes from STS/Tailscale, which are short-lived by construction. +- **🔗 How it sharpens *issuance*.** Put the mint-time inputs (audience / Tailscale client-id / tailnet config) behind scoped SSM paths, and *"which token a role may mint"* becomes *"which SSM path it may decrypt."* That's **scoped minting authority + TTL'd config + full audit** — the finer-grained control is genuine. The truly ephemeral part (the JWT, the tailnet token) still comes from STS/Tailscale, which are short-lived by construction. **openab already ships the reference implementation — `SsmSecurityStack` (#97):** GitHub App private keys live as **SSM SecureString** at `/openab/github/app//private-key`, each encrypted by a **per-App CMK**; each agent task role is granted `kms:Decrypt` on **only its own** key; and **EventBridge + SNS alert** on any read by a non-agent-role principal (`anything-but` the legit role ARNs). That is the scoped-vault pattern in production — reuse it to gate mint-time inputs. **[Today]** From c62fdafe2adea4b01d1a0b767a90280cc840b800 Mon Sep 17 00:00:00 2001 From: "jonatw-eagle[bot]" <298894443+jonatw-eagle[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:35:13 +0000 Subject: [PATCH 3/6] docs: correct SSM #97 claim to verified state (stack deployed; drop unverified storage-path assertion) Co-Authored-By: Claude Opus 4.8 --- docs/foreign-policy/keyless-clearance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/foreign-policy/keyless-clearance.md b/docs/foreign-policy/keyless-clearance.md index 331ee56..74c63bc 100644 --- a/docs/foreign-policy/keyless-clearance.md +++ b/docs/foreign-policy/keyless-clearance.md @@ -88,7 +88,7 @@ flowchart TD - **⚠️ "Dynamic" needs a caveat.** Parameter Store has **no native rotation** (that's Secrets Manager). Its dynamic levers are **parameter policies** — `Expiration`/TTL + EventBridge notifications (Advanced tier) — and **versioning**. So it's a **scoped, TTL-capable, auditable *static carrier*,** not a rotation engine. - **🔗 How it sharpens *issuance*.** Put the mint-time inputs (audience / Tailscale client-id / tailnet config) behind scoped SSM paths, and *"which token a role may mint"* becomes *"which SSM path it may decrypt."* That's **scoped minting authority + TTL'd config + full audit** — the finer-grained control is genuine. The truly ephemeral part (the JWT, the tailnet token) still comes from STS/Tailscale, which are short-lived by construction. -**openab already ships the reference implementation — `SsmSecurityStack` (#97):** GitHub App private keys live as **SSM SecureString** at `/openab/github/app//private-key`, each encrypted by a **per-App CMK**; each agent task role is granted `kms:Decrypt` on **only its own** key; and **EventBridge + SNS alert** on any read by a non-agent-role principal (`anything-but` the legit role ARNs). That is the scoped-vault pattern in production — reuse it to gate mint-time inputs. **[Today]** +**openab has deployed a hardened variant — `SsmSecurityStack` (#97):** it provisions **per-App customer-managed KMS keys** (replacing the default SSM key), grants each agent task role `kms:Decrypt` on **only its own** key, and wires **EventBridge + SNS** alerts on any read of the GitHub App-key parameters by a non-agent-role principal (`anything-but` the legit role ARNs). That's the scoped-vault shape — per-secret KMS isolation + least-privilege + audit — reusable to gate mint-time inputs. *(Stack deployed; exact secret storage paths omitted for this public copy.)* **[Today]** > Fits openab's standing preference: **SSM SecureString over Secrets Manager** — cheaper, KMS-isolated, path-scoped, audited; accept "no native rotation" as the trade and let STS/Tailscale supply the short-lived tokens. From a95b04d170ca0728b4d4c4e7f578ab91b3cddeeb Mon Sep 17 00:00:00 2001 From: "jonatw-eagle[bot]" <298894443+jonatw-eagle[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:39:59 +0000 Subject: [PATCH 4/6] docs: mark section 5 as personal current setup (not openab-official); add NOTAM reference link Co-Authored-By: Claude Opus 4.8 --- docs/foreign-policy/keyless-clearance.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/foreign-policy/keyless-clearance.md b/docs/foreign-policy/keyless-clearance.md index 74c63bc..6261d78 100644 --- a/docs/foreign-policy/keyless-clearance.md +++ b/docs/foreign-policy/keyless-clearance.md @@ -13,7 +13,7 @@ 2. The border protocol (Legs A–C): passport → travel document → entry stamp 3. Ways to arrive — the traveller's document is pluggable (4 scenarios + Mermaid) 4. The scoped vault — SSM SecureString + scoped role (does it sharpen issuance?) -5. How openab wires the house favourite today (real Roles Anywhere setup) +5. My current setup — one working Roles Anywhere architecture 6. octobroker — the Ministry of Foreign Affairs (Today / Proposed / Vision) 7. Sequence diagram — one clearance, end to end 8. Why we fly this way @@ -66,7 +66,7 @@ flowchart TD IMM -->|"same stamp, every arrival"| TAG["Entry class = tag
short-lived tailnet token"] ``` -- **S1 · The diplomatic passport (IAM Roles Anywhere).** *House favourite.* Traveller lives **off-cloud** (on-prem NUC, edge box, another cloud). Presents X.509 from your own CA. The only arrival that is keyless **and** works **anywhere** **and** binds to hardware you control — see §5 for how openab wires it. **[Today]** +- **S1 · The diplomatic passport (IAM Roles Anywhere).** *House favourite.* Traveller lives **off-cloud** (on-prem NUC, edge box, another cloud). Presents X.509 from your own CA. The only arrival that is keyless **and** works **anywhere** **and** binds to hardware you control — see §5 for one working setup. **[Today]** - **S2 · The local-province passport (AWS-native identity).** Traveller **already inside AWS** — Fargate task role, EC2 instance profile, Lambda role, SSO. Skips the passport office; walks straight to the issuing counter. Fewest moving parts; often the shortest runway for openab's own Fargate crews. **[Today]** - **S3 · A valid token already in hand (any AWS creds).** Any SigV4-signable principal can request a travel document — assumed-role creds, an SSO token, even a legacy static key you're migrating off. Best as a **transition**. **[Today]** - **S4 · Arrive straight at immigration (skip AWS).** Tailscale honours its **own** documents — native OAuth client / auth-key — or a visa-waiver with a **non-AWS** government (GitHub Actions, GCP, Azure OIDC). No AWS detour. Trade-off: you give up the **AWS identity as a universal hub**. **[Today]** @@ -94,9 +94,9 @@ flowchart TD --- -## 5. How openab wires the house favourite today (real Roles Anywhere setup) +## 5. My current setup — one working Roles Anywhere architecture -Scenario 1, as actually deployed in `EagleReadonlyStack` (#174) — the N200 panel-lead's read-only AWS telescope, which **doubles as the tailnet-minting identity**: +**This is my own current architecture, not an openab-official standard** — treat it as *a* working reference, not *the* prescribed way. Scenario 1 as I run it in `EagleReadonlyStack` (#174): the N200 panel-lead's read-only AWS telescope, which **doubles as the tailnet-minting identity**: - **Self-managed fleet CA.** Private key is **cold-stored offline** (not in any repo); only the **public CA cert** is registered as a `CERTIFICATE_BUNDLE` **trust anchor**. - **Leaf cert:** `CN=eagle-n200`, ~90-day validity, on the N200 host. Key never leaves it. @@ -178,6 +178,8 @@ sequenceDiagram ## 9. Runbook & NOTAMs +*(A **[NOTAM](https://skybrary.aero/articles/notice-airmen-notam)** — Notice to Air Missions — is aviation's pre-flight bulletin of hazards and "sharp edges"; here it flags the operational gotchas before you fly this.)* + ```bash # One-time per account: turn STS into a recognised issuer aws iam enable-outbound-web-identity-federation From 8315449d4f1c3296f0e03c067f70a6beae53fdeb Mon Sep 17 00:00:00 2001 From: "jonatw-eagle[bot]" <298894443+jonatw-eagle[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:42:24 +0000 Subject: [PATCH 5/6] docs: strip private-repo issue numbers (#97/#174); qualify public octobroker#54 refs Co-Authored-By: Claude Opus 4.8 --- docs/foreign-policy/keyless-clearance.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/foreign-policy/keyless-clearance.md b/docs/foreign-policy/keyless-clearance.md index 6261d78..5c8a4a1 100644 --- a/docs/foreign-policy/keyless-clearance.md +++ b/docs/foreign-policy/keyless-clearance.md @@ -88,7 +88,7 @@ flowchart TD - **⚠️ "Dynamic" needs a caveat.** Parameter Store has **no native rotation** (that's Secrets Manager). Its dynamic levers are **parameter policies** — `Expiration`/TTL + EventBridge notifications (Advanced tier) — and **versioning**. So it's a **scoped, TTL-capable, auditable *static carrier*,** not a rotation engine. - **🔗 How it sharpens *issuance*.** Put the mint-time inputs (audience / Tailscale client-id / tailnet config) behind scoped SSM paths, and *"which token a role may mint"* becomes *"which SSM path it may decrypt."* That's **scoped minting authority + TTL'd config + full audit** — the finer-grained control is genuine. The truly ephemeral part (the JWT, the tailnet token) still comes from STS/Tailscale, which are short-lived by construction. -**openab has deployed a hardened variant — `SsmSecurityStack` (#97):** it provisions **per-App customer-managed KMS keys** (replacing the default SSM key), grants each agent task role `kms:Decrypt` on **only its own** key, and wires **EventBridge + SNS** alerts on any read of the GitHub App-key parameters by a non-agent-role principal (`anything-but` the legit role ARNs). That's the scoped-vault shape — per-secret KMS isolation + least-privilege + audit — reusable to gate mint-time inputs. *(Stack deployed; exact secret storage paths omitted for this public copy.)* **[Today]** +**A hardened variant of this is already deployed (my `SsmSecurityStack`):** it provisions **per-App customer-managed KMS keys** (replacing the default SSM key), grants each agent task role `kms:Decrypt` on **only its own** key, and wires **EventBridge + SNS** alerts on any read of the GitHub App-key parameters by a non-agent-role principal (`anything-but` the legit role ARNs). That's the scoped-vault shape — per-secret KMS isolation + least-privilege + audit — reusable to gate mint-time inputs. *(Stack deployed; exact secret storage paths omitted for this public copy.)* **[Today]** > Fits openab's standing preference: **SSM SecureString over Secrets Manager** — cheaper, KMS-isolated, path-scoped, audited; accept "no native rotation" as the trade and let STS/Tailscale supply the short-lived tokens. @@ -96,7 +96,7 @@ flowchart TD ## 5. My current setup — one working Roles Anywhere architecture -**This is my own current architecture, not an openab-official standard** — treat it as *a* working reference, not *the* prescribed way. Scenario 1 as I run it in `EagleReadonlyStack` (#174): the N200 panel-lead's read-only AWS telescope, which **doubles as the tailnet-minting identity**: +**This is my own current architecture, not an openab-official standard** — treat it as *a* working reference, not *the* prescribed way. Scenario 1 as I run it in my `EagleReadonlyStack`: the N200 panel-lead's read-only AWS telescope, which **doubles as the tailnet-minting identity**: - **Self-managed fleet CA.** Private key is **cold-stored offline** (not in any repo); only the **public CA cert** is registered as a `CERTIFICATE_BUNDLE` **trust anchor**. - **Leaf cert:** `CN=eagle-n200`, ~90-day validity, on the N200 host. Key never leaves it. @@ -142,7 +142,7 @@ profile = ra.CfnProfile(self, "EagleProfile", name="openab-eagle-readonly", - **[Today]** octobroker runs the **GitHub-land** border. Traveller shows a **citizen ID** (`X-Octobroker-Key`) — all it carries — the ministry checks a **default-deny treaty-book** (which repos/tools) and issues a visa. Crucially, it does **not** hand over its own credential and it does **not** strip a broad token down: it **mints a fresh ~1-hour GitHub App installation token, already narrowed at mint time** to the **exact repositories** (via the API's `repositories` param) and a **minimal permission envelope** (a git-push credential gets only `contents:write` — no issues, PRs, Actions, or admin). **GitHub itself enforces both boundaries**, and MCP vs. git tokens are cache- and scope-isolated so a broader token can never satisfy a narrower request. The App private key never leaves the ministry; every issuance is **audit-logged**. The visa is stamped for specific cities and activities *before* it's handed over — no traveller holds a GitHub PAT. *(Verified against `openabdev/octobroker` `src/app_token.rs`.)* - **[Proposed]** RFC `openabdev/octobroker#54` (pluggable provider abstraction, by antigenius0910, 2026-07-24) extracts three traits — **`CredentialBackend`** / **`UpstreamClient`** / **`PolicyClassifier`** — so GitHub becomes *the first* desk, not the only hardcoded one. Explicitly a **pure refactor, no new provider, no config change, byte-identical wire behaviour.** It ships the **hinges**, not a second border. -- **[Vision]** On those hinges, a **Tailnet/AWS desk** opens beside GitHub: a `CredentialBackend` that runs Legs A–C behind the **same** front counter (one citizen ID), **same** treaty-book (default-deny), **same** stamp log (audit). The fleet's foreign policy collapses to its ideal: **one ID, one ministry, every border on waiver.** **Important honesty:** openab has **not** adopted octobroker — its secret surface is broader than GitHub tokens (see the status note up top), so today it runs its **own** scoped vault + Roles Anywhere. This unified-ministry direction — pluggable broker (`#54`) + Roles Anywhere carrying the full secret surface — is an **open design thesis openab is exploring**, not a plan. The protocol in §2 works by hand today; a pluggable ministry is what *could* one day run it for you. **Cost of pluggability (honest):** every new provider is a new trust-critical surface — each `CredentialBackend` must inherit the *same* default-deny + audit invariants and be reviewed as carefully as the GitHub one. A pluggable broker multiplies the places a missed permission check becomes privilege escape; that review burden is the price of the design. +- **[Vision]** On those hinges, a **Tailnet/AWS desk** opens beside GitHub: a `CredentialBackend` that runs Legs A–C behind the **same** front counter (one citizen ID), **same** treaty-book (default-deny), **same** stamp log (audit). The fleet's foreign policy collapses to its ideal: **one ID, one ministry, every border on waiver.** **Important honesty:** openab has **not** adopted octobroker — its secret surface is broader than GitHub tokens (see the status note up top), so today it runs its **own** scoped vault + Roles Anywhere. This unified-ministry direction — pluggable broker (`openabdev/octobroker#54`) + Roles Anywhere carrying the full secret surface — is an **open design thesis openab is exploring**, not a plan. The protocol in §2 works by hand today; a pluggable ministry is what *could* one day run it for you. **Cost of pluggability (honest):** every new provider is a new trust-critical surface — each `CredentialBackend` must inherit the *same* default-deny + audit invariants and be reviewed as carefully as the GitHub one. A pluggable broker multiplies the places a missed permission check becomes privilege escape; that review burden is the price of the design. --- @@ -227,7 +227,7 @@ tailscale up --client-id="" --id-token="$(cat travel.jwt)" --advertis | Citizen ID card | **`X-Octobroker-Key`** | Today | | Treaty-book / stamp log | octobroker **default-deny allowlist + audit** | Today | | GitHub-land visa | scoped, short-lived **GitHub App token** | Today | -| Interchangeable desks | traits `CredentialBackend`/`UpstreamClient`/`PolicyClassifier` | Proposed (`#54`) | +| Interchangeable desks | traits `CredentialBackend`/`UpstreamClient`/`PolicyClassifier` | Proposed (`openabdev/octobroker#54`) | | Tailnet/AWS desk | a workload-identity `CredentialBackend` | Vision | | Civil registry / recognised seal | private CA as **Roles Anywhere trust anchor** | Today | | Birth certificate | short-lived **X.509** (`CN=eagle-n200`, TPM-sealable) | Today | @@ -237,7 +237,7 @@ tailscale up --client-id="" --id-token="$(cat travel.jwt)" --advertis | Visa-waiver treaty | **Tailscale trust-credentials / federated identity** | Today | | Immigration officer | **Tailscale token-exchange** endpoint | Today | | Entry class / short-stay permit | the **tag** + short-lived **tailnet token** (TTL) | Today | -| Scoped customs vault | **SSM SecureString + per-principal KMS + audit** (`#97`) | Today | +| Scoped customs vault | **SSM SecureString + per-principal KMS + audit** | Today | --- From 0ec85c018e7c16812f97aabd881f003eed42628c Mon Sep 17 00:00:00 2001 From: "jonatw-eagle[bot]" <298894443+jonatw-eagle[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:49:15 +0000 Subject: [PATCH 6/6] docs: redact host/stack/role names (clear HARD redaction hit; placeholders) Co-Authored-By: Claude Opus 4.8 --- docs/foreign-policy/keyless-clearance.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/foreign-policy/keyless-clearance.md b/docs/foreign-policy/keyless-clearance.md index 5c8a4a1..fb8a6d1 100644 --- a/docs/foreign-policy/keyless-clearance.md +++ b/docs/foreign-policy/keyless-clearance.md @@ -88,7 +88,7 @@ flowchart TD - **⚠️ "Dynamic" needs a caveat.** Parameter Store has **no native rotation** (that's Secrets Manager). Its dynamic levers are **parameter policies** — `Expiration`/TTL + EventBridge notifications (Advanced tier) — and **versioning**. So it's a **scoped, TTL-capable, auditable *static carrier*,** not a rotation engine. - **🔗 How it sharpens *issuance*.** Put the mint-time inputs (audience / Tailscale client-id / tailnet config) behind scoped SSM paths, and *"which token a role may mint"* becomes *"which SSM path it may decrypt."* That's **scoped minting authority + TTL'd config + full audit** — the finer-grained control is genuine. The truly ephemeral part (the JWT, the tailnet token) still comes from STS/Tailscale, which are short-lived by construction. -**A hardened variant of this is already deployed (my `SsmSecurityStack`):** it provisions **per-App customer-managed KMS keys** (replacing the default SSM key), grants each agent task role `kms:Decrypt` on **only its own** key, and wires **EventBridge + SNS** alerts on any read of the GitHub App-key parameters by a non-agent-role principal (`anything-but` the legit role ARNs). That's the scoped-vault shape — per-secret KMS isolation + least-privilege + audit — reusable to gate mint-time inputs. *(Stack deployed; exact secret storage paths omitted for this public copy.)* **[Today]** +**A hardened variant of this is already deployed (my `SsmHardeningStack`):** it provisions **per-App customer-managed KMS keys** (replacing the default SSM key), grants each agent task role `kms:Decrypt` on **only its own** key, and wires **EventBridge + SNS** alerts on any read of the GitHub App-key parameters by a non-agent-role principal (`anything-but` the legit role ARNs). That's the scoped-vault shape — per-secret KMS isolation + least-privilege + audit — reusable to gate mint-time inputs. *(Stack deployed; exact secret storage paths omitted for this public copy.)* **[Today]** > Fits openab's standing preference: **SSM SecureString over Secrets Manager** — cheaper, KMS-isolated, path-scoped, audited; accept "no native rotation" as the trade and let STS/Tailscale supply the short-lived tokens. @@ -96,25 +96,25 @@ flowchart TD ## 5. My current setup — one working Roles Anywhere architecture -**This is my own current architecture, not an openab-official standard** — treat it as *a* working reference, not *the* prescribed way. Scenario 1 as I run it in my `EagleReadonlyStack`: the N200 panel-lead's read-only AWS telescope, which **doubles as the tailnet-minting identity**: +**This is my own current architecture, not an openab-official standard** — treat it as *a* working reference, not *the* prescribed way. Scenario 1 as I run it in my `RaTelescopeStack`: my always-on lead host's read-only AWS telescope, which **doubles as the tailnet-minting identity**: - **Self-managed fleet CA.** Private key is **cold-stored offline** (not in any repo); only the **public CA cert** is registered as a `CERTIFICATE_BUNDLE` **trust anchor**. -- **Leaf cert:** `CN=eagle-n200`, ~90-day validity, on the N200 host. Key never leaves it. -- **Role trust bound to the CN.** `aws:PrincipalTag/x509Subject/CN == eagle-n200` — the same CA signing *another* leaf can't map into this role. Plus `sts:TagSession` + `sts:SetSourceIdentity` for the Roles Anywhere session tags. +- **Leaf cert:** `CN=host-01`, ~90-day validity, on that host. Key never leaves it. +- **Role trust bound to the CN.** `aws:PrincipalTag/x509Subject/CN == host-01` — the same CA signing *another* leaf can't map into this role. Plus `sts:TagSession` + `sts:SetSourceIdentity` for the Roles Anywhere session tags. - **Permissions:** `ViewOnlyAccess` baseline + named diagnostics (CloudTrail LookupEvents, Logs read, Cost Explorer read, Batch describe). - **DENY-5 (read-only, no secrets, no pivot):** `ssm:GetParameter*`, `secretsmanager:GetSecretValue`, `kms:Decrypt`, `s3:GetObject`, `sts:AssumeRole*`. - **The tailnet grant lives on this role:** `sts:GetWebIdentityToken`. **NOTAM:** this API is **outside the `AssumeRole*` family**, so DENY-5's `sts:AssumeRole*` deny does **not** block it — that's deliberate, but it means the permission must be reviewed on its own. - **Profile:** 1-hour sessions. **Renewal:** a daily countdown cron flags the leaf's expiry. **Revocation:** disable/delete the trust anchor and every leaf dies at once. ```python -# EagleReadonlyStack (trimmed; account/ARNs redacted for this public copy) -anchor = ra.CfnTrustAnchor(self, "FleetCaAnchor", name="openab-fleet-ca", enabled=True, +# RaTelescopeStack (trimmed; account/ARNs redacted for this public copy) +anchor = ra.CfnTrustAnchor(self, "FleetCaAnchor", name="fleet-ca", enabled=True, source=ra.CfnTrustAnchor.SourceProperty( source_type="CERTIFICATE_BUNDLE", source_data=ra.CfnTrustAnchor.SourceDataProperty(x509_certificate_data=FLEET_CA_PEM))) -cn = {"StringEquals": {"aws:PrincipalTag/x509Subject/CN": "eagle-n200"}} -role = iam.Role(self, "EagleReadonly", role_name="openab-eagle-readonly", +cn = {"StringEquals": {"aws:PrincipalTag/x509Subject/CN": "host-01"}} +role = iam.Role(self, "RaTelescope", role_name="ra-telescope-ro", assumed_by=iam.PrincipalWithConditions(iam.ServicePrincipal("rolesanywhere.amazonaws.com"), cn), managed_policies=[iam.ManagedPolicy.from_aws_managed_policy_name("job-function/ViewOnlyAccess")]) role.assume_role_policy.add_statements(iam.PolicyStatement( @@ -130,7 +130,7 @@ role.add_to_policy(iam.PolicyStatement(sid="DenySecretsAndPivot", effect=iam.Eff "sts:AssumeRole","sts:AssumeRoleWithSAML","sts:AssumeRoleWithWebIdentity"], resources=["*"])) -profile = ra.CfnProfile(self, "EagleProfile", name="openab-eagle-readonly", +profile = ra.CfnProfile(self, "RaProfile", name="ra-telescope-ro", enabled=True, duration_seconds=3600, role_arns=[role.role_arn]) ``` @@ -209,7 +209,7 @@ tailscale up --client-id="" --id-token="$(cat travel.jwt)" --advertis **NOTAMs:** `GetWebIdentityToken` sits **outside** `AssumeRole*` (a `Deny AssumeRole*` guard misses it — review separately). The issuing counter is **regional** — the STS *global* endpoint returns `InvalidAction`; call a regional endpoint or set `AWS_STS_REGIONAL_ENDPOINTS=regional`. Audience/duration condition keys not yet in request context → scope by single-purpose role + `resource: self`. Pin `Subject` to the precise role ARN; hand out the narrowest tag/scope. **Blast radius & residual risk (honest — from adversarial review).** -- **CA private-key compromise is the sharpest risk.** The Roles Anywhere trust anchor is the root of S1: if the fleet CA private key leaks, an attacker signs an X.509 with `CN=eagle-n200` and impersonates the role — **CN-binding does not stop a CA-key holder** (they simply forge the CN). Mitigation in place: the CA key is **cold-stored offline** (never in a repo or on a networked host). Stronger still: an **HSM**. Contain with **short-lived leaves** + immediate **trust-anchor disable/delete** on suspicion (kills every leaf at once). +- **CA private-key compromise is the sharpest risk.** The Roles Anywhere trust anchor is the root of S1: if the fleet CA private key leaks, an attacker signs an X.509 with `CN=host-01` and impersonates the role — **CN-binding does not stop a CA-key holder** (they simply forge the CN). Mitigation in place: the CA key is **cold-stored offline** (never in a repo or on a networked host). Stronger still: an **HSM**. Contain with **short-lived leaves** + immediate **trust-anchor disable/delete** on suspicion (kills every leaf at once). - **Hardening option (evaluated).** Back the CA with an **AWS KMS asymmetric key** (~$1/month): the signing key lives in a FIPS 140-2 L3 **HSM and is non-exportable** — there is **no key file to steal**, so this risk largely dissolves. Compromise then requires IAM `kms:Sign` access, which is **CloudTrail-audited and IAM-revocable** — a stronger posture than a cold-stored file, and ~400× cheaper than AWS Private CA (~$400/mo). Tools like `step-ca` support a KMS signer directly (an IAM role with `kms:GetPublicKey` + `kms:Sign` on the CA key). **Trade-off (known):** still **no real-time revocation of individual leaves** — but that's a Roles Anywhere limitation regardless (it honours *imported CRLs only*; no OCSP/CDP callbacks), so lean on **short-lived leaves** for passive revocation and kill the whole CA instantly by disabling the KMS key or the trust anchor. - **Bearer-token replay window.** The JWT and the tailnet token are bearer credentials — an intercepted, un-expired one is usable until it expires. Keep both lifetimes minimal. *Caveat:* the IAM lever to **enforce** a short cap (`sts:DurationSeconds`) is **not yet available** (see NOTAMs); today the cap is procedural (single-purpose role), not policy-enforced. - **Audience-lock gap.** Until the audience condition key ships, the minting role may request a token for **any** `aud`. Bound it with `resource: sts:::self` + a single-purpose role, and use **one minting role per audience/tailnet** — never share it. Pin Tailscale's `Subject` match to the exact role ARN so a reused ARN can't cross tailnets. @@ -230,7 +230,7 @@ tailscale up --client-id="" --id-token="$(cat travel.jwt)" --advertis | Interchangeable desks | traits `CredentialBackend`/`UpstreamClient`/`PolicyClassifier` | Proposed (`openabdev/octobroker#54`) | | Tailnet/AWS desk | a workload-identity `CredentialBackend` | Vision | | Civil registry / recognised seal | private CA as **Roles Anywhere trust anchor** | Today | -| Birth certificate | short-lived **X.509** (`CN=eagle-n200`, TPM-sealable) | Today | +| Birth certificate | short-lived **X.509** (`CN=host-01`, TPM-sealable) | Today | | Passport office → passport | **`aws_signing_helper` → CreateSession** | Today | | Issuing authority + seal registry | **AWS STS Outbound Identity Federation** (issuer + JWKS) | Today | | Machine-readable travel document | signed **OIDC JWT** from `sts:GetWebIdentityToken` | Today |