diff --git a/CLAUDE.md b/CLAUDE.md index f1d2409..a41b63d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/src-tauri/src/aws/live_tests.rs b/app/src-tauri/src/aws/live_tests.rs index e36e505..205c972 100644 --- a/app/src-tauri/src/aws/live_tests.rs +++ b/app/src-tauri/src/aws/live_tests.rs @@ -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() diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index c1bfe33..f418465 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -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. diff --git a/app/src-tauri/src/commands/host_commands.rs b/app/src-tauri/src/commands/host_commands.rs index 823a0d8..3c7bc66 100644 --- a/app/src-tauri/src/commands/host_commands.rs +++ b/app/src-tauri/src/commands/host_commands.rs @@ -547,8 +547,11 @@ mod tests { serde_json::from_str::(&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::(&f.json).expect("policy JSON"); + } assert_eq!( serde_json::to_value(&ws[0]).unwrap()["name"], "workspace-user.json" diff --git a/aws/README.md b/aws/README.md index e1696bc..46d9711 100644 --- a/aws/README.md +++ b/aws/README.md @@ -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 \ --policy-arn arn:aws:iam:::policy/triple-c-workspace-user +aws iam attach-user-policy --user-name \ + --policy-arn arn:aws:iam:::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 diff --git a/aws/policies/workspace-user-tasks.json b/aws/policies/workspace-user-tasks.json new file mode 100644 index 0000000..6815ce6 --- /dev/null +++ b/aws/policies/workspace-user-tasks.json @@ -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" + } + } + } + ] +}