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:
1 parent
bc23fc2dee
commit
2f5305eeb1
6 files changed
+214
-57
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -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",
|
||||
|
||||
@@ -302,7 +302,8 @@
|
||||
"Sid": "CreateTheStateBucket",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:CreateBucket"
|
||||
"s3:CreateBucket",
|
||||
"s3:TagResource"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::triple-c-tfstate-*"
|
||||
|
||||
Reference in new issue
Block a user