fix(aws): pin the state bucket to the account, tag-scope network parents, drop v1-unused grants

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-14 21:34:57 -08:00
1 parent 7dbf568696
commit 02c8616512
6 files changed
+431 -193

No files matched your search

+192 -70
View File
@@ -140,10 +140,27 @@ const COMPONENT_REQUESTED: Context =
&[("aws:RequestTag/triple-c:component", Literal("environment"))];
const COMPONENT_RESOURCE_TAG: &str = "aws:ResourceTag/triple-c:component";
const COMPONENT_TAGGED: Context = &[(COMPONENT_RESOURCE_TAG, Literal("environment"))];
/// A call a service makes on the caller's behalf.
const VIA_AWS_SERVICE: Context = &[("aws:ViaAWSService", Literal("true"))];
/// A call EFS makes on the caller's behalf (a mount target's interface).
const VIA_EFS: Context = &[(
"aws:CalledViaLast",
Literal("elasticfilesystem.amazonaws.com"),
)];
/// Created inside a Triple-C (component-tagged) VPC, subnet or address.
const REQUESTED_IN_TRIPLE_C_PARENT: Context = &[
("aws:RequestTag/triple-c:component", Literal("environment")),
(COMPONENT_RESOURCE_TAG, Literal("environment")),
];
/// The state bucket is in the caller's own account.
const IN_MY_ACCOUNT: Context = &[
("s3:ResourceAccount", Literal("{account}")),
("aws:PrincipalAccount", Literal("{account}")),
];
/// The state bucket is created tagged `triple-c:component=state`.
const STATE_REQUESTED: Context = &[("aws:RequestTag/triple-c:component", Literal("state"))];
const STATE_REQUESTED: Context = &[
("aws:RequestTag/triple-c:component", Literal("state")),
("s3:ResourceAccount", Literal("{account}")),
("aws:PrincipalAccount", Literal("{account}")),
];
const PROBE_CLUSTER: &str = "arn:aws:ecs:{region}:{account}:cluster/triple-c-probe";
const PROBE_TASK: &str =
@@ -408,16 +425,6 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[
write("ecs:UntagResource", PROBE_CLUSTER, &[]),
read("ecs:DescribeClusters", PROBE_CLUSTER, &[]),
read("ecs:ListTagsForResource", PROBE_CLUSTER, &[]),
write(
"ecs:CreateCapacityProvider",
"arn:aws:ecs:{region}:{account}:capacity-provider/triple-c-probe",
&[],
),
write(
"ecs:DeleteCapacityProvider",
"arn:aws:ecs:{region}:{account}:capacity-provider/triple-c-probe",
&[],
),
write("ecs:RegisterTaskDefinition", "*", &[]),
write("ecs:DeregisterTaskDefinition", "*", &[]),
// The network (environment-admin-network.json). Creates carry the
@@ -425,7 +432,7 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[
// its parents; tags go on only through the creating call.
write("ec2:CreateVpc", PROBE_VPC, COMPONENT_REQUESTED),
write("ec2:CreateSubnet", PROBE_SUBNET, COMPONENT_REQUESTED),
write("ec2:CreateSubnet", PROBE_VPC, COMPONENT_REQUESTED),
write("ec2:CreateSubnet", PROBE_VPC, REQUESTED_IN_TRIPLE_C_PARENT),
write(
"ec2:CreateInternetGateway",
PROBE_INTERNET_GATEWAY,
@@ -436,24 +443,38 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[
PROBE_ROUTE_TABLE,
COMPONENT_REQUESTED,
),
write("ec2:CreateRouteTable", PROBE_VPC, COMPONENT_REQUESTED),
write(
"ec2:CreateRouteTable",
PROBE_VPC,
REQUESTED_IN_TRIPLE_C_PARENT,
),
write("ec2:AllocateAddress", PROBE_ELASTIC_IP, COMPONENT_REQUESTED),
write(
"ec2:CreateNatGateway",
PROBE_NAT_GATEWAY,
COMPONENT_REQUESTED,
),
write("ec2:CreateNatGateway", PROBE_SUBNET, COMPONENT_REQUESTED),
write(
"ec2:CreateNatGateway",
PROBE_SUBNET,
REQUESTED_IN_TRIPLE_C_PARENT,
),
write(
"ec2:CreateNatGateway",
PROBE_ELASTIC_IP,
COMPONENT_REQUESTED,
REQUESTED_IN_TRIPLE_C_PARENT,
),
write(
"ec2:CreateSecurityGroup",
PROBE_SECURITY_GROUP,
COMPONENT_REQUESTED,
),
write(
"ec2:CreateNatGateway",
PROBE_VPC,
REQUESTED_IN_TRIPLE_C_PARENT,
),
// The one create that may name an untagged (existing-network) VPC.
write("ec2:CreateSecurityGroup", PROBE_VPC, COMPONENT_REQUESTED),
write(
"ec2:CreateTags",
@@ -505,11 +526,6 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[
COMPONENT_TAGGED,
),
write("ec2:AssociateRouteTable", PROBE_SUBNET, COMPONENT_TAGGED),
write(
"ec2:ReplaceRouteTableAssociation",
PROBE_ROUTE_TABLE,
COMPONENT_TAGGED,
),
write(
"ec2:DisassociateRouteTable",
PROBE_ROUTE_TABLE,
@@ -560,18 +576,23 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[
PROBE_SECURITY_GROUP,
COMPONENT_TAGGED,
),
write(
"ec2:UpdateSecurityGroupRuleDescriptionsEgress",
PROBE_SECURITY_GROUP,
COMPONENT_TAGGED,
),
// EFS creates and deletes a mount target's interface with the caller's
// EC2 permissions.
write(
"ec2:CreateNetworkInterface",
PROBE_NETWORK_INTERFACE,
VIA_AWS_SERVICE,
VIA_EFS,
),
write("ec2:CreateNetworkInterface", PROBE_SUBNET, VIA_AWS_SERVICE),
write("ec2:CreateNetworkInterface", PROBE_SUBNET, VIA_EFS),
write(
"ec2:DeleteNetworkInterface",
PROBE_NETWORK_INTERFACE,
VIA_AWS_SERVICE,
VIA_EFS,
),
read("ec2:DescribeSecurityGroups", "*", &[]),
read("ec2:DescribeVpcs", "*", &[]),
@@ -679,17 +700,6 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[
write("logs:DeleteRetentionPolicy", PROBE_LOG_GROUP, &[]),
read("logs:ListTagsForResource", PROBE_LOG_GROUP, &[]),
read("logs:DescribeLogGroups", "*", &[]),
write(
"ecr:CreateRepository",
"arn:aws:ecr:{region}:{account}:repository/triple-c/probe",
&[],
),
write(
"ecr:DeleteRepository",
"arn:aws:ecr:{region}:{account}:repository/triple-c/probe",
&[],
),
write("ecr:CreatePullThroughCacheRule", "*", &[]),
write("ssm:PutParameter", PROBE_PARAMETER, COMPONENT_REQUESTED),
write(
"ssm:AddTagsToResource",
@@ -705,16 +715,24 @@ 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),
write("s3:PutBucketVersioning", PROBE_STATE_BUCKET, &[]),
write("s3:PutEncryptionConfiguration", PROBE_STATE_BUCKET, &[]),
write("s3:PutBucketPublicAccessBlock", PROBE_STATE_BUCKET, &[]),
write("s3:PutBucketTagging", PROBE_STATE_BUCKET, &[]),
write("s3:PutBucketPolicy", PROBE_STATE_BUCKET, &[]),
read("s3:GetBucketVersioning", PROBE_STATE_BUCKET, &[]),
read("s3:ListBucket", PROBE_STATE_BUCKET, &[]),
read("s3:GetObject", PROBE_STATE_OBJECT, &[]),
write("s3:PutObject", PROBE_STATE_OBJECT, &[]),
write("s3:DeleteObject", PROBE_STATE_OBJECT, &[]),
write("s3:PutBucketVersioning", PROBE_STATE_BUCKET, IN_MY_ACCOUNT),
write(
"s3:PutEncryptionConfiguration",
PROBE_STATE_BUCKET,
IN_MY_ACCOUNT,
),
write(
"s3:PutBucketPublicAccessBlock",
PROBE_STATE_BUCKET,
IN_MY_ACCOUNT,
),
write("s3:PutBucketTagging", PROBE_STATE_BUCKET, IN_MY_ACCOUNT),
write("s3:PutBucketPolicy", PROBE_STATE_BUCKET, IN_MY_ACCOUNT),
read("s3:GetBucketVersioning", PROBE_STATE_BUCKET, IN_MY_ACCOUNT),
read("s3:ListBucket", PROBE_STATE_BUCKET, IN_MY_ACCOUNT),
read("s3:GetObject", PROBE_STATE_OBJECT, IN_MY_ACCOUNT),
write("s3:PutObject", PROBE_STATE_OBJECT, IN_MY_ACCOUNT),
write("s3:DeleteObject", PROBE_STATE_OBJECT, IN_MY_ACCOUNT),
];
#[cfg(test)]
@@ -1676,45 +1694,76 @@ mod tests {
};
assert!(allowed(tier, &tag_at("CreateVpc"), ME, ME));
assert!(!allowed(tier, &tag_at("RunInstances"), ME, ME));
// A mount target's interface is EFS's doing, never a direct call.
// A mount target's interface is EFS's doing, never a direct call, nor
// another service's.
for action in ["ec2:CreateNetworkInterface", "ec2:DeleteNetworkInterface"] {
assert!(
!allowed(tier, &write(action, PROBE_NETWORK_INTERFACE, &[]), ME, ME),
"{action}"
);
let via_lambda: Context = &[("aws:CalledViaLast", Literal("lambda.amazonaws.com"))];
assert!(
!allowed(
tier,
&write(action, PROBE_NETWORK_INTERFACE, via_lambda),
ME,
ME
),
"{action} via another service"
);
}
// No bucket may be deleted, and only the state bucket is managed.
// Only the state bucket is managed, and only in the caller's account
// (the name is predictable, so another account could pre-create it).
const OTHER_BUCKET: &str = "arn:aws:s3:::someone-elses-bucket";
const OTHER_ACCOUNT: Context = &[
("s3:ResourceAccount", Literal("999999999999")),
("aws:PrincipalAccount", Literal("{account}")),
];
const OTHER_ACCOUNT_REQUESTED: Context = &[
("aws:RequestTag/triple-c:component", Literal("state")),
("s3:ResourceAccount", Literal("999999999999")),
("aws:PrincipalAccount", Literal("{account}")),
];
// No bucket may be deleted.
for bucket in [PROBE_STATE_BUCKET, OTHER_BUCKET] {
for context in [&[][..], COMPONENT_TAGGED] {
for context in [&[][..], COMPONENT_TAGGED, IN_MY_ACCOUNT] {
assert!(
!allowed(tier, &write("s3:DeleteBucket", bucket, context), ME, ME),
"s3:DeleteBucket on {bucket}"
);
}
}
for action in [
"s3:PutBucketPolicy",
"s3:PutBucketVersioning",
"s3:PutEncryptionConfiguration",
"s3:PutBucketPublicAccessBlock",
"s3:PutBucketTagging",
] {
let state_calls = [
("s3:PutBucketPolicy", PROBE_STATE_BUCKET),
("s3:PutBucketVersioning", PROBE_STATE_BUCKET),
("s3:PutEncryptionConfiguration", PROBE_STATE_BUCKET),
("s3:PutBucketPublicAccessBlock", PROBE_STATE_BUCKET),
("s3:PutBucketTagging", PROBE_STATE_BUCKET),
("s3:ListBucket", PROBE_STATE_BUCKET),
("s3:GetObject", PROBE_STATE_OBJECT),
("s3:PutObject", PROBE_STATE_OBJECT),
("s3:DeleteObject", PROBE_STATE_OBJECT),
];
for (action, resource) in state_calls {
assert!(
allowed(tier, &write(action, PROBE_STATE_BUCKET, &[]), ME, ME),
allowed(tier, &write(action, resource, IN_MY_ACCOUNT), ME, ME),
"{action}"
);
assert!(
!allowed(tier, &write(action, OTHER_BUCKET, &[]), ME, ME),
"{action}"
for context in [&[][..], OTHER_ACCOUNT] {
assert!(
!allowed(tier, &write(action, resource, context), ME, ME),
"{action} on a state bucket with {context:?}"
);
}
let elsewhere = resource.replace(
"triple-c-tfstate-{account}-{region}",
"someone-elses-bucket",
);
let elsewhere: &'static str = Box::leak(elsewhere.into_boxed_str());
assert!(
!allowed(tier, &write(action, elsewhere, IN_MY_ACCOUNT), ME, ME),
"{action} on {elsewhere}"
);
}
for action in ["s3:PutObject", "s3:DeleteObject", "s3:GetObject"] {
let check = Check {
write: action != "s3:GetObject",
..write(action, "arn:aws:s3:::someone-elses-bucket/key", &[])
};
assert!(!allowed(tier, &check, ME, ME), "{action}");
}
assert!(allowed(
tier,
@@ -1722,15 +1771,88 @@ mod tests {
ME,
ME
));
assert!(!allowed(
for (bucket, context) in [
(PROBE_STATE_BUCKET, IN_MY_ACCOUNT),
(PROBE_STATE_BUCKET, OTHER_ACCOUNT_REQUESTED),
(OTHER_BUCKET, STATE_REQUESTED),
] {
assert!(
!allowed(tier, &write("s3:CreateBucket", bucket, context), ME, ME),
"s3:CreateBucket {bucket} with {context:?}"
);
}
}
/// Network resources are created only inside the environment's own
/// (component-tagged) VPC, subnets and addresses: a tagged subnet,
/// route table, gateway or NAT gateway in someone else's VPC would
/// re-route it. The one exception is a security group, which an
/// `existing`-network environment creates in the user's own VPC.
#[test]
fn network_creates_need_a_triple_c_parent() {
let tier = Tier::EnvironmentAdmin;
for (action, parent) in [
("ec2:CreateSubnet", PROBE_VPC),
("ec2:CreateRouteTable", PROBE_VPC),
("ec2:CreateNatGateway", PROBE_VPC),
("ec2:CreateNatGateway", PROBE_SUBNET),
("ec2:CreateNatGateway", PROBE_ELASTIC_IP),
] {
assert!(
allowed(
tier,
&write(action, parent, REQUESTED_IN_TRIPLE_C_PARENT),
ME,
ME
),
"{action} in a Triple-C {parent}"
);
assert!(
!allowed(tier, &write(action, parent, COMPONENT_REQUESTED), ME, ME),
"{action} in an untagged {parent}"
);
}
// Attaching a gateway (and routing through it) needs the VPC tagged.
for (action, resource) in [
("ec2:AttachInternetGateway", PROBE_VPC),
("ec2:CreateRoute", PROBE_ROUTE_TABLE),
("ec2:AssociateRouteTable", PROBE_ROUTE_TABLE),
("ec2:AssociateRouteTable", PROBE_SUBNET),
] {
assert!(
!allowed(tier, &write(action, resource, COMPONENT_REQUESTED), ME, ME),
"{action} on an untagged {resource}"
);
}
assert!(allowed(
tier,
&write("s3:CreateBucket", PROBE_STATE_BUCKET, &[]),
&write("ec2:CreateSecurityGroup", PROBE_VPC, COMPONENT_REQUESTED),
ME,
ME
));
assert!(!allowed(
tier,
&write("s3:CreateBucket", OTHER_BUCKET, STATE_REQUESTED),
&write("ec2:CreateSecurityGroup", PROBE_VPC, &[]),
ME,
ME
));
// 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(
tier,
&write(
"ec2:ReplaceRouteTableAssociation",
PROBE_ROUTE_TABLE,
COMPONENT_TAGGED
),
ME,
ME
));
// Triple-C roles are passed only to ECS tasks (Fargate, v1).
let pass_to_ec2: Context = &[("iam:PassedToService", Literal("ec2.amazonaws.com"))];
assert!(!allowed(
tier,
&write("iam:PassRole", PROBE_ROLE, pass_to_ec2),
ME,
ME
));
-29
View File
@@ -201,8 +201,6 @@ pub(crate) fn conditions_hold(stmt: &Value, req: &Request) -> bool {
// otherwise when no value matches.
let (matches, negated): (fn(&str, &str) -> bool, bool) = match base {
"StringEquals" => (|p, v| p == v, false),
// A boolean key's value is the string "true" or "false".
"Bool" => (|p, v| p == v, false),
"StringLike" | "ArnLike" => (glob, false),
"StringNotEquals" => (|p, v| p == v, true),
"StringNotLike" => (glob, true),
@@ -290,31 +288,4 @@ mod tests {
("aws:TagKeys", Literal("c"))
])));
}
#[test]
fn bool_compares_the_values_string() {
let stmt: Value = serde_json::json!({
"Sid": "T", "Effect": "Allow", "Action": "x:Y", "Resource": "*",
"Condition": { "Bool": { "aws:ViaAWSService": "true" } }
});
let holds = |context| {
let c = Check {
action: "x:Y",
resource: "*",
context,
write: true,
};
conditions_hold(
&stmt,
&Request {
check: &c,
caller: ME,
owner: ME,
},
)
};
assert!(holds(&[("aws:ViaAWSService", Literal("true"))]));
assert!(!holds(&[("aws:ViaAWSService", Literal("false"))]));
assert!(!holds(&[]));
}
}
+101 -19
View File
@@ -1340,7 +1340,9 @@ fn the_record_is_format_checked_before_use() {
// it shows missing is added here, to the policy, to the check table
// (`policies.rs`) and to `aws/README.md` together.
use crate::aws::policies::{permissions_boundary_json, policy_files, Check, ContextValue, Tier};
use crate::aws::policies::{
checks, permissions_boundary_json, policy_files, Check, ContextValue, Tier,
};
use crate::aws::policy_eval as eval;
type Context = &'static [(&'static str, ContextValue)];
@@ -1404,8 +1406,17 @@ const BOUNDED: Context = &[
),
("aws:PrincipalAccount", Literal("{account}")),
];
/// Created inside an environment-module VPC, subnet or address: the new
/// resource carries the request tag, its parent the component tag.
const ENV_CREATE_IN_ENV: Context = &[
(REQUEST_COMPONENT, Literal("environment")),
(RESOURCE_COMPONENT, Literal("environment")),
];
/// A call EFS makes on the caller's behalf (a mount target's interface).
const VIA_SERVICE: Context = &[("aws:ViaAWSService", Literal("true"))];
const VIA_SERVICE: Context = &[(
"aws:CalledViaLast",
Literal("elasticfilesystem.amazonaws.com"),
)];
const NONE: Context = &[];
macro_rules! created_by {
@@ -1414,18 +1425,24 @@ macro_rules! created_by {
};
}
/// The state bucket is in the caller's own account.
const IN_ACCOUNT: Context = &[
("s3:ResourceAccount", Literal("{account}")),
("aws:PrincipalAccount", Literal("{account}")),
];
/// The S3 backend's calls on one deployment's state and lock objects.
const BACKEND: &[Check] = &[
call("s3:ListBucket", STATE_BUCKET, NONE),
call("s3:GetObject", ENV_STATE, NONE),
call("s3:PutObject", ENV_STATE, NONE),
call("s3:GetObject", ENV_LOCK, NONE),
call("s3:PutObject", ENV_LOCK, NONE),
call("s3:DeleteObject", ENV_LOCK, NONE),
call("s3:GetObject", USER_STATE, NONE),
call("s3:PutObject", USER_STATE, NONE),
call("s3:PutObject", USER_LOCK, NONE),
call("s3:DeleteObject", USER_LOCK, NONE),
call("s3:ListBucket", STATE_BUCKET, IN_ACCOUNT),
call("s3:GetObject", ENV_STATE, IN_ACCOUNT),
call("s3:PutObject", ENV_STATE, IN_ACCOUNT),
call("s3:GetObject", ENV_LOCK, IN_ACCOUNT),
call("s3:PutObject", ENV_LOCK, IN_ACCOUNT),
call("s3:DeleteObject", ENV_LOCK, IN_ACCOUNT),
call("s3:GetObject", USER_STATE, IN_ACCOUNT),
call("s3:PutObject", USER_STATE, IN_ACCOUNT),
call("s3:PutObject", USER_LOCK, IN_ACCOUNT),
call("s3:DeleteObject", USER_LOCK, IN_ACCOUNT),
];
const MODULE_CALLS: &[(&str, &[Check])] = &[
@@ -1462,6 +1479,8 @@ const MODULE_CALLS: &[(&str, &[Check])] = &[
"aws_security_group",
&[
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),
call("ec2:CreateTags", SG, created_by!("CreateSecurityGroup")),
call("ec2:DescribeSecurityGroups", "*", NONE),
@@ -1582,7 +1601,7 @@ const MODULE_CALLS: &[(&str, &[Check])] = &[
"aws_subnet",
&[
call("ec2:CreateSubnet", SUBNET, ENV_CREATE),
call("ec2:CreateSubnet", VPC, ENV_CREATE),
call("ec2:CreateSubnet", VPC, ENV_CREATE_IN_ENV),
call("ec2:CreateTags", SUBNET, created_by!("CreateSubnet")),
call("ec2:DescribeSubnets", "*", NONE),
call("ec2:ModifySubnetAttribute", SUBNET, ENV_TAGGED),
@@ -1596,7 +1615,7 @@ const MODULE_CALLS: &[(&str, &[Check])] = &[
"aws_route_table",
&[
call("ec2:CreateRouteTable", RTB, ENV_CREATE),
call("ec2:CreateRouteTable", VPC, ENV_CREATE),
call("ec2:CreateRouteTable", VPC, ENV_CREATE_IN_ENV),
call("ec2:CreateTags", RTB, created_by!("CreateRouteTable")),
call("ec2:DescribeRouteTables", "*", NONE),
call("ec2:CreateTags", RTB, ENV_TAGGED),
@@ -1615,13 +1634,14 @@ const MODULE_CALLS: &[(&str, &[Check])] = &[
),
(
// Untaggable: authorized against the route table and the subnet.
// Replacing an association is never allowed (it could swap a VPC's
// main route table), so a changed route_table_id fails rather than
// re-associating; the modules never change it.
"aws_route_table_association",
&[
call("ec2:AssociateRouteTable", RTB, ENV_TAGGED),
call("ec2:AssociateRouteTable", SUBNET, ENV_TAGGED),
call("ec2:DescribeRouteTables", "*", NONE),
call("ec2:ReplaceRouteTableAssociation", RTB, ENV_TAGGED),
call("ec2:ReplaceRouteTableAssociation", SUBNET, ENV_TAGGED),
call("ec2:DisassociateRouteTable", RTB, ENV_TAGGED),
call("ec2:DisassociateRouteTable", SUBNET, ENV_TAGGED),
],
@@ -1642,9 +1662,9 @@ const MODULE_CALLS: &[(&str, &[Check])] = &[
"aws_nat_gateway",
&[
call("ec2:CreateNatGateway", NAT, ENV_CREATE),
call("ec2:CreateNatGateway", SUBNET, ENV_CREATE),
call("ec2:CreateNatGateway", EIP, ENV_CREATE),
call("ec2:CreateNatGateway", VPC, ENV_CREATE),
call("ec2:CreateNatGateway", SUBNET, ENV_CREATE_IN_ENV),
call("ec2:CreateNatGateway", EIP, ENV_CREATE_IN_ENV),
call("ec2:CreateNatGateway", VPC, ENV_CREATE_IN_ENV),
call("ec2:CreateTags", NAT, created_by!("CreateNatGateway")),
call("ec2:DescribeNatGateways", "*", NONE),
call("ec2:CreateTags", NAT, ENV_TAGGED),
@@ -1948,3 +1968,65 @@ fn every_onboarding_role_stays_inside_the_boundary() {
}
assert!(seen >= 15, "only {seen} role grants checked");
}
/// Admin write checks for calls the app makes itself, not the modules.
const APP_ONLY_WRITES: &[&str] = &[
// The state bucket's setup (the backend only reads and writes objects).
"s3:CreateBucket",
"s3:PutBucketVersioning",
"s3:PutEncryptionConfiguration",
"s3:PutBucketPublicAccessBlock",
"s3:PutBucketTagging",
"s3:PutBucketPolicy",
// Workspaces (phase 5): task definitions and the roles they name.
"ecs:RegisterTaskDefinition",
"ecs:DeregisterTaskDefinition",
"iam:PassRole",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
// The permission check itself.
"iam:SimulatePrincipalPolicy",
];
fn is_write(action: &str) -> bool {
let verb = action.split(':').nth(1).unwrap();
!["Describe", "List", "Get"]
.iter()
.any(|p| verb.starts_with(p))
}
/// The module table and the check table can't drift: every write a module
/// makes is something the permission check asks about, and every admin write
/// check is a module's call or one of the app's own.
#[test]
fn every_module_write_has_an_admin_check_and_vice_versa() {
let checked: BTreeSet<&str> = checks(Tier::EnvironmentAdmin)
.iter()
.map(|c| c.action)
.filter(|a| is_write(a))
.collect();
let module: BTreeSet<&str> = MODULE_CALLS
.iter()
.flat_map(|(_, calls)| calls.iter().map(|c| c.action))
.filter(|a| is_write(a))
.collect();
let unchecked: Vec<_> = module.difference(&checked).collect();
assert!(
unchecked.is_empty(),
"module writes without an admin check: {unchecked:?}"
);
let app_only: BTreeSet<&str> = APP_ONLY_WRITES.iter().copied().collect();
let stray: Vec<_> = checked
.iter()
.filter(|a| !module.contains(*a) && !app_only.contains(*a))
.collect();
assert!(
stray.is_empty(),
"admin write checks no module or app call needs: {stray:?}"
);
let stale: Vec<_> = app_only
.iter()
.filter(|a| module.contains(*a) || !checked.contains(*a))
.collect();
assert!(stale.is_empty(), "APP_ONLY_WRITES is stale: {stale:?}");
}
+40 -26
View File
@@ -90,12 +90,15 @@ and nothing runs with more than the admin holds. It covers:
- the per-account state bucket `triple-c-tfstate-<account>-<region>` (created by the app, tagged
`triple-c:component=state`, then read and written by OpenTofu's S3 backend, lock files included);
- `triple-c-*` IAM roles, and reading the `triple-c-permissions-boundary` policy;
- ECS clusters (and capacity providers and task definitions);
- ECS clusters (Fargate and Fargate Spot only) and task definitions;
- the network: VPCs, subnets, an internet gateway, route tables and routes, an Elastic IP and NAT
gateway, security groups and their rules (`environment-admin-network.json`);
- EFS file systems, mount targets and file-system policies, and KMS keys and aliases;
- `/triple-c/*` log groups and `/triple-c/*` SSM parameters;
- ECR repositories and pull-through cache rules.
- `/triple-c/*` log groups and `/triple-c/*` SSM parameters.
Not in v1, and so not in the policy: ECR (tasks pull from GHCR), custom ECS capacity providers,
and passing roles to EC2 (`iam:PassRole` is to `ecs-tasks.amazonaws.com` only). Phase 7's EC2
capacity adds them back.
### What each module resource needs
@@ -116,25 +119,32 @@ to the table, the policy, the check table in `policies.rs` and this list togethe
| `aws_ssm_parameter` | `ssm:PutParameter`, `AddTagsToResource`, `GetParameter`, `ListTagsForResource`, `RemoveTagsFromResource`, `DeleteParameter` (`/triple-c/*`), `DescribeParameters` (`*`) |
| `aws_vpc` | `ec2:CreateVpc` (request tag), `CreateTags` (at create), `ModifyVpcAttribute` (DNS hostnames), `CreateTags`/`DeleteTags`, `DeleteVpc` (tagged), `Describe*` |
| `aws_internet_gateway` | `ec2:CreateInternetGateway`, `CreateTags` (at create), `Attach`/`DetachInternetGateway` (tagged gateway and VPC), `DeleteInternetGateway`, `Describe*` |
| `aws_subnet` | `ec2:CreateSubnet` (subnet and VPC, request tag), `CreateTags` (at create), `ModifySubnetAttribute`, `DeleteSubnet` (tagged), `Describe*` |
| `aws_route_table` | `ec2:CreateRouteTable` (table and VPC, request tag), `CreateTags` (at create), `DeleteRouteTable` (tagged), `Describe*` |
| `aws_subnet` | `ec2:CreateSubnet` (subnet: request tag; VPC: tagged), `CreateTags` (at create), `ModifySubnetAttribute`, `DeleteSubnet` (tagged), `Describe*` |
| `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`, `ReplaceRouteTableAssociation`, `DisassociateRouteTable` (the tagged route table and subnet; 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_nat_gateway` | `ec2:CreateNatGateway` (gateway, subnet, address and VPC, request tag), `CreateTags` (at create), `DeleteNatGateway` (tagged), `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:ViaAWSService`) |
| `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_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) |
| 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`,
`PutBucketTagging`, `PutBucketPolicy`, `GetBucket*` and `ListBucket`, all on
`triple-c-tfstate-*` only. No bucket can be deleted.
Every state-bucket statement also requires `s3:ResourceAccount = ${aws:PrincipalAccount}`: the
bucket name is predictable, so another account could create `triple-c-tfstate-<your account>-…`
first and collect (or feed) your state. With the condition, the admin's calls on a bucket owned by
any other account are denied, so the app fails rather than writes state there. (The Service
Authorization Reference lists `s3:ResourceAccount` for `CreateBucket` as well as the bucket and
object actions; Task 8's live run confirms it.)
It is fenced in the following ways.
**Roles are bounded.** The admin can create a `triple-c-*` role, or change its policies, only when
@@ -163,7 +173,10 @@ administrator.
**Destruction is tag-scoped.** VPCs, subnets, internet gateways, route tables, Elastic IPs, NAT
gateways, security groups, EFS file systems and KMS keys can be created only with a
`triple-c:component` tag (the modules' `default_tags` put it on every create request). They can be
changed or deleted only if they carry that tag. Tagging is allowed only as part of the creating
changed or deleted only if they carry that tag. Subnets, route tables and NAT gateways can be
created only *inside* a Triple-C (tagged) VPC, subnet and address, and an internet gateway
attached, a route added or a route table associated only on tagged ones, so the admin cannot
re-route a VPC that isn't Triple-C's. Tagging is allowed only as part of the creating
call (`ec2:CreateAction`, `elasticfilesystem:CreateAction`), or on a resource that is already
Triple-C's. Resources that cannot carry a tag are authorized through one that does: routes and
route-table associations through the route table (and subnet), mount targets and the file-system
@@ -177,23 +190,24 @@ account-wide:
- `ecs:RegisterTaskDefinition` and `ecs:DeregisterTaskDefinition` on `*`. The admin can deregister
*any* task definition revision in the account. `DeregisterTaskDefinition` has no resource type
(IAM authorizes it against `*`), so it cannot be narrowed.
- `ecr:CreatePullThroughCacheRule` on `*`, which also has no resource type. The admin can add
pull-through cache rules for any prefix. This cannot delete or overwrite anything.
- `elasticfilesystem:CreateFileSystem` on `*` (no resource type), still only with a
`triple-c:component` request tag.
- Security groups, subnets, route tables and NAT gateways can be created in **any** VPC or subnet,
as long as the new resource is tagged: the request tag, not the parent's tag, is what the create
statement checks (an `existing`-network environment's VPC is the user's own and untagged).
- `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.
- `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 an AWS
service makes the call for the admin (`aws:ViaAWSService`): EFS creates and deletes a mount
target's interface with the caller's EC2 permissions.
- `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.
- Reads on `*`: `ec2:Describe*`, `elasticfilesystem:Describe*`, the KMS key reads and
`ListAliases`, `logs:DescribeLogGroups` and `ssm:DescribeParameters`.
- `s3:PutBucketPolicy` on the state bucket: an admin could open their own account's state bucket to
others. The state holds no secrets (the modules create none), but it maps the account's
Triple-C resources.
- `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.
## Invariants the modules must keep (phase 4)
@@ -235,8 +249,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 6,080 (60) and
`environment-admin-network.json` about 2,370; a unit test (`every_policy_fits_a_managed_policy`)
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`)
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.
@@ -261,7 +275,8 @@ aws iam attach-user-policy --user-name <user> \
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:ViaAWSService`, and whether `s3:CreateBucket` honours
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.
- **Admin access to user data.** The admin can retag `triple-c-*` roles (`iam:TagRole`). The
@@ -338,5 +353,4 @@ Still open for the phases that create the resources: `RunTask`'s tag keys and `i
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. (Checked with the simulator: a `Bool` condition matches a context entry sent
as a string, which is how the permission check sends `aws:ViaAWSService`.)
are unit-tested only.
+82 -10
View File
@@ -2,23 +2,62 @@
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CreateTaggedNetwork",
"Sid": "CreateTaggedVpcs",
"Effect": "Allow",
"Action": [
"ec2:CreateVpc"
],
"Resource": [
"arn:aws:ec2:*:*:vpc/*"
],
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
}
}
},
{
"Sid": "CreateTaggedSubnets",
"Effect": "Allow",
"Action": [
"ec2:CreateSubnet"
],
"Resource": [
"arn:aws:ec2:*:*:subnet/*"
],
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
}
}
},
{
"Sid": "AllocateTaggedAddresses",
"Effect": "Allow",
"Action": [
"ec2:AllocateAddress"
],
"Resource": [
"arn:aws:ec2:*:*:elastic-ip/*"
],
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
}
}
},
{
"Sid": "CreateOtherTaggedNetwork",
"Effect": "Allow",
"Action": [
"ec2:CreateVpc",
"ec2:CreateSubnet",
"ec2:CreateInternetGateway",
"ec2:CreateRouteTable",
"ec2:AllocateAddress",
"ec2:CreateNatGateway",
"ec2:CreateSecurityGroup"
],
"Resource": [
"arn:aws:ec2:*:*:vpc/*",
"arn:aws:ec2:*:*:subnet/*",
"arn:aws:ec2:*:*:internet-gateway/*",
"arn:aws:ec2:*:*:route-table/*",
"arn:aws:ec2:*:*:elastic-ip/*",
"arn:aws:ec2:*:*:natgateway/*",
"arn:aws:ec2:*:*:security-group/*"
],
@@ -28,6 +67,40 @@
}
}
},
{
"Sid": "CreateOnlyInTripleCParents",
"Effect": "Allow",
"Action": [
"ec2:CreateSubnet",
"ec2:CreateRouteTable",
"ec2:CreateNatGateway"
],
"Resource": [
"arn:aws:ec2:*:*:vpc/*",
"arn:aws:ec2:*:*:subnet/*",
"arn:aws:ec2:*:*:elastic-ip/*"
],
"Condition": {
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "CreateSecurityGroupsInAnyVpc",
"Effect": "Allow",
"Action": [
"ec2:CreateSecurityGroup"
],
"Resource": [
"arn:aws:ec2:*:*:vpc/*"
],
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
}
}
},
{
"Sid": "TagNetworkAtCreate",
"Effect": "Allow",
@@ -73,7 +146,6 @@
"ec2:ReplaceRoute",
"ec2:DeleteRoute",
"ec2:AssociateRouteTable",
"ec2:ReplaceRouteTableAssociation",
"ec2:DisassociateRouteTable",
"ec2:ReleaseAddress",
"ec2:DeleteNatGateway",
@@ -120,8 +192,8 @@
"*"
],
"Condition": {
"Bool": {
"aws:ViaAWSService": "true"
"StringEquals": {
"aws:CalledViaLast": "elasticfilesystem.amazonaws.com"
}
}
},
+16 -39
View File
@@ -50,10 +50,7 @@
],
"Condition": {
"StringEquals": {
"iam:PassedToService": [
"ecs-tasks.amazonaws.com",
"ec2.amazonaws.com"
]
"iam:PassedToService": "ecs-tasks.amazonaws.com"
}
}
},
@@ -136,17 +133,6 @@
"arn:aws:ecs:*:*:cluster/triple-c-*"
]
},
{
"Sid": "ManageCapacityProviders",
"Effect": "Allow",
"Action": [
"ecs:CreateCapacityProvider",
"ecs:DeleteCapacityProvider"
],
"Resource": [
"arn:aws:ecs:*:*:capacity-provider/triple-c-*"
]
},
{
"Sid": "ManageTaskDefinitions",
"Effect": "Allow",
@@ -278,28 +264,6 @@
"arn:aws:logs:*:*:log-group:/triple-c/*"
]
},
{
"Sid": "ManageRepositories",
"Effect": "Allow",
"Action": [
"ecr:CreateRepository",
"ecr:DeleteRepository"
],
"Resource": [
"arn:aws:ecr:*:*:repository/triple-c-*",
"arn:aws:ecr:*:*:repository/triple-c/*"
]
},
{
"Sid": "CreatePullThroughCacheRules",
"Effect": "Allow",
"Action": [
"ecr:CreatePullThroughCacheRule"
],
"Resource": [
"*"
]
},
{
"Sid": "ManageEnvironmentParameters",
"Effect": "Allow",
@@ -346,6 +310,9 @@
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
},
"StringEquals": {
"s3:ResourceAccount": "${aws:PrincipalAccount}"
}
}
},
@@ -363,7 +330,12 @@
],
"Resource": [
"arn:aws:s3:::triple-c-tfstate-*"
]
],
"Condition": {
"StringEquals": {
"s3:ResourceAccount": "${aws:PrincipalAccount}"
}
}
},
{
"Sid": "ReadAndWriteState",
@@ -375,7 +347,12 @@
],
"Resource": [
"arn:aws:s3:::triple-c-tfstate-*/*"
]
],
"Condition": {
"StringEquals": {
"s3:ResourceAccount": "${aws:PrincipalAccount}"
}
}
}
]
}