# Hand-applied policies

Two documents the admin window needs and Terraform does not create. Templates rather than finished
JSON because both name the account number, and this repository does not carry it.

Substitute and apply:

```bash
export AWS_PROFILE=mcp
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
BUCKET=mcp-tfstate-$ACCOUNT

for f in state-bucket-policy operator-role-policy; do
  sed -e "s/ACCOUNT_ID/$ACCOUNT/g" -e "s/BUCKET_NAME/$BUCKET/g" \
    "policies/$f.json.tpl" > "/tmp/$f.json"
done
```

## `state-bucket-policy.json` (runbook step 5)

**Apply this AFTER `terraform apply`.** It names two roles, and a bucket policy whose principal does
not exist yet is rejected with `MalformedPolicy: Invalid principal`. `mcp-deploy` is created by the
apply; `mcp-operator` is step 8, so do that first as well.

```bash
aws s3api put-bucket-policy --bucket "$BUCKET" --policy file:///tmp/state-bucket-policy.json
```

This is the ONLY grant of state access anywhere. Neither role's identity policy has an S3
statement, deliberately, so that there is one list of who may touch state instead of two that
drift apart.

`s3:DeleteObject` is not an oversight. Terraform releases the S3 native lock by DELETING the
`.tflock` object. Grant read and write but not delete and every apply takes a lock it can never
release, which looks like a hung apply and then like permanently locked state.

## `operator-role-policy.json` (runbook step 8)

The standing human role. This is what a person uses after admin expires.

```bash
aws iam create-role --role-name mcp-operator --path /mcp/ \
  --assume-role-policy-document file:///tmp/operator-trust.json

aws iam put-role-policy --role-name mcp-operator --policy-name operator \
  --policy-document file:///tmp/operator-role-policy.json
```

### Read this before you close the admin window

The trust policy above is correct and is not sufficient on its own. Knowing why here, next to the
command, is the difference between arranging it now and rediscovering it on the day access lapses.

`Principal: "*"` with a condition is a DELEGATION, not a grant. It tells IAM the role MAY be assumed
by a matching principal, and IAM then also requires the CALLER's own policy to allow
`sts:AssumeRole`.

**Measured 2026-08-18: `DBaaS-Prod-OnCall-Role` DOES allow it, so this trust policy works from it as
written.** Verified by holding both permission sets at once and assuming the role from the OnCall
one. So the concern below is real in general and does not apply to the permission sets in use here.
Check it again if the standing permission set ever changes.

**Still worth asking for, in whichever permission set you reapply for**, because it makes this
independent of what a permission set happens to include:

```json
{
  "Effect": "Allow",
  "Action": "sts:AssumeRole",
  "Resource": "arn:aws:iam::ACCOUNT_ID:role/mcp/*"
}
```

If that is refused, the fallback is `relink-operator-trust.sh`, which additionally names each
provisioned permission set literally. An explicitly named same-account principal needs no permission
on the caller's side, so it works without the statement above. It is a worse answer for two reasons:
it must be run while you still hold a permission set that can edit IAM under `/mcp/`, and a
re-approval can recreate the SSO role with a different hash, which retires the names and leaves only
the pattern statement, back to needing the caller-side grant. Run it after every renewal.

## `operator-trust.json` (the trust policy for that role)

Matches ANY IAM Identity Center session in this account, by ARN pattern, rather than naming a
permission set.

That is deliberate and it is the whole reason this file exists. Sign-in today is through
`AWSReservedSSO_DBaaS-Prod-Admin-Role_<hash>`, and the plan is to move to a lower-privilege
permission set later while keeping the account. An SSO role's ARN is
`AWSReservedSSO_<permission-set-name>_<hash>`, so naming today's role would break the day the
permission set changes, and it would break in the direction of nobody being able to assume the role
that exists to be assumable.

The obvious alternative, updating the trust policy at swap time, works only if it is done BEFORE the
old permission set is removed. The role can edit its own trust policy (its permissions include
`iam:*` under `/mcp/*`), so recovery is possible, but only from inside a session that can already
assume it. That is a bad thing to depend on remembering.

**The tradeoff, stated rather than buried:** anyone with any permission set in this account can
assume this role, and this role can apply the whole stack.

That is acceptable on one condition, and the condition is load-bearing rather than incidental. This
account is dedicated to this service. That is not our assumption; it is how the account was granted,
stated by the person in security who provisioned it. Assignments here are therefore deliberate, and
"any permission set on this account" is a small and known set.

**If that ever stops being true, this design stops being correct.** The day someone is given a
read-only assignment on this account and is expected to stay read-only, they can assume a role that
applies the whole stack and reads the service's secrets. Whoever notices that first should switch to
naming permission sets explicitly and accept the swap-day step, rather than assuming the wildcard
was reviewed under the new circumstances. It was not. It was reviewed under these.

```bash
sed "s/ACCOUNT_ID/$ACCOUNT/g" policies/operator-trust.json.tpl > /tmp/operator-trust.json
```

### Why it is this broad, stated plainly

`ApplyTheStack` is close to full access on the services this stack uses. That is not a scoping
failure that someone should tighten later without thinking: this role applies the whole stack, so
it can change the whole stack, and a policy that pretended otherwise would be a comfort rather than
a control. Terraform also needs to READ every resource it manages, which defeats most attempts at
narrowing by action.

What IS scoped, and should stay scoped:

- IAM is limited to path `/mcp/*`. The role cannot touch anything else in the account.
- `iam:PassRole` is limited to `/mcp/*` roles AND to ECS. Unscoped `PassRole` turns any role that
  can run a task into a way to become any role in the account.
- Secrets Manager is limited to the `mcp/*` name prefix.

Note that this role CAN read and write secret values, unlike the deploy role, which deliberately
cannot. That is the difference between a human fixing an incident and a pipeline that has no reason
to ever see a secret.
