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:
parent
477c8a98a8
commit
77302a1ebb
7 changed files with 512 additions and 0 deletions
161
security-review/iam/CROSS-REVIEW-PACKET.md
Normal file
161
security-review/iam/CROSS-REVIEW-PACKET.md
Normal 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.
|
||||
25
security-review/iam/README.md
Normal file
25
security-review/iam/README.md
Normal 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.
|
||||
72
security-review/iam/aws-posture-readonly-policy.json
Normal file
72
security-review/iam/aws-posture-readonly-policy.json
Normal 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": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
45
security-review/iam/aws-posture-readonly-policy.rationale.md
Normal file
45
security-review/iam/aws-posture-readonly-policy.rationale.md
Normal 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.
|
||||
26
security-review/iam/aws-posture-trust-policy.json
Normal file
26
security-review/iam/aws-posture-trust-policy.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
security-review/iam/roles-anywhere-config.json
Normal file
38
security-review/iam/roles-anywhere-config.json
Normal 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."
|
||||
}
|
||||
145
security-review/iam/step-ca-config-sketch.md
Normal file
145
security-review/iam/step-ca-config-sketch.md
Normal 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`.
|
||||
Reference in a new issue