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:
1 parent
8e6983cf53
commit
9d1630eb97
3 files changed
+282
-12
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user