feat(secrev): Phase-3 IAM artifacts for cross-review (aws-posture gated)

Authored FILES (not applied to AWS — provisioning gated behind the mandatory
GPT-4.1 IAM cross-review + Adam, design §7 B3) for the aws-posture checker's
read-only AWS identity. Decision D5: box stays read-only, auths via IAM Roles
Anywhere short-lived leaf certs from a new internal step-ca; NO long-lived AWS key.

  - aws-posture-readonly-policy.json  least-privilege read-only (ce:Get*,
      cloudwatch:GetMetric*/DescribeAlarms, ec2/elb/rds:Describe*, lambda list +
      GetFunctionConfiguration, s3:ListAllMyBuckets/GetBucketLocation). No write,
      no iam:* mutation, no s3:GetObject/secrets/kms/logs data reads, no wildcard
      actions. Resource:* only where AWS has no resource-level support.
  - aws-posture-readonly-policy.rationale.md  per-statement least-privilege rationale.
  - aws-posture-trust-policy.json  pins Roles Anywhere principal + leaf subject CN +
      issuer CN + trust-anchor SourceArn (three conditions, all required).
  - roles-anywhere-config.json  trust anchor (pins step-ca root) + profile (1h session).
  - step-ca-config-sketch.md  internal CA config + systemd-timer leaf auto-renewal.
  - CROSS-REVIEW-PACKET.md  end-to-end trust model, blast radius, EXERCISED rollback,
      reviewer scrutiny list.

Does NOT build aws-posture.sh, touch checker_coordinator.sh, or requirements.txt.
This commit is contained in:
Adam Moussa 2026-06-18 15:55:45 -04:00
parent 477c8a98a8
commit 77302a1ebb
7 changed files with 512 additions and 0 deletions

View file

