fix(aws): admin policy gaps found by the live OpenTofu run

- s3:TagResource with s3:CreateBucket: a create with tags authorizes it
  (same request-tag and in-account conditions).
- ec2:CreateSecurityGroup on the VPC: EC2 shows the VPC no request tags,
  so that side is unconditioned; the group itself still needs the tag.
- ec2:DisassociateAddress on the literal */* only (${*}/${*}): destroying
  the NAT gateway's address disassociates it after the association is
  gone. The evaluator learns ${*}.

Checks, MODULE_CALLS and aws/README.md (including the Environments
section and the phase-4 live results) change with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-14 23:31:49 -08:00
1 parent bc23fc2dee
commit 2f5305eeb1
6 files changed
+214 -57

No files matched your search

+49 -20
View File
@@ -190,6 +190,9 @@ const PROBE_ROUTE_TABLE: &str = "arn:aws:ec2:{region}:{account}:route-table/rtb-
const PROBE_ELASTIC_IP: &str =
"arn:aws:ec2:{region}:{account}:elastic-ip/eipalloc-00000000000000000";
const PROBE_NAT_GATEWAY: &str = "arn:aws:ec2:{region}:{account}:natgateway/nat-00000000000000000";
/// What EC2 names when a call's resources no longer exist (an address
/// association that went with its NAT gateway): a literal `*/*`.
const PROBE_GONE_ASSOCIATION: &str = "arn:aws:ec2:{region}:{account}:*/*";
const PROBE_NETWORK_INTERFACE: &str =
"arn:aws:ec2:{region}:{account}:network-interface/eni-00000000000000000";
const PROBE_SECURITY_GROUP_RULE: &str =
@@ -475,7 +478,9 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[
REQUESTED_IN_TRIPLE_C_PARENT,
),
// The one create that may name an untagged (existing-network) VPC.
write("ec2:CreateSecurityGroup", PROBE_VPC, COMPONENT_REQUESTED),
// The VPC side of the request carries no request tag (verified live),
// so only the group's side is tag-checked.
write("ec2:CreateSecurityGroup", PROBE_VPC, &[]),
write(
"ec2:CreateTags",
PROBE_SECURITY_GROUP,
@@ -533,6 +538,10 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[
),
write("ec2:DisassociateRouteTable", PROBE_SUBNET, COMPONENT_TAGGED),
write("ec2:ReleaseAddress", PROBE_ELASTIC_IP, COMPONENT_TAGGED),
// Destroying the NAT gateway's address disassociates it first; the NAT
// gateway is gone by then, so EC2 authorizes the call against the
// literal `*/*` (verified live), which is all the policy allows.
write("ec2:DisassociateAddress", PROBE_GONE_ASSOCIATION, &[]),
write("ec2:DeleteNatGateway", PROBE_NAT_GATEWAY, COMPONENT_TAGGED),
write(
"ec2:DeleteSecurityGroup",
@@ -715,6 +724,8 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[
// The state bucket (the app creates it; OpenTofu's S3 backend reads and
// writes the state and lock objects).
write("s3:CreateBucket", PROBE_STATE_BUCKET, STATE_REQUESTED),
// CreateBucket with tags also authorizes s3:TagResource (verified live).
write("s3:TagResource", PROBE_STATE_BUCKET, STATE_REQUESTED),
write("s3:PutBucketVersioning", PROBE_STATE_BUCKET, IN_MY_ACCOUNT),
write(
"s3:PutEncryptionConfiguration",
@@ -1765,21 +1776,21 @@ mod tests {
"{action} on {elsewhere}"
);
}
assert!(allowed(
tier,
&write("s3:CreateBucket", PROBE_STATE_BUCKET, STATE_REQUESTED),
ME,
ME
));
for (bucket, context) in [
(PROBE_STATE_BUCKET, IN_MY_ACCOUNT),
(PROBE_STATE_BUCKET, OTHER_ACCOUNT_REQUESTED),
(OTHER_BUCKET, STATE_REQUESTED),
] {
for action in ["s3:CreateBucket", "s3:TagResource"] {
assert!(
!allowed(tier, &write("s3:CreateBucket", bucket, context), ME, ME),
"s3:CreateBucket {bucket} with {context:?}"
allowed(tier, &write(action, PROBE_STATE_BUCKET, STATE_REQUESTED), ME, ME),
"{action}"
);
for (bucket, context) in [
(PROBE_STATE_BUCKET, IN_MY_ACCOUNT),
(PROBE_STATE_BUCKET, OTHER_ACCOUNT_REQUESTED),
(OTHER_BUCKET, STATE_REQUESTED),
] {
assert!(
!allowed(tier, &write(action, bucket, context), ME, ME),
"{action} {bucket} with {context:?}"
);
}
}
}
@@ -1824,18 +1835,36 @@ mod tests {
"{action} on an untagged {resource}"
);
}
// A group may be created in any VPC (EC2 shows the VPC no request
// tag), but the group itself must carry the component tag.
assert!(allowed(
tier,
&write("ec2:CreateSecurityGroup", PROBE_VPC, COMPONENT_REQUESTED),
ME,
ME
));
assert!(!allowed(
tier,
&write("ec2:CreateSecurityGroup", PROBE_VPC, &[]),
ME,
ME
));
assert!(!allowed(
tier,
&write("ec2:CreateSecurityGroup", PROBE_SECURITY_GROUP, &[]),
ME,
ME
));
// Only the literal `*/*` of an association that is already gone: a
// live address, Triple-C's or anyone's, is never disassociated.
assert!(allowed(
tier,
&write("ec2:DisassociateAddress", PROBE_GONE_ASSOCIATION, &[]),
ME,
ME
));
for resource in [PROBE_ELASTIC_IP, PROBE_NETWORK_INTERFACE] {
for context in [&[][..], COMPONENT_TAGGED] {
assert!(
!allowed(tier, &write("ec2:DisassociateAddress", resource, context), ME, ME),
"ec2:DisassociateAddress on {resource} with {context:?}"
);
}
}
// A VPC's main route table association names no subnet, so swapping
// it would re-route a VPC by its route table alone: never allowed.
assert!(!allowed(
+15 -3
View File
@@ -16,15 +16,21 @@ pub(crate) const AT: Placeholders = Placeholders {
pub(crate) const ME: &str = "AROAEXAMPLEME:alice@example.com";
pub(crate) const SOMEONE_ELSE: &str = "AROAEXAMPLEOTHER:bob@example.com";
/// What [`Request::resolve`] turns IAM's `${*}` into: a literal `*` in a
/// pattern, which [`glob`] matches only against a `*`.
const LITERAL_STAR: char = '\u{e000}';
/// IAM wildcard match: `*` is any run of characters (including `:` and
/// `/`), `?` is exactly one.
/// `/`), `?` is exactly one; [`LITERAL_STAR`] is a plain `*`.
pub(crate) fn glob(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let t: Vec<char> = text.chars().collect();
let (mut pi, mut ti) = (0, 0);
let (mut star, mut mark) = (None, 0);
while ti < t.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
if pi < p.len()
&& (p[pi] == '?' || p[pi] == t[ti] || (p[pi] == LITERAL_STAR && t[ti] == '*'))
{
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == '*' {
@@ -126,7 +132,9 @@ impl Request<'_> {
out.push_str(&rest[..start]);
let end = rest[start..].find('}').expect("unterminated ${") + start;
let name = &rest[start + 2..end];
let value = if name == "aws:userid" {
let value = if name == "*" {
Some(LITERAL_STAR.to_string())
} else if name == "aws:userid" {
Some(self.caller.to_string())
} else {
self.context_value(name)
@@ -254,6 +262,10 @@ mod tests {
));
assert!(glob("a?c", "abc"));
assert!(!glob("a?c", "ac"));
// `${*}` (resolved to LITERAL_STAR) matches only a real `*`.
let literal = format!("arn:aws:ec2:*:*:{LITERAL_STAR}/{LITERAL_STAR}");
assert!(glob(&literal, "arn:aws:ec2:us-west-2:1:*/*"));
assert!(!glob(&literal, "arn:aws:ec2:us-west-2:1:elastic-ip/eipalloc-1"));
}
#[test]
+9 -2
View File
@@ -1374,6 +1374,7 @@ const EIP: &str = "arn:aws:ec2:{region}:{account}:elastic-ip/eipalloc-0000000000
const NAT: &str = "arn:aws:ec2:{region}:{account}:natgateway/nat-00000000000000000";
const SG: &str = "arn:aws:ec2:{region}:{account}:security-group/sg-00000000000000000";
const SG_RULE: &str = "arn:aws:ec2:{region}:{account}:security-group-rule/sgr-00000000000000000";
const GONE_ASSOCIATION: &str = "arn:aws:ec2:{region}:{account}:*/*";
const ENI: &str = "arn:aws:ec2:{region}:{account}:network-interface/eni-00000000000000000";
const FS: &str = "arn:aws:elasticfilesystem:{region}:{account}:file-system/fs-00000000";
const TASK_ROLE: &str = "arn:aws:iam::{account}:role/triple-c-probe-0123456789ab-task";
@@ -1480,8 +1481,9 @@ const MODULE_CALLS: &[(&str, &[Check])] = &[
&[
call("ec2:CreateSecurityGroup", SG, ENV_CREATE),
// The one create that may name an untagged VPC: an
// `existing`-network environment's is the user's own.
call("ec2:CreateSecurityGroup", VPC, ENV_CREATE),
// `existing`-network environment's is the user's own. EC2 shows
// the VPC no request tag (verified live).
call("ec2:CreateSecurityGroup", VPC, NONE),
call("ec2:CreateTags", SG, created_by!("CreateSecurityGroup")),
call("ec2:DescribeSecurityGroups", "*", NONE),
call("ec2:DescribeSecurityGroupRules", "*", NONE),
@@ -1654,6 +1656,10 @@ const MODULE_CALLS: &[(&str, &[Check])] = &[
call("ec2:DescribeAddresses", "*", NONE),
call("ec2:DescribeAddressesAttribute", "*", NONE),
call("ec2:CreateTags", EIP, ENV_TAGGED),
// Delete disassociates first. The NAT gateway that held the
// association is gone by then, so EC2 authorizes it against a
// literal `*/*` (verified live).
call("ec2:DisassociateAddress", GONE_ASSOCIATION, NONE),
call("ec2:ReleaseAddress", EIP, ENV_TAGGED),
],
),
@@ -1973,6 +1979,7 @@ fn every_onboarding_role_stays_inside_the_boundary() {
const APP_ONLY_WRITES: &[&str] = &[
// The state bucket's setup (the backend only reads and writes objects).
"s3:CreateBucket",
"s3:TagResource",
"s3:PutBucketVersioning",
"s3:PutEncryptionConfiguration",
"s3:PutBucketPublicAccessBlock",
+128 -25
View File
@@ -104,15 +104,16 @@ capacity adds them back.
`app/src-tauri/src/aws/tofu_modules/tests.rs` (`MODULE_CALLS`) maps every resource type, data
source and the S3 backend the modules use to the IAM actions the AWS provider calls to create,
read, tag, update and delete it, and a test evaluates each against the admin files. It is the
**hypothesis**; a live apply and destroy is the proof, and any action that shows missing is added
to the table, the policy, the check table in `policies.rs` and this list together.
read, tag, update and delete it, and a test evaluates each against the admin files. A live apply
and destroy of every module as a role holding only the admin files (see "Live verification") is
the proof; an action it shows missing is added to the table, the policy, the check table in
`policies.rs` and this list together.
| Resource | Actions (authorized against) |
|---|---|
| `aws_ecs_cluster` | `ecs:CreateCluster`, `TagResource` (at create), `DescribeClusters`, `ListTagsForResource`, `UpdateCluster`, `UntagResource`, `DeleteCluster` (cluster `triple-c-*`) |
| `aws_ecs_cluster_capacity_providers` | `ecs:PutClusterCapacityProviders`, `DescribeClusters` (the cluster; untaggable) |
| `aws_security_group` | `ec2:CreateSecurityGroup` (group and VPC, request tag), `CreateTags` (at create), `RevokeSecurityGroupEgress` (AWS's default rule), `Authorize`/`RevokeSecurityGroupIngress`/`Egress` and `UpdateSecurityGroupRuleDescriptions*` (tagged group; new rules: `security-group-rule/*`), `CreateTags`/`DeleteTags`, `DeleteSecurityGroup` (tagged), `Describe*` |
| `aws_security_group` | `ec2:CreateSecurityGroup` (group: request tag; VPC: any, since EC2 shows the VPC no request tag), `CreateTags` (at create), `RevokeSecurityGroupEgress` (AWS's default rule), `Authorize`/`RevokeSecurityGroupIngress`/`Egress` and `UpdateSecurityGroupRuleDescriptions*` (tagged group; new rules: `security-group-rule/*`), `CreateTags`/`DeleteTags`, `DeleteSecurityGroup` (tagged), `Describe*` |
| `aws_kms_key` | `kms:CreateKey` (request tag), `TagResource`, `DescribeKey`, `GetKeyPolicy`, `GetKeyRotationStatus`, `ListResourceTags`, `EnableKeyRotation`, `PutKeyPolicy`, `UpdateKeyDescription`, `UntagResource`, `ScheduleKeyDeletion` (tagged key) |
| `aws_kms_alias` | `kms:CreateAlias`/`UpdateAlias`/`DeleteAlias` (alias `triple-c-*` and the tagged key; untaggable), `ListAliases` |
| `aws_cloudwatch_log_group` | `logs:CreateLogGroup`, `TagResource`, `PutRetentionPolicy`, `ListTagsForResource`, `UntagResource`, `DeleteRetentionPolicy`, `DeleteLogGroup` (`/triple-c/*`), `DescribeLogGroups` (`*`) |
@@ -123,18 +124,18 @@ to the table, the policy, the check table in `policies.rs` and this list togethe
| `aws_route_table` | `ec2:CreateRouteTable` (table: request tag; VPC: tagged), `CreateTags` (at create), `DeleteRouteTable` (tagged), `Describe*` |
| `aws_route` | `ec2:CreateRoute`, `ReplaceRoute`, `DeleteRoute` (the tagged route table; untaggable) |
| `aws_route_table_association` | `ec2:AssociateRouteTable`, `DisassociateRouteTable` (the tagged route table and subnet; untaggable). Never `ReplaceRouteTableAssociation`: it names no subnet when it swaps a VPC's main route table, so it could re-route a VPC that isn't Triple-C's; the modules never change an association's table |
| `aws_eip` | `ec2:AllocateAddress` (request tag), `CreateTags` (at create), `ReleaseAddress` (tagged), `Describe*` |
| `aws_eip` | `ec2:AllocateAddress` (request tag), `CreateTags` (at create), `ReleaseAddress` (tagged), `DisassociateAddress` (only the literal `*/*` EC2 names once the NAT gateway holding the association is gone), `Describe*` |
| `aws_nat_gateway` | `ec2:CreateNatGateway` (gateway: request tag; its subnet, address and VPC: tagged), `CreateTags` (at create), `DeleteNatGateway` (tagged), `Describe*` |
| `aws_efs_file_system` | `elasticfilesystem:CreateFileSystem` (`*`, request tag), `TagResource` (at create), `UpdateFileSystem`, `PutLifecycleConfiguration`, `TagResource`/`UntagResource`, `DeleteFileSystem` (tagged), `Describe*`, `ListTagsForResource` |
| `aws_efs_mount_target` | `elasticfilesystem:CreateMountTarget`, `ModifyMountTargetSecurityGroups`, `DeleteMountTarget` (the tagged file system; untaggable), and the interface EFS makes with the caller's permissions: `ec2:CreateNetworkInterface`, `ec2:DeleteNetworkInterface` (only with `aws:CalledViaLast = elasticfilesystem.amazonaws.com`; in the environment's subnets, which in `existing` mode are the user's own) |
| `aws_efs_mount_target` | `elasticfilesystem:CreateMountTarget`, `ModifyMountTargetSecurityGroups`, `DeleteMountTarget` (the tagged file system; untaggable), and the interface EFS makes with the caller's permissions: `ec2:CreateNetworkInterface`, `ec2:DeleteNetworkInterface` (only with `aws:CalledViaLast = elasticfilesystem.amazonaws.com`, verified live; in the environment's subnets, which in `existing` mode are the user's own) |
| `aws_efs_file_system_policy` | `elasticfilesystem:PutFileSystemPolicy`, `DeleteFileSystemPolicy` (the tagged file system; untaggable), `DescribeFileSystemPolicy` |
| `aws_iam_role` | `iam:CreateRole` (with the boundary), `TagRole`, `UntagRole`, `GetRole`, `ListRolePolicies`, `ListAttachedRolePolicies`, `ListInstanceProfilesForRole`, `UpdateRole`, `UpdateRoleDescription`, `DeleteRole` (`triple-c-*`) |
| `aws_iam_role_policy` | `iam:PutRolePolicy`, `DeleteRolePolicy` (with the boundary), `GetRolePolicy` (the role; untaggable) |
| data sources | `sts:GetCallerIdentity`, `ec2:DescribeAvailabilityZones`, `ec2:DescribeSubnets`, `ssm:GetParameter` |
| S3 backend | `s3:ListBucket` (the state bucket), `GetObject`/`PutObject`/`DeleteObject` (`triple-c-tfstate-*/*`, the `.tflock` objects included), in the caller's own account |
The app also creates the state bucket: `s3:CreateBucket` with a `triple-c:component` request tag,
then `PutBucketVersioning`, `PutEncryptionConfiguration`, `PutBucketPublicAccessBlock`,
The app also creates the state bucket: `s3:CreateBucket` with a `triple-c:component` request tag
(and `s3:TagResource`, which a create with tags also needs, under the same conditions), then `PutBucketVersioning`, `PutEncryptionConfiguration`, `PutBucketPublicAccessBlock`,
`PutBucketTagging`, `PutBucketPolicy`, `GetBucket*` and `ListBucket`, all on
`triple-c-tfstate-*` only. No bucket can be deleted.
@@ -194,21 +195,83 @@ account-wide:
`triple-c:component` request tag.
- `ec2:CreateSecurityGroup` in **any** VPC, as long as the new group is tagged: an
`existing`-network environment creates its tasks group and each user's `-efs` group in the
user's own, untagged VPC. It is the only create that may name an untagged parent.
user's own, untagged VPC. It is the only create that may name an untagged parent. The VPC side
of the grant has no condition at all: EC2 does not show the VPC the request's tags (a request-tag
condition there denied every create, verified live), so the group's own side carries the tag
rule.
- `ec2:DisassociateAddress` on `arn:aws:ec2:*:*:${*}/${*}`, i.e. the literal resource `*/*` and
nothing else (`${*}` is IAM's escaped `*`). Destroying the NAT gateway's address disassociates
it first; by then the NAT gateway and its association are gone and EC2 authorizes the call
against `*/*` (verified live: denied without this, then `InvalidAssociationID.NotFound`, which
the provider ignores). A live association names its address and interface, so no real address
can be disassociated through this statement.
- `ec2:AuthorizeSecurityGroupIngress`/`Egress` on `security-group-rule/*`: authorizing names the
new, untagged rule as well as the group, and the group must still be tagged.
- `ec2:CreateNetworkInterface` and `ec2:DeleteNetworkInterface` on `*`, but only when EFS makes
the call for the admin (`aws:CalledViaLast = elasticfilesystem.amazonaws.com`): EFS creates and
deletes a mount target's interface with the caller's EC2 permissions, in any subnet (an
`existing` environment's are untagged). **Unverified:** whether EFS sets `aws:CalledVia` for
these calls is for Task 8's live run to show; if it does not, this statement needs another
condition.
`existing` environment's are untagged). Verified live: EFS sets `aws:CalledViaLast` for both,
and mount targets are created and deleted under this statement alone.
- Reads on `*`: `ec2:Describe*`, `elasticfilesystem:Describe*`, the KMS key reads and
`ListAliases`, `logs:DescribeLogGroups` and `ssm:DescribeParameters`.
- `s3:PutBucketPolicy` on the state bucket (only a bucket in the admin's own account, enforced by
`s3:ResourceAccount`): an admin could open that bucket to other accounts. The state holds no
secrets (the modules create none), but it maps the account's Triple-C resources.
## Environments (OpenTofu)
An environment is deployed with [OpenTofu](https://opentofu.org) from the modules in `terraform/`.
The app runs them itself (it downloads OpenTofu 1.13.9, verified against a pinned SHA-256 before
it is unpacked), and an admin can run the very same files by hand from a bundle.
| Module | Creates | State key |
|---|---|---|
| `terraform/permissions-boundary` | the account's `triple-c-permissions-boundary` policy (an account administrator applies it; the admin tier cannot) | `boundary/terraform.tfstate` |
| `terraform/environment` | what every user shares: ECS cluster (Fargate, Fargate Spot), the tasks security group (no inbound rules), KMS key and alias, log group `/triple-c/<env>`, the network (`network_mode`: `public`, `private-nat`, `existing`) and, last, the record `/triple-c/environments/<env>` | `environments/<env>/terraform.tfstate` |
| `terraform/user-onboard` | one user's task and execution roles (with the boundary) and their own EFS file system, its `-efs` security group, two mount targets and a file-system policy that admits only their task role | `users/<env>/<owner key>/terraform.tfstate` |
Every module pins the AWS provider (`hashicorp/aws` 6.73.0, hashes in `.terraform.lock.hcl`),
sets `region` explicitly, never overrides an endpoint, and tags everything through `default_tags`
(`triple-c:component`, plus the environment, owner and template version). The onboarding module
reads the environment only from its record, never from its state.
**The state bucket** is `triple-c-tfstate-<account>-<region>`, one per account and region. The app
creates it on the first deploy (versioning, SSE-S3, all four public-access blocks, a TLS-only
bucket policy, tagged `triple-c:component=state`); every call carries `expected_bucket_owner`, so a
bucket of that name in another account is refused, never used. Locking is S3-native
(`use_lockfile = true`): a run holds `<state key>.tflock`, and a held lock is what "deploying"
means to every other copy of the app. The app removes a stale lock (older than two hours, an
environment's only) with a conditional delete on the lock's ETag, so a lock taken since is never
removed. `tofu destroy` leaves an *empty* state object behind; detection reads an empty state as
no deployment at all.
**How the app runs a deployment.** It writes the module into a work folder
(`<app data>/triple-c/aws/work/<bucket>/<state key>/`) with `backend.hcl` and
`terraform.tfvars.json`, then starts a detached runner (the app binary re-executed with
`--tofu-runner`) that runs `tofu init` and `tofu apply` (or `destroy`) in its own process group,
logging to `apply.log`. Closing the app does not stop it; the next start reattaches by following the
log. Cancel interrupts OpenTofu, which stops gracefully and saves its state. OpenTofu's environment
is built from scratch: no inherited `AWS_*`, `TF_*` or `TOFU_*` variables, the connection's profile
name or its keys (in the environment only, never in arguments or files), and the app's own CLI
config, so a `~/.tofurc` cannot redirect provider downloads. The app destroys only an incomplete
deployment (state without a record, or a failed apply); a working environment is removed by an
admin with `tofu destroy` from a bundle.
**Admin bundles.** When the signed-in identity cannot deploy (or the admin prefers to), the app
saves a folder with the module, `backend.hcl`, `terraform.tfvars.json` and a `README.txt` holding
the exact commands. By hand, with credentials carrying the admin files (OpenTofu or Terraform 1.10
or later):
```sh
cd <bundle folder>
tofu init -backend-config=backend.hcl
tofu apply # or: tofu destroy
```
Remove every user's onboarding before destroying their environment. With Terraform, first run
`terraform providers lock` for your platforms (the lock file carries OpenTofu's registry hashes).
The bundle's `README.txt` also shows how to create the state bucket by hand if the app never has.
## Invariants the modules must keep (phase 4)
The policies rely on the following. The modules in `terraform/` establish them (their tests pin
@@ -249,8 +312,8 @@ Create each file as a **customer managed policy** and attach it. In the console,
or role. Inline policies do not fit: a role's inline policies share 10,240 characters and an
environment admin needs every tier file (about 14,500), and a user's share 2,048. A managed policy
may hold 6,144 characters, not counting whitespace. Without whitespace, `workspace-user.json` is
about 6,040 (roughly 100 characters of headroom), `environment-admin.json` about 5,790 (350)
and `environment-admin-network.json` about 3,280; a unit test (`every_policy_fits_a_managed_policy`)
about 6,040 (roughly 100 characters of headroom), `environment-admin.json` about 5,800 (340)
and `environment-admin-network.json` about 3,340; a unit test (`every_policy_fits_a_managed_policy`)
fails before any file outgrows the limit. A tier that outgrows its files gains another file, which
the app then lists with the others. For IAM Identity Center, add the files as inline policies on the permission set
(the "up to 32,768 characters" limit is unverified) or reference the managed policies.
@@ -271,14 +334,15 @@ aws iam attach-user-policy --user-name <user> \
- **Per-user execution roles.** A workspace user can pass only roles tagged with their own owner
ID, so each user's onboarding module creates their execution role as well as their task role
(done: `terraform/user-onboard/iam.tf`).
- **The admin's deploy permissions are a hypothesis until a live apply.** The module table above
is derived from the provider's behaviour and the Service Authorization Reference, not yet from a
real `tofu apply`/`destroy`. In particular unverified: whether tag-on-create needs
`logs:TagResource` / `ssm:AddTagsToResource` / `ecs:TagResource` as listed, whether EFS's
interface calls carry `aws:CalledViaLast = elasticfilesystem.amazonaws.com`, whether
`s3:ResourceAccount` is evaluated for `s3:CreateBucket`, and whether `s3:CreateBucket` honours
`aws:RequestTag`. A trust-policy change on a role (`iam:UpdateAssumeRolePolicy`) is never
allowed, so a module version that changes a role's trust must replace the role.
- **The admin's deploy permissions are verified for `public` and `private-nat`, not `existing`.**
A live apply, update and destroy of every module as a role holding only the admin files passed
(see "Live verification"). The `existing` network mode (a user's own, untagged VPC and subnets)
was not deployed live; its extra grants (`CreateSecurityGroup` in any VPC, mount-target
interfaces in untagged subnets) are the same statements the other modes exercised. Which
tag-on-create actions are strictly *needed* was not tested one by one: the listed ones are
sufficient, and only `s3:TagResource` turned out missing. A trust-policy change on a role
(`iam:UpdateAssumeRolePolicy`) is never allowed, so a module version that changes a role's trust
must replace the role.
- **Admin access to user data.** The admin can retag `triple-c-*` roles (`iam:TagRole`). The
boundary also allows every `triple-c/*` secret and every Triple-C file system. As a result,
isolating users *from the admin* depends on the onboarding module's per-user policies, not on
@@ -351,6 +415,45 @@ Still open for the phases that create the resources: `RunTask`'s tag keys and `i
`iam:ResourceTag` on a real run, the owner tag on a real task for `StopTask`/`ExecuteCommand`/
`StartSession`, session ownership for an SSO-started session, and the EFS and security-group-rule
points above. The simulator does not apply session policies, so the first real create is the final
check. The phase-4 additions (the network, the state bucket, the module table above, the
owner-scoped `CreateAccessPoint`) are verified by Task 8's live apply and destroy; until then they
are unit-tested only.
check. The phase-4 additions (the network, the state bucket, the module table above) are
verified below; the owner-scoped `CreateAccessPoint` waits for phase 6's access points.
### Phase 4: the modules, applied for real (2026-11-14)
```sh
cd app/src-tauri
cargo build # the runner is the app binary, re-executed with --tofu-runner
TRIPLEC_AWS_E2E_PROFILE=<admin profile of a disposable account> TRIPLEC_AWS_E2E_REGION=us-west-2 \
cargo test aws::live_tests::environment -- --ignored --test-threads=1
```
`an_environment_admin_can_deploy_join_update_and_remove_an_environment` creates a role holding
**only** the admin files (as `triple-c-e2e-<id>-envadmin*` managed policies) and does everything as
a session of it, passing its keys to OpenTofu as a keys connection does: `ensure_state_bucket` on a
throwaway `triple-c-tfstate-<account>-<region>-<suffix>` bucket (`TRIPLEC_E2E_BUCKET_SUFFIX`, random
by default), deploy a `public` environment through the real detached runner, `scan` (Ready, a valid
record, joinable), self-onboard (Joined, both roles carry the boundary), update (no changes),
destroy the onboarding, then the environment. If the account has no boundary, the account admin
applies the boundary module first and destroys it at the end. `the_private_nat_mode_deploys` does
the same for `private-nat` (about $0.05: one NAT gateway for a few minutes). A guard destroys
whatever is still applied, as the account admin, and deletes the bucket with every version, on
failure too; every denied action is printed by name. The test drives the same functions as the
commands (`ensure_tofu`, `materialize`, `child_env`, the runner, `follow`, `scan`, the refusal
checks, the conditional lock delete); the Tauri commands' own bookkeeping (launch slots, the
progress events) is not exercised.
| Point | Result |
|---|---|
| `s3:CreateBucket` with a request tag and `s3:ResourceAccount` | ✓ both conditions are evaluated and pass for the admin's own account. ✗→fixed: a create with tags also authorizes **`s3:TagResource`**, which the policy lacked (added to `CreateTheStateBucket`, same conditions) |
| `ec2:CreateSecurityGroup` in a VPC | ✗→fixed: denied on the VPC resource, which EC2 does not show the request's tags, so `CreateSecurityGroupsInAnyVpc` lost its request-tag condition. The group itself still needs the tag |
| Tag-on-create (ECS, KMS, Logs, SSM, EFS, EC2, IAM) | ✓ every create passed with the listed actions; none was missing. Not tested one by one whether each is strictly needed |
| EFS mount-target interfaces | ✓ EFS sets `aws:CalledViaLast = elasticfilesystem.amazonaws.com`: both mount targets were created and deleted with `MountTargetInterfacesThroughEfs` as the only grant of `ec2:CreateNetworkInterface`/`DeleteNetworkInterface`. Ruling R9's fallback was not needed |
| EFS file-system policy naming a just-created role | ✓ no invalid-principal error in any onboarding apply (two applies, each created in 4 s, right after the role; whether the provider retried internally is not visible without its debug log); no module change. If it ever fails, applying the onboarding again completes it |
| NAT address on destroy | ✗→fixed: the provider disassociates the address before releasing it, after the NAT gateway (and the association) are gone, and EC2 authorizes that call against the literal `*/*`. `DisassociateGoneAddresses` allows exactly `arn:aws:ec2:*:*:${*}/${*}`; CloudTrail shows the retried call reaching `InvalidAssociationID.NotFound` |
| Conditional unlock | ✓ on the versioned state bucket, `DeleteObject` with `If-Match` and a mismatched ETag gets 412 (the app's "lock changed" answer) and leaves the lock; with the current ETag it deletes it. A fresh foreign lock scans as Deploying with its `Who` |
| Update | ✓ re-applying with the network read back from the record: "0 added, 0 changed, 0 destroyed" |
| After `tofu destroy` | ✗→fixed (app): the state object stays, empty, so a destroyed environment kept scanning as Incomplete (and a removed onboarding as Joined). Detection now reads a small state object and treats one with no resources as absent |
| Cleanup | ✓ nothing `triple-c` left in us-west-2 (roles, policies, log groups, clusters, VPCs, subnets, gateways, NAT gateways, addresses, security groups, file systems, parameters, aliases, buckets); five environment KMS keys pending deletion (7 days); the boundary, which the account did not have, deleted again |
Not deployed live: the `existing` network mode, and an onboarding by a workspace user from an
admin bundle (the same module, applied with admin credentials).
+11 -6
View File
@@ -94,12 +94,7 @@
],
"Resource": [
"arn:aws:ec2:*:*:vpc/*"
],
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
}
}
]
},
{
"Sid": "TagNetworkAtCreate",
@@ -197,6 +192,16 @@
}
}
},
{
"Sid": "DisassociateGoneAddresses",
"Effect": "Allow",
"Action": [
"ec2:DisassociateAddress"
],
"Resource": [
"arn:aws:ec2:*:*:${*}/${*}"
]
},
{
"Sid": "DescribeEc2",
"Effect": "Allow",
+2 -1
View File
@@ -302,7 +302,8 @@
"Sid": "CreateTheStateBucket",
"Effect": "Allow",
"Action": [
"s3:CreateBucket"
"s3:CreateBucket",
"s3:TagResource"
],
"Resource": [
"arn:aws:s3:::triple-c-tfstate-*"