fix(aws): user secrets on the environment key; no component tag from workspace users

- CreateOwnSecrets requires secretsmanager:KmsKeyId to be a key ARN, and a
  workspace user's kms:Encrypt/GenerateDataKey (via Secrets Manager) only
  works on component-tagged keys, so secrets can't land on the default
  aws/secretsmanager key that the boundary can't decrypt.
- Every workspace statement that sets the owner tag limits aws:TagKeys to
  triple-c:owner/project/environment (ForAllValues:StringEquals), so a user
  can never set triple-c:component.
- Evaluator: multi-valued context keys and ForAllValues (vacuous when
  absent); Check documents repeated keys as one StringList entry.
- README: environment-key invariant and new verify-live entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-14 16:31:38 -08:00
1 parent 8e6983cf53
commit 9d1630eb97
3 files changed
+282 -12

No files matched your search

+205 -10
View File
@@ -55,6 +55,8 @@ pub struct Check {
pub action: &'static str,
/// An ARN template ([`render`]).
pub resource: &'static str,
/// Context entries. A key that appears more than once is a multi-valued
/// key (e.g. `aws:TagKeys`) and is sent as one `StringList` entry.
pub context: &'static [(&'static str, ContextValue)],
pub write: bool,
}
@@ -208,10 +210,15 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[
PROBE_SESSION,
&[(SESSION_OWNER, OwnerId)],
),
// Encrypted with an environment key, never the default
// `aws/secretsmanager` key (which can't carry `triple-c:component`).
write(
"secretsmanager:CreateSecret",
PROBE_SECRET,
&[(OWNER_REQUEST_TAG, OwnerId)],
&[
(OWNER_REQUEST_TAG, OwnerId),
("secretsmanager:KmsKeyId", Literal(PROBE_KEY)),
],
),
// Tag-on-create: the secret has no tags yet, so only the request tag.
write(
@@ -257,18 +264,24 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[
write(
"kms:Encrypt",
PROBE_KEY,
&[(
"kms:ViaService",
Literal("secretsmanager.{region}.amazonaws.com"),
)],
&[
(
"kms:ViaService",
Literal("secretsmanager.{region}.amazonaws.com"),
),
(COMPONENT_RESOURCE_TAG, Literal("environment")),
],
),
write(
"kms:GenerateDataKey",
PROBE_KEY,
&[(
"kms:ViaService",
Literal("secretsmanager.{region}.amazonaws.com"),
)],
&[
(
"kms:ViaService",
Literal("secretsmanager.{region}.amazonaws.com"),
),
(COMPONENT_RESOURCE_TAG, Literal("environment")),
],
),
read("iam:GetRole", "{principal}", OWN_PRINCIPAL),
read("iam:SimulatePrincipalPolicy", "{principal}", OWN_PRINCIPAL),
@@ -558,14 +571,20 @@ mod tests {
impl Request<'_> {
fn context_value(&self, key: &str) -> Option<String> {
self.context_values(key).into_iter().next()
}
/// All values of a (possibly multi-valued) key.
fn context_values(&self, key: &str) -> Vec<String> {
self.check
.context
.iter()
.find(|(k, _)| *k == key)
.filter(|(k, _)| *k == key)
.map(|(_, v)| match v {
ContextValue::OwnerId => self.owner.to_string(),
ContextValue::Literal(l) => render(l, &AT),
})
.collect()
}
/// Resolves `${…}` policy variables the way the simulator does: from
@@ -617,6 +636,24 @@ mod tests {
let cond = cond.as_object().expect("Condition is an object");
cond.iter().all(|(op, entries)| {
let entries = entries.as_object().expect("condition block is an object");
// ForAllValues: every value of the (multi-valued) key matches
// one of the policy's values. Vacuously true when the key is
// absent, which is why statements pair it with a required key.
if let Some(base) = op.strip_prefix("ForAllValues:") {
assert_eq!(
base,
"StringEquals",
"{}: unsupported ForAllValues operator",
sid(stmt)
);
return entries.iter().all(|(key, want)| {
let allowed: Vec<String> = strings(want)
.iter()
.filter_map(|p| req.resolve(p))
.collect();
req.context_values(key).iter().all(|v| allowed.contains(v))
});
}
if op == "Null" {
return entries.iter().all(|(key, want)| {
let absent = req.context_value(key).is_none();
@@ -1498,4 +1535,162 @@ mod tests {
assert!(denied(&write(action, PROBE_STACK, WITH_SERVICE_ROLE)));
}
}
const ALLOWED_TAG_KEYS: [&str; 3] =
["triple-c:owner", "triple-c:project", "triple-c:environment"];
/// A check like `c` whose request also carries these tag keys.
fn with_tag_keys(c: &Check, keys: &[&'static str]) -> Check {
let mut context: Vec<(&'static str, ContextValue)> = c.context.to_vec();
context.extend(keys.iter().map(|k| ("aws:TagKeys", Literal(k))));
Check {
context: Box::leak(context.into_boxed_slice()),
..*c
}
}
/// The workspace checks that set tags: those carrying the owner request tag.
fn tagging_checks() -> Vec<&'static Check> {
let found: Vec<_> = checks(Tier::WorkspaceUser)
.iter()
.filter(|c| c.context.contains(&(OWNER_REQUEST_TAG, OwnerId)))
.collect();
assert!(
found.len() >= 6,
"expected the six tagging checks, got {found:?}"
);
found
}
/// Every workspace statement that lets a user set the owner tag also
/// limits which tag keys they may set.
#[test]
fn workspace_tagging_statements_restrict_tag_keys() {
for s in allows(Tier::WorkspaceUser) {
let sets_owner = s["Condition"]
.as_object()
.is_some_and(|ops| ops.values().any(|e| e.get(OWNER_REQUEST_TAG).is_some()));
if !sets_owner {
continue;
}
let mut got = strings(&s["Condition"]["ForAllValues:StringEquals"]["aws:TagKeys"]);
got.sort();
let mut want: Vec<String> = ALLOWED_TAG_KEYS.iter().map(|k| k.to_string()).collect();
want.sort();
assert_eq!(got, want, "{}: aws:TagKeys", sid(&s));
}
}
/// `triple-c:component` marks environment resources, which detection
/// and the Denies trust; a workspace user must never be able to set it.
#[test]
fn workspace_users_cannot_set_the_component_tag() {
let tier = Tier::WorkspaceUser;
for c in tagging_checks() {
let spoofed = with_tag_keys(c, &["triple-c:owner", "triple-c:component"]);
assert!(
!allowed(tier, &spoofed, ME, ME),
"{} on {} allowed with a triple-c:component tag",
c.action,
c.resource
);
let ordinary = with_tag_keys(c, &["triple-c:owner", "triple-c:project"]);
assert!(
allowed(tier, &ordinary, ME, ME),
"{} with owner and project tags",
c.action
);
// The key restriction is not what grants: without the owner
// request tag, allowed keys alone are still denied.
let ownerless = Check {
context: Box::leak(
ordinary
.context
.iter()
.filter(|(k, _)| *k != OWNER_REQUEST_TAG)
.copied()
.collect::<Vec<_>>()
.into_boxed_slice(),
),
..ordinary
};
assert!(
!allowed(tier, &ownerless, ME, ME),
"{} allowed without the owner request tag",
c.action
);
}
}
#[test]
fn user_secrets_use_an_environment_key() {
let tier = Tier::WorkspaceUser;
let create = checks(tier)
.iter()
.find(|c| c.action == "secretsmanager:CreateSecret")
.unwrap();
for context in [
&[(OWNER_REQUEST_TAG, OwnerId)][..],
&[
(OWNER_REQUEST_TAG, OwnerId),
(
"secretsmanager:KmsKeyId",
Literal("alias/aws/secretsmanager"),
),
][..],
] {
let c = Check { context, ..*create };
assert!(
!allowed(tier, &c, ME, ME),
"CreateSecret allowed with {context:?}"
);
}
for c in checks(tier).iter().filter(|c| c.action.starts_with("kms:")) {
let untagged_key = Check {
context: &[(
"kms:ViaService",
Literal("secretsmanager.{region}.amazonaws.com"),
)],
..*c
};
assert!(
!allowed(tier, &untagged_key, ME, ME),
"{} allowed on a key without triple-c:component",
c.action
);
}
}
#[test]
fn for_all_values_is_vacuous_on_an_absent_key() {
let stmt: Value = serde_json::json!({
"Sid": "T", "Effect": "Allow", "Action": "x:Y", "Resource": "*",
"Condition": { "ForAllValues:StringEquals": { "aws:TagKeys": ["a", "b"] } }
});
let check = |context| Check {
action: "x:Y",
resource: "*",
context,
write: true,
};
let holds = |c: &Check| {
conditions_hold(
&stmt,
&Request {
check: c,
caller: ME,
owner: ME,
},
)
};
assert!(holds(&check(&[])));
assert!(holds(&check(&[
("aws:TagKeys", Literal("a")),
("aws:TagKeys", Literal("b"))
])));
assert!(!holds(&check(&[
("aws:TagKeys", Literal("a")),
("aws:TagKeys", Literal("c"))
])));
}
}
+28 -1
View File
@@ -26,12 +26,23 @@ following:
- **Work with their own tasks.** They can stop them, `ecs execute-command` into them, and open an
SSM session to them. They can end or resume only sessions they started.
- **Manage their own secrets.** They can create, update and delete `triple-c/*` secrets tagged with
their owner ID, and use KMS only through Secrets Manager.
their owner ID.
- A secret must be encrypted with an **environment KMS key**, meaning one tagged
`triple-c:component`. `CreateSecret` must name the key by its full ARN in `KmsKeyId`, and the
user can use KMS (`Encrypt`/`GenerateDataKey`) only through Secrets Manager and only with
environment keys.
- A secret on the default `aws/secretsmanager` key is refused. That key can't carry the tag, so
the workspace's own task role, which is limited by the boundary, couldn't decrypt it.
- A secret tagged `triple-c:component` belongs to the environment. A workspace user can never
tag, untag, overwrite or delete one; an explicit Deny enforces this.
- **Manage their own access points.** They can create and delete EFS access points tagged with
their owner ID, and only on the environment's file system, which is the one tagged
`triple-c:component`.
- **Set only the ordinary tag keys.** Every request that tags something may set only
`triple-c:owner`, `triple-c:project` and `triple-c:environment` (`aws:TagKeys`). This applies to
`RunTask`, the ECS and EFS tag-on-create calls, `CreateSecret`, and secret `TagResource`. A
workspace user can never set `triple-c:component`, which marks environment resources and which
the Denies and phase-4 detection trust.
- **Inspect their own principal.** They can read it (`iam:GetRole`) and simulate it
(`iam:SimulatePrincipalPolicy`). The app's permission check uses both.
- **Read environment information.** This covers ECS, CloudFormation stacks, the `/triple-c/*` SSM
@@ -109,6 +120,8 @@ detection should flag any environment that breaks them.
neither an owner tag nor a component tag could be claimed by any workspace user.
4. **Every environment KMS key and EFS file system carries `triple-c:component`.** The boundary
and the admin tier both rely on this tag.
5. **User secrets use the environment key.** The app must pass the environment KMS key's ARN as
`KmsKeyId` when it creates a secret. The policy refuses any other choice.
## One-time setup: the boundary policy
@@ -190,6 +203,20 @@ a real account:
also authorized against `security-group-rule` resources.
- **Secrets tagging.** Check that `CreateSecret` with tags authorizes `secretsmanager:TagResource`,
and that the `StringEqualsIfExists` and `Null` resource-tag conditions pass at create time.
- **Secret keys.**
- Check that `secretsmanager:KmsKeyId` carries the value exactly as the request gave it, so that
`ArnLike arn:aws:kms:*:*:key/*` works.
- Check that Secrets Manager calls `GenerateDataKey`/`Encrypt` as the caller, so the KMS
statement's `aws:ResourceTag/triple-c:component` rule is what enforces the environment key.
- Check that `PutSecretValue` on such a secret passes.
- **Tag keys.** Check that `aws:TagKeys` is populated for `RunTask`, for `CreateSecret` and secret
`TagResource`, for `CreateAccessPoint`, and for the ECS and EFS tag-on-create `TagResource`
calls. Check also whether ECS-managed (`aws:ecs:*`) or propagated task-definition tags appear in
`RunTask`'s `aws:TagKeys` and would break the key allow-list.
- **Resource tags the boundary and access points rely on.** Check `aws:ResourceTag` on:
- `elasticfilesystem:ClientMount`/`ClientWrite` (the file system's tags, at mount);
- `elasticfilesystem:CreateAccessPoint`, which is authorized against the file system;
- `kms:Decrypt` through Secrets Manager (the key's tags).
- **CloudFormation service roles.** Check that `cloudformation:RoleArn` is present in the request
context when `RoleARN` is given.
- **Task definition scoping.** Check whether `ecs:DeregisterTaskDefinition` and
+49 -1
View File
@@ -17,6 +17,13 @@
},
"ArnLike": {
"ecs:cluster": "arn:aws:ecs:*:*:cluster/triple-c-*"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
@@ -33,6 +40,13 @@
"StringEquals": {
"ecs:CreateAction": "RunTask",
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
@@ -121,6 +135,16 @@
"Condition": {
"StringEquals": {
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
},
"ArnLike": {
"secretsmanager:KmsKeyId": "arn:aws:kms:*:*:key/*"
}
}
},
@@ -142,6 +166,13 @@
},
"Null": {
"aws:ResourceTag/triple-c:component": "true"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
@@ -194,6 +225,13 @@
},
"Null": {
"aws:ResourceTag/triple-c:component": "false"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
@@ -210,6 +248,13 @@
"StringEquals": {
"elasticfilesystem:CreateAction": "CreateAccessPoint",
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
@@ -229,7 +274,7 @@
}
},
{
"Sid": "EncryptThroughSecretsManagerOnly",
"Sid": "EncryptThroughSecretsManagerWithEnvironmentKeysOnly",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
@@ -241,6 +286,9 @@
"Condition": {
"StringLike": {
"kms:ViaService": "secretsmanager.*.amazonaws.com"
},
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},