feat(aws): workspace users register their own task definitions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-15 08:26:08 -08:00
1 parent 01e7b2752a
commit d1883310dd
6 files changed
+393 -14

No files matched your search

+1 -1
View File
@@ -312,7 +312,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
`identity.rs` is `GetCallerIdentity` and the principal to simulate (an assumed role's ARN is
recovered with `iam:GetRole`, because SSO roles have an `/aws-reserved/…` path the session ARN
lacks); `error.rs` classifies SDK errors (SSO expired, access denied naming the action, …);
`policies.rs` embeds `aws/policies/*.json` (the tier policies — the admin tier spans several
`policies.rs` embeds `aws/policies/*.json` (the tier policies — both tiers span several
files, `policy_files` lists them — and the permissions boundary) and the table of checks the
simulator is asked; `permissions.rs` runs it
(`SimulatePrincipalPolicy`, adaptive retries — it throttles — or read-only probes when the
+20
View File
@@ -561,6 +561,7 @@ async fn a_role_with_no_policy_checks_as_insufficient_and_lists_every_action() {
#[ignore]
async fn a_role_without_simulate_permission_falls_back_to_probes() {
e2e(|e| async move {
// `InspectOwnPrincipal` lives in the first file.
let mut doc: serde_json::Value =
serde_json::from_str(policy_files(Tier::WorkspaceUser)[0].1).unwrap();
doc["Statement"]
@@ -570,6 +571,11 @@ async fn a_role_without_simulate_permission_falls_back_to_probes() {
let role = e.role("nosim", None, &[]).await;
let arn = e.managed_policy("nosim", &doc.to_string()).await;
e.attach(&role, &arn).await;
// The tasks file is attached whole.
let tasks = e
.managed_policy("nosim-tasks", policy_files(Tier::WorkspaceUser)[1].1)
.await;
e.attach(&role, &tasks).await;
let cfg = e.assume(&role).await;
let me = caller_identity(&cfg).await.expect("identity");
assert_eq!(me.user_id, role.session_user_id());
@@ -790,6 +796,20 @@ async fn secrets_use_the_environment_key_and_only_their_owner_can_change_them()
as_user.put_secret_value().secret_id(&own).secret_string("v2").send().await,
);
// `workspace-user-tasks.json`: the owner reads their own secret, never
// the environment's or someone else's.
let read = ok(
"get own",
as_user.get_secret_value().secret_id(&own).send().await,
);
assert_eq!(read.secret_string(), Some("v2"));
for (what, arn) in [("environment", &environment), ("someone else's", &someone_elses)] {
denied(
&format!("get {what}"),
as_user.get_secret_value().secret_id(arn).send().await,
);
}
// Any other key is refused.
for (i, (what, k)) in [("no KmsKeyId", None), ("aws/secretsmanager", Some(aws_managed.as_str()))]
.into_iter()
+229 -5
View File
@@ -35,10 +35,16 @@ pub fn policy_files(tier: Tier) -> &'static [(&'static str, &'static str)] {
include_str!("../../../../aws/policies/environment-admin-network.json"),
),
],
Tier::WorkspaceUser => &[(
"workspace-user.json",
include_str!("../../../../aws/policies/workspace-user.json"),
)],
Tier::WorkspaceUser => &[
(
"workspace-user.json",
include_str!("../../../../aws/policies/workspace-user.json"),
),
(
"workspace-user-tasks.json",
include_str!("../../../../aws/policies/workspace-user-tasks.json"),
),
],
}
}
@@ -240,6 +246,36 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[
("ecs:CreateAction", Literal("RunTask")),
],
),
// `workspace-user-tasks.json`: a user registers, tags and retires their
// own task definitions, and reads their own secrets.
write(
"ecs:RegisterTaskDefinition",
PROBE_TASK_DEFINITION,
&[(OWNER_REQUEST_TAG, OwnerId)],
),
write(
"ecs:TagResource",
PROBE_TASK_DEFINITION,
&[
(OWNER_REQUEST_TAG, OwnerId),
("ecs:CreateAction", Literal("RegisterTaskDefinition")),
],
),
write(
"ecs:DeregisterTaskDefinition",
PROBE_TASK_DEFINITION,
&[(OWNER_RESOURCE_TAG, OwnerId)],
),
write(
"ecs:DeleteTaskDefinitions",
PROBE_TASK_DEFINITION,
&[(OWNER_RESOURCE_TAG, OwnerId)],
),
read(
"secretsmanager:GetSecretValue",
PROBE_SECRET,
&[(OWNER_RESOURCE_TAG, OwnerId)],
),
write(
"iam:PassRole",
PROBE_ROLE,
@@ -904,6 +940,27 @@ mod tests {
("ecs:RunTask", "task-definition/", OWNER_REQUEST_TAG),
("ecs:RunTask", "task-definition/", OWNER_RESOURCE_TAG),
("ecs:TagResource", "task/", OWNER_REQUEST_TAG),
(
"ecs:RegisterTaskDefinition",
"task-definition/",
OWNER_REQUEST_TAG,
),
("ecs:TagResource", "task-definition/", OWNER_REQUEST_TAG),
(
"ecs:DeregisterTaskDefinition",
"task-definition/",
OWNER_RESOURCE_TAG,
),
(
"ecs:DeleteTaskDefinitions",
"task-definition/",
OWNER_RESOURCE_TAG,
),
(
"secretsmanager:GetSecretValue",
"secret:",
OWNER_RESOURCE_TAG,
),
("ecs:StopTask", "task/", OWNER_RESOURCE_TAG),
("ecs:ExecuteCommand", "task/", OWNER_RESOURCE_TAG),
("ssm:StartSession", "task/", OWNER_RESOURCE_TAG),
@@ -1066,6 +1123,170 @@ mod tests {
assert!(!allowed(Tier::WorkspaceUser, &other_cluster, ME, ME));
}
/// The three tag keys a workspace user may put on anything they create.
const OWN_TAG_KEYS: Context = &[
("aws:TagKeys", Literal("triple-c:owner")),
("aws:TagKeys", Literal("triple-c:project")),
("aws:TagKeys", Literal("triple-c:environment")),
];
fn ws_check(action: &str, resource_part: &str) -> &'static Check {
checks(Tier::WorkspaceUser)
.iter()
.find(|c| c.action == action && c.resource.contains(resource_part))
.unwrap_or_else(|| panic!("no workspace-user check for {action} on {resource_part}"))
}
#[test]
fn registering_a_task_definition_needs_the_owner_tag_and_only_the_three_keys() {
let reg = ws_check("ecs:RegisterTaskDefinition", "task-definition/");
let with_keys = |extra: Option<&'static str>| -> Check {
let ctx: Context = match extra {
None => &[
(OWNER_REQUEST_TAG, OwnerId),
("aws:TagKeys", Literal("triple-c:owner")),
("aws:TagKeys", Literal("triple-c:project")),
("aws:TagKeys", Literal("triple-c:environment")),
],
Some(_) => &[
(OWNER_REQUEST_TAG, OwnerId),
("aws:TagKeys", Literal("triple-c:owner")),
("aws:TagKeys", Literal("triple-c:project")),
("aws:TagKeys", Literal("triple-c:environment")),
("aws:TagKeys", Literal("extra")),
],
};
Check { context: ctx, ..*reg }
};
assert!(allowed(Tier::WorkspaceUser, &with_keys(None), ME, ME));
assert!(
!allowed(Tier::WorkspaceUser, &with_keys(Some("extra")), ME, ME),
"an extra tag key must be refused"
);
let no_owner = Check {
context: OWN_TAG_KEYS,
..*reg
};
assert!(
!allowed(Tier::WorkspaceUser, &no_owner, ME, ME),
"no owner request tag"
);
let someone_elses = Check {
context: &[(OWNER_REQUEST_TAG, Literal(SOMEONE_ELSE))],
..*reg
};
assert!(!allowed(Tier::WorkspaceUser, &someone_elses, ME, ME));
// Only Triple-C's families.
let other_family = Check {
resource: "arn:aws:ecs:{region}:{account}:task-definition/production:1",
..*reg
};
assert!(!allowed(Tier::WorkspaceUser, &other_family, ME, ME));
}
#[test]
fn task_definitions_are_tagged_only_at_register_and_only_with_the_three_keys() {
let tag = ws_check("ecs:TagResource", "task-definition/");
assert!(allowed(Tier::WorkspaceUser, tag, ME, ME));
let retag = Check {
context: &[(OWNER_REQUEST_TAG, OwnerId)],
..*tag
};
assert!(!allowed(Tier::WorkspaceUser, &retag, ME, ME));
let other_create = Check {
context: &[
(OWNER_REQUEST_TAG, OwnerId),
("ecs:CreateAction", Literal("RunTask")),
],
..*tag
};
assert!(!allowed(Tier::WorkspaceUser, &other_create, ME, ME));
let extra_key = Check {
context: &[
(OWNER_REQUEST_TAG, OwnerId),
("ecs:CreateAction", Literal("RegisterTaskDefinition")),
("aws:TagKeys", Literal("triple-c:owner")),
("aws:TagKeys", Literal("triple-c:component")),
],
..*tag
};
assert!(
!allowed(Tier::WorkspaceUser, &extra_key, ME, ME),
"a task definition must not be tagged triple-c:component"
);
}
#[test]
fn only_the_owner_retires_a_task_definition() {
for action in ["ecs:DeregisterTaskDefinition", "ecs:DeleteTaskDefinitions"] {
let c = ws_check(action, "task-definition/");
assert!(allowed(Tier::WorkspaceUser, c, ME, ME), "{action}");
let theirs = Check {
context: &[(OWNER_RESOURCE_TAG, Literal(SOMEONE_ELSE))],
..*c
};
assert!(!allowed(Tier::WorkspaceUser, &theirs, ME, ME), "{action}");
let other_family = Check {
resource: "arn:aws:ecs:{region}:{account}:task-definition/production:1",
..*c
};
assert!(!allowed(Tier::WorkspaceUser, &other_family, ME, ME), "{action}");
}
}
/// RegisterTaskDefinition with a task or execution role also authorizes
/// `iam:PassRole` on each; `PassOwnTaskRoles` (owner-tagged, to ECS
/// tasks, `triple-c-*` roles) must cover both and nothing else.
#[test]
fn register_task_definition_can_pass_the_users_task_and_execution_roles() {
let pass = ws_check("iam:PassRole", "role/");
for role in [
"arn:aws:iam::{account}:role/triple-c-prod-0123456789ab-task",
"arn:aws:iam::{account}:role/triple-c-prod-0123456789ab-exec",
] {
let c = Check { resource: role, ..*pass };
assert!(allowed(Tier::WorkspaceUser, &c, ME, ME), "{role}");
assert!(!allowed(Tier::WorkspaceUser, &c, ME, SOMEONE_ELSE), "{role}");
}
}
#[test]
fn secrets_are_readable_only_by_their_owner_and_never_the_environments() {
let get = ws_check("secretsmanager:GetSecretValue", "secret:");
assert!(allowed(Tier::WorkspaceUser, get, ME, ME));
assert!(!allowed(Tier::WorkspaceUser, get, ME, SOMEONE_ELSE));
// Even tagged with the caller's owner ID, a component-tagged secret
// (the environment's) is off limits: the Deny holds on its own.
let environments = Check {
context: &[
(OWNER_RESOURCE_TAG, OwnerId),
(COMPONENT_RESOURCE_TAG, Literal("environment")),
],
..*get
};
assert!(!allowed(Tier::WorkspaceUser, &environments, ME, ME));
let component_only = Check {
context: &[(COMPONENT_RESOURCE_TAG, Literal("environment"))],
..*get
};
let req = Request {
check: &component_only,
caller: ME,
owner: ME,
};
let stmts = denies(Tier::WorkspaceUser);
assert!(
matching(&stmts, &req).iter().any(|s| conditions_hold(s, &req)),
"missing a Deny of GetSecretValue on component-tagged secrets"
);
// Not outside Triple-C's secrets.
let other = Check {
resource: "arn:aws:secretsmanager:{region}:{account}:secret:prod/db-AbCdEf",
..*get
};
assert!(!allowed(Tier::WorkspaceUser, &other, ME, ME));
}
#[test]
fn ecs_tag_resource_only_tags_at_run_task() {
let tag = checks(Tier::WorkspaceUser)
@@ -1617,7 +1838,10 @@ mod tests {
names(Tier::EnvironmentAdmin),
["environment-admin.json", "environment-admin-network.json"]
);
assert_eq!(names(Tier::WorkspaceUser), ["workspace-user.json"]);
assert_eq!(
names(Tier::WorkspaceUser),
["workspace-user.json", "workspace-user-tasks.json"]
);
}
/// D8: CloudFormation leaves the policies, the checks and the probes.
+5 -2
View File
@@ -547,8 +547,11 @@ mod tests {
serde_json::from_str::<serde_json::Value>(&f.json).expect("policy JSON");
}
let ws = aws_policy_document(Tier::WorkspaceUser);
assert_eq!(ws.len(), 1);
assert_eq!(ws[0].name, "workspace-user.json");
let names: Vec<&str> = ws.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["workspace-user.json", "workspace-user-tasks.json"]);
for f in &ws {
serde_json::from_str::<serde_json::Value>(&f.json).expect("policy JSON");
}
assert_eq!(
serde_json::to_value(&ws[0]).unwrap()["name"],
"workspace-user.json"
+42 -6
View File
@@ -6,15 +6,16 @@ tier the signed-in identity has under **Settings → Hosts → Check**.
| File | What it is |
|---|---|
| `policies/workspace-user.json` | The **workspace user** tier: create, start, stop, connect to and delete **their own** workspaces in an existing environment |
| `policies/workspace-user.json` | The **workspace user** tier, part 1: create, start, stop, connect to and delete **their own** workspaces in an existing environment |
| `policies/workspace-user-tasks.json` | The **workspace user** tier, part 2: register, tag and retire **their own** ECS task definitions, and read **their own** secrets (`workspace-user.json` has no room left) |
| `policies/environment-admin.json` | The **environment admin** tier, part 1: apply and destroy the environment and onboarding modules (`terraform/`) with OpenTofu, and keep their state |
| `policies/environment-admin-network.json` | The **environment admin** tier, part 2: the network those modules create (VPC, subnets, gateways, routes, security groups) |
| `policies/permissions-boundary.json` | The **permissions boundary** every Triple-C role must carry. It is never attached to a person |
The admin tier is **two** files, because one managed policy holds only 6,144 characters; both are
required, and the app's **Copy** lists every file of a tier. An environment admin attaches all three
tier files (the workspace user's and both admin files): the admin files add environment management
on top of everything a workspace user can do. Explicit Deny statements in any file apply to anyone
The workspace tier and the admin tier are each **two** files, because one managed policy holds only
6,144 characters; both files of a tier are required, and the app's **Copy** lists every file of a
tier. An environment admin attaches all four tier files (both workspace user files and both admin
files): the admin files add environment management on top of everything a workspace user can do. Explicit Deny statements in any file apply to anyone
who has it attached.
## Workspace user
@@ -26,6 +27,23 @@ following:
- **Run tasks.** The task definition must be a `triple-c-*` one tagged with the user's own owner
ID, and the cluster must be a `triple-c-*` one. The new task is tagged with the user's owner ID.
The user can pass only `triple-c-*` roles tagged with their owner ID, and only to ECS tasks.
- **Register their own task definitions** (`workspace-user-tasks.json`). `ecs:RegisterTaskDefinition`
on `triple-c-*` families needs `triple-c:owner = ${aws:userid}` in the request and only the three
ordinary tag keys. `ecs:TagResource` on a task definition is allowed only at registration
(`ecs:CreateAction = RegisterTaskDefinition`, same owner and tag-key conditions), so a
definition can never be retagged. `ecs:DeregisterTaskDefinition` and `ecs:DeleteTaskDefinitions`
need the definition to carry the user's owner ID. A task definition that names a task or
execution role is also authorized with `iam:PassRole` on it, which `PassOwnTaskRoles` already
covers (owner-tagged `triple-c-*` roles, to ECS tasks only; a unit test checks both roles).
`RunOwnTasks` still requires the definition's owner tag, so a user can run only definitions they
registered. The statements use the revision-free and revision forms alike: the `*` in
`task-definition/triple-c-*` matches `family:revision`.
- **Read their own secrets.** `secretsmanager:GetSecretValue` on `triple-c/*` secrets tagged with
their owner ID, so a second machine can read the agent token and attach to a running task
(`ReadOwnSecrets`). An explicit Deny (`NeverReadEnvironmentSecrets`) refuses it on any secret
tagged `triple-c:component`, even one that also carries their owner tag. (`NeverTouchEnvironmentSecrets`
in `workspace-user.json` covers writes; it has no room for another action.) Reading also
needs `kms:Decrypt` through Secrets Manager on the environment key, which the first file grants.
- **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, and open data channels
(`ssmmessages:OpenDataChannel`) only on those.
@@ -341,7 +359,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,800 (340)
about 6,040 (roughly 100 characters of headroom), `workspace-user-tasks.json` about 1,460,
`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
@@ -352,8 +371,12 @@ With the CLI:
```sh
aws iam create-policy --policy-name triple-c-workspace-user \
--policy-document file://policies/workspace-user.json
aws iam create-policy --policy-name triple-c-workspace-user-tasks \
--policy-document file://policies/workspace-user-tasks.json
aws iam attach-user-policy --user-name <user> \
--policy-arn arn:aws:iam::<account>:policy/triple-c-workspace-user
aws iam attach-user-policy --user-name <user> \
--policy-arn arn:aws:iam::<account>:policy/triple-c-workspace-user-tasks
```
## Known gaps
@@ -492,6 +515,19 @@ asserts that every role the admin creates carries the boundary.
Still open:
- **`ecs:RegisterTaskDefinition` and resource-level permissions (phase 5, unverified).** The
statements in `workspace-user-tasks.json` scope the action to `task-definition/triple-c-*`, but
whether ECS authorizes a register call against that ARN (the family is known, the revision is
not yet) or only against `*` has not been checked on real IAM; the unit tests' evaluator only
proves the JSON means what it says. The `aws:RequestTag` and `aws:TagKeys` conditions are
documented for the action either way. Probe, as a role holding the workspace files: register a
`triple-c-e2e-*` definition tagged with the owner and the two other keys. If it is denied
while the policy simulator allows it, change `Resource` of `RegisterOwnTaskDefinitions` to
`"*"` (the request-tag conditions still confine it; the family name is then unenforced) and
record that here. Also confirm `ecs:TagResource` with `ecs:CreateAction = RegisterTaskDefinition`
tags at registration, that `DeregisterTaskDefinition`/`DeleteTaskDefinitions` honour
`aws:ResourceTag`, and that a user can read their own secret only (`live_tests.rs` checks the
secret read);
- the `existing` network mode against a user's own VPC, and an onboarding by a workspace user
from an admin bundle (the same module, applied with admin credentials);
- `DisassociateGoneAddresses` cannot touch a **live** association. Probe: allocate an untagged
+96
View File
@@ -0,0 +1,96 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RegisterOwnTaskDefinitions",
"Effect": "Allow",
"Action": [
"ecs:RegisterTaskDefinition"
],
"Resource": [
"arn:aws:ecs:*:*:task-definition/triple-c-*"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
{
"Sid": "TagOwnTaskDefinitionsAtRegister",
"Effect": "Allow",
"Action": [
"ecs:TagResource"
],
"Resource": [
"arn:aws:ecs:*:*:task-definition/triple-c-*"
],
"Condition": {
"StringEquals": {
"ecs:CreateAction": "RegisterTaskDefinition",
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
{
"Sid": "RetireOwnTaskDefinitions",
"Effect": "Allow",
"Action": [
"ecs:DeregisterTaskDefinition",
"ecs:DeleteTaskDefinitions"
],
"Resource": [
"arn:aws:ecs:*:*:task-definition/triple-c-*"
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
}
}
},
{
"Sid": "ReadOwnSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
}
}
},
{
"Sid": "NeverReadEnvironmentSecrets",
"Effect": "Deny",
"Action": [
"secretsmanager:GetSecretValue"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
],
"Condition": {
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
}
]
}