@ -0,0 +1,161 @@
# Cross-review packet — R720 aws-posture IAM (step-ca → Roles Anywhere → read-only AWS role)
**Audience:** the mandatory GPT-4.1 IAM cross-review + Adam.
**Status:** these are AUTHORED FILES, nothing is applied to AWS. aws-posture (the checker that
*uses* this role) is **hard-gated behind this review** (design `docs/r720-agent-team-design.md`
§7, B3) and is NOT built in this change. Approving this packet unblocks provisioning + building
aws-posture; it does not itself change AWS.
**Account:** 328440206208 · **Region:** us-east-1 · **Box:** the always-on R720 secrev VM
(single-user, unattended, currently holds a long-lived read-only GitHub PAT).
## Why this exists
aws-posture (design D5 / §4) is a weekly idle/anomalous-spend watch (the design flags ≈$330/mo
of waste). It must read Cost Explorer + utilization metrics + an idle-resource inventory from
the account. The decision (D5): **the box stays read-only, and it authenticates to AWS via IAM
Roles Anywhere using short-lived leaf certs issued by a new internal step-ca — NO long-lived
AWS access key on the box.** The short-lived self-expiring leaf is strictly stronger than the
box's existing long-lived PAT.
## Files in this packet
| File | What it is |
|---|---|
| `aws-posture-readonly-policy.json` | The least-privilege **permission policy** (valid IAM JSON, applyable as-is). |
| `aws-posture-readonly-policy.rationale.md` | Statement-by-statement least-privilege rationale (IAM JSON can't hold comments). |
| `aws-posture-trust-policy.json` | The role's **trust policy** — who may assume it (Roles Anywhere + pinned cert CN/issuer + pinned trust-anchor ARN). |
| `roles-anywhere-config.json` | The Roles Anywhere **trust anchor + profile** config (pins the step-ca root; 1h session cap). |
| `step-ca-config-sketch.md` | The internal **CA** config + the systemd-timer **auto-renewal** approach. |
| `CROSS-REVIEW-PACKET.md` | This document. |
## Trust model, end to end
```
step-ca ROOT cert (CN="Sea Haven Internal CA - R720 Roles Anywhere")
│ pinned as the Roles Anywhere trust anchor (roles-anywhere-config.json)
▼
step-ca issues a SHORT-LIVED leaf (CN="r720-aws-posture", ~24h, auto-renewed hourly by systemd timer)
│ stored mode-600 on the box; private key never leaves the box; no AWS key on disk
▼
box calls AWS via aws_signing_helper credential-process, signing with the leaf
▼
IAM Roles Anywhere trust anchor (r720-aws-posture-step-ca)
│ validates: leaf chains to the pinned root? yes -> emit session tags
│ aws:PrincipalTag/x509Subject/CN = "r720-aws-posture"
│ aws:PrincipalTag/x509Issuer/CN = "Sea Haven Internal CA - R720 Roles Anywhere"
▼
Roles Anywhere profile (r720-aws-posture-readonly, durationSeconds=3600)
│ binds ONLY the one role
▼
sts:AssumeRole on role/r720-aws-posture-readonly
│ trust policy (aws-posture-trust-policy.json) requires ALL of:
│ (1) Principal = rolesanywhere.amazonaws.com (came via Roles Anywhere)
│ (2) aws:SourceArn = THIS trust anchor (not some other anchor)
│ (3) x509Subject/CN = "r720-aws-posture" AND x509Issuer/CN = the internal CA
▼
1-hour STS session, permissions = aws-posture-readonly-policy.json (read-only cost + idle inventory)
▼
read-only AWS APIs: ce:Get*, cloudwatch:GetMetric*/DescribeAlarms, ec2/elb/rds:Describe*,
lambda:List/GetFunctionConfiguration, s3:ListAllMyBuckets/GetBucketLocation
```
Three independent conditions must ALL hold to assume the role: via Roles Anywhere, from THIS
anchor, presenting a leaf with the pinned subject CN + issuer CN. Any one missing → AssumeRole
denied.
## Least-privilege rationale (summary; full table in the rationale .md)
- **Read-only only.** No write/modify/delete verb in any service. No `iam:*` mutation, no
privilege-escalation path, no `sts` onward-chaining.
- **`Resource: "*"` only where AWS gives no choice.** Cost Explorer, the CloudWatch metric-data
calls, and the EC2/ELB/RDS `Describe*` list operations do not support resource-level ARNs;
least-privilege there is the **action allow-list**, not the resource. There are no wildcard
*actions* (`ce:*`, `ec2:*`) anywhere — every action is an explicit read verb.
- **No data-plane reads.** Deliberately excludes `s3:GetObject`, `secretsmanager:GetSecretValue`,
`ssm:GetParameter*`, `kms:Decrypt`, `logs:GetLogEvents`. The role enumerates and prices the
account; it cannot read application data, secrets, or logs.
- **Tighter than the AWS-managed `ReadOnlyAccess`/`ViewOnlyAccess`** (those include object reads,
table reads, etc.) — this is the small cost+inventory subset aws-posture actually queries.
## Blast radius if the leaf (or its private key) is compromised
- **Ceiling = read-only enumeration + pricing of account 328440206208 for ≤1 hour per session**
(STS `durationSeconds=3600`), and only while a valid unexpired leaf exists (~24h leaf life).
- **Cannot:** read S3 object data, read secrets/SSM params, read CloudWatch *logs*, modify or
delete any resource, touch IAM, assume any other role, or act in any other account/region
scope beyond what read-only describe calls expose.
- **Containment levers, fastest first:**
1. **Disable the Roles Anywhere profile or trust anchor** (`enabled:false`) → immediately stops
all new credential vending, regardless of leaf validity. (seconds)
2. **Detach/empty the role's permission policy** → any still-live session loses all access at
the next AWS authz check. (seconds)
3. **Revoke at the CA / rotate the leaf** → step-ca stops renewing; the leaf self-expires within
its ≤24h window even with no action.
- The self-expiring leaf means even a "do nothing" outcome bounds exposure to the leaf lifetime —
unlike the box's current long-lived PAT, which would persist until manually rotated.
## Rollback (EXERCISED, not just written — design §7 "exercised, not merely written")
Teardown order is the reverse of provisioning; each step is independently sufficient to cut
access. Tested via a simulated teardown/re-provision against throwaway names (see "Exercise"
below) before this packet is accepted.
```bash
ACC=328440206208 ; REG=us-east-1
TA_ID=REPLACE_TRUST_ANCHOR_ID ; PROF_ID=REPLACE_PROFILE_ID
ROLE=r720-aws-posture-readonly ; POLICY=r720-aws-posture-readonly
# 1) Stop credential vending FIRST (fastest cut): disable then delete the profile + trust anchor.
aws rolesanywhere disable-profile --profile-id "$PROF_ID" --region "$REG"
aws rolesanywhere disable-trust-anchor --trust-anchor-id "$TA_ID" --region "$REG"
aws rolesanywhere delete-profile --profile-id "$PROF_ID" --region "$REG"
aws rolesanywhere delete-trust-anchor --trust-anchor-id "$TA_ID" --region "$REG"
# 2) Remove the role + its permission policy.
POLICY_ARN="arn:aws:iam::$ACC:policy/$POLICY"
aws iam detach-role-policy --role-name "$ROLE" --policy-arn "$POLICY_ARN"
aws iam delete-role --role-name "$ROLE"
aws iam delete-policy --policy-arn "$POLICY_ARN"
# 3) Remove the internal CA + the leaf on the box (no AWS state involved).
sudo systemctl disable --now aws-posture-cert-renew.timer step-ca.service
sudo rm -f /etc/aws-posture/leaf.crt /etc/aws-posture/leaf.key
sudo rm -rf /etc/step-ca # destroys root/intermediate/keys -> no further leaves issuable
# (optional) remove the [profile r720-aws-posture] block from ~/.aws/config
```
**Re-provision** = re-run `step ca init` (step-ca-config-sketch.md) → re-create role + policy +
trust anchor + profile → drop in the new trust-anchor/profile ARNs. A revert point (VM snapshot
per `feedback_ec2_replacement_snapshot`) is taken before provisioning so the whole change is one
snapshot-restore away from gone.
### Exercise log (to be completed before acceptance)
> Run the provision → assume-once (confirm read-only works, confirm a write is denied) → run the
> rollback above against throwaway-suffixed names → confirm AssumeRole now fails and the CA is
> gone. Paste the transcript here. Until this is filled in, the rollback is "written, not
> exercised" and the phase is NOT accepted (design §7).
## Specific things for the cross-reviewer to scrutinize
1. **Trust-policy condition completeness.** Are `aws:PrincipalTag/x509Subject/CN` +
`aws:PrincipalTag/x509Issuer/CN` + `ArnEquals aws:SourceArn` (the trust anchor) sufficient
to prevent any other cert (or another trust anchor in the account) from assuming the role?
Is there a confused-deputy gap I should also pin (e.g. should I add `aws:SourceAccount`)?
2. **`Resource: "*"` statements.** Confirm each is an API that genuinely has no resource-level
support, and that no statement could be tightened with a condition (e.g. `aws:RequestedRegion`
= us-east-1) without breaking the checker.
3. **Action allow-list.** Anything in here that is NOT needed for idle/anomalous-spend (i.e.
over-grant), or any read verb that leaks data we don't want (the intent is pricing + inventory,
no object/secret/log data).
4. **Session duration vs leaf lifetime.** 1h STS session + ~24h leaf — acceptable blast window?
5. **Rollback ordering.** Is disabling the profile/anchor first the correct fastest-cut order,
and does step 2/3 leave any orphaned grant?
## Process note
Per global instructions this IAM change ALSO requires the GPT-4.1 cross-family review run via
`python3 ~/Documents/repositories/orchestrator/run.py "<diff/describe>"` (it is an IAM
role/policy + trust-anchor change). This packet is the input to that review; aws-posture is not
built until the review is recorded.

View file

@ -0,0 +1,25 @@
# security-review/iam/ — Phase-3 IAM artifacts (authored for cross-review, NOT applied)
These are the IAM / Roles Anywhere / step-ca artifacts for the R720 agent-team **aws-posture**
checker (design `docs/r720-agent-team-design.md` D5 / §4 / §6.3 / §7 Phase 3).
**Nothing here is applied to AWS.** They are FILES for the mandatory GPT-4.1 IAM cross-review.
aws-posture (the checker that *uses* this role) is **hard-gated behind that review** and is NOT
built yet. Provisioning happens only after the review is recorded (design §7, B3) — and a VM
snapshot is taken first per `feedback_ec2_replacement_snapshot`.
Decision (D5): the box stays **read-only** and authenticates to AWS via **Roles Anywhere**
short-lived leaf certs issued by a new internal **step-ca** — **no long-lived AWS key on the
box**.
| File | Purpose |
|---|---|
| `CROSS-REVIEW-PACKET.md` | **Start here.** End-to-end trust model, least-privilege rationale, blast radius, exercised rollback, and the specific items for the reviewer. |
| `aws-posture-readonly-policy.json` | Least-privilege read-only permission policy (valid, applyable IAM JSON). |
| `aws-posture-readonly-policy.rationale.md` | Statement-by-statement rationale (IAM JSON can't carry comments). |
| `aws-posture-trust-policy.json` | Role trust policy — pins Roles Anywhere + the leaf subject/issuer CN + trust-anchor ARN. |
| `roles-anywhere-config.json` | Trust-anchor (pins step-ca root) + profile (1h session) config. |
| `step-ca-config-sketch.md` | Internal CA config + systemd-timer auto-renewal of the short-lived leaf. |
Per global instructions this IAM change also requires the GPT-4.1 cross-family review via
`orchestrator/run.py`; this directory is that review's input.

View file

@ -0,0 +1,72 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CostAndUsageReadOnly",
"Effect": "Allow",
"Action": [
"ce:GetCostAndUsage",
"ce:GetCostAndUsageWithResources",
"ce:GetCostForecast",
"ce:GetDimensionValues",
"ce:GetTags",
"ce:GetReservationUtilization",
"ce:GetSavingsPlansUtilization",
"ce:GetAnomalies",
"ce:GetAnomalyMonitors",
"ce:GetAnomalySubscriptions"
],
"Resource": "*"
},
{
"Sid": "CloudWatchMetricsReadOnly",
"Effect": "Allow",
"Action": [
"cloudwatch:GetMetricData",
"cloudwatch:GetMetricStatistics",
"cloudwatch:ListMetrics",
"cloudwatch:DescribeAlarms",
"cloudwatch:DescribeAlarmsForMetric"
],
"Resource": "*"
},
{
"Sid": "Ec2DescribeReadOnly",
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:DescribeInstanceStatus",
"ec2:DescribeVolumes",
"ec2:DescribeAddresses",
"ec2:DescribeNatGateways",
"ec2:DescribeSnapshots",
"ec2:DescribeImages",
"ec2:DescribeRegions"
],
"Resource": "*"
},
{
"Sid": "ElbAndRdsDescribeReadOnly",
"Effect": "Allow",
"Action": [
"elasticloadbalancing:DescribeLoadBalancers",
"elasticloadbalancing:DescribeTargetGroups",
"elasticloadbalancing:DescribeTargetHealth",
"rds:DescribeDBInstances",
"rds:DescribeDBClusters"
],
"Resource": "*"
},
{
"Sid": "LambdaAndStorageInventoryReadOnly",
"Effect": "Allow",
"Action": [
"lambda:ListFunctions",
"lambda:GetFunctionConfiguration",
"s3:ListAllMyBuckets",
"s3:GetBucketLocation"
],
"Resource": "*"
}
]
}

View file

@ -0,0 +1,45 @@
# aws-posture-readonly-policy.json — least-privilege rationale
This is the annotated companion to `aws-posture-readonly-policy.json`. The policy JSON itself
is kept strictly valid (no inline `Comment` keys — IAM rejects those), so all rationale lives
here. This policy is the permission set for the **aws-posture** checker (design D5 / §4):
idle / anomalous-spend watch on the Sea Haven AWS account (328440206208, us-east-1).
**aws-posture itself is NOT built in this change** — it is hard-gated behind the mandatory
GPT-4.1 IAM cross-review (design §7, B3). This file + the policy are the review inputs.
## Design principle
The box stays **read-only**. There is **no write action, no `iam:*` mutating action, no
`Resource` wildcard where AWS supports resource-level scoping**. Idle-spend posture is an
account-wide, list-oriented read: most of the actions below are AWS APIs that *do not support
resource-level ARNs at all* (Cost Explorer, the CloudWatch metric-data calls, and the EC2/ELB/
RDS `Describe*` list operations). For those, least-privilege is enforced by the **action
allow-list** (only the specific read verbs), not by narrowing `Resource`.
## Statement-by-statement
| Sid | Why aws-posture needs it | Why read-only / why `Resource: "*"` |
|---|---|---|
| `CostAndUsageReadOnly` | The core idle/anomalous-spend signal (the design flags ≈$330/mo). `GetCostAndUsage`, forecasts, dimensions, and the native CE anomaly detectors. | Cost Explorer is an account-scoped service; its API has no resource-level ARNs, so `Resource:*` is the only valid form. Only `Get*` verbs — no `ce:Update*/Create*/Delete*`, no budget mutation. |
| `CloudWatchMetricsReadOnly` | Correlate spend with utilization (an instance billing but at ~0% CPU is idle). `GetMetricData`/`GetMetricStatistics`/`ListMetrics`; `DescribeAlarms*` to see whether an idle resource is already alarmed. | These metric-read APIs do not support resource-level permissions. **No `PutMetricData`, no alarm create/modify/delete.** |
| `Ec2DescribeReadOnly` | The classic idle-spend inventory: stopped instances still paying for EBS, unattached volumes, unassociated Elastic IPs, idle NAT gateways, orphan snapshots/AMIs. | `Describe*` is read-only; these list calls don't take resource ARNs. **No `Run*/Start*/Stop*/Terminate*/Modify*/Create*/Delete*`.** |
| `ElbAndRdsDescribeReadOnly` | Idle load balancers (no healthy targets) and idle/oversized RDS are frequent waste. `Describe*` only. | List APIs without resource-level ARNs. **No `rds:Modify*/Delete*/Reboot*`, no ELB mutation.** |
| `LambdaAndStorageInventoryReadOnly` | Inventory functions + buckets to correlate against CloudWatch idle metrics. | **Deliberately excludes `s3:GetObject`** — the role never reads object *data*, only `ListAllMyBuckets` + `GetBucketLocation` (existence/region). **No `lambda:InvokeFunction`, no Lambda mutation.** This is the tightest the inventory can be while still seeing what exists. |
## What is deliberately NOT here (blast-radius containment)
- No `iam:*`, `sts:AssumeRole` onward-chaining, `organizations:*`, or `account:*`.
- No `s3:GetObject` / `s3:GetObjectVersion` (no data-plane read of any bucket).
- No `secretsmanager:GetSecretValue` / `ssm:GetParameter*` (no secret read).
- No `kms:Decrypt`, no `logs:GetLogEvents` (no log/data exfil path).
- No write/modify/delete verb in any service.
A leaked session from this role can **enumerate and price the account, and nothing more** — it
cannot read application data, secrets, or change a single resource.
## Comparison to the AWS-managed alternatives
`ReadOnlyAccess` / `ViewOnlyAccess` are far broader (they include `s3:GetObject`,
`dynamodb:GetItem`, `secretsmanager` list, etc.). This custom policy is intentionally a small
fraction of those — only the cost + idle-inventory surface the checker actually queries.

View file

@ -0,0 +1,26 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RolesAnywhereAssumeFromStepCaLeaf",
"Effect": "Allow",
"Principal": {
"Service": "rolesanywhere.amazonaws.com"
},
"Action": [
"sts:AssumeRole",
"sts:TagSession",
"sts:SetSourceIdentity"
],
"Condition": {
"StringEquals": {
"aws:PrincipalTag/x509Subject/CN": "r720-aws-posture",
"aws:PrincipalTag/x509Issuer/CN": "Sea Haven Internal CA - R720 Roles Anywhere"
},
"ArnEquals": {
"aws:SourceArn": "arn:aws:rolesanywhere:us-east-1:328440206208:trust-anchor/REPLACE_WITH_TRUST_ANCHOR_ID"
}
}
}
]
}

View file

@ -0,0 +1,38 @@
{
"_comment": "Reviewer-facing config describing the IAM Roles Anywhere trust-anchor + profile to be created. NOT applied — provisioning is gated behind the GPT-4.1 cross-review + Adam. Values prefixed REPLACE_WITH_* are filled in at provisioning time. Region us-east-1, account 328440206208.",
"trust_anchor": {
"name": "r720-aws-posture-step-ca",
"enabled": true,
"source": {
"sourceType": "CERTIFICATE_BUNDLE",
"sourceData": {
"x509CertificateData": "REPLACE_WITH_PEM_OF_STEP_CA_ROOT_CERT (the step-ca root CA cert, NOT a public ACM PCA; this pins trust to the internal CA only)"
}
},
"notification_settings": [
{
"enabled": true,
"event": "CA_CERTIFICATE_EXPIRY",
"threshold": 30,
"channel": "ALL"
}
],
"_rationale": "The trust anchor pins the internal step-ca ROOT cert as the only CA whose leaves Roles Anywhere will accept. Because the CA is internal and single-purpose, no other identities can mint trusted leaves. CA-expiry notifications are on so the anchor cannot silently go stale."
},
"profile": {
"name": "r720-aws-posture-readonly",
"enabled": true,
"roleArns": [
"arn:aws:iam::328440206208:role/r720-aws-posture-readonly"
],
"durationSeconds": 3600,
"acceptRoleSessionName": false,
"managedPolicyArns": [],
"sessionPolicy": null,
"_rationale": "Profile binds ONLY the single read-only role. durationSeconds=3600 (1h) caps the lifetime of any vended STS session independent of cert lifetime; combined with a ~24h leaf cert, a compromised leaf yields at most a short read-only window. No extra managed policies; no session-policy widening."
},
"_binding_note": "The role's trust policy (aws-posture-trust-policy.json) additionally pins aws:PrincipalTag/x509Subject/CN = 'r720-aws-posture' AND aws:PrincipalTag/x509Issuer/CN, and ArnEquals on aws:SourceArn = this trust anchor. So three independent conditions must all hold for AssumeRole to succeed: (1) the call comes via Roles Anywhere, (2) from THIS trust anchor, (3) presenting a leaf whose subject CN and issuer CN match. Roles Anywhere maps x509 subject/issuer fields into aws:PrincipalTag/x509Subject/* and aws:PrincipalTag/x509Issuer/* session tags, which is what the trust policy keys on."
}

View file

@ -0,0 +1,145 @@
# step-ca config sketch — internal CA for aws-posture Roles Anywhere leaf certs
Design ref: `docs/r720-agent-team-design.md` §6.3 (step-ca + Roles Anywhere, D5) and §7 Phase 3.
**Not provisioned here.** This is the config + renewal approach for the GPT-4.1 cross-review.
step-ca is the small internal CA on the R720 box (Smallstep `step-ca`) whose **root** cert is
pinned as the Roles Anywhere trust anchor, and which issues a **short-lived leaf** that the box
presents to Roles Anywhere to obtain short-lived read-only STS credentials. **No long-lived AWS
key ever lands on the box** — the leaf self-expires and is auto-renewed by a systemd timer.
## Trust chain (one CA, one purpose)
```
step-ca ROOT (offline-ish, long-lived)
└── step-ca intermediate (the online signer)
└── leaf CN=r720-aws-posture (short-lived, ~24h, auto-renewed)
└── presented to AWS IAM Roles Anywhere trust anchor
└── AssumeRole -> r720-aws-posture-readonly (1h STS session)
```
The trust anchor pins the **root** cert (`roles-anywhere-config.json` → `sourceData
.x509CertificateData`). The role trust policy (`aws-posture-trust-policy.json`) additionally
pins the leaf **subject CN** (`r720-aws-posture`) and **issuer CN**, so only this CA's leaf with
this exact CN can assume the role.
## `ca.json` (sketch — the single-purpose provisioner)
```jsonc
{
"root": "/etc/step-ca/certs/root_ca.crt",
"crt": "/etc/step-ca/certs/intermediate_ca.crt",
"key": "/etc/step-ca/secrets/intermediate_ca_key",
"address": "127.0.0.1:8443", // localhost-only; the box is the sole client
"dnsNames": ["localhost", "r720.lan"],
"authority": {
"claims": {
"minTLSCertDuration": "5m",
"maxTLSCertDuration": "24h", // hard cap: leaves are short-lived
"defaultTLSCertDuration": "24h",
"disableRenewal": false
},
"provisioners": [
{
"type": "JWK",
"name": "aws-posture",
"key": { "use": "sig", "kty": "EC", "crv": "P-256", "alg": "ES256", "kid": "REPLACE", "x": "REPLACE", "y": "REPLACE" },
"encryptedKey": "REPLACE_WITH_ENCRYPTED_PROVISIONER_KEY",
"claims": {
"maxTLSCertDuration": "24h",
"defaultTLSCertDuration": "24h"
},
"options": {
"x509": {
// The provisioner only ever issues this one CN; templating keeps the
// subject/issuer fields the Roles Anywhere trust policy pins.
"templateData": { "CommonName": "r720-aws-posture" }
}
}
}
]
}
}
```
Root CA subject CN: **`Sea Haven Internal CA - R720 Roles Anywhere`** (matches the
`x509Issuer/CN` condition in `aws-posture-trust-policy.json`).
## Initial bootstrap (one-time, at provisioning)
```bash
step ca init \
--name "Sea Haven Internal CA - R720 Roles Anywhere" \
--dns localhost --dns r720.lan --address 127.0.0.1:8443 \
--provisioner aws-posture --deployment-type standalone
# Issue the first leaf the box will present to Roles Anywhere:
step ca certificate "r720-aws-posture" \
/etc/aws-posture/leaf.crt /etc/aws-posture/leaf.key \
--not-after 24h --provisioner aws-posture
```
`leaf.key` is mode 600, owned by the unattended service user; it never leaves the box.
## Auto-renewal — systemd timer (the leaf self-expires; the timer keeps it fresh)
`step-ca` ships `step ca renew`, which no-ops until the cert is within its renewal window.
`/etc/systemd/system/aws-posture-cert-renew.service`:
```ini
[Unit]
Description=Renew r720-aws-posture Roles Anywhere leaf certificate
After=network-online.target step-ca.service
[Service]
Type=oneshot
User=aws-posture
# --expires-in: renew only when <8h of life remains; idempotent, safe to run hourly.
ExecStart=/usr/bin/step ca renew --force --expires-in 8h \
/etc/aws-posture/leaf.crt /etc/aws-posture/leaf.key
# step-ca renew rewrites the cert in place; aws_signing_helper reads it fresh each call,
# so no service reload is needed.
```
`/etc/systemd/system/aws-posture-cert-renew.timer`:
```ini
[Unit]
Description=Hourly renewal check for the aws-posture leaf cert
[Timer]
OnCalendar=hourly
RandomizedDelaySec=300
Persistent=true # catch up a renewal missed while the box was off
[Install]
WantedBy=timers.target
```
Hourly check + 8h renewal window + 24h cert = the leaf is always fresh and a missed window has
hours of slack. The timer mirrors the existing secrev launchd/systemd discipline.
## How aws-posture USES the leaf (no AWS key on disk)
aws-posture invokes AWS's `aws_signing_helper credential-process`, which signs the Roles
Anywhere request with the **leaf** and returns short-lived STS creds on stdout:
```ini
# ~/.aws/config (on the box)
[profile r720-aws-posture]
credential_process = /usr/local/bin/aws_signing_helper credential-process \
--certificate /etc/aws-posture/leaf.crt \
--private-key /etc/aws-posture/leaf.key \
--trust-anchor-arn arn:aws:rolesanywhere:us-east-1:328440206208:trust-anchor/REPLACE \
--profile-arn arn:aws:rolesanywhere:us-east-1:328440206208:profile/REPLACE \
--role-arn arn:aws:iam::328440206208:role/r720-aws-posture-readonly
```
The credentials live only in process memory for the 1h session duration; nothing long-lived is
written. This is **strictly stronger than the box's existing long-lived GitHub PAT** (design
§6.3): the AWS identity self-expires and rotates without operator action.
## Capacity note (design §6.5)
step-ca on a 4GB / 2 vCPU / 40GB box is negligible (a localhost signer + a tiny DB). Re-check
disk headroom after Phase 1 per §6.5; snapshot the Hyper-V VM before standing this up per
`feedback_ec2_replacement_snapshot`.