From f924040f9e05191894b15ba7cd79f8e0d10aa142 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 20:34:00 -0800 Subject: [PATCH 01/24] docs: phase 4 plan (environment + onboarding stacks) Co-Authored-By: Claude Opus 5.5 --- .../2026-11-14-aws-environment-stacks.md | 448 ++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-14-aws-environment-stacks.md diff --git a/docs/superpowers/plans/2026-11-14-aws-environment-stacks.md b/docs/superpowers/plans/2026-11-14-aws-environment-stacks.md new file mode 100644 index 0000000..bb952cd --- /dev/null +++ b/docs/superpowers/plans/2026-11-14-aws-environment-stacks.md @@ -0,0 +1,448 @@ +# AWS Environment + Onboarding Stacks (Phase 4) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** From Settings → Hosts, a user picks a region on an AWS connection. They see the Triple-C environments there, including half-deployed and outdated ones. They can deploy a new environment from the CloudFormation template that ships with the app, update or repair one, and join one. Joining onboards them automatically when they are allowed to, and otherwise produces an exact onboarding request for an admin. Strict tenancy is the only mode. + +**Architecture:** +- Three CloudFormation templates ship in `aws/`, compiled in with `include_str!`: + - the **environment** template; + - the per-user **onboarding** template; + - a one-off **permissions boundary** template. +- `aws/templates.rs` owns names, versions and stack parameters. +- `aws/stacks.rs` creates, updates, deletes and watches stacks behind a mockable trait. +- `aws/environments.rs` detects environments by merging the SSM parameters with the tagged stacks, and also reports the boundary and the caller's onboarding state. +- Commands live in a new `commands/environment_commands.rs`. Stack progress streams to the UI as Tauri events. +- The UI is an Environments panel on each AWS connection in `HostsSettings`. +- Static tests keep the templates and the environment-admin policy in step. An ignored live test deploys everything as a role that holds *only* the admin policies, then tears it down. + +**Tech Stack:** +- Rust and Tauri v2, with the existing `aws-sdk-cloudformation`, `aws-sdk-iam`, `aws-sdk-ssm` and `sha2`. No new crates. In particular, no `aws-sdk-ec2` (compile time): existing-VPC mode takes typed IDs. +- React, TypeScript and vitest. + +**Spec:** `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`. Relevant sections: +- "User flow" §2; +- "Connectivity" (network); +- "Storage"; +- "Secrets and uploads"; +- "Multi-user accounts" (strict tenancy); +- "Shipped AWS artefacts"; +- "Phase 3: policy verification and rulings". + +**Branch:** `feat/aws-environments` off `main` (3bc0e9f). + +## Decisions this plan makes + +These go in the spec in Task 8. Each is a deviation or a resolved gap, so the reviewer should read them first. + +- **D1. Templates are JSON**: `aws/triple-c-environment.json`, `aws/triple-c-user-onboard.json` and `aws/triple-c-permissions-boundary.json`. + - CloudFormation takes JSON natively, and the app already depends on `serde_json`. + - There is no YAML crate in the tree, and the drift tests must parse the templates. +- **D2. Strict tenancy gives each user their own EFS file system**, created by their onboarding stack. The spec had one file system per environment. + - In the single-file-system design, the file system policy would have to bind each access point to its owner's task role. EFS can't express that: `ClientMount` sees the access point's ARN, not its tags, and access point IDs are random. + - With one file system per user, its policy allows only that user's task role. An empty file system costs nothing. + - The environment keeps the shared parts: cluster, security group, KMS key, log group and network. +- **D3. No ECR in v1.** Tasks pull `ghcr.io/shadowdao/triple-c-sandbox` straight from GHCR, which the spec allows ("the default when the environment has no ECR mirror"). An ECR mirror is later work. +- **D4. Network modes** (template parameter `NetworkMode`): + - `public` (**default**): creates a VPC with two public subnets. Tasks get a public IP behind a security group with **no inbound rules**. There is no NAT gateway, so it costs $0. + - `private-nat`: creates a VPC with two public and two private subnets and one NAT gateway. Tasks run in the private subnets. The UI says this costs about $33/month plus data. + - `existing`: `VpcId`, `SubnetA`, `SubnetB` (in different AZs) and `AssignPublicIp`. + - The spec's "private subnets" becomes one option, not the only one. The no-inbound rule holds in every mode. +- **D5. An admin request is a template file plus a CLI command, not a console link.** CloudFormation quick-create links need the template at an S3 URL, and Triple-C has no public bucket. + - The request offers to save the template, using a Rust-side save dialog like `pick_save_path`. + - It shows the exact parameters and tags. + - It gives a copyable `aws cloudformation deploy …` command. +- **D6. The app deletes only failed or half-deployed stacks.** Those are `ROLLBACK_COMPLETE`, `ROLLBACK_FAILED`, `CREATE_FAILED` and `DELETE_FAILED`. Deleting a working environment or onboarding stack stays in the console for v1, which is the irreversible case. +- **D7. The admin tier is two policy files**, `environment-admin.json` and `environment-admin-network.json`. Both are required for the admin tier. The VPC actions don't fit in one 6,144-character managed policy (the admin file is already about 5,530). `aws_policy_document` returns every file for a tier. +- **D8. The workspace-user policy is unchanged.** Detection uses only reads it already grants: + - `ssm:GetParameter*` on `/triple-c/*`, which covers `GetParametersByPath` on `/triple-c/environments`; + - `cloudformation:DescribeStacks` and `ListStacks`; + - `iam:GetRole` on `role/triple-c-*`. + + It stays about 30 characters under the size limit. The split the phase-3 notes called for moves to phase 5, which adds actions. + +## Global Constraints + +- Names. These are pure functions in `aws/templates.rs`, and nothing else builds these strings. + - **Environment name**: `^[a-z][a-z0-9-]{0,19}$`, not ending in `-`, with no `--`. + - **Environment stack**: `triple-c-`. **Parameter**: `/triple-c/environments/`. + - **Owner key**: the first 12 hex characters of `sha256(aws:userid)`. A `UserId` contains `:`, which IAM role names reject. + - **Onboarding stack**: `triple-c--user-`. + - **Per-user roles**: `triple-c---task` and `triple-c---exec`. The longest is 47 characters, under the 64 limit. + - **Boundary stack**: `triple-c-permissions-boundary`. It creates the managed policy `triple-c-permissions-boundary`, with no path. +- Tags. + - Every taggable resource in the environment template carries `triple-c:component=environment` and `triple-c:environment=`. + - In the onboarding template, every taggable resource carries `triple-c:component=user-onboarding`, `triple-c:environment=` and `triple-c:owner=`. + - Stacks are tagged the same way, plus `triple-c:template-version=`. + - Workspace users never set `triple-c:component`; phase 3 depends on that. +- Every IAM role a template creates has `PermissionsBoundary` set to `arn:aws:iam::${AWS::AccountId}:policy/triple-c-permissions-boundary`. Trust is `ecs-tasks.amazonaws.com` with `aws:SourceAccount = ${AWS::AccountId}`. +- No stack uses a service role (`RoleARN`). The admin policy denies one, so CloudFormation acts with the caller's own credentials. +- The security group has **no** `SecurityGroupIngress`, inline or as a separate resource. +- The KMS key policy delegates to IAM (`arn:aws:iam::${AWS::AccountId}:root`, `kms:*`). The key has rotation on and `PendingWindowInDays: 7`. +- The environment parameter's value is JSON, `EnvironmentInfo` schema 1 (Task 1 lists the fields). It is **untrusted input**. Anyone holding `ssm:PutParameter` on `/triple-c/*` can write it, so the app validates every field before use and caps the value at 4 KiB. +- Template versions are `ENVIRONMENT_TEMPLATE_VERSION: u32 = 1` and `ONBOARD_TEMPLATE_VERSION: u32 = 1`. Each is written in three places, and a test checks they agree: the template's `Metadata.TripleC.TemplateVersion`, the stack tag, and (for the environment) the parameter JSON. +- `TemplateBody` is limited to 51,200 bytes, and a test checks it. +- AWS calls use `aws::connection::sdk_config`, never `aws_config::defaults()`. +- Every new command needs `#[tauri::command]`, a `generate_handler!` entry and an `allow-*` grant in `capabilities/default.json`. `src/test/capabilities.test.ts` must pass. +- When a policy JSON changes, its checks in `policies.rs` and `aws/README.md` change in the same commit. Every policy fits in 6,144 characters with whitespace removed. +- Live AWS spending is capped by the user at $10 for this work, and every resource is cleaned up. Live tests use the scratchpad `awsx` wrapper, never the container's own `AWS_*` env. + +## Review Focus + +1. **A half-deployed stack**: `CREATE_IN_PROGRESS`, `ROLLBACK_COMPLETE`, `UPDATE_ROLLBACK_COMPLETE` or `DELETE_FAILED`. + - Show it with its state. + - Offer Delete only for the D6 states. + - Show an in-progress one with live progress, not as "Failed". + - `UPDATE_ROLLBACK_COMPLETE` is usable, so offer Update. + - Test: `half_deployed_stacks_get_the_right_actions` (Task 5). +2. **A record and its stack disagree.** + - A parameter with no stack is shown as "Stack missing" and is not joinable. + - A tagged stack with no parameter is shown as "Record missing — update to repair". + - Test: `a_parameter_without_a_stack_and_a_stack_without_a_parameter_are_both_listed` (Task 5). +3. **Update when nothing changed.** CloudFormation's `ValidationError` "No updates are to be performed." means "Already up to date", not a failure. Test: `an_update_with_no_changes_is_already_up_to_date` (Task 4). +4. **A hostile or garbled environment record**: bad JSON, 64 KiB of text, an ARN in another account or region, or a name that doesn't match its path. It is reported as "Unreadable environment record" and never used to build a request. Test: `untrusted_environment_records_are_rejected` (Task 5). +5. **The app closes mid-deploy.** The stack carries on. On the next detection, the in-progress stack is shown, and its progress can be followed from its existing events without starting a second deploy. Test: `watching_a_stack_already_in_progress_reports_its_existing_events_once` (Task 4). + +--- + +### Task 1: Environment template and `aws/templates.rs` + +**Files:** +- Create: `aws/triple-c-environment.json` +- Create: `app/src-tauri/src/aws/templates.rs`, with tests in `app/src-tauri/src/aws/templates/tests.rs` +- Modify: `app/src-tauri/src/aws/mod.rs` + +**Interfaces:** +- Produces, in `aws::templates`: + ```rust + pub const ENVIRONMENT_TEMPLATE_VERSION: u32 = 1; + pub const ONBOARD_TEMPLATE_VERSION: u32 = 1; + pub const BOUNDARY_POLICY_NAME: &str = "triple-c-permissions-boundary"; // re-export of policies::PERMISSIONS_BOUNDARY_NAME + pub const BOUNDARY_STACK_NAME: &str = "triple-c-permissions-boundary"; + #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] + pub enum TemplateKind { Environment, UserOnboard, PermissionsBoundary } + pub fn template_body(kind: TemplateKind) -> &'static str; + pub fn template_file_name(kind: TemplateKind) -> &'static str; // "triple-c-environment.json", … + pub fn validate_environment_name(name: &str) -> Result; + pub fn environment_stack_name(env: &str) -> String; + pub fn environment_parameter_name(env: &str) -> String; + pub fn owner_key(user_id: &str) -> String; // 12 lowercase hex + pub fn onboarding_stack_name(env: &str, owner_key: &str) -> String; + pub fn user_role_names(env: &str, owner_key: &str) -> (String, String); // (task, exec) + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "mode", rename_all = "kebab-case")] + pub enum NetworkChoice { Public, PrivateNat, Existing { vpc_id: String, subnet_a: String, subnet_b: String, assign_public_ip: bool } } + pub fn environment_parameters(env: &str, network: &NetworkChoice) -> Result, String>; // validates vpc-/subnet- ids + pub fn stack_tags(kind: TemplateKind, env: Option<&str>, owner_id: Option<&str>) -> Vec<(String, String)>; + ``` +- Produces the `EnvironmentInfo` JSON shape. Task 5 parses it, and the template writes it with `Fn::Sub`: + `{"schema":1,"name","template_version","cluster_arn","security_group_id","subnet_ids":[2],"assign_public_ip":bool,"kms_key_arn","log_group","network_mode","tenancy":"strict"}`. +- Template parameters: `EnvironmentName`, `NetworkMode` (`public|private-nat|existing`), `VpcId`, `SubnetA`, `SubnetB`, `AssignPublicIp` (`true|false`) and `LogRetentionDays` (default 30). +- Exports, which Task 2's onboarding template imports: `triple-c--ClusterArn`, `-SecurityGroupId`, `-SubnetA`, `-SubnetB`, `-KmsKeyArn` and `-LogGroupName`. +- Resources, always created: the ECS cluster (`triple-c-`, `FARGATE` and `FARGATE_SPOT` capacity providers), the security group (egress all, no ingress), the KMS key and its alias `alias/triple-c-`, the log group `/triple-c/`, and the SSM parameter. +- Resources, conditional on network mode: the VPC, IGW, subnets, route tables, and the NAT gateway and EIP. + +- [ ] **Step 1: Write the failing tests** in `templates/tests.rs`: + - `environment_names_are_validated`: accepts `dev` and `team-a1`. Rejects `""`, `Dev`, `1dev`, `dev-`, `a--b`, a 21-character name, and `dev/x`. + - `owner_keys_are_stable_and_role_safe`: `owner_key("AROAEXAMPLE:jdoe@example.com")` is 12 characters from `[0-9a-f]`, is the same on every call, and differs from `owner_key("AROAEXAMPLE:jdoe2@example.com")`. `user_role_names("abcdefghijklmnopqrst", &key)` are each at most 64 characters. + - `the_environment_template_parses_and_fits`: `template_body(Environment)` parses as JSON and is under 51,200 bytes. `Metadata.TripleC.TemplateVersion == ENVIRONMENT_TEMPLATE_VERSION`. + - `the_security_group_has_no_ingress`: there is no `AWS::EC2::SecurityGroupIngress` resource and no `SecurityGroupIngress` property. + - `every_taggable_environment_resource_is_tagged_component_environment`: every resource with a `Tags` property has `triple-c:component=environment` and `triple-c:environment` (`Ref EnvironmentName`). The ECS cluster, KMS key, log group, security group, VPC pieces and the SSM parameter all count; resource types that can't take tags are excluded by an explicit list in the test. + - `the_environment_record_renders_to_valid_json`: substitute fake values into the parameter's `Fn::Sub` string, with the network-mode conditions resolved each way. The result parses into the field list above, with `schema == 1` and `template_version == 1`. + - `exports_are_named_for_the_environment`: all six export names are `Fn::Sub` of `triple-c-${EnvironmentName}-`. + - `environment_parameters_cover_every_network_mode`: `Existing` with `vpc-0abc`/`subnet-1`/`subnet-2` gives all six parameters. A bad ID (`vpc-;`) is an error. `Public` sets `NetworkMode=public` and leaves the existing-VPC parameters at their template defaults. +- [ ] **Step 2: Run** `cargo test aws::templates`. Expected: FAIL (module missing). +- [ ] **Step 3: Write `aws/triple-c-environment.json` and implement `templates.rs`.** + - Use the long-form intrinsics; JSON has no short form. + - The record's JSON booleans come from `Fn::If` returning the literal text `true`/`false` inside the `Fn::Sub`. + - `subnet_ids` comes from conditions selecting the created or the existing subnets. +- [ ] **Step 4: Run** `cargo test aws::templates`. Expected: PASS. +- [ ] **Step 5: Validate the template against CloudFormation.** Run `awsx aws cloudformation validate-template --template-body file://aws/triple-c-environment.json --region us-west-2`. Expected: no error. This is free and creates nothing. +- [ ] **Step 6: Commit** with the message `feat(aws): environment CloudFormation template and stack naming`. + +### Task 2: Onboarding and boundary templates + +**Files:** +- Create: `aws/triple-c-user-onboard.json` +- Create: `aws/triple-c-permissions-boundary.json` +- Modify: `app/src-tauri/src/aws/templates.rs`, `templates/tests.rs` + +**Interfaces:** +- Consumes: Task 1's names, exports and tags. +- Produces: + ```rust + pub fn onboarding_parameters(env: &str, owner_id: &str) -> Vec<(String, String)>; // EnvironmentName, OwnerId, OwnerKey (= owner_key(owner_id)), BedrockOnTaskRole=false + ``` +- The onboarding template's parameters are `EnvironmentName`, `OwnerId`, `OwnerKey` and `BedrockOnTaskRole` (`false` by default). +- It creates the following resources. + - **Task role** `triple-c---task`. It may use: + - `ssmmessages:CreateControlChannel/CreateDataChannel/OpenControlChannel/OpenDataChannel` on `*`; + - `logs:CreateLogStream/PutLogEvents` on the environment's log group; + - `elasticfilesystem:ClientMount/ClientWrite` on *this user's* file system; + - Bedrock invoke, only under the `BedrockOnTaskRole` condition. + - **Execution role** `triple-c---exec`. It may use: + - `secretsmanager:GetSecretValue` on `triple-c/*` where `aws:ResourceTag/triple-c:owner` equals the `OwnerId` parameter; + - `kms:Decrypt` on the environment key via `secretsmanager.${AWS::Region}.amazonaws.com`; + - `logs:CreateLogStream/PutLogEvents` on the environment's log group. + - **The user's EFS file system.** It is encrypted, `PerformanceMode: generalPurpose`, `ThroughputMode: elastic`, and has two mount targets (the environment's subnets and security group). + - **That file system's policy:** + - Deny `aws:SecureTransport=false`. + - Deny any request where `elasticfilesystem:AccessPointArn` is null. + - Allow `ClientMount` and `ClientWrite` only to the task role's ARN. + - **The security group rule.** EFS needs inbound NFS (2049) **from the environment security group to the mount targets**. This must not open ingress on the tasks. So the onboarding stack creates its own mount-target security group `triple-c---efs`, with ingress 2049 only from the environment security group, and the environment group stays ingress-free. +- The boundary template is one `AWS::IAM::ManagedPolicy` named `triple-c-permissions-boundary`. Its `PolicyDocument` equals `aws/policies/permissions-boundary.json`. + +- [ ] **Step 1: Write the failing tests:** + - `every_onboarding_role_carries_the_boundary_and_owner_tag`: every `AWS::IAM::Role` has `PermissionsBoundary` equal to the `Fn::Sub` of the boundary ARN, and the tag `triple-c:owner = Ref OwnerId`. `RoleName` is `Fn::Sub` of `triple-c-${EnvironmentName}-${OwnerKey}-task` or `-exec`. + - `role_trust_is_ecs_tasks_in_this_account_only`: the trust principal is `ecs-tasks.amazonaws.com` with a `StringEquals aws:SourceAccount` condition. + - `the_users_file_system_admits_only_their_task_role_through_an_access_point`: the file system policy has the two Denies and one Allow whose principal is `Fn::GetAtt TaskRole.Arn`. + - `the_environment_security_group_gains_no_ingress`: the only ingress anywhere in the onboarding template is on the `-efs` group, port 2049, sourced from `Fn::ImportValue triple-c-${EnvironmentName}-SecurityGroupId`. + - `exec_role_reads_only_its_owners_secrets`: the `GetSecretValue` statement is conditioned on `aws:ResourceTag/triple-c:owner` = `Ref OwnerId`. + - `the_boundary_template_matches_the_shipped_boundary`: parse both. The template's `PolicyDocument` equals `permissions-boundary.json` as JSON values, and `ManagedPolicyName == BOUNDARY_POLICY_NAME`. + - `every_onboarding_role_stays_inside_the_boundary`: every action each role is granted is allowed by `permissions-boundary.json`, under the Task 3 evaluator. **Write this test in Task 3** once the evaluator is shared, and leave it as a TODO here. +- [ ] **Step 2: Run** `cargo test aws::templates`. Expected: FAIL. +- [ ] **Step 3: Write both templates and `onboarding_parameters`.** +- [ ] **Step 4: Run the tests** and `awsx aws cloudformation validate-template` on both files. Expected: PASS, and both valid. +- [ ] **Step 5: Commit** with the message `feat(aws): per-user onboarding and permissions-boundary templates`. + +### Task 3: The admin policies cover the templates + +**Files:** +- Create: `aws/policies/environment-admin-network.json` +- Modify: `aws/policies/environment-admin.json` +- Modify: `app/src-tauri/src/aws/policies.rs` +- Modify: `app/src-tauri/src/aws/permissions.rs` (one tier is now several files) +- Modify: `app/src-tauri/src/commands/host_commands.rs` (`aws_policy_document`) +- Modify: `app/src/lib/tauri-commands.ts`, `app/src/lib/types.ts`, `components/settings/HostsSettings.tsx` (show and copy each file) +- Modify: `aws/README.md` +- Move: the test-only IAM evaluator from `policies.rs` `mod tests` into `app/src-tauri/src/aws/policy_eval.rs` (`#[cfg(test)]`), so the template tests can use it. Its behaviour is unchanged. + +**Interfaces:** +- Produces: + ```rust + pub fn policy_files(tier: Tier) -> &'static [(&'static str, &'static str)]; // (file name, json); admin = [environment-admin.json, environment-admin-network.json] + #[tauri::command] pub fn aws_policy_document(tier: Tier) -> Vec; // PolicyFile { name: String, json: String } + ``` + `policy_json` is removed. Its callers use `policy_files`, and the simulator evaluates all of a tier's files together. +- A table in `templates/tests.rs` maps each template resource type to the IAM actions CloudFormation calls to create, tag, read, update and delete it. For example, `AWS::KMS::Key` maps to `kms:CreateKey`, `kms:TagResource`, `kms:DescribeKey`, `kms:GetKeyPolicy`, `kms:PutKeyPolicy`, `kms:EnableKeyRotation`, `kms:GetKeyRotationStatus`, `kms:ListResourceTags` and `kms:ScheduleKeyDeletion`. + - The table is the hypothesis. The Task 8 live test is the proof, and any action it finds missing goes into both the table and the policy. + +- [ ] **Step 1: Write the failing tests:** + - `the_admin_tier_can_deploy_every_template_resource`: for each resource in the environment and onboarding templates, every mapped action is allowed by the admin tier's files. Use the resource's tags (`triple-c:component` present) and resource names from Task 1's naming functions. + - `every_onboarding_role_stays_inside_the_boundary` (from Task 2). + - The existing `every_policy_fits_a_managed_policy` covers the new file. The existing check-table drift tests now require a check for every new admin action. + - `the_admin_still_cannot_touch_untagged_resources`: `ec2:DeleteVpc`, `ec2:DeleteSubnet` and `ec2:DeleteNatGateway` on a resource without `triple-c:component` are denied. +- [ ] **Step 2: Run** `cargo test aws::`. Expected: FAIL. +- [ ] **Step 3: Add the actions.** + - VPC, subnet, IGW, route, route-table, EIP and NAT go in `environment-admin-network.json`. + - Creation requires the `aws:RequestTag/triple-c:component` tag. + - Mutation and deletion require `aws:ResourceTag/triple-c:component`. + - `ec2:CreateTags` is allowed only with `ec2:CreateAction` set to one of those create calls. + - Everything else goes in `environment-admin.json`. That covers the extra KMS, ECS, logs, EFS and SSM read and tag actions, plus `iam:GetPolicy` on the boundary ARN, which detection uses. + - Add a check-table row for each new action, and update the README tier sections. +- [ ] **Step 4: Run** `cargo test aws::` and `npx vitest run src/components/settings`. Expected: PASS. +- [ ] **Step 5: Commit** with the message `feat(aws): admin policies cover the environment and onboarding stacks`. + +### Task 4: Stack operations (`aws/stacks.rs`) + +**Files:** +- Create: `app/src-tauri/src/aws/stacks.rs`, with tests in `aws/stacks/tests.rs` +- Modify: `app/src-tauri/src/aws/error.rs` if a new variant is needed (`AlreadyExists { name }`) + +**Interfaces:** +- Produces: + ```rust + #[async_trait] pub trait StackApi: Send + Sync { + async fn create(&self, name: &str, body: &str, params: &[(String, String)], tags: &[(String, String)]) -> Result; // stack id; CAPABILITY_NAMED_IAM; OnFailure=ROLLBACK + async fn update(&self, name: &str, body: &str, keep_params: &[String], tags: &[(String, String)]) -> Result; // UsePreviousValue for keep_params + async fn delete(&self, name: &str) -> Result<(), AwsError>; + async fn describe(&self, name: &str) -> Result, AwsError>; // None when it does not exist + async fn events(&self, name: &str) -> Result, AwsError>; // newest first, as the API returns + async fn list_tagged(&self, key: &str, value: &str) -> Result, AwsError>; // DescribeStacks paged, filtered by tag client-side + } + pub struct SdkStackApi { … } impl SdkStackApi { pub fn new(cfg: &SdkConfig) -> Self } + pub enum UpdateOutcome { Started, AlreadyUpToDate } + #[derive(Serialize, Clone, Debug, PartialEq)] pub struct StackSummary { name, id, status: String, status_reason: Option, tags: Vec<(String,String)>, template_version: Option } // from tag triple-c:template-version + #[derive(Serialize, Clone, Debug, PartialEq)] pub struct StackEvent { id, timestamp: String, logical_id, resource_type, status, reason: Option } + pub fn is_terminal(status: &str) -> bool; // *_COMPLETE or *_FAILED, except *_IN_PROGRESS + pub fn is_success(status: &str) -> bool; // CREATE_COMPLETE, UPDATE_COMPLETE, DELETE_COMPLETE, IMPORT_COMPLETE + pub fn is_deletable_failure(status: &str) -> bool; // D6 list + pub async fn watch(api: &A, name: &str, poll: Duration, deadline: Duration, on_events: impl FnMut(&[StackEvent])) -> Result; + ``` + - `watch` reports each event once, in chronological order. Events that already existed when it started are reported in the first batch. It returns when `describe` is terminal (or `None` after a delete), and returns `Err(Timeout)` after `deadline`. + - The real poll interval is 3 s and the deadline is 60 min. Tests pass `Duration::ZERO` and a fake clock is not needed. + +- [ ] **Step 1: Write the failing tests** with a scripted fake `StackApi`: + - `an_update_with_no_changes_is_already_up_to_date`: the SDK `ValidationError` with the message `No updates are to be performed.` maps to `UpdateOutcome::AlreadyUpToDate`. Test the mapping function `update_error_outcome(&str)` directly. + - `creating_a_stack_that_exists_is_already_exists`: `AlreadyExistsException` maps to `AwsError::AlreadyExists { name }`. + - `watching_a_stack_already_in_progress_reports_its_existing_events_once`: polls return events [e1], then [e1, e2], then [e1, e2, e3] with a terminal status. The callback sees e1, then e2, then e3, each exactly once and in time order. + - `watch_ends_on_delete_complete_or_a_vanished_stack`. + - `statuses_are_classified`: `ROLLBACK_COMPLETE` is terminal, not a success, and deletable. `UPDATE_ROLLBACK_COMPLETE` is terminal, not a success, and not deletable. `CREATE_IN_PROGRESS` is not terminal. +- [ ] **Step 2: Run** `cargo test aws::stacks`. Expected: FAIL. +- [ ] **Step 3: Implement** `stacks.rs`, using `aws-sdk-cloudformation`. Classify errors through `classify_sdk_error`. +- [ ] **Step 4: Run** `cargo test aws::stacks`. Expected: PASS. +- [ ] **Step 5: Commit** with the message `feat(aws): CloudFormation stack operations and progress watching`. + +### Task 5: Detection (`aws/environments.rs`) + +**Files:** +- Create: `app/src-tauri/src/aws/environments.rs`, with tests in `aws/environments/tests.rs` + +**Interfaces:** +- Consumes: `templates::*`, `stacks::{StackApi, StackSummary, is_deletable_failure}`, and `identity::CallerIdentity`. +- Produces: + ```rust + #[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct EnvironmentInfo { schema: u32, name, template_version: u32, cluster_arn, security_group_id, subnet_ids: Vec, assign_public_ip: bool, kms_key_arn, log_group, network_mode, tenancy } + pub fn parse_environment_record(path_name: &str, value: &str, account: &str, region: &str) -> Result; + #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(tag = "state", rename_all = "snake_case")] + pub enum EnvironmentState { Ready, Deploying, UpdateAvailable, Failed { reason: Option }, StackMissing, RecordMissing, UnreadableRecord { reason: String } } + #[derive(Serialize, Clone, Debug, PartialEq)] pub enum EnvironmentAction { Join, Update, Delete, Watch } // snake_case + #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(tag = "state", rename_all = "snake_case")] + pub enum OnboardingState { Joined, NotJoined, InProgress, Failed { reason: Option } } + #[derive(Serialize, Clone, Debug, PartialEq)] pub struct DetectedEnvironment { name, stack_status: Option, template_version: Option, state: EnvironmentState, actions: Vec, info: Option, onboarding: Option } + #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(rename_all = "snake_case")] pub enum BoundaryState { Present, Missing, Unknown } + #[derive(Serialize, Clone, Debug)] pub struct EnvironmentScan { region, account, owner_id, owner_key, boundary: BoundaryState, environments: Vec } + pub fn merge(records: Vec<(String, Result)>, stacks: Vec, current_version: u32) -> Vec; // sorted by name; onboarding None + #[async_trait] pub trait DetectApi: Send + Sync { async fn environment_records(&self) -> Result, AwsError>; async fn boundary_exists(&self, account: &str) -> Result, AwsError>; async fn role_exists(&self, name: &str) -> Result, AwsError>; } // None = denied + pub async fn scan(d: &D, s: &S, who: &CallerIdentity, region: &str) -> Result; + ``` +- Rules: + - The `Join` action is offered only for `Ready` and `UpdateAvailable`. + - `UpdateAvailable` means the stack's `template_version` is below the current version. + - `UPDATE_ROLLBACK_COMPLETE` is `Ready`, with a note that the last update rolled back. + - Onboarding is resolved only for joinable environments: + 1. `describe(onboarding_stack_name)`. + 2. If the stack doesn't exist, `role_exists(task role)`. This catches an admin who deployed it under another stack name. + 3. If that is denied, `NotJoined`. + - The boundary state comes from `iam:GetPolicy`. If that is denied, the state is `Unknown`. +- `parse_environment_record` rejects: + - a value over 4 KiB, or one that isn't JSON; + - `schema != 1`; + - a `name` different from the path's last segment; + - any ARN not in `arn:aws::::` for this region and account; + - `subnet_ids` that isn't exactly 2 `subnet-[0-9a-f]+` IDs, or a security group that isn't `sg-[0-9a-f]+`; + - `tenancy != "strict"`. + + The rejection reason names the field and never echoes the value. + +- [ ] **Step 1: Write the failing tests:** + - `untrusted_environment_records_are_rejected`: a table of the rejections above, plus one valid record that parses. + - `a_parameter_without_a_stack_and_a_stack_without_a_parameter_are_both_listed`: gives `StackMissing` with no Join, and `RecordMissing` with Update. + - `half_deployed_stacks_get_the_right_actions`: + - `CREATE_IN_PROGRESS` → `Deploying` [Watch]; + - `ROLLBACK_COMPLETE` → `Failed` [Delete]; + - `DELETE_FAILED` → `Failed` [Delete]; + - `UPDATE_ROLLBACK_COMPLETE` → `Ready` [Join, Update]; + - `UPDATE_IN_PROGRESS` → `Deploying` [Watch]. + - `an_older_template_offers_update`: version 0 against current version 1 gives `UpdateAvailable` [Join, Update]. + - `onboarding_is_found_by_stack_then_by_role`: the stack present at `CREATE_COMPLETE` gives `Joined`. No stack but the role present gives `Joined`. No stack and no role gives `NotJoined`. Stack at `ROLLBACK_COMPLETE` gives `Failed`. + - `a_denied_boundary_lookup_is_unknown_not_missing`. +- [ ] **Step 2: Run** `cargo test aws::environments`. Expected: FAIL. +- [ ] **Step 3: Implement** it, with `SdkDetectApi`: `GetParametersByPath` on `/triple-c/environments` (non-recursive, paged), and `iam:GetPolicy`/`GetRole`. An `AccessDenied` maps to `None`. +- [ ] **Step 4: Run** the tests. Expected: PASS. +- [ ] **Step 5: Commit** with the message `feat(aws): detect environments, the boundary and onboarding`. + +### Task 6: Commands, progress events and admin requests + +**Files:** +- Create: `app/src-tauri/src/commands/environment_commands.rs` +- Modify: `commands/mod.rs`, `lib.rs` (`generate_handler!`), `capabilities/default.json` +- Modify: `app/src/lib/types.ts`, `app/src/lib/tauri-commands.ts` + +**Interfaces:** +- Produces these commands. Each takes `connection_id` and `region` (validated with `validate_supported_region`). An `op_id` is a new UUID. + ```rust + detect_aws_environments(connection_id, region) -> Result + deploy_aws_environment(connection_id, region, name, network: NetworkChoice, app) -> Result // validates name; refuses if boundary Missing + update_aws_environment(connection_id, region, name, app) -> Result // keep_params = all template params + delete_failed_aws_stack(connection_id, region, stack_name, app) -> Result // only if describe() status is_deletable_failure and stack name is one of ours + watch_aws_stack(connection_id, region, stack_name, app) -> Result // attach to an in-progress stack + onboard_aws_user(connection_id, region, environment, app) -> Result // self-onboarding with the caller's own owner id + deploy_aws_permissions_boundary(connection_id, region, app) -> Result + aws_admin_request(connection_id, region, kind: TemplateKind, environment: Option, owner_id: Option) -> Result + save_aws_template(kind: TemplateKind, window) -> Result, String> // Rust-side save dialog (pattern: file_commands::pick_save_path); writes the embedded body + ``` + - `AdminRequest { stack_name, template_file_name, parameters: Vec<(String,String)>, tags: Vec<(String,String)>, capabilities: Vec, cli_command: String }`. + - `cli_command` is `aws cloudformation deploy --region --stack-name --template-file --capabilities CAPABILITY_NAMED_IAM --parameter-overrides K=V … --tags K=V …`, with every value shell-quoted by `commands::terminal_commands::shell_quote_arg`. Make that function `pub(crate)`; don't copy it. + - Every mutating command spawns `stacks::watch` and returns at once. + - Progress is emitted on the Tauri event `aws-stack-progress` as `{ op_id, stack_name, events: StackEvent[], done: bool, result?: { ok: bool, status, message? } }`. + - "Already up to date" is emitted as `done` with `ok: true` and `status: "NO_CHANGES"`. +- Stack names passed in from the frontend are checked to have the form `triple-c-*`, and to be one this app's naming functions can produce. The frontend can't name an arbitrary stack. + +- [ ] **Step 1: Write failing unit tests** in the module, against the pure helpers: + - `admin_requests_are_exact_and_quoted`: an onboarding request for env `dev` and owner `AROAX:o'brien@example.com` contains `OwnerId='AROAX:o'"'"'brien@example.com'`, or whatever the helper's quoting is (assert that it round-trips through `sh -c 'printf %s …'`). Its stack name is `triple-c-dev-user-`. + - `only_our_stacks_can_be_deleted_or_watched`: `triple-c-dev` and `triple-c-dev-user-0123456789ab` are accepted. `other-stack`, `triple-c-` and `triple-c-dev; x` are rejected. + - `deploy_refuses_when_the_boundary_is_missing`, using a fake that reports `Missing`. +- [ ] **Step 2: Run** `cargo test environment_commands`. Expected: FAIL. +- [ ] **Step 3: Implement** the commands, register them and grant them. Add the TS types and wrappers: `EnvironmentScan`, `DetectedEnvironment`, `NetworkChoice`, `AdminRequest`, `StackProgress`, `TemplateKind`. +- [ ] **Step 4: Run** `cargo test`, `cargo check` and `npx vitest run src/test/capabilities.test.ts`. Expected: PASS. +- [ ] **Step 5: Commit** with the message `feat(aws): environment commands with streamed stack progress`. + +### Task 7: Environments UI + +**Files:** +- Create: `app/src/components/settings/hosts/EnvironmentsPanel.tsx`, `DeployEnvironmentModal.tsx`, `AdminRequestModal.tsx`, `StackProgress.tsx`, plus a `*.test.tsx` for each of the first three +- Modify: `app/src/components/settings/HostsSettings.tsx`, which renders `` under each connection's permission check + +**Interfaces:** +- Consumes: the Task 6 wrappers and the `aws-stack-progress` event. + +**Behaviour** (spec §2's table, with phase-3 conventions: `StatusIndicator`, `Modal`, `Button`, `Field`, design tokens): +- **Region select.** Defaults to the connection's `default_region`. Detection runs when the panel opens and when the region changes. A "Detect again" button reruns it. +- **One row per environment**, showing its state, the template version and actions: + - **Join**: + - If `onboarding` is `Joined`, it shows "Joined" instead. + - Otherwise, an environment admin gets `onboardAwsUser` with progress. + - Anyone else gets the onboarding `AdminRequestModal`. The row then reads "Waiting for an admin to onboard you", and the request is regenerated each time the panel opens. + - **Update**: runs `updateAwsEnvironment` with progress. "Already up to date" is a success. + - **Delete**: a typed confirmation of the stack name, then `deleteFailedAwsStack`. + - **Watch**: `watchAwsStack`. +- **"Deploy a new environment."** + - It is the primary action when nothing is found and the tier is admin. It is secondary when environments exist. + - If the tier is not admin and nothing is found, show the explanation, the policy files from `awsPolicyDocument("environment_admin")`, and the environment `AdminRequestModal`. +- **`DeployEnvironmentModal`:** + - Name, defaulting to `main`, validated with the same rule as Rust (mirror the regex in TS; Rust is the boundary). + - Network, as a `SegmentedControl` with the D4 modes. Each mode states its cost: `public` "$0, tasks get a public IP, no inbound access"; `private-nat` "about $33/month for the NAT gateway"; `existing` shows three ID fields and the public-IP toggle. + - If the boundary is `Missing`, the modal first shows a step: "Create the permissions boundary (needs an account administrator)". That step offers `deployAwsPermissionsBoundary`. If that fails with access denied, it shows the boundary `AdminRequestModal`. + - If the boundary is `Unknown`, deploying is allowed, with a note that it fails if the boundary is missing. +- **`StackProgress`** shows events oldest first, with resource, status and reason. Failure reasons are shown in full. When done, it shows a result line and triggers re-detection. +- **`AdminRequestModal`:** "Save template…" (`saveAwsTemplate`), the parameters and tags table, and the CLI command with a Copy button. + +- [ ] **Step 1: Write failing component tests**, mocking `tauri-commands`: + - `shows deploy as primary when nothing is found and the user is an admin`. + - `offers an admin request instead of deploy for a workspace user with nothing found`. + - `a failed stack offers delete only after typing its name`. + - `join onboards an admin and shows progress until done, then re-detects`. + - `join for a workspace user opens the onboarding request with the exact command`. + - `deploy modal blocks on a missing boundary and offers to create it`. + - `the public network mode is the default and says it costs nothing`. +- [ ] **Step 2: Run** `npx vitest run src/components/settings/hosts`. Expected: FAIL. +- [ ] **Step 3: Implement** the components. +- [ ] **Step 4: Run** `npx vitest run` and `npx tsc --noEmit`. Expected: PASS. +- [ ] **Step 5: Commit** with the message `feat(aws): environments panel — detect, deploy, update, join`. + +### Task 8: Live verification, docs and spec + +**Files:** +- Modify: `app/src-tauri/src/aws/live_tests.rs`, adding the `environment` tests below +- Modify: `aws/README.md` (an "Environment and onboarding stacks" section, plus results in "Live verification"), `CLAUDE.md` (the `aws/` paragraph: templates, stacks, environments, D2), and the spec (phase 4 done, decisions D1–D8, the EFS isolation change, "To verify" updates) + +**Live tests** (ignored; they read `TRIPLEC_AWS_E2E_PROFILE` and `TRIPLEC_AWS_E2E_REGION`; run with `awsx cargo test aws::live_tests::environment -- --ignored --test-threads=1`): +- `an_environment_admin_can_deploy_join_update_and_remove_an_environment`: + 1. As the admin credentials, ensure the boundary exists. If it doesn't, deploy the boundary stack and record that it was created, so it can be removed at the end. + 2. Create the role `triple-c-e2e-envadmin`, trusted by the caller's account. Attach the admin tier's two policies as `triple-c-e2e-*` managed policies, and give it the boundary. + 3. Assume that role. Every following step runs as it, *only*. + 4. Deploy `triple-c-e2e` in `public` mode, and watch it to `CREATE_COMPLETE`. + 5. `scan` finds it `Ready` with a valid record. + 6. Self-onboard, and see `Joined`. + 7. Update, and get `AlreadyUpToDate`. + 8. Delete the onboarding stack, then the environment stack. + 9. Finally, as admin, delete the e2e role and policies, and the boundary if this test created it. + - Cleanup runs on failure too, through a guard. + - Every access-denied error is printed with its action, so a missing policy action shows up as a precise failure. +- `the_private_nat_mode_deploys` (separately ignored): an admin deploy in `private-nat` mode, then `CREATE_COMPLETE`, then delete. This costs about $0.05. + +- [ ] **Step 1: Write the tests.** +- [ ] **Step 2: Run them** with the e2e environment and fix any denied action. Each fix goes in the Task 3 policy files, the resource-action table, the check table and the README together, and is re-run until green. Record each action IAM required beyond the table in the README's "Live verification". +- [ ] **Step 3: Confirm cleanup.** List `triple-c-*` stacks, roles, policies and log groups, `triple-c` VPCs, EIPs and EFS file systems, and `alias/triple-c-*` KMS aliases in the region. Expected: none left, with KMS keys only pending deletion. Record the spend. +- [ ] **Step 4: Write the docs and spec updates.** Then run the full `cargo test`, `npx vitest run` and `npx tsc --noEmit`. Expected: PASS. +- [ ] **Step 5: Commit** with the message `test(aws): live environment lifecycle as an environment admin; docs`. -- 2.52.0 From fe7642ee2c4cc9e01878d97e34c2b5f3d2bc3a15 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 20:43:51 -0800 Subject: [PATCH 02/24] docs: phase 4 plan switches to OpenTofu with S3 state Co-Authored-By: Claude Opus 5.5 --- .../2026-11-14-aws-environment-stacks.md | 700 ++++++++++-------- 1 file changed, 372 insertions(+), 328 deletions(-) diff --git a/docs/superpowers/plans/2026-11-14-aws-environment-stacks.md b/docs/superpowers/plans/2026-11-14-aws-environment-stacks.md index bb952cd..fc3dea0 100644 --- a/docs/superpowers/plans/2026-11-14-aws-environment-stacks.md +++ b/docs/superpowers/plans/2026-11-14-aws-environment-stacks.md @@ -1,448 +1,492 @@ -# AWS Environment + Onboarding Stacks (Phase 4) Implementation Plan +# AWS Environment + Onboarding (Phase 4, OpenTofu) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** From Settings → Hosts, a user picks a region on an AWS connection. They see the Triple-C environments there, including half-deployed and outdated ones. They can deploy a new environment from the CloudFormation template that ships with the app, update or repair one, and join one. Joining onboards them automatically when they are allowed to, and otherwise produces an exact onboarding request for an admin. Strict tenancy is the only mode. +**Goal:** From Settings → Hosts, a user picks a region on an AWS connection and sees the Triple-C environments there, including incomplete and outdated ones. The user can: +- deploy a new environment from the Terraform module that ships with the app; +- update or repair an existing environment; +- join an environment. Joining onboards the user automatically if they're allowed to. Otherwise it produces an exact onboarding bundle for an admin. + +Strict tenancy is the only mode. **Architecture:** -- Three CloudFormation templates ship in `aws/`, compiled in with `include_str!`: - - the **environment** template; - - the per-user **onboarding** template; - - a one-off **permissions boundary** template. -- `aws/templates.rs` owns names, versions and stack parameters. -- `aws/stacks.rs` creates, updates, deletes and watches stacks behind a mockable trait. -- `aws/environments.rs` detects environments by merging the SSM parameters with the tagged stacks, and also reports the boundary and the caller's onboarding state. -- Commands live in a new `commands/environment_commands.rs`. Stack progress streams to the UI as Tauri events. -- The UI is an Environments panel on each AWS connection in `HostsSettings`. -- Static tests keep the templates and the environment-admin policy in step. An ignored live test deploys everything as a role that holds *only* the admin policies, then tears it down. +- **Modules.** Three Terraform modules (HCL, OpenTofu-compatible) ship in `aws/terraform/`: the environment, per-user onboarding, and the permissions boundary. They are compiled into the app with `include_str!`. +- **Running OpenTofu.** The app downloads a pinned OpenTofu release on first use and checks its SHA-256. It materializes a module into a working directory with a generated S3 backend and `terraform.tfvars.json`, then runs `tofu init` and `tofu apply -json`. The apply runs **detached**, so it survives the app closing. The app follows its log file and turns the JSON lines into progress events. +- **State.** State lives in one bucket per account and region, `triple-c-tfstate--`. The bucket is versioned and encrypted, blocks public access, and uses S3-native locking. The app creates it before the first deploy. +- **Detection.** The app merges the SSM environment records with the state objects and locks in that bucket. +- **Commands and UI.** The Tauri commands are in `commands/environment_commands.rs`. The UI is an Environments panel on each AWS connection. +- **Proof.** Static HCL tests keep the modules and the admin policies in step. An ignored live test deploys everything as a role that holds *only* the admin policies. **Tech Stack:** -- Rust and Tauri v2, with the existing `aws-sdk-cloudformation`, `aws-sdk-iam`, `aws-sdk-ssm` and `sha2`. No new crates. In particular, no `aws-sdk-ec2` (compile time): existing-VPC mode takes typed IDs. +- Rust and Tauri v2, with the existing `aws-sdk-ssm`, `aws-sdk-iam`, `sha2`, `tar` and `reqwest`, if present (otherwise the HTTP client the app already uses). +- **New dependencies:** + - `aws-sdk-s3`, with `default-features = false` and the same features as the other SDK crates. + - `zip` (deflate only), for the Windows release. + - `flate2`, if it is not already a direct dependency. + - `hcl-rs` as a **dev-dependency**, for the static module tests. +- OpenTofu itself is downloaded at runtime and is never bundled. The version is pinned in source (`TOFU_VERSION`), along with one SHA-256 per platform taken from that release's `SHA256SUMS`. The AWS provider is pinned in `versions.tf`, and the module's committed `.terraform.lock.hcl` carries hashes for every shipped platform. - React, TypeScript and vitest. **Spec:** `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`. Relevant sections: - "User flow" §2; - "Connectivity" (network); - "Storage"; -- "Secrets and uploads"; - "Multi-user accounts" (strict tenancy); - "Shipped AWS artefacts"; - "Phase 3: policy verification and rulings". -**Branch:** `feat/aws-environments` off `main` (3bc0e9f). +**Branch:** `feat/aws-environments`. A superseded CloudFormation start is parked in `git stash` ("phase4 task1 CFN WIP"). Drop that stash once Task 1 lands. -## Decisions this plan makes +## Decisions (user, 2026-11-14) -These go in the spec in Task 8. Each is a deviation or a resolved gap, so the reviewer should read them first. +The user chose: +- **Terraform over CloudFormation;** +- the **app runs it** itself; +- **OpenTofu**, which is MPL-2.0 and safe for the app to download and run; +- **state in an S3 bucket** in the account. -- **D1. Templates are JSON**: `aws/triple-c-environment.json`, `aws/triple-c-user-onboard.json` and `aws/triple-c-permissions-boundary.json`. - - CloudFormation takes JSON natively, and the app already depends on `serde_json`. - - There is no YAML crate in the tree, and the drift tests must parse the templates. -- **D2. Strict tenancy gives each user their own EFS file system**, created by their onboarding stack. The spec had one file system per environment. - - In the single-file-system design, the file system policy would have to bind each access point to its owner's task role. EFS can't express that: `ClientMount` sees the access point's ARN, not its tags, and access point IDs are random. - - With one file system per user, its policy allows only that user's task role. An empty file system costs nothing. - - The environment keeps the shared parts: cluster, security group, KMS key, log group and network. -- **D3. No ECR in v1.** Tasks pull `ghcr.io/shadowdao/triple-c-sandbox` straight from GHCR, which the spec allows ("the default when the environment has no ECR mirror"). An ECR mirror is later work. -- **D4. Network modes** (template parameter `NetworkMode`): - - `public` (**default**): creates a VPC with two public subnets. Tasks get a public IP behind a security group with **no inbound rules**. There is no NAT gateway, so it costs $0. - - `private-nat`: creates a VPC with two public and two private subnets and one NAT gateway. Tasks run in the private subnets. The UI says this costs about $33/month plus data. - - `existing`: `VpcId`, `SubnetA`, `SubnetB` (in different AZs) and `AssignPublicIp`. - - The spec's "private subnets" becomes one option, not the only one. The no-inbound rule holds in every mode. -- **D5. An admin request is a template file plus a CLI command, not a console link.** CloudFormation quick-create links need the template at an S3 URL, and Triple-C has no public bucket. - - The request offers to save the template, using a Rust-side save dialog like `pick_save_path`. - - It shows the exact parameters and tags. - - It gives a copyable `aws cloudformation deploy …` command. -- **D6. The app deletes only failed or half-deployed stacks.** Those are `ROLLBACK_COMPLETE`, `ROLLBACK_FAILED`, `CREATE_FAILED` and `DELETE_FAILED`. Deleting a working environment or onboarding stack stays in the console for v1, which is the irreversible case. -- **D7. The admin tier is two policy files**, `environment-admin.json` and `environment-admin-network.json`. Both are required for the admin tier. The VPC actions don't fit in one 6,144-character managed policy (the admin file is already about 5,530). `aws_policy_document` returns every file for a tier. -- **D8. The workspace-user policy is unchanged.** Detection uses only reads it already grants: - - `ssm:GetParameter*` on `/triple-c/*`, which covers `GetParametersByPath` on `/triple-c/environments`; - - `cloudformation:DescribeStacks` and `ListStacks`; +The plan adds the decisions below. Task 8 writes all of them into the spec, replacing the CloudFormation text. + +- **D2. Strict tenancy gives each user their own EFS file system**, created by their onboarding module. + - Why: EFS can't bind an access point to its owner's task role. `ClientMount` sees the access point's ARN, not its tags, and access point IDs are random. + - The file system policy allows only that user's task role. An empty file system costs nothing. + - The environment keeps the shared pieces: cluster, security group, KMS key, log group and network. +- **D3. No ECR in v1.** Tasks pull `ghcr.io/shadowdao/triple-c-sandbox` straight from GHCR. The spec allows this ("the default when the environment has no ECR mirror"). +- **D4. Network modes** (the `network_mode` variable): + - `public` (**default**): a new VPC with two public subnets. Tasks get a public IP, and the security group has **no inbound rules**. There is no NAT. It costs $0. + - `private-nat`: a new VPC with two public and two private subnets and one NAT gateway. The UI says this costs about $33/month. + - `existing`: the user supplies `vpc_id`, `subnet_ids` (exactly 2, in different AZs) and `assign_public_ip`. +- **D5. An admin request is a folder bundle.** The app writes it into a folder the user picks through a Rust-side folder dialog. It contains: + - the module's files; + - `backend.hcl`; + - `terraform.tfvars.json`; + - a `README.txt` with the exact commands: `tofu init -backend-config=backend.hcl` followed by `tofu apply`. Terraform 1.10+ works too. +- **D6. The app destroys only incomplete deployments.** That means state exists but there's no environment record, or the last apply failed. A working environment is destroyed by an admin running `tofu destroy` from a bundle, which is the irreversible case. +- **D7. The admin tier spans several policy files.** It is `environment-admin.json` and `environment-admin-network.json`, plus a third file if the live test needs one. All of them are required for the tier. `aws_policy_document` returns every file for a tier. +- **D8. CloudFormation leaves the policies**, so the `cloudformation:*` actions, checks and probes are removed. The workspace-user policy loses `ReadStacks` and gains nothing. Detection for workspace users reads only: + - `ssm:GetParameter*` on `/triple-c/*`; - `iam:GetRole` on `role/triple-c-*`. - It stays about 30 characters under the size limit. The split the phase-3 notes called for moves to phase 5, which adds actions. + Bucket reads (the state objects and locks) are admin-only. A workspace user sees only environments that have a record. +- **D9. One state key per deployment**, inside the per-account, per-region bucket: + - `environments//terraform.tfstate`; + - `users///terraform.tfstate`; + - `boundary/terraform.tfstate`. + + The lock is the matching `.tflock` object (S3-native locking, `use_lockfile = true`). A held lock is a deploy in progress. Its JSON (`Who`, `Created`, `Operation`) is shown. +- **D10. Applies are detached.** `tofu apply` runs in its own process group, with stdout and stderr going to `/apply.log` and its PID in `/apply.pid`. Closing the app doesn't interrupt it. On the next run, the app sees the live PID or the held lock and reattaches by following the log. The app sends an interrupt only if the user presses Cancel. OpenTofu then stops gracefully and saves state. ## Global Constraints -- Names. These are pure functions in `aws/templates.rs`, and nothing else builds these strings. - - **Environment name**: `^[a-z][a-z0-9-]{0,19}$`, not ending in `-`, with no `--`. - - **Environment stack**: `triple-c-`. **Parameter**: `/triple-c/environments/`. - - **Owner key**: the first 12 hex characters of `sha256(aws:userid)`. A `UserId` contains `:`, which IAM role names reject. - - **Onboarding stack**: `triple-c--user-`. - - **Per-user roles**: `triple-c---task` and `triple-c---exec`. The longest is 47 characters, under the 64 limit. - - **Boundary stack**: `triple-c-permissions-boundary`. It creates the managed policy `triple-c-permissions-boundary`, with no path. -- Tags. - - Every taggable resource in the environment template carries `triple-c:component=environment` and `triple-c:environment=`. - - In the onboarding template, every taggable resource carries `triple-c:component=user-onboarding`, `triple-c:environment=` and `triple-c:owner=`. - - Stacks are tagged the same way, plus `triple-c:template-version=`. - - Workspace users never set `triple-c:component`; phase 3 depends on that. -- Every IAM role a template creates has `PermissionsBoundary` set to `arn:aws:iam::${AWS::AccountId}:policy/triple-c-permissions-boundary`. Trust is `ecs-tasks.amazonaws.com` with `aws:SourceAccount = ${AWS::AccountId}`. -- No stack uses a service role (`RoleARN`). The admin policy denies one, so CloudFormation acts with the caller's own credentials. -- The security group has **no** `SecurityGroupIngress`, inline or as a separate resource. -- The KMS key policy delegates to IAM (`arn:aws:iam::${AWS::AccountId}:root`, `kms:*`). The key has rotation on and `PendingWindowInDays: 7`. -- The environment parameter's value is JSON, `EnvironmentInfo` schema 1 (Task 1 lists the fields). It is **untrusted input**. Anyone holding `ssm:PutParameter` on `/triple-c/*` can write it, so the app validates every field before use and caps the value at 4 KiB. -- Template versions are `ENVIRONMENT_TEMPLATE_VERSION: u32 = 1` and `ONBOARD_TEMPLATE_VERSION: u32 = 1`. Each is written in three places, and a test checks they agree: the template's `Metadata.TripleC.TemplateVersion`, the stack tag, and (for the environment) the parameter JSON. -- `TemplateBody` is limited to 51,200 bytes, and a test checks it. -- AWS calls use `aws::connection::sdk_config`, never `aws_config::defaults()`. -- Every new command needs `#[tauri::command]`, a `generate_handler!` entry and an `allow-*` grant in `capabilities/default.json`. `src/test/capabilities.test.ts` must pass. -- When a policy JSON changes, its checks in `policies.rs` and `aws/README.md` change in the same commit. Every policy fits in 6,144 characters with whitespace removed. -- Live AWS spending is capped by the user at $10 for this work, and every resource is cleaned up. Live tests use the scratchpad `awsx` wrapper, never the container's own `AWS_*` env. +- **Names.** These are pure functions in `aws/tofu_modules.rs`, and nothing else builds these strings. + - **Environment name:** `^[a-z][a-z0-9-]{0,19}$`, not ending in `-`, with no `--`. + - **Parameter:** `/triple-c/environments/`. + - **Owner key:** the first 12 hex characters of `sha256(aws:userid)`. A `UserId` contains `:`, which IAM role names reject. + - **Per-user roles:** `triple-c---task` and `triple-c---exec`. The longest is 47 characters, under the 64-character limit. + - **Boundary policy:** `triple-c-permissions-boundary`, with no path. + - **State bucket:** `triple-c-tfstate--`. The longest region gives 46 characters, under the 63-character limit. +- **Tags.** + - Each module's provider block sets `default_tags`: + - environment: `triple-c:component=environment`, `triple-c:environment=`, `triple-c:template-version=`; + - onboarding: `triple-c:component=user-onboarding`, `triple-c:environment`, `triple-c:owner=`, `triple-c:template-version`; + - boundary: `triple-c:component=boundary`. + - No resource overrides `triple-c:component`. + - The state bucket is tagged `triple-c:component=state`. + - Workspace users never set `triple-c:component`; phase 3 relies on that. +- **IAM roles.** Every role a module creates has `permissions_boundary` set to `arn::iam:::policy/triple-c-permissions-boundary`. It trusts `ecs-tasks.amazonaws.com`, with `aws:SourceAccount` set to the account. +- **No inbound access.** The environment security group has **no** ingress, inline or as a separate `aws_vpc_security_group_ingress_rule` or `aws_security_group_rule`. +- **KMS key.** The key policy delegates to IAM (`arn:aws:iam:::root`, `kms:*`). Rotation is on and `deletion_window_in_days = 7`. +- **The environment record** is an SSM `String` parameter holding JSON, `EnvironmentInfo` schema 1 (fields listed in Task 1). It is written by the module with `jsonencode`. It is **untrusted input**: the app validates every field and caps the value at 4 KiB. +- **Versions.** `ENVIRONMENT_MODULE_VERSION: u32 = 1` and `ONBOARD_MODULE_VERSION: u32 = 1`. Each appears in the Rust constant, the module's `locals.template_version` and the `template-version` default tag, and a test checks they agree. +- **The OpenTofu child's environment.** + - Every inherited `AWS_*` variable is removed, along with `TF_*` and `TOFU_*` except the ones the app sets. + - A profile connection gets `AWS_PROFILE=`. + - A keys connection gets `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` **in the child's environment only**. They never go in argv, files or logs. + - The app sets `TF_PLUGIN_CACHE_DIR`, `TF_IN_AUTOMATION=1`, `TF_INPUT=0` and `CHECKPOINT_DISABLE=1`. + - The provider block sets `region` explicitly. No `endpoints` override is allowed in any module. +- **Downloads.** The OpenTofu archive is verified against the pinned SHA-256 *before* extraction. On a mismatch the app deletes it and returns an error; it never runs an unverified binary. The provider is pinned by `.terraform.lock.hcl`, so `tofu init` verifies it. +- **Commands.** Every new command needs `#[tauri::command]`, a `generate_handler!` entry and an `allow-*` grant in `capabilities/default.json`. `src/test/capabilities.test.ts` must pass. +- **Policies.** When a policy JSON changes, its checks in `policies.rs` and `aws/README.md` change in the same commit. Every policy fits in 6,144 characters with whitespace removed. +- **Live AWS testing.** The user caps spending at $10, and every resource is cleaned up. Use the scratchpad `awsx` wrapper, never the container's own `AWS_*` environment. ## Review Focus -1. **A half-deployed stack**: `CREATE_IN_PROGRESS`, `ROLLBACK_COMPLETE`, `UPDATE_ROLLBACK_COMPLETE` or `DELETE_FAILED`. - - Show it with its state. - - Offer Delete only for the D6 states. - - Show an in-progress one with live progress, not as "Failed". - - `UPDATE_ROLLBACK_COMPLETE` is usable, so offer Update. - - Test: `half_deployed_stacks_get_the_right_actions` (Task 5). -2. **A record and its stack disagree.** - - A parameter with no stack is shown as "Stack missing" and is not joinable. - - A tagged stack with no parameter is shown as "Record missing — update to repair". - - Test: `a_parameter_without_a_stack_and_a_stack_without_a_parameter_are_both_listed` (Task 5). -3. **Update when nothing changed.** CloudFormation's `ValidationError` "No updates are to be performed." means "Already up to date", not a failure. Test: `an_update_with_no_changes_is_already_up_to_date` (Task 4). -4. **A hostile or garbled environment record**: bad JSON, 64 KiB of text, an ARN in another account or region, or a name that doesn't match its path. It is reported as "Unreadable environment record" and never used to build a request. Test: `untrusted_environment_records_are_rejected` (Task 5). -5. **The app closes mid-deploy.** The stack carries on. On the next detection, the in-progress stack is shown, and its progress can be followed from its existing events without starting a second deploy. Test: `watching_a_stack_already_in_progress_reports_its_existing_events_once` (Task 4). +1. **An apply that was cut off.** State and the lock are present, with no live process and no record. Expected: + - The environment shows as "Incomplete — last change was interrupted". + - It offers Repair (re-apply) and Destroy. + - It offers Unlock only when the lock is more than 2 hours old and isn't held by a live local process. + + Test: `interrupted_deployments_get_the_right_actions` (Task 5). +2. **A record and its state disagree.** + - A record with no state (deployed by hand elsewhere, or with the state deleted) shows as "Managed outside this account's Triple-C state". It is joinable, but has no Update or Destroy. + - State with no record is incomplete, as in item 1. + + Test: `a_record_without_state_and_state_without_a_record_are_both_listed` (Task 5). +3. **A hostile or garbled environment record**: bad JSON, 64 KiB, an ARN in another account or region, or a name that doesn't match its path. It is reported as "Unreadable environment record" and never used to build a request. Test: `untrusted_environment_records_are_rejected` (Task 5). +4. **The app closes mid-apply.** OpenTofu carries on. On the next detection, the deployment shows as "Deploying", and its progress replays from `apply.log` once, without starting a second apply. Test: `following_an_existing_log_reports_each_event_once` (Task 4). +5. **A tampered or truncated OpenTofu download.** Expected: deleted, an error, never executed, and a retry downloads again. Test: `a_download_with_the_wrong_hash_is_deleted_and_refused` (Task 4). --- -### Task 1: Environment template and `aws/templates.rs` +### Task 1: Environment module and `aws/tofu_modules.rs` **Files:** -- Create: `aws/triple-c-environment.json` -- Create: `app/src-tauri/src/aws/templates.rs`, with tests in `app/src-tauri/src/aws/templates/tests.rs` +- Create: `aws/terraform/environment/{versions.tf,variables.tf,main.tf,network.tf,outputs.tf,.terraform.lock.hcl}` +- Create: `app/src-tauri/src/aws/tofu_modules.rs`, with tests in `aws/tofu_modules/tests.rs` - Modify: `app/src-tauri/src/aws/mod.rs` +- Modify: `app/src-tauri/Cargo.toml`, adding `hcl-rs` under `[dev-dependencies]` **Interfaces:** -- Produces, in `aws::templates`: - ```rust - pub const ENVIRONMENT_TEMPLATE_VERSION: u32 = 1; - pub const ONBOARD_TEMPLATE_VERSION: u32 = 1; - pub const BOUNDARY_POLICY_NAME: &str = "triple-c-permissions-boundary"; // re-export of policies::PERMISSIONS_BOUNDARY_NAME - pub const BOUNDARY_STACK_NAME: &str = "triple-c-permissions-boundary"; - #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] - pub enum TemplateKind { Environment, UserOnboard, PermissionsBoundary } - pub fn template_body(kind: TemplateKind) -> &'static str; - pub fn template_file_name(kind: TemplateKind) -> &'static str; // "triple-c-environment.json", … - pub fn validate_environment_name(name: &str) -> Result; - pub fn environment_stack_name(env: &str) -> String; - pub fn environment_parameter_name(env: &str) -> String; - pub fn owner_key(user_id: &str) -> String; // 12 lowercase hex - pub fn onboarding_stack_name(env: &str, owner_key: &str) -> String; - pub fn user_role_names(env: &str, owner_key: &str) -> (String, String); // (task, exec) - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "mode", rename_all = "kebab-case")] - pub enum NetworkChoice { Public, PrivateNat, Existing { vpc_id: String, subnet_a: String, subnet_b: String, assign_public_ip: bool } } - pub fn environment_parameters(env: &str, network: &NetworkChoice) -> Result, String>; // validates vpc-/subnet- ids - pub fn stack_tags(kind: TemplateKind, env: Option<&str>, owner_id: Option<&str>) -> Vec<(String, String)>; - ``` -- Produces the `EnvironmentInfo` JSON shape. Task 5 parses it, and the template writes it with `Fn::Sub`: - `{"schema":1,"name","template_version","cluster_arn","security_group_id","subnet_ids":[2],"assign_public_ip":bool,"kms_key_arn","log_group","network_mode","tenancy":"strict"}`. -- Template parameters: `EnvironmentName`, `NetworkMode` (`public|private-nat|existing`), `VpcId`, `SubnetA`, `SubnetB`, `AssignPublicIp` (`true|false`) and `LogRetentionDays` (default 30). -- Exports, which Task 2's onboarding template imports: `triple-c--ClusterArn`, `-SecurityGroupId`, `-SubnetA`, `-SubnetB`, `-KmsKeyArn` and `-LogGroupName`. -- Resources, always created: the ECS cluster (`triple-c-`, `FARGATE` and `FARGATE_SPOT` capacity providers), the security group (egress all, no ingress), the KMS key and its alias `alias/triple-c-`, the log group `/triple-c/`, and the SSM parameter. -- Resources, conditional on network mode: the VPC, IGW, subnets, route tables, and the NAT gateway and EIP. - -- [ ] **Step 1: Write the failing tests** in `templates/tests.rs`: - - `environment_names_are_validated`: accepts `dev` and `team-a1`. Rejects `""`, `Dev`, `1dev`, `dev-`, `a--b`, a 21-character name, and `dev/x`. - - `owner_keys_are_stable_and_role_safe`: `owner_key("AROAEXAMPLE:jdoe@example.com")` is 12 characters from `[0-9a-f]`, is the same on every call, and differs from `owner_key("AROAEXAMPLE:jdoe2@example.com")`. `user_role_names("abcdefghijklmnopqrst", &key)` are each at most 64 characters. - - `the_environment_template_parses_and_fits`: `template_body(Environment)` parses as JSON and is under 51,200 bytes. `Metadata.TripleC.TemplateVersion == ENVIRONMENT_TEMPLATE_VERSION`. - - `the_security_group_has_no_ingress`: there is no `AWS::EC2::SecurityGroupIngress` resource and no `SecurityGroupIngress` property. - - `every_taggable_environment_resource_is_tagged_component_environment`: every resource with a `Tags` property has `triple-c:component=environment` and `triple-c:environment` (`Ref EnvironmentName`). The ECS cluster, KMS key, log group, security group, VPC pieces and the SSM parameter all count; resource types that can't take tags are excluded by an explicit list in the test. - - `the_environment_record_renders_to_valid_json`: substitute fake values into the parameter's `Fn::Sub` string, with the network-mode conditions resolved each way. The result parses into the field list above, with `schema == 1` and `template_version == 1`. - - `exports_are_named_for_the_environment`: all six export names are `Fn::Sub` of `triple-c-${EnvironmentName}-`. - - `environment_parameters_cover_every_network_mode`: `Existing` with `vpc-0abc`/`subnet-1`/`subnet-2` gives all six parameters. A bad ID (`vpc-;`) is an error. `Public` sets `NetworkMode=public` and leaves the existing-VPC parameters at their template defaults. -- [ ] **Step 2: Run** `cargo test aws::templates`. Expected: FAIL (module missing). -- [ ] **Step 3: Write `aws/triple-c-environment.json` and implement `templates.rs`.** - - Use the long-form intrinsics; JSON has no short form. - - The record's JSON booleans come from `Fn::If` returning the literal text `true`/`false` inside the `Fn::Sub`. - - `subnet_ids` comes from conditions selecting the created or the existing subnets. -- [ ] **Step 4: Run** `cargo test aws::templates`. Expected: PASS. -- [ ] **Step 5: Validate the template against CloudFormation.** Run `awsx aws cloudformation validate-template --template-body file://aws/triple-c-environment.json --region us-west-2`. Expected: no error. This is free and creates nothing. -- [ ] **Step 6: Commit** with the message `feat(aws): environment CloudFormation template and stack naming`. - -### Task 2: Onboarding and boundary templates - -**Files:** -- Create: `aws/triple-c-user-onboard.json` -- Create: `aws/triple-c-permissions-boundary.json` -- Modify: `app/src-tauri/src/aws/templates.rs`, `templates/tests.rs` - -**Interfaces:** -- Consumes: Task 1's names, exports and tags. - Produces: ```rust - pub fn onboarding_parameters(env: &str, owner_id: &str) -> Vec<(String, String)>; // EnvironmentName, OwnerId, OwnerKey (= owner_key(owner_id)), BedrockOnTaskRole=false + pub const ENVIRONMENT_MODULE_VERSION: u32 = 1; + pub const ONBOARD_MODULE_VERSION: u32 = 1; + pub const BOUNDARY_POLICY_NAME: &str = "triple-c-permissions-boundary"; // = policies::PERMISSIONS_BOUNDARY_NAME + #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] + pub enum ModuleKind { Environment, UserOnboard, PermissionsBoundary } + pub fn module_files(kind: ModuleKind) -> &'static [(&'static str, &'static str)]; // (relative file name, contents), include_str!, incl. .terraform.lock.hcl + pub fn validate_environment_name(name: &str) -> Result; + pub fn environment_parameter_name(env: &str) -> String; + pub fn owner_key(user_id: &str) -> String; + pub fn user_role_names(env: &str, owner_key: &str) -> (String, String); // (task, exec) + pub fn state_bucket_name(account: &str, region: &str) -> String; + pub fn state_key(kind: ModuleKind, env: Option<&str>, owner_key: Option<&str>) -> String; // D9 + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "mode", rename_all = "kebab-case")] + pub enum NetworkChoice { Public, PrivateNat, Existing { vpc_id: String, subnet_ids: Vec, assign_public_ip: bool } } + pub fn environment_vars(env: &str, region: &str, network: &NetworkChoice) -> Result; // tfvars JSON; validates vpc-/subnet- ids, exactly 2 subnets ``` -- The onboarding template's parameters are `EnvironmentName`, `OwnerId`, `OwnerKey` and `BedrockOnTaskRole` (`false` by default). -- It creates the following resources. - - **Task role** `triple-c---task`. It may use: +- Variables: `environment_name`, `region`, `network_mode`, `vpc_id` (default `""`), `subnet_ids` (default `[]`), `assign_public_ip` (default `false`) and `log_retention_days` (default 30). Each has a `validation` block that matches the Rust rules. +- Resources: + - the ECS cluster `triple-c-`, with `aws_ecs_cluster_capacity_providers` for FARGATE and FARGATE_SPOT; + - the security group `triple-c--tasks`, with egress set to all and no ingress; + - the KMS key and the alias `alias/triple-c-`; + - the log group `/triple-c/`; + - the network resources, according to `network_mode` (`count` or `for_each`); + - the SSM parameter. +- The record is the SSM parameter value, `jsonencode({...})` with these fields: `schema` (1), `name`, `template_version`, `cluster_arn`, `security_group_id`, `subnet_ids` (exactly 2), `assign_public_ip`, `kms_key_arn`, `log_group`, `network_mode`, and `tenancy` (always `"strict"`). Task 2's onboarding module reads it, and Task 5 parses it. +- `versions.tf` pins `required_version >= 1.10` and `hashicorp/aws` to an exact `6.x` version. Choose the current one, and write it in the report. It also declares an **empty** `backend "s3" {}`, which the app fills in with `-backend-config`. + +- [ ] **Step 1: Install OpenTofu locally for development** (sudo is available), using the same pinned version Task 4 will use. Choose the latest stable 1.x release and record it in the report. +- [ ] **Step 2: Write the failing tests** in `tofu_modules/tests.rs`. Parse the module with `hcl-rs`. + - `environment_names_are_validated`: accepts `dev` and `team-a1`. Rejects `""`, `Dev`, `1dev`, `dev-`, `a--b`, a 21-character name and `dev/x`. + - `owner_keys_are_stable_and_role_safe`: the key is 12 characters in `[0-9a-f]` and stable. Different user IDs give different keys. Both role names are at most 64 characters for a 20-character environment name. + - `state_bucket_and_keys_follow_d9`. + - `the_module_versions_agree`: `locals.template_version` equals the constant, and `default_tags` carries it. + - `default_tags_mark_the_environment_and_nothing_overrides_component`. + - `the_security_group_has_no_ingress`: there are no `ingress` blocks, and no ingress-rule resources. + - `no_provider_endpoint_overrides`. + - `the_record_has_exactly_the_schema_fields`: the `jsonencode` object's keys equal the field list above. + - `environment_vars_cover_every_network_mode`: `existing` with 1 or 3 subnets, or `vpc-;`, is an error. +- [ ] **Step 3: Run** `cargo test aws::tofu_modules`. Expected: FAIL. +- [ ] **Step 4: Write the module and implement the module.** + - Generate `.terraform.lock.hcl` with `tofu providers lock -platform=linux_amd64 -platform=linux_arm64 -platform=darwin_amd64 -platform=darwin_arm64 -platform=windows_amd64`. + - Run `tofu fmt -check` and `tofu init -backend=false && tofu validate` in the module directory. + - Don't commit `.terraform/`. Add it to `.gitignore`. +- [ ] **Step 5: Run** the tests and `tofu validate`. Expected: PASS and "Success!". +- [ ] **Step 6: Commit** with the message `feat(aws): environment Terraform module and naming`. + +### Task 2: Onboarding and boundary modules + +**Files:** +- Create: `aws/terraform/user-onboard/*` and `aws/terraform/permissions-boundary/*`, with the same file set as Task 1 +- Modify: `aws/tofu_modules.rs`, `tofu_modules/tests.rs` + +**Interfaces:** +- Produces: + ```rust + pub fn onboarding_vars(env: &str, region: &str, owner_id: &str) -> serde_json::Value; // environment_name, region, owner_id, owner_key, bedrock_on_task_role=false + pub fn boundary_vars(region: &str) -> serde_json::Value; // region, policy_json = policies::permissions_boundary_json() + ``` +- **Onboarding module.** It reads the environment through `data "aws_ssm_parameter"` and `jsondecode`, and creates: + - **The task role** `triple-c---task`. It allows: - `ssmmessages:CreateControlChannel/CreateDataChannel/OpenControlChannel/OpenDataChannel` on `*`; - `logs:CreateLogStream/PutLogEvents` on the environment's log group; - `elasticfilesystem:ClientMount/ClientWrite` on *this user's* file system; - - Bedrock invoke, only under the `BedrockOnTaskRole` condition. - - **Execution role** `triple-c---exec`. It may use: - - `secretsmanager:GetSecretValue` on `triple-c/*` where `aws:ResourceTag/triple-c:owner` equals the `OwnerId` parameter; - - `kms:Decrypt` on the environment key via `secretsmanager.${AWS::Region}.amazonaws.com`; - - `logs:CreateLogStream/PutLogEvents` on the environment's log group. - - **The user's EFS file system.** It is encrypted, `PerformanceMode: generalPurpose`, `ThroughputMode: elastic`, and has two mount targets (the environment's subnets and security group). - - **That file system's policy:** - - Deny `aws:SecureTransport=false`. - - Deny any request where `elasticfilesystem:AccessPointArn` is null. - - Allow `ClientMount` and `ClientWrite` only to the task role's ARN. - - **The security group rule.** EFS needs inbound NFS (2049) **from the environment security group to the mount targets**. This must not open ingress on the tasks. So the onboarding stack creates its own mount-target security group `triple-c---efs`, with ingress 2049 only from the environment security group, and the environment group stays ingress-free. -- The boundary template is one `AWS::IAM::ManagedPolicy` named `triple-c-permissions-boundary`. Its `PolicyDocument` equals `aws/policies/permissions-boundary.json`. + - Bedrock invoke, only when `bedrock_on_task_role` is set. + - **The execution role** `triple-c---exec`. It allows: + - `secretsmanager:GetSecretValue` on `triple-c/*`, where `aws:ResourceTag/triple-c:owner` equals `var.owner_id`; + - `kms:Decrypt` on the environment key, via `secretsmanager..amazonaws.com`; + - the same log permissions as the task role. + - **The user's EFS file system**: encrypted, `generalPurpose`, elastic throughput, with a mount target in each of the environment's two subnets. + - **The file system policy:** + - Deny when `aws:SecureTransport=false`. + - Deny when `elasticfilesystem:AccessPointArn` is null. + - Allow `ClientMount` and `ClientWrite` to the task role's ARN only. + - **The mount-target security group** `triple-c---efs`. Its only ingress is TCP 2049 from the environment security group. The environment group itself never gains ingress. +- **Boundary module.** One `aws_iam_policy` named `triple-c-permissions-boundary`, whose `policy` is `var.policy_json`. The app passes the shipped JSON, so there is no second copy to drift. - [ ] **Step 1: Write the failing tests:** - - `every_onboarding_role_carries_the_boundary_and_owner_tag`: every `AWS::IAM::Role` has `PermissionsBoundary` equal to the `Fn::Sub` of the boundary ARN, and the tag `triple-c:owner = Ref OwnerId`. `RoleName` is `Fn::Sub` of `triple-c-${EnvironmentName}-${OwnerKey}-task` or `-exec`. - - `role_trust_is_ecs_tasks_in_this_account_only`: the trust principal is `ecs-tasks.amazonaws.com` with a `StringEquals aws:SourceAccount` condition. - - `the_users_file_system_admits_only_their_task_role_through_an_access_point`: the file system policy has the two Denies and one Allow whose principal is `Fn::GetAtt TaskRole.Arn`. - - `the_environment_security_group_gains_no_ingress`: the only ingress anywhere in the onboarding template is on the `-efs` group, port 2049, sourced from `Fn::ImportValue triple-c-${EnvironmentName}-SecurityGroupId`. - - `exec_role_reads_only_its_owners_secrets`: the `GetSecretValue` statement is conditioned on `aws:ResourceTag/triple-c:owner` = `Ref OwnerId`. - - `the_boundary_template_matches_the_shipped_boundary`: parse both. The template's `PolicyDocument` equals `permissions-boundary.json` as JSON values, and `ManagedPolicyName == BOUNDARY_POLICY_NAME`. - - `every_onboarding_role_stays_inside_the_boundary`: every action each role is granted is allowed by `permissions-boundary.json`, under the Task 3 evaluator. **Write this test in Task 3** once the evaluator is shared, and leave it as a TODO here. -- [ ] **Step 2: Run** `cargo test aws::templates`. Expected: FAIL. -- [ ] **Step 3: Write both templates and `onboarding_parameters`.** -- [ ] **Step 4: Run the tests** and `awsx aws cloudformation validate-template` on both files. Expected: PASS, and both valid. -- [ ] **Step 5: Commit** with the message `feat(aws): per-user onboarding and permissions-boundary templates`. + - `every_onboarding_role_carries_the_boundary_and_owner_tag`. + - `role_trust_is_ecs_tasks_in_this_account_only`. + - `the_users_file_system_admits_only_their_task_role_through_an_access_point`. + - `the_environment_security_group_gains_no_ingress`: the only ingress is the `-efs` group's port 2049 rule, sourced from the record's `security_group_id`. + - `exec_role_reads_only_its_owners_secrets`. + - `boundary_vars_carry_the_shipped_boundary`: `policy_json` equals `permissions-boundary.json`. + - `every_onboarding_role_stays_inside_the_boundary`: **write this in Task 3**, using the shared evaluator. +- [ ] **Step 2: Run** `cargo test aws::tofu_modules`. Expected: FAIL. +- [ ] **Step 3: Write both modules**, plus their lock files, and run `tofu fmt`, `tofu init -backend=false` and `tofu validate`. +- [ ] **Step 4: Run** the tests. Expected: PASS. +- [ ] **Step 5: Commit** with the message `feat(aws): onboarding and permissions-boundary Terraform modules`. -### Task 3: The admin policies cover the templates +### Task 3: The admin policies cover the modules; CloudFormation leaves the policies **Files:** -- Create: `aws/policies/environment-admin-network.json` -- Modify: `aws/policies/environment-admin.json` -- Modify: `app/src-tauri/src/aws/policies.rs` -- Modify: `app/src-tauri/src/aws/permissions.rs` (one tier is now several files) -- Modify: `app/src-tauri/src/commands/host_commands.rs` (`aws_policy_document`) -- Modify: `app/src/lib/tauri-commands.ts`, `app/src/lib/types.ts`, `components/settings/HostsSettings.tsx` (show and copy each file) +- Create: `aws/policies/environment-admin-network.json`, plus a third admin file only if the size limit forces it +- Modify: `aws/policies/environment-admin.json`, `aws/policies/workspace-user.json` (remove `ReadStacks`) +- Modify: `app/src-tauri/src/aws/policies.rs`, `permissions.rs`, `permissions/tests.rs` (drop the CFN checks and probe, and evaluate every file of a tier together) +- Modify: `commands/host_commands.rs` (`aws_policy_document -> Vec`), `app/src/lib/{types,tauri-commands}.ts`, `components/settings/HostsSettings.tsx` (show and copy each file) +- Modify: `app/src-tauri/Cargo.toml`: remove `aws-sdk-cloudformation` if nothing else uses it - Modify: `aws/README.md` -- Move: the test-only IAM evaluator from `policies.rs` `mod tests` into `app/src-tauri/src/aws/policy_eval.rs` (`#[cfg(test)]`), so the template tests can use it. Its behaviour is unchanged. +- Move: the test-only IAM evaluator from `policies.rs`'s `mod tests` to `aws/policy_eval.rs` (`#[cfg(test)]`). Its behaviour doesn't change. **Interfaces:** - Produces: ```rust - pub fn policy_files(tier: Tier) -> &'static [(&'static str, &'static str)]; // (file name, json); admin = [environment-admin.json, environment-admin-network.json] + pub fn policy_files(tier: Tier) -> &'static [(&'static str, &'static str)]; // replaces policy_json #[tauri::command] pub fn aws_policy_document(tier: Tier) -> Vec; // PolicyFile { name: String, json: String } ``` - `policy_json` is removed. Its callers use `policy_files`, and the simulator evaluates all of a tier's files together. -- A table in `templates/tests.rs` maps each template resource type to the IAM actions CloudFormation calls to create, tag, read, update and delete it. For example, `AWS::KMS::Key` maps to `kms:CreateKey`, `kms:TagResource`, `kms:DescribeKey`, `kms:GetKeyPolicy`, `kms:PutKeyPolicy`, `kms:EnableKeyRotation`, `kms:GetKeyRotationStatus`, `kms:ListResourceTags` and `kms:ScheduleKeyDeletion`. - - The table is the hypothesis. The Task 8 live test is the proof, and any action it finds missing goes into both the table and the policy. +- `tofu_modules/tests.rs` gets a table mapping each **Terraform resource type** used by the modules to the IAM actions the AWS provider calls to create, read (refresh), tag, update and delete it. For example, `aws_kms_key` maps to `kms:CreateKey`, `TagResource`, `DescribeKey`, `GetKeyPolicy`, `GetKeyRotationStatus`, `ListResourceTags`, `EnableKeyRotation`, `PutKeyPolicy` and `ScheduleKeyDeletion`. + - The table is the hypothesis. Task 8's live run is the proof, and any action it shows missing is added to the table, the policy, the check table and the README together. +- **State bucket permissions (admin):** + - `s3:CreateBucket` with `aws:RequestTag/triple-c:component`; + - `s3:PutBucketVersioning`, `PutEncryptionConfiguration`, `PutBucketPublicAccessBlock`, `PutBucketTagging`, `PutBucketPolicy`, `GetBucket*` and `ListBucket` on `arn:aws:s3:::triple-c-tfstate-*`; + - `s3:GetObject`, `PutObject` and `DeleteObject` on `arn:aws:s3:::triple-c-tfstate-*/*`. +- **Other admin additions:** + - `iam:GetPolicy` on the boundary ARN, which detection uses; + - the `iam:` read actions the provider uses on `role/triple-c-*`; + - the KMS, EFS, logs and SSM reads and tagging. +- **Network creation** goes in `environment-admin-network.json`: + - creates are tag-conditioned on `aws:RequestTag/triple-c:component`; + - mutations and deletes are conditioned on `aws:ResourceTag/triple-c:component`; + - `ec2:CreateTags` is allowed only under `ec2:CreateAction` for those creates. - [ ] **Step 1: Write the failing tests:** - - `the_admin_tier_can_deploy_every_template_resource`: for each resource in the environment and onboarding templates, every mapped action is allowed by the admin tier's files. Use the resource's tags (`triple-c:component` present) and resource names from Task 1's naming functions. - - `every_onboarding_role_stays_inside_the_boundary` (from Task 2). - - The existing `every_policy_fits_a_managed_policy` covers the new file. The existing check-table drift tests now require a check for every new admin action. - - `the_admin_still_cannot_touch_untagged_resources`: `ec2:DeleteVpc`, `ec2:DeleteSubnet` and `ec2:DeleteNatGateway` on a resource without `triple-c:component` are denied. + - `the_admin_tier_can_apply_every_module_resource`: every action mapped for every resource in the environment and onboarding modules is allowed by the admin tier's files. The test uses the `triple-c:component` tag and the names from Task 1. + - `every_onboarding_role_stays_inside_the_boundary`. + - `the_admin_still_cannot_touch_untagged_resources`: `ec2:DeleteVpc`, `DeleteSubnet`, `DeleteNatGateway` and `s3:DeleteBucket` on a resource without `triple-c:component`, or outside `triple-c-tfstate-*`, are denied. + - `no_policy_mentions_cloudformation`. + - The existing size and check-drift tests cover the new files. - [ ] **Step 2: Run** `cargo test aws::`. Expected: FAIL. -- [ ] **Step 3: Add the actions.** - - VPC, subnet, IGW, route, route-table, EIP and NAT go in `environment-admin-network.json`. - - Creation requires the `aws:RequestTag/triple-c:component` tag. - - Mutation and deletion require `aws:ResourceTag/triple-c:component`. - - `ec2:CreateTags` is allowed only with `ec2:CreateAction` set to one of those create calls. - - Everything else goes in `environment-admin.json`. That covers the extra KMS, ECS, logs, EFS and SSM read and tag actions, plus `iam:GetPolicy` on the boundary ARN, which detection uses. - - Add a check-table row for each new action, and update the README tier sections. -- [ ] **Step 4: Run** `cargo test aws::` and `npx vitest run src/components/settings`. Expected: PASS. -- [ ] **Step 5: Commit** with the message `feat(aws): admin policies cover the environment and onboarding stacks`. +- [ ] **Step 3: Implement it.** +- [ ] **Step 4: Run** `cargo test aws::`, then `npx vitest run src/components/settings`. Expected: PASS. +- [ ] **Step 5: Commit** with the message `feat(aws): admin policies cover the Terraform modules; drop CloudFormation`. -### Task 4: Stack operations (`aws/stacks.rs`) +### Task 4: The OpenTofu runner (`aws/tofu.rs`) **Files:** -- Create: `app/src-tauri/src/aws/stacks.rs`, with tests in `aws/stacks/tests.rs` -- Modify: `app/src-tauri/src/aws/error.rs` if a new variant is needed (`AlreadyExists { name }`) +- Create: `app/src-tauri/src/aws/tofu.rs`, with tests in `aws/tofu/tests.rs` +- Modify: `Cargo.toml` (`zip`, and `flate2` if needed) **Interfaces:** - Produces: ```rust - #[async_trait] pub trait StackApi: Send + Sync { - async fn create(&self, name: &str, body: &str, params: &[(String, String)], tags: &[(String, String)]) -> Result; // stack id; CAPABILITY_NAMED_IAM; OnFailure=ROLLBACK - async fn update(&self, name: &str, body: &str, keep_params: &[String], tags: &[(String, String)]) -> Result; // UsePreviousValue for keep_params - async fn delete(&self, name: &str) -> Result<(), AwsError>; - async fn describe(&self, name: &str) -> Result, AwsError>; // None when it does not exist - async fn events(&self, name: &str) -> Result, AwsError>; // newest first, as the API returns - async fn list_tagged(&self, key: &str, value: &str) -> Result, AwsError>; // DescribeStacks paged, filtered by tag client-side - } - pub struct SdkStackApi { … } impl SdkStackApi { pub fn new(cfg: &SdkConfig) -> Self } - pub enum UpdateOutcome { Started, AlreadyUpToDate } - #[derive(Serialize, Clone, Debug, PartialEq)] pub struct StackSummary { name, id, status: String, status_reason: Option, tags: Vec<(String,String)>, template_version: Option } // from tag triple-c:template-version - #[derive(Serialize, Clone, Debug, PartialEq)] pub struct StackEvent { id, timestamp: String, logical_id, resource_type, status, reason: Option } - pub fn is_terminal(status: &str) -> bool; // *_COMPLETE or *_FAILED, except *_IN_PROGRESS - pub fn is_success(status: &str) -> bool; // CREATE_COMPLETE, UPDATE_COMPLETE, DELETE_COMPLETE, IMPORT_COMPLETE - pub fn is_deletable_failure(status: &str) -> bool; // D6 list - pub async fn watch(api: &A, name: &str, poll: Duration, deadline: Duration, on_events: impl FnMut(&[StackEvent])) -> Result; + pub const TOFU_VERSION: &str = ""; + pub fn release_asset(os: &str, arch: &str) -> Option<(String /*file name*/, &'static str /*sha256*/)>; // linux/darwin amd64+arm64 (.tar.gz), windows amd64 (.zip) + pub async fn ensure_tofu(root: &Path) -> Result; // /tofu//tofu[.exe]; downloads from github.com/opentofu/opentofu/releases/download/v/, verifies, extracts, chmod 755; reuses an existing verified binary (re-hash on each call, cheap) + pub fn verify_and_extract(archive: &Path, expected_sha256: &str, dest: &Path) -> Result; // deletes archive on mismatch + pub struct Workdir { pub dir: PathBuf } + pub fn materialize(root: &Path, kind: ModuleKind, state_key: &str, bucket: &str, region: &str, vars: &serde_json::Value) -> Result; // /work///; writes module_files, backend.hcl (bucket, key, region, encrypt=true, use_lockfile=true), terraform.tfvars.json (0600) + pub fn child_env(conn: &HostConnection, keys: Option<&AwsKeys>, plugin_cache: &Path) -> Vec<(String, String)>; // the Global Constraints env, nothing inherited from AWS_/TF_/TOFU_ + pub enum TofuOp { Apply, Destroy } + pub fn start_detached(tofu: &Path, wd: &Workdir, op: TofuOp, env: &[(String, String)]) -> Result; // runs `init -input=false -backend-config=backend.hcl` then `apply|destroy -auto-approve -json -input=false`, as one detached shell-free sequence (a tiny runner: spawn init, wait, spawn apply) writing apply.log, apply.pid, apply.exit + pub fn is_running(wd: &Workdir) -> bool; // apply.pid alive and apply.exit absent + pub fn interrupt(wd: &Workdir) -> Result<(), AwsError>; // SIGINT (Windows: CTRL_BREAK to the process group) + #[derive(Serialize, Clone, Debug, PartialEq)] pub struct TofuEvent { pub ts: String, pub level: String, pub kind: String /* "@type" */, pub resource: Option, pub action: Option, pub message: String } + pub fn parse_log_line(line: &str) -> Option; // tofu -json lines; non-JSON lines (init output) become kind "log" + pub async fn follow(wd: &Workdir, poll: Duration, on_events: impl FnMut(&[TofuEvent])) -> Result; // from byte 0, each line once, until apply.exit exists + pub struct TofuOutcome { pub ok: bool, pub exit_code: i32, pub summary: Option } ``` - - `watch` reports each event once, in chronological order. Events that already existed when it started are reported in the first batch. It returns when `describe` is terminal (or `None` after a delete), and returns `Err(Timeout)` after `deadline`. - - The real poll interval is 3 s and the deadline is 60 min. Tests pass `Duration::ZERO` and a fake clock is not needed. +- Detaching: the runner is the app binary itself, re-executed with a hidden `--tofu-runner ` argument, so no shell is involved. + - The runner runs init and then apply, writes the exit code, and exits. + - On Unix it is spawned with `setsid` (`pre_exec` with `libc::setsid`). On Windows it uses `CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS`. + - Changing `main.rs` to handle the flag before Tauri starts is part of this task. + - The runner receives `child_env` by inheriting the environment the app gives it at spawn time. **Credentials never go on argv.** -- [ ] **Step 1: Write the failing tests** with a scripted fake `StackApi`: - - `an_update_with_no_changes_is_already_up_to_date`: the SDK `ValidationError` with the message `No updates are to be performed.` maps to `UpdateOutcome::AlreadyUpToDate`. Test the mapping function `update_error_outcome(&str)` directly. - - `creating_a_stack_that_exists_is_already_exists`: `AlreadyExistsException` maps to `AwsError::AlreadyExists { name }`. - - `watching_a_stack_already_in_progress_reports_its_existing_events_once`: polls return events [e1], then [e1, e2], then [e1, e2, e3] with a terminal status. The callback sees e1, then e2, then e3, each exactly once and in time order. - - `watch_ends_on_delete_complete_or_a_vanished_stack`. - - `statuses_are_classified`: `ROLLBACK_COMPLETE` is terminal, not a success, and deletable. `UPDATE_ROLLBACK_COMPLETE` is terminal, not a success, and not deletable. `CREATE_IN_PROGRESS` is not terminal. -- [ ] **Step 2: Run** `cargo test aws::stacks`. Expected: FAIL. -- [ ] **Step 3: Implement** `stacks.rs`, using `aws-sdk-cloudformation`. Classify errors through `classify_sdk_error`. -- [ ] **Step 4: Run** `cargo test aws::stacks`. Expected: PASS. -- [ ] **Step 5: Commit** with the message `feat(aws): CloudFormation stack operations and progress watching`. +- [ ] **Step 1: Write the failing tests:** + - `a_download_with_the_wrong_hash_is_deleted_and_refused`: write a fake archive, call `verify_and_extract` with another hash, and expect `Err`, with the archive gone and nothing extracted. + - `the_child_environment_carries_nothing_inherited`: set `AWS_ENDPOINT_URL`, `AWS_ACCESS_KEY_ID`, `TF_LOG` and `TOFU_X` in the test's view of the env (pass the parent env in as a parameter rather than mutating the process env). Expect none of them in `child_env`, except the connection's own keys or profile. Expect the four `TF_*` and `CHECKPOINT_*` values the app sets. + - `materialize_writes_backend_and_vars_privately`: `backend.hcl` holds the bucket, key, region, `use_lockfile = true` and `encrypt = true`. `terraform.tfvars.json` has mode `0600` on Unix. The module files are byte-identical. + - `following_an_existing_log_reports_each_event_once`: write 3 JSON lines, then 2 more, then `apply.exit`. The callback sees 5 events, in order, once each, and the outcome is `ok`. + - `apply_errored_and_diagnostics_surface_their_message`: parse a real `-json` `diagnostic` line (severity error) and an `apply_errored` line. + - `release_assets_exist_for_every_shipped_platform`. +- [ ] **Step 2: Run** `cargo test aws::tofu`. Expected: FAIL. +- [ ] **Step 3: Implement it.** Take the hashes from the release's `SHA256SUMS` file; verify that file's signature if practical, and say in the report what you did. +- [ ] **Step 4: Run** the tests, then do a local smoke test. With `ensure_tofu` into a temp root, `materialize` the boundary module with a dummy bucket, and run only `tofu init -backend=false` plus `validate` through the same binary. Expected: PASS. +- [ ] **Step 5: Commit** with the message `feat(aws): pinned OpenTofu download and a detached apply runner`. -### Task 5: Detection (`aws/environments.rs`) +### Task 5: The state bucket and detection (`aws/environments.rs`) **Files:** - Create: `app/src-tauri/src/aws/environments.rs`, with tests in `aws/environments/tests.rs` +- Modify: `Cargo.toml` (`aws-sdk-s3`) **Interfaces:** -- Consumes: `templates::*`, `stacks::{StackApi, StackSummary, is_deletable_failure}`, and `identity::CallerIdentity`. +- Consumes: `tofu_modules::*`, `tofu::is_running`/`Workdir` (for the local-process check), `identity::CallerIdentity`. - Produces: ```rust #[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct EnvironmentInfo { schema: u32, name, template_version: u32, cluster_arn, security_group_id, subnet_ids: Vec, assign_public_ip: bool, kms_key_arn, log_group, network_mode, tenancy } pub fn parse_environment_record(path_name: &str, value: &str, account: &str, region: &str) -> Result; + #[derive(Serialize, Clone, Debug, PartialEq)] pub struct LockInfo { who: String, created: String, operation: String, age_secs: u64, local_process: bool } #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(tag = "state", rename_all = "snake_case")] - pub enum EnvironmentState { Ready, Deploying, UpdateAvailable, Failed { reason: Option }, StackMissing, RecordMissing, UnreadableRecord { reason: String } } - #[derive(Serialize, Clone, Debug, PartialEq)] pub enum EnvironmentAction { Join, Update, Delete, Watch } // snake_case + pub enum EnvironmentState { Ready, Deploying { lock: Option }, UpdateAvailable, Incomplete { lock: Option }, ExternallyManaged, UnreadableRecord { reason: String } } + #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(rename_all = "snake_case")] pub enum EnvironmentAction { Join, Update, Repair, Destroy, Follow, Unlock } #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(tag = "state", rename_all = "snake_case")] - pub enum OnboardingState { Joined, NotJoined, InProgress, Failed { reason: Option } } - #[derive(Serialize, Clone, Debug, PartialEq)] pub struct DetectedEnvironment { name, stack_status: Option, template_version: Option, state: EnvironmentState, actions: Vec, info: Option, onboarding: Option } - #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(rename_all = "snake_case")] pub enum BoundaryState { Present, Missing, Unknown } - #[derive(Serialize, Clone, Debug)] pub struct EnvironmentScan { region, account, owner_id, owner_key, boundary: BoundaryState, environments: Vec } - pub fn merge(records: Vec<(String, Result)>, stacks: Vec, current_version: u32) -> Vec; // sorted by name; onboarding None - #[async_trait] pub trait DetectApi: Send + Sync { async fn environment_records(&self) -> Result, AwsError>; async fn boundary_exists(&self, account: &str) -> Result, AwsError>; async fn role_exists(&self, name: &str) -> Result, AwsError>; } // None = denied - pub async fn scan(d: &D, s: &S, who: &CallerIdentity, region: &str) -> Result; + pub enum OnboardingState { Joined, NotJoined, InProgress, Incomplete } + #[derive(Serialize, Clone, Debug, PartialEq)] pub struct DetectedEnvironment { name, template_version: Option, state: EnvironmentState, actions: Vec, info: Option, onboarding: Option } + #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(rename_all = "snake_case")] pub enum Knowable { Yes, No, Unknown } + #[derive(Serialize, Clone, Debug)] pub struct EnvironmentScan { region, account, owner_id, owner_key, state_bucket: Knowable, boundary: Knowable, environments: Vec } + pub struct StateObjects { pub states: Vec /* keys */, pub locks: Vec<(String, String /*lock json*/, u64 /*age*/)> } + pub fn merge(records: Vec<(String, Result)>, objects: Option<&StateObjects>, running_locally: &dyn Fn(&str) -> bool, current_version: u32) -> Vec; + #[async_trait] pub trait DetectApi: Send + Sync { + async fn environment_records(&self) -> Result, AwsError>; + async fn state_objects(&self, bucket: &str) -> Result, AwsError>; // None = bucket missing or denied + async fn bucket_exists(&self, bucket: &str) -> Result; + async fn boundary_exists(&self, account: &str) -> Result; + async fn role_exists(&self, name: &str) -> Result; + } + pub async fn scan(d: &D, who: &CallerIdentity, region: &str, running_locally: &dyn Fn(&str) -> bool) -> Result; + pub async fn ensure_state_bucket(cfg: &SdkConfig, account: &str, region: &str) -> Result; ``` -- Rules: - - The `Join` action is offered only for `Ready` and `UpdateAvailable`. - - `UpdateAvailable` means the stack's `template_version` is below the current version. - - `UPDATE_ROLLBACK_COMPLETE` is `Ready`, with a note that the last update rolled back. - - Onboarding is resolved only for joinable environments: - 1. `describe(onboarding_stack_name)`. - 2. If the stack doesn't exist, `role_exists(task role)`. This catches an admin who deployed it under another stack name. - 3. If that is denied, `NotJoined`. - - The boundary state comes from `iam:GetPolicy`. If that is denied, the state is `Unknown`. +- `ensure_state_bucket` creates the bucket with the `triple-c:component=state` tag. `LocationConstraint` is omitted in `us-east-1`. It then sets versioning, SSE-S3 (AES256) encryption, all four public-access blocks, and a bucket policy denying non-TLS requests. It is idempotent: `BucketAlreadyOwnedByYou` counts as success, and `BucketAlreadyExists` from another owner is an error that names the bucket. +- Rules for `merge`: + - **Ready**: a record with state at the current version. + - **UpdateAvailable**: a record with state at an older version. + - **Deploying**: the lock is held and either a local process is running or the lock is less than 2 hours old. The action is Follow when the process is local. + - **Incomplete**: state with no record and no live lock, or a stale lock. Actions are Repair and Destroy, plus Unlock when the lock is stale and not local. + - **ExternallyManaged**: a record with no state (or no bucket access). The only action is Join. + - Join is offered for Ready, UpdateAvailable and ExternallyManaged. + - Onboarding is resolved for joinable environments. Check the user's state key first, then `role_exists` on the task role. If that is denied, the state is `NotJoined`. - `parse_environment_record` rejects: - - a value over 4 KiB, or one that isn't JSON; - - `schema != 1`; - - a `name` different from the path's last segment; - - any ARN not in `arn:aws::::` for this region and account; - - `subnet_ids` that isn't exactly 2 `subnet-[0-9a-f]+` IDs, or a security group that isn't `sg-[0-9a-f]+`; - - `tenancy != "strict"`. + - more than 4 KiB, or not JSON; + - `schema` other than 1; + - a name that doesn't match the path; + - any ARN not in this account and region; + - anything other than exactly 2 `subnet-[0-9a-f]+` IDs; + - a security group that isn't `sg-[0-9a-f]+`; + - `tenancy` other than `strict`. - The rejection reason names the field and never echoes the value. + The reason names the field and never echoes the value. - [ ] **Step 1: Write the failing tests:** - - `untrusted_environment_records_are_rejected`: a table of the rejections above, plus one valid record that parses. - - `a_parameter_without_a_stack_and_a_stack_without_a_parameter_are_both_listed`: gives `StackMissing` with no Join, and `RecordMissing` with Update. - - `half_deployed_stacks_get_the_right_actions`: - - `CREATE_IN_PROGRESS` → `Deploying` [Watch]; - - `ROLLBACK_COMPLETE` → `Failed` [Delete]; - - `DELETE_FAILED` → `Failed` [Delete]; - - `UPDATE_ROLLBACK_COMPLETE` → `Ready` [Join, Update]; - - `UPDATE_IN_PROGRESS` → `Deploying` [Watch]. - - `an_older_template_offers_update`: version 0 against current version 1 gives `UpdateAvailable` [Join, Update]. - - `onboarding_is_found_by_stack_then_by_role`: the stack present at `CREATE_COMPLETE` gives `Joined`. No stack but the role present gives `Joined`. No stack and no role gives `NotJoined`. Stack at `ROLLBACK_COMPLETE` gives `Failed`. - - `a_denied_boundary_lookup_is_unknown_not_missing`. + - `untrusted_environment_records_are_rejected`: a table of those cases, plus one valid record. + - `a_record_without_state_and_state_without_a_record_are_both_listed`. + - `interrupted_deployments_get_the_right_actions`: + - a stale lock with no local process gives `Incomplete` with [Repair, Destroy, Unlock]; + - a fresh lock gives `Deploying` with no actions; + - a local process gives `Deploying` with [Follow]. + - `an_older_module_offers_update`. + - `onboarding_is_found_by_state_then_by_role`. + - `denied_lookups_are_unknown_not_missing`. - [ ] **Step 2: Run** `cargo test aws::environments`. Expected: FAIL. -- [ ] **Step 3: Implement** it, with `SdkDetectApi`: `GetParametersByPath` on `/triple-c/environments` (non-recursive, paged), and `iam:GetPolicy`/`GetRole`. An `AccessDenied` maps to `None`. +- [ ] **Step 3: Implement it**, including the SDK `DetectApi`: SSM `GetParametersByPath`, S3 `ListObjectsV2` with the prefixes `environments/`, `users/` and `boundary/` plus `GetObject` on `*.tflock`, and IAM `GetPolicy`/`GetRole`. Access denied maps to `Unknown` or `None`. - [ ] **Step 4: Run** the tests. Expected: PASS. -- [ ] **Step 5: Commit** with the message `feat(aws): detect environments, the boundary and onboarding`. +- [ ] **Step 5: Commit** with the message `feat(aws): state bucket, and detection of environments, locks and onboarding`. -### Task 6: Commands, progress events and admin requests +### Task 6: Commands, progress events and admin bundles **Files:** - Create: `app/src-tauri/src/commands/environment_commands.rs` -- Modify: `commands/mod.rs`, `lib.rs` (`generate_handler!`), `capabilities/default.json` -- Modify: `app/src/lib/types.ts`, `app/src/lib/tauri-commands.ts` +- Modify: `commands/mod.rs`, `lib.rs`, `capabilities/default.json`, `app/src/lib/{types,tauri-commands}.ts` **Interfaces:** -- Produces these commands. Each takes `connection_id` and `region` (validated with `validate_supported_region`). An `op_id` is a new UUID. +- Produces these commands. Each takes `connection_id` and `region` (validated). The work root is `dirs::data_dir()/triple-c/aws/`. An `op_id` is a UUID. ```rust detect_aws_environments(connection_id, region) -> Result - deploy_aws_environment(connection_id, region, name, network: NetworkChoice, app) -> Result // validates name; refuses if boundary Missing - update_aws_environment(connection_id, region, name, app) -> Result // keep_params = all template params - delete_failed_aws_stack(connection_id, region, stack_name, app) -> Result // only if describe() status is_deletable_failure and stack name is one of ours - watch_aws_stack(connection_id, region, stack_name, app) -> Result // attach to an in-progress stack - onboard_aws_user(connection_id, region, environment, app) -> Result // self-onboarding with the caller's own owner id + deploy_aws_environment(connection_id, region, name, network: NetworkChoice, app) -> Result // ensure_tofu, ensure_state_bucket, refuse if boundary == No, refuse if a record or state for `name` exists + update_aws_environment(connection_id, region, name, app) -> Result // re-apply with vars read back from the record (network from record; never change network on update) + repair_aws_environment(connection_id, region, name, network: Option, app) -> Result // re-apply an Incomplete one; network required if no record + destroy_incomplete_aws_environment(connection_id, region, name, app) -> Result // only when detection says Incomplete + unlock_aws_state(connection_id, region, state_key) -> Result<(), AwsError> // only for a stale, non-local lock (re-checked server-side), deletes the .tflock object + follow_aws_deployment(connection_id, region, state_key, app) -> Result + cancel_aws_deployment(connection_id, region, state_key) -> Result<(), AwsError> // tofu::interrupt + onboard_aws_user(connection_id, region, environment, app) -> Result // self-onboarding with the caller's own owner id deploy_aws_permissions_boundary(connection_id, region, app) -> Result - aws_admin_request(connection_id, region, kind: TemplateKind, environment: Option, owner_id: Option) -> Result - save_aws_template(kind: TemplateKind, window) -> Result, String> // Rust-side save dialog (pattern: file_commands::pick_save_path); writes the embedded body + save_aws_admin_bundle(connection_id, region, kind: ModuleKind, environment: Option, owner_id: Option, window) -> Result, AwsError> // Rust-side folder dialog; writes D5 bundle; returns the folder path ``` - - `AdminRequest { stack_name, template_file_name, parameters: Vec<(String,String)>, tags: Vec<(String,String)>, capabilities: Vec, cli_command: String }`. - - `cli_command` is `aws cloudformation deploy --region --stack-name --template-file --capabilities CAPABILITY_NAMED_IAM --parameter-overrides K=V … --tags K=V …`, with every value shell-quoted by `commands::terminal_commands::shell_quote_arg`. Make that function `pub(crate)`; don't copy it. - - Every mutating command spawns `stacks::watch` and returns at once. - - Progress is emitted on the Tauri event `aws-stack-progress` as `{ op_id, stack_name, events: StackEvent[], done: bool, result?: { ok: bool, status, message? } }`. - - "Already up to date" is emitted as `done` with `ok: true` and `status: "NO_CHANGES"`. -- Stack names passed in from the frontend are checked to have the form `triple-c-*`, and to be one this app's naming functions can produce. The frontend can't name an arbitrary stack. + - Progress is emitted on the Tauri event `aws-deploy-progress` as `{ op_id, state_key, events: TofuEvent[], done: bool, result?: { ok, exit_code, summary? } }`. + - State keys passed in from the frontend must equal a key `state_key()` can produce. Anything else is refused. + - The bundle's `README.txt` gives the exact commands for this account, region and bucket. Its vars come from the same `*_vars` functions the app applies with. The bucket must exist first, so the README says to deploy from the app once or to create the bucket by hand, and gives the CLI for that. -- [ ] **Step 1: Write failing unit tests** in the module, against the pure helpers: - - `admin_requests_are_exact_and_quoted`: an onboarding request for env `dev` and owner `AROAX:o'brien@example.com` contains `OwnerId='AROAX:o'"'"'brien@example.com'`, or whatever the helper's quoting is (assert that it round-trips through `sh -c 'printf %s …'`). Its stack name is `triple-c-dev-user-`. - - `only_our_stacks_can_be_deleted_or_watched`: `triple-c-dev` and `triple-c-dev-user-0123456789ab` are accepted. `other-stack`, `triple-c-` and `triple-c-dev; x` are rejected. - - `deploy_refuses_when_the_boundary_is_missing`, using a fake that reports `Missing`. +- [ ] **Step 1: Write the failing tests** against the pure helpers: + - `bundles_contain_the_module_backend_vars_and_readme`: `write_bundle(dir, …)` writes every `module_files` entry, plus `backend.hcl`, `terraform.tfvars.json` and a README naming the bucket and key. + - `only_our_state_keys_are_accepted`. + - `deploy_refuses_an_existing_name_and_a_missing_boundary`. + - `update_reuses_the_recorded_network`. - [ ] **Step 2: Run** `cargo test environment_commands`. Expected: FAIL. -- [ ] **Step 3: Implement** the commands, register them and grant them. Add the TS types and wrappers: `EnvironmentScan`, `DetectedEnvironment`, `NetworkChoice`, `AdminRequest`, `StackProgress`, `TemplateKind`. +- [ ] **Step 3: Implement** the commands, register them, grant them, and add the TS types and wrappers. - [ ] **Step 4: Run** `cargo test`, `cargo check` and `npx vitest run src/test/capabilities.test.ts`. Expected: PASS. -- [ ] **Step 5: Commit** with the message `feat(aws): environment commands with streamed stack progress`. +- [ ] **Step 5: Commit** with the message `feat(aws): environment commands with streamed OpenTofu progress`. ### Task 7: Environments UI **Files:** -- Create: `app/src/components/settings/hosts/EnvironmentsPanel.tsx`, `DeployEnvironmentModal.tsx`, `AdminRequestModal.tsx`, `StackProgress.tsx`, plus a `*.test.tsx` for each of the first three -- Modify: `app/src/components/settings/HostsSettings.tsx`, which renders `` under each connection's permission check +- Create: `app/src/components/settings/hosts/{EnvironmentsPanel,DeployEnvironmentModal,AdminBundleModal,DeployProgress}.tsx`, plus tests for the first three +- Modify: `HostsSettings.tsx`, which renders `` -**Interfaces:** -- Consumes: the Task 6 wrappers and the `aws-stack-progress` event. - -**Behaviour** (spec §2's table, with phase-3 conventions: `StatusIndicator`, `Modal`, `Button`, `Field`, design tokens): -- **Region select.** Defaults to the connection's `default_region`. Detection runs when the panel opens and when the region changes. A "Detect again" button reruns it. -- **One row per environment**, showing its state, the template version and actions: - - **Join**: - - If `onboarding` is `Joined`, it shows "Joined" instead. - - Otherwise, an environment admin gets `onboardAwsUser` with progress. - - Anyone else gets the onboarding `AdminRequestModal`. The row then reads "Waiting for an admin to onboard you", and the request is regenerated each time the panel opens. - - **Update**: runs `updateAwsEnvironment` with progress. "Already up to date" is a success. - - **Delete**: a typed confirmation of the stack name, then `deleteFailedAwsStack`. - - **Watch**: `watchAwsStack`. -- **"Deploy a new environment."** - - It is the primary action when nothing is found and the tier is admin. It is secondary when environments exist. - - If the tier is not admin and nothing is found, show the explanation, the policy files from `awsPolicyDocument("environment_admin")`, and the environment `AdminRequestModal`. +**Behaviour.** This follows spec §2's table. It uses `StatusIndicator`, `Modal`, `Button`, `Field`, `SegmentedControl` and the design tokens. +- **Detection.** A region select defaults to `default_region`. Detection runs when the panel opens and whenever the region changes. There is also a "Detect again" button. +- **Environment rows.** Each row shows the state (Lock owner and age are shown when present), the version, and its actions: + - Join; + - Update; + - Repair, which asks for the network if there is no record; + - Destroy, which requires typing the name; + - Follow; + - Unlock, which shows who holds the lock and since when, and needs a typed confirmation; + - Cancel, during a local deploy. +- **Joining.** + - When the user is already onboarded, the row reads "Joined". + - For an admin, Join runs `onboardAwsUser` and shows progress. + - Anyone else gets the `AdminBundleModal` for onboarding, and the row reads "Waiting for an admin to onboard you". +- **Deploying.** + - "Deploy a new environment" is the primary action when nothing is found and the user is an admin. It is secondary when environments exist. + - A non-admin with nothing found sees an explanation, the policy files (`awsPolicyDocument("environment_admin")`), and the environment `AdminBundleModal`. - **`DeployEnvironmentModal`:** - - Name, defaulting to `main`, validated with the same rule as Rust (mirror the regex in TS; Rust is the boundary). - - Network, as a `SegmentedControl` with the D4 modes. Each mode states its cost: `public` "$0, tasks get a public IP, no inbound access"; `private-nat` "about $33/month for the NAT gateway"; `existing` shows three ID fields and the public-IP toggle. - - If the boundary is `Missing`, the modal first shows a step: "Create the permissions boundary (needs an account administrator)". That step offers `deployAwsPermissionsBoundary`. If that fails with access denied, it shows the boundary `AdminRequestModal`. - - If the boundary is `Unknown`, deploying is allowed, with a note that it fails if the boundary is missing. -- **`StackProgress`** shows events oldest first, with resource, status and reason. Failure reasons are shown in full. When done, it shows a result line and triggers re-detection. -- **`AdminRequestModal`:** "Save template…" (`saveAwsTemplate`), the parameters and tags table, and the CLI command with a Copy button. + - Name: defaults to `main`, and is validated with the Rust rule mirrored in TS. + - Network: the D4 modes, each with its cost line. `public` reads "$0 — tasks get a public IP, no inbound access". `private-nat` reads "about $33/month for the NAT gateway". `existing` has a VPC ID, two subnet IDs and a public-IP toggle. + - If `boundary` is `no`, the modal first offers a step: "Create the permissions boundary (needs an account administrator)". If that is denied, it opens the boundary bundle. + - A first-run note says "First deploy downloads OpenTofu (~30 MB) and the AWS provider (~150 MB)." +- **`DeployProgress`.** It lists events in order: resource, action and message. Errors are shown in full. A final result line triggers re-detection. Cancel calls `cancelAwsDeployment` after a confirmation. +- **`AdminBundleModal`.** It explains the bundle and has a "Save bundle to folder…" button (`saveAwsAdminBundle`). After saving, it shows the saved path and the two commands, with a Copy button. -- [ ] **Step 1: Write failing component tests**, mocking `tauri-commands`: +- [ ] **Step 1: Write the failing tests:** - `shows deploy as primary when nothing is found and the user is an admin`. - - `offers an admin request instead of deploy for a workspace user with nothing found`. - - `a failed stack offers delete only after typing its name`. + - `offers an admin bundle instead of deploy for a workspace user with nothing found`. + - `an incomplete environment offers repair and destroy, destroy only after typing its name`. + - `a stale lock offers unlock with its owner and age`. - `join onboards an admin and shows progress until done, then re-detects`. - - `join for a workspace user opens the onboarding request with the exact command`. - `deploy modal blocks on a missing boundary and offers to create it`. - `the public network mode is the default and says it costs nothing`. - [ ] **Step 2: Run** `npx vitest run src/components/settings/hosts`. Expected: FAIL. -- [ ] **Step 3: Implement** the components. +- [ ] **Step 3: Implement it.** - [ ] **Step 4: Run** `npx vitest run` and `npx tsc --noEmit`. Expected: PASS. -- [ ] **Step 5: Commit** with the message `feat(aws): environments panel — detect, deploy, update, join`. +- [ ] **Step 5: Commit** with the message `feat(aws): environments panel — detect, deploy, update, repair, join`. ### Task 8: Live verification, docs and spec **Files:** -- Modify: `app/src-tauri/src/aws/live_tests.rs`, adding the `environment` tests below -- Modify: `aws/README.md` (an "Environment and onboarding stacks" section, plus results in "Live verification"), `CLAUDE.md` (the `aws/` paragraph: templates, stacks, environments, D2), and the spec (phase 4 done, decisions D1–D8, the EFS isolation change, "To verify" updates) +- Modify: `app/src-tauri/src/aws/live_tests.rs`, adding the `environment` tests +- Modify: `aws/README.md`. Add an "Environments (OpenTofu)" section covering the modules, the state bucket, bundles and running them by hand. Add the results to "Live verification". +- Modify: `CLAUDE.md`'s `aws/` paragraph, covering `tofu_modules`, `tofu`, `environments`, D2 and D10. +- Modify: the spec. Phase 4 is done. Replace the CloudFormation design text (§2 "Deploy uses the CloudFormation template…", stack detection, "Shipped AWS artefacts") with the OpenTofu design and decisions D2–D10, and update "To verify". **Live tests** (ignored; they read `TRIPLEC_AWS_E2E_PROFILE` and `TRIPLEC_AWS_E2E_REGION`; run with `awsx cargo test aws::live_tests::environment -- --ignored --test-threads=1`): - `an_environment_admin_can_deploy_join_update_and_remove_an_environment`: - 1. As the admin credentials, ensure the boundary exists. If it doesn't, deploy the boundary stack and record that it was created, so it can be removed at the end. - 2. Create the role `triple-c-e2e-envadmin`, trusted by the caller's account. Attach the admin tier's two policies as `triple-c-e2e-*` managed policies, and give it the boundary. - 3. Assume that role. Every following step runs as it, *only*. - 4. Deploy `triple-c-e2e` in `public` mode, and watch it to `CREATE_COMPLETE`. - 5. `scan` finds it `Ready` with a valid record. - 6. Self-onboard, and see `Joined`. - 7. Update, and get `AlreadyUpToDate`. - 8. Delete the onboarding stack, then the environment stack. - 9. Finally, as admin, delete the e2e role and policies, and the boundary if this test created it. - - Cleanup runs on failure too, through a guard. - - Every access-denied error is printed with its action, so a missing policy action shows up as a precise failure. -- `the_private_nat_mode_deploys` (separately ignored): an admin deploy in `private-nat` mode, then `CREATE_COMPLETE`, then delete. This costs about $0.05. + 1. As admin, make sure the boundary exists. If it doesn't, apply the boundary module and remember to remove it at the end. + 2. Create the role `triple-c-e2e-envadmin` with the admin tier's policies attached as `triple-c-e2e-*` managed policies, and with the boundary set. + 3. Assume that role. Every step from here runs as it **only**. Pass its temporary keys to the child as a keys connection. + 4. `ensure_state_bucket` on a test-unique bucket. Allow a `TRIPLEC_E2E_BUCKET_SUFFIX` so the test doesn't touch a real one. + 5. Deploy `e2e` in `public` mode through the real runner and follow it to `ok`. + 6. `scan` finds it Ready with a valid record. + 7. Self-onboard and see `Joined`. + 8. Update. The apply shows "No changes". + 9. Destroy the onboarding, then the environment. + 10. As admin, delete the role, the policies, the bucket (every version), and the boundary if this test created it. + - A guard runs cleanup on failure too. + - Every denied action is printed with its name. +- `the_private_nat_mode_deploys` (ignored separately): deploy in `private-nat` mode, check it reaches `ok`, then destroy. It costs about $0.05. - [ ] **Step 1: Write the tests.** -- [ ] **Step 2: Run them** with the e2e environment and fix any denied action. Each fix goes in the Task 3 policy files, the resource-action table, the check table and the README together, and is re-run until green. Record each action IAM required beyond the table in the README's "Live verification". -- [ ] **Step 3: Confirm cleanup.** List `triple-c-*` stacks, roles, policies and log groups, `triple-c` VPCs, EIPs and EFS file systems, and `alias/triple-c-*` KMS aliases in the region. Expected: none left, with KMS keys only pending deletion. Record the spend. -- [ ] **Step 4: Write the docs and spec updates.** Then run the full `cargo test`, `npx vitest run` and `npx tsc --noEmit`. Expected: PASS. -- [ ] **Step 5: Commit** with the message `test(aws): live environment lifecycle as an environment admin; docs`. +- [ ] **Step 2: Run them and fix any denied action.** Each fix goes into the Task 3 policies, the resource-action table, the check table and the README together. Re-run until green, and record what IAM needed beyond the table. +- [ ] **Step 3: Confirm cleanup.** Look for leftover `triple-c-*` roles, policies and log groups, any `triple-c` VPCs, EIPs, EFS file systems, `triple-c-tfstate-*` buckets, and `alias/triple-c-*` aliases. Expected: none, apart from KMS keys pending deletion. Record the spend. +- [ ] **Step 4: Write the docs and the spec**, then run the full `cargo test`, `npx vitest run` and `npx tsc --noEmit`. Expected: PASS. +- [ ] **Step 5: Commit** with the message `test(aws): live environment lifecycle with OpenTofu as an environment admin; docs`. -- 2.52.0 From 3583bee31e29f728e12be2dd7eb5c90772083e35 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 20:50:55 -0800 Subject: [PATCH 03/24] feat(aws): environment Terraform module and naming Co-Authored-By: Claude Opus 5.5 --- .gitignore | 7 + app/src-tauri/Cargo.lock | 81 +++ app/src-tauri/Cargo.toml | 2 + app/src-tauri/src/aws/mod.rs | 1 + app/src-tauri/src/aws/tofu_modules.rs | 217 +++++++ app/src-tauri/src/aws/tofu_modules/tests.rs | 533 ++++++++++++++++++ aws/terraform/environment/.terraform.lock.hcl | 39 ++ aws/terraform/environment/main.tf | 123 ++++ aws/terraform/environment/network.tf | 124 ++++ aws/terraform/environment/outputs.tf | 32 ++ aws/terraform/environment/variables.tf | 96 ++++ aws/terraform/environment/versions.tf | 15 + 12 files changed, 1270 insertions(+) create mode 100644 app/src-tauri/src/aws/tofu_modules.rs create mode 100644 app/src-tauri/src/aws/tofu_modules/tests.rs create mode 100644 aws/terraform/environment/.terraform.lock.hcl create mode 100644 aws/terraform/environment/main.tf create mode 100644 aws/terraform/environment/network.tf create mode 100644 aws/terraform/environment/outputs.tf create mode 100644 aws/terraform/environment/variables.tf create mode 100644 aws/terraform/environment/versions.tf diff --git a/.gitignore b/.gitignore index acfd1df..7a0af42 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,10 @@ screenshot_for_fix/ # Windows CI code signing (scripts/windows-signing-setup.ps1) .code-signing/ + +# OpenTofu working state (aws/terraform/*): providers, local state, locks. +# The modules' .terraform.lock.hcl files are committed. +.terraform/ +*.tfstate +*.tfstate.* +.terraform.tfstate.lock.info diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 260bbfd..3b5a9e6 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -3432,6 +3432,46 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hcl-edit" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1b3e90b173143b26dbb7495ac79e25c0206389d012881cea7cda9f0f1fce8a" +dependencies = [ + "fnv", + "hcl-primitives", + "pratt", + "vecmap-rs", + "winnow 1.0.5", +] + +[[package]] +name = "hcl-primitives" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c35ea757c1bdfa0bdfe8f65771be944d199a7f379b739c8f6fe2ad2f92d18ecb" +dependencies = [ + "itoa", + "kstring", + "ryu", + "serde", + "unicode-ident", +] + +[[package]] +name = "hcl-rs" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9736cfea6e2ed86d991394a6a5106cf768ee97dcb3db9cbea49859a9ecbfd1fb" +dependencies = [ + "hcl-edit", + "hcl-primitives", + "indexmap 2.13.0", + "itoa", + "serde", + "vecmap-rs", +] + [[package]] name = "heapless" version = "0.8.0" @@ -4123,6 +4163,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kstring" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a09b82a7f771ed02dc0dd9b27130a0fa5499fa15ed3027116c1e5e4e591bd9e" +dependencies = [ + "serde", + "static_assertions", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -5159,6 +5209,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pratt" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e0a4425d076f0718b820673a38fbf3747080c61017eeb0dd79bc7e472b8bb8" + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -6338,6 +6394,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -7256,6 +7318,7 @@ dependencies = [ "fern", "futures-util", "gix", + "hcl-rs", "iana-time-zone", "include_dir", "keyring", @@ -7480,6 +7543,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vecmap-rs" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c1dc449b873236909c7f325adf395071c209047737d831183e95c692413adf" +dependencies = [ + "serde", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -8255,6 +8327,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 8a922ec..c12a00e 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -89,6 +89,8 @@ tempfile = "3" # Only `aws::live_tests` (ignored): it creates a throwaway environment key to # check that Secrets Manager and the permissions boundary see its tags. aws-sdk-kms = { version = "1.131.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } +# Only `aws::tofu_modules` tests: they parse the shipped OpenTofu modules. +hcl-rs = "0.20" [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index 2cc45ab..f51e5ee 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -10,6 +10,7 @@ mod identity; mod live_tests; pub mod permissions; pub mod policies; +pub mod tofu_modules; pub use connection::{sdk_config, sdk_config_for_keys}; pub use error::AwsError; diff --git a/app/src-tauri/src/aws/tofu_modules.rs b/app/src-tauri/src/aws/tofu_modules.rs new file mode 100644 index 0000000..bd15950 --- /dev/null +++ b/app/src-tauri/src/aws/tofu_modules.rs @@ -0,0 +1,217 @@ +//! The OpenTofu modules Triple-C ships (`aws/terraform/*` at the repo root) +//! and the names the modules and the app agree on. +//! +//! Every name a module or a phase-4 command uses — environment, parameter, +//! owner key, per-user roles, state bucket and keys — is built here and +//! nowhere else. The tests parse the shipped HCL and pin it to these +//! constants: versions, default tags, the record's fields, no inbound access +//! and no endpoint overrides. + +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; + +use crate::models::host_connection::validate_supported_region; + +/// `locals.template_version` (and the `triple-c:template-version` default +/// tag) of `aws/terraform/environment`. +#[allow(dead_code)] // used by phase-4 commands +pub const ENVIRONMENT_MODULE_VERSION: u32 = 1; +/// The same for the user-onboarding module. +#[allow(dead_code)] // used by phase-4 commands +pub const ONBOARD_MODULE_VERSION: u32 = 1; +/// The permissions boundary every module-created role carries (no path). +/// Equal to [`super::policies::PERMISSIONS_BOUNDARY_NAME`]; a test checks it. +#[allow(dead_code)] // used by phase-4 commands +pub const BOUNDARY_POLICY_NAME: &str = "triple-c-permissions-boundary"; + +#[allow(dead_code)] // used by phase-4 commands +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum ModuleKind { + Environment, + UserOnboard, + PermissionsBoundary, +} + +macro_rules! module { + ($dir:literal: $($file:literal),+ $(,)?) => { + &[$(($file, include_str!(concat!("../../../../aws/terraform/", $dir, "/", $file)))),+] + }; +} + +const ENVIRONMENT_FILES: &[(&str, &str)] = module!("environment": + "versions.tf", + "variables.tf", + "main.tf", + "network.tf", + "outputs.tf", + ".terraform.lock.hcl", +); + +/// The module's files as (file name relative to the module directory, +/// contents), lock file included, for writing into a work or bundle folder. +/// The onboarding and boundary modules arrive in later phase-4 tasks; until +/// then they have no files. +#[allow(dead_code)] // used by phase-4 commands +pub fn module_files(kind: ModuleKind) -> &'static [(&'static str, &'static str)] { + match kind { + ModuleKind::Environment => ENVIRONMENT_FILES, + ModuleKind::UserOnboard | ModuleKind::PermissionsBoundary => &[], + } +} + +/// `^[a-z][a-z0-9-]{0,19}$`, not ending in `-`, with no `--` — the same rule +/// as the module's `environment_name` validation. +#[allow(dead_code)] // used by phase-4 commands +pub fn validate_environment_name(name: &str) -> Result { + let b = name.as_bytes(); + let ok = !b.is_empty() + && b.len() <= 20 + && b[0].is_ascii_lowercase() + && b.iter() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-') + && !name.ends_with('-') + && !name.contains("--"); + if ok { + Ok(name.to_string()) + } else { + Err(format!( + "\"{}\" is not a valid environment name: use 1 to 20 lowercase letters, digits and \ + single hyphens, starting with a letter and not ending in a hyphen", + name.escape_debug() + )) + } +} + +/// The SSM parameter holding an environment's record. +#[allow(dead_code)] // used by phase-4 commands +pub fn environment_parameter_name(env: &str) -> String { + format!("/triple-c/environments/{env}") +} + +/// The first 12 hex characters of `sha256(aws:userid)`. A `UserId` holds a +/// `:`, which IAM role names reject, so per-user names use this instead. +#[allow(dead_code)] // used by phase-4 commands +pub fn owner_key(user_id: &str) -> String { + Sha256::digest(user_id.as_bytes()) + .iter() + .take(6) + .map(|b| format!("{b:02x}")) + .collect() +} + +/// A user's (task role, execution role) names. At most 47 characters. +#[allow(dead_code)] // used by phase-4 commands +pub fn user_role_names(env: &str, owner_key: &str) -> (String, String) { + ( + format!("triple-c-{env}-{owner_key}-task"), + format!("triple-c-{env}-{owner_key}-exec"), + ) +} + +/// The per-account, per-region state bucket. +#[allow(dead_code)] // used by phase-4 commands +pub fn state_bucket_name(account: &str, region: &str) -> String { + format!("triple-c-tfstate-{account}-{region}") +} + +/// A deployment's state object in [`state_bucket_name`] (D9); the lock is the +/// same key plus `.tflock`. +/// +/// Panics if `kind` needs a part that is `None` (the environment for +/// `Environment` and `UserOnboard`, the owner key for `UserOnboard`): that is +/// a programming error, not input. +#[allow(dead_code)] // used by phase-4 commands +pub fn state_key(kind: ModuleKind, env: Option<&str>, owner_key: Option<&str>) -> String { + let env = || env.expect("state_key: this module kind needs an environment"); + match kind { + ModuleKind::Environment => format!("environments/{}/terraform.tfstate", env()), + ModuleKind::UserOnboard => format!( + "users/{}/{}/terraform.tfstate", + env(), + owner_key.expect("state_key: a user deployment needs an owner key") + ), + ModuleKind::PermissionsBoundary => "boundary/terraform.tfstate".to_string(), + } +} + +/// How an environment's tasks reach the network (D4). +#[allow(dead_code)] // used by phase-4 commands +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "mode", rename_all = "kebab-case")] +pub enum NetworkChoice { + /// A new VPC with two public subnets; tasks get a public IP. No NAT, $0. + Public, + /// A new VPC with private subnets behind one NAT gateway (~$33/month). + PrivateNat, + /// The user's own VPC and exactly two subnets. + Existing { + vpc_id: String, + subnet_ids: Vec, + assign_public_ip: bool, + }, +} + +/// `vpc-` / `subnet-` followed by 8 or 17 lowercase hex characters. +fn is_aws_id(id: &str, prefix: &str) -> bool { + id.strip_prefix(prefix).is_some_and(|hex| { + (hex.len() == 8 || hex.len() == 17) + && hex.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f')) + }) +} + +/// The environment module's `terraform.tfvars.json`. Applies the same rules +/// as the module's variable validations, so a bad value fails here first. +#[allow(dead_code)] // used by phase-4 commands +pub fn environment_vars( + env: &str, + region: &str, + network: &NetworkChoice, +) -> Result { + let env = validate_environment_name(env)?; + let region = validate_supported_region(region)?; + let (mode, vpc_id, subnet_ids, assign_public_ip) = match network { + NetworkChoice::Public => ("public", String::new(), Vec::new(), false), + NetworkChoice::PrivateNat => ("private-nat", String::new(), Vec::new(), false), + NetworkChoice::Existing { + vpc_id, + subnet_ids, + assign_public_ip, + } => { + if !is_aws_id(vpc_id, "vpc-") { + return Err(format!("\"{}\" is not a VPC id", vpc_id.escape_debug())); + } + if subnet_ids.len() != 2 { + return Err(format!( + "An existing network needs exactly 2 subnets (in different availability \ + zones), not {}", + subnet_ids.len() + )); + } + if let Some(bad) = subnet_ids.iter().find(|s| !is_aws_id(s, "subnet-")) { + return Err(format!("\"{}\" is not a subnet id", bad.escape_debug())); + } + if subnet_ids[0] == subnet_ids[1] { + return Err("The two subnets must be different".into()); + } + ( + "existing", + vpc_id.clone(), + subnet_ids.clone(), + *assign_public_ip, + ) + } + }; + Ok(json!({ + "environment_name": env, + "region": region, + "network_mode": mode, + "vpc_id": vpc_id, + "subnet_ids": subnet_ids, + "assign_public_ip": assign_public_ip, + })) +} + +#[cfg(test)] +mod tests; diff --git a/app/src-tauri/src/aws/tofu_modules/tests.rs b/app/src-tauri/src/aws/tofu_modules/tests.rs new file mode 100644 index 0000000..0e50436 --- /dev/null +++ b/app/src-tauri/src/aws/tofu_modules/tests.rs @@ -0,0 +1,533 @@ +use std::collections::BTreeSet; + +use hcl::expr::{Expression, ObjectKey}; +use hcl::{Block, Body}; +use serde_json::json; + +use super::*; +use crate::aws::policies::PERMISSIONS_BOUNDARY_NAME; + +const RECORD_FIELDS: [&str; 11] = [ + "schema", + "name", + "template_version", + "cluster_arn", + "security_group_id", + "subnet_ids", + "assign_public_ip", + "kms_key_arn", + "log_group", + "network_mode", + "tenancy", +]; + +const VARIABLES: [&str; 7] = [ + "environment_name", + "region", + "network_mode", + "vpc_id", + "subnet_ids", + "assign_public_ip", + "log_retention_days", +]; + +/// Every `.tf` file of `kind`, parsed and merged into one body. +fn parsed(kind: ModuleKind) -> Body { + let mut structures = Vec::new(); + for (name, text) in module_files(kind) { + if name.ends_with(".tf") { + let body = hcl::parse(text).unwrap_or_else(|e| panic!("{name}: {e}")); + structures.extend(body.into_inner()); + } + } + assert!(!structures.is_empty(), "{kind:?} has no .tf files"); + Body::from(structures) +} + +fn tf_text(kind: ModuleKind) -> String { + module_files(kind) + .iter() + .filter(|(name, _)| name.ends_with(".tf")) + .map(|(_, text)| *text) + .collect::>() + .join("\n") +} + +fn label(block: &Block, i: usize) -> Option<&str> { + block.labels.get(i).map(|l| l.as_str()) +} + +/// Every block in `body`, at any depth. +fn all_blocks(body: &Body) -> Vec<&Block> { + let mut out = Vec::new(); + for b in body.blocks() { + out.push(b); + out.extend(all_blocks(&b.body)); + } + out +} + +fn top_blocks<'a>(body: &'a Body, ident: &'a str) -> impl Iterator + 'a { + body.blocks() + .filter(move |b| b.identifier.as_str() == ident) +} + +fn resources<'a>(body: &'a Body, ty: &'a str) -> Vec<&'a Block> { + top_blocks(body, "resource") + .filter(|b| label(b, 0) == Some(ty)) + .collect() +} + +fn attr<'a>(body: &'a Body, key: &str) -> Option<&'a Expression> { + body.attributes() + .find(|a| a.key.as_str() == key) + .map(|a| &a.expr) +} + +fn key_name(key: &ObjectKey) -> String { + match key { + ObjectKey::Identifier(id) => id.as_str().to_string(), + ObjectKey::Expression(Expression::String(s)) => s.clone(), + other => panic!("unexpected object key {other:?}"), + } +} + +fn object_keys(expr: &Expression) -> BTreeSet { + match expr { + Expression::Object(obj) => obj.keys().map(key_name).collect(), + other => panic!("expected an object, got {other:?}"), + } +} + +fn object_get<'a>(expr: &'a Expression, key: &str) -> Option<&'a Expression> { + match expr { + Expression::Object(obj) => obj.iter().find(|(k, _)| key_name(k) == key).map(|(_, v)| v), + other => panic!("expected an object, got {other:?}"), + } +} + +fn render(expr: &Expression) -> String { + hcl::format::to_string(expr).unwrap() +} + +fn aws_provider(body: &Body) -> &Block { + let providers: Vec<_> = top_blocks(body, "provider").collect(); + assert_eq!(providers.len(), 1, "exactly one provider block"); + assert_eq!(label(providers[0], 0), Some("aws")); + providers[0] +} + +fn default_tags(body: &Body) -> &Expression { + let provider = aws_provider(body); + let tags_blocks: Vec<_> = top_blocks(&provider.body, "default_tags").collect(); + assert_eq!(tags_blocks.len(), 1, "one default_tags block"); + attr(&tags_blocks[0].body, "tags").expect("default_tags.tags") +} + +/// The environment record: the `jsonencode` argument of the SSM parameter's value. +fn record(body: &Body) -> (&Block, &Expression) { + let params = resources(body, "aws_ssm_parameter"); + assert_eq!(params.len(), 1, "one SSM parameter"); + let value = attr(¶ms[0].body, "value").expect("parameter value"); + let Expression::FuncCall(call) = value else { + panic!("the record is not jsonencode(...): {value:?}") + }; + assert_eq!(call.name.name.as_str(), "jsonencode"); + assert_eq!(call.args.len(), 1); + (params[0], &call.args[0]) +} + +#[test] +fn environment_names_are_validated() { + for ok in ["dev", "team-a1", "a", "abcdefghij0123456789"] { + assert_eq!(validate_environment_name(ok), Ok(ok.to_string()), "{ok}"); + } + for bad in [ + "", + "Dev", + "1dev", + "-dev", + "dev-", + "a--b", + "abcdefghij0123456789x", + "dev/x", + "dev_x", + "dév", + "dev ", + ] { + assert!(validate_environment_name(bad).is_err(), "{bad:?} accepted"); + } + assert_eq!( + environment_parameter_name("dev"), + "/triple-c/environments/dev" + ); +} + +#[test] +fn owner_keys_are_stable_and_role_safe() { + let a = owner_key("AROAEXAMPLEID:alice@example.com"); + assert_eq!(a.len(), 12); + assert!( + a.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f')), + "{a}" + ); + assert_eq!(a, owner_key("AROAEXAMPLEID:alice@example.com")); + assert_ne!(a, owner_key("AROAEXAMPLEID:bob@example.com")); + // The key is the first 12 hex characters of the SHA-256 of the user ID. + assert_eq!(owner_key("AIDAEXAMPLE"), sha256_prefix("AIDAEXAMPLE")); + + let env = "abcdefghij0123456789"; + assert_eq!(env.len(), 20); + let (task, exec) = user_role_names(env, &a); + assert_eq!(task, format!("triple-c-{env}-{a}-task")); + assert_eq!(exec, format!("triple-c-{env}-{a}-exec")); + assert!(task.len() <= 64 && exec.len() <= 64, "{task} / {exec}"); + for name in [&task, &exec] { + assert!( + name.bytes() + .all(|c| c.is_ascii_alphanumeric() || b"+=,.@_-".contains(&c)), + "{name} is not a valid role name" + ); + } +} + +fn sha256_prefix(s: &str) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(s.as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect::()[..12] + .to_string() +} + +#[test] +fn state_bucket_and_keys_follow_d9() { + assert_eq!(BOUNDARY_POLICY_NAME, PERMISSIONS_BOUNDARY_NAME); + assert_eq!(BOUNDARY_POLICY_NAME, "triple-c-permissions-boundary"); + + assert_eq!( + state_bucket_name("111122223333", "us-west-2"), + "triple-c-tfstate-111122223333-us-west-2" + ); + // The longest commercial region name still fits S3's 63 characters. + let longest = state_bucket_name("111122223333", "ap-southeast-5"); + assert!(longest.len() <= 63, "{longest}"); + + assert_eq!( + state_key(ModuleKind::Environment, Some("dev"), None), + "environments/dev/terraform.tfstate" + ); + assert_eq!( + state_key(ModuleKind::UserOnboard, Some("dev"), Some("0123456789ab")), + "users/dev/0123456789ab/terraform.tfstate" + ); + assert_eq!( + state_key(ModuleKind::PermissionsBoundary, None, None), + "boundary/terraform.tfstate" + ); +} + +#[test] +fn the_module_versions_agree() { + assert_eq!(ENVIRONMENT_MODULE_VERSION, 1); + assert_eq!(ONBOARD_MODULE_VERSION, 1); + let body = parsed(ModuleKind::Environment); + let locals: Vec<_> = top_blocks(&body, "locals") + .filter_map(|b| attr(&b.body, "template_version")) + .collect(); + assert_eq!(locals.len(), 1, "one locals.template_version"); + assert_eq!( + locals[0], + &Expression::Number(ENVIRONMENT_MODULE_VERSION.into()) + ); + let tag = object_get(default_tags(&body), "triple-c:template-version") + .expect("template-version default tag"); + assert!( + render(tag).contains("local.template_version"), + "template-version tag is {}", + render(tag) + ); + let (_, rec) = record(&body); + assert!(render(object_get(rec, "template_version").unwrap()).contains("local.template_version")); +} + +#[test] +fn default_tags_mark_the_environment_and_nothing_overrides_component() { + let body = parsed(ModuleKind::Environment); + let tags = default_tags(&body); + assert_eq!( + object_keys(tags), + BTreeSet::from([ + "triple-c:component".to_string(), + "triple-c:environment".to_string(), + "triple-c:template-version".to_string(), + ]) + ); + assert_eq!( + object_get(tags, "triple-c:component"), + Some(&Expression::String("environment".into())) + ); + assert_eq!( + render(object_get(tags, "triple-c:environment").unwrap()), + "var.environment_name" + ); + + // The provider's default_tags is the only place the component is named, + // and no resource sets tags that could shadow it. + assert_eq!( + tf_text(ModuleKind::Environment) + .matches("triple-c:component") + .count(), + 1 + ); + for block in all_blocks(&body) { + if block.identifier.as_str() == "default_tags" { + continue; + } + for key in ["tags", "tags_all"] { + if let Some(t) = attr(&block.body, key) { + let keys = object_keys(t); + assert!( + keys.iter().all(|k| !k.starts_with("triple-c:")), + "{:?} sets triple-c tags {keys:?}", + block.labels + ); + } + } + } +} + +#[test] +fn the_security_group_has_no_ingress() { + let body = parsed(ModuleKind::Environment); + for ty in [ + "aws_vpc_security_group_ingress_rule", + "aws_security_group_rule", + ] { + assert!(resources(&body, ty).is_empty(), "{ty} present"); + } + assert!( + all_blocks(&body) + .iter() + .all(|b| b.identifier.as_str() != "ingress"), + "an ingress block is present" + ); + let groups = resources(&body, "aws_security_group"); + assert_eq!(groups.len(), 1); + let sg = groups[0]; + assert_eq!( + render(attr(&sg.body, "name").unwrap()), + "\"triple-c-${var.environment_name}-tasks\"" + ); + // `ingress = []` makes OpenTofu remove any rule added out of band. + assert_eq!(attr(&sg.body, "ingress"), Some(&Expression::Array(vec![]))); + let egress: Vec<_> = top_blocks(&sg.body, "egress").collect(); + assert_eq!(egress.len(), 1, "one egress block"); + assert_eq!( + attr(&egress[0].body, "protocol"), + Some(&Expression::String("-1".into())) + ); +} + +#[test] +fn no_provider_endpoint_overrides() { + let body = parsed(ModuleKind::Environment); + let provider = aws_provider(&body); + assert_eq!( + render(attr(&provider.body, "region").unwrap()), + "var.region" + ); + assert!(attr(&provider.body, "endpoints").is_none()); + assert!(top_blocks(&provider.body, "endpoints").next().is_none()); + let text = tf_text(ModuleKind::Environment); + for needle in [ + "endpoints", + "endpoint_url", + "skip_credentials_validation", + "assume_role", + ] { + assert!(!text.contains(needle), "the module mentions {needle}"); + } + + // versions.tf: the provider is pinned exactly, and the backend is an + // empty S3 block the app fills in. + let terraform: Vec<_> = top_blocks(&body, "terraform").collect(); + assert_eq!(terraform.len(), 1); + let tf = &terraform[0].body; + assert_eq!( + attr(tf, "required_version"), + Some(&Expression::String(">= 1.10".into())) + ); + let providers = top_blocks(tf, "required_providers").next().unwrap(); + let aws = attr(&providers.body, "aws").unwrap(); + assert_eq!( + object_get(aws, "source"), + Some(&Expression::String("hashicorp/aws".into())) + ); + let Some(Expression::String(version)) = object_get(aws, "version") else { + panic!("aws provider version is not a string") + }; + let parts: Vec<&str> = version.split('.').collect(); + assert!( + parts.len() == 3 && parts[0] == "6" && parts.iter().all(|p| p.parse::().is_ok()), + "aws provider must be pinned to an exact 6.x version, got {version}" + ); + let backend: Vec<_> = top_blocks(tf, "backend").collect(); + assert_eq!(backend.len(), 1); + assert_eq!(label(backend[0], 0), Some("s3")); + assert!( + backend[0].body.iter().next().is_none(), + "the backend block must be empty" + ); + + // The lock file ships with the module and pins the same version. + let lock = module_files(ModuleKind::Environment) + .iter() + .find(|(name, _)| *name == ".terraform.lock.hcl") + .expect("lock file shipped") + .1; + assert!(lock.contains("registry.opentofu.org/hashicorp/aws")); + assert!( + lock.contains(&format!("version = \"{version}\"")), + "lock pins {version}" + ); +} + +#[test] +fn the_record_has_exactly_the_schema_fields() { + let body = parsed(ModuleKind::Environment); + let (param, rec) = record(&body); + assert_eq!( + object_keys(rec), + RECORD_FIELDS.iter().map(|s| s.to_string()).collect() + ); + assert_eq!( + object_get(rec, "schema"), + Some(&Expression::Number(1.into())) + ); + assert_eq!( + object_get(rec, "tenancy"), + Some(&Expression::String("strict".into())) + ); + assert_eq!( + attr(¶m.body, "type"), + Some(&Expression::String("String".into())) + ); + assert_eq!( + render(attr(¶m.body, "name").unwrap()), + "\"/triple-c/environments/${var.environment_name}\"" + ); +} + +#[test] +fn every_variable_is_validated() { + let body = parsed(ModuleKind::Environment); + let vars: Vec<_> = top_blocks(&body, "variable").collect(); + let names: BTreeSet<_> = vars.iter().filter_map(|b| label(b, 0)).collect(); + assert_eq!(names, VARIABLES.iter().copied().collect()); + for v in vars { + assert!( + top_blocks(&v.body, "validation").next().is_some(), + "variable {:?} has no validation", + label(v, 0) + ); + } +} + +#[test] +fn module_files_ship_the_lock_file() { + let names: Vec<_> = module_files(ModuleKind::Environment) + .iter() + .map(|(n, _)| *n) + .collect(); + for want in [ + "versions.tf", + "variables.tf", + "main.tf", + "network.tf", + "outputs.tf", + ".terraform.lock.hcl", + ] { + assert!(names.contains(&want), "{want} missing from {names:?}"); + } +} + +#[test] +fn environment_vars_cover_every_network_mode() { + let vars = environment_vars("dev", "us-west-2", &NetworkChoice::Public).unwrap(); + assert_eq!( + vars, + json!({ + "environment_name": "dev", + "region": "us-west-2", + "network_mode": "public", + "vpc_id": "", + "subnet_ids": [], + "assign_public_ip": false, + }) + ); + let vars = environment_vars("dev", "us-west-2", &NetworkChoice::PrivateNat).unwrap(); + assert_eq!(vars["network_mode"], "private-nat"); + assert_eq!(vars["subnet_ids"], json!([])); + + let existing = |vpc: &str, subnets: &[&str]| NetworkChoice::Existing { + vpc_id: vpc.into(), + subnet_ids: subnets.iter().map(|s| s.to_string()).collect(), + assign_public_ip: true, + }; + let two = ["subnet-0123456789abcdef0", "subnet-89abcdef"]; + let vars = environment_vars("dev", "eu-central-1", &existing("vpc-0a1b2c3d", &two)).unwrap(); + assert_eq!( + vars, + json!({ + "environment_name": "dev", + "region": "eu-central-1", + "network_mode": "existing", + "vpc_id": "vpc-0a1b2c3d", + "subnet_ids": two, + "assign_public_ip": true, + }) + ); + // Every variable the app sets is one the module declares. + for key in vars.as_object().unwrap().keys() { + assert!(VARIABLES.contains(&key.as_str()), "{key}"); + } + + for bad in [ + existing("vpc-0a1b2c3d", &["subnet-0123456789abcdef0"]), + existing( + "vpc-0a1b2c3d", + &[ + "subnet-0123456789abcdef0", + "subnet-89abcdef", + "subnet-01234567", + ], + ), + existing("vpc-0a1b2c3d", &["subnet-89abcdef", "subnet-89abcdef"]), + existing("vpc-;", &two), + existing("vpc-0A1B2C3D", &two), + existing("", &two), + existing("vpc-0a1b2c3d", &["subnet-0123456789abcdef0", "subnet-;"]), + existing("vpc-0a1b2c3d", &["subnet-0123456789abcdef0", "sg-89abcdef"]), + ] { + assert!( + environment_vars("dev", "us-west-2", &bad).is_err(), + "{bad:?} accepted" + ); + } + assert!(environment_vars("Dev", "us-west-2", &NetworkChoice::Public).is_err()); + assert!(environment_vars("dev", "us-west-2;", &NetworkChoice::Public).is_err()); + assert!(environment_vars("dev", "cn-north-1", &NetworkChoice::Public).is_err()); + + // The command layer receives the choice as tagged JSON. + let parsed: NetworkChoice = serde_json::from_value(json!({ + "mode": "existing", + "vpc_id": "vpc-0a1b2c3d", + "subnet_ids": two, + "assign_public_ip": false, + })) + .unwrap(); + assert!(matches!(parsed, NetworkChoice::Existing { .. })); + let parsed: NetworkChoice = serde_json::from_value(json!({ "mode": "private-nat" })).unwrap(); + assert_eq!(parsed, NetworkChoice::PrivateNat); +} diff --git a/aws/terraform/environment/.terraform.lock.hcl b/aws/terraform/environment/.terraform.lock.hcl new file mode 100644 index 0000000..55c488f --- /dev/null +++ b/aws/terraform/environment/.terraform.lock.hcl @@ -0,0 +1,39 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.73.0" + constraints = "6.73.0" + hashes = [ + "h1:1zQPP8XHNYS2m7MnhPd19nR6xjWk3p/e08X+B7GGx7I=", + "h1:IdAE6YFUBZvlLDzcKe+khHpceC+cZMJy8DOsuFV1iLs=", + "h1:KLd1R0RCfFNyhJ8fBLI7KuCI8VvV8fMZh2+Bt4/5UEk=", + "h1:NE8f7fV/bAVTxhVOuQSIRcG0/NTNMrrrZTFJ863U3v8=", + "h1:PLSysFV+jYgBuI42kYv4AMGMDusaKj6o4MfKz7+AD2w=", + "h1:PirCmCZao9jd4snXgOlO+OrG4HtPKQVyiu37vmoM6Y8=", + "h1:ZDDEyufl0zfy5gWdRkfFrDj/K7Dt9oym+FTrKTyzPmY=", + "h1:cqILWB/Kxr0AYCi+pJj9A8d5KzsXEOrKxbFHh3VfKsQ=", + "h1:iUIwaluxZuApYxl8C5Xpwo5mb/9OkEMn6tAzLKVTxag=", + "h1:jjsA3nBL/NmNYeG0bmVhHv1ruFBH2ucdkW9VH1iE5Tw=", + "h1:mKUNC7il3hHVi93ZyWogDKWTCwuhorndZf629rHqElQ=", + "h1:miKdeCwqPUV/IPN+HCS0ZZquE4ENjb18UhOO612M+AY=", + "h1:mrSvOTfc0Uwurvpyj9uEhbGLUqUWA1p3VAAK+qA8OXs=", + "h1:pOp3kyT/dPNiMXzc7CtQqFrPhIFR7gqtgX5iepQJczs=", + "h1:q5MABxUl6HoEzLYB21WOLdH+EZzioo0n9/8pkKYweF0=", + "zh:01a4a08962eb85b35c9912e715df1a5124e5064cd60792b37c82ebe8898489b8", + "zh:3efa54bad2af67c07547f6682bfe2b419c9f018c63008dc7d8a1e4691fd35569", + "zh:432944bc45558e48a526e522b559dc74b70dc37cfe460420ed48527d36563531", + "zh:6e06ddd0fa7f5c9b88c428abb18ae8d8ee9749a773ca52b2ee5010d8ce697eca", + "zh:7efbfba3b2daed43b0ee0322b62cfd0c9102b2320cac74878e950b5d02ffc979", + "zh:8866298c36a45b7867415119289a8f680f32d54f5553f738880ffcc0561c6907", + "zh:9dcde5077f648b6231eef6de019a5dd89ba7ec393767be4f6cbde7e420a5a874", + "zh:9fd52591011b1066036a47a4e677cad00d63956579a200cb57c9228f1dfb4a1a", + "zh:a61f8e114e5233e63b8d2b034b1ad59a220e532fa34aaaa63eec6bad73a0ca21", + "zh:ba50cb9245a579285169565995e20bebb410e7f4d4181dc7de1205877fe04728", + "zh:c2eb24eedab492b3e42f78632b0dd6a8d23f3021b080fee2137e91765629a9e1", + "zh:cac7953f04c637c303f212b87e96089950bb95856ffbeb2719c5138299a64787", + "zh:d7929af6ee61fb8c7d6ec5d3a0ff9cf8554f01d0b470b3787d7e7882e6df3946", + "zh:d7f889bcdbf6b5889ca2252ce517241a2723e61927b2826683d01b648ea91f9a", + "zh:ff4c4ca563e044561e3c3836f51b24dca034879574ca7e334b27a49b25d1df51", + ] +} diff --git a/aws/terraform/environment/main.tf b/aws/terraform/environment/main.tf new file mode 100644 index 0000000..9cd9d74 --- /dev/null +++ b/aws/terraform/environment/main.tf @@ -0,0 +1,123 @@ +# A Triple-C environment: the pieces every user's tasks share. Each user's +# own roles, EFS file system and secrets come from the onboarding module. + +locals { + # ENVIRONMENT_MODULE_VERSION in app/src-tauri/src/aws/tofu_modules.rs. + template_version = 1 + name = "triple-c-${var.environment_name}" +} + +provider "aws" { + region = var.region + + # The only place the component tag is set: the admin policy's + # destruction rights are scoped to it, so no resource may override it. + default_tags { + tags = { + "triple-c:component" = "environment" + "triple-c:environment" = var.environment_name + "triple-c:template-version" = tostring(local.template_version) + } + } +} + +data "aws_caller_identity" "current" {} + +data "aws_partition" "current" {} + +resource "aws_ecs_cluster" "this" { + name = local.name + + setting { + name = "containerInsights" + value = "disabled" + } +} + +resource "aws_ecs_cluster_capacity_providers" "this" { + cluster_name = aws_ecs_cluster.this.name + capacity_providers = ["FARGATE", "FARGATE_SPOT"] + + default_capacity_provider_strategy { + capacity_provider = "FARGATE" + weight = 1 + } +} + +# Tasks are reached only through SSM, so nothing may connect in. +resource "aws_security_group" "tasks" { + name = "triple-c-${var.environment_name}-tasks" + description = "Triple-C ${var.environment_name} tasks: outbound only" + vpc_id = local.vpc_id + + # Empty, not absent: OpenTofu then removes any rule added out of band. + ingress = [] + + egress { + description = "All outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } +} + +resource "aws_kms_key" "this" { + description = "Triple-C environment ${var.environment_name}" + enable_key_rotation = true + deletion_window_in_days = 7 + + # Delegate to IAM: who may use the key is decided by IAM policies (and the + # permissions boundary), not by the key policy. + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "EnableIAMPolicies" + Effect = "Allow" + Principal = { AWS = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root" } + Action = "kms:*" + Resource = "*" + }] + }) +} + +resource "aws_kms_alias" "this" { + name = "alias/${local.name}" + target_key_id = aws_kms_key.this.key_id +} + +resource "aws_cloudwatch_log_group" "this" { + name = "/triple-c/${var.environment_name}" + retention_in_days = var.log_retention_days +} + +# The environment record (EnvironmentInfo schema 1). Written last, so its +# presence means the environment is complete. The app treats it as untrusted. +resource "aws_ssm_parameter" "environment" { + name = "/triple-c/environments/${var.environment_name}" + type = "String" + tier = "Standard" + value = jsonencode({ + schema = 1 + name = var.environment_name + template_version = local.template_version + cluster_arn = aws_ecs_cluster.this.arn + security_group_id = aws_security_group.tasks.id + subnet_ids = local.task_subnet_ids + assign_public_ip = local.assign_public_ip + kms_key_arn = aws_kms_key.this.arn + log_group = aws_cloudwatch_log_group.this.name + network_mode = var.network_mode + tenancy = "strict" + }) + + depends_on = [ + aws_ecs_cluster_capacity_providers.this, + aws_kms_alias.this, + aws_route.public_internet, + aws_route.private_nat, + aws_route_table_association.public, + aws_route_table_association.private, + ] +} diff --git a/aws/terraform/environment/network.tf b/aws/terraform/environment/network.tf new file mode 100644 index 0000000..67a6a59 --- /dev/null +++ b/aws/terraform/environment/network.tf @@ -0,0 +1,124 @@ +# The network, by network_mode: +# public new VPC, two public subnets, internet gateway; tasks get a +# public IP (the security group still admits nothing). +# private-nat the same plus two private subnets behind one NAT gateway; +# tasks run in the private subnets without a public IP. +# existing the given vpc_id / subnet_ids; nothing is created here. + +locals { + create_vpc = var.network_mode != "existing" + private_nat = var.network_mode == "private-nat" + vpc_cidr = "10.42.0.0/16" + + vpc_id = local.create_vpc ? one(aws_vpc.this[*].id) : var.vpc_id + task_subnet_ids = ( + var.network_mode == "existing" ? var.subnet_ids + : local.private_nat ? aws_subnet.private[*].id + : aws_subnet.public[*].id + ) + assign_public_ip = ( + var.network_mode == "existing" ? var.assign_public_ip + : var.network_mode == "public" + ) +} + +data "aws_availability_zones" "available" { + count = local.create_vpc ? 1 : 0 + state = "available" + + filter { + name = "opt-in-status" + values = ["opt-in-not-required"] + } +} + +resource "aws_vpc" "this" { + count = local.create_vpc ? 1 : 0 + cidr_block = local.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { Name = local.name } +} + +resource "aws_internet_gateway" "this" { + count = local.create_vpc ? 1 : 0 + vpc_id = aws_vpc.this[0].id + + tags = { Name = local.name } +} + +resource "aws_subnet" "public" { + count = local.create_vpc ? 2 : 0 + vpc_id = aws_vpc.this[0].id + cidr_block = cidrsubnet(local.vpc_cidr, 8, count.index) + availability_zone = data.aws_availability_zones.available[0].names[count.index] + + tags = { Name = "${local.name}-public-${count.index}" } +} + +resource "aws_route_table" "public" { + count = local.create_vpc ? 1 : 0 + vpc_id = aws_vpc.this[0].id + + tags = { Name = "${local.name}-public" } +} + +resource "aws_route" "public_internet" { + count = local.create_vpc ? 1 : 0 + route_table_id = aws_route_table.public[0].id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this[0].id +} + +resource "aws_route_table_association" "public" { + count = local.create_vpc ? 2 : 0 + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public[0].id +} + +resource "aws_eip" "nat" { + count = local.private_nat ? 1 : 0 + domain = "vpc" + + tags = { Name = "${local.name}-nat" } +} + +resource "aws_nat_gateway" "this" { + count = local.private_nat ? 1 : 0 + allocation_id = aws_eip.nat[0].id + subnet_id = aws_subnet.public[0].id + + tags = { Name = local.name } + + depends_on = [aws_internet_gateway.this] +} + +resource "aws_subnet" "private" { + count = local.private_nat ? 2 : 0 + vpc_id = aws_vpc.this[0].id + cidr_block = cidrsubnet(local.vpc_cidr, 8, 10 + count.index) + availability_zone = data.aws_availability_zones.available[0].names[count.index] + + tags = { Name = "${local.name}-private-${count.index}" } +} + +resource "aws_route_table" "private" { + count = local.private_nat ? 1 : 0 + vpc_id = aws_vpc.this[0].id + + tags = { Name = "${local.name}-private" } +} + +resource "aws_route" "private_nat" { + count = local.private_nat ? 1 : 0 + route_table_id = aws_route_table.private[0].id + destination_cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.this[0].id +} + +resource "aws_route_table_association" "private" { + count = local.private_nat ? 2 : 0 + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private[0].id +} diff --git a/aws/terraform/environment/outputs.tf b/aws/terraform/environment/outputs.tf new file mode 100644 index 0000000..0a7f9c7 --- /dev/null +++ b/aws/terraform/environment/outputs.tf @@ -0,0 +1,32 @@ +output "parameter_name" { + description = "The SSM parameter holding the environment record." + value = aws_ssm_parameter.environment.name +} + +output "cluster_arn" { + value = aws_ecs_cluster.this.arn +} + +output "security_group_id" { + value = aws_security_group.tasks.id +} + +output "vpc_id" { + value = local.vpc_id +} + +output "subnet_ids" { + value = local.task_subnet_ids +} + +output "assign_public_ip" { + value = local.assign_public_ip +} + +output "kms_key_arn" { + value = aws_kms_key.this.arn +} + +output "log_group" { + value = aws_cloudwatch_log_group.this.name +} diff --git a/aws/terraform/environment/variables.tf b/aws/terraform/environment/variables.tf new file mode 100644 index 0000000..7e50ba7 --- /dev/null +++ b/aws/terraform/environment/variables.tf @@ -0,0 +1,96 @@ +# Every rule here mirrors the app's (`app/src-tauri/src/aws/tofu_modules.rs`), +# so an admin running a bundle by hand gets the same answers as the app. + +variable "environment_name" { + description = "The environment's name: lowercase letters, digits and single hyphens, starting with a letter, at most 20 characters." + type = string + + validation { + condition = ( + can(regex("^[a-z][a-z0-9-]{0,19}$", var.environment_name)) + && !endswith(var.environment_name, "-") + && !strcontains(var.environment_name, "--") + ) + error_message = "environment_name must match ^[a-z][a-z0-9-]{0,19}$, not end in '-' and not contain '--'." + } +} + +variable "region" { + description = "The AWS region to deploy into, e.g. us-west-2." + type = string + + validation { + condition = ( + can(regex("^[a-z]{2}(-[a-z]+)+-[0-9]+$", var.region)) + && length([for p in ["cn-", "us-gov-", "us-iso-", "us-isob-", "eu-isoe-", "us-isof-"] : p if startswith(var.region, p)]) == 0 + ) + error_message = "region must be a commercial AWS region id such as us-west-2 (China and GovCloud are not supported)." + } +} + +variable "network_mode" { + description = "public: a new VPC with two public subnets and no NAT. private-nat: a new VPC with two private subnets behind one NAT gateway (about $33/month). existing: the given vpc_id and subnet_ids." + type = string + default = "public" + + validation { + condition = contains(["public", "private-nat", "existing"], var.network_mode) + error_message = "network_mode must be public, private-nat or existing." + } +} + +variable "vpc_id" { + description = "network_mode = existing only: the VPC to run tasks in." + type = string + default = "" + + validation { + condition = ( + var.network_mode == "existing" + ? can(regex("^vpc-([0-9a-f]{8}|[0-9a-f]{17})$", var.vpc_id)) + : var.vpc_id == "" + ) + error_message = "vpc_id must be a VPC id (vpc-xxxxxxxx) when network_mode = existing, and empty otherwise." + } +} + +variable "subnet_ids" { + description = "network_mode = existing only: exactly two subnets of vpc_id, in different availability zones." + type = list(string) + default = [] + + validation { + condition = ( + var.network_mode == "existing" + ? ( + length(var.subnet_ids) == 2 + && length(distinct(var.subnet_ids)) == 2 + && alltrue([for s in var.subnet_ids : can(regex("^subnet-([0-9a-f]{8}|[0-9a-f]{17})$", s))]) + ) + : length(var.subnet_ids) == 0 + ) + error_message = "subnet_ids must be exactly two distinct subnet ids (subnet-xxxxxxxx) when network_mode = existing, and empty otherwise." + } +} + +variable "assign_public_ip" { + description = "network_mode = existing only: whether tasks get a public IP (needed in a public subnet without NAT)." + type = bool + default = false + + validation { + condition = var.network_mode == "existing" || !var.assign_public_ip + error_message = "assign_public_ip applies only to network_mode = existing; the other modes decide it themselves." + } +} + +variable "log_retention_days" { + description = "How long the environment's log group keeps task logs." + type = number + default = 30 + + validation { + condition = contains([1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.log_retention_days) + error_message = "log_retention_days must be a CloudWatch Logs retention value (1, 3, 5, 7, 14, 30, 60, 90, ...)." + } +} diff --git a/aws/terraform/environment/versions.tf b/aws/terraform/environment/versions.tf new file mode 100644 index 0000000..a3efb1c --- /dev/null +++ b/aws/terraform/environment/versions.tf @@ -0,0 +1,15 @@ +terraform { + # OpenTofu 1.10+ or Terraform 1.10+: S3-native state locking (`use_lockfile`). + required_version = ">= 1.10" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "6.73.0" + } + } + + # Filled in by the app (or an admin bundle's backend.hcl) with + # `-backend-config`: bucket, key, region, use_lockfile = true. + backend "s3" {} +} -- 2.52.0 From 6192b7c1e8b8c6d0046a5834a6db90684de324c9 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 20:54:42 -0800 Subject: [PATCH 04/24] fix(aws): keep environment subnet AZs stable; state_key takes a Deployment Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/tofu_modules.rs | 33 ++++++++++-------- app/src-tauri/src/aws/tofu_modules/tests.rs | 38 +++++++++++++++++++-- aws/terraform/environment/network.tf | 14 +++++++- 3 files changed, 67 insertions(+), 18 deletions(-) diff --git a/app/src-tauri/src/aws/tofu_modules.rs b/app/src-tauri/src/aws/tofu_modules.rs index bd15950..ccb7904 100644 --- a/app/src-tauri/src/aws/tofu_modules.rs +++ b/app/src-tauri/src/aws/tofu_modules.rs @@ -116,23 +116,28 @@ pub fn state_bucket_name(account: &str, region: &str) -> String { format!("triple-c-tfstate-{account}-{region}") } +/// One deployment, i.e. one state object in the bucket (D9). +#[allow(dead_code)] // used by phase-4 commands +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Deployment<'a> { + /// The environment module for this environment name. + Environment(&'a str), + /// A user's onboarding module in an environment. + User { env: &'a str, owner_key: &'a str }, + /// The account's permissions-boundary module. + Boundary, +} + /// A deployment's state object in [`state_bucket_name`] (D9); the lock is the /// same key plus `.tflock`. -/// -/// Panics if `kind` needs a part that is `None` (the environment for -/// `Environment` and `UserOnboard`, the owner key for `UserOnboard`): that is -/// a programming error, not input. #[allow(dead_code)] // used by phase-4 commands -pub fn state_key(kind: ModuleKind, env: Option<&str>, owner_key: Option<&str>) -> String { - let env = || env.expect("state_key: this module kind needs an environment"); - match kind { - ModuleKind::Environment => format!("environments/{}/terraform.tfstate", env()), - ModuleKind::UserOnboard => format!( - "users/{}/{}/terraform.tfstate", - env(), - owner_key.expect("state_key: a user deployment needs an owner key") - ), - ModuleKind::PermissionsBoundary => "boundary/terraform.tfstate".to_string(), +pub fn state_key(d: Deployment) -> String { + match d { + Deployment::Environment(env) => format!("environments/{env}/terraform.tfstate"), + Deployment::User { env, owner_key } => { + format!("users/{env}/{owner_key}/terraform.tfstate") + } + Deployment::Boundary => "boundary/terraform.tfstate".to_string(), } } diff --git a/app/src-tauri/src/aws/tofu_modules/tests.rs b/app/src-tauri/src/aws/tofu_modules/tests.rs index 0e50436..262bbe9 100644 --- a/app/src-tauri/src/aws/tofu_modules/tests.rs +++ b/app/src-tauri/src/aws/tofu_modules/tests.rs @@ -214,19 +214,51 @@ fn state_bucket_and_keys_follow_d9() { assert!(longest.len() <= 63, "{longest}"); assert_eq!( - state_key(ModuleKind::Environment, Some("dev"), None), + state_key(Deployment::Environment("dev")), "environments/dev/terraform.tfstate" ); assert_eq!( - state_key(ModuleKind::UserOnboard, Some("dev"), Some("0123456789ab")), + state_key(Deployment::User { + env: "dev", + owner_key: "0123456789ab" + }), "users/dev/0123456789ab/terraform.tfstate" ); assert_eq!( - state_key(ModuleKind::PermissionsBoundary, None, None), + state_key(Deployment::Boundary), "boundary/terraform.tfstate" ); } +#[test] +fn subnet_availability_zones_are_stable_once_chosen() { + let body = parsed(ModuleKind::Environment); + // Not filtered on `state`: an impaired AZ must not reorder the list. + let azs: Vec<_> = top_blocks(&body, "data") + .filter(|b| label(b, 0) == Some("aws_availability_zones")) + .collect(); + assert_eq!(azs.len(), 1); + assert!( + attr(&azs[0].body, "state").is_none(), + "aws_availability_zones must not filter on state" + ); + // And each subnet ignores later AZ changes, so a moved list never + // replaces a subnet. + let subnets = resources(&body, "aws_subnet"); + assert_eq!(subnets.len(), 2, "public and private subnets"); + for subnet in subnets { + let lifecycle = top_blocks(&subnet.body, "lifecycle") + .next() + .unwrap_or_else(|| panic!("{:?} has no lifecycle block", subnet.labels)); + let ignored = attr(&lifecycle.body, "ignore_changes").expect("ignore_changes"); + assert!( + render(ignored).contains("availability_zone"), + "{:?} does not ignore availability_zone changes", + subnet.labels + ); + } +} + #[test] fn the_module_versions_agree() { assert_eq!(ENVIRONMENT_MODULE_VERSION, 1); diff --git a/aws/terraform/environment/network.tf b/aws/terraform/environment/network.tf index 67a6a59..72e4bb5 100644 --- a/aws/terraform/environment/network.tf +++ b/aws/terraform/environment/network.tf @@ -22,9 +22,13 @@ locals { ) } +# The subnets' AZs must not move once chosen: a changed availability_zone +# replaces the subnet (and the ENIs, NAT gateway, associations and record +# hanging off it). So the list is not filtered on `state` -- a temporary +# impairment would reorder it -- only on opt-in status, which a region's +# default AZs never change; and each subnet ignores later AZ changes. data "aws_availability_zones" "available" { count = local.create_vpc ? 1 : 0 - state = "available" filter { name = "opt-in-status" @@ -55,6 +59,10 @@ resource "aws_subnet" "public" { availability_zone = data.aws_availability_zones.available[0].names[count.index] tags = { Name = "${local.name}-public-${count.index}" } + + lifecycle { + ignore_changes = [availability_zone] + } } resource "aws_route_table" "public" { @@ -101,6 +109,10 @@ resource "aws_subnet" "private" { availability_zone = data.aws_availability_zones.available[0].names[count.index] tags = { Name = "${local.name}-private-${count.index}" } + + lifecycle { + ignore_changes = [availability_zone] + } } resource "aws_route_table" "private" { -- 2.52.0 From f061ddb9a428b7cae16102cd403c731b407000c6 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 21:03:20 -0800 Subject: [PATCH 05/24] feat(aws): onboarding and permissions-boundary Terraform modules Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/tofu_modules.rs | 47 +- app/src-tauri/src/aws/tofu_modules/tests.rs | 672 +++++++++++++++++- .../permissions-boundary/.terraform.lock.hcl | 39 + aws/terraform/permissions-boundary/main.tf | 20 + aws/terraform/permissions-boundary/outputs.tf | 3 + .../permissions-boundary/variables.tf | 22 + .../permissions-boundary/versions.tf | 15 + .../user-onboard/.terraform.lock.hcl | 39 + aws/terraform/user-onboard/efs.tf | 91 +++ aws/terraform/user-onboard/iam.tf | 125 ++++ aws/terraform/user-onboard/main.tf | 57 ++ aws/terraform/user-onboard/outputs.tf | 19 + aws/terraform/user-onboard/variables.tf | 56 ++ aws/terraform/user-onboard/versions.tf | 15 + 14 files changed, 1201 insertions(+), 19 deletions(-) create mode 100644 aws/terraform/permissions-boundary/.terraform.lock.hcl create mode 100644 aws/terraform/permissions-boundary/main.tf create mode 100644 aws/terraform/permissions-boundary/outputs.tf create mode 100644 aws/terraform/permissions-boundary/variables.tf create mode 100644 aws/terraform/permissions-boundary/versions.tf create mode 100644 aws/terraform/user-onboard/.terraform.lock.hcl create mode 100644 aws/terraform/user-onboard/efs.tf create mode 100644 aws/terraform/user-onboard/iam.tf create mode 100644 aws/terraform/user-onboard/main.tf create mode 100644 aws/terraform/user-onboard/outputs.tf create mode 100644 aws/terraform/user-onboard/variables.tf create mode 100644 aws/terraform/user-onboard/versions.tf diff --git a/app/src-tauri/src/aws/tofu_modules.rs b/app/src-tauri/src/aws/tofu_modules.rs index ccb7904..10448ec 100644 --- a/app/src-tauri/src/aws/tofu_modules.rs +++ b/app/src-tauri/src/aws/tofu_modules.rs @@ -49,15 +49,32 @@ const ENVIRONMENT_FILES: &[(&str, &str)] = module!("environment": ".terraform.lock.hcl", ); +const USER_ONBOARD_FILES: &[(&str, &str)] = module!("user-onboard": + "versions.tf", + "variables.tf", + "main.tf", + "iam.tf", + "efs.tf", + "outputs.tf", + ".terraform.lock.hcl", +); + +const PERMISSIONS_BOUNDARY_FILES: &[(&str, &str)] = module!("permissions-boundary": + "versions.tf", + "variables.tf", + "main.tf", + "outputs.tf", + ".terraform.lock.hcl", +); + /// The module's files as (file name relative to the module directory, /// contents), lock file included, for writing into a work or bundle folder. -/// The onboarding and boundary modules arrive in later phase-4 tasks; until -/// then they have no files. #[allow(dead_code)] // used by phase-4 commands pub fn module_files(kind: ModuleKind) -> &'static [(&'static str, &'static str)] { match kind { ModuleKind::Environment => ENVIRONMENT_FILES, - ModuleKind::UserOnboard | ModuleKind::PermissionsBoundary => &[], + ModuleKind::UserOnboard => USER_ONBOARD_FILES, + ModuleKind::PermissionsBoundary => PERMISSIONS_BOUNDARY_FILES, } } @@ -218,5 +235,29 @@ pub fn environment_vars( })) } +/// The onboarding module's `terraform.tfvars.json` for the user whose +/// `aws:userid` is `owner_id`. Bedrock stays on the workspace user's own +/// credentials unless a later setting opts the task role in. +#[allow(dead_code)] // used by phase-4 commands +pub fn onboarding_vars(env: &str, region: &str, owner_id: &str) -> serde_json::Value { + json!({ + "environment_name": env, + "region": region, + "owner_id": owner_id, + "owner_key": owner_key(owner_id), + "bedrock_on_task_role": false, + }) +} + +/// The boundary module's `terraform.tfvars.json`: the shipped boundary +/// document itself, so the module holds no second copy. +#[allow(dead_code)] // used by phase-4 commands +pub fn boundary_vars(region: &str) -> serde_json::Value { + json!({ + "region": region, + "policy_json": super::policies::permissions_boundary_json(), + }) +} + #[cfg(test)] mod tests; diff --git a/app/src-tauri/src/aws/tofu_modules/tests.rs b/app/src-tauri/src/aws/tofu_modules/tests.rs index 262bbe9..7f4eeb0 100644 --- a/app/src-tauri/src/aws/tofu_modules/tests.rs +++ b/app/src-tauri/src/aws/tofu_modules/tests.rs @@ -361,9 +361,21 @@ fn the_security_group_has_no_ingress() { ); } +const ALL_MODULES: [ModuleKind; 3] = [ + ModuleKind::Environment, + ModuleKind::UserOnboard, + ModuleKind::PermissionsBoundary, +]; + #[test] fn no_provider_endpoint_overrides() { - let body = parsed(ModuleKind::Environment); + for kind in ALL_MODULES { + no_provider_endpoint_overrides_in(kind); + } +} + +fn no_provider_endpoint_overrides_in(kind: ModuleKind) { + let body = parsed(kind); let provider = aws_provider(&body); assert_eq!( render(attr(&provider.body, "region").unwrap()), @@ -371,7 +383,8 @@ fn no_provider_endpoint_overrides() { ); assert!(attr(&provider.body, "endpoints").is_none()); assert!(top_blocks(&provider.body, "endpoints").next().is_none()); - let text = tf_text(ModuleKind::Environment); + // A role's `assume_role_policy` is not a provider `assume_role`. + let text = tf_text(kind).replace("assume_role_policy", ""); for needle in [ "endpoints", "endpoint_url", @@ -413,10 +426,10 @@ fn no_provider_endpoint_overrides() { ); // The lock file ships with the module and pins the same version. - let lock = module_files(ModuleKind::Environment) + let lock = module_files(kind) .iter() .find(|(name, _)| *name == ".terraform.lock.hcl") - .expect("lock file shipped") + .unwrap_or_else(|| panic!("{kind:?}: lock file shipped")) .1; assert!(lock.contains("registry.opentofu.org/hashicorp/aws")); assert!( @@ -468,19 +481,27 @@ fn every_variable_is_validated() { #[test] fn module_files_ship_the_lock_file() { - let names: Vec<_> = module_files(ModuleKind::Environment) - .iter() - .map(|(n, _)| *n) - .collect(); - for want in [ - "versions.tf", - "variables.tf", - "main.tf", - "network.tf", - "outputs.tf", - ".terraform.lock.hcl", + for (kind, extra) in [ + (ModuleKind::Environment, &["network.tf"][..]), + (ModuleKind::UserOnboard, &["iam.tf", "efs.tf"][..]), + (ModuleKind::PermissionsBoundary, &[][..]), ] { - assert!(names.contains(&want), "{want} missing from {names:?}"); + let names: Vec<_> = module_files(kind).iter().map(|(n, _)| *n).collect(); + for want in [ + "versions.tf", + "variables.tf", + "main.tf", + "outputs.tf", + ".terraform.lock.hcl", + ] + .iter() + .chain(extra) + { + assert!( + names.contains(want), + "{kind:?}: {want} missing from {names:?}" + ); + } } } @@ -563,3 +584,622 @@ fn environment_vars_cover_every_network_mode() { let parsed: NetworkChoice = serde_json::from_value(json!({ "mode": "private-nat" })).unwrap(); assert_eq!(parsed, NetworkChoice::PrivateNat); } + +// --------------------------------------------------------------------------- +// The onboarding and permissions-boundary modules. +// --------------------------------------------------------------------------- + +const ONBOARD_VARIABLES: [&str; 5] = [ + "environment_name", + "region", + "owner_id", + "owner_key", + "bedrock_on_task_role", +]; + +/// The boundary ARN every module-created role carries, as the module writes it. +const BOUNDARY_ARN_TEMPLATE: &str = "arn:${data.aws_partition.current.partition}:iam::\ + ${data.aws_caller_identity.current.account_id}:policy/triple-c-permissions-boundary"; + +/// The template text of a string expression: a literal, or a quoted +/// template with its `${...}` interpolations kept verbatim. +fn template_text(expr: &Expression) -> String { + match expr { + Expression::String(s) => s.clone(), + Expression::TemplateExpr(t) => t.to_string(), + other => format!("${{{}}}", render(other)), + } +} + +/// `local.`'s expression, from the module's (single) definition. +fn local_def<'a>(body: &'a Body, name: &str) -> &'a Expression { + let defs: Vec<_> = top_blocks(body, "locals") + .filter_map(|b| attr(&b.body, name)) + .collect(); + assert_eq!(defs.len(), 1, "local.{name} defined once"); + defs[0] +} + +/// Template text with every whole `${local.x}` replaced by the local's own +/// text, repeatedly, so the result names only variables, data sources and +/// traversals into other locals (`${local.env.log_group}` stays as written). +fn expand_locals(body: &Body, text: &str) -> String { + let mut out = text.to_string(); + let mut from = 0; + for _ in 0..32 { + let Some(found) = out[from..].find("${local.") else { + return out; + }; + let start = from + found; + let name_start = start + "${local.".len(); + let name_len = out[name_start..] + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(out.len() - name_start); + let name_end = name_start + name_len; + if !out[name_end..].starts_with('}') { + from = name_end; + continue; + } + let replacement = match local_def(body, &out[name_start..name_end]) { + e @ (Expression::String(_) | Expression::TemplateExpr(_)) => template_text(e), + other => format!("${{{}}}", render(other)), + }; + out = format!("{}{}{}", &out[..start], replacement, &out[name_end + 1..]); + from = start; + } + panic!("locals nest too deeply in {text}") +} + +/// An HCL value as JSON, keeping interpolations as template text +/// (`aws_iam_role.task.arn` becomes `"${aws_iam_role.task.arn}"`), so a +/// `jsonencode({...})` policy can be inspected like the JSON it produces. +fn to_json(expr: &Expression) -> serde_json::Value { + use serde_json::Value; + match expr { + Expression::Null => Value::Null, + Expression::Bool(b) => Value::Bool(*b), + Expression::Number(n) => serde_json::to_value(n).unwrap(), + Expression::Array(items) => Value::Array(items.iter().map(to_json).collect()), + Expression::Object(obj) => { + Value::Object(obj.iter().map(|(k, v)| (key_name(k), to_json(v))).collect()) + } + other => Value::String(template_text(other)), + } +} + +/// The `jsonencode(...)` argument of `attr_name`, as JSON. +fn jsonencoded(block: &Block, attr_name: &str) -> serde_json::Value { + let expr = attr(&block.body, attr_name) + .unwrap_or_else(|| panic!("{:?} has no {attr_name}", block.labels)); + let Expression::FuncCall(call) = expr else { + panic!("{:?}.{attr_name} is not jsonencode(...)", block.labels) + }; + assert_eq!(call.name.name.as_str(), "jsonencode"); + to_json(&call.args[0]) +} + +fn statements(policy: &serde_json::Value) -> Vec { + assert_eq!(policy["Version"], "2012-10-17"); + policy["Statement"] + .as_array() + .expect("Statement list") + .clone() +} + +/// A statement's actions (a string or a list). +fn actions(stmt: &serde_json::Value) -> BTreeSet { + match &stmt["Action"] { + serde_json::Value::String(a) => BTreeSet::from([a.clone()]), + serde_json::Value::Array(a) => a.iter().map(|v| v.as_str().unwrap().to_string()).collect(), + other => panic!("bad Action {other}"), + } +} + +fn resource(body: &Body, ty: &str, name: &str) -> Block { + resources(body, ty) + .into_iter() + .find(|b| label(b, 1) == Some(name)) + .unwrap_or_else(|| panic!("{ty}.{name} missing")) + .clone() +} + +/// The inline policies attached to `aws_iam_role.`, by resource name. +fn role_policies(body: &Body, role: &str) -> Vec { + resources(body, "aws_iam_role_policy") + .into_iter() + .filter(|b| render(attr(&b.body, "role").unwrap()) == format!("aws_iam_role.{role}.id")) + .cloned() + .collect() +} + +#[test] +fn every_onboarding_role_carries_the_boundary_and_owner_tag() { + let body = parsed(ModuleKind::UserOnboard); + + // Owner, environment, component and version come from the provider's + // default_tags, so every role (and everything else) carries them. + let tags = default_tags(&body); + assert_eq!( + object_keys(tags), + BTreeSet::from([ + "triple-c:component".to_string(), + "triple-c:environment".to_string(), + "triple-c:owner".to_string(), + "triple-c:template-version".to_string(), + ]) + ); + assert_eq!( + object_get(tags, "triple-c:component"), + Some(&Expression::String("user-onboarding".into())) + ); + assert_eq!( + render(object_get(tags, "triple-c:owner").unwrap()), + "var.owner_id" + ); + assert_eq!( + render(object_get(tags, "triple-c:environment").unwrap()), + "var.environment_name" + ); + assert_eq!( + tf_text(ModuleKind::UserOnboard) + .matches("triple-c:component") + .count(), + 1 + ); + for block in all_blocks(&body) { + if block.identifier.as_str() == "default_tags" { + continue; + } + for key in ["tags", "tags_all"] { + if let Some(t) = attr(&block.body, key) { + let keys = object_keys(t); + assert!( + keys.iter().all(|k| !k.starts_with("triple-c:")), + "{:?} sets triple-c tags {keys:?}", + block.labels + ); + } + } + } + + // Exactly the two per-user roles, named as the app names them, each + // bounded; their permissions are inline only (nothing attached that the + // module doesn't spell out). + let roles = resources(&body, "aws_iam_role"); + assert_eq!(roles.len(), 2, "task and exec roles"); + let key = owner_key("AROAEXAMPLE:alice"); + let (task, exec) = user_role_names("dev", &key); + for (name, want) in [("task", task), ("exec", exec)] { + let role = resource(&body, "aws_iam_role", name); + let named = expand_locals(&body, &template_text(attr(&role.body, "name").unwrap())) + .replace("${var.environment_name}", "dev") + .replace("${var.owner_key}", &key); + assert_eq!(named, want); + let boundary = attr(&role.body, "permissions_boundary") + .unwrap_or_else(|| panic!("{name} role has no permissions_boundary")); + assert_eq!( + expand_locals(&body, &template_text(boundary)), + BOUNDARY_ARN_TEMPLATE + ); + assert!(attr(&role.body, "managed_policy_arns").is_none()); + assert!(top_blocks(&role.body, "inline_policy").next().is_none()); + } + for ty in [ + "aws_iam_role_policy_attachment", + "aws_iam_role_policy_attachments_exclusive", + "aws_iam_policy_attachment", + "aws_iam_policy", + "aws_iam_user", + "aws_iam_instance_profile", + ] { + assert!(resources(&body, ty).is_empty(), "{ty} present"); + } + + // The onboarding module's version agrees with the app's. + let version = local_def(&body, "template_version"); + assert_eq!(version, &Expression::Number(ONBOARD_MODULE_VERSION.into())); + assert!( + render(object_get(tags, "triple-c:template-version").unwrap()) + .contains("local.template_version") + ); +} + +#[test] +fn role_trust_is_ecs_tasks_in_this_account_only() { + let body = parsed(ModuleKind::UserOnboard); + for role in resources(&body, "aws_iam_role") { + let trust = jsonencoded(role, "assume_role_policy"); + assert_eq!( + statements(&trust), + vec![json!({ + "Effect": "Allow", + "Principal": { "Service": "ecs-tasks.amazonaws.com" }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "${data.aws_caller_identity.current.account_id}" + } + } + })], + "{:?}", + role.labels + ); + } +} + +#[test] +fn the_users_file_system_admits_only_their_task_role_through_an_access_point() { + let body = parsed(ModuleKind::UserOnboard); + + let systems = resources(&body, "aws_efs_file_system"); + assert_eq!(systems.len(), 1); + let fs = systems[0]; + assert_eq!(label(fs, 1), Some("this")); + assert_eq!(attr(&fs.body, "encrypted"), Some(&Expression::Bool(true))); + assert_eq!( + attr(&fs.body, "performance_mode"), + Some(&Expression::String("generalPurpose".into())) + ); + assert_eq!( + attr(&fs.body, "throughput_mode"), + Some(&Expression::String("elastic".into())) + ); + + // One mount target in each of the environment's two subnets. + let targets = resources(&body, "aws_efs_mount_target"); + assert_eq!(targets.len(), 1); + let mt = targets[0]; + assert_eq!(attr(&mt.body, "count"), Some(&Expression::Number(2.into()))); + assert_eq!( + render(attr(&mt.body, "file_system_id").unwrap()), + "aws_efs_file_system.this.id" + ); + assert_eq!( + render(attr(&mt.body, "subnet_id").unwrap()), + "local.env.subnet_ids[count.index]" + ); + assert_eq!( + to_json(attr(&mt.body, "security_groups").unwrap()), + json!(["${aws_security_group.efs.id}"]) + ); + + // The file system policy. + let policies = resources(&body, "aws_efs_file_system_policy"); + assert_eq!(policies.len(), 1); + assert_eq!( + render(attr(&policies[0].body, "file_system_id").unwrap()), + "aws_efs_file_system.this.id" + ); + let stmts = statements(&jsonencoded(policies[0], "policy")); + let fs_arn = "${aws_efs_file_system.this.arn}"; + let deny_insecure = stmts + .iter() + .find(|s| s["Condition"]["Bool"]["aws:SecureTransport"] == "false") + .expect("a SecureTransport deny"); + assert_eq!(deny_insecure["Effect"], "Deny"); + assert_eq!(deny_insecure["Principal"], json!({ "AWS": "*" })); + assert_eq!(deny_insecure["Action"], "*"); + assert_eq!(deny_insecure["Resource"], fs_arn); + + let deny_direct = stmts + .iter() + .find(|s| s["Condition"]["Null"]["elasticfilesystem:AccessPointArn"] == "true") + .expect("a deny without an access point"); + assert_eq!(deny_direct["Effect"], "Deny"); + assert_eq!(deny_direct["Principal"], json!({ "AWS": "*" })); + assert!( + actions(deny_direct).is_superset(&BTreeSet::from([ + "elasticfilesystem:ClientMount".to_string(), + "elasticfilesystem:ClientWrite".to_string(), + "elasticfilesystem:ClientRootAccess".to_string(), + ])), + "{deny_direct}" + ); + assert_eq!(deny_direct["Resource"], fs_arn); + + let allows: Vec<_> = stmts.iter().filter(|s| s["Effect"] == "Allow").collect(); + assert_eq!(allows.len(), 1, "one Allow: {stmts:?}"); + assert_eq!( + allows[0]["Principal"], + json!({ "AWS": "${aws_iam_role.task.arn}" }) + ); + assert_eq!( + actions(allows[0]), + BTreeSet::from([ + "elasticfilesystem:ClientMount".to_string(), + "elasticfilesystem:ClientWrite".to_string(), + ]) + ); + assert_eq!(allows[0]["Resource"], fs_arn); + assert!(allows[0].get("Condition").is_none() || allows[0]["Condition"].is_null()); + assert_eq!(stmts.len(), 3, "{stmts:?}"); + + // The task role may mount this file system and no other. + let efs: Vec<_> = role_policies(&body, "task") + .iter() + .flat_map(|p| statements(&jsonencoded(p, "policy"))) + .filter(|s| { + actions(s) + .iter() + .any(|a| a.starts_with("elasticfilesystem:")) + }) + .collect(); + assert_eq!(efs.len(), 1); + assert_eq!(efs[0]["Resource"], fs_arn); +} + +#[test] +fn the_environment_security_group_gains_no_ingress() { + let body = parsed(ModuleKind::UserOnboard); + for ty in [ + "aws_vpc_security_group_ingress_rule", + "aws_vpc_security_group_egress_rule", + "aws_security_group_rule", + "aws_network_interface_sg_attachment", + ] { + assert!(resources(&body, ty).is_empty(), "{ty} present"); + } + + // The module's only security group is the user's mount-target group. + let groups = resources(&body, "aws_security_group"); + assert_eq!(groups.len(), 1); + let sg = groups[0]; + assert_eq!(label(sg, 1), Some("efs")); + let key = owner_key("AROAEXAMPLE:alice"); + let name = expand_locals(&body, &template_text(attr(&sg.body, "name").unwrap())) + .replace("${var.environment_name}", "dev") + .replace("${var.owner_key}", &key); + assert_eq!(name, format!("triple-c-dev-{key}-efs")); + + // Its single ingress rule: NFS from the environment's task group, taken + // from the record. There is no other ingress anywhere in the module. + let ingress: Vec<_> = all_blocks(&body) + .into_iter() + .filter(|b| b.identifier.as_str() == "ingress") + .collect(); + assert_eq!(ingress.len(), 1, "exactly one ingress rule"); + assert_eq!(top_blocks(&sg.body, "ingress").count(), 1); + let rule = &ingress[0].body; + assert_eq!( + attr(rule, "from_port"), + Some(&Expression::Number(2049.into())) + ); + assert_eq!( + attr(rule, "to_port"), + Some(&Expression::Number(2049.into())) + ); + assert_eq!( + attr(rule, "protocol"), + Some(&Expression::String("tcp".into())) + ); + assert_eq!( + to_json(attr(rule, "security_groups").unwrap()), + json!(["${local.env.security_group_id}"]) + ); + for open in ["cidr_blocks", "ipv6_cidr_blocks", "prefix_list_ids", "self"] { + assert!(attr(rule, open).is_none(), "the NFS rule sets {open}"); + } + + // Mount targets never initiate connections (and groups are stateful), so + // the group has no egress; `egress = []` removes any added out of band. + assert_eq!(attr(&sg.body, "egress"), Some(&Expression::Array(vec![]))); + assert!(top_blocks(&sg.body, "egress").next().is_none()); + + // The environment's group is referenced only as that rule's source. + let text = tf_text(ModuleKind::UserOnboard); + assert_eq!(text.matches("local.env.security_group_id").count(), 1); + assert_eq!( + text.matches("security_group_id").count(), + 2, + "the rule and the efs group output" + ); +} + +#[test] +fn exec_role_reads_only_its_owners_secrets() { + let body = parsed(ModuleKind::UserOnboard); + let account = "${data.aws_caller_identity.current.account_id}"; + let partition = "${data.aws_partition.current.partition}"; + let expand = |v: &serde_json::Value| expand_locals(&body, v.as_str().unwrap()); + + let policies = role_policies(&body, "exec"); + assert_eq!(policies.len(), 1, "one exec policy"); + let stmts = statements(&jsonencoded(&policies[0], "policy")); + let all: BTreeSet = stmts.iter().flat_map(actions).collect(); + assert_eq!( + all, + BTreeSet::from([ + "secretsmanager:GetSecretValue".to_string(), + "kms:Decrypt".to_string(), + "logs:CreateLogStream".to_string(), + "logs:PutLogEvents".to_string(), + ]) + ); + assert!(stmts.iter().all(|s| s["Effect"] == "Allow")); + + let secrets = stmts + .iter() + .find(|s| actions(s).contains("secretsmanager:GetSecretValue")) + .unwrap(); + assert_eq!(actions(secrets).len(), 1); + assert_eq!( + expand(&secrets["Resource"]), + format!("arn:{partition}:secretsmanager:${{var.region}}:{account}:secret:triple-c/*") + ); + assert_eq!( + secrets["Condition"], + json!({ "StringEquals": { "aws:ResourceTag/triple-c:owner": "${var.owner_id}" } }) + ); + + let decrypt = stmts + .iter() + .find(|s| actions(s).contains("kms:Decrypt")) + .unwrap(); + assert_eq!(actions(decrypt).len(), 1); + assert_eq!(decrypt["Resource"], "${local.env.kms_key_arn}"); + assert_eq!( + decrypt["Condition"], + json!({ "StringEquals": { "kms:ViaService": "secretsmanager.${var.region}.amazonaws.com" } }) + ); + + // Logs: the environment's log group only, for both roles. + let log_group = format!( + "arn:{partition}:logs:${{var.region}}:{account}:log-group:${{local.env.log_group}}:*" + ); + for role in ["exec", "task"] { + let logs: Vec<_> = role_policies(&body, role) + .iter() + .flat_map(|p| statements(&jsonencoded(p, "policy"))) + .filter(|s| actions(s).iter().any(|a| a.starts_with("logs:"))) + .collect(); + assert_eq!(logs.len(), 1, "{role}"); + assert_eq!( + actions(&logs[0]), + BTreeSet::from([ + "logs:CreateLogStream".to_string(), + "logs:PutLogEvents".to_string() + ]) + ); + assert_eq!(expand(&logs[0]["Resource"]), log_group, "{role}"); + } + + // The task role: exec channels, logs and its file system; Bedrock only + // through a separate policy that exists when bedrock_on_task_role is set. + let task = role_policies(&body, "task"); + assert_eq!(task.len(), 2, "task policy + optional bedrock policy"); + let mut always = BTreeSet::new(); + for p in &task { + let acts: BTreeSet = statements(&jsonencoded(p, "policy")) + .iter() + .flat_map(actions) + .collect(); + if acts.iter().any(|a| a.starts_with("bedrock:")) { + assert_eq!( + render(attr(&p.body, "count").expect("bedrock policy is counted")), + "var.bedrock_on_task_role ? 1 : 0" + ); + assert!( + acts.iter().all(|a| a.starts_with("bedrock:Invoke")), + "{acts:?}" + ); + } else { + assert!(attr(&p.body, "count").is_none()); + always.extend(acts); + } + } + assert_eq!( + always, + BTreeSet::from([ + "ssmmessages:CreateControlChannel".to_string(), + "ssmmessages:CreateDataChannel".to_string(), + "ssmmessages:OpenControlChannel".to_string(), + "ssmmessages:OpenDataChannel".to_string(), + "logs:CreateLogStream".to_string(), + "logs:PutLogEvents".to_string(), + "elasticfilesystem:ClientMount".to_string(), + "elasticfilesystem:ClientWrite".to_string(), + ]) + ); +} + +#[test] +fn onboarding_reads_the_environment_only_from_its_record() { + let body = parsed(ModuleKind::UserOnboard); + let params: Vec<_> = top_blocks(&body, "data") + .filter(|b| label(b, 0) == Some("aws_ssm_parameter")) + .collect(); + assert_eq!(params.len(), 1); + assert_eq!( + render(attr(¶ms[0].body, "name").unwrap()), + "\"/triple-c/environments/${var.environment_name}\"" + ); + assert!(render(local_def(&body, "env")).starts_with("jsondecode(data.aws_ssm_parameter.")); + // No remote state, no other reads of the environment's deployment. + let text = tf_text(ModuleKind::UserOnboard); + assert!(!text.contains("terraform_remote_state")); + assert!(resources(&body, "aws_ssm_parameter").is_empty()); +} + +#[test] +fn onboarding_vars_name_the_owner() { + let owner = "AROAEXAMPLEID:alice@example.com"; + let vars = onboarding_vars("dev", "us-west-2", owner); + assert_eq!( + vars, + json!({ + "environment_name": "dev", + "region": "us-west-2", + "owner_id": owner, + "owner_key": owner_key(owner), + "bedrock_on_task_role": false, + }) + ); + let body = parsed(ModuleKind::UserOnboard); + let declared: BTreeSet<_> = top_blocks(&body, "variable") + .filter_map(|b| label(b, 0)) + .collect(); + assert_eq!(declared, ONBOARD_VARIABLES.iter().copied().collect()); + for v in top_blocks(&body, "variable") { + if label(v, 0) != Some("bedrock_on_task_role") { + assert!( + top_blocks(&v.body, "validation").next().is_some(), + "variable {:?} has no validation", + label(v, 0) + ); + } + } +} + +#[test] +fn boundary_vars_carry_the_shipped_boundary() { + let vars = boundary_vars("us-west-2"); + assert_eq!( + vars, + json!({ + "region": "us-west-2", + "policy_json": include_str!("../../../../../aws/policies/permissions-boundary.json"), + }) + ); + assert_eq!( + vars["policy_json"], + crate::aws::policies::permissions_boundary_json() + ); + + let body = parsed(ModuleKind::PermissionsBoundary); + let declared: BTreeSet<_> = top_blocks(&body, "variable") + .filter_map(|b| label(b, 0)) + .collect(); + assert_eq!(declared, BTreeSet::from(["region", "policy_json"])); + + // One policy, no path, whose document is exactly what the app passes. + let policies = resources(&body, "aws_iam_policy"); + assert_eq!(policies.len(), 1); + let policy = policies[0]; + assert_eq!( + attr(&policy.body, "name"), + Some(&Expression::String(BOUNDARY_POLICY_NAME.into())) + ); + assert!( + attr(&policy.body, "path").is_none() + || attr(&policy.body, "path") == Some(&Expression::String("/".into())) + ); + assert_eq!( + render(attr(&policy.body, "policy").unwrap()), + "var.policy_json" + ); + let resource_count = top_blocks(&body, "resource").count(); + assert_eq!( + resource_count, 1, + "the boundary module creates only the policy" + ); + + let tags = default_tags(&body); + assert_eq!( + object_keys(tags), + BTreeSet::from(["triple-c:component".to_string()]) + ); + assert_eq!( + object_get(tags, "triple-c:component"), + Some(&Expression::String("boundary".into())) + ); +} diff --git a/aws/terraform/permissions-boundary/.terraform.lock.hcl b/aws/terraform/permissions-boundary/.terraform.lock.hcl new file mode 100644 index 0000000..55c488f --- /dev/null +++ b/aws/terraform/permissions-boundary/.terraform.lock.hcl @@ -0,0 +1,39 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.73.0" + constraints = "6.73.0" + hashes = [ + "h1:1zQPP8XHNYS2m7MnhPd19nR6xjWk3p/e08X+B7GGx7I=", + "h1:IdAE6YFUBZvlLDzcKe+khHpceC+cZMJy8DOsuFV1iLs=", + "h1:KLd1R0RCfFNyhJ8fBLI7KuCI8VvV8fMZh2+Bt4/5UEk=", + "h1:NE8f7fV/bAVTxhVOuQSIRcG0/NTNMrrrZTFJ863U3v8=", + "h1:PLSysFV+jYgBuI42kYv4AMGMDusaKj6o4MfKz7+AD2w=", + "h1:PirCmCZao9jd4snXgOlO+OrG4HtPKQVyiu37vmoM6Y8=", + "h1:ZDDEyufl0zfy5gWdRkfFrDj/K7Dt9oym+FTrKTyzPmY=", + "h1:cqILWB/Kxr0AYCi+pJj9A8d5KzsXEOrKxbFHh3VfKsQ=", + "h1:iUIwaluxZuApYxl8C5Xpwo5mb/9OkEMn6tAzLKVTxag=", + "h1:jjsA3nBL/NmNYeG0bmVhHv1ruFBH2ucdkW9VH1iE5Tw=", + "h1:mKUNC7il3hHVi93ZyWogDKWTCwuhorndZf629rHqElQ=", + "h1:miKdeCwqPUV/IPN+HCS0ZZquE4ENjb18UhOO612M+AY=", + "h1:mrSvOTfc0Uwurvpyj9uEhbGLUqUWA1p3VAAK+qA8OXs=", + "h1:pOp3kyT/dPNiMXzc7CtQqFrPhIFR7gqtgX5iepQJczs=", + "h1:q5MABxUl6HoEzLYB21WOLdH+EZzioo0n9/8pkKYweF0=", + "zh:01a4a08962eb85b35c9912e715df1a5124e5064cd60792b37c82ebe8898489b8", + "zh:3efa54bad2af67c07547f6682bfe2b419c9f018c63008dc7d8a1e4691fd35569", + "zh:432944bc45558e48a526e522b559dc74b70dc37cfe460420ed48527d36563531", + "zh:6e06ddd0fa7f5c9b88c428abb18ae8d8ee9749a773ca52b2ee5010d8ce697eca", + "zh:7efbfba3b2daed43b0ee0322b62cfd0c9102b2320cac74878e950b5d02ffc979", + "zh:8866298c36a45b7867415119289a8f680f32d54f5553f738880ffcc0561c6907", + "zh:9dcde5077f648b6231eef6de019a5dd89ba7ec393767be4f6cbde7e420a5a874", + "zh:9fd52591011b1066036a47a4e677cad00d63956579a200cb57c9228f1dfb4a1a", + "zh:a61f8e114e5233e63b8d2b034b1ad59a220e532fa34aaaa63eec6bad73a0ca21", + "zh:ba50cb9245a579285169565995e20bebb410e7f4d4181dc7de1205877fe04728", + "zh:c2eb24eedab492b3e42f78632b0dd6a8d23f3021b080fee2137e91765629a9e1", + "zh:cac7953f04c637c303f212b87e96089950bb95856ffbeb2719c5138299a64787", + "zh:d7929af6ee61fb8c7d6ec5d3a0ff9cf8554f01d0b470b3787d7e7882e6df3946", + "zh:d7f889bcdbf6b5889ca2252ce517241a2723e61927b2826683d01b648ea91f9a", + "zh:ff4c4ca563e044561e3c3836f51b24dca034879574ca7e334b27a49b25d1df51", + ] +} diff --git a/aws/terraform/permissions-boundary/main.tf b/aws/terraform/permissions-boundary/main.tf new file mode 100644 index 0000000..6edd29c --- /dev/null +++ b/aws/terraform/permissions-boundary/main.tf @@ -0,0 +1,20 @@ +# The permissions boundary every role a Triple-C module creates must carry: +# the most any such role may ever hold. One per account. + +provider "aws" { + region = var.region + + default_tags { + tags = { + "triple-c:component" = "boundary" + } + } +} + +resource "aws_iam_policy" "boundary" { + # BOUNDARY_POLICY_NAME in app/src-tauri/src/aws/tofu_modules.rs; no path, + # so the ARN the other modules build is exact. + name = "triple-c-permissions-boundary" + description = "Triple-C: the most any Triple-C-created role may hold" + policy = var.policy_json +} diff --git a/aws/terraform/permissions-boundary/outputs.tf b/aws/terraform/permissions-boundary/outputs.tf new file mode 100644 index 0000000..1c9420a --- /dev/null +++ b/aws/terraform/permissions-boundary/outputs.tf @@ -0,0 +1,3 @@ +output "policy_arn" { + value = aws_iam_policy.boundary.arn +} diff --git a/aws/terraform/permissions-boundary/variables.tf b/aws/terraform/permissions-boundary/variables.tf new file mode 100644 index 0000000..a78fa0f --- /dev/null +++ b/aws/terraform/permissions-boundary/variables.tf @@ -0,0 +1,22 @@ +variable "region" { + description = "The region the provider talks to (IAM itself is global), e.g. us-west-2." + type = string + + validation { + condition = ( + can(regex("^[a-z]{2}(-[a-z]+)+-[0-9]+$", var.region)) + && length([for p in ["cn-", "us-gov-", "us-iso-", "us-isob-", "eu-isoe-", "us-isof-"] : p if startswith(var.region, p)]) == 0 + ) + error_message = "region must be a commercial AWS region id such as us-west-2 (China and GovCloud are not supported)." + } +} + +variable "policy_json" { + description = "The boundary document: the app passes aws/policies/permissions-boundary.json verbatim, so this module holds no copy." + type = string + + validation { + condition = can(jsondecode(var.policy_json).Statement) + error_message = "policy_json must be an IAM policy document (JSON with a Statement)." + } +} diff --git a/aws/terraform/permissions-boundary/versions.tf b/aws/terraform/permissions-boundary/versions.tf new file mode 100644 index 0000000..a3efb1c --- /dev/null +++ b/aws/terraform/permissions-boundary/versions.tf @@ -0,0 +1,15 @@ +terraform { + # OpenTofu 1.10+ or Terraform 1.10+: S3-native state locking (`use_lockfile`). + required_version = ">= 1.10" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "6.73.0" + } + } + + # Filled in by the app (or an admin bundle's backend.hcl) with + # `-backend-config`: bucket, key, region, use_lockfile = true. + backend "s3" {} +} diff --git a/aws/terraform/user-onboard/.terraform.lock.hcl b/aws/terraform/user-onboard/.terraform.lock.hcl new file mode 100644 index 0000000..55c488f --- /dev/null +++ b/aws/terraform/user-onboard/.terraform.lock.hcl @@ -0,0 +1,39 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.73.0" + constraints = "6.73.0" + hashes = [ + "h1:1zQPP8XHNYS2m7MnhPd19nR6xjWk3p/e08X+B7GGx7I=", + "h1:IdAE6YFUBZvlLDzcKe+khHpceC+cZMJy8DOsuFV1iLs=", + "h1:KLd1R0RCfFNyhJ8fBLI7KuCI8VvV8fMZh2+Bt4/5UEk=", + "h1:NE8f7fV/bAVTxhVOuQSIRcG0/NTNMrrrZTFJ863U3v8=", + "h1:PLSysFV+jYgBuI42kYv4AMGMDusaKj6o4MfKz7+AD2w=", + "h1:PirCmCZao9jd4snXgOlO+OrG4HtPKQVyiu37vmoM6Y8=", + "h1:ZDDEyufl0zfy5gWdRkfFrDj/K7Dt9oym+FTrKTyzPmY=", + "h1:cqILWB/Kxr0AYCi+pJj9A8d5KzsXEOrKxbFHh3VfKsQ=", + "h1:iUIwaluxZuApYxl8C5Xpwo5mb/9OkEMn6tAzLKVTxag=", + "h1:jjsA3nBL/NmNYeG0bmVhHv1ruFBH2ucdkW9VH1iE5Tw=", + "h1:mKUNC7il3hHVi93ZyWogDKWTCwuhorndZf629rHqElQ=", + "h1:miKdeCwqPUV/IPN+HCS0ZZquE4ENjb18UhOO612M+AY=", + "h1:mrSvOTfc0Uwurvpyj9uEhbGLUqUWA1p3VAAK+qA8OXs=", + "h1:pOp3kyT/dPNiMXzc7CtQqFrPhIFR7gqtgX5iepQJczs=", + "h1:q5MABxUl6HoEzLYB21WOLdH+EZzioo0n9/8pkKYweF0=", + "zh:01a4a08962eb85b35c9912e715df1a5124e5064cd60792b37c82ebe8898489b8", + "zh:3efa54bad2af67c07547f6682bfe2b419c9f018c63008dc7d8a1e4691fd35569", + "zh:432944bc45558e48a526e522b559dc74b70dc37cfe460420ed48527d36563531", + "zh:6e06ddd0fa7f5c9b88c428abb18ae8d8ee9749a773ca52b2ee5010d8ce697eca", + "zh:7efbfba3b2daed43b0ee0322b62cfd0c9102b2320cac74878e950b5d02ffc979", + "zh:8866298c36a45b7867415119289a8f680f32d54f5553f738880ffcc0561c6907", + "zh:9dcde5077f648b6231eef6de019a5dd89ba7ec393767be4f6cbde7e420a5a874", + "zh:9fd52591011b1066036a47a4e677cad00d63956579a200cb57c9228f1dfb4a1a", + "zh:a61f8e114e5233e63b8d2b034b1ad59a220e532fa34aaaa63eec6bad73a0ca21", + "zh:ba50cb9245a579285169565995e20bebb410e7f4d4181dc7de1205877fe04728", + "zh:c2eb24eedab492b3e42f78632b0dd6a8d23f3021b080fee2137e91765629a9e1", + "zh:cac7953f04c637c303f212b87e96089950bb95856ffbeb2719c5138299a64787", + "zh:d7929af6ee61fb8c7d6ec5d3a0ff9cf8554f01d0b470b3787d7e7882e6df3946", + "zh:d7f889bcdbf6b5889ca2252ce517241a2723e61927b2826683d01b648ea91f9a", + "zh:ff4c4ca563e044561e3c3836f51b24dca034879574ca7e334b27a49b25d1df51", + ] +} diff --git a/aws/terraform/user-onboard/efs.tf b/aws/terraform/user-onboard/efs.tf new file mode 100644 index 0000000..beca896 --- /dev/null +++ b/aws/terraform/user-onboard/efs.tf @@ -0,0 +1,91 @@ +# The user's own file system (D2): only their task role may mount it, and +# only through an access point, over TLS. + +# The environment's subnets, for the mount-target group's VPC and to check +# the two subnets can each hold one mount target. +data "aws_subnet" "task" { + count = 2 + id = local.env.subnet_ids[count.index] +} + +resource "aws_efs_file_system" "this" { + creation_token = local.name + encrypted = true + performance_mode = "generalPurpose" + throughput_mode = "elastic" + + tags = { Name = local.name } +} + +# Admits NFS from the environment's task group, which itself gains nothing. +resource "aws_security_group" "efs" { + name = "${local.name}-efs" + description = "Triple-C ${var.environment_name}: NFS to one user's file system" + vpc_id = data.aws_subnet.task[0].vpc_id + + ingress { + description = "NFS from the environment's tasks" + from_port = 2049 + to_port = 2049 + protocol = "tcp" + security_groups = [local.env.security_group_id] + } + + # Mount targets never open connections and groups are stateful, so NFS + # replies need no rule. Empty, not absent: OpenTofu then removes any rule + # added out of band (an absent egress would keep AWS's allow-all default). + egress = [] + + lifecycle { + precondition { + condition = ( + data.aws_subnet.task[0].vpc_id == data.aws_subnet.task[1].vpc_id + && data.aws_subnet.task[0].availability_zone != data.aws_subnet.task[1].availability_zone + ) + error_message = "The environment's two subnets must be in one VPC and in different availability zones." + } + } +} + +resource "aws_efs_mount_target" "this" { + count = 2 + file_system_id = aws_efs_file_system.this.id + subnet_id = local.env.subnet_ids[count.index] + security_groups = [aws_security_group.efs.id] +} + +resource "aws_efs_file_system_policy" "this" { + file_system_id = aws_efs_file_system.this.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "DenyInsecureTransport" + Effect = "Deny" + Principal = { AWS = "*" } + Action = "*" + Resource = aws_efs_file_system.this.arn + Condition = { Bool = { "aws:SecureTransport" = "false" } } + }, + { + Sid = "DenyWithoutAccessPoint" + Effect = "Deny" + Principal = { AWS = "*" } + Action = [ + "elasticfilesystem:ClientMount", + "elasticfilesystem:ClientWrite", + "elasticfilesystem:ClientRootAccess", + ] + Resource = aws_efs_file_system.this.arn + Condition = { Null = { "elasticfilesystem:AccessPointArn" = "true" } } + }, + { + Sid = "OwnersTaskRoleOnly" + Effect = "Allow" + Principal = { AWS = aws_iam_role.task.arn } + Action = ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite"] + Resource = aws_efs_file_system.this.arn + }, + ] + }) +} diff --git a/aws/terraform/user-onboard/iam.tf b/aws/terraform/user-onboard/iam.tf new file mode 100644 index 0000000..3b3caa4 --- /dev/null +++ b/aws/terraform/user-onboard/iam.tf @@ -0,0 +1,125 @@ +# The user's two roles. Both carry the permissions boundary and trust only +# ECS tasks in this account; the owner tag comes from default_tags. + +# The role the user's container runs as. +resource "aws_iam_role" "task" { + name = "${local.name}-task" + description = "Triple-C ${var.environment_name}: tasks of ${var.owner_id}" + permissions_boundary = local.boundary_arn + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + Action = "sts:AssumeRole" + Condition = { + StringEquals = { "aws:SourceAccount" = data.aws_caller_identity.current.account_id } + } + }] + }) +} + +resource "aws_iam_role_policy" "task" { + name = "triple-c-task" + role = aws_iam_role.task.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EcsExecChannels" + Effect = "Allow" + Action = [ + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel", + ] + Resource = "*" + }, + { + Sid = "WriteEnvironmentLogs" + Effect = "Allow" + Action = ["logs:CreateLogStream", "logs:PutLogEvents"] + Resource = local.log_group_arn + }, + { + Sid = "MountOwnFileSystem" + Effect = "Allow" + Action = ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite"] + Resource = aws_efs_file_system.this.arn + }, + ] + }) +} + +resource "aws_iam_role_policy" "task_bedrock" { + count = var.bedrock_on_task_role ? 1 : 0 + name = "triple-c-bedrock" + role = aws_iam_role.task.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "InvokeBedrockModels" + Effect = "Allow" + Action = ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"] + # Cross-region inference profiles invoke models in other regions. + Resource = [ + "arn:${local.partition}:bedrock:*::foundation-model/*", + "arn:${local.partition}:bedrock:*:${local.account}:inference-profile/*", + "arn:${local.partition}:bedrock:*:${local.account}:application-inference-profile/*", + ] + }] + }) +} + +# The role ECS uses to start the user's tasks: pull their secrets, write logs. +resource "aws_iam_role" "exec" { + name = "${local.name}-exec" + description = "Triple-C ${var.environment_name}: task execution for ${var.owner_id}" + permissions_boundary = local.boundary_arn + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + Action = "sts:AssumeRole" + Condition = { + StringEquals = { "aws:SourceAccount" = data.aws_caller_identity.current.account_id } + } + }] + }) +} + +resource "aws_iam_role_policy" "exec" { + name = "triple-c-exec" + role = aws_iam_role.exec.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadOwnSecrets" + Effect = "Allow" + Action = "secretsmanager:GetSecretValue" + Resource = "arn:${local.partition}:secretsmanager:${var.region}:${local.account}:secret:triple-c/*" + Condition = { + StringEquals = { "aws:ResourceTag/triple-c:owner" = var.owner_id } + } + }, + { + Sid = "DecryptSecretsThroughSecretsManager" + Effect = "Allow" + Action = "kms:Decrypt" + Resource = local.env.kms_key_arn + Condition = { + StringEquals = { "kms:ViaService" = "secretsmanager.${var.region}.amazonaws.com" } + } + }, + { + Sid = "WriteEnvironmentLogs" + Effect = "Allow" + Action = ["logs:CreateLogStream", "logs:PutLogEvents"] + Resource = local.log_group_arn + }, + ] + }) +} diff --git a/aws/terraform/user-onboard/main.tf b/aws/terraform/user-onboard/main.tf new file mode 100644 index 0000000..fda41c9 --- /dev/null +++ b/aws/terraform/user-onboard/main.tf @@ -0,0 +1,57 @@ +# One user's place in an environment: their task and execution roles and +# their own EFS file system (D2, strict tenancy). The environment itself is +# read only from its record, never from its state. + +locals { + # ONBOARD_MODULE_VERSION in app/src-tauri/src/aws/tofu_modules.rs. + template_version = 1 + name = "triple-c-${var.environment_name}-${var.owner_key}" + + partition = data.aws_partition.current.partition + account = data.aws_caller_identity.current.account_id + + # The environment record (EnvironmentInfo schema 1), checked below. + env = jsondecode(data.aws_ssm_parameter.environment.insecure_value) + + boundary_arn = "arn:${local.partition}:iam::${local.account}:policy/triple-c-permissions-boundary" + log_group_arn = "arn:${local.partition}:logs:${var.region}:${local.account}:log-group:${local.env.log_group}:*" +} + +provider "aws" { + region = var.region + + # The only place the component tag is set: the admin policy's + # destruction rights are scoped to it, so no resource may override it. + default_tags { + tags = { + "triple-c:component" = "user-onboarding" + "triple-c:environment" = var.environment_name + "triple-c:owner" = var.owner_id + "triple-c:template-version" = tostring(local.template_version) + } + } +} + +data "aws_caller_identity" "current" {} + +data "aws_partition" "current" {} + +# The record is a String parameter (not secret), so its non-sensitive value +# is read: subnet ids drive `count`, which a sensitive value cannot. +data "aws_ssm_parameter" "environment" { + name = "/triple-c/environments/${var.environment_name}" + with_decryption = false + + lifecycle { + postcondition { + condition = try( + jsondecode(self.insecure_value).schema == 1 + && jsondecode(self.insecure_value).name == var.environment_name + && jsondecode(self.insecure_value).tenancy == "strict" + && length(jsondecode(self.insecure_value).subnet_ids) == 2, + false + ) + error_message = "The environment record is missing or not a schema-1 strict-tenancy record with two subnets; deploy (or update) the environment first." + } + } +} diff --git a/aws/terraform/user-onboard/outputs.tf b/aws/terraform/user-onboard/outputs.tf new file mode 100644 index 0000000..7987212 --- /dev/null +++ b/aws/terraform/user-onboard/outputs.tf @@ -0,0 +1,19 @@ +output "task_role_arn" { + value = aws_iam_role.task.arn +} + +output "execution_role_arn" { + value = aws_iam_role.exec.arn +} + +output "file_system_id" { + value = aws_efs_file_system.this.id +} + +output "file_system_arn" { + value = aws_efs_file_system.this.arn +} + +output "efs_security_group_id" { + value = aws_security_group.efs.id +} diff --git a/aws/terraform/user-onboard/variables.tf b/aws/terraform/user-onboard/variables.tf new file mode 100644 index 0000000..8565f59 --- /dev/null +++ b/aws/terraform/user-onboard/variables.tf @@ -0,0 +1,56 @@ +# Every rule here mirrors the app's (`app/src-tauri/src/aws/tofu_modules.rs`), +# so an admin running a bundle by hand gets the same answers as the app. + +variable "environment_name" { + description = "The environment to join: lowercase letters, digits and single hyphens, starting with a letter, at most 20 characters." + type = string + + validation { + condition = ( + can(regex("^[a-z][a-z0-9-]{0,19}$", var.environment_name)) + && !endswith(var.environment_name, "-") + && !strcontains(var.environment_name, "--") + ) + error_message = "environment_name must match ^[a-z][a-z0-9-]{0,19}$, not end in '-' and not contain '--'." + } +} + +variable "region" { + description = "The environment's AWS region, e.g. us-west-2." + type = string + + validation { + condition = ( + can(regex("^[a-z]{2}(-[a-z]+)+-[0-9]+$", var.region)) + && length([for p in ["cn-", "us-gov-", "us-iso-", "us-isob-", "eu-isoe-", "us-isof-"] : p if startswith(var.region, p)]) == 0 + ) + error_message = "region must be a commercial AWS region id such as us-west-2 (China and GovCloud are not supported)." + } +} + +variable "owner_id" { + description = "The user's aws:userid (e.g. AROA...:alice@example.com). Every resource is tagged triple-c:owner with it." + type = string + + # Also a tag value, so only characters both allow. + validation { + condition = can(regex("^[A-Za-z0-9_.:/=+@-]{1,128}$", var.owner_id)) + error_message = "owner_id must be an aws:userid: 1 to 128 letters, digits and _.:/=+@- characters." + } +} + +variable "owner_key" { + description = "The first 12 hex characters of sha256(owner_id); names the user's roles, which reject the ':' in owner_id." + type = string + + validation { + condition = can(regex("^[0-9a-f]{12}$", var.owner_key)) && var.owner_key == substr(sha256(var.owner_id), 0, 12) + error_message = "owner_key must be the first 12 hex characters of sha256(owner_id)." + } +} + +variable "bedrock_on_task_role" { + description = "Whether the task role may invoke Bedrock models (otherwise the workspace user's own credentials are used)." + type = bool + default = false +} diff --git a/aws/terraform/user-onboard/versions.tf b/aws/terraform/user-onboard/versions.tf new file mode 100644 index 0000000..a3efb1c --- /dev/null +++ b/aws/terraform/user-onboard/versions.tf @@ -0,0 +1,15 @@ +terraform { + # OpenTofu 1.10+ or Terraform 1.10+: S3-native state locking (`use_lockfile`). + required_version = ">= 1.10" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "6.73.0" + } + } + + # Filled in by the app (or an admin bundle's backend.hcl) with + # `-backend-config`: bucket, key, region, use_lockfile = true. + backend "s3" {} +} -- 2.52.0 From 31c0a918e69a6f21fc903c3e0ffa96edda5b2177 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 21:08:32 -0800 Subject: [PATCH 06/24] fix(aws): EC2-safe SG descriptions, deny other EFS principals, format-check the record Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/tofu_modules/tests.rs | 131 +++++++++++++++++++- aws/terraform/user-onboard/efs.tf | 20 ++- aws/terraform/user-onboard/main.tf | 16 +++ 3 files changed, 162 insertions(+), 5 deletions(-) diff --git a/app/src-tauri/src/aws/tofu_modules/tests.rs b/app/src-tauri/src/aws/tofu_modules/tests.rs index 7f4eeb0..4d6fd8b 100644 --- a/app/src-tauri/src/aws/tofu_modules/tests.rs +++ b/app/src-tauri/src/aws/tofu_modules/tests.rs @@ -912,7 +912,28 @@ fn the_users_file_system_admits_only_their_task_role_through_an_access_point() { ); assert_eq!(allows[0]["Resource"], fs_arn); assert!(allows[0].get("Condition").is_none() || allows[0]["Condition"].is_null()); - assert_eq!(stmts.len(), 3, "{stmts:?}"); + let deny_others = stmts + .iter() + .find(|s| s["Sid"] == "DenyAllButOwnersTaskRole") + .expect("a deny for every principal but the task role"); + assert_eq!( + deny_others, + &json!({ + "Sid": "DenyAllButOwnersTaskRole", + "Effect": "Deny", + "Principal": { "AWS": "*" }, + "Action": [ + "elasticfilesystem:ClientMount", + "elasticfilesystem:ClientWrite", + "elasticfilesystem:ClientRootAccess", + ], + "Resource": fs_arn, + "Condition": { + "ArnNotEquals": { "aws:PrincipalArn": "${aws_iam_role.task.arn}" } + }, + }) + ); + assert_eq!(stmts.len(), 4, "{stmts:?}"); // The task role may mount this file system and no other. let efs: Vec<_> = role_policies(&body, "task") @@ -990,8 +1011,8 @@ fn the_environment_security_group_gains_no_ingress() { assert_eq!(text.matches("local.env.security_group_id").count(), 1); assert_eq!( text.matches("security_group_id").count(), - 2, - "the rule and the efs group output" + 4, + "the rule, the record check and its message, and the efs group output" ); } @@ -1203,3 +1224,107 @@ fn boundary_vars_carry_the_shipped_boundary() { Some(&Expression::String("boundary".into())) ); } + +/// EC2 accepts only these characters in a security group's or rule's +/// description; the provider checks only the length, so a stray apostrophe +/// passes `tofu validate` and fails `CreateSecurityGroup`. +fn is_ec2_description(text: &str) -> bool { + text.chars() + .all(|c| c.is_ascii_alphanumeric() || " ._-:/()#,@[]+=&;{}!$*".contains(c)) +} + +#[test] +fn security_group_descriptions_use_only_ec2_characters() { + assert!(is_ec2_description( + "Triple-C ${var.environment_name}: NFS (tcp)" + )); + assert!(!is_ec2_description("the environment's tasks")); + + for kind in [ModuleKind::Environment, ModuleKind::UserOnboard] { + let body = parsed(kind); + let mut seen = 0; + for ty in [ + "aws_security_group", + "aws_security_group_rule", + "aws_vpc_security_group_ingress_rule", + "aws_vpc_security_group_egress_rule", + ] { + for res in resources(&body, ty) { + let mut bodies = vec![&res.body]; + bodies.extend( + res.body + .blocks() + .filter(|b| matches!(b.identifier.as_str(), "ingress" | "egress")) + .map(|b| &b.body), + ); + for b in bodies { + if let Some(d) = attr(b, "description") { + let text = template_text(d); + assert!( + is_ec2_description(&text), + "{kind:?} {:?}: description {text:?} has characters EC2 rejects", + res.labels + ); + seen += 1; + } + } + } + } + assert!(seen >= 2, "{kind:?}: only {seen} descriptions checked"); + } +} + +#[test] +fn the_record_is_format_checked_before_use() { + let body = parsed(ModuleKind::UserOnboard); + let param = top_blocks(&body, "data") + .find(|b| label(b, 0) == Some("aws_ssm_parameter")) + .unwrap(); + let lifecycle = top_blocks(¶m.body, "lifecycle") + .next() + .expect("lifecycle"); + let conditions: String = top_blocks(&lifecycle.body, "postcondition") + .map(|p| render(attr(&p.body, "condition").expect("condition"))) + .collect::>() + .join("\n"); + for needle in [ + "jsondecode(self.insecure_value).schema == 1", + "jsondecode(self.insecure_value).name == var.environment_name", + "jsondecode(self.insecure_value).tenancy == \"strict\"", + "length(jsondecode(self.insecure_value).subnet_ids) == 2", + "\"^arn:aws[a-z-]*:kms:${var.region}:${data.aws_caller_identity.current.account_id}:key/[0-9a-f-]+$\"", + "jsondecode(self.insecure_value).kms_key_arn", + "jsondecode(self.insecure_value).log_group == \"/triple-c/${var.environment_name}\"", + "\"^sg-[0-9a-f]{8,17}$\"", + "jsondecode(self.insecure_value).security_group_id", + "\"^subnet-[0-9a-f]{8,17}$\"", + // Every subnet, not just the first. + "alltrue([for s in jsondecode(self.insecure_value).subnet_ids", + ] { + assert!( + conditions.contains(needle), + "the record postconditions do not check {needle}:\n{conditions}" + ); + } + + // Every record field the module reads is one the postconditions check. + let text = tf_text(ModuleKind::UserOnboard); + let mut used = BTreeSet::new(); + for (i, _) in text.match_indices("local.env.") { + let rest = &text[i + "local.env.".len()..]; + let end = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + used.insert(rest[..end].to_string()); + } + assert!( + used.contains("kms_key_arn") && used.contains("subnet_ids"), + "{used:?}" + ); + for field in &used { + assert!( + conditions.contains(&format!("jsondecode(self.insecure_value).{field}")), + "local.env.{field} is used but never checked" + ); + } +} diff --git a/aws/terraform/user-onboard/efs.tf b/aws/terraform/user-onboard/efs.tf index beca896..34b0242 100644 --- a/aws/terraform/user-onboard/efs.tf +++ b/aws/terraform/user-onboard/efs.tf @@ -18,13 +18,15 @@ resource "aws_efs_file_system" "this" { } # Admits NFS from the environment's task group, which itself gains nothing. +# EC2 descriptions allow only a-zA-Z0-9, spaces and ._-:/()#,@[]+=&;{}!$* +# (no apostrophes); the provider does not check, CreateSecurityGroup does. resource "aws_security_group" "efs" { name = "${local.name}-efs" - description = "Triple-C ${var.environment_name}: NFS to one user's file system" + description = "Triple-C ${var.environment_name}: NFS to the file system of ${var.owner_key}" vpc_id = data.aws_subnet.task[0].vpc_id ingress { - description = "NFS from the environment's tasks" + description = "NFS from the environment task group" from_port = 2049 to_port = 2049 protocol = "tcp" @@ -79,6 +81,20 @@ resource "aws_efs_file_system_policy" "this" { Resource = aws_efs_file_system.this.arn Condition = { Null = { "elasticfilesystem:AccessPointArn" = "true" } } }, + { + # Same-account access needs an identity OR a resource policy, so the + # Allow below is not enough by itself: refuse every other principal. + Sid = "DenyAllButOwnersTaskRole" + Effect = "Deny" + Principal = { AWS = "*" } + Action = [ + "elasticfilesystem:ClientMount", + "elasticfilesystem:ClientWrite", + "elasticfilesystem:ClientRootAccess", + ] + Resource = aws_efs_file_system.this.arn + Condition = { ArnNotEquals = { "aws:PrincipalArn" = aws_iam_role.task.arn } } + }, { Sid = "OwnersTaskRoleOnly" Effect = "Allow" diff --git a/aws/terraform/user-onboard/main.tf b/aws/terraform/user-onboard/main.tf index fda41c9..e100951 100644 --- a/aws/terraform/user-onboard/main.tf +++ b/aws/terraform/user-onboard/main.tf @@ -42,6 +42,8 @@ data "aws_ssm_parameter" "environment" { name = "/triple-c/environments/${var.environment_name}" with_decryption = false + # The record is untrusted input that feeds IAM policies and the network: + # every field this module uses is format-checked before anything is built. lifecycle { postcondition { condition = try( @@ -53,5 +55,19 @@ data "aws_ssm_parameter" "environment" { ) error_message = "The environment record is missing or not a schema-1 strict-tenancy record with two subnets; deploy (or update) the environment first." } + + postcondition { + condition = try( + can(regex( + "^arn:aws[a-z-]*:kms:${var.region}:${data.aws_caller_identity.current.account_id}:key/[0-9a-f-]+$", + jsondecode(self.insecure_value).kms_key_arn + )) + && jsondecode(self.insecure_value).log_group == "/triple-c/${var.environment_name}" + && can(regex("^sg-[0-9a-f]{8,17}$", jsondecode(self.insecure_value).security_group_id)) + && alltrue([for s in jsondecode(self.insecure_value).subnet_ids : can(regex("^subnet-[0-9a-f]{8,17}$", s))]), + false + ) + error_message = "The environment record has a malformed kms_key_arn (not a key in this region and account), log_group (not /triple-c/), security_group_id or subnet id." + } } } -- 2.52.0 From 7dbf56869650f3eadbc140b08ee225382a5186f2 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 21:25:48 -0800 Subject: [PATCH 07/24] feat(aws): admin policies cover the Terraform modules; drop CloudFormation Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 13 +- app/src-tauri/Cargo.lock | 27 - app/src-tauri/Cargo.toml | 1 - app/src-tauri/src/aws/live_tests.rs | 15 +- app/src-tauri/src/aws/mod.rs | 2 + app/src-tauri/src/aws/permissions.rs | 17 +- app/src-tauri/src/aws/permissions/tests.rs | 38 +- app/src-tauri/src/aws/policies.rs | 827 ++++++++++-------- app/src-tauri/src/aws/policy_eval.rs | 320 +++++++ app/src-tauri/src/aws/tofu_modules/tests.rs | 620 +++++++++++++ app/src-tauri/src/commands/host_commands.rs | 38 +- .../settings/HostsSettings.test.tsx | 48 +- app/src/components/settings/HostsSettings.tsx | 83 +- app/src/lib/tauri-commands.ts | 5 +- app/src/lib/types.ts | 6 + aws/README.md | 190 ++-- aws/policies/environment-admin-network.json | 139 +++ aws/policies/environment-admin.json | 209 ++--- aws/policies/workspace-user.json | 14 +- 19 files changed, 1993 insertions(+), 619 deletions(-) create mode 100644 app/src-tauri/src/aws/policy_eval.rs create mode 100644 aws/policies/environment-admin-network.json diff --git a/CLAUDE.md b/CLAUDE.md index 21feaa6..ae7a67b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -312,14 +312,17 @@ 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 two tier policies and the permissions boundary) - and the table of checks the simulator is asked; `permissions.rs` runs it + `policies.rs` embeds `aws/policies/*.json` (the tier policies — the admin tier spans 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 caller can't simulate itself; a probed run never claims the admin tier). `policies.rs`'s tests are the drift guard: every allowed action has a check, every check is allowed by a small IAM - evaluator, owner-scoped checks are denied for someone else, admin destruction is tag-scoped, - the boundary fences hold, every Resource is an ARN and every policy fits a 6,144-character - managed policy. **Change a policy JSON, its checks and `aws/README.md` together.** The evaluator + evaluator (`policy_eval.rs`, test-only; `tofu_modules/tests.rs` also asks it whether the admin + tier allows every call `MODULE_CALLS` lists for the Terraform modules), owner-scoped checks are + denied for someone else, admin destruction is tag-scoped, the boundary fences hold, every + Resource is an ARN and every policy fits a 6,144-character managed policy. **Change a policy + JSON, its checks and `aws/README.md` together.** The evaluator is not IAM: `live_tests.rs` (ignored; `TRIPLEC_AWS_E2E_PROFILE`, `TRIPLEC_AWS_E2E_REGION`, admin credentials for a disposable account; `cargo test aws::live_tests -- --ignored --test-threads=1`) runs the check and real calls against throwaway `triple-c-e2e-*` roles, diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 3b5a9e6..08fed18 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -366,32 +366,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "aws-sdk-cloudformation" -version = "1.139.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b77def03a5fb2442348afdd6ecd0eafbfa117243865cc78e899beb26edf7fb6d" -dependencies = [ - "arc-swap", - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-query", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", - "aws-smithy-xml", - "aws-types", - "fastrand", - "http", - "regex-lite", - "tracing", -] - [[package]] name = "aws-sdk-ecs" version = "1.159.0" @@ -7301,7 +7275,6 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-runtime", - "aws-sdk-cloudformation", "aws-sdk-ecs", "aws-sdk-iam", "aws-sdk-kms", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index c12a00e..b2b29b9 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -64,7 +64,6 @@ aws-sdk-ssm = { version = "1.137.0", default-features = false, features = ["defa aws-sdk-ecs = { version = "1.159.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } aws-sdk-sts = { version = "1.126.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } aws-sdk-iam = { version = "1.136.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } -aws-sdk-cloudformation = { version = "1.139.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } aws-sdk-secretsmanager = { version = "1.128.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } # Named by src/aws/connection.rs: the credentials-provider trait (its profile # wrapper) and the profile-file set (aws-config's alias for it is deprecated). diff --git a/app/src-tauri/src/aws/live_tests.rs b/app/src-tauri/src/aws/live_tests.rs index 9c5b92b..9385318 100644 --- a/app/src-tauri/src/aws/live_tests.rs +++ b/app/src-tauri/src/aws/live_tests.rs @@ -39,7 +39,7 @@ use super::permissions::{ SdkPermissionApi, Summary, Verdict, }; use super::policies::{ - checks, permissions_boundary_json, policy_json, Placeholders, Tier, PERMISSIONS_BOUNDARY_NAME, + checks, permissions_boundary_json, policy_files, Placeholders, Tier, PERMISSIONS_BOUNDARY_NAME, }; use super::{caller_identity, sdk_config, sdk_config_for_keys, CallerIdentity}; use crate::models::host_connection::{AwsAuth, HostConnection, HostKind}; @@ -276,10 +276,12 @@ impl E2e { Tier::WorkspaceUser => "workspace", Tier::EnvironmentAdmin => "admin", }; - let arn = self - .managed_policy(&format!("{suffix}-{name}"), policy_json(tier)) - .await; - self.attach(&role, &arn).await; + for (i, (_, json)) in policy_files(tier).iter().enumerate() { + let arn = self + .managed_policy(&format!("{suffix}-{name}{i}"), json) + .await; + self.attach(&role, &arn).await; + } } role } @@ -555,7 +557,7 @@ async fn a_role_with_no_policy_checks_as_insufficient_and_lists_every_action() { async fn a_role_without_simulate_permission_falls_back_to_probes() { e2e(|e| async move { let mut doc: serde_json::Value = - serde_json::from_str(policy_json(Tier::WorkspaceUser)).unwrap(); + serde_json::from_str(policy_files(Tier::WorkspaceUser)[0].1).unwrap(); doc["Statement"] .as_array_mut() .unwrap() @@ -578,7 +580,6 @@ async fn a_role_without_simulate_permission_falls_back_to_probes() { for probed in [ "ecs:ListClusters", "ssm:GetParametersByPath", - "cloudformation:DescribeStacks", "secretsmanager:ListSecrets", ] { assert_eq!(verdict(probed), Verdict::Allowed, "{probed}"); diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index f51e5ee..27134e3 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -10,6 +10,8 @@ mod identity; mod live_tests; pub mod permissions; pub mod policies; +#[cfg(test)] +mod policy_eval; pub mod tofu_modules; pub use connection::{sdk_config, sdk_config_for_keys}; diff --git a/app/src-tauri/src/aws/permissions.rs b/app/src-tauri/src/aws/permissions.rs index 52e9f81..6f6c314 100644 --- a/app/src-tauri/src/aws/permissions.rs +++ b/app/src-tauri/src/aws/permissions.rs @@ -74,15 +74,13 @@ pub fn simulation_request(check: &Check, p: &Placeholders, user_id: &str) -> Sim pub enum Probe { EcsListClusters, SsmGetParametersByPath, - CfnDescribeStacks, SecretsListSecrets, } impl Probe { - const ALL: [Probe; 4] = [ + const ALL: [Probe; 3] = [ Probe::EcsListClusters, Probe::SsmGetParametersByPath, - Probe::CfnDescribeStacks, Probe::SecretsListSecrets, ]; @@ -91,7 +89,6 @@ impl Probe { match action.split_once(':')?.0 { "ecs" => Some(Probe::EcsListClusters), "ssm" => Some(Probe::SsmGetParametersByPath), - "cloudformation" => Some(Probe::CfnDescribeStacks), "secretsmanager" => Some(Probe::SecretsListSecrets), _ => None, } @@ -101,7 +98,6 @@ impl Probe { match self { Probe::EcsListClusters => "ecs:ListClusters", Probe::SsmGetParametersByPath => "ssm:GetParametersByPath", - Probe::CfnDescribeStacks => "cloudformation:DescribeStacks", Probe::SecretsListSecrets => "secretsmanager:ListSecrets", } } @@ -130,7 +126,6 @@ pub struct SdkPermissionApi { iam: aws_sdk_iam::Client, ecs: aws_sdk_ecs::Client, ssm: aws_sdk_ssm::Client, - cfn: aws_sdk_cloudformation::Client, secrets: aws_sdk_secretsmanager::Client, } @@ -147,7 +142,6 @@ impl SdkPermissionApi { iam: aws_sdk_iam::Client::from_conf(iam), ecs: aws_sdk_ecs::Client::new(cfg), ssm: aws_sdk_ssm::Client::new(cfg), - cfn: aws_sdk_cloudformation::Client::new(cfg), secrets: aws_sdk_secretsmanager::Client::new(cfg), } } @@ -213,13 +207,6 @@ impl PermissionApi for SdkPermissionApi { .await .map(drop) .map_err(|e| classify(&e)), - Probe::CfnDescribeStacks => self - .cfn - .describe_stacks() - .send() - .await - .map(drop) - .map_err(|e| classify(&e)), Probe::SecretsListSecrets => self .secrets .list_secrets() @@ -515,7 +502,7 @@ async fn simulate_all( )) } -/// Per-tier outcomes from the four probes. `denied` is the action whose +/// Per-tier outcomes from the three probes. `denied` is the action whose /// denial forced probing; the caller's own-principal checks of it are /// `Denied`. async fn probe_all( diff --git a/app/src-tauri/src/aws/permissions/tests.rs b/app/src-tauri/src/aws/permissions/tests.rs index 182bb08..8a883da 100644 --- a/app/src-tauri/src/aws/permissions/tests.rs +++ b/app/src-tauri/src/aws/permissions/tests.rs @@ -17,10 +17,9 @@ fn identity() -> CallerIdentity { } } -const ALL_PROBES: [Probe; 4] = [ +const ALL_PROBES: [Probe; 3] = [ Probe::EcsListClusters, Probe::SsmGetParametersByPath, - Probe::CfnDescribeStacks, Probe::SecretsListSecrets, ]; @@ -214,15 +213,11 @@ fn a_missing_action_reports_insufficient_and_names_it() { #[test] fn an_admin_gap_still_reports_workspace_user() { let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); - api.allow.remove("cloudformation:CreateStack"); + api.allow.remove("ec2:CreateVpc"); let report = run(&api); assert_eq!(report.summary, Summary::WorkspaceUser); assert_eq!( - verdict( - &report, - Tier::EnvironmentAdmin, - "cloudformation:CreateStack" - ), + verdict(&report, Tier::EnvironmentAdmin, "ec2:CreateVpc"), Verdict::Denied ); } @@ -407,10 +402,6 @@ fn a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified() { verdict(&report, ws, "ssm:GetParametersByPath"), Verdict::Allowed ); - assert_eq!( - verdict(&report, ws, "cloudformation:DescribeStacks"), - Verdict::Allowed - ); assert_eq!( verdict(&report, ws, "secretsmanager:ListSecrets"), Verdict::Allowed @@ -446,7 +437,7 @@ fn a_denied_get_role_falls_back_to_probes() { m => panic!("expected Probed, got {m:?}"), } assert!(api.simulated().is_empty()); - assert_eq!(api.probed.lock().unwrap().len(), 4); + assert_eq!(api.probed.lock().unwrap().len(), ALL_PROBES.len()); // Denied on the caller's own role: direct evidence. assert_eq!( verdict(&report, Tier::WorkspaceUser, "iam:GetRole"), @@ -487,14 +478,10 @@ fn a_probed_run_never_claims_environment_admin() { #[test] fn throttled_simulations_block_the_tier_and_carry_notes() { let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]); - api.simulate_errors = vec![("cloudformation:CreateStack", throttled())]; + api.simulate_errors = vec![("ec2:CreateVpc", throttled())]; let report = run(&api); assert_eq!(report.method, Method::Simulated); - let r = row( - &report, - Tier::EnvironmentAdmin, - "cloudformation:CreateStack", - ); + let r = row(&report, Tier::EnvironmentAdmin, "ec2:CreateVpc"); assert_eq!(r.verdict, Verdict::Unverified); assert!(r.note.as_deref().unwrap().contains("Throttling"), "{r:?}"); // Allowed rows carry no note. @@ -548,7 +535,7 @@ fn credential_errors_anywhere_are_errors() { // From a probe. let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]); api.get_role_denied = true; - api.probes_ok.remove(&Probe::CfnDescribeStacks); + api.probes_ok.remove(&Probe::SecretsListSecrets); api.probe_error = Some(AwsError::KeysMissing); assert_eq!( try_run(&api, &identity()).unwrap_err(), @@ -630,3 +617,14 @@ fn check_permissions_is_send() { } let _ = with::; } + +/// D8: CloudFormation left the policies, so nothing probes it either. +#[test] +fn no_probe_is_cloudformation() { + assert_eq!(Probe::ALL, ALL_PROBES); + for p in Probe::ALL { + assert!(!p.action().starts_with("cloudformation:"), "{p:?}"); + assert_eq!(Probe::for_action(p.action()), Some(p)); + } + assert_eq!(Probe::for_action("cloudformation:DescribeStacks"), None); +} diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index 583915a..d76ef6d 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -19,11 +19,26 @@ pub enum Tier { WorkspaceUser, } -/// The policy document an admin attaches for `tier`. -pub fn policy_json(tier: Tier) -> &'static str { +/// The policy documents an admin attaches for `tier`, as (file name under +/// `aws/policies/`, contents). Every file of a tier is required: the admin +/// tier is split across files only because a managed policy holds 6,144 +/// characters (D7). +pub fn policy_files(tier: Tier) -> &'static [(&'static str, &'static str)] { match tier { - Tier::EnvironmentAdmin => include_str!("../../../../aws/policies/environment-admin.json"), - Tier::WorkspaceUser => include_str!("../../../../aws/policies/workspace-user.json"), + Tier::EnvironmentAdmin => &[ + ( + "environment-admin.json", + include_str!("../../../../aws/policies/environment-admin.json"), + ), + ( + "environment-admin-network.json", + include_str!("../../../../aws/policies/environment-admin-network.json"), + ), + ], + Tier::WorkspaceUser => &[( + "workspace-user.json", + include_str!("../../../../aws/policies/workspace-user.json"), + )], } } @@ -125,6 +140,10 @@ 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"))]; +/// The state bucket is created tagged `triple-c:component=state`. +const STATE_REQUESTED: Context = &[("aws:RequestTag/triple-c:component", Literal("state"))]; const PROBE_CLUSTER: &str = "arn:aws:ecs:{region}:{account}:cluster/triple-c-probe"; const PROBE_TASK: &str = @@ -139,7 +158,6 @@ const PROBE_ALIAS: &str = "arn:aws:kms:{region}:{account}:alias/triple-c-probe"; const PROBE_LOG_GROUP: &str = "arn:aws:logs:{region}:{account}:log-group:/triple-c/probe"; const PROBE_PARAMETER: &str = "arn:aws:ssm:{region}:{account}:parameter/triple-c/environments/probe"; -const PROBE_STACK: &str = "arn:aws:cloudformation:{region}:{account}:stack/triple-c-probe/00000000-0000-0000-0000-000000000000"; const PROBE_FILE_SYSTEM: &str = "arn:aws:elasticfilesystem:{region}:{account}:file-system/fs-00000000"; const PROBE_ACCESS_POINT: &str = @@ -148,6 +166,22 @@ const PROBE_SECURITY_GROUP: &str = "arn:aws:ec2:{region}:{account}:security-group/sg-00000000000000000"; const PROBE_VPC: &str = "arn:aws:ec2:{region}:{account}:vpc/vpc-00000000000000000"; const PROBE_SESSION: &str = "arn:aws:ssm:{region}:{account}:session/probe-0123456789abcdef0"; +const PROBE_SUBNET: &str = "arn:aws:ec2:{region}:{account}:subnet/subnet-00000000000000000"; +const PROBE_INTERNET_GATEWAY: &str = + "arn:aws:ec2:{region}:{account}:internet-gateway/igw-00000000000000000"; +const PROBE_ROUTE_TABLE: &str = "arn:aws:ec2:{region}:{account}:route-table/rtb-00000000000000000"; +const PROBE_ELASTIC_IP: &str = + "arn:aws:ec2:{region}:{account}:elastic-ip/eipalloc-00000000000000000"; +const PROBE_NAT_GATEWAY: &str = "arn:aws:ec2:{region}:{account}:natgateway/nat-00000000000000000"; +const PROBE_NETWORK_INTERFACE: &str = + "arn:aws:ec2:{region}:{account}:network-interface/eni-00000000000000000"; +const PROBE_SECURITY_GROUP_RULE: &str = + "arn:aws:ec2:{region}:{account}:security-group-rule/sgr-00000000000000000"; +/// [`super::tofu_modules::state_bucket_name`]'s bucket. +const PROBE_STATE_BUCKET: &str = "arn:aws:s3:::triple-c-tfstate-{account}-{region}"; +const PROBE_STATE_OBJECT: &str = + "arn:aws:s3:::triple-c-tfstate-{account}-{region}/environments/probe/terraform.tfstate"; +const PROBE_BOUNDARY: &str = "arn:aws:iam::{account}:policy/triple-c-permissions-boundary"; const fn write(action: &'static str, resource: &'static str, context: Context) -> Check { Check { @@ -255,13 +289,15 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ PROBE_SECRET, &[(OWNER_RESOURCE_TAG, OwnerId)], ), - // Only on the environment's (component-tagged) file system. + // Only on the user's own file system: Triple-C's (component-tagged by + // their onboarding module) and tagged with their owner ID (D2). write( "elasticfilesystem:CreateAccessPoint", PROBE_FILE_SYSTEM, &[ (OWNER_REQUEST_TAG, OwnerId), - (COMPONENT_RESOURCE_TAG, Literal("environment")), + (OWNER_RESOURCE_TAG, OwnerId), + (COMPONENT_RESOURCE_TAG, Literal("user-onboarding")), ], ), write( @@ -320,8 +356,6 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ read("ecs:DescribeClusters", PROBE_CLUSTER, &[]), read("ecs:ListClusters", "*", &[]), read("ssm:GetParametersByPath", PROBE_PARAMETER, &[]), - read("cloudformation:DescribeStacks", "*", &[]), - read("cloudformation:ListStacks", "*", &[]), read("secretsmanager:ListSecrets", "*", &[]), read("elasticfilesystem:DescribeAccessPoints", "*", &[]), read( @@ -333,10 +367,10 @@ const WORKSPACE_USER_CHECKS: &[Check] = &[ read("sts:GetCallerIdentity", "*", &[]), ]; +/// The admin tier's checks, across all its files. `tofu_modules`' tests hold +/// the per-resource table these are drawn from. const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ - write("cloudformation:CreateStack", PROBE_STACK, &[]), - write("cloudformation:UpdateStack", PROBE_STACK, &[]), - write("cloudformation:DeleteStack", PROBE_STACK, &[]), + // Roles: bounded writes, by name. write("iam:CreateRole", PROBE_ROLE, WITH_BOUNDARY), write("iam:PutRolePolicy", PROBE_ROLE, WITH_BOUNDARY), write("iam:DeleteRolePolicy", PROBE_ROLE, WITH_BOUNDARY), @@ -344,7 +378,14 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ write("iam:DetachRolePolicy", PROBE_ROLE, WITH_BOUNDARY), write("iam:DeleteRole", PROBE_ROLE, &[]), write("iam:TagRole", PROBE_ROLE, &[]), + write("iam:UntagRole", PROBE_ROLE, &[]), + write("iam:UpdateRole", PROBE_ROLE, &[]), + write("iam:UpdateRoleDescription", PROBE_ROLE, &[]), read("iam:GetRole", PROBE_ROLE, &[]), + read("iam:GetRolePolicy", PROBE_ROLE, &[]), + read("iam:ListRolePolicies", PROBE_ROLE, &[]), + read("iam:ListAttachedRolePolicies", PROBE_ROLE, &[]), + read("iam:ListInstanceProfilesForRole", PROBE_ROLE, &[]), write( "iam:PassRole", PROBE_ROLE, @@ -352,9 +393,21 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ ), read("iam:GetRole", "{principal}", OWN_PRINCIPAL), read("iam:SimulatePrincipalPolicy", "{principal}", OWN_PRINCIPAL), - write("ecs:CreateCluster", PROBE_CLUSTER, &[]), + // Detection reads the boundary policy. + read("iam:GetPolicy", PROBE_BOUNDARY, &[]), + // ECS, by name. + write("ecs:CreateCluster", PROBE_CLUSTER, COMPONENT_REQUESTED), write("ecs:DeleteCluster", PROBE_CLUSTER, &[]), + write("ecs:UpdateCluster", PROBE_CLUSTER, &[]), write("ecs:PutClusterCapacityProviders", PROBE_CLUSTER, &[]), + write( + "ecs:TagResource", + PROBE_CLUSTER, + &[("ecs:CreateAction", Literal("CreateCluster"))], + ), + 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", @@ -367,19 +420,104 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ ), write("ecs:RegisterTaskDefinition", "*", &[]), write("ecs:DeregisterTaskDefinition", "*", &[]), - // CreateSecurityGroup is authorized against the new group and the VPC. + // The network (environment-admin-network.json). Creates carry the + // component request tag and are authorized against the new resource and + // 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:CreateInternetGateway", + PROBE_INTERNET_GATEWAY, + COMPONENT_REQUESTED, + ), + write( + "ec2:CreateRouteTable", + PROBE_ROUTE_TABLE, + COMPONENT_REQUESTED, + ), + write("ec2:CreateRouteTable", PROBE_VPC, COMPONENT_REQUESTED), + 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_ELASTIC_IP, + COMPONENT_REQUESTED, + ), write( "ec2:CreateSecurityGroup", PROBE_SECURITY_GROUP, COMPONENT_REQUESTED, ), - write("ec2:CreateSecurityGroup", PROBE_VPC, &[]), + write("ec2:CreateSecurityGroup", PROBE_VPC, COMPONENT_REQUESTED), write( "ec2:CreateTags", PROBE_SECURITY_GROUP, &[("ec2:CreateAction", Literal("CreateSecurityGroup"))], ), + write( + "ec2:CreateTags", + PROBE_VPC, + &[("ec2:CreateAction", Literal("CreateVpc"))], + ), + write( + "ec2:CreateTags", + PROBE_NAT_GATEWAY, + &[("ec2:CreateAction", Literal("CreateNatGateway"))], + ), + // Changes and deletes need the component tag on the resource. write("ec2:CreateTags", PROBE_SECURITY_GROUP, COMPONENT_TAGGED), + write("ec2:DeleteTags", PROBE_SUBNET, COMPONENT_TAGGED), + write("ec2:ModifyVpcAttribute", PROBE_VPC, COMPONENT_TAGGED), + write("ec2:DeleteVpc", PROBE_VPC, COMPONENT_TAGGED), + write("ec2:ModifySubnetAttribute", PROBE_SUBNET, COMPONENT_TAGGED), + write("ec2:DeleteSubnet", PROBE_SUBNET, COMPONENT_TAGGED), + write( + "ec2:AttachInternetGateway", + PROBE_INTERNET_GATEWAY, + COMPONENT_TAGGED, + ), + write("ec2:AttachInternetGateway", PROBE_VPC, COMPONENT_TAGGED), + write( + "ec2:DetachInternetGateway", + PROBE_INTERNET_GATEWAY, + COMPONENT_TAGGED, + ), + write( + "ec2:DeleteInternetGateway", + PROBE_INTERNET_GATEWAY, + COMPONENT_TAGGED, + ), + write("ec2:DeleteRouteTable", PROBE_ROUTE_TABLE, COMPONENT_TAGGED), + // Routes and associations can't be tagged: their route table (and + // subnet) can. + write("ec2:CreateRoute", PROBE_ROUTE_TABLE, COMPONENT_TAGGED), + write("ec2:ReplaceRoute", PROBE_ROUTE_TABLE, COMPONENT_TAGGED), + write("ec2:DeleteRoute", PROBE_ROUTE_TABLE, COMPONENT_TAGGED), + write( + "ec2:AssociateRouteTable", + PROBE_ROUTE_TABLE, + COMPONENT_TAGGED, + ), + write("ec2:AssociateRouteTable", PROBE_SUBNET, COMPONENT_TAGGED), + write( + "ec2:ReplaceRouteTableAssociation", + PROBE_ROUTE_TABLE, + COMPONENT_TAGGED, + ), + write( + "ec2:DisassociateRouteTable", + PROBE_ROUTE_TABLE, + COMPONENT_TAGGED, + ), + write("ec2:DisassociateRouteTable", PROBE_SUBNET, COMPONENT_TAGGED), + write("ec2:ReleaseAddress", PROBE_ELASTIC_IP, COMPONENT_TAGGED), + write("ec2:DeleteNatGateway", PROBE_NAT_GATEWAY, COMPONENT_TAGGED), write( "ec2:DeleteSecurityGroup", PROBE_SECURITY_GROUP, @@ -390,14 +528,55 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ PROBE_SECURITY_GROUP, COMPONENT_TAGGED, ), + // Authorizing also names the new (untagged) rule. + write( + "ec2:AuthorizeSecurityGroupIngress", + PROBE_SECURITY_GROUP_RULE, + &[], + ), + write( + "ec2:AuthorizeSecurityGroupEgress", + PROBE_SECURITY_GROUP, + COMPONENT_TAGGED, + ), + write( + "ec2:AuthorizeSecurityGroupEgress", + PROBE_SECURITY_GROUP_RULE, + &[], + ), write( "ec2:RevokeSecurityGroupIngress", PROBE_SECURITY_GROUP, COMPONENT_TAGGED, ), + // The provider revokes AWS's default egress rule after creating a group. + write( + "ec2:RevokeSecurityGroupEgress", + PROBE_SECURITY_GROUP, + COMPONENT_TAGGED, + ), + write( + "ec2:UpdateSecurityGroupRuleDescriptionsIngress", + 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, + ), + write("ec2:CreateNetworkInterface", PROBE_SUBNET, VIA_AWS_SERVICE), + write( + "ec2:DeleteNetworkInterface", + PROBE_NETWORK_INTERFACE, + VIA_AWS_SERVICE, + ), read("ec2:DescribeSecurityGroups", "*", &[]), - // CreateFileSystem has no resource type: IAM authorizes it against `*` - // (verified live; a `file-system/*` Resource never matches). + read("ec2:DescribeVpcs", "*", &[]), + // EFS. CreateFileSystem has no resource type: IAM authorizes it against + // `*` (verified live; a `file-system/*` Resource never matches). write( "elasticfilesystem:CreateFileSystem", "*", @@ -416,11 +595,26 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ PROBE_FILE_SYSTEM, COMPONENT_TAGGED, ), + write( + "elasticfilesystem:UntagResource", + PROBE_FILE_SYSTEM, + COMPONENT_TAGGED, + ), write( "elasticfilesystem:DeleteFileSystem", PROBE_FILE_SYSTEM, COMPONENT_TAGGED, ), + write( + "elasticfilesystem:UpdateFileSystem", + PROBE_FILE_SYSTEM, + COMPONENT_TAGGED, + ), + write( + "elasticfilesystem:PutLifecycleConfiguration", + PROBE_FILE_SYSTEM, + COMPONENT_TAGGED, + ), write( "elasticfilesystem:CreateMountTarget", PROBE_FILE_SYSTEM, @@ -431,23 +625,60 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ PROBE_FILE_SYSTEM, COMPONENT_TAGGED, ), + write( + "elasticfilesystem:ModifyMountTargetSecurityGroups", + PROBE_FILE_SYSTEM, + COMPONENT_TAGGED, + ), write( "elasticfilesystem:PutFileSystemPolicy", PROBE_FILE_SYSTEM, COMPONENT_TAGGED, ), + write( + "elasticfilesystem:DeleteFileSystemPolicy", + PROBE_FILE_SYSTEM, + COMPONENT_TAGGED, + ), + read( + "elasticfilesystem:DescribeFileSystems", + PROBE_FILE_SYSTEM, + &[], + ), + read( + "elasticfilesystem:ListTagsForResource", + PROBE_FILE_SYSTEM, + &[], + ), + // KMS. write("kms:CreateKey", "*", COMPONENT_REQUESTED), write("kms:ScheduleKeyDeletion", PROBE_KEY, COMPONENT_TAGGED), write("kms:PutKeyPolicy", PROBE_KEY, COMPONENT_TAGGED), + write("kms:EnableKeyRotation", PROBE_KEY, COMPONENT_TAGGED), + write("kms:UpdateKeyDescription", PROBE_KEY, COMPONENT_TAGGED), write("kms:TagResource", PROBE_KEY, COMPONENT_TAGGED), + write("kms:UntagResource", PROBE_KEY, COMPONENT_TAGGED), // An alias call is authorized against both the alias and the key. write("kms:CreateAlias", PROBE_ALIAS, &[]), write("kms:CreateAlias", PROBE_KEY, COMPONENT_TAGGED), + write("kms:UpdateAlias", PROBE_ALIAS, &[]), + write("kms:UpdateAlias", PROBE_KEY, COMPONENT_TAGGED), write("kms:DeleteAlias", PROBE_ALIAS, &[]), write("kms:DeleteAlias", PROBE_KEY, COMPONENT_TAGGED), - write("logs:CreateLogGroup", PROBE_LOG_GROUP, &[]), + read("kms:DescribeKey", PROBE_KEY, &[]), + read("kms:GetKeyPolicy", PROBE_KEY, &[]), + read("kms:GetKeyRotationStatus", PROBE_KEY, &[]), + read("kms:ListResourceTags", PROBE_KEY, &[]), + read("kms:ListAliases", "*", &[]), + // Logs, SSM, ECR, by name. + write("logs:CreateLogGroup", PROBE_LOG_GROUP, COMPONENT_REQUESTED), + write("logs:TagResource", PROBE_LOG_GROUP, COMPONENT_REQUESTED), + write("logs:UntagResource", PROBE_LOG_GROUP, &[]), write("logs:DeleteLogGroup", PROBE_LOG_GROUP, &[]), write("logs:PutRetentionPolicy", PROBE_LOG_GROUP, &[]), + 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", @@ -459,96 +690,63 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ &[], ), write("ecr:CreatePullThroughCacheRule", "*", &[]), - write("ssm:PutParameter", PROBE_PARAMETER, &[]), + write("ssm:PutParameter", PROBE_PARAMETER, COMPONENT_REQUESTED), + write( + "ssm:AddTagsToResource", + PROBE_PARAMETER, + COMPONENT_REQUESTED, + ), + write("ssm:RemoveTagsFromResource", PROBE_PARAMETER, &[]), write("ssm:DeleteParameter", PROBE_PARAMETER, &[]), + read("ssm:GetParameter", PROBE_PARAMETER, &[]), + read("ssm:ListTagsForResource", PROBE_PARAMETER, &[]), + read("ssm:DescribeParameters", "*", &[]), + read("sts:GetCallerIdentity", "*", &[]), + // 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, &[]), ]; #[cfg(test)] mod tests { use super::*; + use crate::aws::policy_eval::{ + actions, conditions_hold, effect, evaluate_in, glob, matching, parse, rendered, resources, + sid, statements_of, strings, Request, ME, SOMEONE_ELSE, + }; use serde_json::Value; use std::collections::BTreeSet; - const AT: Placeholders = Placeholders { - region: "us-west-2", - account: "111122223333", - principal: "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Dev_0123456789abcdef", - }; - const ME: &str = "AROAEXAMPLEME:alice@example.com"; - const SOMEONE_ELSE: &str = "AROAEXAMPLEOTHER:bob@example.com"; - const TIERS: [Tier; 2] = [Tier::WorkspaceUser, Tier::EnvironmentAdmin]; - /// IAM wildcard match: `*` is any run of characters (including `:` and - /// `/`), `?` is exactly one. - fn glob(pattern: &str, text: &str) -> bool { - let p: Vec = pattern.chars().collect(); - let t: Vec = text.chars().collect(); - let (mut pi, mut ti) = (0, 0); - let (mut star, mut mark) = (None, 0); - while ti < t.len() { - if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { - pi += 1; - ti += 1; - } else if pi < p.len() && p[pi] == '*' { - star = Some(pi); - mark = ti; - pi += 1; - } else if let Some(s) = star { - pi = s + 1; - mark += 1; - ti = mark; - } else { - return false; - } - } - while pi < p.len() && p[pi] == '*' { - pi += 1; - } - pi == p.len() - } - - #[test] - fn glob_behaves_like_iam_wildcards() { - assert!(glob("ecs:Describe*", "ecs:DescribeClusters")); - assert!(!glob("ecs:Describe*", "ecs:ListClusters")); - assert!(glob( - "arn:aws:ecs:*:*:task/triple-c-*/*", - "arn:aws:ecs:us-west-2:1:task/triple-c-x/abc" - )); - assert!(!glob( - "arn:aws:ecs:*:*:task/triple-c-*/*", - "arn:aws:ecs:us-west-2:1:task/other/abc" - )); - assert!(glob("a?c", "abc")); - assert!(!glob("a?c", "ac")); - } - - fn parse(name: &str, json: &str) -> Value { - serde_json::from_str(json).unwrap_or_else(|e| panic!("{name} is not JSON: {e}")) - } - - fn policy(tier: Tier) -> Value { - parse(&format!("{tier:?} policy"), policy_json(tier)) + /// Every file of `tier`, parsed: (file name, document). + fn policies(tier: Tier) -> Vec<(&'static str, Value)> { + policy_files(tier) + .iter() + .map(|(name, json)| (*name, parse(name, json))) + .collect() } fn boundary() -> Value { parse("permissions boundary", permissions_boundary_json()) } - fn statements_of(doc: &Value) -> Vec { - doc["Statement"] - .as_array() - .expect("policy has no Statement array") - .clone() - } - + /// The statements of every file of `tier`, which IAM evaluates together. fn statements(tier: Tier) -> Vec { - statements_of(&policy(tier)) - } - - fn effect(stmt: &Value) -> &str { - stmt["Effect"].as_str().unwrap_or("") + policies(tier) + .iter() + .flat_map(|(_, doc)| statements_of(doc)) + .collect() } fn allows(tier: Tier) -> Vec { @@ -565,185 +763,10 @@ mod tests { .collect() } - /// `Action` / `Resource` / condition values: a string or an array of them. - fn strings(v: &Value) -> Vec { - match v { - Value::String(s) => vec![s.clone()], - Value::Array(a) => a - .iter() - .map(|x| x.as_str().expect("expected a string").to_string()) - .collect(), - other => panic!("expected a string or array, got {other}"), - } - } - - fn actions(stmt: &Value) -> Vec { - strings(&stmt["Action"]) - } - - fn resources(stmt: &Value) -> Vec { - strings(&stmt["Resource"]) - } - - fn sid(stmt: &Value) -> &str { - stmt["Sid"].as_str().unwrap_or("") - } - - fn rendered(check: &Check) -> String { - render(check.resource, &AT) - } - - /// The request: who calls, and whose the resource / request tag is. - #[derive(Clone, Copy)] - struct Request<'a> { - check: &'a Check, - /// `${aws:userid}` of the caller. - caller: &'a str, - /// What `OwnerId` context entries carry. - owner: &'a str, - } - - impl Request<'_> { - fn context_value(&self, key: &str) -> Option { - self.context_values(key).into_iter().next() - } - - /// All values of a (possibly multi-valued) key. - fn context_values(&self, key: &str) -> Vec { - self.check - .context - .iter() - .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 - /// the request context. An unresolvable variable makes the string - /// match nothing (`None`). - fn resolve(&self, s: &str) -> Option { - let mut out = String::new(); - let mut rest = s; - while let Some(start) = rest.find("${") { - out.push_str(&rest[..start]); - let end = rest[start..].find('}').expect("unterminated ${") + start; - let name = &rest[start + 2..end]; - let value = if name == "aws:userid" { - Some(self.caller.to_string()) - } else { - self.context_value(name) - }; - out.push_str(&value?); - rest = &rest[end + 1..]; - } - out.push_str(rest); - Some(out) - } - } - - /// Statements that name the action and one of whose resources matches - /// the rendered resource. - fn matching<'s>(stmts: &'s [Value], req: &Request) -> Vec<&'s Value> { - let resource = rendered(req.check); - stmts - .iter() - .filter(|s| actions(s).iter().any(|a| glob(a, req.check.action))) - .filter(|s| { - resources(s) - .iter() - .filter_map(|r| req.resolve(r)) - .any(|r| glob(&r, &resource)) - }) - .collect() - } - - /// A deliberately small IAM condition evaluator: the operators the - /// shipped policies use, and nothing else (an unknown operator fails the - /// test rather than being ignored). - fn conditions_hold(stmt: &Value, req: &Request) -> bool { - let Some(cond) = stmt.get("Condition") else { - return true; - }; - 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 = 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(); - match want.as_str() { - Some("true") => absent, - Some("false") => !absent, - _ => panic!("{}: Null takes \"true\" or \"false\"", sid(stmt)), - } - }); - } - let (base, if_exists) = match op.strip_suffix("IfExists") { - Some(b) => (b, true), - None => (op.as_str(), false), - }; - // A negated operator holds when the key is absent, and - // otherwise when no value matches. - let (matches, negated): (fn(&str, &str) -> bool, bool) = match base { - "StringEquals" => (|p, v| p == v, false), - "StringLike" | "ArnLike" => (glob, false), - "StringNotEquals" => (|p, v| p == v, true), - "StringNotLike" => (glob, true), - other => panic!("{}: unsupported condition operator {other}", sid(stmt)), - }; - entries - .iter() - .all(|(key, want)| match req.context_value(key) { - None => if_exists || negated, - Some(value) => { - let any = strings(want) - .iter() - .filter_map(|p| req.resolve(p)) - .any(|p| matches(&p, &value)); - any != negated - } - }) - }) - } - fn evaluate(tier: Tier, req: Request) -> bool { evaluate_in(&statements(tier), req) } - /// Allowed by some Allow and by no Deny among `stmts`. - fn evaluate_in(stmts: &[Value], req: Request) -> bool { - let applies = |s: &&Value| conditions_hold(s, &req); - let allowed = matching(stmts, &req) - .into_iter() - .filter(|s| effect(s) == "Allow") - .any(|s| applies(&s)); - let denied = matching(stmts, &req) - .into_iter() - .filter(|s| effect(s) == "Deny") - .any(|s| applies(&s)); - allowed && !denied - } - fn allowed(tier: Tier, check: &Check, caller: &str, owner: &str) -> bool { evaluate( tier, @@ -787,7 +810,9 @@ mod tests { #[test] fn policies_are_valid_iam_json() { for tier in TIERS { - validate_document(&format!("{tier:?}"), &policy(tier)); + for (name, doc) in policies(tier) { + validate_document(name, &doc); + } assert!(!allows(tier).is_empty(), "{tier:?} allows nothing"); } validate_document("permissions boundary", &boundary()); @@ -868,6 +893,11 @@ mod tests { "file-system/", OWNER_REQUEST_TAG, ), + ( + "elasticfilesystem:CreateAccessPoint", + "file-system/", + OWNER_RESOURCE_TAG, + ), ( "elasticfilesystem:TagResource", "access-point/", @@ -1127,14 +1157,8 @@ mod tests { /// live: "Resource ${aws:PrincipalArn} must be in ARN format or \"*\""). #[test] fn every_resource_is_an_arn_or_star() { - let docs = [ - ("workspace-user", policy(Tier::WorkspaceUser)), - ("environment-admin", policy(Tier::EnvironmentAdmin)), - ( - "permissions-boundary", - serde_json::from_str(permissions_boundary_json()).unwrap(), - ), - ]; + let mut docs: Vec<(&str, Value)> = TIERS.into_iter().flat_map(policies).collect(); + docs.push(("permissions-boundary.json", boundary())); for (name, doc) in &docs { for s in statements_of(doc) { for r in resources(&s) { @@ -1150,16 +1174,17 @@ mod tests { /// A customer managed policy may hold 6,144 characters, not counting /// whitespace (verified live: the workspace policy is close). An - /// environment admin needs both tier policies, which together exceed a - /// role's 10,240-character inline budget, so they ship as managed - /// policies. + /// environment admin needs every file of both tiers, which together + /// exceed a role's 10,240-character inline budget, so they ship as + /// managed policies, the admin tier in several (D7). #[test] fn every_policy_fits_a_managed_policy() { - for (name, text) in [ - ("workspace-user", policy_json(Tier::WorkspaceUser)), - ("environment-admin", policy_json(Tier::EnvironmentAdmin)), - ("permissions-boundary", permissions_boundary_json()), - ] { + let mut files: Vec<(&str, &str)> = TIERS + .into_iter() + .flat_map(|t| policy_files(t).iter().copied()) + .collect(); + files.push(("permissions-boundary.json", permissions_boundary_json())); + for (name, text) in files { let size = text.chars().filter(|c| !c.is_whitespace()).count(); assert!( size <= 6144, @@ -1522,28 +1547,195 @@ mod tests { assert!(allowed(tier, create, ME, ME)); } + /// Strict tenancy (D2): each user has their own file system, tagged + /// with their owner ID by their onboarding module, and may create access + /// points only there. #[test] - fn access_points_only_on_the_environments_file_system() { + fn access_points_only_on_the_users_own_file_system() { let c = checks(Tier::WorkspaceUser) .iter() .find(|c| c.action == "elasticfilesystem:CreateAccessPoint") .unwrap(); + assert!(allowed(Tier::WorkspaceUser, c, ME, ME)); let foreign_fs = Check { context: &[(OWNER_REQUEST_TAG, OwnerId)], ..*c }; assert!(!allowed(Tier::WorkspaceUser, &foreign_fs, ME, ME)); + // Someone else's onboarding file system, with my owner tag requested. + let someone_elses_fs = Check { + context: &[ + (OWNER_REQUEST_TAG, OwnerId), + (OWNER_RESOURCE_TAG, Literal(SOMEONE_ELSE)), + (COMPONENT_RESOURCE_TAG, Literal("user-onboarding")), + ], + ..*c + }; + assert!(!allowed(Tier::WorkspaceUser, &someone_elses_fs, ME, ME)); + // An owner-tagged file system that isn't Triple-C's. + let not_triple_c = Check { + context: &[(OWNER_REQUEST_TAG, OwnerId), (OWNER_RESOURCE_TAG, OwnerId)], + ..*c + }; + assert!(!allowed(Tier::WorkspaceUser, ¬_triple_c, ME, ME)); + } + + #[test] + fn the_admin_tier_spans_its_files() { + let names = |t: Tier| policy_files(t).iter().map(|(n, _)| *n).collect::>(); + assert_eq!( + names(Tier::EnvironmentAdmin), + ["environment-admin.json", "environment-admin-network.json"] + ); + assert_eq!(names(Tier::WorkspaceUser), ["workspace-user.json"]); + } + + /// D8: CloudFormation leaves the policies, the checks and the probes. + #[test] + fn no_policy_mentions_cloudformation() { + let mut texts: Vec<(&str, &str)> = TIERS + .into_iter() + .flat_map(|t| policy_files(t).iter().copied()) + .collect(); + texts.push(("permissions-boundary.json", permissions_boundary_json())); + for (name, text) in texts { + assert!( + !text.to_ascii_lowercase().contains("cloudformation"), + "{name} mentions CloudFormation" + ); + } + for tier in TIERS { + for c in checks(tier) { + assert!( + !c.action.starts_with("cloudformation:") + && !c.resource.contains("cloudformation"), + "{tier:?}: check {} on {}", + c.action, + c.resource + ); + } + } + } + + /// The network the admin creates is tag-scoped like everything else: an + /// untagged VPC, subnet, gateway or route table is someone else's, and + /// the only bucket the admin may touch is the state bucket. + #[test] + fn the_admin_still_cannot_touch_untagged_resources() { + let tier = Tier::EnvironmentAdmin; + for (action, resource) in [ + ("ec2:DeleteVpc", PROBE_VPC), + ("ec2:ModifyVpcAttribute", PROBE_VPC), + ("ec2:DeleteSubnet", PROBE_SUBNET), + ("ec2:DeleteInternetGateway", PROBE_INTERNET_GATEWAY), + ("ec2:DetachInternetGateway", PROBE_INTERNET_GATEWAY), + ("ec2:DeleteRouteTable", PROBE_ROUTE_TABLE), + ("ec2:CreateRoute", PROBE_ROUTE_TABLE), + ("ec2:DeleteRoute", PROBE_ROUTE_TABLE), + ("ec2:AssociateRouteTable", PROBE_ROUTE_TABLE), + ("ec2:DeleteNatGateway", PROBE_NAT_GATEWAY), + ("ec2:ReleaseAddress", PROBE_ELASTIC_IP), + ("ec2:DeleteSecurityGroup", PROBE_SECURITY_GROUP), + ("ec2:CreateTags", PROBE_VPC), + ("ec2:DeleteTags", PROBE_SUBNET), + ] { + let tagged = write(action, resource, COMPONENT_TAGGED); + assert!( + allowed(tier, &tagged, ME, ME), + "{action} on a tagged {resource}" + ); + let untagged = write(action, resource, &[]); + assert!( + !allowed(tier, &untagged, ME, ME), + "{action} on an untagged {resource}" + ); + } + // Creates need the component request tag. + for (action, resource) in [ + ("ec2:CreateVpc", PROBE_VPC), + ("ec2:CreateSubnet", PROBE_SUBNET), + ("ec2:CreateInternetGateway", PROBE_INTERNET_GATEWAY), + ("ec2:CreateRouteTable", PROBE_ROUTE_TABLE), + ("ec2:AllocateAddress", PROBE_ELASTIC_IP), + ("ec2:CreateNatGateway", PROBE_NAT_GATEWAY), + ] { + assert!( + allowed(tier, &write(action, resource, COMPONENT_REQUESTED), ME, ME), + "{action}" + ); + assert!( + !allowed(tier, &write(action, resource, &[]), ME, ME), + "untagged {action}" + ); + } + // Tags go on at create only through the creating call. + let tag_at = |create: &'static str| -> Check { + let ctx: Context = + Box::leak(vec![("ec2:CreateAction", Literal(create))].into_boxed_slice()); + write("ec2:CreateTags", PROBE_VPC, ctx) + }; + 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. + for action in ["ec2:CreateNetworkInterface", "ec2:DeleteNetworkInterface"] { + assert!( + !allowed(tier, &write(action, PROBE_NETWORK_INTERFACE, &[]), ME, ME), + "{action}" + ); + } + // No bucket may be deleted, and only the state bucket is managed. + const OTHER_BUCKET: &str = "arn:aws:s3:::someone-elses-bucket"; + for bucket in [PROBE_STATE_BUCKET, OTHER_BUCKET] { + for context in [&[][..], COMPONENT_TAGGED] { + 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", + ] { + assert!( + allowed(tier, &write(action, PROBE_STATE_BUCKET, &[]), ME, ME), + "{action}" + ); + assert!( + !allowed(tier, &write(action, OTHER_BUCKET, &[]), ME, ME), + "{action}" + ); + } + 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, + &write("s3:CreateBucket", PROBE_STATE_BUCKET, STATE_REQUESTED), + ME, + ME + )); + assert!(!allowed( + tier, + &write("s3:CreateBucket", PROBE_STATE_BUCKET, &[]), + ME, + ME + )); + assert!(!allowed( + tier, + &write("s3:CreateBucket", OTHER_BUCKET, STATE_REQUESTED), + ME, + ME + )); } - const CFN_WRITES: &[&str] = &[ - "cloudformation:CreateStack", - "cloudformation:UpdateStack", - "cloudformation:CreateChangeSet", - ]; - const WITH_SERVICE_ROLE: Context = &[( - "cloudformation:RoleArn", - Literal("arn:aws:iam::{account}:role/AnyServiceRole"), - )]; const OTHER_BOUNDARY: Context = &[ ( "iam:PermissionsBoundary", @@ -1552,19 +1744,6 @@ mod tests { ("aws:PrincipalAccount", Literal("{account}")), ]; - #[test] - fn triple_c_stacks_cannot_use_a_service_role() { - for action in CFN_WRITES { - let with_role = write(action, PROBE_STACK, WITH_SERVICE_ROLE); - assert!( - !allowed(Tier::EnvironmentAdmin, &with_role, ME, ME), - "{action} allowed with a CloudFormation service role" - ); - let plain = write(action, PROBE_STACK, &[]); - assert!(allowed(Tier::EnvironmentAdmin, &plain, ME, ME), "{action}"); - } - } - /// True if some Deny statement in `tier` applies to `check`. fn explicitly_denied(tier: Tier, check: &Check) -> bool { let stmts = denies(tier); @@ -1634,11 +1813,6 @@ mod tests { } } } - // A stack deployed with a service role would act outside the - // boundary. - for action in CFN_WRITES { - assert!(denied(&write(action, PROBE_STACK, WITH_SERVICE_ROLE))); - } } const ALLOWED_TAG_KEYS: [&str; 3] = @@ -1766,39 +1940,6 @@ mod tests { } } - #[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")) - ]))); - } - /// A key whose *key policy* grants account principals through Secrets /// Manager (the AWS-managed `aws/secretsmanager` key, or any such customer /// key) needs no identity-policy Allow, so only an explicit Deny keeps a diff --git a/app/src-tauri/src/aws/policy_eval.rs b/app/src-tauri/src/aws/policy_eval.rs new file mode 100644 index 0000000..2eccb56 --- /dev/null +++ b/app/src-tauri/src/aws/policy_eval.rs @@ -0,0 +1,320 @@ +//! The test-only IAM evaluator the policy drift guards use (moved here from +//! `policies.rs` so the Terraform-module tests can ask it too). It is +//! deliberately small: the operators the shipped policies use and nothing +//! else; an unknown operator fails the test rather than being ignored. It is +//! not IAM -- `live_tests.rs` and the live runs are the proof. + +use serde_json::Value; + +use super::policies::{render, Check, ContextValue, Placeholders}; + +pub(crate) const AT: Placeholders = Placeholders { + region: "us-west-2", + account: "111122223333", + principal: "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Dev_0123456789abcdef", +}; +pub(crate) const ME: &str = "AROAEXAMPLEME:alice@example.com"; +pub(crate) const SOMEONE_ELSE: &str = "AROAEXAMPLEOTHER:bob@example.com"; + +/// IAM wildcard match: `*` is any run of characters (including `:` and +/// `/`), `?` is exactly one. +pub(crate) fn glob(pattern: &str, text: &str) -> bool { + let p: Vec = pattern.chars().collect(); + let t: Vec = text.chars().collect(); + let (mut pi, mut ti) = (0, 0); + let (mut star, mut mark) = (None, 0); + while ti < t.len() { + if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { + pi += 1; + ti += 1; + } else if pi < p.len() && p[pi] == '*' { + star = Some(pi); + mark = ti; + pi += 1; + } else if let Some(s) = star { + pi = s + 1; + mark += 1; + ti = mark; + } else { + return false; + } + } + while pi < p.len() && p[pi] == '*' { + pi += 1; + } + pi == p.len() +} + +pub(crate) fn parse(name: &str, json: &str) -> Value { + serde_json::from_str(json).unwrap_or_else(|e| panic!("{name} is not JSON: {e}")) +} + +pub(crate) fn statements_of(doc: &Value) -> Vec { + doc["Statement"] + .as_array() + .expect("policy has no Statement array") + .clone() +} + +pub(crate) fn effect(stmt: &Value) -> &str { + stmt["Effect"].as_str().unwrap_or("") +} + +/// `Action` / `Resource` / condition values: a string or an array of them. +pub(crate) fn strings(v: &Value) -> Vec { + match v { + Value::String(s) => vec![s.clone()], + Value::Array(a) => a + .iter() + .map(|x| x.as_str().expect("expected a string").to_string()) + .collect(), + other => panic!("expected a string or array, got {other}"), + } +} + +pub(crate) fn actions(stmt: &Value) -> Vec { + strings(&stmt["Action"]) +} + +pub(crate) fn resources(stmt: &Value) -> Vec { + strings(&stmt["Resource"]) +} + +pub(crate) fn sid(stmt: &Value) -> &str { + stmt["Sid"].as_str().unwrap_or("") +} + +pub(crate) fn rendered(check: &Check) -> String { + render(check.resource, &AT) +} + +/// The request: who calls, and whose the resource / request tag is. +#[derive(Clone, Copy)] +pub(crate) struct Request<'a> { + pub check: &'a Check, + /// `${aws:userid}` of the caller. + pub caller: &'a str, + /// What `OwnerId` context entries carry. + pub owner: &'a str, +} + +impl Request<'_> { + pub(crate) fn context_value(&self, key: &str) -> Option { + self.context_values(key).into_iter().next() + } + + /// All values of a (possibly multi-valued) key. + pub(crate) fn context_values(&self, key: &str) -> Vec { + self.check + .context + .iter() + .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 + /// the request context. An unresolvable variable makes the string + /// match nothing (`None`). + pub(crate) fn resolve(&self, s: &str) -> Option { + let mut out = String::new(); + let mut rest = s; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let end = rest[start..].find('}').expect("unterminated ${") + start; + let name = &rest[start + 2..end]; + let value = if name == "aws:userid" { + Some(self.caller.to_string()) + } else { + self.context_value(name) + }; + out.push_str(&value?); + rest = &rest[end + 1..]; + } + out.push_str(rest); + Some(out) + } +} + +/// Statements that name the action and one of whose resources matches +/// the rendered resource. +pub(crate) fn matching<'s>(stmts: &'s [Value], req: &Request) -> Vec<&'s Value> { + let resource = rendered(req.check); + stmts + .iter() + .filter(|s| actions(s).iter().any(|a| glob(a, req.check.action))) + .filter(|s| { + resources(s) + .iter() + .filter_map(|r| req.resolve(r)) + .any(|r| glob(&r, &resource)) + }) + .collect() +} + +/// A deliberately small IAM condition evaluator: the operators the +/// shipped policies use, and nothing else (an unknown operator fails the +/// test rather than being ignored). +pub(crate) fn conditions_hold(stmt: &Value, req: &Request) -> bool { + let Some(cond) = stmt.get("Condition") else { + return true; + }; + 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 = 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(); + match want.as_str() { + Some("true") => absent, + Some("false") => !absent, + _ => panic!("{}: Null takes \"true\" or \"false\"", sid(stmt)), + } + }); + } + let (base, if_exists) = match op.strip_suffix("IfExists") { + Some(b) => (b, true), + None => (op.as_str(), false), + }; + // A negated operator holds when the key is absent, and + // 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), + other => panic!("{}: unsupported condition operator {other}", sid(stmt)), + }; + entries + .iter() + .all(|(key, want)| match req.context_value(key) { + None => if_exists || negated, + Some(value) => { + let any = strings(want) + .iter() + .filter_map(|p| req.resolve(p)) + .any(|p| matches(&p, &value)); + any != negated + } + }) + }) +} + +/// Allowed by some Allow and by no Deny among `stmts`. +pub(crate) fn evaluate_in(stmts: &[Value], req: Request) -> bool { + let applies = |s: &&Value| conditions_hold(s, &req); + let allowed = matching(stmts, &req) + .into_iter() + .filter(|s| effect(s) == "Allow") + .any(|s| applies(&s)); + let denied = matching(stmts, &req) + .into_iter() + .filter(|s| effect(s) == "Deny") + .any(|s| applies(&s)); + allowed && !denied +} + +#[cfg(test)] +mod tests { + use super::*; + use ContextValue::Literal; + + #[test] + fn glob_behaves_like_iam_wildcards() { + assert!(glob("ecs:Describe*", "ecs:DescribeClusters")); + assert!(!glob("ecs:Describe*", "ecs:ListClusters")); + assert!(glob( + "arn:aws:ecs:*:*:task/triple-c-*/*", + "arn:aws:ecs:us-west-2:1:task/triple-c-x/abc" + )); + assert!(!glob( + "arn:aws:ecs:*:*:task/triple-c-*/*", + "arn:aws:ecs:us-west-2:1:task/other/abc" + )); + assert!(glob("a?c", "abc")); + assert!(!glob("a?c", "ac")); + } + + #[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")) + ]))); + } + + #[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(&[])); + } +} diff --git a/app/src-tauri/src/aws/tofu_modules/tests.rs b/app/src-tauri/src/aws/tofu_modules/tests.rs index 4d6fd8b..a3adfa2 100644 --- a/app/src-tauri/src/aws/tofu_modules/tests.rs +++ b/app/src-tauri/src/aws/tofu_modules/tests.rs @@ -1328,3 +1328,623 @@ fn the_record_is_format_checked_before_use() { ); } } + +// --------------------------------------------------------------------------- +// What the admin tier must allow for the modules to apply. +// +// MODULE_CALLS maps every Terraform resource type (and data source, and the +// S3 backend) the environment and onboarding modules use to the IAM actions +// the AWS provider calls to create, read (refresh), tag, update and delete +// it, each with the resource it is authorized against and the context IAM +// sees. It is the hypothesis: Task 8's live run is the proof, and an action +// 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::policy_eval as eval; + +type Context = &'static [(&'static str, ContextValue)]; + +const fn call(action: &'static str, resource: &'static str, context: Context) -> Check { + Check { + action, + resource, + context, + write: true, + } +} + +// The resources the modules create for environment `probe` and the user +// whose owner key is `0123456789ab`, named as Task 1 names them (pinned to +// the naming functions by `the_module_call_table_uses_the_shared_names`). +const ENV: &str = "probe"; +const OWNER_KEY: &str = "0123456789ab"; +const CLUSTER: &str = "arn:aws:ecs:{region}:{account}:cluster/triple-c-probe"; +const KEY: &str = "arn:aws:kms:{region}:{account}:key/00000000-0000-0000-0000-000000000000"; +const ALIAS: &str = "arn:aws:kms:{region}:{account}:alias/triple-c-probe"; +const LOG_GROUP: &str = "arn:aws:logs:{region}:{account}:log-group:/triple-c/probe"; +const PARAMETER: &str = "arn:aws:ssm:{region}:{account}:parameter/triple-c/environments/probe"; +const VPC: &str = "arn:aws:ec2:{region}:{account}:vpc/vpc-00000000000000000"; +const SUBNET: &str = "arn:aws:ec2:{region}:{account}:subnet/subnet-00000000000000000"; +const IGW: &str = "arn:aws:ec2:{region}:{account}:internet-gateway/igw-00000000000000000"; +const RTB: &str = "arn:aws:ec2:{region}:{account}:route-table/rtb-00000000000000000"; +const EIP: &str = "arn:aws:ec2:{region}:{account}:elastic-ip/eipalloc-00000000000000000"; +const NAT: &str = "arn:aws:ec2:{region}:{account}:natgateway/nat-00000000000000000"; +const SG: &str = "arn:aws:ec2:{region}:{account}:security-group/sg-00000000000000000"; +const SG_RULE: &str = "arn:aws:ec2:{region}:{account}:security-group-rule/sgr-00000000000000000"; +const ENI: &str = "arn:aws:ec2:{region}:{account}:network-interface/eni-00000000000000000"; +const FS: &str = "arn:aws:elasticfilesystem:{region}:{account}:file-system/fs-00000000"; +const TASK_ROLE: &str = "arn:aws:iam::{account}:role/triple-c-probe-0123456789ab-task"; +const EXEC_ROLE: &str = "arn:aws:iam::{account}:role/triple-c-probe-0123456789ab-exec"; +const STATE_BUCKET: &str = "arn:aws:s3:::triple-c-tfstate-{account}-{region}"; +const ENV_STATE: &str = + "arn:aws:s3:::triple-c-tfstate-{account}-{region}/environments/probe/terraform.tfstate"; +const ENV_LOCK: &str = + "arn:aws:s3:::triple-c-tfstate-{account}-{region}/environments/probe/terraform.tfstate.tflock"; +const USER_STATE: &str = + "arn:aws:s3:::triple-c-tfstate-{account}-{region}/users/probe/0123456789ab/terraform.tfstate"; +const USER_LOCK: &str = "arn:aws:s3:::triple-c-tfstate-{account}-{region}/users/probe/0123456789ab/terraform.tfstate.tflock"; + +use ContextValue::Literal; + +const REQUEST_COMPONENT: &str = "aws:RequestTag/triple-c:component"; +const RESOURCE_COMPONENT: &str = "aws:ResourceTag/triple-c:component"; +/// Created by the environment module: default_tags put the component tag on +/// the create request. +const ENV_CREATE: Context = &[(REQUEST_COMPONENT, Literal("environment"))]; +/// An environment-module resource after creation. +const ENV_TAGGED: Context = &[(RESOURCE_COMPONENT, Literal("environment"))]; +const USER_CREATE: Context = &[(REQUEST_COMPONENT, Literal("user-onboarding"))]; +const USER_TAGGED: Context = &[(RESOURCE_COMPONENT, Literal("user-onboarding"))]; +/// Role writes name the boundary the module sets. +const BOUNDED: Context = &[ + ( + "iam:PermissionsBoundary", + Literal("arn:aws:iam::{account}:policy/triple-c-permissions-boundary"), + ), + ("aws:PrincipalAccount", Literal("{account}")), +]; +/// A call EFS makes on the caller's behalf (a mount target's interface). +const VIA_SERVICE: Context = &[("aws:ViaAWSService", Literal("true"))]; +const NONE: Context = &[]; + +macro_rules! created_by { + ($action:literal) => { + &[("ec2:CreateAction", Literal($action))] + }; +} + +/// 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), +]; + +const MODULE_CALLS: &[(&str, &[Check])] = &[ + ( + "aws_ecs_cluster", + &[ + call("ecs:CreateCluster", CLUSTER, ENV_CREATE), + call( + "ecs:TagResource", + CLUSTER, + &[("ecs:CreateAction", Literal("CreateCluster"))], + ), + call("ecs:DescribeClusters", CLUSTER, NONE), + call("ecs:ListTagsForResource", CLUSTER, NONE), + call("ecs:UpdateCluster", CLUSTER, ENV_TAGGED), + call("ecs:TagResource", CLUSTER, ENV_TAGGED), + call("ecs:UntagResource", CLUSTER, ENV_TAGGED), + call("ecs:DeleteCluster", CLUSTER, ENV_TAGGED), + ], + ), + ( + // Untaggable: authorized against the (named) cluster. + "aws_ecs_cluster_capacity_providers", + &[ + call("ecs:PutClusterCapacityProviders", CLUSTER, ENV_TAGGED), + call("ecs:DescribeClusters", CLUSTER, NONE), + ], + ), + ( + // Both modules' groups. The provider revokes AWS's default egress + // rule after creating (both groups declare their own egress), and + // authorizes rules one call per direction, against the group and the + // new (untagged) rule. + "aws_security_group", + &[ + call("ec2:CreateSecurityGroup", SG, ENV_CREATE), + call("ec2:CreateSecurityGroup", VPC, ENV_CREATE), + call("ec2:CreateTags", SG, created_by!("CreateSecurityGroup")), + call("ec2:DescribeSecurityGroups", "*", NONE), + call("ec2:DescribeSecurityGroupRules", "*", NONE), + call("ec2:RevokeSecurityGroupEgress", SG, USER_TAGGED), + call("ec2:RevokeSecurityGroupEgress", SG, ENV_TAGGED), + call("ec2:AuthorizeSecurityGroupEgress", SG, ENV_TAGGED), + call("ec2:AuthorizeSecurityGroupEgress", SG_RULE, NONE), + call("ec2:AuthorizeSecurityGroupIngress", SG, USER_TAGGED), + call("ec2:AuthorizeSecurityGroupIngress", SG_RULE, NONE), + call("ec2:RevokeSecurityGroupIngress", SG, ENV_TAGGED), + call( + "ec2:UpdateSecurityGroupRuleDescriptionsIngress", + SG, + USER_TAGGED, + ), + call( + "ec2:UpdateSecurityGroupRuleDescriptionsEgress", + SG, + ENV_TAGGED, + ), + call("ec2:CreateTags", SG, ENV_TAGGED), + call("ec2:DeleteTags", SG, ENV_TAGGED), + call("ec2:DescribeNetworkInterfaces", "*", NONE), + call("ec2:DeleteSecurityGroup", SG, ENV_TAGGED), + call("ec2:DeleteSecurityGroup", SG, USER_TAGGED), + ], + ), + ( + // Tag-on-create authorizes kms:TagResource against the new key with + // its request tags (verified live in phase 3). + "aws_kms_key", + &[ + call("kms:CreateKey", "*", ENV_CREATE), + call("kms:TagResource", KEY, ENV_TAGGED), + call("kms:DescribeKey", KEY, NONE), + call("kms:GetKeyPolicy", KEY, NONE), + call("kms:GetKeyRotationStatus", KEY, NONE), + call("kms:ListResourceTags", KEY, NONE), + call("kms:EnableKeyRotation", KEY, ENV_TAGGED), + call("kms:PutKeyPolicy", KEY, ENV_TAGGED), + call("kms:UpdateKeyDescription", KEY, ENV_TAGGED), + call("kms:UntagResource", KEY, ENV_TAGGED), + call("kms:ScheduleKeyDeletion", KEY, ENV_TAGGED), + ], + ), + ( + // Untaggable: authorized against the alias (by name) and the key. + "aws_kms_alias", + &[ + call("kms:CreateAlias", ALIAS, NONE), + call("kms:CreateAlias", KEY, ENV_TAGGED), + call("kms:ListAliases", "*", NONE), + call("kms:UpdateAlias", ALIAS, NONE), + call("kms:UpdateAlias", KEY, ENV_TAGGED), + call("kms:DeleteAlias", ALIAS, NONE), + call("kms:DeleteAlias", KEY, ENV_TAGGED), + ], + ), + ( + "aws_cloudwatch_log_group", + &[ + call("logs:CreateLogGroup", LOG_GROUP, ENV_CREATE), + call("logs:TagResource", LOG_GROUP, ENV_CREATE), + call("logs:PutRetentionPolicy", LOG_GROUP, NONE), + call("logs:DescribeLogGroups", "*", NONE), + call("logs:ListTagsForResource", LOG_GROUP, NONE), + call("logs:UntagResource", LOG_GROUP, NONE), + call("logs:DeleteRetentionPolicy", LOG_GROUP, NONE), + call("logs:DeleteLogGroup", LOG_GROUP, NONE), + ], + ), + ( + "aws_ssm_parameter", + &[ + call("ssm:PutParameter", PARAMETER, ENV_CREATE), + call("ssm:AddTagsToResource", PARAMETER, ENV_CREATE), + call("ssm:GetParameter", PARAMETER, NONE), + call("ssm:DescribeParameters", "*", NONE), + call("ssm:ListTagsForResource", PARAMETER, NONE), + call("ssm:RemoveTagsFromResource", PARAMETER, NONE), + call("ssm:DeleteParameter", PARAMETER, NONE), + ], + ), + ( + // enable_dns_hostnames is set after CreateVpc; reads include the + // default ACL, route table and security group. + "aws_vpc", + &[ + call("ec2:CreateVpc", VPC, ENV_CREATE), + call("ec2:CreateTags", VPC, created_by!("CreateVpc")), + call("ec2:DescribeVpcs", "*", NONE), + call("ec2:DescribeVpcAttribute", "*", NONE), + call("ec2:DescribeNetworkAcls", "*", NONE), + call("ec2:DescribeRouteTables", "*", NONE), + call("ec2:ModifyVpcAttribute", VPC, ENV_TAGGED), + call("ec2:CreateTags", VPC, ENV_TAGGED), + call("ec2:DeleteTags", VPC, ENV_TAGGED), + call("ec2:DeleteVpc", VPC, ENV_TAGGED), + ], + ), + ( + "aws_internet_gateway", + &[ + call("ec2:CreateInternetGateway", IGW, ENV_CREATE), + call("ec2:CreateTags", IGW, created_by!("CreateInternetGateway")), + call("ec2:AttachInternetGateway", IGW, ENV_TAGGED), + call("ec2:AttachInternetGateway", VPC, ENV_TAGGED), + call("ec2:DescribeInternetGateways", "*", NONE), + call("ec2:CreateTags", IGW, ENV_TAGGED), + call("ec2:DetachInternetGateway", IGW, ENV_TAGGED), + call("ec2:DetachInternetGateway", VPC, ENV_TAGGED), + call("ec2:DeleteInternetGateway", IGW, ENV_TAGGED), + ], + ), + ( + // Deleting waits for the subnet's interfaces to go. + "aws_subnet", + &[ + call("ec2:CreateSubnet", SUBNET, ENV_CREATE), + call("ec2:CreateSubnet", VPC, ENV_CREATE), + call("ec2:CreateTags", SUBNET, created_by!("CreateSubnet")), + call("ec2:DescribeSubnets", "*", NONE), + call("ec2:ModifySubnetAttribute", SUBNET, ENV_TAGGED), + call("ec2:CreateTags", SUBNET, ENV_TAGGED), + call("ec2:DeleteTags", SUBNET, ENV_TAGGED), + call("ec2:DescribeNetworkInterfaces", "*", NONE), + call("ec2:DeleteSubnet", SUBNET, ENV_TAGGED), + ], + ), + ( + "aws_route_table", + &[ + call("ec2:CreateRouteTable", RTB, ENV_CREATE), + call("ec2:CreateRouteTable", VPC, ENV_CREATE), + call("ec2:CreateTags", RTB, created_by!("CreateRouteTable")), + call("ec2:DescribeRouteTables", "*", NONE), + call("ec2:CreateTags", RTB, ENV_TAGGED), + call("ec2:DeleteRouteTable", RTB, ENV_TAGGED), + ], + ), + ( + // Untaggable: authorized against the route table. + "aws_route", + &[ + call("ec2:CreateRoute", RTB, ENV_TAGGED), + call("ec2:DescribeRouteTables", "*", NONE), + call("ec2:ReplaceRoute", RTB, ENV_TAGGED), + call("ec2:DeleteRoute", RTB, ENV_TAGGED), + ], + ), + ( + // Untaggable: authorized against the route table and the subnet. + "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), + ], + ), + ( + "aws_eip", + &[ + call("ec2:AllocateAddress", EIP, ENV_CREATE), + call("ec2:CreateTags", EIP, created_by!("AllocateAddress")), + call("ec2:DescribeAddresses", "*", NONE), + call("ec2:DescribeAddressesAttribute", "*", NONE), + call("ec2:CreateTags", EIP, ENV_TAGGED), + call("ec2:ReleaseAddress", EIP, ENV_TAGGED), + ], + ), + ( + // Authorized against the gateway, its subnet, its address and the VPC. + "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:CreateTags", NAT, created_by!("CreateNatGateway")), + call("ec2:DescribeNatGateways", "*", NONE), + call("ec2:CreateTags", NAT, ENV_TAGGED), + call("ec2:DeleteNatGateway", NAT, ENV_TAGGED), + ], + ), + ( + "aws_efs_file_system", + &[ + call("elasticfilesystem:CreateFileSystem", "*", USER_CREATE), + call( + "elasticfilesystem:TagResource", + FS, + &[( + "elasticfilesystem:CreateAction", + Literal("CreateFileSystem"), + )], + ), + call("elasticfilesystem:DescribeFileSystems", FS, NONE), + call("elasticfilesystem:DescribeLifecycleConfiguration", FS, NONE), + call("elasticfilesystem:ListTagsForResource", FS, NONE), + call("elasticfilesystem:UpdateFileSystem", FS, USER_TAGGED), + call( + "elasticfilesystem:PutLifecycleConfiguration", + FS, + USER_TAGGED, + ), + call("elasticfilesystem:TagResource", FS, USER_TAGGED), + call("elasticfilesystem:UntagResource", FS, USER_TAGGED), + call("elasticfilesystem:DeleteFileSystem", FS, USER_TAGGED), + ], + ), + ( + // Untaggable here (the module sets no tags on it). EFS creates and + // deletes the mount target's interface with the caller's EC2 + // permissions (CreateMountTarget / DeleteMountTarget API reference). + "aws_efs_mount_target", + &[ + call("elasticfilesystem:CreateMountTarget", FS, USER_TAGGED), + call("ec2:DescribeSubnets", "*", NONE), + call("ec2:DescribeNetworkInterfaces", "*", NONE), + call("ec2:CreateNetworkInterface", ENI, VIA_SERVICE), + call("ec2:CreateNetworkInterface", SUBNET, VIA_SERVICE), + call("ec2:CreateNetworkInterface", SG, VIA_SERVICE), + call("elasticfilesystem:DescribeMountTargets", FS, NONE), + call( + "elasticfilesystem:DescribeMountTargetSecurityGroups", + FS, + NONE, + ), + call( + "elasticfilesystem:ModifyMountTargetSecurityGroups", + FS, + USER_TAGGED, + ), + call("elasticfilesystem:DeleteMountTarget", FS, USER_TAGGED), + call("ec2:DeleteNetworkInterface", ENI, VIA_SERVICE), + ], + ), + ( + // Untaggable: authorized against the file system. + "aws_efs_file_system_policy", + &[ + call("elasticfilesystem:PutFileSystemPolicy", FS, USER_TAGGED), + call("elasticfilesystem:DescribeFileSystemPolicy", FS, NONE), + call("elasticfilesystem:DeleteFileSystemPolicy", FS, USER_TAGGED), + ], + ), + ( + // Deleting a role first lists (and removes) its instance profiles + // and inline policies. + "aws_iam_role", + &[ + call("iam:CreateRole", TASK_ROLE, BOUNDED), + call("iam:CreateRole", EXEC_ROLE, BOUNDED), + call("iam:TagRole", TASK_ROLE, NONE), + call("iam:GetRole", TASK_ROLE, NONE), + call("iam:ListRolePolicies", TASK_ROLE, NONE), + call("iam:ListAttachedRolePolicies", TASK_ROLE, NONE), + call("iam:ListInstanceProfilesForRole", TASK_ROLE, NONE), + call("iam:UpdateRole", TASK_ROLE, NONE), + call("iam:UpdateRoleDescription", TASK_ROLE, NONE), + call("iam:UntagRole", TASK_ROLE, NONE), + call("iam:DeleteRole", EXEC_ROLE, NONE), + ], + ), + ( + // Untaggable: authorized against the role. + "aws_iam_role_policy", + &[ + call("iam:PutRolePolicy", TASK_ROLE, BOUNDED), + call("iam:GetRolePolicy", TASK_ROLE, NONE), + call("iam:DeleteRolePolicy", EXEC_ROLE, BOUNDED), + ], + ), + ( + "data.aws_caller_identity", + &[call("sts:GetCallerIdentity", "*", NONE)], + ), + ("data.aws_partition", &[]), + ( + "data.aws_availability_zones", + &[call("ec2:DescribeAvailabilityZones", "*", NONE)], + ), + ( + "data.aws_ssm_parameter", + &[call("ssm:GetParameter", PARAMETER, NONE)], + ), + ("data.aws_subnet", &[call("ec2:DescribeSubnets", "*", NONE)]), + ("backend.s3", BACKEND), +]; + +/// `resource.` and `data.` of the environment and onboarding +/// modules, and `backend.s3`. +fn module_types() -> BTreeSet { + let mut types = BTreeSet::new(); + for kind in [ModuleKind::Environment, ModuleKind::UserOnboard] { + let body = parsed(kind); + for b in top_blocks(&body, "resource") { + types.insert(label(b, 0).unwrap().to_string()); + } + for b in top_blocks(&body, "data") { + types.insert(format!("data.{}", label(b, 0).unwrap())); + } + for t in top_blocks(&body, "terraform") { + for backend in top_blocks(&t.body, "backend") { + types.insert(format!("backend.{}", label(backend, 0).unwrap())); + } + } + } + types +} + +#[test] +fn the_module_call_table_covers_every_type_the_modules_use() { + let table: BTreeSet = MODULE_CALLS.iter().map(|(t, _)| t.to_string()).collect(); + assert_eq!( + table.len(), + MODULE_CALLS.len(), + "a type appears twice in MODULE_CALLS" + ); + assert_eq!(module_types(), table); +} + +#[test] +fn the_module_call_table_uses_the_shared_names() { + let (task, exec) = user_role_names(ENV, OWNER_KEY); + assert!(TASK_ROLE.ends_with(&format!("role/{task}"))); + assert!(EXEC_ROLE.ends_with(&format!("role/{exec}"))); + assert!(PARAMETER.ends_with(&format!("parameter{}", environment_parameter_name(ENV)))); + assert!(LOG_GROUP.ends_with(&format!("log-group:/triple-c/{ENV}"))); + assert!(CLUSTER.ends_with(&format!("cluster/triple-c-{ENV}"))); + assert!(ALIAS.ends_with(&format!("alias/triple-c-{ENV}"))); + let bucket = state_bucket_name("{account}", "{region}"); + assert_eq!(STATE_BUCKET, format!("arn:aws:s3:::{bucket}")); + let env_key = state_key(Deployment::Environment(ENV)); + let user_key = state_key(Deployment::User { + env: ENV, + owner_key: OWNER_KEY, + }); + assert_eq!(ENV_STATE, format!("{STATE_BUCKET}/{env_key}")); + assert_eq!(ENV_LOCK, format!("{STATE_BUCKET}/{env_key}.tflock")); + assert_eq!(USER_STATE, format!("{STATE_BUCKET}/{user_key}")); + assert_eq!(USER_LOCK, format!("{STATE_BUCKET}/{user_key}.tflock")); +} + +fn tier_statements(tiers: &[Tier]) -> Vec { + tiers + .iter() + .flat_map(|t| policy_files(*t).iter()) + .flat_map(|(name, json)| eval::statements_of(&eval::parse(name, json))) + .collect() +} + +fn allowed_by(stmts: &[serde_json::Value], check: &Check) -> bool { + eval::evaluate_in( + stmts, + eval::Request { + check, + caller: eval::ME, + owner: eval::ME, + }, + ) +} + +/// Every call of every module resource is allowed by the admin tier's +/// files alone, and still allowed with the workspace tier (whose Denies an +/// admin also carries) attached as well. +#[test] +fn the_admin_tier_can_apply_every_module_resource() { + let admin = tier_statements(&[Tier::EnvironmentAdmin]); + let both = tier_statements(&[Tier::EnvironmentAdmin, Tier::WorkspaceUser]); + let mut missing = Vec::new(); + for (ty, calls) in MODULE_CALLS { + for c in *calls { + for (which, stmts) in [("admin", &admin), ("admin + workspace", &both)] { + if !allowed_by(stmts, c) { + missing.push(format!( + "{ty}: {} on {} with {:?} ({which})", + c.action, + eval::rendered(c), + c.context + )); + } + } + } + } + assert!(missing.is_empty(), "not allowed:\n{}", missing.join("\n")); +} + +/// The sample values the onboarding module's role policies are rendered +/// with: (template text, value). +const ONBOARD_SAMPLES: &[(&str, &str)] = &[ + ("${local.partition}", "aws"), + ("${data.aws_partition.current.partition}", "aws"), + ("${local.account}", "111122223333"), + ( + "${data.aws_caller_identity.current.account_id}", + "111122223333", + ), + ("${var.region}", "us-west-2"), + ("${var.owner_id}", "AROAEXAMPLEME:alice@example.com"), + ("${local.env.log_group}", "/triple-c/probe"), + ( + "${local.env.kms_key_arn}", + "arn:aws:kms:us-west-2:111122223333:key/00000000-0000-0000-0000-000000000000", + ), + ( + "${aws_efs_file_system.this.arn}", + "arn:aws:elasticfilesystem:us-west-2:111122223333:file-system/fs-00000000", + ), +]; + +fn leak(s: String) -> &'static str { + Box::leak(s.into_boxed_str()) +} + +/// Every action a per-user role's policies grant, on every resource they +/// name, is within the permissions boundary: the boundary, not the module, +/// is the ceiling, so a module change can't widen a role past it unseen. +/// Each request carries what the role's own statement requires (its +/// `StringEquals` conditions) and the component tag every Triple-C key and +/// file system has. +#[test] +fn every_onboarding_role_stays_inside_the_boundary() { + let body = parsed(ModuleKind::UserOnboard); + let boundary = eval::statements_of(&eval::parse( + "permissions-boundary.json", + permissions_boundary_json(), + )); + let render_sample = |text: &str| { + let mut out = expand_locals(&body, text); + for (from, to) in ONBOARD_SAMPLES { + out = out.replace(from, to); + } + assert!(!out.contains("${"), "unrendered {out}"); + out + }; + let mut seen = 0; + for policy in resources(&body, "aws_iam_role_policy") { + for stmt in statements(&jsonencoded(policy, "policy")) { + assert_eq!(stmt["Effect"], "Allow", "{:?}", policy.labels); + for key in stmt.as_object().unwrap().keys() { + assert!( + ["Sid", "Effect", "Action", "Resource", "Condition"].contains(&key.as_str()), + "{:?}: {key}", + policy.labels + ); + } + let mut context: Vec<(&'static str, ContextValue)> = + vec![(RESOURCE_COMPONENT, Literal("user-onboarding"))]; + if let Some(cond) = stmt.get("Condition") { + for (op, entries) in cond.as_object().unwrap() { + assert_eq!(op, "StringEquals", "{:?}: unexpected {op}", policy.labels); + for (k, v) in entries.as_object().unwrap() { + context.push(( + leak(k.clone()), + Literal(leak(render_sample(v.as_str().unwrap()))), + )); + } + } + } + let context: Context = Box::leak(context.into_boxed_slice()); + for action in actions(&stmt) { + for resource in eval::strings(&stmt["Resource"]) { + let check = call( + leak(action.clone()), + leak(render_sample(&resource)), + context, + ); + assert!( + allowed_by(&boundary, &check), + "{:?}: {action} on {} with {context:?} is outside the boundary", + policy.labels, + check.resource + ); + seen += 1; + } + } + } + } + assert!(seen >= 15, "only {seen} role grants checked"); +} diff --git a/app/src-tauri/src/commands/host_commands.rs b/app/src-tauri/src/commands/host_commands.rs index a099795..03e1cd4 100644 --- a/app/src-tauri/src/commands/host_commands.rs +++ b/app/src-tauri/src/commands/host_commands.rs @@ -6,7 +6,7 @@ use std::future::Future; use tauri::State; use crate::aws::permissions::{check_permissions, PermissionReport, SdkPermissionApi}; -use crate::aws::policies::{policy_json, Tier}; +use crate::aws::policies::{policy_files, Tier}; use crate::aws::{caller_identity, sdk_config, sdk_config_for_keys, AwsError, CallerIdentity}; use crate::models::{ validate_connection_name, validate_profile_name, validate_supported_region, AppSettings, AwsAuth, @@ -281,9 +281,24 @@ pub async fn check_aws_permissions( check_permissions(&api, &identity, ®ion).await } +/// One policy file of a tier: its file name under `aws/policies/` and its +/// JSON, verbatim. +#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +pub struct PolicyFile { + pub name: String, + pub json: String, +} + +/// Every policy file `tier` needs, in order; all of them are required (D7). #[tauri::command] -pub fn aws_policy_document(tier: Tier) -> String { - policy_json(tier).to_string() +pub fn aws_policy_document(tier: Tier) -> Vec { + policy_files(tier) + .iter() + .map(|(name, json)| PolicyFile { + name: name.to_string(), + json: json.to_string(), + }) + .collect() } #[cfg(test)] @@ -522,4 +537,21 @@ mod tests { } assert!(validated("Prod", "eu-west-1").is_ok()); } + + #[test] + fn the_policy_document_is_every_file_of_the_tier() { + let admin = aws_policy_document(Tier::EnvironmentAdmin); + let names: Vec<&str> = admin.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["environment-admin.json", "environment-admin-network.json"]); + for f in &admin { + 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"); + assert_eq!( + serde_json::to_value(&ws[0]).unwrap()["name"], + "workspace-user.json" + ); + } } diff --git a/app/src/components/settings/HostsSettings.test.tsx b/app/src/components/settings/HostsSettings.test.tsx index 75444fb..7a1c0fa 100644 --- a/app/src/components/settings/HostsSettings.test.tsx +++ b/app/src/components/settings/HostsSettings.test.tsx @@ -140,11 +140,18 @@ describe("HostsSettings", () => { expect(screen.getByLabelText("Secret access key")).toHaveValue(""); }); - it("check shows Insufficient with the denied actions and the copy-policy buttons", async () => { - invoke.mockImplementation(async (cmd: string) => { + it("check shows Insufficient with the denied actions and every policy file to copy", async () => { + const adminFiles = [ + { name: "environment-admin.json", json: '{"Statement":"admin"}' }, + { name: "environment-admin-network.json", json: '{"Statement":"network"}' }, + ]; + invoke.mockImplementation(async (cmd: string, args?: { tier: string }) => { if (cmd === "list_aws_profiles") return []; if (cmd === "check_aws_permissions") return report({}); - if (cmd === "aws_policy_document") return '{"Version":"2012-10-17"}'; + if (cmd === "aws_policy_document") + return args?.tier === "environment_admin" + ? adminFiles + : [{ name: "workspace-user.json", json: '{"Version":"2012-10-17"}' }]; throw new Error(`unexpected ${cmd}`); }); const writeText = vi.fn().mockResolvedValue(undefined); @@ -154,10 +161,22 @@ describe("HostsSettings", () => { expect(await screen.findByText("Insufficient")).toBeInTheDocument(); expect(screen.getByText("ecs:RunTask")).toBeInTheDocument(); expect(screen.getByText(/SCPs or session policies/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Copy workspace-user policy" })); + + fireEvent.click(screen.getByRole("button", { name: "Show workspace-user policy" })); + fireEvent.click(await screen.findByRole("button", { name: "Copy workspace-user.json" })); await waitFor(() => expect(writeText).toHaveBeenCalledWith('{"Version":"2012-10-17"}')); expect(invoke).toHaveBeenCalledWith("aws_policy_document", { tier: "workspace_user" }); - expect(screen.getByRole("button", { name: "Copy admin policy" })).toBeInTheDocument(); + expect(screen.getByText("workspace-user.json copied to clipboard.")).toBeInTheDocument(); + + // The admin tier is every one of its files, each shown and copied on its own. + fireEvent.click(screen.getByRole("button", { name: "Show admin policies" })); + expect(await screen.findByText(/Attach all 2 files/)).toBeInTheDocument(); + for (const f of adminFiles) { + expect(screen.getByText(f.json)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: `Copy ${f.name}` })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(f.json)); + } + expect(invoke).toHaveBeenCalledWith("aws_policy_document", { tier: "environment_admin" }); }); it("shows a neutral Incomplete badge when the only blockers are failed checks", async () => { @@ -301,14 +320,27 @@ describe("HostsSettings", () => { invoke.mockImplementation(async (cmd: string) => { if (cmd === "list_aws_profiles") return []; if (cmd === "check_aws_permissions") return report({}); - if (cmd === "aws_policy_document") return "{}"; + if (cmd === "aws_policy_document") return [{ name: "environment-admin.json", json: "{}" }]; throw new Error(`unexpected ${cmd}`); }); Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) } }); render(); fireEvent.click(screen.getByRole("button", { name: "Check Prod" })); - fireEvent.click(await screen.findByRole("button", { name: "Copy admin policy" })); - expect(await screen.findByText(/Could not copy the policy: denied/)).toBeInTheDocument(); + fireEvent.click(await screen.findByRole("button", { name: "Show admin policies" })); + fireEvent.click(await screen.findByRole("button", { name: "Copy environment-admin.json" })); + expect(await screen.findByText(/Could not copy environment-admin.json: denied/)).toBeInTheDocument(); + }); + + it("shows a message when the policy files can't be loaded", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "list_aws_profiles") return []; + if (cmd === "check_aws_permissions") return report({}); + throw new Error("boom"); + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Check Prod" })); + fireEvent.click(await screen.findByRole("button", { name: "Show admin policies" })); + expect(await screen.findByText(/Could not load the policy: boom/)).toBeInTheDocument(); }); it.each([ diff --git a/app/src/components/settings/HostsSettings.tsx b/app/src/components/settings/HostsSettings.tsx index 5cd885a..d18babb 100644 --- a/app/src/components/settings/HostsSettings.tsx +++ b/app/src/components/settings/HostsSettings.tsx @@ -17,6 +17,7 @@ import type { HostConnection, PermissionReport, PermissionSummary, + PolicyFile, Tier, } from "../../lib/types"; import Button from "../ui/Button"; @@ -93,6 +94,70 @@ function ErrorLine({ ); } +/** A tier's policy files, loaded on demand: each one shown and copied on its own. */ +function PolicyFiles({ + tier, + label, + onCopy, +}: { + tier: Tier; + label: string; + onCopy: (file: PolicyFile) => void; +}) { + const [files, setFiles] = useState(null); + const [error, setError] = useState(null); + + const load = async () => { + setError(null); + try { + setFiles(await awsPolicyDocument(tier)); + } catch (e) { + setError(`Could not load the policy: ${awsErrorMessage(toAwsError(e))}`); + } + }; + + if (!files) { + return ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ); + } + return ( +
+

+ {tier === "workspace_user" ? "Workspace user" : "Environment admin"}:{" "} + {files.length === 1 + ? "attach this file as a customer managed policy." + : `Attach all ${files.length} files, each as its own customer managed policy.`} +

+ {files.map((f) => ( +
+
+ {f.name} + +
+
+ Show JSON +
+              {f.json}
+            
+
+
+ ))} +
+ ); +} + function PermissionReportView({ report }: { report: PermissionReport }) { const [copyNote, setCopyNote] = useState(null); const probed = typeof report.method === "object"; @@ -121,12 +186,12 @@ function PermissionReportView({ report }: { report: PermissionReport }) { t.results.some((r) => r.verdict === "denied" && OWN_PRINCIPAL_ACTIONS.includes(r.action)), ); - const copy = async (tier: Tier) => { + const copy = async (file: PolicyFile) => { try { - await navigator.clipboard.writeText(await awsPolicyDocument(tier)); - setCopyNote("Policy copied to clipboard."); + await navigator.clipboard.writeText(file.json); + setCopyNote(`${file.name} copied to clipboard.`); } catch (e) { - setCopyNote(`Could not copy the policy: ${awsErrorMessage(toAwsError(e))}`); + setCopyNote(`Could not copy ${file.name}: ${awsErrorMessage(toAwsError(e))}`); } }; @@ -181,14 +246,8 @@ function PermissionReportView({ report }: { report: PermissionReport }) { ))} -
- - -
+ + {copyNote && (

{copyNote} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 9bada24..b03b9bd 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport, HostConnection, CallerIdentity, PermissionReport, Tier } from "./types"; +import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport, HostConnection, CallerIdentity, PermissionReport, PolicyFile, Tier } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -76,8 +76,9 @@ export const awsConnectionSsoLogin = (id: string) => /** `region` omitted: the connection's default region. */ export const checkAwsPermissions = (id: string, region?: string) => invoke("check_aws_permissions", { id, region: region ?? null }); +/** Every policy file `tier` needs, in order: attach all of them. */ export const awsPolicyDocument = (tier: Tier) => - invoke("aws_policy_document", { tier }); + invoke("aws_policy_document", { tier }); /** Check a corporate CA path and report what would be installed. Never * rejects for a bad path — the reason comes back in `error`. */ export const inspectCaCertPath = (path: string) => diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 87b93e3..21b7051 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -1158,6 +1158,12 @@ export interface CallerIdentity { export type Tier = "environment_admin" | "workspace_user"; +/** One policy file of a tier (`aws/policies/`); a tier needs all of its files. */ +export interface PolicyFile { + name: string; + json: string; +} + export type Verdict = "allowed" | "denied" | "unverified"; export interface ActionResult { diff --git a/aws/README.md b/aws/README.md index 573bebd..1ff9017 100644 --- a/aws/README.md +++ b/aws/README.md @@ -7,11 +7,14 @@ 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/environment-admin.json` | The **environment admin** tier: deploy, update and delete the environment stack, and onboard users | +| `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 | -An environment admin needs **both** tier files. The admin policy adds environment management on -top of everything a workspace user can do. Explicit Deny statements in either file apply to anyone +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 who has it attached. ## Workspace user @@ -42,7 +45,7 @@ following: is needed because the AWS-managed `aws/secretsmanager` key, or any customer key whose key policy grants account principals directly, needs no identity-policy Allow; without the Deny such a `CreateSecret` succeeds. Keeping every user secret on the environment key keeps it - under the key policy the environment stack controls. (The permissions boundary does *not* + under the key policy the environment module controls. (The permissions boundary does *not* stop a task role decrypting an `aws/secretsmanager` secret: that key's policy grants the caller directly.) - Every `TagResource` on a secret must carry `triple-c:owner = ${aws:userid}` in the request, @@ -50,8 +53,10 @@ following: - 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`. + their owner ID, and create them only on **their own** file system: one tagged + `triple-c:component` (their onboarding module tags it `user-onboarding`) *and* + `triple-c:owner = ${aws:userid}`. Each user has their own file system (strict tenancy), so + another user's file system is refused even though it is Triple-C's. - **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 @@ -71,20 +76,65 @@ following: roles, add those roles' ARNs or path prefixes (for example `arn:aws:iam::*:role/engineers/*`) to `InspectOwnPrincipal` in both tier policies. Everything matched there can be read and simulated by every holder of the policy, so keep it to the sign-in roles. -- **Read environment information.** This covers ECS, CloudFormation stacks, the `/triple-c/*` SSM - parameters, the secret list, EFS, and the `/triple-c/*` log groups. Listing is not a secret; the - app filters by owner for display. +- **Read environment information.** This covers ECS, the `/triple-c/*` SSM parameters (the + environment records), the secret list, EFS, and the `/triple-c/*` log groups. Listing is not a + secret; the app filters by owner for display. A workspace user sees only environments that have + a record: the OpenTofu state bucket is admin-only. ## Environment admin -The admin tier manages `triple-c-*` CloudFormation stacks and the resources they create: +The admin tier applies (and destroys) the OpenTofu modules in `terraform/` with the admin's own +credentials: the provider calls AWS as the admin, so the policy must allow every call it makes, +and nothing runs with more than the admin holds. It covers: -- `triple-c-*` IAM roles; -- ECS clusters, capacity providers and task definitions; -- security groups, EFS file systems and KMS keys; +- the per-account state bucket `triple-c-tfstate--` (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); +- 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. +### What each module resource needs + +`app/src-tauri/src/aws/tofu_modules/tests.rs` (`MODULE_CALLS`) maps every resource type, data +source and the S3 backend the modules use to the IAM actions the AWS provider calls to create, +read, tag, update and delete it, and a test evaluates each against the admin files. It is the +**hypothesis**; a live apply and destroy is the proof, and any action that shows missing is added +to the table, the policy, the check table in `policies.rs` and this list together. + +| Resource | Actions (authorized against) | +|---|---| +| `aws_ecs_cluster` | `ecs:CreateCluster`, `TagResource` (at create), `DescribeClusters`, `ListTagsForResource`, `UpdateCluster`, `UntagResource`, `DeleteCluster` (cluster `triple-c-*`) | +| `aws_ecs_cluster_capacity_providers` | `ecs:PutClusterCapacityProviders`, `DescribeClusters` (the cluster; untaggable) | +| `aws_security_group` | `ec2:CreateSecurityGroup` (group and VPC, request tag), `CreateTags` (at create), `RevokeSecurityGroupEgress` (AWS's default rule), `Authorize`/`RevokeSecurityGroupIngress`/`Egress` and `UpdateSecurityGroupRuleDescriptions*` (tagged group; new rules: `security-group-rule/*`), `CreateTags`/`DeleteTags`, `DeleteSecurityGroup` (tagged), `Describe*` | +| `aws_kms_key` | `kms:CreateKey` (request tag), `TagResource`, `DescribeKey`, `GetKeyPolicy`, `GetKeyRotationStatus`, `ListResourceTags`, `EnableKeyRotation`, `PutKeyPolicy`, `UpdateKeyDescription`, `UntagResource`, `ScheduleKeyDeletion` (tagged key) | +| `aws_kms_alias` | `kms:CreateAlias`/`UpdateAlias`/`DeleteAlias` (alias `triple-c-*` and the tagged key; untaggable), `ListAliases` | +| `aws_cloudwatch_log_group` | `logs:CreateLogGroup`, `TagResource`, `PutRetentionPolicy`, `ListTagsForResource`, `UntagResource`, `DeleteRetentionPolicy`, `DeleteLogGroup` (`/triple-c/*`), `DescribeLogGroups` (`*`) | +| `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_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_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_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_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) | + +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. + It is fenced in the following ways. **Roles are bounded.** The admin can create a `triple-c-*` role, or change its policies, only when @@ -96,9 +146,9 @@ the role carries the `triple-c-permissions-boundary` managed policy as its permi - ECS Exec channels; - reading `triple-c/*` secrets; - decrypting only through Secrets Manager, and only with environment (component-tagged) keys; - - mounting and writing only environment (component-tagged) file systems; whether that excludes - root access depends on access points fixing the POSIX user and root directory, which is only - true once phase 6 enforces them (see "Enforced access-point roots" under Phase 6 gaps); + - mounting and writing only Triple-C (component-tagged) file systems, which are each user's own + (tagged `user-onboarding` by their onboarding module); the file system's own policy admits + only its owner's task role, through an access point; - `sts:GetCallerIdentity`; - invoking Bedrock models. - The boundary grants nothing in IAM. @@ -110,13 +160,16 @@ the role carries the `triple-c-permissions-boundary` managed policy as its permi Without these fences an admin could create a role with any permissions and become an account administrator. -**No CloudFormation service roles.** The admin cannot create or update a `triple-c-*` stack, or -create a change set for one, with a service role (`RoleARN`). - -**Destruction is tag-scoped.** Security groups, EFS file systems and KMS keys can be created only -with a `triple-c:component` tag. They can be changed or deleted only if they carry that tag. -Tagging is allowed only as part of the creating call, or on a resource that is already -Triple-C's. +**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 +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 +policy through the file system, KMS aliases through the key, role policies through the +(boundary-carrying) role. Clusters, log groups, parameters, roles and the state bucket are scoped +by name (`triple-c-*`, `/triple-c/*`, `triple-c-tfstate-*`). **What is not scoped.** The scoping above does not cover every action. These actions apply account-wide: @@ -128,28 +181,35 @@ account-wide: 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. -- `ec2:CreateSecurityGroup` in any VPC (the new group itself must be tagged), and `ec2:Describe*`. -- `cloudformation:*` on `triple-c-*` stacks. Whatever a template creates is limited by the actions - above, because CloudFormation runs with the caller's own permissions (see the invariants below). +- 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: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. +- 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. -## Invariants the templates must keep (phase 4) +## Invariants the modules must keep (phase 4) -The policies rely on the following. Phase 4's templates must establish them, and phase 4's -detection should flag any environment that breaks them. +The policies rely on the following. The modules in `terraform/` establish them (their tests pin +it), and detection should flag any environment that breaks them. 1. **Every `triple-c-*` role carries `triple-c-permissions-boundary`.** The admin tier cannot create one without it. A role created some other way, such as by an account administrator or by hand, must still have the boundary. Detection should flag a `triple-c-*` role that has no boundary. -2. **`triple-c-*` stacks are never deployed with a CloudFormation service role.** `UpdateStack` - and change sets on a stack that already has a service role reuse it without `iam:PassRole`, - and that role is not under the boundary. The admin tier denies setting `RoleARN`. Detection - should flag a `triple-c-*` stack whose `RoleARN` is set. -3. **Every environment-level `triple-c/*` secret carries `triple-c:component`.** A secret with +2. **Every environment-level `triple-c/*` secret carries `triple-c:component`.** A secret with 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 +3. **Every environment KMS key and EFS file system carries `triple-c:component`,** and every + user's file system also carries `triple-c:owner`. The boundary, the admin tier and the + workspace user's access-point rule rely on these tags. +4. **User secrets use the environment key.** The app must pass the environment KMS key's ARN as `KmsKeyId` when it creates a secret. The workspace policy explicitly denies Secrets Manager encryption with any key that lacks `triple-c:component`, including `aws/secretsmanager`. @@ -164,19 +224,21 @@ aws iam create-policy --policy-name triple-c-permissions-boundary \ ``` Create the policy **without a path**. The admin policy expects exactly -`arn:aws:iam:::policy/triple-c-permissions-boundary`. Phase 4 decides how the app offers -this step; one option is to list it with the environment stack's prerequisites. +`arn:aws:iam:::policy/triple-c-permissions-boundary`. The `terraform/permissions-boundary` +module creates the same policy, applied by an account administrator; the admin tier can read it +(`iam:GetPolicy`, which detection uses) but never create or change it. ## Attaching the tier policies Create each file as a **customer managed policy** and attach it. In the console, go to **IAM → Policies → Create policy → JSON**, paste the file, and attach the policy to a user, group or role. Inline policies do not fit: a role's inline policies share 10,240 characters and an -environment admin needs both tiers (about 11,500), and a user's share 2,048. A managed policy may -hold 6,144 characters, not counting whitespace; the workspace policy is about 6,110 (roughly 30 -characters of headroom, so phase 4 must split `workspace-user.json` into two policies before -adding anything to it), and a unit test (`every_policy_fits_a_managed_policy`) fails before it -outgrows that. For IAM Identity Center, add the files as inline policies on the permission set +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`) +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. With the CLI: @@ -193,20 +255,20 @@ aws iam attach-user-policy --user-name \ **Phase 4** - **Per-user execution roles.** A workspace user can pass only roles tagged with their own owner - ID. Each user's onboarding stack must therefore create their execution role as well as their - task role. The alternative is a separate statement that allows the environment's shared - execution role. -- **The admin's full deploy permissions.** Deploying needs the following, added with the same - boundary and tag scoping once the templates exist: - - tag-on-create permissions for ECS, CloudWatch Logs, SSM and ECR; - - autoscaling and instance-profile actions for EC2 capacity; - - `cloudformation:ValidateTemplate`; - - whatever else the templates create. + ID, so each user's onboarding module creates their execution role as well as their task role + (done: `terraform/user-onboard/iam.tf`). +- **The admin's deploy permissions are a hypothesis until a live apply.** The module table above + 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 + `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 - boundary also allows every `triple-c/*` secret and every environment file system. As a result, - isolating users *from the admin* depends on the onboarding template's per-user policies, not on + boundary also allows every `triple-c/*` secret and every Triple-C file system. As a result, + isolating users *from the admin* depends on the onboarding module's per-user policies, not on IAM fences. -- **The workspace tier's secret Deny also applies to admins.** If the environment stack ever +- **The workspace tier's secret Deny also applies to admins.** If the environment module ever manages component-tagged secrets through the admin's credentials, that Deny will block `PutSecretValue`/`DeleteSecret` for them. @@ -219,11 +281,10 @@ aws iam attach-user-policy --user-name \ **Phase 6** -- **Enforced access-point roots.** A workspace user creates their own access points. The - `RootDirectory` and `PosixUser` must be enforced, so that an access point rooted at `/` cannot - read other users' data. Two ways to do this: an EFS file-system policy that pins each access - point to its owner's task role, or access points created by the onboarding stack rather than by - the user. +- **Access-point roots.** A workspace user creates their own access points, only on their own file + system (strict tenancy: one file system per user, whose policy admits only their task role). An + access point rooted at `/` therefore reaches only that user's own data. Pinning `RootDirectory` + and `PosixUser` is still worth doing if file systems are ever shared again. ## Live verification (2026-11-14) @@ -268,11 +329,14 @@ ends (keys are scheduled for deletion, 7 days). One test creates the real | Secret ownership | ✓ another owner's or a component-tagged secret cannot be written, deleted or claimed; `triple-c:component` cannot be set. An untagged `triple-c/*` secret *can* be claimed (invariant 3) | | `aws:TagKeys` | ✓ for `CreateSecret` and secret `TagResource`. `RunTask` (including `aws:ecs:*` and propagated tags), `CreateAccessPoint`, ECS/EFS tag-on-create: — need phase 4/5/6 resources | | Boundary and key tags | ✓ a role under the boundary decrypts through Secrets Manager with a component-tagged key and is refused with an untagged customer key, so KMS supplies the key's `aws:ResourceTag` | -| `cloudformation:RoleArn` | ✓ present when `RoleARN` is given; the Deny fires. The admin tier cannot pass any role to CloudFormation anyway (`PassRole` is only to ECS tasks and EC2) | +| `cloudformation:RoleArn` | ✓ (phase 3) present when `RoleARN` is given; the Deny fired. CloudFormation has since left the policies (phase 4, D8), and the Deny with it | | Task definition / pull-through scoping | ✓ both authorize against `*`; they cannot be narrowed | Still open for the phases that create the resources: `RunTask`'s tag keys and `iam:PassRole`'s `iam:ResourceTag` on a real run, the owner tag on a real task for `StopTask`/`ExecuteCommand`/ `StartSession`, session ownership for an SSO-started session, and the EFS and security-group-rule points above. The simulator does not apply session policies, so the first real create is the final -check. +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`.) diff --git a/aws/policies/environment-admin-network.json b/aws/policies/environment-admin-network.json new file mode 100644 index 0000000..93c5ffd --- /dev/null +++ b/aws/policies/environment-admin-network.json @@ -0,0 +1,139 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "CreateTaggedNetwork", + "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/*" + ], + "Condition": { + "Null": { + "aws:RequestTag/triple-c:component": "false" + } + } + }, + { + "Sid": "TagNetworkAtCreate", + "Effect": "Allow", + "Action": [ + "ec2:CreateTags" + ], + "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/*" + ], + "Condition": { + "StringEquals": { + "ec2:CreateAction": [ + "CreateVpc", + "CreateSubnet", + "CreateInternetGateway", + "CreateRouteTable", + "AllocateAddress", + "CreateNatGateway", + "CreateSecurityGroup" + ] + } + } + }, + { + "Sid": "ManageTripleCNetwork", + "Effect": "Allow", + "Action": [ + "ec2:DeleteVpc", + "ec2:ModifyVpcAttribute", + "ec2:DeleteSubnet", + "ec2:ModifySubnetAttribute", + "ec2:AttachInternetGateway", + "ec2:DetachInternetGateway", + "ec2:DeleteInternetGateway", + "ec2:DeleteRouteTable", + "ec2:CreateRoute", + "ec2:ReplaceRoute", + "ec2:DeleteRoute", + "ec2:AssociateRouteTable", + "ec2:ReplaceRouteTableAssociation", + "ec2:DisassociateRouteTable", + "ec2:ReleaseAddress", + "ec2:DeleteNatGateway", + "ec2:DeleteSecurityGroup", + "ec2:AuthorizeSecurityGroup*", + "ec2:RevokeSecurityGroup*", + "ec2:UpdateSecurityGroupRuleDescriptions*", + "ec2:CreateTags", + "ec2:DeleteTags" + ], + "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/*" + ], + "Condition": { + "Null": { + "aws:ResourceTag/triple-c:component": "false" + } + } + }, + { + "Sid": "AuthorizeNewSecurityGroupRules", + "Effect": "Allow", + "Action": [ + "ec2:AuthorizeSecurityGroup*" + ], + "Resource": [ + "arn:aws:ec2:*:*:security-group-rule/*" + ] + }, + { + "Sid": "MountTargetInterfacesThroughEfs", + "Effect": "Allow", + "Action": [ + "ec2:CreateNetworkInterface", + "ec2:DeleteNetworkInterface" + ], + "Resource": [ + "*" + ], + "Condition": { + "Bool": { + "aws:ViaAWSService": "true" + } + } + }, + { + "Sid": "DescribeEc2", + "Effect": "Allow", + "Action": [ + "ec2:Describe*" + ], + "Resource": [ + "*" + ] + } + ] +} diff --git a/aws/policies/environment-admin.json b/aws/policies/environment-admin.json index d6cfbde..d743bae 100644 --- a/aws/policies/environment-admin.json +++ b/aws/policies/environment-admin.json @@ -1,16 +1,6 @@ { "Version": "2012-10-17", "Statement": [ - { - "Sid": "ManageEnvironmentStacks", - "Effect": "Allow", - "Action": [ - "cloudformation:*" - ], - "Resource": [ - "arn:aws:cloudformation:*:*:stack/triple-c-*/*" - ] - }, { "Sid": "ManageBoundedTripleCRoles", "Effect": "Allow", @@ -31,12 +21,19 @@ } }, { - "Sid": "TagReadAndDeleteTripleCRoles", + "Sid": "ManageAndReadTripleCRoles", "Effect": "Allow", "Action": [ "iam:DeleteRole", "iam:TagRole", - "iam:GetRole" + "iam:UntagRole", + "iam:UpdateRole", + "iam:UpdateRoleDescription", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListRolePolicies", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole" ], "Resource": [ "arn:aws:iam::*:role/triple-c-*" @@ -98,23 +95,6 @@ } } }, - { - "Sid": "NoServiceRoleForTripleCStacks", - "Effect": "Deny", - "Action": [ - "cloudformation:CreateStack", - "cloudformation:UpdateStack", - "cloudformation:CreateChangeSet" - ], - "Resource": [ - "arn:aws:cloudformation:*:*:stack/triple-c-*/*" - ], - "Condition": { - "Null": { - "cloudformation:RoleArn": "false" - } - } - }, { "Sid": "InspectOwnPrincipal", "Effect": "Allow", @@ -129,13 +109,28 @@ "arn:aws:iam::*:user/*/${aws:username}" ] }, + { + "Sid": "ReadTheBoundary", + "Effect": "Allow", + "Action": [ + "iam:GetPolicy" + ], + "Resource": [ + "arn:aws:iam::*:policy/triple-c-permissions-boundary" + ] + }, { "Sid": "ManageClusters", "Effect": "Allow", "Action": [ "ecs:CreateCluster", "ecs:DeleteCluster", - "ecs:PutClusterCapacityProviders" + "ecs:UpdateCluster", + "ecs:PutClusterCapacityProviders", + "ecs:DescribeClusters", + "ecs:TagResource", + "ecs:UntagResource", + "ecs:ListTagsForResource" ], "Resource": [ "arn:aws:ecs:*:*:cluster/triple-c-*" @@ -163,74 +158,6 @@ "*" ] }, - { - "Sid": "CreateTaggedSecurityGroups", - "Effect": "Allow", - "Action": [ - "ec2:CreateSecurityGroup" - ], - "Resource": [ - "arn:aws:ec2:*:*:security-group/*" - ], - "Condition": { - "Null": { - "aws:RequestTag/triple-c:component": "false" - } - } - }, - { - "Sid": "CreateSecurityGroupsInVpcs", - "Effect": "Allow", - "Action": [ - "ec2:CreateSecurityGroup" - ], - "Resource": [ - "arn:aws:ec2:*:*:vpc/*" - ] - }, - { - "Sid": "TagSecurityGroupsAtCreate", - "Effect": "Allow", - "Action": [ - "ec2:CreateTags" - ], - "Resource": [ - "arn:aws:ec2:*:*:security-group/*" - ], - "Condition": { - "StringEquals": { - "ec2:CreateAction": "CreateSecurityGroup" - } - } - }, - { - "Sid": "ManageTripleCSecurityGroups", - "Effect": "Allow", - "Action": [ - "ec2:DeleteSecurityGroup", - "ec2:AuthorizeSecurityGroup*", - "ec2:RevokeSecurityGroup*", - "ec2:CreateTags" - ], - "Resource": [ - "arn:aws:ec2:*:*:security-group/*" - ], - "Condition": { - "Null": { - "aws:ResourceTag/triple-c:component": "false" - } - } - }, - { - "Sid": "DescribeEc2", - "Effect": "Allow", - "Action": [ - "ec2:Describe*" - ], - "Resource": [ - "*" - ] - }, { "Sid": "CreateTaggedFileSystems", "Effect": "Allow", @@ -266,10 +193,15 @@ "Effect": "Allow", "Action": [ "elasticfilesystem:DeleteFileSystem", + "elasticfilesystem:UpdateFileSystem", + "elasticfilesystem:PutLifecycleConfiguration", "elasticfilesystem:CreateMountTarget", "elasticfilesystem:DeleteMountTarget", + "elasticfilesystem:ModifyMountTargetSecurityGroups", "elasticfilesystem:PutFileSystemPolicy", - "elasticfilesystem:TagResource" + "elasticfilesystem:DeleteFileSystemPolicy", + "elasticfilesystem:TagResource", + "elasticfilesystem:UntagResource" ], "Resource": [ "arn:aws:elasticfilesystem:*:*:file-system/*" @@ -301,8 +233,12 @@ "Action": [ "kms:ScheduleKeyDeletion", "kms:PutKeyPolicy", + "kms:EnableKeyRotation", + "kms:UpdateKeyDescription", "kms:TagResource", + "kms:UntagResource", "kms:CreateAlias", + "kms:UpdateAlias", "kms:DeleteAlias" ], "Resource": [ @@ -319,6 +255,7 @@ "Effect": "Allow", "Action": [ "kms:CreateAlias", + "kms:UpdateAlias", "kms:DeleteAlias" ], "Resource": [ @@ -331,7 +268,11 @@ "Action": [ "logs:CreateLogGroup", "logs:DeleteLogGroup", - "logs:PutRetentionPolicy" + "logs:PutRetentionPolicy", + "logs:DeleteRetentionPolicy", + "logs:TagResource", + "logs:UntagResource", + "logs:ListTagsForResource" ], "Resource": [ "arn:aws:logs:*:*:log-group:/triple-c/*" @@ -364,11 +305,77 @@ "Effect": "Allow", "Action": [ "ssm:PutParameter", - "ssm:DeleteParameter" + "ssm:DeleteParameter", + "ssm:GetParameter*", + "ssm:AddTagsToResource", + "ssm:RemoveTagsFromResource", + "ssm:ListTagsForResource" ], "Resource": [ "arn:aws:ssm:*:*:parameter/triple-c/*" ] + }, + { + "Sid": "ReadWhatTheModulesRefresh", + "Effect": "Allow", + "Action": [ + "kms:DescribeKey", + "kms:GetKeyPolicy", + "kms:GetKeyRotationStatus", + "kms:ListResourceTags", + "kms:ListAliases", + "logs:DescribeLogGroups", + "ssm:DescribeParameters", + "elasticfilesystem:Describe*", + "elasticfilesystem:ListTagsForResource", + "sts:GetCallerIdentity" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "CreateTheStateBucket", + "Effect": "Allow", + "Action": [ + "s3:CreateBucket" + ], + "Resource": [ + "arn:aws:s3:::triple-c-tfstate-*" + ], + "Condition": { + "Null": { + "aws:RequestTag/triple-c:component": "false" + } + } + }, + { + "Sid": "ManageTheStateBucket", + "Effect": "Allow", + "Action": [ + "s3:PutBucketVersioning", + "s3:PutEncryptionConfiguration", + "s3:PutBucketPublicAccessBlock", + "s3:PutBucketTagging", + "s3:PutBucketPolicy", + "s3:GetBucket*", + "s3:ListBucket" + ], + "Resource": [ + "arn:aws:s3:::triple-c-tfstate-*" + ] + }, + { + "Sid": "ReadAndWriteState", + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ], + "Resource": [ + "arn:aws:s3:::triple-c-tfstate-*/*" + ] } ] } diff --git a/aws/policies/workspace-user.json b/aws/policies/workspace-user.json index 00426f7..230c11d 100644 --- a/aws/policies/workspace-user.json +++ b/aws/policies/workspace-user.json @@ -232,7 +232,8 @@ ], "Condition": { "StringEquals": { - "aws:RequestTag/triple-c:owner": "${aws:userid}" + "aws:RequestTag/triple-c:owner": "${aws:userid}", + "aws:ResourceTag/triple-c:owner": "${aws:userid}" }, "Null": { "aws:ResourceTag/triple-c:component": "false" @@ -344,17 +345,6 @@ "arn:aws:ssm:*:*:parameter/triple-c/*" ] }, - { - "Sid": "ReadStacks", - "Effect": "Allow", - "Action": [ - "cloudformation:DescribeStacks", - "cloudformation:ListStacks" - ], - "Resource": [ - "*" - ] - }, { "Sid": "ListSecrets", "Effect": "Allow", -- 2.52.0 From 02c8616512a30df5fcb320caa965fef0c9bb07fd Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 21:34:57 -0800 Subject: [PATCH 08/24] fix(aws): pin the state bucket to the account, tag-scope network parents, drop v1-unused grants Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/policies.rs | 262 ++++++++++++++------ app/src-tauri/src/aws/policy_eval.rs | 29 --- app/src-tauri/src/aws/tofu_modules/tests.rs | 120 +++++++-- aws/README.md | 66 +++-- aws/policies/environment-admin-network.json | 92 ++++++- aws/policies/environment-admin.json | 55 ++-- 6 files changed, 431 insertions(+), 193 deletions(-) diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index d76ef6d..e9d6b94 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -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 )); diff --git a/app/src-tauri/src/aws/policy_eval.rs b/app/src-tauri/src/aws/policy_eval.rs index 2eccb56..dff5662 100644 --- a/app/src-tauri/src/aws/policy_eval.rs +++ b/app/src-tauri/src/aws/policy_eval.rs @@ -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(&[])); - } } diff --git a/app/src-tauri/src/aws/tofu_modules/tests.rs b/app/src-tauri/src/aws/tofu_modules/tests.rs index a3adfa2..15ee715 100644 --- a/app/src-tauri/src/aws/tofu_modules/tests.rs +++ b/app/src-tauri/src/aws/tofu_modules/tests.rs @@ -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:?}"); +} diff --git a/aws/README.md b/aws/README.md index 1ff9017..bdb2b1c 100644 --- a/aws/README.md +++ b/aws/README.md @@ -90,12 +90,15 @@ and nothing runs with more than the admin holds. It covers: - the per-account state bucket `triple-c-tfstate--` (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--…` +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 \ 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. diff --git a/aws/policies/environment-admin-network.json b/aws/policies/environment-admin-network.json index 93c5ffd..c05954f 100644 --- a/aws/policies/environment-admin-network.json +++ b/aws/policies/environment-admin-network.json @@ -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" } } }, diff --git a/aws/policies/environment-admin.json b/aws/policies/environment-admin.json index d743bae..cce320f 100644 --- a/aws/policies/environment-admin.json +++ b/aws/policies/environment-admin.json @@ -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}" + } + } } ] } -- 2.52.0 From a000d64577d661be9de8913ace5c969d6bc2fe73 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 21:46:43 -0800 Subject: [PATCH 09/24] feat(aws): pinned OpenTofu download and a detached apply runner Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/Cargo.lock | 2 + app/src-tauri/Cargo.toml | 9 + app/src-tauri/src/aws/mod.rs | 1 + app/src-tauri/src/aws/tofu.rs | 938 ++++++++++++++++++++++++++++ app/src-tauri/src/aws/tofu/tests.rs | 442 +++++++++++++ app/src-tauri/src/lib.rs | 7 + app/src-tauri/src/main.rs | 6 + 7 files changed, 1405 insertions(+) create mode 100644 app/src-tauri/src/aws/tofu.rs create mode 100644 app/src-tauri/src/aws/tofu/tests.rs diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 08fed18..bee73dc 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -7289,12 +7289,14 @@ dependencies = [ "chrono", "dirs", "fern", + "flate2", "futures-util", "gix", "hcl-rs", "iana-time-zone", "include_dir", "keyring", + "libc", "local-ip-address", "log", "rand 0.9.5", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index b2b29b9..58b2bc2 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -28,6 +28,9 @@ dirs = "6" log = "0.4" fern = { version = "0.7", features = ["date-based"] } tar = "0.4" +# Unpacks the pinned OpenTofu release archive (aws/tofu.rs); every platform's +# archive is a .tar.gz. The version already in the tree. +flate2 = "1" include_dir = "0.7" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] } iana-time-zone = "0.1" @@ -77,6 +80,12 @@ aws-smithy-async = { version = "1.3.1", default-features = false, features = ["r # Linux only: WebKitGTK refuses getUserMedia until the embedder enables media # streams and answers the permission request (see src/linux_media.rs). Same # version tauri/wry already resolve, so this adds no second copy. +# aws/tofu.rs: `setsid` for the detached OpenTofu runner, SIGINT to its +# process group on Cancel, and a liveness probe for its PID. Already in the +# tree through tokio and friends. +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [target.'cfg(target_os = "linux")'.dependencies] webkit2gtk = "2.0.2" diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index 27134e3..de50ec2 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -12,6 +12,7 @@ pub mod permissions; pub mod policies; #[cfg(test)] mod policy_eval; +pub mod tofu; pub mod tofu_modules; pub use connection::{sdk_config, sdk_config_for_keys}; diff --git a/app/src-tauri/src/aws/tofu.rs b/app/src-tauri/src/aws/tofu.rs new file mode 100644 index 0000000..6977ed9 --- /dev/null +++ b/app/src-tauri/src/aws/tofu.rs @@ -0,0 +1,938 @@ +//! The OpenTofu runner: a pinned, hash-verified `tofu` binary, the work +//! folder a deployment is applied from, the child environment it runs in, +//! and a detached `init` + `apply|destroy` whose log the app follows (D10). +//! +//! **Detached.** An apply must survive the app closing, so it is not a child +//! the app waits on. The app re-executes *its own binary* with the hidden +//! `--tofu-runner ` argument (`main.rs` hands that to +//! [`runner_main`] before anything Tauri or GTK starts). The runner runs +//! `tofu init` then `tofu apply|destroy`, appending both to `apply.log`, and +//! writes the exit code to `apply.exit` — always, even when init fails or +//! `tofu` cannot be started. No shell is involved anywhere. On Unix the +//! runner is its own session (`setsid`), so its process group is the +//! Cancel target; on Windows it is `DETACHED_PROCESS | +//! CREATE_NEW_PROCESS_GROUP`. +//! +//! **Credentials** reach `tofu` only through the environment the runner is +//! started with ([`child_env`]), never argv, files or the log. The runner +//! itself writes only its own status lines to the log, never its +//! environment. + +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Stdio}; +use std::time::Duration; + +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use super::error::AwsError; +use super::tofu_modules::{module_files, state_key, Deployment, ModuleKind}; +use crate::models::host_connection::{ + validate_supported_region, AwsAuth, HostConnection, HostKind, +}; +use crate::storage::secure::AwsKeys; + +/// The OpenTofu release the app downloads (the modules' `required_version` +/// admits it; Task 1 verified the modules against it). +pub const TOFU_VERSION: &str = "1.13.9"; + +#[cfg(windows)] +const TOFU_BIN: &str = "tofu.exe"; +#[cfg(not(windows))] +const TOFU_BIN: &str = "tofu"; + +/// `(os, arch, sha256)` of `tofu___.tar.gz`, copied from the +/// release's `tofu_1.13.9_SHA256SUMS`, whose detached GPG signature +/// (`tofu_1.13.9_SHA256SUMS.gpgsig`) verified against OpenTofu's signing key +/// `E3E6 E43D 84CB 852E ADB0 051D 0C0A F313 E5FD 9F80` +/// (https://get.opentofu.org/opentofu.asc). Every platform uses the +/// `.tar.gz` — OpenTofu publishes one for Windows too — so one extractor +/// covers them all. To bump: take the new SUMS file, verify its signature +/// the same way, and never hash your own download. +const RELEASES: &[(&str, &str, &str)] = &[ + ( + "linux", + "amd64", + "b34b327cc1ef07178207b6f2cd1f5276537d3e5b7be316500f6dac50d4c0a857", + ), + ( + "linux", + "arm64", + "5dc196dde75b667e4806d358310ec96742774cd0993c54c1237efee5644b69b8", + ), + ( + "darwin", + "amd64", + "347e2e5a2cfe2f368829572d308b2e7c1e0486b20a1779090ff06360f7551e4f", + ), + ( + "darwin", + "arm64", + "496f4c3064b5c3bff79174b3dae4faaf65a66774eb8e2bf074f895412c5f124f", + ), + ( + "windows", + "amd64", + "e0772c2fb51e68b58f8dad47452ae965478b696cfa4c42054b1da5b7538df4ef", + ), +]; + +/// A release archive is ~30 MB; anything far past that is not one. +const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024; + +const RUNNER_FLAG: &str = "--tofu-runner"; +/// How the runner learns which `tofu` to run. A path, not a secret; the +/// runner removes it before starting `tofu`. +const TOFU_BIN_ENV: &str = "TRIPLEC_TOFU_BIN"; +const LOG_FILE: &str = "apply.log"; +const PID_FILE: &str = "apply.pid"; +const EXIT_FILE: &str = "apply.exit"; + +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +fn other(message: impl Into) -> AwsError { + AwsError::Other { + message: message.into(), + } +} + +/// The release file and its pinned SHA-256 for a platform. Accepts Rust's +/// names (`macos`, `x86_64`, `aarch64`) as well as OpenTofu's. Only the +/// platforms the app ships on are listed. +pub fn release_asset(os: &str, arch: &str) -> Option<(String, &'static str)> { + let os = match os { + "macos" | "darwin" => "darwin", + "linux" => "linux", + "windows" => "windows", + _ => return None, + }; + let arch = match arch { + "x86_64" | "amd64" => "amd64", + "aarch64" | "arm64" => "arm64", + _ => return None, + }; + RELEASES + .iter() + .find(|(o, a, _)| *o == os && *a == arch) + .map(|(o, a, sha)| (format!("tofu_{TOFU_VERSION}_{o}_{a}.tar.gz"), *sha)) +} + +pub(crate) fn sha256_file(path: &Path) -> io::Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buf = vec![0u8; 64 * 1024]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect()) +} + +/// Check `archive` against `expected_sha256` and only then extract the +/// `tofu` binary from it into `dest` (mode 755). Nothing else in the archive +/// is written. On a mismatch the archive is deleted and nothing is extracted. +pub fn verify_and_extract( + archive: &Path, + expected_sha256: &str, + dest: &Path, +) -> Result { + let actual = sha256_file(archive) + .map_err(|e| other(format!("Could not read the OpenTofu download: {e}")))?; + if !actual.eq_ignore_ascii_case(expected_sha256) { + let _ = fs::remove_file(archive); + return Err(other(format!( + "The OpenTofu download did not match its pinned SHA-256 (expected {expected_sha256}, got {actual}); it was deleted" + ))); + } + + let file = File::open(archive) + .map_err(|e| other(format!("Could not open the OpenTofu download: {e}")))?; + let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(file)); + let entries = tar + .entries() + .map_err(|e| other(format!("The OpenTofu archive is unreadable: {e}")))?; + for entry in entries { + let mut entry = + entry.map_err(|e| other(format!("The OpenTofu archive is unreadable: {e}")))?; + let is_bin = entry + .path() + .map(|p| { + p.components() + .map(|c| c.as_os_str()) + .eq([std::ffi::OsStr::new(TOFU_BIN)]) + || p == Path::new(".").join(TOFU_BIN) + }) + .unwrap_or(false); + if !is_bin || !entry.header().entry_type().is_file() { + continue; + } + let tmp = dest.join(format!(".{TOFU_BIN}.{}.tmp", rand::random::())); + let result = (|| -> io::Result<()> { + let mut out = File::create(&tmp)?; + let copied = io::copy(&mut (&mut entry).take(MAX_ARCHIVE_BYTES), &mut out)?; + if copied >= MAX_ARCHIVE_BYTES { + return Err(io::Error::other("the binary is implausibly large")); + } + out.sync_all()?; + drop(out); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755))?; + } + fs::rename(&tmp, dest.join(TOFU_BIN)) + })(); + if let Err(e) = result { + let _ = fs::remove_file(&tmp); + return Err(other(format!("Could not extract OpenTofu: {e}"))); + } + return Ok(dest.join(TOFU_BIN)); + } + Err(other(format!("The OpenTofu archive has no {TOFU_BIN}"))) +} + +/// `/tofu//tofu[.exe]`, downloaded and verified on first use. +/// A binary already there is reused only if it still hashes to what was +/// recorded when it was extracted from a verified archive. +#[allow(dead_code)] // used by phase-4 commands +pub async fn ensure_tofu(root: &Path) -> Result { + let (file, sha) = release_asset(std::env::consts::OS, std::env::consts::ARCH) + .ok_or_else(|| other("OpenTofu isn't available for this platform"))?; + let dir = root.join("tofu").join(TOFU_VERSION); + fs::create_dir_all(&dir) + .map_err(|e| other(format!("Could not create {}: {e}", dir.display())))?; + let bin = dir.join(TOFU_BIN); + let marker = dir.join(format!("{TOFU_BIN}.sha256")); + + let (b, m) = (bin.clone(), marker.clone()); + let reusable = tokio::task::spawn_blocking(move || { + let recorded = fs::read_to_string(&m).ok()?; + let actual = sha256_file(&b).ok()?; + Some(recorded.trim() == actual) + }) + .await + .ok() + .flatten() + .unwrap_or(false); + if reusable { + return Ok(bin); + } + let _ = fs::remove_file(&bin); + let _ = fs::remove_file(&marker); + + let url = + format!("https://github.com/opentofu/opentofu/releases/download/v{TOFU_VERSION}/{file}"); + let archive = dir.join(format!("{file}.{}.part", rand::random::())); + let downloaded = download(&url, &archive).await; + if let Err(e) = downloaded { + let _ = fs::remove_file(&archive); + return Err(e); + } + let (a, d) = (archive.clone(), dir.clone()); + let extracted = tokio::task::spawn_blocking(move || { + let bin = verify_and_extract(&a, sha, &d)?; + let _ = fs::remove_file(&a); + let hash = sha256_file(&bin).map_err(|e| other(format!("Could not hash OpenTofu: {e}")))?; + fs::write(d.join(format!("{TOFU_BIN}.sha256")), hash) + .map_err(|e| other(format!("Could not record the OpenTofu hash: {e}")))?; + Ok::<_, AwsError>(bin) + }) + .await + .map_err(|e| other(format!("OpenTofu extraction failed: {e}")))?; + if extracted.is_err() { + let _ = fs::remove_file(&archive); + } + extracted +} + +async fn download(url: &str, to: &Path) -> Result<(), AwsError> { + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(20)) + .timeout(Duration::from_secs(600)) + .build() + .map_err(|e| other(format!("Could not start the OpenTofu download: {e}")))?; + let mut resp = client + .get(url) + .send() + .await + .and_then(|r| r.error_for_status()) + .map_err(|e| other(format!("Could not download OpenTofu: {e}")))?; + let mut out = File::create(to) + .map_err(|e| other(format!("Could not save the OpenTofu download: {e}")))?; + let mut total = 0u64; + while let Some(chunk) = resp + .chunk() + .await + .map_err(|e| other(format!("The OpenTofu download was interrupted: {e}")))? + { + total += chunk.len() as u64; + if total > MAX_ARCHIVE_BYTES { + return Err(other("The OpenTofu download is implausibly large")); + } + out.write_all(&chunk) + .map_err(|e| other(format!("Could not save the OpenTofu download: {e}")))?; + } + out.sync_all() + .map_err(|e| other(format!("Could not save the OpenTofu download: {e}"))) +} + +/// The folder one deployment is initialised and applied from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Workdir { + pub dir: PathBuf, +} + +/// One path component of a work folder: no separators, no `.`/`..`, and +/// only characters that are also safe inside a quoted HCL string. +fn safe_segment(s: &str) -> bool { + !s.is_empty() + && s.len() <= 128 + && s != "." + && s != ".." + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// Write `kind`'s module into `/work///` with `backend.hcl` and `terraform.tfvars.json` (0600 on +/// Unix). Refuses while a run is in progress there. `.tf` files and the lock +/// file left by an older module version are removed first, so a file a later +/// version dropped can't linger and still be loaded. +#[allow(dead_code)] // used by phase-4 commands +pub fn materialize( + root: &Path, + kind: ModuleKind, + deployment: Deployment, + bucket: &str, + region: &str, + vars: &Value, +) -> Result { + if !safe_segment(bucket) { + return Err(other("Invalid state bucket name")); + } + if region.trim() != region { + return Err(other("Invalid region")); + } + let region = validate_supported_region(region).map_err(other)?; + let key = state_key(deployment); + let stem = key + .strip_suffix(".tfstate") + .ok_or_else(|| other("Invalid state key"))?; + if !stem.split('/').all(safe_segment) { + return Err(other("Invalid deployment name")); + } + let mut dir = root.join("work").join(bucket); + dir.extend(stem.split('/')); + let wd = Workdir { dir }; + if is_running(&wd) { + return Err(other("A deploy is already running for this deployment")); + } + + let io = |e: io::Error| other(format!("Could not write the work folder: {e}")); + fs::create_dir_all(&wd.dir).map_err(io)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(root.join("work"), fs::Permissions::from_mode(0o700)).map_err(io)?; + } + for entry in fs::read_dir(&wd.dir).map_err(io)? { + let entry = entry.map_err(io)?; + let name = entry.file_name().to_string_lossy().into_owned(); + let module_file = + name.ends_with(".tf") || name.ends_with(".tf.json") || name == ".terraform.lock.hcl"; + if module_file && entry.file_type().map_err(io)?.is_file() { + fs::remove_file(entry.path()).map_err(io)?; + } + } + for (name, text) in module_files(kind) { + fs::write(wd.dir.join(name), text).map_err(io)?; + } + let backend = format!( + "bucket = \"{bucket}\"\nkey = \"{key}\"\nregion = \"{region}\"\nencrypt = true\nuse_lockfile = true\n" + ); + fs::write(wd.dir.join("backend.hcl"), backend).map_err(io)?; + let json = serde_json::to_vec_pretty(vars) + .map_err(|e| other(format!("Could not encode the module variables: {e}")))?; + write_private(&wd.dir.join("terraform.tfvars.json"), &json).map_err(io)?; + Ok(wd) +} + +/// Replace `path` with a file only its owner can read (Unix). The old file +/// is removed first, since opening an existing file keeps its mode. +fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { + match fs::remove_file(path) { + Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e), + _ => {} + } + let mut opts = OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts.open(path)?; + f.write_all(bytes)?; + f.sync_all() +} + +/// What `tofu` may see of the app's own environment. An allowlist, not +/// "everything minus AWS_/TF_/TOFU_": anything else the app was started with +/// (`LD_PRELOAD`, tokens, the AppImage's library paths) has no business in +/// an apply. +/// - `PATH`: `credential_process`/SSO helpers and the AppImage runtime's +/// `fusermount`. +/// - `HOME` / `USERPROFILE`: where the AWS provider finds `~/.aws` (config, +/// credentials, SSO cache) for a profile connection. +/// - `TMPDIR` / `TMP` / `TEMP`: scratch space for tofu and its provider. +/// - `SystemRoot`: Go's networking and crypto fail on Windows without it. +/// - The proxy variables and `SSL_CERT_FILE` / `SSL_CERT_DIR`: a corporate +/// network needs them for the provider download and every AWS call. +const INHERITED: &[&str] = &[ + "PATH", + "HOME", + "USERPROFILE", + "TMPDIR", + "TMP", + "TEMP", + "SystemRoot", + "HTTPS_PROXY", + "HTTP_PROXY", + "NO_PROXY", + "https_proxy", + "http_proxy", + "no_proxy", + "SSL_CERT_FILE", + "SSL_CERT_DIR", +]; + +fn inherited(name: &str) -> bool { + INHERITED.iter().any(|a| { + if cfg!(windows) { + a.eq_ignore_ascii_case(name) + } else { + *a == name + } + }) +} + +/// The complete environment for the runner and the `tofu` it starts: the +/// [`INHERITED`] variables from `parent` (the real caller passes +/// `std::env::vars()`), the connection's own credentials — `AWS_PROFILE` for +/// a profile, the three key variables for a keys connection — and the +/// app's `TF_*` / `CHECKPOINT_*` settings. Nothing else AWS-, TF- or +/// TOFU-shaped. A profile uses the default `~/.aws` paths, as +/// `aws/connection.rs` does. +#[allow(dead_code)] // used by phase-4 commands +pub fn child_env( + conn: &HostConnection, + keys: Option<&AwsKeys>, + plugin_cache: &Path, + parent: impl IntoIterator, +) -> Vec<(String, String)> { + let mut env: Vec<(String, String)> = parent.into_iter().filter(|(k, _)| inherited(k)).collect(); + let HostKind::Aws { auth, .. } = &conn.kind; + match auth { + AwsAuth::Profile { name } => env.push(("AWS_PROFILE".into(), name.clone())), + AwsAuth::Keys { .. } => { + if let Some(keys) = keys { + env.push(("AWS_ACCESS_KEY_ID".into(), keys.access_key_id.clone())); + env.push(( + "AWS_SECRET_ACCESS_KEY".into(), + keys.secret_access_key.clone(), + )); + if let Some(token) = &keys.session_token { + env.push(("AWS_SESSION_TOKEN".into(), token.clone())); + } + } + } + } + env.push(( + "TF_PLUGIN_CACHE_DIR".into(), + plugin_cache.to_string_lossy().into_owned(), + )); + env.push(("TF_IN_AUTOMATION".into(), "1".into())); + env.push(("TF_INPUT".into(), "0".into())); + env.push(("CHECKPOINT_DISABLE".into(), "1".into())); + env +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TofuOp { + Apply, + Destroy, +} + +impl TofuOp { + fn as_arg(self) -> &'static str { + match self { + TofuOp::Apply => "apply", + TofuOp::Destroy => "destroy", + } + } + + fn parse(s: &str) -> Option { + match s { + "apply" => Some(TofuOp::Apply), + "destroy" => Some(TofuOp::Destroy), + _ => None, + } + } +} + +/// The program to re-execute as the runner. Inside an AppImage that is the +/// AppImage file, not `current_exe()`: the latter lives on the AppImage's +/// FUSE mount, which is unmounted when the app exits — under a runner that +/// is meant to outlive it. +fn runner_program() -> io::Result { + #[cfg(target_os = "linux")] + if let Some(appimage) = std::env::var_os("APPIMAGE").map(PathBuf::from) { + if appimage.is_file() { + return Ok(appimage); + } + } + std::env::current_exe() +} + +fn remove_if_present(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e), + _ => Ok(()), + } +} + +/// Start `tofu init` then `apply|destroy` in a detached runner and return +/// its PID (also written to `apply.pid`). The previous run's log and exit +/// code are cleared first. `env` is the runner's entire environment +/// ([`child_env`]); credentials go nowhere else. +#[allow(dead_code)] // used by phase-4 commands +pub fn start_detached( + tofu: &Path, + wd: &Workdir, + op: TofuOp, + env: &[(String, String)], +) -> Result { + if is_running(wd) { + return Err(other("A deploy is already running for this deployment")); + } + let io = |e: io::Error| other(format!("Could not start the deploy: {e}")); + for f in [LOG_FILE, EXIT_FILE, PID_FILE] { + remove_if_present(&wd.dir.join(f)).map_err(io)?; + } + let mut cmd = Command::new(runner_program().map_err(io)?); + cmd.arg(RUNNER_FLAG) + .arg(&wd.dir) + .arg(op.as_arg()) + .env_clear() + .envs(env.iter().map(|(k, v)| (k, v))) + .env(TOFU_BIN_ENV, tofu) + .current_dir(&wd.dir) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // SAFETY: setsid is async-signal-safe and touches no Rust state. + unsafe { + cmd.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + } + let mut child = cmd.spawn().map_err(io)?; + let pid = child.id(); + let tmp = wd.dir.join(format!("{PID_FILE}.tmp")); + fs::write(&tmp, pid.to_string()) + .and_then(|_| fs::rename(&tmp, wd.dir.join(PID_FILE))) + .map_err(io)?; + // Reap it if it finishes while the app is still open (no zombie). + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(pid) +} + +fn read_pid(wd: &Workdir) -> Option { + fs::read_to_string(wd.dir.join(PID_FILE)) + .ok()? + .trim() + .parse() + .ok() + .filter(|p| *p > 0) +} + +#[cfg(unix)] +fn pid_alive(pid: u32) -> bool { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return false; + }; + // SAFETY: signal 0 only checks that the process exists. + let r = unsafe { libc::kill(pid, 0) }; + r == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(windows)] +fn pid_alive(pid: u32) -> bool { + use std::os::windows::process::CommandExt; + // No Win32 bindings in the tree; tasklist is always present. + Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"]) + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).contains(&format!("\"{pid}\""))) + .unwrap_or(false) +} + +/// A run is in progress: `apply.pid` names a live process and `apply.exit` +/// doesn't exist yet. (A PID reused after a reboot can read as live; the +/// state lock is the other half of "a deploy is in progress".) +pub fn is_running(wd: &Workdir) -> bool { + !wd.dir.join(EXIT_FILE).exists() && read_pid(wd).is_some_and(pid_alive) +} + +/// Cancel: SIGINT to the runner's process group, so `tofu` stops gracefully +/// and saves state. The runner survives it and still writes `apply.exit`. +#[allow(dead_code)] // used by phase-4 commands +pub fn interrupt(wd: &Workdir) -> Result<(), AwsError> { + if !is_running(wd) { + return Err(other("No deploy is running for this deployment")); + } + #[cfg(unix)] + { + let pid = read_pid(wd).ok_or_else(|| other("No deploy is running"))?; + let pgid = libc::pid_t::try_from(pid).map_err(|_| other("Invalid runner PID"))?; + // SAFETY: plain kill(2) on the runner's own process group (setsid + // made its PID the group id). + if unsafe { libc::kill(-pgid, libc::SIGINT) } != 0 { + return Err(other(format!( + "Could not interrupt the deploy: {}", + io::Error::last_os_error() + ))); + } + Ok(()) + } + #[cfg(windows)] + { + Err(other( + "Cancelling a running deploy isn't supported on Windows yet", + )) + } +} + +/// One line of `apply.log`. +#[derive(Serialize, Clone, Debug, PartialEq)] +pub struct TofuEvent { + pub ts: String, + pub level: String, + /// The `-json` message's `type`; `"log"` for plain text (init, runner). + pub kind: String, + pub resource: Option, + pub action: Option, + pub message: String, +} + +/// A log line can be long (a diagnostic's detail); the UI needs a readable +/// message, not a megabyte. +const MAX_MESSAGE_CHARS: usize = 4000; + +fn capped(s: &str) -> String { + match s.char_indices().nth(MAX_MESSAGE_CHARS) { + Some((i, _)) => format!("{}…", &s[..i]), + None => s.to_string(), + } +} + +/// A `tofu -json` line as an event; any other non-blank line becomes a +/// `"log"` event. `apply_errored` is reported at level `error` (tofu itself +/// logs it as `info`), and a diagnostic carries its detail. +pub fn parse_log_line(line: &str) -> Option { + let line = line.trim_end_matches(['\r', '\n']); + if line.trim().is_empty() { + return None; + } + let plain = || TofuEvent { + ts: String::new(), + level: "info".into(), + kind: "log".into(), + resource: None, + action: None, + message: capped(line), + }; + let Ok(Value::Object(m)) = serde_json::from_str::(line.trim()) else { + return Some(plain()); + }; + let s = |v: Option<&Value>| v.and_then(Value::as_str).map(str::to_string); + let kind = s(m.get("type")).unwrap_or_else(|| "log".into()); + let mut level = s(m.get("@level")).unwrap_or_else(|| "info".into()); + let mut message = s(m.get("@message")).unwrap_or_default(); + let hook = m.get("hook").or_else(|| m.get("change")); + let mut resource = s(hook.and_then(|h| h.pointer("/resource/addr"))); + let action = s(hook.and_then(|h| h.get("action"))); + if kind == "apply_errored" { + level = "error".into(); + } + if let Some(d) = m.get("diagnostic") { + if let Some(sev) = s(d.get("severity")) { + level = sev; + } + resource = resource.or_else(|| s(d.get("address"))); + if let Some(detail) = s(d.get("detail")).filter(|d| !d.trim().is_empty()) { + message = format!("{message}: {}", detail.trim()); + } + } + Some(TofuEvent { + ts: s(m.get("@timestamp")).unwrap_or_default(), + level, + kind, + resource, + action, + message: capped(&message), + }) +} + +#[derive(Serialize, Clone, Debug, PartialEq)] +pub struct TofuOutcome { + pub ok: bool, + pub exit_code: i32, + /// The last change summary ("Apply complete! …") on success; the last + /// error otherwise. + pub summary: Option, +} + +fn read_new(path: &Path, offset: u64) -> io::Result> { + let mut f = match File::open(path) { + Ok(f) => f, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + f.seek(SeekFrom::Start(offset))?; + let mut buf = Vec::new(); + f.read_to_end(&mut buf)?; + Ok(buf) +} + +/// Follow `apply.log` from byte 0, handing each complete line's event to +/// `on_events` exactly once, until `apply.exit` appears. Reattaching after a +/// restart is the same call. If the runner dies without an exit code (killed, +/// power loss) the outcome is a failure rather than waiting forever. +#[allow(dead_code)] // used by phase-4 commands +pub async fn follow( + wd: &Workdir, + poll: Duration, + mut on_events: impl FnMut(&[TofuEvent]), +) -> Result { + let log = wd.dir.join(LOG_FILE); + let exit = wd.dir.join(EXIT_FILE); + let mut offset = 0u64; + let mut pending: Vec = Vec::new(); + let mut last_summary: Option = None; + let mut last_error: Option = None; + loop { + // Checked before reading: the runner writes the exit code only after + // tofu has written its last line, so this read sees everything. + let finished = exit.exists(); + let runner_gone = + !finished && read_pid(wd).is_some_and(|p| !pid_alive(p)) && !exit.exists(); + let new = read_new(&log, offset) + .map_err(|e| other(format!("Could not read the deploy log: {e}")))?; + offset += new.len() as u64; + pending.extend_from_slice(&new); + + let mut events = Vec::new(); + while let Some(pos) = pending.iter().position(|b| *b == b'\n') { + let line: Vec = pending.drain(..=pos).collect(); + events.extend(parse_log_line(&String::from_utf8_lossy(&line))); + } + if (finished || runner_gone) && !pending.is_empty() { + events.extend(parse_log_line(&String::from_utf8_lossy(&pending))); + pending.clear(); + } + for e in &events { + if e.kind == "change_summary" { + last_summary = Some(e.message.clone()); + } + if e.level == "error" { + last_error = Some(e.message.clone()); + } + } + if !events.is_empty() { + on_events(&events); + } + + if finished { + let text = fs::read_to_string(&exit) + .map_err(|e| other(format!("Could not read the deploy's exit code: {e}")))?; + let exit_code = text.trim().parse::().unwrap_or(-1); + let ok = exit_code == 0; + let summary = if ok { + last_summary + } else { + last_error.or(last_summary) + }; + return Ok(TofuOutcome { + ok, + exit_code, + summary, + }); + } + if runner_gone { + return Ok(TofuOutcome { + ok: false, + exit_code: -1, + summary: Some( + "The deploy stopped without reporting a result; the state lock may still be held" + .into(), + ), + }); + } + tokio::time::sleep(poll).await; + } +} + +// --------------------------------------------------------------------------- +// The runner (the app binary re-executed with `--tofu-runner`). + +/// `main.rs` calls this first. `None` unless the arguments are +/// `--tofu-runner `; otherwise the runner's exit +/// code, for the process to exit with. +pub fn runner_main(args: impl IntoIterator) -> Option { + let args: Vec = args.into_iter().skip(1).take(4).collect(); + if args.first().map(|a| a != RUNNER_FLAG).unwrap_or(true) { + return None; + } + let (Some(dir), Some(op), None) = (args.get(1), args.get(2), args.get(3)) else { + return Some(2); + }; + let Some(op) = op.to_str().and_then(TofuOp::parse) else { + return Some(2); + }; + let dir = PathBuf::from(dir); + if !dir.is_dir() { + return Some(2); + } + let code = match OpenOptions::new() + .create(true) + .append(true) + .open(dir.join(LOG_FILE)) + { + Ok(mut log) => run_steps(&dir, op, &mut log), + Err(_) => 1, + }; + write_exit(&dir, code); + Some(code) +} + +#[cfg(unix)] +extern "C" fn ignore_signal(_: libc::c_int) {} + +fn run_steps(dir: &Path, op: TofuOp, log: &mut File) -> i32 { + let Some(tofu) = std::env::var_os(TOFU_BIN_ENV) else { + let _ = writeln!(log, "triple-c: no OpenTofu binary was given to the runner"); + return 1; + }; + // Cancel signals the whole group. The runner must outlive it to record + // the exit code, so it catches SIGINT with a handler that does nothing. + // A handler, not SIG_IGN: an ignored disposition would be inherited + // through exec, and tofu would ignore the Cancel too. + #[cfg(unix)] + // SAFETY: installs an empty, async-signal-safe handler. + unsafe { + libc::signal( + libc::SIGINT, + ignore_signal as extern "C" fn(libc::c_int) as libc::sighandler_t, + ); + } + let init = [ + "init", + "-input=false", + "-no-color", + "-backend-config=backend.hcl", + ]; + let code = step(Path::new(&tofu), dir, &init, log); + if code != 0 { + let _ = writeln!(log, "triple-c: tofu init failed (exit {code})"); + return code; + } + let run = [op.as_arg(), "-auto-approve", "-json", "-input=false"]; + let code = step(Path::new(&tofu), dir, &run, log); + let _ = writeln!(log, "triple-c: tofu {} finished (exit {code})", op.as_arg()); + code +} + +fn step(tofu: &Path, dir: &Path, args: &[&str], log: &mut File) -> i32 { + let _ = writeln!(log, "triple-c: running tofu {}", args[0]); + let (Ok(out), Ok(err)) = (log.try_clone(), log.try_clone()) else { + return 1; + }; + let mut cmd = Command::new(tofu); + cmd.args(args) + .current_dir(dir) + .env_remove(TOFU_BIN_ENV) + .stdin(Stdio::null()) + .stdout(out) + .stderr(err); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(CREATE_NO_WINDOW); + } + match cmd.status() { + Ok(status) => exit_code(status), + Err(e) => { + let _ = writeln!(log, "triple-c: could not start OpenTofu: {e}"); + 127 + } + } +} + +fn exit_code(status: ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = status.signal() { + return 128 + sig; + } + } + 1 +} + +/// Written via a rename so a follower never reads a half-written code. +fn write_exit(dir: &Path, code: i32) { + let tmp = dir.join(format!("{EXIT_FILE}.tmp")); + if fs::write(&tmp, format!("{code}\n")).is_ok() && fs::rename(&tmp, dir.join(EXIT_FILE)).is_ok() + { + return; + } + let _ = fs::write(dir.join(EXIT_FILE), format!("{code}\n")); +} + +#[cfg(test)] +mod tests; diff --git a/app/src-tauri/src/aws/tofu/tests.rs b/app/src-tauri/src/aws/tofu/tests.rs new file mode 100644 index 0000000..d6b1d1a --- /dev/null +++ b/app/src-tauri/src/aws/tofu/tests.rs @@ -0,0 +1,442 @@ +use std::collections::BTreeMap; +use std::io::Write; +use std::time::Duration; + +use super::*; +use crate::aws::tofu_modules::{boundary_vars, module_files, Deployment, ModuleKind}; +use crate::models::host_connection::{AwsAuth, HostConnection, HostKind}; +use crate::storage::secure::AwsKeys; + +fn profile_conn(name: &str) -> HostConnection { + HostConnection { + id: "c1".into(), + name: "Test".into(), + kind: HostKind::Aws { + auth: AwsAuth::Profile { name: name.into() }, + default_region: "us-east-1".into(), + }, + } +} + +fn keys_conn() -> HostConnection { + HostConnection { + id: "c2".into(), + name: "Keys".into(), + kind: HostKind::Aws { + auth: AwsAuth::Keys { + keyring_ref: "c2".into(), + }, + default_region: "us-east-1".into(), + }, + } +} + +fn parent_env() -> Vec<(String, String)> { + [ + ("PATH", "/usr/bin:/bin"), + ("HOME", "/home/u"), + ("AWS_ENDPOINT_URL", "http://evil.example"), + ("AWS_ACCESS_KEY_ID", "AKIAPARENTNOTREAL"), + ("AWS_PROFILE", "parent-profile"), + ("TF_LOG", "TRACE"), + ("TF_PLUGIN_CACHE_DIR", "/parent/cache"), + ("TOFU_X", "1"), + ("CHECKPOINT_DISABLE", "0"), + ("GITHUB_TOKEN", "parent-token-not-real"), + ("LD_PRELOAD", "/tmp/evil.so"), + ("HTTPS_PROXY", "http://proxy.corp:3128"), + ] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +#[test] +fn a_download_with_the_wrong_hash_is_deleted_and_refused() { + let tmp = tempfile::tempdir().unwrap(); + let archive = tmp.path().join("tofu_fake.tar.gz"); + std::fs::write(&archive, b"not really an archive").unwrap(); + let dest = tmp.path().join("out"); + std::fs::create_dir_all(&dest).unwrap(); + let wrong = "0".repeat(64); + + let result = verify_and_extract(&archive, &wrong, &dest); + + assert!(result.is_err()); + assert!(!archive.exists(), "a mismatched archive must be deleted"); + assert_eq!( + std::fs::read_dir(&dest).unwrap().count(), + 0, + "nothing extracted" + ); +} + +#[test] +fn a_verified_archive_yields_only_the_binary() { + let tmp = tempfile::tempdir().unwrap(); + let archive = tmp.path().join("tofu.tar.gz"); + { + let gz = flate2::write::GzEncoder::new( + std::fs::File::create(&archive).unwrap(), + flate2::Compression::fast(), + ); + let mut tar = tar::Builder::new(gz); + for (name, body) in [("LICENSE", &b"mpl"[..]), (TOFU_BIN, &b"#!fake"[..])] { + let mut h = tar::Header::new_gnu(); + h.set_size(body.len() as u64); + h.set_mode(0o644); + h.set_cksum(); + tar.append_data(&mut h, name, body).unwrap(); + } + tar.into_inner().unwrap().finish().unwrap(); + } + let sha = sha256_file(&archive).unwrap(); + let dest = tmp.path().join("out"); + std::fs::create_dir_all(&dest).unwrap(); + + let bin = verify_and_extract(&archive, &sha, &dest).unwrap(); + + assert_eq!(bin, dest.join(TOFU_BIN)); + assert_eq!(std::fs::read(&bin).unwrap(), b"#!fake"); + assert!(!dest.join("LICENSE").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&bin).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o755); + } +} + +#[test] +fn the_child_environment_carries_nothing_inherited() { + let cache = std::path::Path::new("/app/plugin-cache"); + + let env: BTreeMap = child_env(&profile_conn("work"), None, cache, parent_env()) + .into_iter() + .collect(); + + for gone in [ + "AWS_ENDPOINT_URL", + "AWS_ACCESS_KEY_ID", + "TF_LOG", + "TOFU_X", + "GITHUB_TOKEN", + "LD_PRELOAD", + ] { + assert!(!env.contains_key(gone), "{gone} leaked: {env:?}"); + } + assert_eq!(env.get("AWS_PROFILE").map(String::as_str), Some("work")); + assert_eq!( + env.get("TF_PLUGIN_CACHE_DIR").map(String::as_str), + Some("/app/plugin-cache") + ); + assert_eq!(env.get("TF_IN_AUTOMATION").map(String::as_str), Some("1")); + assert_eq!(env.get("TF_INPUT").map(String::as_str), Some("0")); + assert_eq!(env.get("CHECKPOINT_DISABLE").map(String::as_str), Some("1")); + assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin:/bin")); + assert_eq!(env.get("HOME").map(String::as_str), Some("/home/u")); + assert_eq!( + env.get("HTTPS_PROXY").map(String::as_str), + Some("http://proxy.corp:3128") + ); + let aws: Vec<_> = env.keys().filter(|k| k.starts_with("AWS_")).collect(); + assert_eq!(aws, ["AWS_PROFILE"]); + + let keys = AwsKeys { + access_key_id: "AKIACONNNOTREAL".into(), + secret_access_key: "conn-secret-not-real".into(), + session_token: Some("conn-token-not-real".into()), + }; + let env: BTreeMap = child_env(&keys_conn(), Some(&keys), cache, parent_env()) + .into_iter() + .collect(); + assert_eq!( + env.get("AWS_ACCESS_KEY_ID").map(String::as_str), + Some("AKIACONNNOTREAL") + ); + assert_eq!( + env.get("AWS_SECRET_ACCESS_KEY").map(String::as_str), + Some("conn-secret-not-real") + ); + assert_eq!( + env.get("AWS_SESSION_TOKEN").map(String::as_str), + Some("conn-token-not-real") + ); + let aws: Vec<_> = env.keys().filter(|k| k.starts_with("AWS_")).collect(); + assert_eq!( + aws, + [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN" + ] + ); +} + +#[test] +fn materialize_writes_backend_and_vars_privately() { + let tmp = tempfile::tempdir().unwrap(); + let vars = boundary_vars("us-east-1"); + + let wd = materialize( + tmp.path(), + ModuleKind::PermissionsBoundary, + Deployment::Boundary, + "triple-c-tfstate-123456789012-us-east-1", + "us-east-1", + &vars, + ) + .unwrap(); + + assert_eq!( + wd.dir, + tmp.path() + .join("work/triple-c-tfstate-123456789012-us-east-1/boundary/terraform") + ); + let backend = std::fs::read_to_string(wd.dir.join("backend.hcl")).unwrap(); + assert!( + backend.contains(r#"bucket = "triple-c-tfstate-123456789012-us-east-1""#), + "{backend}" + ); + assert!( + backend.contains(r#"key = "boundary/terraform.tfstate""#), + "{backend}" + ); + assert!(backend.contains(r#"region = "us-east-1""#), "{backend}"); + assert!(backend.contains("use_lockfile = true"), "{backend}"); + assert!(backend.contains("encrypt = true"), "{backend}"); + + let tfvars = wd.dir.join("terraform.tfvars.json"); + let written: serde_json::Value = + serde_json::from_slice(&std::fs::read(&tfvars).unwrap()).unwrap(); + assert_eq!(written, vars); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&tfvars).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + for (name, text) in module_files(ModuleKind::PermissionsBoundary) { + assert_eq!( + std::fs::read(wd.dir.join(name)).unwrap(), + text.as_bytes(), + "{name}" + ); + } +} + +#[test] +fn materialize_refuses_names_that_leave_the_work_folder() { + let tmp = tempfile::tempdir().unwrap(); + let vars = serde_json::json!({}); + for (bucket, region) in [ + ("../x", "us-east-1"), + ("b", "us-east-1\"\n"), + ("", "us-east-1"), + ] { + assert!(materialize( + tmp.path(), + ModuleKind::PermissionsBoundary, + Deployment::Boundary, + bucket, + region, + &vars + ) + .is_err()); + } + assert!(materialize( + tmp.path(), + ModuleKind::Environment, + Deployment::Environment(".."), + "b", + "us-east-1", + &vars + ) + .is_err()); +} + +fn append(path: &std::path::Path, lines: &[String]) { + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + for l in lines { + writeln!(f, "{l}").unwrap(); + } +} + +fn json_line(i: usize) -> String { + format!( + r#"{{"@level":"info","@message":"event {i}","@module":"tofu.ui","@timestamp":"2026-11-14T00:00:0{i}Z","type":"apply_progress"}}"# + ) +} + +#[tokio::test] +async fn following_an_existing_log_reports_each_event_once() { + let tmp = tempfile::tempdir().unwrap(); + let wd = Workdir { + dir: tmp.path().to_path_buf(), + }; + let log = wd.dir.join("apply.log"); + append(&log, &(0..3).map(json_line).collect::>()); + + let dir = wd.dir.clone(); + let writer = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(60)).await; + append( + &dir.join("apply.log"), + &(3..5).map(json_line).collect::>(), + ); + tokio::time::sleep(Duration::from_millis(60)).await; + std::fs::write(dir.join("apply.exit"), "0").unwrap(); + }); + + let mut seen = Vec::new(); + let outcome = follow(&wd, Duration::from_millis(10), |events| { + seen.extend(events.iter().map(|e| e.message.clone())) + }) + .await + .unwrap(); + writer.await.unwrap(); + + assert_eq!( + seen, + (0..5).map(|i| format!("event {i}")).collect::>() + ); + assert!(outcome.ok); + assert_eq!(outcome.exit_code, 0); +} + +#[tokio::test] +async fn a_failed_run_reports_its_exit_code_and_last_error() { + let tmp = tempfile::tempdir().unwrap(); + let wd = Workdir { + dir: tmp.path().to_path_buf(), + }; + append(&wd.dir.join("apply.log"), &[DIAGNOSTIC.to_string()]); + std::fs::write(wd.dir.join("apply.exit"), "1\n").unwrap(); + + let outcome = follow(&wd, Duration::from_millis(10), |_| {}) + .await + .unwrap(); + + assert!(!outcome.ok); + assert_eq!(outcome.exit_code, 1); + assert!(outcome + .summary + .unwrap() + .contains("local-exec provisioner error")); +} + +/// Real `tofu apply -json` output (OpenTofu 1.13.9, a failing `local-exec`). +const APPLY_ERRORED: &str = r#"{"@level":"info","@message":"terraform_data.boom: Creation errored after 0s","@module":"tofu.ui","@timestamp":"2026-11-14T21:39:42.012032-08:00","hook":{"resource":{"addr":"terraform_data.boom","module":"","resource":"terraform_data.boom","implied_provider":"terraform","resource_type":"terraform_data","resource_name":"boom","resource_key":null},"action":"create","elapsed_seconds":0},"type":"apply_errored"}"#; +const DIAGNOSTIC: &str = r#"{"@level":"error","@message":"Error: local-exec provisioner error","@module":"tofu.ui","@timestamp":"2026-11-14T21:39:42.020787-08:00","diagnostic":{"severity":"error","summary":"local-exec provisioner error","detail":"Error running command 'false': exit status 1. Output: ","address":"terraform_data.boom","range":{"filename":"main.tf","start":{"line":2,"column":28,"byte":62},"end":{"line":2,"column":29,"byte":63}},"snippet":{"context":"resource \"terraform_data\" \"boom\"","code":" provisioner \"local-exec\" {","start_line":2,"highlight_start_offset":27,"highlight_end_offset":28,"values":[]}},"type":"diagnostic"}"#; + +#[test] +fn apply_errored_and_diagnostics_surface_their_message() { + let e = parse_log_line(APPLY_ERRORED).unwrap(); + assert_eq!(e.kind, "apply_errored"); + assert_eq!(e.level, "error"); + assert_eq!(e.resource.as_deref(), Some("terraform_data.boom")); + assert_eq!(e.action.as_deref(), Some("create")); + assert_eq!(e.message, "terraform_data.boom: Creation errored after 0s"); + assert_eq!(e.ts, "2026-11-14T21:39:42.012032-08:00"); + + let d = parse_log_line(DIAGNOSTIC).unwrap(); + assert_eq!(d.kind, "diagnostic"); + assert_eq!(d.level, "error"); + assert_eq!(d.resource.as_deref(), Some("terraform_data.boom")); + assert!( + d.message.contains("local-exec provisioner error"), + "{}", + d.message + ); + assert!(d.message.contains("exit status 1"), "{}", d.message); + + let plain = parse_log_line("Initializing the backend...").unwrap(); + assert_eq!(plain.kind, "log"); + assert_eq!(plain.message, "Initializing the backend..."); + assert!(parse_log_line(" ").is_none()); +} + +#[test] +fn release_assets_exist_for_every_shipped_platform() { + for (os, arch) in [ + ("linux", "x86_64"), + ("linux", "aarch64"), + ("macos", "x86_64"), + ("macos", "aarch64"), + ("windows", "x86_64"), + ] { + let (file, sha) = release_asset(os, arch).unwrap_or_else(|| panic!("{os}/{arch}")); + assert!(file.starts_with(&format!("tofu_{TOFU_VERSION}_")), "{file}"); + assert!(file.ends_with(".tar.gz"), "{file}"); + assert_eq!(sha.len(), 64, "{file}"); + assert!( + sha.bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()), + "{file}" + ); + } + assert_eq!(TOFU_VERSION, "1.13.9"); + assert!(release_asset(std::env::consts::OS, std::env::consts::ARCH).is_some()); + assert!(release_asset("freebsd", "x86_64").is_none()); +} + +#[test] +fn no_run_is_in_progress_without_a_live_pid() { + let tmp = tempfile::tempdir().unwrap(); + let wd = Workdir { + dir: tmp.path().to_path_buf(), + }; + assert!(!is_running(&wd)); + std::fs::write(wd.dir.join("apply.pid"), std::process::id().to_string()).unwrap(); + assert!(is_running(&wd), "this test process is alive"); + std::fs::write(wd.dir.join("apply.exit"), "0").unwrap(); + assert!(!is_running(&wd), "an exit code means the run is over"); +} + +/// Downloads OpenTofu (network; no AWS): `cargo test aws::tofu::tests::smoke +/// -- --ignored --nocapture`. Fetches the pinned release into a temp root, +/// reuses it on a second call, materializes the boundary module and runs +/// `init -backend=false` + `validate` through that binary. +#[tokio::test] +#[ignore] +async fn smoke_the_downloaded_tofu_validates_the_boundary_module() { + let root = tempfile::tempdir().unwrap(); + let tofu = ensure_tofu(root.path()).await.unwrap(); + assert_eq!(ensure_tofu(root.path()).await.unwrap(), tofu, "reused"); + let wd = materialize( + root.path(), + ModuleKind::PermissionsBoundary, + Deployment::Boundary, + "triple-c-tfstate-000000000000-us-east-1", + "us-east-1", + &boundary_vars("us-east-1"), + ) + .unwrap(); + let cache = root.path().join("plugin-cache"); + std::fs::create_dir_all(&cache).unwrap(); + let env = child_env(&profile_conn("none"), None, &cache, std::env::vars()); + for args in [ + &["version"][..], + &["init", "-backend=false", "-input=false", "-no-color"], + &["validate", "-no-color"], + ] { + let out = std::process::Command::new(&tofu) + .args(args) + .current_dir(&wd.dir) + .env_clear() + .envs(env.iter().map(|(k, v)| (k, v))) + .output() + .unwrap(); + println!( + "$ tofu {}\n{}{}", + args.join(" "), + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!(out.status.success(), "tofu {args:?} failed"); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 89f59fb..3c12540 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -211,6 +211,13 @@ where } } +/// The detached OpenTofu runner (`aws::tofu`): `Some(exit code)` when this +/// process was started as one, `None` for a normal launch. `main` asks +/// before anything Tauri or GTK starts, so a runner never opens a window. +pub fn tofu_runner_main() -> Option { + aws::tofu::runner_main(std::env::args_os()) +} + pub fn run() { logging::init(); diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index b345fde..ace5851 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -144,6 +144,12 @@ mod tests { } fn main() { + // A detached OpenTofu apply re-executes this binary with `--tofu-runner` + // (aws/tofu.rs). It must not get anywhere near Tauri, GTK or a window. + if let Some(code) = triple_c_lib::tofu_runner_main() { + std::process::exit(code); + } + // Before *any* `std::env::set_var` — `url_open` hands a child process the // environment this app was started with, and the workaround below is one // of the things that must not leak into it (see triple-c#34). Anything -- 2.52.0 From 9ad8b99d3c63c92115b70e2a0d383cc8c9b134a9 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 22:00:41 -0800 Subject: [PATCH 10/24] fix(aws): runner liveness by lock, Cancel via the runner, clean tofu env The runner holds an exclusive lock on apply.lock for its life and writes its own PID, so a reused PID is never a running deploy; start_detached waits for the lock (or kills the spawn). Cancel signals the runner alone, which forwards SIGINT to tofu, so an AppImage's FUSE server in the same group is never hit. tofu gets exactly child_env's variables with any AppImage launcher changes undone, an app-owned CLI config, endpoint URLs from AWS config ignored, and no AWS config files for keys connections. Keys connections without keys are an error; init runs with -json so its errors become the summary; Windows allowlist and AWS_CA_BUNDLE added. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/tofu.rs | 486 +++++++++++++++++++++------- app/src-tauri/src/aws/tofu/tests.rs | 410 +++++++++++++++++++++-- app/src-tauri/src/url_open.rs | 9 +- 3 files changed, 759 insertions(+), 146 deletions(-) diff --git a/app/src-tauri/src/aws/tofu.rs b/app/src-tauri/src/aws/tofu.rs index 6977ed9..cf091b9 100644 --- a/app/src-tauri/src/aws/tofu.rs +++ b/app/src-tauri/src/aws/tofu.rs @@ -9,20 +9,25 @@ //! `tofu init` then `tofu apply|destroy`, appending both to `apply.log`, and //! writes the exit code to `apply.exit` — always, even when init fails or //! `tofu` cannot be started. No shell is involved anywhere. On Unix the -//! runner is its own session (`setsid`), so its process group is the -//! Cancel target; on Windows it is `DETACHED_PROCESS | -//! CREATE_NEW_PROCESS_GROUP`. +//! runner is its own session (`setsid`); on Windows it is +//! `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`. +//! +//! **Liveness** is the runner's exclusive lock on `apply.lock`, held for its +//! whole life, never "the PID in `apply.pid` exists" (PIDs are reused). +//! **Cancel** is SIGINT to the runner alone, which forwards it to `tofu`. //! //! **Credentials** reach `tofu` only through the environment the runner is //! started with ([`child_env`]), never argv, files or the log. The runner //! itself writes only its own status lines to the log, never its //! environment. +use std::collections::BTreeMap; use std::ffi::OsString; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::time::Duration; use serde::Serialize; @@ -91,6 +96,11 @@ const TOFU_BIN_ENV: &str = "TRIPLEC_TOFU_BIN"; const LOG_FILE: &str = "apply.log"; const PID_FILE: &str = "apply.pid"; const EXIT_FILE: &str = "apply.exit"; +/// Held (exclusive advisory lock) by the runner for its whole life. +const LOCK_FILE: &str = "apply.lock"; +/// The names (not values) of the variables the app gave the runner, so the +/// runner can hand `tofu` exactly those (see [`tofu_env`]). +const ENV_KEYS_VAR: &str = "TRIPLEC_TOFU_ENV_KEYS"; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; @@ -205,11 +215,14 @@ pub fn verify_and_extract( /// `/tofu//tofu[.exe]`, downloaded and verified on first use. /// A binary already there is reused only if it still hashes to what was -/// recorded when it was extracted from a verified archive. +/// recorded when it was extracted from a verified archive. Also prepares +/// the plugin cache and CLI config [`child_env`] points at. #[allow(dead_code)] // used by phase-4 commands pub async fn ensure_tofu(root: &Path) -> Result { let (file, sha) = release_asset(std::env::consts::OS, std::env::consts::ARCH) .ok_or_else(|| other("OpenTofu isn't available for this platform"))?; + prepare_support_files(root) + .map_err(|e| other(format!("Could not prepare the OpenTofu folder: {e}")))?; let dir = root.join("tofu").join(TOFU_VERSION); fs::create_dir_all(&dir) .map_err(|e| other(format!("Could not create {}: {e}", dir.display())))?; @@ -392,22 +405,30 @@ fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { /// "everything minus AWS_/TF_/TOFU_": anything else the app was started with /// (`LD_PRELOAD`, tokens, the AppImage's library paths) has no business in /// an apply. -/// - `PATH`: `credential_process`/SSO helpers and the AppImage runtime's -/// `fusermount`. +/// - `PATH` (and Windows' `PATHEXT`, `ComSpec`): `credential_process`/SSO +/// helpers, and the AppImage runtime's `fusermount`. /// - `HOME` / `USERPROFILE`: where the AWS provider finds `~/.aws` (config, /// credentials, SSO cache) for a profile connection. /// - `TMPDIR` / `TMP` / `TEMP`: scratch space for tofu and its provider. -/// - `SystemRoot`: Go's networking and crypto fail on Windows without it. -/// - The proxy variables and `SSL_CERT_FILE` / `SSL_CERT_DIR`: a corporate -/// network needs them for the provider download and every AWS call. +/// - `SystemRoot`, `windir`, `APPDATA`, `LOCALAPPDATA`: Go's networking and +/// crypto fail on Windows without `SystemRoot`; the others are where +/// Windows programs (credential helpers included) find their own state. +/// - The proxy variables, `SSL_CERT_FILE` / `SSL_CERT_DIR` and +/// `AWS_CA_BUNDLE`: a corporate network needs them for the provider +/// download and every AWS call. `AWS_CA_BUNDLE` only adds trusted CAs. const INHERITED: &[&str] = &[ "PATH", + "PATHEXT", + "ComSpec", "HOME", "USERPROFILE", "TMPDIR", "TMP", "TEMP", "SystemRoot", + "windir", + "APPDATA", + "LOCALAPPDATA", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", @@ -416,57 +437,145 @@ const INHERITED: &[&str] = &[ "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", + "AWS_CA_BUNDLE", ]; -fn inherited(name: &str) -> bool { - INHERITED.iter().any(|a| { - if cfg!(windows) { - a.eq_ignore_ascii_case(name) - } else { - *a == name - } - }) +/// For a profile connection: the profile files the app's own SDK config +/// reads. `aws/connection.rs` uses the SDK's default file resolution, which +/// honours these two variables in the app's environment, so tofu is given +/// the same values and both read the same files. +const PROFILE_FILE_VARS: &[&str] = &["AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE"]; + +fn name_is(a: &str, name: &str) -> bool { + if cfg!(windows) { + a.eq_ignore_ascii_case(name) + } else { + a == name + } } -/// The complete environment for the runner and the `tofu` it starts: the -/// [`INHERITED`] variables from `parent` (the real caller passes -/// `std::env::vars()`), the connection's own credentials — `AWS_PROFILE` for -/// a profile, the three key variables for a keys connection — and the -/// app's `TF_*` / `CHECKPOINT_*` settings. Nothing else AWS-, TF- or -/// TOFU-shaped. A profile uses the default `~/.aws` paths, as -/// `aws/connection.rs` does. +/// `/tofu/plugin-cache`: `TF_PLUGIN_CACHE_DIR`, shared by every +/// deployment. +pub fn plugin_cache_dir(root: &Path) -> PathBuf { + root.join("tofu").join("plugin-cache") +} + +/// `/tofu/tofurc`: `TF_CLI_CONFIG_FILE`. The user's own `~/.tofurc` +/// (or `terraform.rc`) could carry `dev_overrides`, which bypass the lock +/// file's provider hashes, or a mirror; this one says only "install +/// providers directly from their registry". No secrets. +fn cli_config_path(root: &Path) -> PathBuf { + root.join("tofu").join("tofurc") +} + +const CLI_CONFIG: &str = "provider_installation {\n direct {}\n}\n"; + +/// `/tofu/no-aws-config`, which never exists: a keys connection's +/// `AWS_CONFIG_FILE` and `AWS_SHARED_CREDENTIALS_FILE`, so the user's +/// `[default]` profile (region, endpoints, credential settings) can't mix +/// into a connection that has its own keys. +fn no_aws_files_path(root: &Path) -> PathBuf { + root.join("tofu").join("no-aws-config") +} + +/// Create the plugin cache and the CLI config, and make sure the "no AWS +/// config" path really doesn't exist. Run by [`ensure_tofu`] on every call. +fn prepare_support_files(root: &Path) -> io::Result<()> { + fs::create_dir_all(plugin_cache_dir(root))?; + fs::write(cli_config_path(root), CLI_CONFIG)?; + let absent = no_aws_files_path(root); + match fs::symlink_metadata(&absent) { + Ok(m) if m.is_dir() => fs::remove_dir_all(&absent)?, + Ok(_) => fs::remove_file(&absent)?, + Err(_) => {} + } + Ok(()) +} + +/// The Rust SDK expands a leading `~` in the profile-file variables; the Go +/// SDK reads them literally. Expand it here so both see the same file. +fn expand_home(value: &str, parent: &[(String, String)]) -> String { + let rest = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\")); + let home = parent + .iter() + .find(|(k, _)| name_is("HOME", k)) + .or_else(|| parent.iter().find(|(k, _)| name_is("USERPROFILE", k))); + match (rest, home) { + (Some(rest), Some((_, home))) => Path::new(home).join(rest).to_string_lossy().into_owned(), + _ => value.to_string(), + } +} + +/// The complete environment for the runner and the `tofu` it starts. From +/// `parent` (the real caller passes `std::env::vars()`) only the +/// [`INHERITED`] variables, plus a profile connection's [`PROFILE_FILE_VARS`] +/// so tofu reads the files the app does. Then: +/// - the connection's credentials: `AWS_PROFILE` for a profile, the key +/// variables for a keys connection (whose `keys` must be given), with the +/// AWS config and credentials files pointed at a path that doesn't exist; +/// - `AWS_IGNORE_CONFIGURED_ENDPOINT_URLS=true`, so an `endpoint_url` in the +/// user's AWS config can't redirect signed calls (the Go SDK v2 that tofu's +/// S3 backend and the AWS provider use honours it); +/// - `TF_CLI_CONFIG_FILE`, `TF_PLUGIN_CACHE_DIR`, `TF_IN_AUTOMATION=1`, +/// `TF_INPUT=0`, `CHECKPOINT_DISABLE=1`. +/// +/// `root` is the one [`ensure_tofu`] prepared. #[allow(dead_code)] // used by phase-4 commands pub fn child_env( conn: &HostConnection, keys: Option<&AwsKeys>, - plugin_cache: &Path, + root: &Path, parent: impl IntoIterator, -) -> Vec<(String, String)> { - let mut env: Vec<(String, String)> = parent.into_iter().filter(|(k, _)| inherited(k)).collect(); +) -> Result, AwsError> { + let parent: Vec<(String, String)> = parent.into_iter().collect(); + let mut env: Vec<(String, String)> = parent + .iter() + .filter(|(k, _)| INHERITED.iter().any(|a| name_is(a, k))) + .cloned() + .collect(); let HostKind::Aws { auth, .. } = &conn.kind; match auth { - AwsAuth::Profile { name } => env.push(("AWS_PROFILE".into(), name.clone())), - AwsAuth::Keys { .. } => { - if let Some(keys) = keys { - env.push(("AWS_ACCESS_KEY_ID".into(), keys.access_key_id.clone())); - env.push(( - "AWS_SECRET_ACCESS_KEY".into(), - keys.secret_access_key.clone(), - )); - if let Some(token) = &keys.session_token { - env.push(("AWS_SESSION_TOKEN".into(), token.clone())); + AwsAuth::Profile { name } => { + env.push(("AWS_PROFILE".into(), name.clone())); + for var in PROFILE_FILE_VARS { + if let Some((_, v)) = parent + .iter() + .find(|(k, v)| name_is(var, k) && !v.is_empty()) + { + env.push((var.to_string(), expand_home(v, &parent))); } } } + AwsAuth::Keys { .. } => { + let keys = keys.ok_or(AwsError::KeysMissing)?; + env.push(("AWS_ACCESS_KEY_ID".into(), keys.access_key_id.clone())); + env.push(( + "AWS_SECRET_ACCESS_KEY".into(), + keys.secret_access_key.clone(), + )); + if let Some(token) = &keys.session_token { + env.push(("AWS_SESSION_TOKEN".into(), token.clone())); + } + let absent = no_aws_files_path(root).to_string_lossy().into_owned(); + env.push(("AWS_CONFIG_FILE".into(), absent.clone())); + env.push(("AWS_SHARED_CREDENTIALS_FILE".into(), absent)); + } } + env.push(("AWS_IGNORE_CONFIGURED_ENDPOINT_URLS".into(), "true".into())); + env.push(( + "TF_CLI_CONFIG_FILE".into(), + cli_config_path(root).to_string_lossy().into_owned(), + )); env.push(( "TF_PLUGIN_CACHE_DIR".into(), - plugin_cache.to_string_lossy().into_owned(), + plugin_cache_dir(root).to_string_lossy().into_owned(), )); env.push(("TF_IN_AUTOMATION".into(), "1".into())); env.push(("TF_INPUT".into(), "0".into())); env.push(("CHECKPOINT_DISABLE".into(), "1".into())); - env + Ok(env) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -513,10 +622,21 @@ fn remove_if_present(path: &Path) -> io::Result<()> { } } +/// How long [`start_detached`] waits for the runner to take its lock. An +/// AppImage has to mount itself first, which is the slow case. +const RUNNER_START_TIMEOUT: Duration = Duration::from_secs(20); + /// Start `tofu init` then `apply|destroy` in a detached runner and return -/// its PID (also written to `apply.pid`). The previous run's log and exit -/// code are cleared first. `env` is the runner's entire environment -/// ([`child_env`]); credentials go nowhere else. +/// the runner's PID. The previous run's log, PID and exit code are cleared +/// first. `env` is the runner's entire environment ([`child_env`]); +/// credentials go nowhere else. +/// +/// Returns only once the runner holds `apply.lock` and has written +/// `apply.pid` (or has already finished), so the run is followable and +/// cancellable the moment this returns. If it does neither within +/// [`RUNNER_START_TIMEOUT`] the spawned process is killed and this fails: +/// nothing is left running that the app can't see. Blocks for that wait +/// (normally milliseconds); call it from a blocking task. #[allow(dead_code)] // used by phase-4 commands pub fn start_detached( tofu: &Path, @@ -531,6 +651,7 @@ pub fn start_detached( for f in [LOG_FILE, EXIT_FILE, PID_FILE] { remove_if_present(&wd.dir.join(f)).map_err(io)?; } + let names: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect(); let mut cmd = Command::new(runner_program().map_err(io)?); cmd.arg(RUNNER_FLAG) .arg(&wd.dir) @@ -538,6 +659,7 @@ pub fn start_detached( .env_clear() .envs(env.iter().map(|(k, v)| (k, v))) .env(TOFU_BIN_ENV, tofu) + .env(ENV_KEYS_VAR, names.join(",")) .current_dir(&wd.dir) .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -563,11 +685,35 @@ pub fn start_detached( cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); } let mut child = cmd.spawn().map_err(io)?; - let pid = child.id(); - let tmp = wd.dir.join(format!("{PID_FILE}.tmp")); - fs::write(&tmp, pid.to_string()) - .and_then(|_| fs::rename(&tmp, wd.dir.join(PID_FILE))) - .map_err(io)?; + + let deadline = std::time::Instant::now() + RUNNER_START_TIMEOUT; + let pid = loop { + if lock_held(wd) { + if let Some(pid) = read_pid(wd) { + break pid; + } + } + if wd.dir.join(EXIT_FILE).exists() { + break read_pid(wd).unwrap_or_else(|| child.id()); + } + // An AppImage's launcher may exit 0 once it has handed over; only a + // failure with nothing to show for it is final. + if let Ok(Some(status)) = child.try_wait() { + if !status.success() && !lock_held(wd) && !wd.dir.join(EXIT_FILE).exists() { + return Err(other(format!( + "The deploy runner failed to start ({status})" + ))); + } + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(other( + "The deploy runner did not start in time; it was stopped", + )); + } + std::thread::sleep(Duration::from_millis(20)); + }; // Reap it if it finishes while the app is still open (no zombie). std::thread::spawn(move || { let _ = child.wait(); @@ -584,39 +730,34 @@ fn read_pid(wd: &Workdir) -> Option { .filter(|p| *p > 0) } -#[cfg(unix)] -fn pid_alive(pid: u32) -> bool { - let Ok(pid) = libc::pid_t::try_from(pid) else { +/// Whether some process holds `apply.lock`. The runner takes it before it +/// writes `apply.pid` and keeps it until it exits (the OS drops it however +/// the runner dies), so this — not "the PID in `apply.pid` exists", which a +/// reused PID would satisfy — is what "the runner is alive" means. A +/// separate file from `apply.pid` because a Windows lock also blocks reads. +fn lock_held(wd: &Workdir) -> bool { + let Ok(file) = File::open(wd.dir.join(LOCK_FILE)) else { return false; }; - // SAFETY: signal 0 only checks that the process exists. - let r = unsafe { libc::kill(pid, 0) }; - r == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) + match file.try_lock() { + Ok(()) => false, // released again when `file` drops + Err(fs::TryLockError::WouldBlock) => true, + Err(fs::TryLockError::Error(_)) => false, + } } -#[cfg(windows)] -fn pid_alive(pid: u32) -> bool { - use std::os::windows::process::CommandExt; - // No Win32 bindings in the tree; tasklist is always present. - Command::new("tasklist") - .args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"]) - .creation_flags(CREATE_NO_WINDOW) - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).contains(&format!("\"{pid}\""))) - .unwrap_or(false) -} - -/// A run is in progress: `apply.pid` names a live process and `apply.exit` -/// doesn't exist yet. (A PID reused after a reboot can read as live; the -/// state lock is the other half of "a deploy is in progress".) +/// A run is in progress: its runner holds `apply.lock` and `apply.exit` +/// doesn't exist yet. pub fn is_running(wd: &Workdir) -> bool { - !wd.dir.join(EXIT_FILE).exists() && read_pid(wd).is_some_and(pid_alive) + !wd.dir.join(EXIT_FILE).exists() && lock_held(wd) } -/// Cancel: SIGINT to the runner's process group, so `tofu` stops gracefully -/// and saves state. The runner survives it and still writes `apply.exit`. +/// Cancel: SIGINT to the runner *process* (never a process group: inside an +/// AppImage the group also holds the runtime's FUSE server, which would +/// unmount the runner's own binary). The runner forwards it to the `tofu` +/// it is running, which stops gracefully and saves state, and still writes +/// `apply.exit`. Only signals while the runner holds its lock, so a stale +/// PID never reaches another process. #[allow(dead_code)] // used by phase-4 commands pub fn interrupt(wd: &Workdir) -> Result<(), AwsError> { if !is_running(wd) { @@ -625,10 +766,9 @@ pub fn interrupt(wd: &Workdir) -> Result<(), AwsError> { #[cfg(unix)] { let pid = read_pid(wd).ok_or_else(|| other("No deploy is running"))?; - let pgid = libc::pid_t::try_from(pid).map_err(|_| other("Invalid runner PID"))?; - // SAFETY: plain kill(2) on the runner's own process group (setsid - // made its PID the group id). - if unsafe { libc::kill(-pgid, libc::SIGINT) } != 0 { + let pid = libc::pid_t::try_from(pid).map_err(|_| other("Invalid runner PID"))?; + // SAFETY: plain kill(2) on the lock-holding runner. + if unsafe { libc::kill(pid, libc::SIGINT) } != 0 { return Err(other(format!( "Could not interrupt the deploy: {}", io::Error::last_os_error() @@ -675,9 +815,16 @@ pub fn parse_log_line(line: &str) -> Option { if line.trim().is_empty() { return None; } + // Plain text is the runner's own lines or a crash; an `Error:` there is + // still the run's error. let plain = || TofuEvent { ts: String::new(), - level: "info".into(), + level: if line.trim_start().starts_with("Error:") { + "error" + } else { + "info" + } + .into(), kind: "log".into(), resource: None, action: None, @@ -756,8 +903,7 @@ pub async fn follow( // Checked before reading: the runner writes the exit code only after // tofu has written its last line, so this read sees everything. let finished = exit.exists(); - let runner_gone = - !finished && read_pid(wd).is_some_and(|p| !pid_alive(p)) && !exit.exists(); + let runner_gone = !finished && !lock_held(wd) && !exit.exists(); let new = read_new(&log, offset) .map_err(|e| other(format!("Could not read the deploy log: {e}")))?; offset += new.len() as u64; @@ -817,6 +963,10 @@ pub async fn follow( // --------------------------------------------------------------------------- // The runner (the app binary re-executed with `--tofu-runner`). +/// Exit status of a runner that found another runner already holding the +/// folder's lock. It writes nothing: the folder belongs to that run. +const RUNNER_BUSY: i32 = 75; + /// `main.rs` calls this first. `None` unless the arguments are /// `--tofu-runner `; otherwise the runner's exit /// code, for the process to exit with. @@ -835,56 +985,151 @@ pub fn runner_main(args: impl IntoIterator) -> Option { if !dir.is_dir() { return Some(2); } + // An empty path fails to spawn, which is logged and recorded (127). + let tofu = std::env::var_os(TOFU_BIN_ENV) + .map(PathBuf::from) + .unwrap_or_default(); + let own: BTreeMap = std::env::vars().collect(); + let env = tofu_env(&own); + Some(run_runner(&dir, op, &tofu, &env).unwrap_or(RUNNER_BUSY)) +} + +/// The environment `tofu` gets: exactly the variables the app put in +/// [`child_env`] (their names arrive in [`ENV_KEYS_VAR`]), with whatever an +/// AppImage launcher did to them undone — its `AppRun` prepends the bundle's +/// directories to `PATH` and adds `LD_LIBRARY_PATH`, `PYTHONPATH`, GTK and +/// other variables before the runner's `main` runs. Names the app didn't +/// pass are dropped; `PATH` is restored from the launcher's saved original +/// or stripped of `$APPDIR` entries ([`crate::url_open`]'s rules). +fn tofu_env(own: &BTreeMap) -> Vec<(String, String)> { + let Some(names) = own.get(ENV_KEYS_VAR) else { + return Vec::new(); + }; + let changes = + crate::url_open::sanitize_child_env(own, own, own.get("APPDIR").map(String::as_str)); + let mut seen = std::collections::BTreeSet::new(); + let mut env = Vec::new(); + for name in names + .split(',') + .filter(|n| !n.is_empty() && *n != ENV_KEYS_VAR && *n != TOFU_BIN_ENV) + { + if !seen.insert(name) { + continue; + } + let value = match changes.iter().find(|(k, _)| k == name) { + Some((_, changed)) => changed.clone(), + None => own.get(name).cloned(), + }; + if let Some(value) = value { + env.push((name.to_string(), value)); + } + } + env +} + +/// The `tofu` process the runner is waiting on (0: none), for the SIGINT +/// handler to forward Cancel to. +static CHILD_PID: AtomicI32 = AtomicI32::new(0); +/// Cancel arrived; nothing further is started. +static CANCELLED: AtomicBool = AtomicBool::new(false); + +/// Forwards SIGINT to the running `tofu` (atomics and kill(2) are +/// async-signal-safe). The runner itself carries on so it can record the +/// exit code. A handler, not SIG_IGN, which `tofu` would inherit. +#[cfg(unix)] +extern "C" fn forward_sigint(_: libc::c_int) { + CANCELLED.store(true, Ordering::SeqCst); + let pid = CHILD_PID.load(Ordering::SeqCst); + if pid > 0 { + // SAFETY: kill(2) is async-signal-safe; pid > 0 never names a group. + unsafe { + libc::kill(pid, libc::SIGINT); + } + } +} + +/// Take the folder's lock (a few tries: a liveness probe may hold it for a +/// moment), write `apply.pid`, run the steps, write `apply.exit`. `None` if +/// another runner holds the lock. +fn run_runner(dir: &Path, op: TofuOp, tofu: &Path, env: &[(String, String)]) -> Option { + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(dir.join(LOCK_FILE)) + .ok()?; + let mut locked = false; + for _ in 0..50 { + if lock.try_lock().is_ok() { + locked = true; + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + if !locked { + return None; + } + let tmp = dir.join(format!("{PID_FILE}.tmp")); + let pid_written = fs::write(&tmp, std::process::id().to_string()) + .and_then(|_| fs::rename(&tmp, dir.join(PID_FILE))); + + CHILD_PID.store(0, Ordering::SeqCst); + CANCELLED.store(false, Ordering::SeqCst); + #[cfg(unix)] + // SAFETY: installs an async-signal-safe handler. + unsafe { + libc::signal( + libc::SIGINT, + forward_sigint as extern "C" fn(libc::c_int) as libc::sighandler_t, + ); + } + let code = match OpenOptions::new() .create(true) .append(true) .open(dir.join(LOG_FILE)) { - Ok(mut log) => run_steps(&dir, op, &mut log), + Ok(mut log) => { + if let Err(e) = &pid_written { + let _ = writeln!( + log, + "triple-c: could not record the runner's PID ({e}); Cancel won't reach it" + ); + } + run_steps(dir, op, tofu, env, &mut log) + } Err(_) => 1, }; - write_exit(&dir, code); + write_exit(dir, code); + drop(lock); Some(code) } -#[cfg(unix)] -extern "C" fn ignore_signal(_: libc::c_int) {} - -fn run_steps(dir: &Path, op: TofuOp, log: &mut File) -> i32 { - let Some(tofu) = std::env::var_os(TOFU_BIN_ENV) else { - let _ = writeln!(log, "triple-c: no OpenTofu binary was given to the runner"); - return 1; - }; - // Cancel signals the whole group. The runner must outlive it to record - // the exit code, so it catches SIGINT with a handler that does nothing. - // A handler, not SIG_IGN: an ignored disposition would be inherited - // through exec, and tofu would ignore the Cancel too. - #[cfg(unix)] - // SAFETY: installs an empty, async-signal-safe handler. - unsafe { - libc::signal( - libc::SIGINT, - ignore_signal as extern "C" fn(libc::c_int) as libc::sighandler_t, - ); - } +fn run_steps(dir: &Path, op: TofuOp, tofu: &Path, env: &[(String, String)], log: &mut File) -> i32 { + // `-json` on init too, so its errors (a missing bucket, access denied) + // arrive as diagnostics and become the outcome's summary. let init = [ "init", "-input=false", - "-no-color", + "-json", "-backend-config=backend.hcl", ]; - let code = step(Path::new(&tofu), dir, &init, log); + let code = step(tofu, dir, &init, env, log); if code != 0 { let _ = writeln!(log, "triple-c: tofu init failed (exit {code})"); return code; } + if CANCELLED.load(Ordering::SeqCst) { + let _ = writeln!(log, "triple-c: cancelled before tofu {}", op.as_arg()); + return 130; + } let run = [op.as_arg(), "-auto-approve", "-json", "-input=false"]; - let code = step(Path::new(&tofu), dir, &run, log); + let code = step(tofu, dir, &run, env, log); let _ = writeln!(log, "triple-c: tofu {} finished (exit {code})", op.as_arg()); code } -fn step(tofu: &Path, dir: &Path, args: &[&str], log: &mut File) -> i32 { +fn step(tofu: &Path, dir: &Path, args: &[&str], env: &[(String, String)], log: &mut File) -> i32 { let _ = writeln!(log, "triple-c: running tofu {}", args[0]); let (Ok(out), Ok(err)) = (log.try_clone(), log.try_clone()) else { return 1; @@ -892,7 +1137,8 @@ fn step(tofu: &Path, dir: &Path, args: &[&str], log: &mut File) -> i32 { let mut cmd = Command::new(tofu); cmd.args(args) .current_dir(dir) - .env_remove(TOFU_BIN_ENV) + .env_clear() + .envs(env.iter().map(|(k, v)| (k, v))) .stdin(Stdio::null()) .stdout(out) .stderr(err); @@ -901,11 +1147,31 @@ fn step(tofu: &Path, dir: &Path, args: &[&str], log: &mut File) -> i32 { use std::os::windows::process::CommandExt; cmd.creation_flags(CREATE_NO_WINDOW); } - match cmd.status() { - Ok(status) => exit_code(status), + let mut child = match cmd.spawn() { + Ok(child) => child, Err(e) => { let _ = writeln!(log, "triple-c: could not start OpenTofu: {e}"); - 127 + return 127; + } + }; + CHILD_PID.store(i32::try_from(child.id()).unwrap_or(0), Ordering::SeqCst); + // A Cancel that landed between spawn and the store above. + #[cfg(unix)] + if CANCELLED.load(Ordering::SeqCst) { + if let Ok(pid) = libc::pid_t::try_from(child.id()) { + // SAFETY: kill(2) on our own child. + unsafe { + libc::kill(pid, libc::SIGINT); + } + } + } + let status = child.wait(); + CHILD_PID.store(0, Ordering::SeqCst); + match status { + Ok(status) => exit_code(status), + Err(e) => { + let _ = writeln!(log, "triple-c: lost track of OpenTofu: {e}"); + 1 } } } diff --git a/app/src-tauri/src/aws/tofu/tests.rs b/app/src-tauri/src/aws/tofu/tests.rs index d6b1d1a..76399ff 100644 --- a/app/src-tauri/src/aws/tofu/tests.rs +++ b/app/src-tauri/src/aws/tofu/tests.rs @@ -107,13 +107,19 @@ fn a_verified_archive_yields_only_the_binary() { } } +fn env_map(v: Vec<(String, String)>) -> BTreeMap { + v.into_iter().collect() +} + +fn get<'a>(env: &'a BTreeMap, k: &str) -> Option<&'a str> { + env.get(k).map(String::as_str) +} + #[test] fn the_child_environment_carries_nothing_inherited() { - let cache = std::path::Path::new("/app/plugin-cache"); + let root = std::path::Path::new("/app"); - let env: BTreeMap = child_env(&profile_conn("work"), None, cache, parent_env()) - .into_iter() - .collect(); + let env = env_map(child_env(&profile_conn("work"), None, root, parent_env()).unwrap()); for gone in [ "AWS_ENDPOINT_URL", @@ -125,54 +131,199 @@ fn the_child_environment_carries_nothing_inherited() { ] { assert!(!env.contains_key(gone), "{gone} leaked: {env:?}"); } - assert_eq!(env.get("AWS_PROFILE").map(String::as_str), Some("work")); + assert_eq!(get(&env, "AWS_PROFILE"), Some("work")); assert_eq!( - env.get("TF_PLUGIN_CACHE_DIR").map(String::as_str), - Some("/app/plugin-cache") + get(&env, "TF_PLUGIN_CACHE_DIR"), + Some("/app/tofu/plugin-cache") ); - assert_eq!(env.get("TF_IN_AUTOMATION").map(String::as_str), Some("1")); - assert_eq!(env.get("TF_INPUT").map(String::as_str), Some("0")); - assert_eq!(env.get("CHECKPOINT_DISABLE").map(String::as_str), Some("1")); - assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin:/bin")); - assert_eq!(env.get("HOME").map(String::as_str), Some("/home/u")); + assert_eq!(get(&env, "TF_CLI_CONFIG_FILE"), Some("/app/tofu/tofurc")); + assert_eq!(get(&env, "TF_IN_AUTOMATION"), Some("1")); + assert_eq!(get(&env, "TF_INPUT"), Some("0")); + assert_eq!(get(&env, "CHECKPOINT_DISABLE"), Some("1")); assert_eq!( - env.get("HTTPS_PROXY").map(String::as_str), - Some("http://proxy.corp:3128") + get(&env, "AWS_IGNORE_CONFIGURED_ENDPOINT_URLS"), + Some("true") ); + assert_eq!(get(&env, "PATH"), Some("/usr/bin:/bin")); + assert_eq!(get(&env, "HOME"), Some("/home/u")); + assert_eq!(get(&env, "HTTPS_PROXY"), Some("http://proxy.corp:3128")); let aws: Vec<_> = env.keys().filter(|k| k.starts_with("AWS_")).collect(); - assert_eq!(aws, ["AWS_PROFILE"]); + assert_eq!(aws, ["AWS_IGNORE_CONFIGURED_ENDPOINT_URLS", "AWS_PROFILE"]); + let tf: Vec<_> = env + .keys() + .filter(|k| k.starts_with("TF_") || k.starts_with("TOFU_")) + .collect(); + assert_eq!( + tf, + [ + "TF_CLI_CONFIG_FILE", + "TF_INPUT", + "TF_IN_AUTOMATION", + "TF_PLUGIN_CACHE_DIR" + ] + ); let keys = AwsKeys { access_key_id: "AKIACONNNOTREAL".into(), secret_access_key: "conn-secret-not-real".into(), session_token: Some("conn-token-not-real".into()), }; - let env: BTreeMap = child_env(&keys_conn(), Some(&keys), cache, parent_env()) - .into_iter() - .collect(); + let env = env_map(child_env(&keys_conn(), Some(&keys), root, parent_env()).unwrap()); + assert_eq!(get(&env, "AWS_ACCESS_KEY_ID"), Some("AKIACONNNOTREAL")); assert_eq!( - env.get("AWS_ACCESS_KEY_ID").map(String::as_str), - Some("AKIACONNNOTREAL") - ); - assert_eq!( - env.get("AWS_SECRET_ACCESS_KEY").map(String::as_str), + get(&env, "AWS_SECRET_ACCESS_KEY"), Some("conn-secret-not-real") ); + assert_eq!(get(&env, "AWS_SESSION_TOKEN"), Some("conn-token-not-real")); + // The user's own AWS config can't mix into a keys connection. assert_eq!( - env.get("AWS_SESSION_TOKEN").map(String::as_str), - Some("conn-token-not-real") + get(&env, "AWS_CONFIG_FILE"), + Some("/app/tofu/no-aws-config") + ); + assert_eq!( + get(&env, "AWS_SHARED_CREDENTIALS_FILE"), + Some("/app/tofu/no-aws-config") ); let aws: Vec<_> = env.keys().filter(|k| k.starts_with("AWS_")).collect(); assert_eq!( aws, [ "AWS_ACCESS_KEY_ID", + "AWS_CONFIG_FILE", + "AWS_IGNORE_CONFIGURED_ENDPOINT_URLS", "AWS_SECRET_ACCESS_KEY", - "AWS_SESSION_TOKEN" + "AWS_SESSION_TOKEN", + "AWS_SHARED_CREDENTIALS_FILE", ] ); } +#[test] +fn a_keys_connection_without_keys_is_refused() { + let result = child_env( + &keys_conn(), + None, + std::path::Path::new("/app"), + parent_env(), + ); + assert!( + matches!(result, Err(crate::aws::AwsError::KeysMissing)), + "{result:?}" + ); +} + +#[test] +fn profile_files_and_the_ca_bundle_follow_the_app() { + let mut parent = parent_env(); + parent.push(("AWS_CONFIG_FILE".into(), "~/work/aws-config".into())); + parent.push(( + "AWS_SHARED_CREDENTIALS_FILE".into(), + "/etc/aws/creds".into(), + )); + parent.push(("AWS_CA_BUNDLE".into(), "/etc/corp-ca.pem".into())); + let root = std::path::Path::new("/app"); + + let env = env_map(child_env(&profile_conn("work"), None, root, parent.clone()).unwrap()); + assert_eq!( + get(&env, "AWS_CONFIG_FILE"), + Some("/home/u/work/aws-config") + ); + assert_eq!( + get(&env, "AWS_SHARED_CREDENTIALS_FILE"), + Some("/etc/aws/creds") + ); + assert_eq!(get(&env, "AWS_CA_BUNDLE"), Some("/etc/corp-ca.pem")); + + let keys = AwsKeys { + access_key_id: "AKIACONNNOTREAL".into(), + secret_access_key: "conn-secret-not-real".into(), + session_token: None, + }; + let env = env_map(child_env(&keys_conn(), Some(&keys), root, parent).unwrap()); + assert_eq!( + get(&env, "AWS_CONFIG_FILE"), + Some("/app/tofu/no-aws-config") + ); + assert_eq!(get(&env, "AWS_CA_BUNDLE"), Some("/etc/corp-ca.pem")); + assert!(!env.contains_key("AWS_SESSION_TOKEN")); +} + +#[tokio::test] +async fn ensure_prepares_the_cli_config_and_keeps_the_no_config_path_absent() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("tofu/no-aws-config")).unwrap(); + // Not reaching the download: the support files come first, and a failed + // download is fine for this test. + let _ = prepare_support_files(root.path()); + assert_eq!( + std::fs::read_to_string(root.path().join("tofu/tofurc")).unwrap(), + CLI_CONFIG + ); + assert!(root.path().join("tofu/plugin-cache").is_dir()); + assert!(!root.path().join("tofu/no-aws-config").exists()); +} + +#[test] +fn the_runner_hands_tofu_exactly_the_apps_variables_without_appimage_changes() { + let appdir = "/tmp/.mount_triplecAbc"; + let own: BTreeMap = [ + (ENV_KEYS_VAR, "PATH,HOME,AWS_PROFILE,TF_IN_AUTOMATION"), + (TOFU_BIN_ENV, "/app/tofu/1.13.9/tofu"), + ( + "PATH", + "/tmp/.mount_triplecAbc/usr/bin:/usr/local/bin:/usr/bin", + ), + ("HOME", "/home/u"), + ("AWS_PROFILE", "work"), + ("TF_IN_AUTOMATION", "1"), + // What AppRun and the GTK hook add: + ("APPDIR", appdir), + ("APPIMAGE", "/home/u/Triple-C.AppImage"), + ("ARGV0", "Triple-C.AppImage"), + ("OWD", "/home/u"), + ("LD_LIBRARY_PATH", "/tmp/.mount_triplecAbc/usr/lib"), + ("PYTHONPATH", "/tmp/.mount_triplecAbc/usr/share/pyshared"), + ( + "XDG_DATA_DIRS", + "/tmp/.mount_triplecAbc/usr/share:/usr/share", + ), + ("GTK_PATH", "/tmp/.mount_triplecAbc/usr/lib/gtk-3.0"), + ( + "GDK_PIXBUF_MODULE_FILE", + "/tmp/.mount_triplecAbc/usr/lib/loaders.cache", + ), + ] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let env = env_map(tofu_env(&own)); + + let expected: BTreeMap = [ + ("PATH", "/usr/local/bin:/usr/bin"), + ("HOME", "/home/u"), + ("AWS_PROFILE", "work"), + ("TF_IN_AUTOMATION", "1"), + ] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + assert_eq!(env, expected); + + // A launcher that saved the original gets it restored. + let mut saved = own.clone(); + saved.insert("PATH_ORIG".into(), "/usr/bin:/bin".into()); + assert_eq!( + get(&env_map(tofu_env(&saved)), "PATH"), + Some("/usr/bin:/bin") + ); + + // Without the key list, nothing at all. + let mut bare = own; + bare.remove(ENV_KEYS_VAR); + assert!(tofu_env(&bare).is_empty()); +} + #[test] fn materialize_writes_backend_and_vars_privately() { let tmp = tempfile::tempdir().unwrap(); @@ -280,6 +431,9 @@ async fn following_an_existing_log_reports_each_event_once() { }; let log = wd.dir.join("apply.log"); append(&log, &(0..3).map(json_line).collect::>()); + // Stand in for the runner: it holds the lock until it has written the + // exit code. + let lock = hold_lock(&wd); let dir = wd.dir.clone(); let writer = tokio::spawn(async move { @@ -290,6 +444,7 @@ async fn following_an_existing_log_reports_each_event_once() { ); tokio::time::sleep(Duration::from_millis(60)).await; std::fs::write(dir.join("apply.exit"), "0").unwrap(); + drop(lock); }); let mut seen = Vec::new(); @@ -384,17 +539,212 @@ fn release_assets_exist_for_every_shipped_platform() { assert!(release_asset("freebsd", "x86_64").is_none()); } +fn hold_lock(wd: &Workdir) -> std::fs::File { + let f = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(wd.dir.join("apply.lock")) + .unwrap(); + f.try_lock().unwrap(); + f +} + #[test] -fn no_run_is_in_progress_without_a_live_pid() { +fn a_live_but_unrelated_pid_is_not_a_running_deploy() { let tmp = tempfile::tempdir().unwrap(); let wd = Workdir { dir: tmp.path().to_path_buf(), }; assert!(!is_running(&wd)); + // A PID that is certainly alive (this test process), and no lock: a + // reused PID after a reboot looks exactly like this. std::fs::write(wd.dir.join("apply.pid"), std::process::id().to_string()).unwrap(); - assert!(is_running(&wd), "this test process is alive"); + assert!(!is_running(&wd), "a live PID alone is not a run"); + assert!( + interrupt(&wd).is_err(), + "nothing is signalled without the lock" + ); + std::fs::write(wd.dir.join("apply.lock"), "").unwrap(); + assert!(!is_running(&wd), "an unheld lock file is not a run"); + + let lock = hold_lock(&wd); + assert!(is_running(&wd), "the lock is held"); std::fs::write(wd.dir.join("apply.exit"), "0").unwrap(); assert!(!is_running(&wd), "an exit code means the run is over"); + drop(lock); +} + +#[tokio::test] +async fn a_runner_that_died_without_an_exit_code_ends_the_follow() { + let tmp = tempfile::tempdir().unwrap(); + let wd = Workdir { + dir: tmp.path().to_path_buf(), + }; + append(&wd.dir.join("apply.log"), &[json_line(0)]); + std::fs::write(wd.dir.join("apply.pid"), std::process::id().to_string()).unwrap(); + + let outcome = tokio::time::timeout( + Duration::from_secs(5), + follow(&wd, Duration::from_millis(10), |_| {}), + ) + .await + .expect("follow must not wait forever") + .unwrap(); + assert!(!outcome.ok); + assert_eq!(outcome.exit_code, -1); +} + +/// Real `tofu init -json` output for a bad `backend.hcl`. +const INIT_DIAGNOSTIC: &str = r#"{"@level":"error","@message":"Error: Unsupported argument","@module":"tofu.ui","@timestamp":"2026-11-14T21:53:38.405933-08:00","diagnostic":{"severity":"error","summary":"Unsupported argument","detail":"An argument named \"nonsense_attr\" is not expected here.","range":{"filename":"backend.hcl","start":{"line":1,"column":1,"byte":0},"end":{"line":1,"column":14,"byte":13}},"snippet":{"context":null,"code":"nonsense_attr = 1","start_line":1,"highlight_start_offset":0,"highlight_end_offset":13,"values":[]}},"type":"diagnostic"}"#; + +#[cfg(unix)] +fn fake_tofu(dir: &std::path::Path, script: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = dir.join("fake-tofu"); + std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path +} + +/// The runner's state lives in statics; the in-process runner tests take +/// turns. +#[cfg(unix)] +static RUNNER_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// A turn at the runner statics. On release it puts SIGINT back to its +/// default, so the forwarding handler `run_runner` installs doesn't make +/// this test process ignore Ctrl-C. +#[cfg(unix)] +struct RunnerTurn(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>); + +#[cfg(unix)] +impl Drop for RunnerTurn { + fn drop(&mut self) { + // SAFETY: restores the default disposition. + unsafe { + libc::signal(libc::SIGINT, libc::SIG_DFL); + } + } +} + +#[cfg(unix)] +fn runner_turn() -> RunnerTurn { + RunnerTurn(RUNNER_TESTS.lock().unwrap_or_else(|e| e.into_inner())) +} + +#[cfg(unix)] +#[tokio::test] +async fn a_failed_init_still_writes_the_exit_code_and_a_summary() { + let _turn = runner_turn(); + let bin = tempfile::tempdir().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let wd = Workdir { + dir: tmp.path().to_path_buf(), + }; + let tofu = fake_tofu( + bin.path(), + &format!("[ \"$1\" = init ] || {{ echo apply-ran; exit 0; }}\ncat <<'EOF'\n{INIT_DIAGNOSTIC}\nEOF\nexit 1"), + ); + let env = vec![("PATH".to_string(), "/usr/bin:/bin".to_string())]; + + let code = run_runner(&wd.dir, TofuOp::Apply, &tofu, &env); + + assert_eq!(code, Some(1)); + assert_eq!( + std::fs::read_to_string(wd.dir.join("apply.exit")) + .unwrap() + .trim(), + "1" + ); + assert!(!std::fs::read_to_string(wd.dir.join("apply.log")) + .unwrap() + .contains("apply-ran")); + assert!(!is_running(&wd), "the lock goes with the runner"); + let outcome = follow(&wd, Duration::from_millis(10), |_| {}) + .await + .unwrap(); + assert!(!outcome.ok); + let summary = outcome.summary.unwrap(); + assert!(summary.contains("Unsupported argument"), "{summary}"); +} + +#[cfg(unix)] +#[test] +fn the_runner_gives_tofu_only_its_environment() { + let _turn = runner_turn(); + let bin = tempfile::tempdir().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let tofu = fake_tofu(bin.path(), "env | sort"); + let env = vec![ + ("PATH".to_string(), "/usr/bin:/bin".to_string()), + ("AWS_PROFILE".to_string(), "work".to_string()), + ]; + + assert_eq!(run_runner(tmp.path(), TofuOp::Apply, &tofu, &env), Some(0)); + + let log = std::fs::read_to_string(tmp.path().join("apply.log")).unwrap(); + assert!(log.contains("AWS_PROFILE=work"), "{log}"); + for line in log + .lines() + .filter(|l| l.contains('=') && !l.starts_with("triple-c:")) + { + let name = line.split('=').next().unwrap(); + // `sh` itself adds PWD/SHLVL/_; nothing else may appear. + assert!( + ["PATH", "AWS_PROFILE", "PWD", "SHLVL", "_"].contains(&name), + "unexpected variable {name} in tofu's environment" + ); + } +} + +/// Cancel signals the runner (here: this process) alone; the runner's +/// handler forwards SIGINT to `tofu`, which stops; the runner records it. +#[cfg(unix)] +#[test] +fn cancel_reaches_tofu_through_the_runner_and_the_exit_is_recorded() { + let _turn = runner_turn(); + let bin = tempfile::tempdir().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let wd = Workdir { + dir: tmp.path().to_path_buf(), + }; + let tofu = fake_tofu( + bin.path(), + "[ \"$1\" = init ] && exit 0\necho started\nexec sleep 30", + ); + let env = vec![("PATH".to_string(), "/usr/bin:/bin".to_string())]; + + let dir = wd.dir.clone(); + let runner = std::thread::spawn(move || run_runner(&dir, TofuOp::Apply, &tofu, &env)); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !(CHILD_PID.load(Ordering::SeqCst) > 0 + && std::fs::read_to_string(wd.dir.join("apply.log")) + .unwrap_or_default() + .contains("started")) + { + assert!(std::time::Instant::now() < deadline, "apply never started"); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(is_running(&wd)); + assert_eq!(read_pid(&wd), Some(std::process::id())); + + let started = std::time::Instant::now(); + interrupt(&wd).unwrap(); + let code = runner.join().unwrap(); + + assert!( + started.elapsed() < Duration::from_secs(10), + "tofu was not interrupted" + ); + assert_eq!(code, Some(128 + libc::SIGINT)); + assert_eq!( + std::fs::read_to_string(wd.dir.join("apply.exit")) + .unwrap() + .trim(), + (128 + libc::SIGINT).to_string() + ); + assert!(!is_running(&wd)); } /// Downloads OpenTofu (network; no AWS): `cargo test aws::tofu::tests::smoke @@ -416,9 +766,7 @@ async fn smoke_the_downloaded_tofu_validates_the_boundary_module() { &boundary_vars("us-east-1"), ) .unwrap(); - let cache = root.path().join("plugin-cache"); - std::fs::create_dir_all(&cache).unwrap(); - let env = child_env(&profile_conn("none"), None, &cache, std::env::vars()); + let env = child_env(&profile_conn("none"), None, root.path(), std::env::vars()).unwrap(); for args in [ &["version"][..], &["init", "-backend=false", "-input=false", "-no-color"], diff --git a/app/src-tauri/src/url_open.rs b/app/src-tauri/src/url_open.rs index 7372386..b7a6210 100644 --- a/app/src-tauri/src/url_open.rs +++ b/app/src-tauri/src/url_open.rs @@ -187,11 +187,10 @@ fn strip_appdir_entries(value: &str, appdir: &str) -> Option { /// from the process, so the rules can be tested without a global mutex around /// the environment. Returns changes sorted by variable name so assertions are /// deterministic. -// Only the Linux spawn path reads these; the macOS/Windows path delegates to -// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the -// tests and the documentation stay in one piece on every platform. -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] -fn sanitize_child_env( +/// +/// Also used by the OpenTofu runner (`aws::tofu::tofu_env`), which undoes an +/// AppImage launcher's changes before starting `tofu`. +pub(crate) fn sanitize_child_env( current: &BTreeMap, pristine: &BTreeMap, appdir: Option<&str>, -- 2.52.0 From b7cdf0285990fcadb8ab9a202aad9bde62456541 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 22:05:31 -0800 Subject: [PATCH 11/24] fix(aws): never kill a runner that holds the lock; serialise starts start_detached stops waiting once the runner holds apply.lock and returns Started { pid: Option } (no PID: Cancel unavailable), killing only a runner that never took the lock nor finished. The check, clear, spawn and wait run under an exclusive start.lock, and a concurrent start or a held apply.lock is StartError::AlreadyRunning with nothing touched. The runner installs its SIGINT forwarder before writing apply.pid. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/tofu.rs | 157 +++++++++++++++++++------ app/src-tauri/src/aws/tofu/tests.rs | 173 ++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 36 deletions(-) diff --git a/app/src-tauri/src/aws/tofu.rs b/app/src-tauri/src/aws/tofu.rs index cf091b9..11e06fa 100644 --- a/app/src-tauri/src/aws/tofu.rs +++ b/app/src-tauri/src/aws/tofu.rs @@ -98,6 +98,8 @@ const PID_FILE: &str = "apply.pid"; const EXIT_FILE: &str = "apply.exit"; /// Held (exclusive advisory lock) by the runner for its whole life. const LOCK_FILE: &str = "apply.lock"; +/// Held by [`start_detached`] for its check → clear → spawn → wait. +const START_LOCK_FILE: &str = "start.lock"; /// The names (not values) of the variables the app gave the runner, so the /// runner can hand `tofu` exactly those (see [`tofu_env`]). const ENV_KEYS_VAR: &str = "TRIPLEC_TOFU_ENV_KEYS"; @@ -626,33 +628,105 @@ fn remove_if_present(path: &Path) -> io::Result<()> { /// AppImage has to mount itself first, which is the slow case. const RUNNER_START_TIMEOUT: Duration = Duration::from_secs(20); -/// Start `tofu init` then `apply|destroy` in a detached runner and return -/// the runner's PID. The previous run's log, PID and exit code are cleared -/// first. `env` is the runner's entire environment ([`child_env`]); -/// credentials go nowhere else. +/// Why [`start_detached`] did not start a run. +#[derive(Debug)] +pub enum StartError { + /// A runner holds this deployment's lock, or another start for it is + /// under way. Nothing in the folder was touched. + AlreadyRunning, + Failed(AwsError), +} + +impl std::fmt::Display for StartError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StartError::AlreadyRunning => { + write!(f, "A deploy is already running for this deployment") + } + StartError::Failed(e) => write!(f, "{e}"), + } + } +} + +impl From for AwsError { + fn from(e: StartError) -> Self { + match e { + StartError::AlreadyRunning => other(StartError::AlreadyRunning.to_string()), + StartError::Failed(e) => e, + } + } +} + +/// A started run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Started { + /// The runner's PID. `None` when the runner is running (it holds the + /// lock) but its PID couldn't be read: the run is followable, but Cancel + /// is unavailable. + pub pid: Option, +} + +/// Start `tofu init` then `apply|destroy` in a detached runner. The previous +/// run's log, PID and exit code are cleared first. `env` is the runner's +/// entire environment ([`child_env`]); credentials go nowhere else. /// -/// Returns only once the runner holds `apply.lock` and has written -/// `apply.pid` (or has already finished), so the run is followable and -/// cancellable the moment this returns. If it does neither within -/// [`RUNNER_START_TIMEOUT`] the spawned process is killed and this fails: -/// nothing is left running that the app can't see. Blocks for that wait -/// (normally milliseconds); call it from a blocking task. +/// The whole check → clear → spawn → wait sequence holds an exclusive lock +/// on `start.lock`, so two starts on one deployment can't interleave; the +/// loser, like a start while a runner holds `apply.lock`, gets +/// [`StartError::AlreadyRunning`] without touching anything. +/// +/// Returns once the runner holds `apply.lock` (or has already finished), so +/// the run is followable the moment this returns. A runner that has taken +/// the lock is never killed. Only one that neither took the lock nor wrote +/// `apply.exit` within [`RUNNER_START_TIMEOUT`] is killed, and this fails. +/// Blocks for that wait (normally milliseconds); call it from a blocking +/// task. #[allow(dead_code)] // used by phase-4 commands pub fn start_detached( tofu: &Path, wd: &Workdir, op: TofuOp, env: &[(String, String)], -) -> Result { - if is_running(wd) { - return Err(other("A deploy is already running for this deployment")); +) -> Result { + let program = runner_program() + .map_err(|e| StartError::Failed(other(format!("Could not start the deploy: {e}"))))?; + start_detached_with(&program, RUNNER_START_TIMEOUT, tofu, wd, op, env) +} + +/// After the runner takes its lock, how long to wait for its PID file. +const PID_GRACE: Duration = Duration::from_secs(2); + +fn start_detached_with( + program: &Path, + timeout: Duration, + tofu: &Path, + wd: &Workdir, + op: TofuOp, + env: &[(String, String)], +) -> Result { + let failed = + |e: io::Error| StartError::Failed(other(format!("Could not start the deploy: {e}"))); + let start_lock = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(wd.dir.join(START_LOCK_FILE)) + .map_err(failed)?; + match start_lock.try_lock() { + Ok(()) => {} + Err(fs::TryLockError::WouldBlock) => return Err(StartError::AlreadyRunning), + Err(fs::TryLockError::Error(e)) => return Err(failed(e)), + } + // Held, not merely "exit not written yet": a runner that is finishing + // still owns the folder until it lets go. + if lock_held(wd) { + return Err(StartError::AlreadyRunning); } - let io = |e: io::Error| other(format!("Could not start the deploy: {e}")); for f in [LOG_FILE, EXIT_FILE, PID_FILE] { - remove_if_present(&wd.dir.join(f)).map_err(io)?; + remove_if_present(&wd.dir.join(f)).map_err(failed)?; } let names: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect(); - let mut cmd = Command::new(runner_program().map_err(io)?); + let mut cmd = Command::new(program); cmd.arg(RUNNER_FLAG) .arg(&wd.dir) .arg(op.as_arg()) @@ -684,33 +758,42 @@ pub fn start_detached( const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); } - let mut child = cmd.spawn().map_err(io)?; + let mut child = cmd.spawn().map_err(failed)?; + let exited = || wd.dir.join(EXIT_FILE).exists(); - let deadline = std::time::Instant::now() + RUNNER_START_TIMEOUT; - let pid = loop { - if lock_held(wd) { - if let Some(pid) = read_pid(wd) { - break pid; - } - } - if wd.dir.join(EXIT_FILE).exists() { - break read_pid(wd).unwrap_or_else(|| child.id()); + let deadline = std::time::Instant::now() + timeout; + loop { + if lock_held(wd) || exited() { + break; } // An AppImage's launcher may exit 0 once it has handed over; only a // failure with nothing to show for it is final. if let Ok(Some(status)) = child.try_wait() { - if !status.success() && !lock_held(wd) && !wd.dir.join(EXIT_FILE).exists() { - return Err(other(format!( + if !status.success() && !lock_held(wd) && !exited() { + return Err(StartError::Failed(other(format!( "The deploy runner failed to start ({status})" - ))); + )))); } } if std::time::Instant::now() >= deadline { + // Never took the lock and never finished: nothing of a run has + // begun, so stopping it orphans nothing. let _ = child.kill(); let _ = child.wait(); - return Err(other( + return Err(StartError::Failed(other( "The deploy runner did not start in time; it was stopped", - )); + ))); + } + std::thread::sleep(Duration::from_millis(20)); + } + // The runner owns the run from here; it is not killed whatever happens. + let grace = std::time::Instant::now() + PID_GRACE; + let pid = loop { + if let Some(pid) = read_pid(wd) { + break Some(pid); + } + if exited() || std::time::Instant::now() >= grace { + break None; } std::thread::sleep(Duration::from_millis(20)); }; @@ -718,7 +801,8 @@ pub fn start_detached( std::thread::spawn(move || { let _ = child.wait(); }); - Ok(pid) + drop(start_lock); + Ok(Started { pid }) } fn read_pid(wd: &Workdir) -> Option { @@ -1069,10 +1153,6 @@ fn run_runner(dir: &Path, op: TofuOp, tofu: &Path, env: &[(String, String)]) -> if !locked { return None; } - let tmp = dir.join(format!("{PID_FILE}.tmp")); - let pid_written = fs::write(&tmp, std::process::id().to_string()) - .and_then(|_| fs::rename(&tmp, dir.join(PID_FILE))); - CHILD_PID.store(0, Ordering::SeqCst); CANCELLED.store(false, Ordering::SeqCst); #[cfg(unix)] @@ -1083,6 +1163,11 @@ fn run_runner(dir: &Path, op: TofuOp, tofu: &Path, env: &[(String, String)]) -> forward_sigint as extern "C" fn(libc::c_int) as libc::sighandler_t, ); } + // Only now that Cancel is forwarded rather than fatal does the PID (the + // Cancel target) appear. + let tmp = dir.join(format!("{PID_FILE}.tmp")); + let pid_written = fs::write(&tmp, std::process::id().to_string()) + .and_then(|_| fs::rename(&tmp, dir.join(PID_FILE))); let code = match OpenOptions::new() .create(true) diff --git a/app/src-tauri/src/aws/tofu/tests.rs b/app/src-tauri/src/aws/tofu/tests.rs index 76399ff..caaf067 100644 --- a/app/src-tauri/src/aws/tofu/tests.rs +++ b/app/src-tauri/src/aws/tofu/tests.rs @@ -788,3 +788,176 @@ async fn smoke_the_downloaded_tofu_validates_the_boundary_module() { assert!(out.status.success(), "tofu {args:?} failed"); } } + +#[test] +fn a_second_start_while_the_runner_holds_the_lock_touches_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let wd = Workdir { + dir: tmp.path().to_path_buf(), + }; + let lock = hold_lock(&wd); + std::fs::write(wd.dir.join("apply.pid"), "4242").unwrap(); + std::fs::write(wd.dir.join("apply.log"), "first run\n").unwrap(); + + let result = start_detached( + std::path::Path::new("/nonexistent/tofu"), + &wd, + TofuOp::Destroy, + &[], + ); + + assert!( + matches!(result, Err(StartError::AlreadyRunning)), + "{result:?}" + ); + assert_eq!( + std::fs::read_to_string(wd.dir.join("apply.pid")).unwrap(), + "4242" + ); + assert_eq!( + std::fs::read_to_string(wd.dir.join("apply.log")).unwrap(), + "first run\n" + ); + drop(lock); +} + +#[test] +fn a_start_already_under_way_makes_a_second_one_fail_fast() { + let tmp = tempfile::tempdir().unwrap(); + let wd = Workdir { + dir: tmp.path().to_path_buf(), + }; + let starting = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(wd.dir.join("start.lock")) + .unwrap(); + starting.try_lock().unwrap(); + std::fs::write(wd.dir.join("apply.pid"), "4242").unwrap(); + + let result = start_detached( + std::path::Path::new("/nonexistent/tofu"), + &wd, + TofuOp::Apply, + &[], + ); + + assert!( + matches!(result, Err(StartError::AlreadyRunning)), + "{result:?}" + ); + assert_eq!( + std::fs::read_to_string(wd.dir.join("apply.pid")).unwrap(), + "4242" + ); +} + +#[cfg(target_os = "linux")] +fn fake_runner(dir: &std::path::Path, body: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + // Invoked as: --tofu-runner + let path = dir.join("fake-runner"); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path +} + +/// A runner that took the lock but never wrote its PID is running a deploy: +/// the start returns (Cancel unavailable) and does not kill it. +#[cfg(target_os = "linux")] +#[test] +fn a_runner_holding_the_lock_without_a_pid_is_never_killed() { + if !std::path::Path::new("/usr/bin/flock").exists() { + eprintln!("skipped: no flock(1)"); + return; + } + let bin = tempfile::tempdir().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let wd = Workdir { + dir: tmp.path().to_path_buf(), + }; + let program = fake_runner(bin.path(), "exec /usr/bin/flock \"$2/apply.lock\" sleep 6"); + let env = vec![("PATH".to_string(), "/usr/bin:/bin".to_string())]; + + let started = start_detached_with( + &program, + Duration::from_secs(10), + std::path::Path::new("/unused"), + &wd, + TofuOp::Apply, + &env, + ) + .unwrap(); + + assert_eq!(started, Started { pid: None }); + assert!(is_running(&wd), "the runner must still be alive"); + // And the start lock was released for the next start, which is refused + // because a runner holds the deployment. + let again = start_detached(std::path::Path::new("/unused"), &wd, TofuOp::Apply, &[]); + assert!( + matches!(again, Err(StartError::AlreadyRunning)), + "{again:?}" + ); +} + +/// Only a runner that never took the lock and never finished is stopped. +#[cfg(target_os = "linux")] +#[test] +fn a_runner_that_never_takes_the_lock_is_stopped_and_reported() { + let bin = tempfile::tempdir().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let wd = Workdir { + dir: tmp.path().to_path_buf(), + }; + let program = fake_runner(bin.path(), "exec sleep 30"); + let env = vec![("PATH".to_string(), "/usr/bin:/bin".to_string())]; + + let started = std::time::Instant::now(); + let result = start_detached_with( + &program, + Duration::from_millis(300), + std::path::Path::new("/unused"), + &wd, + TofuOp::Apply, + &env, + ); + + assert!( + matches!(&result, Err(StartError::Failed(e)) if e.to_string().contains("did not start in time")), + "{result:?}" + ); + assert!(started.elapsed() < Duration::from_secs(5)); +} + +/// A runner that takes the lock and writes its PID is reported with it. +#[cfg(target_os = "linux")] +#[test] +fn a_started_runner_reports_its_pid() { + if !std::path::Path::new("/usr/bin/flock").exists() { + eprintln!("skipped: no flock(1)"); + return; + } + let bin = tempfile::tempdir().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let wd = Workdir { + dir: tmp.path().to_path_buf(), + }; + let program = fake_runner( + bin.path(), + "exec /usr/bin/flock \"$2/apply.lock\" sh -c 'echo 31337 > \"$0/apply.pid\"; sleep 3' \"$2\"", + ); + let env = vec![("PATH".to_string(), "/usr/bin:/bin".to_string())]; + + let started = start_detached_with( + &program, + Duration::from_secs(10), + std::path::Path::new("/unused"), + &wd, + TofuOp::Apply, + &env, + ) + .unwrap(); + + assert_eq!(started, Started { pid: Some(31337) }); +} -- 2.52.0 From f26388969be410abf18f189eeb7ca9d8ade27b0e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 22:16:05 -0800 Subject: [PATCH 12/24] feat(aws): state bucket, and detection of environments, locks and onboarding Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/Cargo.lock | 124 ++- app/src-tauri/Cargo.toml | 1 + app/src-tauri/src/aws/environments.rs | 999 ++++++++++++++++++ app/src-tauri/src/aws/environments/tests.rs | 703 ++++++++++++ app/src-tauri/src/aws/error.rs | 10 + app/src-tauri/src/aws/mod.rs | 1 + app/src-tauri/src/aws/tofu.rs | 37 +- app/src-tauri/src/aws/tofu/tests.rs | 36 + app/src/components/settings/HostsSettings.tsx | 2 + app/src/lib/aws-error.ts | 1 + app/src/lib/types.ts | 1 + 11 files changed, 1898 insertions(+), 17 deletions(-) create mode 100644 app/src-tauri/src/aws/environments.rs create mode 100644 app/src-tauri/src/aws/environments/tests.rs diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index bee73dc..f73bdd9 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -298,7 +298,7 @@ dependencies = [ "fastrand", "hex", "http", - "sha1", + "sha1 0.10.6", "time", "tokio", "tracing", @@ -350,6 +350,7 @@ dependencies = [ "aws-credential-types", "aws-sigv4", "aws-smithy-async", + "aws-smithy-eventstream", "aws-smithy-http", "aws-smithy-runtime", "aws-smithy-runtime-api", @@ -438,6 +439,42 @@ dependencies = [ "tracing", ] +[[package]] +name = "aws-sdk-s3" +version = "1.159.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5c431c5db2f1f6f536c2a19425575e28273859c6ab399d9ac957f73678c7db" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http", + "http-body", + "lru", + "percent-encoding", + "regex-lite", + "sha2 0.11.1", + "tracing", + "url", +] + [[package]] name = "aws-sdk-secretsmanager" version = "1.128.0" @@ -565,6 +602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57a4393e6addc3380632887b0998d06ad5f099ab5c478be0a3eeedecf8604161" dependencies = [ "aws-credential-types", + "aws-smithy-eventstream", "aws-smithy-http", "aws-smithy-runtime-api", "aws-smithy-types", @@ -590,12 +628,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "aws-smithy-checksums" +version = "0.65.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2f2d12a431aab3703d55277278bad45f02208569b11506220b95fdc1ef0507" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http", + "http-body", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1 0.11.0", + "sha2 0.11.1", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.61.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c8a643d0e114cae39e45415e3225793a6396698b7779a0c9e3ede731d8fc71" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + [[package]] name = "aws-smithy-http" version = "0.64.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8" dependencies = [ + "aws-smithy-eventstream", "aws-smithy-runtime-api", "aws-smithy-types", "bytes", @@ -806,7 +877,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", "tokio-tungstenite", @@ -1359,6 +1430,16 @@ dependencies = [ "libc", ] +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -4272,6 +4353,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -4332,6 +4422,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.0" @@ -6208,6 +6308,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha1dc" version = "0.1.4" @@ -6362,6 +6473,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7278,6 +7395,7 @@ dependencies = [ "aws-sdk-ecs", "aws-sdk-iam", "aws-sdk-kms", + "aws-sdk-s3", "aws-sdk-secretsmanager", "aws-sdk-ssm", "aws-sdk-sts", @@ -7339,7 +7457,7 @@ dependencies = [ "httparse", "log", "rand 0.9.5", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "utf-8", ] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 58b2bc2..9457a6b 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -76,6 +76,7 @@ aws-runtime = { version = "1.10.4", default-features = false } # The default sleep and time source for the hand-built SdkConfig (no env/profile # loading); the version and feature already in the tree. aws-smithy-async = { version = "1.3.1", default-features = false, features = ["rt-tokio"] } +aws-sdk-s3 = { version = "1.159.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } # Linux only: WebKitGTK refuses getUserMedia until the embedder enables media # streams and answers the permission request (see src/linux_media.rs). Same diff --git a/app/src-tauri/src/aws/environments.rs b/app/src-tauri/src/aws/environments.rs new file mode 100644 index 0000000..c163671 --- /dev/null +++ b/app/src-tauri/src/aws/environments.rs @@ -0,0 +1,999 @@ +//! The state bucket, and what the account holds: environments (by their SSM +//! record and their state object), interrupted or running deployments (by +//! the S3-native `.tflock` objects) and the caller's own onboarding. +//! +//! Everything read back from AWS here is untrusted: an environment record or +//! a lock object is written by whoever can write the parameter or the +//! bucket. Records are validated field by field (reasons name the field, +//! never echo the value), lock contents are capped and sanitized, and only +//! key shapes the app itself builds (`tofu_modules::state_key`) are listed. +//! +//! Workspace users can read only the records and `iam:GetRole` (D8): a +//! denied lookup is `Unknown` (or no objects at all), never "missing". + +use std::collections::BTreeMap; +use std::future::Future; + +use aws_config::SdkConfig; +use aws_sdk_s3::config::http::HttpResponse; +use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError}; +use aws_sdk_s3::types::{ + BucketLocationConstraint, BucketVersioningStatus, CreateBucketConfiguration, + PublicAccessBlockConfiguration, ServerSideEncryption, ServerSideEncryptionByDefault, + ServerSideEncryptionConfiguration, ServerSideEncryptionRule, Tag, Tagging, + VersioningConfiguration, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; + +use super::error::{classify_sdk_error, AwsError}; +use super::identity::CallerIdentity; +use super::tofu_modules::{ + environment_parameter_name, owner_key, state_bucket_name, state_key, user_role_names, + validate_environment_name, Deployment, BOUNDARY_POLICY_NAME, +}; +use crate::models::host_connection::validate_supported_region; + +/// An environment record (the module's SSM parameter) or a lock object +/// larger than this is refused unread. +const MAX_UNTRUSTED_BYTES: usize = 4096; +/// A lock string shown to the user is at most this many characters. +const MAX_LOCK_STRING_CHARS: usize = 128; +/// A lock younger than this is a deploy in progress somewhere else; an older +/// one is an apply that died holding it. +const LIVE_LOCK_SECS: u64 = 2 * 3600; +const RECORD_SCHEMA: u64 = 1; +/// Bounds on what one scan lists, so a bucket or parameter path full of +/// junk can't make it unbounded. +const MAX_RECORDS: usize = 500; +const MAX_OBJECTS_PER_PREFIX: usize = 5000; +const MAX_LOCKS: usize = 200; + +/// An environment's record, schema 1 (`aws/terraform/environment/main.tf`). +#[allow(dead_code)] // used by phase-4 commands +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +pub struct EnvironmentInfo { + pub schema: u32, + pub name: String, + pub template_version: u32, + pub cluster_arn: String, + pub security_group_id: String, + pub subnet_ids: Vec, + pub assign_public_ip: bool, + pub kms_key_arn: String, + pub log_group: String, + pub network_mode: String, + pub tenancy: String, +} + +fn field<'a>(obj: &'a Map, name: &str) -> Result<&'a Value, String> { + obj.get(name).ok_or_else(|| format!("{name} is missing")) +} + +fn str_field<'a>(obj: &'a Map, name: &str) -> Result<&'a str, String> { + field(obj, name)? + .as_str() + .ok_or_else(|| format!("{name} is not a string")) +} + +/// `` followed by 1 to 32 lowercase hex characters. +fn is_hex_id(s: &str, prefix: &str) -> bool { + s.strip_prefix(prefix).is_some_and(|hex| { + !hex.is_empty() + && hex.len() <= 32 + && hex.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f')) + }) +} + +/// Validate an environment record read from the parameter for `path_name`. +/// Every field is checked against what the module writes, and every ARN +/// must be in `account` and `region`. The error names the field, never its +/// value. +#[allow(dead_code)] // used by phase-4 commands +pub fn parse_environment_record( + path_name: &str, + value: &str, + account: &str, + region: &str, +) -> Result { + let name = validate_environment_name(path_name) + .map_err(|_| "the parameter's path is not a valid environment name".to_string())?; + if value.len() > MAX_UNTRUSTED_BYTES { + return Err("the record is larger than 4 KiB".into()); + } + let parsed: Value = + serde_json::from_str(value).map_err(|_| "the record is not JSON".to_string())?; + let obj = parsed + .as_object() + .ok_or_else(|| "the record is not a JSON object".to_string())?; + + if field(obj, "schema")?.as_u64() != Some(RECORD_SCHEMA) { + return Err("schema is not 1".into()); + } + if str_field(obj, "name")? != name { + return Err("name does not match the parameter's path".into()); + } + let template_version = field(obj, "template_version")? + .as_u64() + .and_then(|v| u32::try_from(v).ok()) + .filter(|v| *v >= 1) + .ok_or_else(|| "template_version is not a version number".to_string())?; + + let cluster_arn = str_field(obj, "cluster_arn")?; + if cluster_arn != format!("arn:aws:ecs:{region}:{account}:cluster/triple-c-{name}") { + return Err( + "cluster_arn is not this environment's cluster in this account and region".into(), + ); + } + let security_group_id = str_field(obj, "security_group_id")?; + if !is_hex_id(security_group_id, "sg-") { + return Err("security_group_id is not a security group id".into()); + } + let subnet_ids: Vec = field(obj, "subnet_ids")? + .as_array() + .filter(|a| a.len() == 2) + .and_then(|a| { + a.iter() + .map(|s| { + s.as_str() + .filter(|s| is_hex_id(s, "subnet-")) + .map(String::from) + }) + .collect::>>() + }) + .ok_or_else(|| "subnet_ids is not exactly 2 subnet ids".to_string())?; + let assign_public_ip = field(obj, "assign_public_ip")? + .as_bool() + .ok_or_else(|| "assign_public_ip is not true or false".to_string())?; + let kms_key_arn = str_field(obj, "kms_key_arn")?; + let key_prefix = format!("arn:aws:kms:{region}:{account}:key/"); + let key_ok = kms_key_arn.strip_prefix(&key_prefix).is_some_and(|id| { + !id.is_empty() + && id.len() <= 64 + && id + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') + }); + if !key_ok { + return Err("kms_key_arn is not a KMS key in this account and region".into()); + } + let log_group = str_field(obj, "log_group")?; + if log_group != format!("/triple-c/{name}") { + return Err("log_group is not this environment's log group".into()); + } + let network_mode = str_field(obj, "network_mode")?; + if !matches!(network_mode, "public" | "private-nat" | "existing") { + return Err("network_mode is not a known network mode".into()); + } + if str_field(obj, "tenancy")? != "strict" { + return Err("tenancy is not strict".into()); + } + + Ok(EnvironmentInfo { + schema: RECORD_SCHEMA as u32, + name: name.clone(), + template_version, + cluster_arn: cluster_arn.to_string(), + security_group_id: security_group_id.to_string(), + subnet_ids, + assign_public_ip, + kms_key_arn: kms_key_arn.to_string(), + log_group: log_group.to_string(), + network_mode: network_mode.to_string(), + tenancy: "strict".to_string(), + }) +} + +/// A held state lock, as shown to the user. +#[allow(dead_code)] // used by phase-4 commands +#[derive(Serialize, Clone, Debug, PartialEq)] +pub struct LockInfo { + pub who: String, + pub created: String, + pub operation: String, + /// From the lock object's `LastModified`, not its (untrusted) `Created`. + pub age_secs: u64, + /// This machine's runner is applying the deployment. + pub local_process: bool, +} + +/// Control characters and the invisible/bidi formatting characters that +/// could disguise text are dropped; the rest is cut to +/// [`MAX_LOCK_STRING_CHARS`]. +fn sanitize_lock_string(s: &str) -> String { + s.chars() + .filter(|c| { + !c.is_control() + && !matches!( + *c, + '\u{200b}'..='\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2060}'..='\u{2064}' + | '\u{2066}'..='\u{2069}' + | '\u{feff}' + ) + }) + .take(MAX_LOCK_STRING_CHARS) + .collect() +} + +/// A lock from its object's contents (S3-native locking's JSON: `ID`, +/// `Operation`, `Info`, `Who`, `Version`, `Created`, `Path`). Contents that +/// are too large or not JSON still mean a held lock, with nothing to show. +#[allow(dead_code)] // used by phase-4 commands +pub fn lock_info(json: &str, age_secs: u64, local_process: bool) -> LockInfo { + let parsed = (json.len() <= MAX_UNTRUSTED_BYTES) + .then(|| serde_json::from_str::(json).ok()) + .flatten(); + let get = |k: &str| { + parsed + .as_ref() + .and_then(|v| v.get(k)) + .and_then(Value::as_str) + .map(sanitize_lock_string) + .unwrap_or_default() + }; + LockInfo { + who: get("Who"), + created: get("Created"), + operation: get("Operation"), + age_secs, + local_process, + } +} + +#[allow(dead_code)] // used by phase-4 commands +#[derive(Serialize, Clone, Debug, PartialEq)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum EnvironmentState { + /// Record and state, at the app's module version. + Ready, + /// The lock is held by a local runner or recently by someone else. + Deploying { lock: Option }, + /// Record and state at an older module version. + UpdateAvailable, + /// State without a record (a failed apply), or a stale lock. + Incomplete { lock: Option }, + /// A record whose state this caller can't see (another bucket, an admin + /// bundle, or no bucket access). + ExternallyManaged, + /// The record failed validation. + UnreadableRecord { reason: String }, +} + +#[allow(dead_code)] // used by phase-4 commands +#[derive(Serialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum EnvironmentAction { + Join, + Update, + Repair, + Destroy, + Follow, + Unlock, +} + +#[allow(dead_code)] // used by phase-4 commands +#[derive(Serialize, Clone, Debug, PartialEq)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum OnboardingState { + Joined, + NotJoined, + InProgress, + Incomplete, +} + +#[allow(dead_code)] // used by phase-4 commands +#[derive(Serialize, Clone, Debug, PartialEq)] +pub struct DetectedEnvironment { + pub name: String, + pub template_version: Option, + pub state: EnvironmentState, + pub actions: Vec, + pub info: Option, + /// The caller's onboarding; resolved only for joinable environments. + pub onboarding: Option, +} + +#[allow(dead_code)] // used by phase-4 commands +#[derive(Serialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Knowable { + Yes, + No, + /// Denied: this caller can't tell. + Unknown, +} + +#[allow(dead_code)] // used by phase-4 commands +#[derive(Serialize, Clone, Debug)] +pub struct EnvironmentScan { + pub region: String, + pub account: String, + /// `aws:userid`. + pub owner_id: String, + pub owner_key: String, + pub state_bucket: Knowable, + pub boundary: Knowable, + pub environments: Vec, +} + +/// The state bucket's listing under `environments/`, `users/` and +/// `boundary/`. +#[allow(dead_code)] // used by phase-4 commands +#[derive(Debug, Default)] +pub struct StateObjects { + /// State object keys. + pub states: Vec, + /// (`.tflock`, its contents, age in seconds from its + /// `LastModified`). + pub locks: Vec<(String, String, u64)>, +} + +impl StateObjects { + fn has_state(&self, key: &str) -> bool { + self.states.iter().any(|k| k == key) + } + + /// The lock on `state_key`, as (contents, age). + fn lock(&self, state_key: &str) -> Option<(&str, u64)> { + self.locks + .iter() + .find(|(k, _, _)| k.strip_suffix(".tflock") == Some(state_key)) + .map(|(_, json, age)| (json.as_str(), *age)) + } +} + +/// The environment name of an `environments//terraform.tfstate[.tflock]` +/// key, if it is one the app could have written. +fn environment_of_key(key: &str) -> Option<&str> { + let key = key.strip_suffix(".tflock").unwrap_or(key); + let env = key + .strip_prefix("environments/")? + .strip_suffix("/terraform.tfstate")?; + (validate_environment_name(env).is_ok()).then_some(env) +} + +/// Whether this machine's runner is applying a deployment +/// (`tofu::is_running(&tofu::workdir_for(..))`). +pub type RunningLocally<'a> = &'a (dyn Fn(Deployment) -> bool + Sync); + +/// One environment from its record (if any), the bucket listing (if this +/// caller can read it) and whether a local runner is applying it. +fn detect_one( + name: &str, + record: Option>, + objects: Option<&StateObjects>, + running_locally: RunningLocally, + current_version: u32, +) -> DetectedEnvironment { + use EnvironmentAction::*; + let key = state_key(Deployment::Environment(name)); + let local = running_locally(Deployment::Environment(name)); + let lock = objects + .and_then(|o| o.lock(&key)) + .map(|(json, age)| lock_info(json, age, local)); + let has_state = objects.is_some_and(|o| o.has_state(&key)); + let (info, reason) = match record { + Some(Ok(info)) => (Some(info), None), + Some(Err(reason)) => (None, Some(reason)), + None => (None, None), + }; + let template_version = info.as_ref().map(|i| i.template_version); + + let (state, actions) = if local { + (EnvironmentState::Deploying { lock }, vec![Follow]) + } else if lock.as_ref().is_some_and(|l| l.age_secs < LIVE_LOCK_SECS) { + (EnvironmentState::Deploying { lock }, vec![]) + } else if lock.is_some() { + // Stale, and not ours (a local runner was handled above). + ( + EnvironmentState::Incomplete { lock }, + vec![Repair, Destroy, Unlock], + ) + } else if let Some(reason) = reason { + // A re-apply rewrites the record, so Repair is offered when the + // state is ours. Never Destroy: the environment may be working (D6). + let actions = if has_state { vec![Repair] } else { vec![] }; + (EnvironmentState::UnreadableRecord { reason }, actions) + } else if let Some(info) = &info { + if !has_state { + (EnvironmentState::ExternallyManaged, vec![Join]) + } else if info.template_version < current_version { + (EnvironmentState::UpdateAvailable, vec![Join, Update]) + } else { + (EnvironmentState::Ready, vec![Join]) + } + } else { + // Only reachable with state and no record: a failed apply. + ( + EnvironmentState::Incomplete { lock: None }, + vec![Repair, Destroy], + ) + }; + + DetectedEnvironment { + name: name.to_string(), + template_version, + state, + actions, + info, + onboarding: None, + } +} + +/// Combine the records and the bucket listing into one list of +/// environments, sorted by name. `objects` is `None` when the caller can't +/// read the bucket (or there is none). Onboarding is left unresolved +/// ([`scan`] does that). +#[allow(dead_code)] // used by phase-4 commands +pub fn merge( + records: Vec<(String, Result)>, + objects: Option<&StateObjects>, + running_locally: RunningLocally, + current_version: u32, +) -> Vec { + let mut by_name: BTreeMap>> = BTreeMap::new(); + for (name, record) in records { + by_name.insert(name, Some(record)); + } + if let Some(o) = objects { + let keys = o.states.iter().chain(o.locks.iter().map(|(k, _, _)| k)); + for env in keys.filter_map(|k| environment_of_key(k)) { + by_name.entry(env.to_string()).or_insert(None); + } + } + by_name + .into_iter() + .map(|(name, record)| detect_one(&name, record, objects, running_locally, current_version)) + .collect() +} + +/// The caller's onboarding from the bucket alone, or `None` when only +/// `iam:GetRole` on the task role can tell. +fn onboarding_from_objects( + objects: Option<&StateObjects>, + deployment: Deployment, + local: bool, +) -> Option { + if local { + return Some(OnboardingState::InProgress); + } + let objects = objects?; + let key = state_key(deployment); + match objects.lock(&key) { + Some((_, age)) if age < LIVE_LOCK_SECS => Some(OnboardingState::InProgress), + Some(_) => Some(OnboardingState::Incomplete), + None if objects.has_state(&key) => Some(OnboardingState::Joined), + None => None, + } +} + +/// What detection asks AWS. Each method returns a `Send` future so [`scan`] +/// is `Send` (a Tauri command needs that) for any implementation; an impl +/// may still write them as `async fn`. Access denied is an answer +/// (`Unknown` / `None`), not an error. +#[allow(dead_code)] // used by phase-4 commands +pub trait DetectApi: Send + Sync { + /// (parameter name, value) under `/triple-c/environments/`. + fn environment_records( + &self, + ) -> impl Future, AwsError>> + Send; + /// `None` = the bucket is missing or this caller can't list it. + fn state_objects( + &self, + bucket: &str, + ) -> impl Future, AwsError>> + Send; + fn bucket_exists( + &self, + bucket: &str, + ) -> impl Future> + Send; + fn boundary_exists( + &self, + account: &str, + ) -> impl Future> + Send; + fn role_exists(&self, name: &str) -> impl Future> + Send; +} + +/// Everything the account holds for this caller in `region`. +#[allow(dead_code)] // used by phase-4 commands +pub async fn scan( + d: &D, + who: &CallerIdentity, + region: &str, + running_locally: RunningLocally<'_>, +) -> Result { + let prefix = environment_parameter_name(""); + let records = d + .environment_records() + .await? + .into_iter() + .filter_map(|(param, value)| { + let name = param.strip_prefix(&prefix)?; + if validate_environment_name(name).is_err() { + log::warn!("Ignoring an environment record under an invalid name"); + return None; + } + let parsed = parse_environment_record(name, &value, &who.account, region); + Some((name.to_string(), parsed)) + }) + .collect(); + + let bucket = state_bucket_name(&who.account, region); + let state_bucket = d.bucket_exists(&bucket).await?; + let objects = match state_bucket { + Knowable::Yes => d.state_objects(&bucket).await?, + _ => None, + }; + let boundary = d.boundary_exists(&who.account).await?; + + let mut environments = merge( + records, + objects.as_ref(), + running_locally, + super::tofu_modules::ENVIRONMENT_MODULE_VERSION, + ); + let key = owner_key(&who.user_id); + for env in &mut environments { + if !env.actions.contains(&EnvironmentAction::Join) { + continue; + } + let deployment = Deployment::User { + env: &env.name, + owner_key: &key, + }; + let from_objects = + onboarding_from_objects(objects.as_ref(), deployment, running_locally(deployment)); + env.onboarding = Some(match from_objects { + Some(state) => state, + None => { + let (task_role, _) = user_role_names(&env.name, &key); + match d.role_exists(&task_role).await? { + Knowable::Yes => OnboardingState::Joined, + Knowable::No | Knowable::Unknown => OnboardingState::NotJoined, + } + } + }); + } + + Ok(EnvironmentScan { + region: region.to_string(), + account: who.account.clone(), + owner_id: who.user_id.clone(), + owner_key: key, + state_bucket, + boundary, + environments, + }) +} + +/// What a failed lookup means. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Lookup { + Missing, + Denied, + Error, +} + +/// From a service error's code and HTTP status. HeadBucket has no body, so +/// its 403/404 carry no code; a 403 is also what an `expected_bucket_owner` +/// mismatch gives, which is right: a bucket of another account's is not +/// ours to know about. +fn lookup_outcome(code: Option<&str>, status: Option) -> Lookup { + const MISSING: &[&str] = &["NotFound", "NoSuchEntity", "NoSuchBucket", "NoSuchKey"]; + const DENIED: &[&str] = &["AccessDenied", "AccessDeniedException", "Forbidden"]; + match (code, status) { + (Some(c), _) if MISSING.contains(&c) => Lookup::Missing, + (Some(c), _) if DENIED.contains(&c) => Lookup::Denied, + (None, Some(404)) => Lookup::Missing, + (None, Some(403)) => Lookup::Denied, + (_, Some(403)) => Lookup::Denied, + _ => Lookup::Error, + } +} + +/// Code and HTTP status of an SDK error (no status for a non-service +/// error). `SdkError` is the same type in every SDK crate. +fn error_parts( + err: &SdkError, +) -> (Option, Option) { + ( + err.code().map(str::to_string), + err.raw_response().map(|r| r.status().as_u16()), + ) +} + +/// Yes / No (missing) / Unknown (denied) from a lookup's result. +fn knowable( + action: &str, + res: Result>, +) -> Result +where + E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static, +{ + match res { + Ok(_) => Ok(Knowable::Yes), + Err(e) => { + let (code, status) = error_parts(&e); + match lookup_outcome(code.as_deref(), status) { + Lookup::Missing => Ok(Knowable::No), + Lookup::Denied => Ok(Knowable::Unknown), + Lookup::Error => Err(classify_sdk_error(action, &e)), + } + } + } +} + +/// The real thing, from a connection's `SdkConfig`. +#[allow(dead_code)] // used by phase-4 commands +pub struct SdkDetectApi { + ssm: aws_sdk_ssm::Client, + s3: aws_sdk_s3::Client, + iam: aws_sdk_iam::Client, + /// `expected_bucket_owner` on every S3 call. + account: String, +} + +impl SdkDetectApi { + #[allow(dead_code)] // used by phase-4 commands + pub fn new(cfg: &SdkConfig, account: &str) -> Self { + SdkDetectApi { + ssm: aws_sdk_ssm::Client::new(cfg), + s3: aws_sdk_s3::Client::new(cfg), + iam: aws_sdk_iam::Client::new(cfg), + account: account.to_string(), + } + } + + /// Keys (and their age) under `prefix`, or `None` when listing is + /// denied or the bucket is gone. + async fn list( + &self, + bucket: &str, + prefix: &str, + ) -> Result>, AwsError> { + const ACTION: &str = "s3:ListBucket"; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let mut out = Vec::new(); + let mut token: Option = None; + loop { + let res = self + .s3 + .list_objects_v2() + .bucket(bucket) + .prefix(prefix) + .expected_bucket_owner(&self.account) + .set_continuation_token(token.take()) + .send() + .await; + let page = match res { + Ok(p) => p, + Err(e) => { + let (code, status) = error_parts(&e); + return match lookup_outcome(code.as_deref(), status) { + Lookup::Missing | Lookup::Denied => Ok(None), + Lookup::Error => Err(classify_sdk_error(ACTION, &e)), + }; + } + }; + for obj in page.contents() { + let Some(key) = obj.key() else { continue }; + let age = obj + .last_modified() + .map(|t| now.saturating_sub(t.secs()).max(0) as u64) + .unwrap_or(0); + out.push((key.to_string(), age, obj.size().unwrap_or(0))); + } + if out.len() >= MAX_OBJECTS_PER_PREFIX { + log::warn!("The state bucket lists more than {MAX_OBJECTS_PER_PREFIX} objects under {prefix}; ignoring the rest"); + break; + } + match page.next_continuation_token() { + Some(t) if page.is_truncated() == Some(true) => token = Some(t.to_string()), + _ => break, + } + } + Ok(Some(out)) + } + + /// A lock object's contents; empty when it is too large to read or can't + /// be read, `None` when it was released since the listing. + async fn lock_contents( + &self, + bucket: &str, + key: &str, + size: i64, + ) -> Result, AwsError> { + const ACTION: &str = "s3:GetObject"; + if size < 0 || size as usize > MAX_UNTRUSTED_BYTES { + return Ok(Some(String::new())); + } + let res = self + .s3 + .get_object() + .bucket(bucket) + .key(key) + .expected_bucket_owner(&self.account) + .send() + .await; + let obj = match res { + Ok(o) => o, + Err(e) => { + let (code, status) = error_parts(&e); + return match lookup_outcome(code.as_deref(), status) { + Lookup::Missing => Ok(None), + Lookup::Denied => Ok(Some(String::new())), + Lookup::Error => Err(classify_sdk_error(ACTION, &e)), + }; + } + }; + if obj + .content_length() + .is_some_and(|n| n < 0 || n as usize > MAX_UNTRUSTED_BYTES) + { + return Ok(Some(String::new())); + } + let bytes = obj + .body + .collect() + .await + .map_err(|e| AwsError::Other { + message: format!("Could not read a state lock: {e}"), + })? + .into_bytes(); + if bytes.len() > MAX_UNTRUSTED_BYTES { + return Ok(Some(String::new())); + } + Ok(Some(String::from_utf8_lossy(&bytes).into_owned())) + } +} + +impl DetectApi for SdkDetectApi { + async fn environment_records(&self) -> Result, AwsError> { + const ACTION: &str = "ssm:GetParametersByPath"; + let path = environment_parameter_name(""); + let mut out = Vec::new(); + let mut token: Option = None; + loop { + let page = self + .ssm + .get_parameters_by_path() + .path(path.trim_end_matches('/')) + .recursive(false) + .with_decryption(false) + .set_next_token(token.take()) + .send() + .await + .map_err(|e| classify_sdk_error(ACTION, &e))?; + for p in page.parameters() { + if let (Some(name), Some(value)) = (p.name(), p.value()) { + out.push((name.to_string(), value.to_string())); + } + } + if out.len() >= MAX_RECORDS { + log::warn!("More than {MAX_RECORDS} environment records; ignoring the rest"); + break; + } + match page.next_token() { + Some(t) => token = Some(t.to_string()), + None => break, + } + } + Ok(out) + } + + async fn state_objects(&self, bucket: &str) -> Result, AwsError> { + let mut objects = StateObjects::default(); + let mut lock_keys = Vec::new(); + for prefix in ["environments/", "users/", "boundary/"] { + let Some(listed) = self.list(bucket, prefix).await? else { + return Ok(None); + }; + for (key, age, size) in listed { + if key.ends_with("/terraform.tfstate") { + objects.states.push(key); + } else if key.ends_with("/terraform.tfstate.tflock") { + lock_keys.push((key, age, size)); + } + } + } + if lock_keys.len() > MAX_LOCKS { + log::warn!("More than {MAX_LOCKS} state locks; ignoring the rest"); + lock_keys.truncate(MAX_LOCKS); + } + for (key, age, size) in lock_keys { + if let Some(json) = self.lock_contents(bucket, &key, size).await? { + objects.locks.push((key, json, age)); + } + } + Ok(Some(objects)) + } + + async fn bucket_exists(&self, bucket: &str) -> Result { + let res = self + .s3 + .head_bucket() + .bucket(bucket) + .expected_bucket_owner(&self.account) + .send() + .await; + knowable("s3:ListBucket", res) + } + + async fn boundary_exists(&self, account: &str) -> Result { + let arn = format!("arn:aws:iam::{account}:policy/{BOUNDARY_POLICY_NAME}"); + let res = self.iam.get_policy().policy_arn(arn).send().await; + knowable("iam:GetPolicy", res) + } + + async fn role_exists(&self, name: &str) -> Result { + let res = self.iam.get_role().role_name(name).send().await; + knowable("iam:GetRole", res) + } +} + +/// `s3:CreateBucket`'s answer, by error code: `Some(Ok)` for a bucket this +/// account already owns, `Some(Err)` for a name another account holds, +/// `None` for an ordinary error to classify. +fn create_bucket_outcome(code: Option<&str>, bucket: &str) -> Option> { + match code { + Some("BucketAlreadyOwnedByYou") => Some(Ok(())), + Some("BucketAlreadyExists") => Some(Err(AwsError::StateBucketTaken { + bucket: bucket.to_string(), + })), + _ => None, + } +} + +const STATE_TAG: (&str, &str) = ("triple-c:component", "state"); + +fn state_tag() -> Tag { + Tag::builder() + .key(STATE_TAG.0) + .value(STATE_TAG.1) + .build() + .expect("key and value are set") +} + +/// The component tag, and a `LocationConstraint` everywhere but +/// `us-east-1` (which refuses one). +fn create_bucket_configuration(region: &str) -> CreateBucketConfiguration { + let b = CreateBucketConfiguration::builder().tags(state_tag()); + if region == "us-east-1" { + b.build() + } else { + b.location_constraint(BucketLocationConstraint::from(region)) + .build() + } +} + +/// Deny every request to the bucket that doesn't use TLS. +fn state_bucket_policy(bucket: &str) -> String { + json!({ + "Version": "2012-10-17", + "Statement": [{ + "Sid": "DenyInsecureTransport", + "Effect": "Deny", + "Principal": "*", + "Action": "s3:*", + "Resource": [ + format!("arn:aws:s3:::{bucket}"), + format!("arn:aws:s3:::{bucket}/*"), + ], + "Condition": { "Bool": { "aws:SecureTransport": "false" } }, + }], + }) + .to_string() +} + +/// Create (or adopt) `triple-c-tfstate--` and configure it: +/// the component tag, versioning, SSE-S3, all four public-access blocks and +/// a TLS-only policy. Idempotent. Every call after the create carries +/// `expected_bucket_owner`, so a bucket of another account's is never +/// configured or trusted. +#[allow(dead_code)] // used by phase-4 commands +pub async fn ensure_state_bucket( + cfg: &SdkConfig, + account: &str, + region: &str, +) -> Result { + let region = + validate_supported_region(region).map_err(|message| AwsError::Other { message })?; + let bucket = state_bucket_name(account, ®ion); + let s3 = aws_sdk_s3::Client::new(cfg); + + if let Err(e) = s3 + .create_bucket() + .bucket(&bucket) + .create_bucket_configuration(create_bucket_configuration(®ion)) + .send() + .await + { + match create_bucket_outcome(e.code(), &bucket) { + Some(Ok(())) => {} + Some(Err(taken)) => return Err(taken), + None => return Err(classify_sdk_error("s3:CreateBucket", &e)), + } + } + // us-east-1 answers a create of a bucket this account owns with success, + // not BucketAlreadyOwnedByYou: confirm ownership before configuring. + s3.head_bucket() + .bucket(&bucket) + .expected_bucket_owner(account) + .send() + .await + .map_err(|e| classify_sdk_error("s3:ListBucket", &e))?; + + s3.put_bucket_tagging() + .bucket(&bucket) + .expected_bucket_owner(account) + .tagging( + Tagging::builder() + .tag_set(state_tag()) + .build() + .expect("tag set is set"), + ) + .send() + .await + .map_err(|e| classify_sdk_error("s3:PutBucketTagging", &e))?; + s3.put_bucket_versioning() + .bucket(&bucket) + .expected_bucket_owner(account) + .versioning_configuration( + VersioningConfiguration::builder() + .status(BucketVersioningStatus::Enabled) + .build(), + ) + .send() + .await + .map_err(|e| classify_sdk_error("s3:PutBucketVersioning", &e))?; + s3.put_bucket_encryption() + .bucket(&bucket) + .expected_bucket_owner(account) + .server_side_encryption_configuration( + ServerSideEncryptionConfiguration::builder() + .rules( + ServerSideEncryptionRule::builder() + .apply_server_side_encryption_by_default( + ServerSideEncryptionByDefault::builder() + .sse_algorithm(ServerSideEncryption::Aes256) + .build() + .expect("algorithm is set"), + ) + .build(), + ) + .build() + .expect("rules are set"), + ) + .send() + .await + .map_err(|e| classify_sdk_error("s3:PutEncryptionConfiguration", &e))?; + s3.put_public_access_block() + .bucket(&bucket) + .expected_bucket_owner(account) + .public_access_block_configuration( + PublicAccessBlockConfiguration::builder() + .block_public_acls(true) + .ignore_public_acls(true) + .block_public_policy(true) + .restrict_public_buckets(true) + .build(), + ) + .send() + .await + .map_err(|e| classify_sdk_error("s3:PutBucketPublicAccessBlock", &e))?; + s3.put_bucket_policy() + .bucket(&bucket) + .expected_bucket_owner(account) + .policy(state_bucket_policy(&bucket)) + .send() + .await + .map_err(|e| classify_sdk_error("s3:PutBucketPolicy", &e))?; + Ok(bucket) +} + +#[cfg(test)] +mod tests; diff --git a/app/src-tauri/src/aws/environments/tests.rs b/app/src-tauri/src/aws/environments/tests.rs new file mode 100644 index 0000000..6a62f06 --- /dev/null +++ b/app/src-tauri/src/aws/environments/tests.rs @@ -0,0 +1,703 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use serde_json::{json, Value}; + +use super::*; +use crate::aws::tofu_modules::{owner_key, user_role_names}; + +const ACCOUNT: &str = "123456789012"; +const REGION: &str = "eu-west-1"; +const USER_ID: &str = "AROAEXAMPLEID:alice"; + +fn record(name: &str, version: u32) -> Value { + json!({ + "schema": 1, + "name": name, + "template_version": version, + "cluster_arn": format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-{name}"), + "security_group_id": "sg-0123456789abcdef0", + "subnet_ids": ["subnet-0123456789abcdef0", "subnet-0fedcba9876543210"], + "assign_public_ip": true, + "kms_key_arn": format!( + "arn:aws:kms:{REGION}:{ACCOUNT}:key/1234abcd-12ab-34cd-56ef-1234567890ab" + ), + "log_group": format!("/triple-c/{name}"), + "network_mode": "public", + "tenancy": "strict", + }) +} + +fn parsed(name: &str, version: u32) -> EnvironmentInfo { + parse_environment_record(name, &record(name, version).to_string(), ACCOUNT, REGION).unwrap() +} + +fn with(name: &str, field: &str, value: Value) -> String { + let mut r = record(name, 1); + r[field] = value; + r.to_string() +} + +fn without(name: &str, field: &str) -> String { + let mut r = record(name, 1); + r.as_object_mut().unwrap().remove(field); + r.to_string() +} + +#[test] +fn untrusted_environment_records_are_rejected() { + let valid = parsed("dev", 1); + assert_eq!(valid.name, "dev"); + assert_eq!(valid.template_version, 1); + assert_eq!(valid.subnet_ids.len(), 2); + assert!(valid.assign_public_ip); + assert_eq!(valid.tenancy, "strict"); + + let other_account = "999999999999"; + let secret = "s3cr3t-value-never-echoed"; + let cases: Vec<(&str, String, &str)> = vec![ + ( + "too large", + format!("{{\"pad\":\"{}\"}}", "x".repeat(5000)), + "4 KiB", + ), + ("not json", "not json at all".into(), "JSON"), + ("json but not an object", "[1,2]".into(), "JSON"), + ("schema 2", with("dev", "schema", json!(2)), "schema"), + ("schema missing", without("dev", "schema"), "schema"), + ( + "name differs from the path", + with("dev", "name", json!("prod")), + "name", + ), + ( + "cluster in another account", + with( + "dev", + "cluster_arn", + json!(format!( + "arn:aws:ecs:{REGION}:{other_account}:cluster/triple-c-dev" + )), + ), + "cluster_arn", + ), + ( + "cluster in another region", + with( + "dev", + "cluster_arn", + json!(format!( + "arn:aws:ecs:us-east-1:{ACCOUNT}:cluster/triple-c-dev" + )), + ), + "cluster_arn", + ), + ( + "kms key in another account", + with( + "dev", + "kms_key_arn", + json!(format!("arn:aws:kms:{REGION}:{other_account}:key/abcd")), + ), + "kms_key_arn", + ), + ( + "kms arn of another service", + with( + "dev", + "kms_key_arn", + json!(format!("arn:aws:s3:{REGION}:{ACCOUNT}:key/abcd")), + ), + "kms_key_arn", + ), + ( + "one subnet", + with("dev", "subnet_ids", json!(["subnet-0123abcd"])), + "subnet_ids", + ), + ( + "three subnets", + with( + "dev", + "subnet_ids", + json!(["subnet-0123abcd", "subnet-0123abce", "subnet-0123abcf"]), + ), + "subnet_ids", + ), + ( + "a subnet that isn't one", + with("dev", "subnet_ids", json!(["subnet-0123abcd", secret])), + "subnet_ids", + ), + ( + "uppercase subnet hex", + with( + "dev", + "subnet_ids", + json!(["subnet-0123ABCD", "subnet-0123abcd"]), + ), + "subnet_ids", + ), + ( + "security group that isn't one", + with("dev", "security_group_id", json!(secret)), + "security_group_id", + ), + ( + "security group of the wrong type", + with("dev", "security_group_id", json!(7)), + "security_group_id", + ), + ( + "shared tenancy", + with("dev", "tenancy", json!("shared")), + "tenancy", + ), + ( + "log group outside /triple-c/", + with("dev", "log_group", json!(secret)), + "log_group", + ), + ( + "unknown network mode", + with("dev", "network_mode", json!(secret)), + "network_mode", + ), + ( + "assign_public_ip not a bool", + with("dev", "assign_public_ip", json!("yes")), + "assign_public_ip", + ), + ( + "template_version not a number", + with("dev", "template_version", json!(secret)), + "template_version", + ), + ]; + for (what, value, field) in cases { + let err = parse_environment_record("dev", &value, ACCOUNT, REGION) + .expect_err(&format!("accepted: {what}")); + assert!(err.contains(field), "{what}: {err:?} should name {field}"); + assert!(!err.contains(secret), "{what}: {err:?} echoes the value"); + assert!( + !err.contains(other_account), + "{what}: {err:?} echoes the value" + ); + } + + // The path's name is itself checked. + assert!( + parse_environment_record("Bad Name", &record("dev", 1).to_string(), ACCOUNT, REGION) + .is_err() + ); +} + +fn nobody_local(_: Deployment) -> bool { + false +} + +fn env_key(env: &str) -> String { + state_key(Deployment::Environment(env)) +} + +fn lock_json(who: &str) -> String { + json!({ + "ID": "8a1c-uuid", + "Operation": "OperationTypeApply", + "Info": "", + "Who": who, + "Version": "1.10.6", + "Created": "2026-11-14T10:00:00Z", + "Path": "triple-c-tfstate/environments/x/terraform.tfstate", + }) + .to_string() +} + +fn objects(states: &[String], locks: &[(String, u64)]) -> StateObjects { + StateObjects { + states: states.to_vec(), + locks: locks + .iter() + .map(|(k, age)| (format!("{k}.tflock"), lock_json("admin@laptop"), *age)) + .collect(), + } +} + +fn find<'a>(envs: &'a [DetectedEnvironment], name: &str) -> &'a DetectedEnvironment { + envs.iter() + .find(|e| e.name == name) + .unwrap_or_else(|| panic!("{name} not listed in {envs:#?}")) +} + +#[test] +fn a_record_without_state_and_state_without_a_record_are_both_listed() { + let objs = objects(&[env_key("orphan")], &[]); + let envs = merge( + vec![("shared".into(), Ok(parsed("shared", 1)))], + Some(&objs), + &nobody_local, + 1, + ); + assert_eq!(envs.len(), 2, "{envs:#?}"); + + let shared = find(&envs, "shared"); + assert_eq!(shared.state, EnvironmentState::ExternallyManaged); + assert_eq!(shared.actions, vec![EnvironmentAction::Join]); + assert_eq!(shared.template_version, Some(1)); + assert!(shared.info.is_some()); + + let orphan = find(&envs, "orphan"); + assert_eq!(orphan.state, EnvironmentState::Incomplete { lock: None }); + assert_eq!( + orphan.actions, + vec![EnvironmentAction::Repair, EnvironmentAction::Destroy] + ); + assert_eq!(orphan.template_version, None); + assert!(orphan.info.is_none()); + + // Without bucket access (a workspace user) a record is externally + // managed too, and nothing else is listed. + let envs = merge( + vec![("shared".into(), Ok(parsed("shared", 1)))], + None, + &nobody_local, + 1, + ); + assert_eq!(envs.len(), 1); + assert_eq!(envs[0].state, EnvironmentState::ExternallyManaged); + + // An unreadable record is listed with its reason. + let envs = merge( + vec![("bad".into(), Err("tenancy is not strict".into()))], + None, + &nobody_local, + 1, + ); + assert_eq!( + envs[0].state, + EnvironmentState::UnreadableRecord { + reason: "tenancy is not strict".into() + } + ); + assert!(envs[0].actions.is_empty()); +} + +#[test] +fn interrupted_deployments_get_the_right_actions() { + let stale = 3 * 3600; + let fresh = 60; + + // A stale lock, no local process: Incomplete, with Unlock. + let objs = objects(&[env_key("a")], &[(env_key("a"), stale)]); + let envs = merge(Vec::new(), Some(&objs), &nobody_local, 1); + let a = find(&envs, "a"); + let EnvironmentState::Incomplete { lock: Some(lock) } = &a.state else { + panic!("{a:#?}"); + }; + assert_eq!(lock.who, "admin@laptop"); + assert_eq!(lock.operation, "OperationTypeApply"); + assert_eq!(lock.created, "2026-11-14T10:00:00Z"); + assert_eq!(lock.age_secs, stale); + assert!(!lock.local_process); + assert_eq!( + a.actions, + vec![ + EnvironmentAction::Repair, + EnvironmentAction::Destroy, + EnvironmentAction::Unlock + ] + ); + + // A stale lock on an environment that has a record is still Incomplete. + let envs = merge( + vec![("a".into(), Ok(parsed("a", 1)))], + Some(&objs), + &nobody_local, + 1, + ); + assert!(matches!( + find(&envs, "a").state, + EnvironmentState::Incomplete { lock: Some(_) } + )); + + // A fresh lock (a first apply: no state object yet): Deploying, nothing + // to do but wait. + let objs = objects(&[], &[(env_key("b"), fresh)]); + let envs = merge(Vec::new(), Some(&objs), &nobody_local, 1); + let b = find(&envs, "b"); + let EnvironmentState::Deploying { lock: Some(lock) } = &b.state else { + panic!("{b:#?}"); + }; + assert!(!lock.local_process); + assert!(b.actions.is_empty()); + + // A local process, even under an old lock: Deploying, Follow. + let objs = objects(&[env_key("c")], &[(env_key("c"), stale)]); + let local_c = |d: Deployment| d == Deployment::Environment("c"); + let envs = merge(Vec::new(), Some(&objs), &local_c, 1); + let c = find(&envs, "c"); + let EnvironmentState::Deploying { lock: Some(lock) } = &c.state else { + panic!("{c:#?}"); + }; + assert!(lock.local_process); + assert_eq!(c.actions, vec![EnvironmentAction::Follow]); +} + +#[test] +fn lock_contents_are_capped_and_sanitized() { + let nasty = json!({ + "Who": format!("evil\u{1b}[31m\u{202e}name\n{}", "w".repeat(1000)), + "Operation": "OperationTypeApply", + "Created": "2026-11-14T10:00:00Z", + }) + .to_string(); + let lock = lock_info(&nasty, 10, false); + assert!(!lock.who.chars().any(|c| c.is_control()), "{:?}", lock.who); + assert!(!lock.who.contains('\u{202e}')); + assert!(lock.who.starts_with("evil[31mname"), "{:?}", lock.who); + assert!(lock.who.chars().count() <= 128); + + // Oversized or unparsable contents are a held lock with nothing to say. + let huge = json!({ "Who": "x".repeat(5000) }).to_string(); + for text in [huge.as_str(), "not json", ""] { + let lock = lock_info(text, 7, false); + assert_eq!(lock.who, ""); + assert_eq!(lock.age_secs, 7); + } +} + +#[test] +fn an_older_module_offers_update() { + let objs = objects(&[env_key("old"), env_key("new")], &[]); + let envs = merge( + vec![ + ("old".into(), Ok(parsed("old", 1))), + ("new".into(), Ok(parsed("new", 2))), + ], + Some(&objs), + &nobody_local, + 2, + ); + let old = find(&envs, "old"); + assert_eq!(old.state, EnvironmentState::UpdateAvailable); + assert_eq!( + old.actions, + vec![EnvironmentAction::Join, EnvironmentAction::Update] + ); + assert_eq!(old.template_version, Some(1)); + let new = find(&envs, "new"); + assert_eq!(new.state, EnvironmentState::Ready); + assert_eq!(new.actions, vec![EnvironmentAction::Join]); +} + +/// A `DetectApi` that answers from fixed data and records what it was asked. +struct FakeDetect { + records: Vec<(String, String)>, + objects: Option, + bucket: Knowable, + boundary: Knowable, + roles: HashMap, + calls: Mutex>, +} + +impl FakeDetect { + fn new() -> Self { + FakeDetect { + records: Vec::new(), + objects: None, + bucket: Knowable::Yes, + boundary: Knowable::Yes, + roles: HashMap::new(), + calls: Mutex::new(Vec::new()), + } + } + fn with_record(mut self, name: &str, version: u32) -> Self { + self.records.push(( + environment_parameter_name(name), + record(name, version).to_string(), + )); + self + } + fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + fn log(&self, call: String) { + self.calls.lock().unwrap().push(call); + } +} + +impl DetectApi for FakeDetect { + async fn environment_records(&self) -> Result, AwsError> { + self.log("records".into()); + Ok(self.records.clone()) + } + async fn state_objects(&self, bucket: &str) -> Result, AwsError> { + self.log(format!("objects {bucket}")); + Ok(self.objects.as_ref().map(|o| StateObjects { + states: o.states.clone(), + locks: o.locks.clone(), + })) + } + async fn bucket_exists(&self, bucket: &str) -> Result { + self.log(format!("bucket {bucket}")); + Ok(self.bucket.clone()) + } + async fn boundary_exists(&self, account: &str) -> Result { + self.log(format!("boundary {account}")); + Ok(self.boundary.clone()) + } + async fn role_exists(&self, name: &str) -> Result { + self.log(format!("role {name}")); + Ok(self.roles.get(name).cloned().unwrap_or(Knowable::No)) + } +} + +fn who() -> CallerIdentity { + CallerIdentity { + account: ACCOUNT.into(), + arn: format!("arn:aws:sts::{ACCOUNT}:assumed-role/Admin/alice"), + user_id: USER_ID.into(), + } +} + +fn run_scan(d: &FakeDetect, local: &(dyn Fn(Deployment) -> bool + Sync)) -> EnvironmentScan { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(scan(d, &who(), REGION, local)) + .unwrap() +} + +fn user_key(env: &str) -> String { + state_key(Deployment::User { + env, + owner_key: &owner_key(USER_ID), + }) +} + +fn task_role(env: &str) -> String { + user_role_names(env, &owner_key(USER_ID)).0 +} + +#[test] +fn onboarding_is_found_by_state_then_by_role() { + let mut d = FakeDetect::new() + .with_record("bystate", 1) + .with_record("byrole", 1) + .with_record("denied", 1) + .with_record("absent", 1) + .with_record("joining", 1); + d.objects = Some(StateObjects { + states: vec![ + env_key("bystate"), + env_key("byrole"), + env_key("denied"), + env_key("absent"), + env_key("joining"), + user_key("bystate"), + ], + locks: vec![( + format!("{}.tflock", user_key("joining")), + lock_json("me"), + 30, + )], + }); + d.roles.insert(task_role("byrole"), Knowable::Yes); + d.roles.insert(task_role("denied"), Knowable::Unknown); + + let scan = run_scan(&d, &nobody_local); + assert_eq!(scan.owner_id, USER_ID); + assert_eq!(scan.owner_key, owner_key(USER_ID)); + let onboarding = |n: &str| find(&scan.environments, n).onboarding.clone(); + assert_eq!(onboarding("bystate"), Some(OnboardingState::Joined)); + assert_eq!(onboarding("byrole"), Some(OnboardingState::Joined)); + assert_eq!(onboarding("denied"), Some(OnboardingState::NotJoined)); + assert_eq!(onboarding("absent"), Some(OnboardingState::NotJoined)); + assert_eq!(onboarding("joining"), Some(OnboardingState::InProgress)); + + // The state key answered for `bystate` and the lock for `joining`, so + // only the others cost a GetRole. + let roles: Vec = d + .calls() + .into_iter() + .filter(|c| c.starts_with("role ")) + .collect(); + assert!(!roles.contains(&format!("role {}", task_role("bystate")))); + assert!(!roles.contains(&format!("role {}", task_role("joining")))); + assert!(roles.contains(&format!("role {}", task_role("byrole")))); + assert_eq!(roles.len(), 3, "{roles:?}"); + + // A local onboarding run is InProgress; a stale onboarding lock is + // Incomplete. + let mut d = FakeDetect::new().with_record("x", 1).with_record("y", 1); + d.objects = Some(StateObjects { + states: vec![env_key("x"), env_key("y")], + locks: vec![( + format!("{}.tflock", user_key("y")), + lock_json("me"), + 3 * 3600, + )], + }); + let ok = owner_key(USER_ID); + let local_x = move |dep: Deployment| { + dep == Deployment::User { + env: "x", + owner_key: &ok, + } + }; + let scan = run_scan(&d, &local_x); + assert_eq!( + find(&scan.environments, "x").onboarding, + Some(OnboardingState::InProgress) + ); + assert_eq!( + find(&scan.environments, "y").onboarding, + Some(OnboardingState::Incomplete) + ); + + // Not joinable, not resolved. + let mut d = FakeDetect::new(); + d.objects = Some(StateObjects { + states: vec![env_key("orphan")], + locks: Vec::new(), + }); + let scan = run_scan(&d, &nobody_local); + assert_eq!(find(&scan.environments, "orphan").onboarding, None); +} + +#[test] +fn denied_lookups_are_unknown_not_missing() { + // A workspace user: HeadBucket and GetPolicy are denied. + let mut d = FakeDetect::new().with_record("shared", 1); + d.bucket = Knowable::Unknown; + d.boundary = Knowable::Unknown; + d.roles.insert(task_role("shared"), Knowable::Unknown); + + let scan = run_scan(&d, &nobody_local); + assert_eq!(scan.state_bucket, Knowable::Unknown); + assert_eq!(scan.boundary, Knowable::Unknown); + assert_eq!(scan.account, ACCOUNT); + assert_eq!(scan.region, REGION); + let shared = find(&scan.environments, "shared"); + assert_eq!(shared.state, EnvironmentState::ExternallyManaged); + assert_eq!(shared.onboarding, Some(OnboardingState::NotJoined)); + // An unknown bucket isn't listed. + assert!( + !d.calls().iter().any(|c| c.starts_with("objects ")), + "{:?}", + d.calls() + ); + assert!(d + .calls() + .contains(&format!("bucket {}", state_bucket_name(ACCOUNT, REGION)))); + + // A missing bucket is No, and isn't listed either. + let mut d = FakeDetect::new().with_record("shared", 1); + d.bucket = Knowable::No; + d.boundary = Knowable::No; + let scan = run_scan(&d, &nobody_local); + assert_eq!(scan.state_bucket, Knowable::No); + assert_eq!(scan.boundary, Knowable::No); + + // The SDK side: what each answer means. + assert_eq!( + lookup_outcome(Some("AccessDenied"), Some(403)), + Lookup::Denied + ); + assert_eq!(lookup_outcome(None, Some(403)), Lookup::Denied); + assert_eq!( + lookup_outcome(Some("AccessDeniedException"), Some(400)), + Lookup::Denied + ); + assert_eq!(lookup_outcome(Some("NotFound"), Some(404)), Lookup::Missing); + assert_eq!(lookup_outcome(None, Some(404)), Lookup::Missing); + assert_eq!( + lookup_outcome(Some("NoSuchEntity"), Some(404)), + Lookup::Missing + ); + assert_eq!( + lookup_outcome(Some("NoSuchBucket"), Some(404)), + Lookup::Missing + ); + assert_eq!(lookup_outcome(Some("Throttling"), Some(400)), Lookup::Error); + assert_eq!(lookup_outcome(None, None), Lookup::Error); +} + +#[test] +fn records_under_unexpected_paths_are_skipped() { + let d = FakeDetect { + records: vec![ + ("/triple-c/environments/Bad Name".into(), "{}".into()), + ("/triple-c/environments/a/b".into(), "{}".into()), + ("/elsewhere/dev".into(), "{}".into()), + ( + environment_parameter_name("ok"), + record("ok", 1).to_string(), + ), + ], + ..FakeDetect::new() + }; + let scan = run_scan(&d, &nobody_local); + let names: Vec<&str> = scan.environments.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, vec!["ok"]); +} + +#[test] +fn only_buckets_owned_by_you_count_as_created() { + let bucket = state_bucket_name(ACCOUNT, REGION); + assert_eq!( + create_bucket_outcome(Some("BucketAlreadyOwnedByYou"), &bucket), + Some(Ok(())) + ); + let taken = create_bucket_outcome(Some("BucketAlreadyExists"), &bucket); + assert_eq!( + taken, + Some(Err(AwsError::StateBucketTaken { + bucket: bucket.clone() + })) + ); + assert!(taken.unwrap().unwrap_err().to_string().contains(&bucket)); + // Anything else is classified as an ordinary SDK error. + assert_eq!(create_bucket_outcome(Some("AccessDenied"), &bucket), None); + assert_eq!(create_bucket_outcome(None, &bucket), None); +} + +#[test] +fn the_state_bucket_is_configured_as_specified() { + // No LocationConstraint in us-east-1; the region everywhere else. The + // component tag either way. + let east = create_bucket_configuration("us-east-1"); + assert!(east.location_constraint().is_none()); + let west = create_bucket_configuration(REGION); + assert_eq!(west.location_constraint().map(|c| c.as_str()), Some(REGION)); + for cfg in [&east, &west] { + let tags: Vec<(&str, &str)> = cfg.tags().iter().map(|t| (t.key(), t.value())).collect(); + assert_eq!(tags, vec![("triple-c:component", "state")]); + } + + let bucket = state_bucket_name(ACCOUNT, REGION); + let policy: Value = serde_json::from_str(&state_bucket_policy(&bucket)).unwrap(); + let st = &policy["Statement"][0]; + assert_eq!(st["Effect"], "Deny"); + assert_eq!(st["Principal"], "*"); + assert_eq!(st["Action"], "s3:*"); + assert_eq!( + st["Resource"], + json!([ + format!("arn:aws:s3:::{bucket}"), + format!("arn:aws:s3:::{bucket}/*") + ]) + ); + assert_eq!(st["Condition"]["Bool"]["aws:SecureTransport"], "false"); +} + +#[test] +fn the_scan_serializes_for_the_frontend() { + let d = FakeDetect::new().with_record("dev", 1); + let scan = run_scan(&d, &nobody_local); + let v = serde_json::to_value(&scan).unwrap(); + assert_eq!(v["state_bucket"], "yes"); + assert_eq!(v["environments"][0]["state"]["state"], "externally_managed"); + assert_eq!(v["environments"][0]["actions"], json!(["join"])); + assert_eq!(v["environments"][0]["onboarding"]["state"], "not_joined"); +} diff --git a/app/src-tauri/src/aws/error.rs b/app/src-tauri/src/aws/error.rs index 2ec0e8e..4a0ce1c 100644 --- a/app/src-tauri/src/aws/error.rs +++ b/app/src-tauri/src/aws/error.rs @@ -24,6 +24,11 @@ pub enum AwsError { action: String, message: String, }, + /// `s3:CreateBucket` said `BucketAlreadyExists`: the state bucket's + /// name belongs to another AWS account. Never treated as success. + StateBucketTaken { + bucket: String, + }, Other { message: String, }, @@ -53,6 +58,11 @@ impl std::fmt::Display for AwsError { AwsError::AccessDenied { action, message } => { write!(f, "Access denied for {action}: {message}") } + AwsError::StateBucketTaken { bucket } => write!( + f, + "The state bucket name \"{bucket}\" is already taken by another AWS account, so \ + Triple-C can't keep OpenTofu state there" + ), AwsError::Other { message } => f.write_str(message), } } diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index de50ec2..98e76f0 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -4,6 +4,7 @@ //! mod connection; +pub mod environments; mod error; mod identity; #[cfg(test)] diff --git a/app/src-tauri/src/aws/tofu.rs b/app/src-tauri/src/aws/tofu.rs index 11e06fa..27e2fd0 100644 --- a/app/src-tauri/src/aws/tofu.rs +++ b/app/src-tauri/src/aws/tofu.rs @@ -320,9 +320,29 @@ fn safe_segment(s: &str) -> bool { .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) } +/// The work folder of one deployment, `/work///`, without creating or writing anything: what +/// [`materialize`] writes into and what [`is_running`] asks about. +#[allow(dead_code)] // used by phase-4 commands +pub fn workdir_for(root: &Path, deployment: Deployment, bucket: &str) -> Result { + if !safe_segment(bucket) { + return Err(other("Invalid state bucket name")); + } + let key = state_key(deployment); + let stem = key + .strip_suffix(".tfstate") + .ok_or_else(|| other("Invalid state key"))?; + if !stem.split('/').all(safe_segment) { + return Err(other("Invalid deployment name")); + } + let mut dir = root.join("work").join(bucket); + dir.extend(stem.split('/')); + Ok(Workdir { dir }) +} + /// Write `kind`'s module into `/work///` with `backend.hcl` and `terraform.tfvars.json` (0600 on -/// Unix). Refuses while a run is in progress there. `.tf` files and the lock +/// .tfstate>/` ([`workdir_for`]) with `backend.hcl` and +/// `terraform.tfvars.json` (0600 on Unix). Refuses while a run is in progress there. `.tf` files and the lock /// file left by an older module version are removed first, so a file a later /// version dropped can't linger and still be loaded. #[allow(dead_code)] // used by phase-4 commands @@ -334,23 +354,12 @@ pub fn materialize( region: &str, vars: &Value, ) -> Result { - if !safe_segment(bucket) { - return Err(other("Invalid state bucket name")); - } + let wd = workdir_for(root, deployment, bucket)?; if region.trim() != region { return Err(other("Invalid region")); } let region = validate_supported_region(region).map_err(other)?; let key = state_key(deployment); - let stem = key - .strip_suffix(".tfstate") - .ok_or_else(|| other("Invalid state key"))?; - if !stem.split('/').all(safe_segment) { - return Err(other("Invalid deployment name")); - } - let mut dir = root.join("work").join(bucket); - dir.extend(stem.split('/')); - let wd = Workdir { dir }; if is_running(&wd) { return Err(other("A deploy is already running for this deployment")); } diff --git a/app/src-tauri/src/aws/tofu/tests.rs b/app/src-tauri/src/aws/tofu/tests.rs index caaf067..18349dd 100644 --- a/app/src-tauri/src/aws/tofu/tests.rs +++ b/app/src-tauri/src/aws/tofu/tests.rs @@ -406,6 +406,42 @@ fn materialize_refuses_names_that_leave_the_work_folder() { .is_err()); } +#[test] +fn workdir_for_names_the_materialized_folder_without_writing() { + let tmp = tempfile::tempdir().unwrap(); + let bucket = "triple-c-tfstate-123456789012-us-east-1"; + let user = Deployment::User { + env: "dev", + owner_key: "0123456789ab", + }; + + let wd = workdir_for(tmp.path(), user, bucket).unwrap(); + assert_eq!( + wd.dir, + tmp.path() + .join(format!("work/{bucket}/users/dev/0123456789ab/terraform")) + ); + assert!( + !tmp.path().join("work").exists(), + "workdir_for wrote something" + ); + assert!(!is_running(&wd)); + + let made = materialize( + tmp.path(), + ModuleKind::UserOnboard, + user, + bucket, + "us-east-1", + &serde_json::json!({}), + ) + .unwrap(); + assert_eq!(made, wd); + + assert!(workdir_for(tmp.path(), Deployment::Environment(".."), bucket).is_err()); + assert!(workdir_for(tmp.path(), Deployment::Boundary, "../x").is_err()); +} + fn append(path: &std::path::Path, lines: &[String]) { let mut f = std::fs::OpenOptions::new() .create(true) diff --git a/app/src/components/settings/HostsSettings.tsx b/app/src/components/settings/HostsSettings.tsx index d18babb..b6713cd 100644 --- a/app/src/components/settings/HostsSettings.tsx +++ b/app/src/components/settings/HostsSettings.tsx @@ -42,6 +42,8 @@ export function awsErrorMessage(e: AwsError): string { return `Profile ${e.profile} was not found in your AWS config, or has no credentials.`; case "keys_missing": return "The access keys for this connection are missing from the keychain. Remove and re-add the connection."; + case "state_bucket_taken": + return `The state bucket name ${e.bucket} is already taken by another AWS account.`; default: return e.message; } diff --git a/app/src/lib/aws-error.ts b/app/src/lib/aws-error.ts index c2ca7d7..d4bbfe3 100644 --- a/app/src/lib/aws-error.ts +++ b/app/src/lib/aws-error.ts @@ -7,6 +7,7 @@ const KINDS: Record = { keys_missing: true, invalid_credentials: true, access_denied: true, + state_bucket_taken: true, other: true, }; diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 21b7051..07c2f92 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -1148,6 +1148,7 @@ export type AwsError = | { kind: "keys_missing" } | { kind: "invalid_credentials"; message: string } | { kind: "access_denied"; action: string; message: string } + | { kind: "state_bucket_taken"; bucket: string } | { kind: "other"; message: string }; export interface CallerIdentity { -- 2.52.0 From 97e6a3e15a4dd3ed61bb87167b3f31e35c07d856 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 22:20:34 -0800 Subject: [PATCH 13/24] fix(aws): never offer Destroy on a working environment; surface credential 403s; keep bucket tags Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/environments.rs | 101 ++++++++++++--- app/src-tauri/src/aws/environments/tests.rs | 129 +++++++++++++++++++- 2 files changed, 212 insertions(+), 18 deletions(-) diff --git a/app/src-tauri/src/aws/environments.rs b/app/src-tauri/src/aws/environments.rs index c163671..20a78d9 100644 --- a/app/src-tauri/src/aws/environments.rs +++ b/app/src-tauri/src/aws/environments.rs @@ -142,6 +142,9 @@ pub fn parse_environment_record( .collect::>>() }) .ok_or_else(|| "subnet_ids is not exactly 2 subnet ids".to_string())?; + if subnet_ids[0] == subnet_ids[1] { + return Err("subnet_ids must be two different subnets".into()); + } let assign_public_ip = field(obj, "assign_public_ip")? .as_bool() .ok_or_else(|| "assign_public_ip is not true or false".to_string())?; @@ -386,11 +389,16 @@ fn detect_one( } else if lock.as_ref().is_some_and(|l| l.age_secs < LIVE_LOCK_SECS) { (EnvironmentState::Deploying { lock }, vec![]) } else if lock.is_some() { - // Stale, and not ours (a local runner was handled above). - ( - EnvironmentState::Incomplete { lock }, - vec![Repair, Destroy, Unlock], - ) + // Stale, and not ours (a local runner was handled above). A valid + // record with our state is a working environment whose last run + // (an Update, a plan) died: never Destroy it from the app (D6). + let working = info.is_some() && has_state; + let actions = if working { + vec![Repair, Unlock] + } else { + vec![Repair, Destroy, Unlock] + }; + (EnvironmentState::Incomplete { lock }, actions) } else if let Some(reason) = reason { // A re-apply rewrites the record, so Repair is offered when the // state is ours. Never Destroy: the environment may be working (D6). @@ -586,8 +594,10 @@ fn lookup_outcome(code: Option<&str>, status: Option) -> Lookup { (Some(c), _) if MISSING.contains(&c) => Lookup::Missing, (Some(c), _) if DENIED.contains(&c) => Lookup::Denied, (None, Some(404)) => Lookup::Missing, + // A 403 with no body (HeadBucket). With a code that isn't access + // denied (InvalidAccessKeyId, SignatureDoesNotMatch, ExpiredToken, + // ...) it is an error the caller must see. (None, Some(403)) => Lookup::Denied, - (_, Some(403)) => Lookup::Denied, _ => Lookup::Error, } } @@ -850,6 +860,36 @@ fn create_bucket_outcome(code: Option<&str>, bucket: &str) -> Option, bucket: &str) -> Option { + (status == Some(403)).then(|| AwsError::StateBucketTaken { + bucket: bucket.to_string(), + }) +} + +/// The bucket's tag set with ours added, keeping every other tag, or `None` +/// when `triple-c:component=state` is already there (nothing to put). A +/// different `triple-c:component` value is replaced: no resource overrides +/// the component tag. +fn tags_with_state(existing: &[(String, String)]) -> Option> { + if existing + .iter() + .any(|(k, v)| k == STATE_TAG.0 && v == STATE_TAG.1) + { + return None; + } + let mut tags: Vec<(String, String)> = existing + .iter() + .filter(|(k, _)| k != STATE_TAG.0) + .cloned() + .collect(); + tags.push((STATE_TAG.0.to_string(), STATE_TAG.1.to_string())); + Some(tags) +} + fn state_tag() -> Tag { Tag::builder() .key(STATE_TAG.0) @@ -920,25 +960,54 @@ pub async fn ensure_state_bucket( } // us-east-1 answers a create of a bucket this account owns with success, // not BucketAlreadyOwnedByYou: confirm ownership before configuring. - s3.head_bucket() + if let Err(e) = s3 + .head_bucket() .bucket(&bucket) .expected_bucket_owner(account) .send() .await - .map_err(|e| classify_sdk_error("s3:ListBucket", &e))?; + { + let (_, status) = error_parts(&e); + return Err(head_after_create_error(status, &bucket) + .unwrap_or_else(|| classify_sdk_error("s3:ListBucket", &e))); + } - s3.put_bucket_tagging() + let existing = match s3 + .get_bucket_tagging() .bucket(&bucket) .expected_bucket_owner(account) - .tagging( - Tagging::builder() - .tag_set(state_tag()) - .build() - .expect("tag set is set"), - ) .send() .await - .map_err(|e| classify_sdk_error("s3:PutBucketTagging", &e))?; + { + Ok(out) => out + .tag_set() + .iter() + .map(|t| (t.key().to_string(), t.value().to_string())) + .collect(), + Err(e) if e.code() == Some("NoSuchTagSet") => Vec::new(), + Err(e) => return Err(classify_sdk_error("s3:GetBucketTagging", &e)), + }; + if let Some(tags) = tags_with_state(&existing) { + let tag_set = tags + .into_iter() + .map(|(k, v)| Tag::builder().key(k).value(v).build()) + .collect::, _>>() + .map_err(|e| AwsError::Other { + message: format!("Could not build the state bucket's tags: {e}"), + })?; + s3.put_bucket_tagging() + .bucket(&bucket) + .expected_bucket_owner(account) + .tagging( + Tagging::builder() + .set_tag_set(Some(tag_set)) + .build() + .expect("tag set is set"), + ) + .send() + .await + .map_err(|e| classify_sdk_error("s3:PutBucketTagging", &e))?; + } s3.put_bucket_versioning() .bucket(&bucket) .expected_bucket_owner(account) diff --git a/app/src-tauri/src/aws/environments/tests.rs b/app/src-tauri/src/aws/environments/tests.rs index 6a62f06..ecf3108 100644 --- a/app/src-tauri/src/aws/environments/tests.rs +++ b/app/src-tauri/src/aws/environments/tests.rs @@ -129,6 +129,15 @@ fn untrusted_environment_records_are_rejected() { with("dev", "subnet_ids", json!(["subnet-0123abcd", secret])), "subnet_ids", ), + ( + "the same subnet twice", + with( + "dev", + "subnet_ids", + json!(["subnet-0123abcd", "subnet-0123abcd"]), + ), + "two different subnets", + ), ( "uppercase subnet hex", with( @@ -308,17 +317,58 @@ fn interrupted_deployments_get_the_right_actions() { ] ); - // A stale lock on an environment that has a record is still Incomplete. + // A stale lock on a working environment (valid record and our state): + // Incomplete, but never Destroy (D6) — an interrupted Update or plan. let envs = merge( vec![("a".into(), Ok(parsed("a", 1)))], Some(&objs), &nobody_local, 1, ); + let a = find(&envs, "a"); assert!(matches!( - find(&envs, "a").state, + a.state, EnvironmentState::Incomplete { lock: Some(_) } )); + assert_eq!( + a.actions, + vec![EnvironmentAction::Repair, EnvironmentAction::Unlock] + ); + + // A stale lock with an unreadable record keeps Destroy: there is no + // valid record, so nothing says the environment works. + let envs = merge( + vec![("a".into(), Err("tenancy is not strict".into()))], + Some(&objs), + &nobody_local, + 1, + ); + assert_eq!( + find(&envs, "a").actions, + vec![ + EnvironmentAction::Repair, + EnvironmentAction::Destroy, + EnvironmentAction::Unlock + ] + ); + + // A stale lock with a record but no state of ours (a first apply that + // died before writing state) keeps Destroy too. + let no_state = objects(&[], &[(env_key("a"), stale)]); + let envs = merge( + vec![("a".into(), Ok(parsed("a", 1)))], + Some(&no_state), + &nobody_local, + 1, + ); + assert_eq!( + find(&envs, "a").actions, + vec![ + EnvironmentAction::Repair, + EnvironmentAction::Destroy, + EnvironmentAction::Unlock + ] + ); // A fresh lock (a first apply: no state object yet): Deploying, nothing // to do but wait. @@ -620,6 +670,19 @@ fn denied_lookups_are_unknown_not_missing() { Lookup::Missing ); assert_eq!(lookup_outcome(Some("Throttling"), Some(400)), Lookup::Error); + // A 403 that is a credential failure is an error, never Unknown. + for code in [ + "InvalidClientTokenId", + "SignatureDoesNotMatch", + "InvalidAccessKeyId", + "ExpiredToken", + ] { + assert_eq!( + lookup_outcome(Some(code), Some(403)), + Lookup::Error, + "{code}" + ); + } assert_eq!(lookup_outcome(None, None), Lookup::Error); } @@ -662,6 +725,68 @@ fn only_buckets_owned_by_you_count_as_created() { assert_eq!(create_bucket_outcome(None, &bucket), None); } +#[test] +fn a_bucket_of_another_account_after_create_is_taken() { + let bucket = state_bucket_name(ACCOUNT, REGION); + assert_eq!( + head_after_create_error(Some(403), &bucket), + Some(AwsError::StateBucketTaken { + bucket: bucket.clone() + }) + ); + assert_eq!(head_after_create_error(Some(500), &bucket), None); + assert_eq!(head_after_create_error(None, &bucket), None); +} + +#[test] +fn only_a_missing_component_tag_is_put_and_other_tags_are_kept() { + let t = |k: &str, v: &str| (k.to_string(), v.to_string()); + assert_eq!( + tags_with_state(&[t("team", "infra"), t("triple-c:component", "state")]), + None + ); + assert_eq!( + tags_with_state(&[]), + Some(vec![t("triple-c:component", "state")]) + ); + assert_eq!( + tags_with_state(&[t("team", "infra"), t("cost", "42")]), + Some(vec![ + t("team", "infra"), + t("cost", "42"), + t("triple-c:component", "state") + ]) + ); + // A different component value is replaced, not duplicated. + assert_eq!( + tags_with_state(&[t("triple-c:component", "other"), t("team", "infra")]), + Some(vec![t("team", "infra"), t("triple-c:component", "state")]) + ); +} + +#[test] +fn hostile_state_keys_name_no_environment() { + for key in [ + "environments/a/b/terraform.tfstate", + "environments/Bad/terraform.tfstate", + "environments/a/terraform.tfstate.tflock.x", + "other/a/terraform.tfstate", + "environments//terraform.tfstate", + "environments/../terraform.tfstate", + "environments/a/terraform.tfstate.tflock.tflock", + ] { + assert_eq!(environment_of_key(key), None, "{key}"); + } + assert_eq!( + environment_of_key("environments/dev/terraform.tfstate"), + Some("dev") + ); + assert_eq!( + environment_of_key("environments/dev/terraform.tfstate.tflock"), + Some("dev") + ); +} + #[test] fn the_state_bucket_is_configured_as_specified() { // No LocationConstraint in us-east-1; the region everywhere else. The -- 2.52.0 From 3324e3d6fe06c16fa816c801ba107017bacef880 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 22:29:57 -0800 Subject: [PATCH 14/24] feat(aws): environment commands with streamed OpenTofu progress Eleven commands (detect, deploy, update, repair, destroy-incomplete, unlock, follow, cancel, onboard, boundary, admin bundle) that re-run detection server-side before acting, start detached OpenTofu runs off the async runtime, and stream each run's log on aws-deploy-progress with one follower per deployment. State keys from the frontend are parsed back into a Deployment; anything state_key() can't produce is refused. The admin bundle is written into a folder picked from Rust and never overwrites unrelated files. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/capabilities/default.json | 11 + app/src-tauri/gen/schemas/acl-manifests.json | 2 +- app/src-tauri/gen/schemas/capabilities.json | 2 +- app/src-tauri/gen/schemas/desktop-schema.json | 132 +++ app/src-tauri/gen/schemas/linux-schema.json | 132 +++ app/src-tauri/src/aws/environments.rs | 17 - app/src-tauri/src/aws/mod.rs | 2 +- app/src-tauri/src/aws/tofu.rs | 33 +- app/src-tauri/src/aws/tofu_modules.rs | 17 +- .../src/commands/environment_commands.rs | 952 ++++++++++++++++++ .../commands/environment_commands/tests.rs | 359 +++++++ app/src-tauri/src/commands/host_commands.rs | 2 +- app/src-tauri/src/commands/mod.rs | 1 + app/src-tauri/src/lib.rs | 11 + app/src/lib/tauri-commands.ts | 41 +- app/src/lib/types.ts | 104 ++ 16 files changed, 1769 insertions(+), 49 deletions(-) create mode 100644 app/src-tauri/src/commands/environment_commands.rs create mode 100644 app/src-tauri/src/commands/environment_commands/tests.rs diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index bfc19aa..97f14b5 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -65,6 +65,17 @@ "allow-aws-connection-sso-login", "allow-check-aws-permissions", "allow-aws-policy-document", + "allow-detect-aws-environments", + "allow-deploy-aws-environment", + "allow-update-aws-environment", + "allow-repair-aws-environment", + "allow-destroy-incomplete-aws-environment", + "allow-unlock-aws-state", + "allow-follow-aws-deployment", + "allow-cancel-aws-deployment", + "allow-onboard-aws-user", + "allow-deploy-aws-permissions-boundary", + "allow-save-aws-admin-bundle", "allow-detect-host-timezone", "allow-export-settings", "allow-preview-settings-import", diff --git a/app/src-tauri/gen/schemas/acl-manifests.json b/app/src-tauri/gen/schemas/acl-manifests.json index 9e332bb..ebb41ab 100644 --- a/app/src-tauri/gen/schemas/acl-manifests.json +++ b/app/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["clear_scheduler_notifications"],"deny":[]}},"allow-close-browser-view-popout":{"identifier":"allow-close-browser-view-popout","description":"Enables the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":["close_browser_view_popout"],"deny":[]}},"allow-close-container-page":{"identifier":"allow-close-container-page","description":"Enables the close_container_page command without any pre-configured scope.","commands":{"allow":["close_container_page"],"deny":[]}},"allow-close-terminal-session":{"identifier":"allow-close-terminal-session","description":"Enables the close_terminal_session command without any pre-configured scope.","commands":{"allow":["close_terminal_session"],"deny":[]}},"allow-confirm-migration":{"identifier":"allow-confirm-migration","description":"Enables the confirm_migration command without any pre-configured scope.","commands":{"allow":["confirm_migration"],"deny":[]}},"allow-create-container-directory":{"identifier":"allow-create-container-directory","description":"Enables the create_container_directory command without any pre-configured scope.","commands":{"allow":["create_container_directory"],"deny":[]}},"allow-delete-note":{"identifier":"allow-delete-note","description":"Enables the delete_note command without any pre-configured scope.","commands":{"allow":["delete_note"],"deny":[]}},"allow-detect-aws-config":{"identifier":"allow-detect-aws-config","description":"Enables the detect_aws_config command without any pre-configured scope.","commands":{"allow":["detect_aws_config"],"deny":[]}},"allow-detect-host-timezone":{"identifier":"allow-detect-host-timezone","description":"Enables the detect_host_timezone command without any pre-configured scope.","commands":{"allow":["detect_host_timezone"],"deny":[]}},"allow-detect-install-options":{"identifier":"allow-detect-install-options","description":"Enables the detect_install_options command without any pre-configured scope.","commands":{"allow":["detect_install_options"],"deny":[]}},"allow-download-container-backup":{"identifier":"allow-download-container-backup","description":"Enables the download_container_backup command without any pre-configured scope.","commands":{"allow":["download_container_backup"],"deny":[]}},"allow-download-container-file":{"identifier":"allow-download-container-file","description":"Enables the download_container_file command without any pre-configured scope.","commands":{"allow":["download_container_file"],"deny":[]}},"allow-export-settings":{"identifier":"allow-export-settings","description":"Enables the export_settings command without any pre-configured scope.","commands":{"allow":["export_settings"],"deny":[]}},"allow-forget-marketplace-installs":{"identifier":"allow-forget-marketplace-installs","description":"Enables the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":["forget_marketplace_installs"],"deny":[]}},"allow-get-app-version":{"identifier":"allow-get-app-version","description":"Enables the get_app_version command without any pre-configured scope.","commands":{"allow":["get_app_version"],"deny":[]}},"allow-get-auth-bridge-status":{"identifier":"allow-get-auth-bridge-status","description":"Enables the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":["get_auth_bridge_status"],"deny":[]}},"allow-get-browser-view-match-window":{"identifier":"allow-get-browser-view-match-window","description":"Enables the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["get_browser_view_match_window"],"deny":[]}},"allow-get-browser-view-popout-state":{"identifier":"allow-get-browser-view-popout-state","description":"Enables the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":["get_browser_view_popout_state"],"deny":[]}},"allow-get-browser-view-status":{"identifier":"allow-get-browser-view-status","description":"Enables the get_browser_view_status command without any pre-configured scope.","commands":{"allow":["get_browser_view_status"],"deny":[]}},"allow-get-container-info":{"identifier":"allow-get-container-info","description":"Enables the get_container_info command without any pre-configured scope.","commands":{"allow":["get_container_info"],"deny":[]}},"allow-get-container-page-state":{"identifier":"allow-get-container-page-state","description":"Enables the get_container_page_state command without any pre-configured scope.","commands":{"allow":["get_container_page_state"],"deny":[]}},"allow-get-container-staleness":{"identifier":"allow-get-container-staleness","description":"Enables the get_container_staleness command without any pre-configured scope.","commands":{"allow":["get_container_staleness"],"deny":[]}},"allow-get-gateway-auth-token":{"identifier":"allow-get-gateway-auth-token","description":"Enables the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["get_gateway_auth_token"],"deny":[]}},"allow-get-gateway-status":{"identifier":"allow-get-gateway-status","description":"Enables the get_gateway_status command without any pre-configured scope.","commands":{"allow":["get_gateway_status"],"deny":[]}},"allow-get-help-content":{"identifier":"allow-get-help-content","description":"Enables the get_help_content command without any pre-configured scope.","commands":{"allow":["get_help_content"],"deny":[]}},"allow-get-marketplace-sync-report":{"identifier":"allow-get-marketplace-sync-report","description":"Enables the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":["get_marketplace_sync_report"],"deny":[]}},"allow-get-migration-state":{"identifier":"allow-get-migration-state","description":"Enables the get_migration_state command without any pre-configured scope.","commands":{"allow":["get_migration_state"],"deny":[]}},"allow-get-scheduled-task-log":{"identifier":"allow-get-scheduled-task-log","description":"Enables the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":["get_scheduled_task_log"],"deny":[]}},"allow-get-scheduler-notifications":{"identifier":"allow-get-scheduler-notifications","description":"Enables the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["get_scheduler_notifications"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-get-stt-status":{"identifier":"allow-get-stt-status","description":"Enables the get_stt_status command without any pre-configured scope.","commands":{"allow":["get_stt_status"],"deny":[]}},"allow-get-web-terminal-status":{"identifier":"allow-get-web-terminal-status","description":"Enables the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":["get_web_terminal_status"],"deny":[]}},"allow-has-claude-token":{"identifier":"allow-has-claude-token","description":"Enables the has_claude_token command without any pre-configured scope.","commands":{"allow":["has_claude_token"],"deny":[]}},"allow-inspect-ca-cert-path":{"identifier":"allow-inspect-ca-cert-path","description":"Enables the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":["inspect_ca_cert_path"],"deny":[]}},"allow-install-browser-view-browser":{"identifier":"allow-install-browser-view-browser","description":"Enables the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":["install_browser_view_browser"],"deny":[]}},"allow-install-browser-view-support":{"identifier":"allow-install-browser-view-support","description":"Enables the install_browser_view_support command without any pre-configured scope.","commands":{"allow":["install_browser_view_support"],"deny":[]}},"allow-install-marketplace-item":{"identifier":"allow-install-marketplace-item","description":"Enables the install_marketplace_item command without any pre-configured scope.","commands":{"allow":["install_marketplace_item"],"deny":[]}},"allow-list-aws-profiles":{"identifier":"allow-list-aws-profiles","description":"Enables the list_aws_profiles command without any pre-configured scope.","commands":{"allow":["list_aws_profiles"],"deny":[]}},"allow-list-claude-sessions":{"identifier":"allow-list-claude-sessions","description":"Enables the list_claude_sessions command without any pre-configured scope.","commands":{"allow":["list_claude_sessions"],"deny":[]}},"allow-list-container-capabilities":{"identifier":"allow-list-container-capabilities","description":"Enables the list_container_capabilities command without any pre-configured scope.","commands":{"allow":["list_container_capabilities"],"deny":[]}},"allow-list-container-files":{"identifier":"allow-list-container-files","description":"Enables the list_container_files command without any pre-configured scope.","commands":{"allow":["list_container_files"],"deny":[]}},"allow-list-marketplace-snapshots":{"identifier":"allow-list-marketplace-snapshots","description":"Enables the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":["list_marketplace_snapshots"],"deny":[]}},"allow-list-marketplace-updates":{"identifier":"allow-list-marketplace-updates","description":"Enables the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":["list_marketplace_updates"],"deny":[]}},"allow-list-notes":{"identifier":"allow-list-notes","description":"Enables the list_notes command without any pre-configured scope.","commands":{"allow":["list_notes"],"deny":[]}},"allow-list-projects":{"identifier":"allow-list-projects","description":"Enables the list_projects command without any pre-configured scope.","commands":{"allow":["list_projects"],"deny":[]}},"allow-list-scheduled-tasks":{"identifier":"allow-list-scheduled-tasks","description":"Enables the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":["list_scheduled_tasks"],"deny":[]}},"allow-marketplace-gh-host-available":{"identifier":"allow-marketplace-gh-host-available","description":"Enables the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":["marketplace_gh_host_available"],"deny":[]}},"allow-marketplace-item-diff":{"identifier":"allow-marketplace-item-diff","description":"Enables the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":["marketplace_item_diff"],"deny":[]}},"allow-migrate-project-to-base":{"identifier":"allow-migrate-project-to-base","description":"Enables the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":["migrate_project_to_base"],"deny":[]}},"allow-open-browser-view-popout":{"identifier":"allow-open-browser-view-popout","description":"Enables the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":["open_browser_view_popout"],"deny":[]}},"allow-open-file-viewer":{"identifier":"allow-open-file-viewer","description":"Enables the open_file_viewer command without any pre-configured scope.","commands":{"allow":["open_file_viewer"],"deny":[]}},"allow-open-page-in-container-browser":{"identifier":"allow-open-page-in-container-browser","description":"Enables the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":["open_page_in_container_browser"],"deny":[]}},"allow-open-terminal-session":{"identifier":"allow-open-terminal-session","description":"Enables the open_terminal_session command without any pre-configured scope.","commands":{"allow":["open_terminal_session"],"deny":[]}},"allow-open-url-external":{"identifier":"allow-open-url-external","description":"Enables the open_url_external command without any pre-configured scope.","commands":{"allow":["open_url_external"],"deny":[]}},"allow-paste-image-to-terminal":{"identifier":"allow-paste-image-to-terminal","description":"Enables the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":["paste_image_to_terminal"],"deny":[]}},"allow-preview-settings-import":{"identifier":"allow-preview-settings-import","description":"Enables the preview_settings_import command without any pre-configured scope.","commands":{"allow":["preview_settings_import"],"deny":[]}},"allow-pull-gateway-image":{"identifier":"allow-pull-gateway-image","description":"Enables the pull_gateway_image command without any pre-configured scope.","commands":{"allow":["pull_gateway_image"],"deny":[]}},"allow-pull-image":{"identifier":"allow-pull-image","description":"Enables the pull_image command without any pre-configured scope.","commands":{"allow":["pull_image"],"deny":[]}},"allow-pull-stt-image":{"identifier":"allow-pull-stt-image","description":"Enables the pull_stt_image command without any pre-configured scope.","commands":{"allow":["pull_stt_image"],"deny":[]}},"allow-read-container-file":{"identifier":"allow-read-container-file","description":"Enables the read_container_file command without any pre-configured scope.","commands":{"allow":["read_container_file"],"deny":[]}},"allow-rebuild-project-container":{"identifier":"allow-rebuild-project-container","description":"Enables the rebuild_project_container command without any pre-configured scope.","commands":{"allow":["rebuild_project_container"],"deny":[]}},"allow-reconcile-project-statuses":{"identifier":"allow-reconcile-project-statuses","description":"Enables the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":["reconcile_project_statuses"],"deny":[]}},"allow-refresh-marketplaces":{"identifier":"allow-refresh-marketplaces","description":"Enables the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":["refresh_marketplaces"],"deny":[]}},"allow-regenerate-gateway-auth-token":{"identifier":"allow-regenerate-gateway-auth-token","description":"Enables the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["regenerate_gateway_auth_token"],"deny":[]}},"allow-regenerate-web-terminal-token":{"identifier":"allow-regenerate-web-terminal-token","description":"Enables the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":["regenerate_web_terminal_token"],"deny":[]}},"allow-remove-host-connection":{"identifier":"allow-remove-host-connection","description":"Enables the remove_host_connection command without any pre-configured scope.","commands":{"allow":["remove_host_connection"],"deny":[]}},"allow-remove-marketplace":{"identifier":"allow-remove-marketplace","description":"Enables the remove_marketplace command without any pre-configured scope.","commands":{"allow":["remove_marketplace"],"deny":[]}},"allow-remove-marketplace-account":{"identifier":"allow-remove-marketplace-account","description":"Enables the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":["remove_marketplace_account"],"deny":[]}},"allow-remove-project":{"identifier":"allow-remove-project","description":"Enables the remove_project command without any pre-configured scope.","commands":{"allow":["remove_project"],"deny":[]}},"allow-remove-scheduled-task":{"identifier":"allow-remove-scheduled-task","description":"Enables the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":["remove_scheduled_task"],"deny":[]}},"allow-rename-container-path":{"identifier":"allow-rename-container-path","description":"Enables the rename_container_path command without any pre-configured scope.","commands":{"allow":["rename_container_path"],"deny":[]}},"allow-rollback-migration":{"identifier":"allow-rollback-migration","description":"Enables the rollback_migration command without any pre-configured scope.","commands":{"allow":["rollback_migration"],"deny":[]}},"allow-run-docker-install":{"identifier":"allow-run-docker-install","description":"Enables the run_docker_install command without any pre-configured scope.","commands":{"allow":["run_docker_install"],"deny":[]}},"allow-run-scheduled-task-now":{"identifier":"allow-run-scheduled-task-now","description":"Enables the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":["run_scheduled_task_now"],"deny":[]}},"allow-save-note":{"identifier":"allow-save-note","description":"Enables the save_note command without any pre-configured scope.","commands":{"allow":["save_note"],"deny":[]}},"allow-send-audio-data":{"identifier":"allow-send-audio-data","description":"Enables the send_audio_data command without any pre-configured scope.","commands":{"allow":["send_audio_data"],"deny":[]}},"allow-set-auth-bridge-enabled":{"identifier":"allow-set-auth-bridge-enabled","description":"Enables the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":["set_auth_bridge_enabled"],"deny":[]}},"allow-set-browser-view-enabled":{"identifier":"allow-set-browser-view-enabled","description":"Enables the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":["set_browser_view_enabled"],"deny":[]}},"allow-set-browser-view-match-window":{"identifier":"allow-set-browser-view-match-window","description":"Enables the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["set_browser_view_match_window"],"deny":[]}},"allow-set-browser-view-popout-always-on-top":{"identifier":"allow-set-browser-view-popout-always-on-top","description":"Enables the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":["set_browser_view_popout_always_on_top"],"deny":[]}},"allow-set-container-page-viewport":{"identifier":"allow-set-container-page-viewport","description":"Enables the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":["set_container_page_viewport"],"deny":[]}},"allow-set-gateway-api-key":{"identifier":"allow-set-gateway-api-key","description":"Enables the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":["set_gateway_api_key"],"deny":[]}},"allow-set-global-item-disabled":{"identifier":"allow-set-global-item-disabled","description":"Enables the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":["set_global_item_disabled"],"deny":[]}},"allow-set-scheduled-task-enabled":{"identifier":"allow-set-scheduled-task-enabled","description":"Enables the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":["set_scheduled_task_enabled"],"deny":[]}},"allow-start-audio-bridge":{"identifier":"allow-start-audio-bridge","description":"Enables the start_audio_bridge command without any pre-configured scope.","commands":{"allow":["start_audio_bridge"],"deny":[]}},"allow-start-gateway":{"identifier":"allow-start-gateway","description":"Enables the start_gateway command without any pre-configured scope.","commands":{"allow":["start_gateway"],"deny":[]}},"allow-start-marketplace-gh-container-login":{"identifier":"allow-start-marketplace-gh-container-login","description":"Enables the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":["start_marketplace_gh_container_login"],"deny":[]}},"allow-start-project-container":{"identifier":"allow-start-project-container","description":"Enables the start_project_container command without any pre-configured scope.","commands":{"allow":["start_project_container"],"deny":[]}},"allow-start-stt":{"identifier":"allow-start-stt","description":"Enables the start_stt command without any pre-configured scope.","commands":{"allow":["start_stt"],"deny":[]}},"allow-start-web-terminal":{"identifier":"allow-start-web-terminal","description":"Enables the start_web_terminal command without any pre-configured scope.","commands":{"allow":["start_web_terminal"],"deny":[]}},"allow-stop-audio-bridge":{"identifier":"allow-stop-audio-bridge","description":"Enables the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":["stop_audio_bridge"],"deny":[]}},"allow-stop-gateway":{"identifier":"allow-stop-gateway","description":"Enables the stop_gateway command without any pre-configured scope.","commands":{"allow":["stop_gateway"],"deny":[]}},"allow-stop-project-container":{"identifier":"allow-stop-project-container","description":"Enables the stop_project_container command without any pre-configured scope.","commands":{"allow":["stop_project_container"],"deny":[]}},"allow-stop-stt":{"identifier":"allow-stop-stt","description":"Enables the stop_stt command without any pre-configured scope.","commands":{"allow":["stop_stt"],"deny":[]}},"allow-stop-web-terminal":{"identifier":"allow-stop-web-terminal","description":"Enables the stop_web_terminal command without any pre-configured scope.","commands":{"allow":["stop_web_terminal"],"deny":[]}},"allow-submit-claude-token-code":{"identifier":"allow-submit-claude-token-code","description":"Enables the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":["submit_claude_token_code"],"deny":[]}},"allow-sweep-claude-token-snapshots":{"identifier":"allow-sweep-claude-token-snapshots","description":"Enables the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":["sweep_claude_token_snapshots"],"deny":[]}},"allow-terminal-input":{"identifier":"allow-terminal-input","description":"Enables the terminal_input command without any pre-configured scope.","commands":{"allow":["terminal_input"],"deny":[]}},"allow-terminal-resize":{"identifier":"allow-terminal-resize","description":"Enables the terminal_resize command without any pre-configured scope.","commands":{"allow":["terminal_resize"],"deny":[]}},"allow-test-marketplace-account":{"identifier":"allow-test-marketplace-account","description":"Enables the test_marketplace_account command without any pre-configured scope.","commands":{"allow":["test_marketplace_account"],"deny":[]}},"allow-transcribe-audio":{"identifier":"allow-transcribe-audio","description":"Enables the transcribe_audio command without any pre-configured scope.","commands":{"allow":["transcribe_audio"],"deny":[]}},"allow-uninstall-marketplace-item":{"identifier":"allow-uninstall-marketplace-item","description":"Enables the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":["uninstall_marketplace_item"],"deny":[]}},"allow-update-aws-connection":{"identifier":"allow-update-aws-connection","description":"Enables the update_aws_connection command without any pre-configured scope.","commands":{"allow":["update_aws_connection"],"deny":[]}},"allow-update-marketplace":{"identifier":"allow-update-marketplace","description":"Enables the update_marketplace command without any pre-configured scope.","commands":{"allow":["update_marketplace"],"deny":[]}},"allow-update-marketplace-item":{"identifier":"allow-update-marketplace-item","description":"Enables the update_marketplace_item command without any pre-configured scope.","commands":{"allow":["update_marketplace_item"],"deny":[]}},"allow-update-project":{"identifier":"allow-update-project","description":"Enables the update_project command without any pre-configured scope.","commands":{"allow":["update_project"],"deny":[]}},"allow-update-scheduled-task":{"identifier":"allow-update-scheduled-task","description":"Enables the update_scheduled_task command without any pre-configured scope.","commands":{"allow":["update_scheduled_task"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-upload-files-to-container":{"identifier":"allow-upload-files-to-container","description":"Enables the upload_files_to_container command without any pre-configured scope.","commands":{"allow":["upload_files_to_container"],"deny":[]}},"allow-upload-host-file-to-terminal":{"identifier":"allow-upload-host-file-to-terminal","description":"Enables the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":["upload_host_file_to_terminal"],"deny":[]}},"allow-viewer-choose-file":{"identifier":"allow-viewer-choose-file","description":"Enables the viewer_choose_file command without any pre-configured scope.","commands":{"allow":["viewer_choose_file"],"deny":[]}},"allow-viewer-get-state":{"identifier":"allow-viewer-get-state","description":"Enables the viewer_get_state command without any pre-configured scope.","commands":{"allow":["viewer_get_state"],"deny":[]}},"allow-viewer-poll-file":{"identifier":"allow-viewer-poll-file","description":"Enables the viewer_poll_file command without any pre-configured scope.","commands":{"allow":["viewer_poll_file"],"deny":[]}},"allow-viewer-read-file":{"identifier":"allow-viewer-read-file","description":"Enables the viewer_read_file command without any pre-configured scope.","commands":{"allow":["viewer_read_file"],"deny":[]}},"allow-viewer-write-file":{"identifier":"allow-viewer-write-file","description":"Enables the viewer_write_file command without any pre-configured scope.","commands":{"allow":["viewer_write_file"],"deny":[]}},"deny-acquire-claude-token":{"identifier":"deny-acquire-claude-token","description":"Denies the acquire_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["acquire_claude_token"]}},"deny-add-aws-keys-connection":{"identifier":"deny-add-aws-keys-connection","description":"Denies the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_keys_connection"]}},"deny-add-aws-profile-connection":{"identifier":"deny-add-aws-profile-connection","description":"Denies the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_profile_connection"]}},"deny-add-marketplace":{"identifier":"deny-add-marketplace","description":"Denies the add_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace"]}},"deny-add-marketplace-gh-host-account":{"identifier":"deny-add-marketplace-gh-host-account","description":"Denies the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_gh_host_account"]}},"deny-add-marketplace-token-account":{"identifier":"deny-add-marketplace-token-account","description":"Denies the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_token_account"]}},"deny-add-project":{"identifier":"deny-add-project","description":"Denies the add_project command without any pre-configured scope.","commands":{"allow":[],"deny":["add_project"]}},"deny-add-scheduled-task":{"identifier":"deny-add-scheduled-task","description":"Denies the add_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["add_scheduled_task"]}},"deny-apply-marketplace-now":{"identifier":"deny-apply-marketplace-now","description":"Denies the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_marketplace_now"]}},"deny-apply-settings-import":{"identifier":"deny-apply-settings-import","description":"Denies the apply_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_settings_import"]}},"deny-aws-connection-identity":{"identifier":"deny-aws-connection-identity","description":"Denies the aws_connection_identity command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_identity"]}},"deny-aws-connection-sso-login":{"identifier":"deny-aws-connection-sso-login","description":"Denies the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_sso_login"]}},"deny-aws-policy-document":{"identifier":"deny-aws-policy-document","description":"Denies the aws_policy_document command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_policy_document"]}},"deny-aws-sso-refresh":{"identifier":"deny-aws-sso-refresh","description":"Denies the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_sso_refresh"]}},"deny-build-gateway-image":{"identifier":"deny-build-gateway-image","description":"Denies the build_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_gateway_image"]}},"deny-build-image":{"identifier":"deny-build-image","description":"Denies the build_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_image"]}},"deny-build-stt-image":{"identifier":"deny-build-stt-image","description":"Denies the build_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_stt_image"]}},"deny-cancel-claude-token":{"identifier":"deny-cancel-claude-token","description":"Denies the cancel_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_claude_token"]}},"deny-cancel-marketplace-gh-login":{"identifier":"deny-cancel-marketplace-gh-login","description":"Denies the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_marketplace_gh_login"]}},"deny-check-aws-permissions":{"identifier":"deny-check-aws-permissions","description":"Denies the check_aws_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_aws_permissions"]}},"deny-check-browser-view-support":{"identifier":"deny-check-browser-view-support","description":"Denies the check_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["check_browser_view_support"]}},"deny-check-docker":{"identifier":"deny-check-docker","description":"Denies the check_docker command without any pre-configured scope.","commands":{"allow":[],"deny":["check_docker"]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-check-gateway-health":{"identifier":"deny-check-gateway-health","description":"Denies the check_gateway_health command without any pre-configured scope.","commands":{"allow":[],"deny":["check_gateway_health"]}},"deny-check-image-exists":{"identifier":"deny-check-image-exists","description":"Denies the check_image_exists command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_exists"]}},"deny-check-image-update":{"identifier":"deny-check-image-update","description":"Denies the check_image_update command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_update"]}},"deny-clear-claude-token":{"identifier":"deny-clear-claude-token","description":"Denies the clear_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_claude_token"]}},"deny-clear-gateway-api-key":{"identifier":"deny-clear-gateway-api-key","description":"Denies the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_gateway_api_key"]}},"deny-clear-scheduler-notifications":{"identifier":"deny-clear-scheduler-notifications","description":"Denies the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_scheduler_notifications"]}},"deny-close-browser-view-popout":{"identifier":"deny-close-browser-view-popout","description":"Denies the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["close_browser_view_popout"]}},"deny-close-container-page":{"identifier":"deny-close-container-page","description":"Denies the close_container_page command without any pre-configured scope.","commands":{"allow":[],"deny":["close_container_page"]}},"deny-close-terminal-session":{"identifier":"deny-close-terminal-session","description":"Denies the close_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["close_terminal_session"]}},"deny-confirm-migration":{"identifier":"deny-confirm-migration","description":"Denies the confirm_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm_migration"]}},"deny-create-container-directory":{"identifier":"deny-create-container-directory","description":"Denies the create_container_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["create_container_directory"]}},"deny-delete-note":{"identifier":"deny-delete-note","description":"Denies the delete_note command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_note"]}},"deny-detect-aws-config":{"identifier":"deny-detect-aws-config","description":"Denies the detect_aws_config command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_config"]}},"deny-detect-host-timezone":{"identifier":"deny-detect-host-timezone","description":"Denies the detect_host_timezone command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_host_timezone"]}},"deny-detect-install-options":{"identifier":"deny-detect-install-options","description":"Denies the detect_install_options command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_install_options"]}},"deny-download-container-backup":{"identifier":"deny-download-container-backup","description":"Denies the download_container_backup command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_backup"]}},"deny-download-container-file":{"identifier":"deny-download-container-file","description":"Denies the download_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_file"]}},"deny-export-settings":{"identifier":"deny-export-settings","description":"Denies the export_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["export_settings"]}},"deny-forget-marketplace-installs":{"identifier":"deny-forget-marketplace-installs","description":"Denies the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":[],"deny":["forget_marketplace_installs"]}},"deny-get-app-version":{"identifier":"deny-get-app-version","description":"Denies the get_app_version command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_version"]}},"deny-get-auth-bridge-status":{"identifier":"deny-get-auth-bridge-status","description":"Denies the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_auth_bridge_status"]}},"deny-get-browser-view-match-window":{"identifier":"deny-get-browser-view-match-window","description":"Denies the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_match_window"]}},"deny-get-browser-view-popout-state":{"identifier":"deny-get-browser-view-popout-state","description":"Denies the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_popout_state"]}},"deny-get-browser-view-status":{"identifier":"deny-get-browser-view-status","description":"Denies the get_browser_view_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_status"]}},"deny-get-container-info":{"identifier":"deny-get-container-info","description":"Denies the get_container_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_info"]}},"deny-get-container-page-state":{"identifier":"deny-get-container-page-state","description":"Denies the get_container_page_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_page_state"]}},"deny-get-container-staleness":{"identifier":"deny-get-container-staleness","description":"Denies the get_container_staleness command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_staleness"]}},"deny-get-gateway-auth-token":{"identifier":"deny-get-gateway-auth-token","description":"Denies the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_auth_token"]}},"deny-get-gateway-status":{"identifier":"deny-get-gateway-status","description":"Denies the get_gateway_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_status"]}},"deny-get-help-content":{"identifier":"deny-get-help-content","description":"Denies the get_help_content command without any pre-configured scope.","commands":{"allow":[],"deny":["get_help_content"]}},"deny-get-marketplace-sync-report":{"identifier":"deny-get-marketplace-sync-report","description":"Denies the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":[],"deny":["get_marketplace_sync_report"]}},"deny-get-migration-state":{"identifier":"deny-get-migration-state","description":"Denies the get_migration_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_migration_state"]}},"deny-get-scheduled-task-log":{"identifier":"deny-get-scheduled-task-log","description":"Denies the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduled_task_log"]}},"deny-get-scheduler-notifications":{"identifier":"deny-get-scheduler-notifications","description":"Denies the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduler_notifications"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-get-stt-status":{"identifier":"deny-get-stt-status","description":"Denies the get_stt_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_stt_status"]}},"deny-get-web-terminal-status":{"identifier":"deny-get-web-terminal-status","description":"Denies the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_web_terminal_status"]}},"deny-has-claude-token":{"identifier":"deny-has-claude-token","description":"Denies the has_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["has_claude_token"]}},"deny-inspect-ca-cert-path":{"identifier":"deny-inspect-ca-cert-path","description":"Denies the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":[],"deny":["inspect_ca_cert_path"]}},"deny-install-browser-view-browser":{"identifier":"deny-install-browser-view-browser","description":"Denies the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_browser"]}},"deny-install-browser-view-support":{"identifier":"deny-install-browser-view-support","description":"Denies the install_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_support"]}},"deny-install-marketplace-item":{"identifier":"deny-install-marketplace-item","description":"Denies the install_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["install_marketplace_item"]}},"deny-list-aws-profiles":{"identifier":"deny-list-aws-profiles","description":"Denies the list_aws_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_aws_profiles"]}},"deny-list-claude-sessions":{"identifier":"deny-list-claude-sessions","description":"Denies the list_claude_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["list_claude_sessions"]}},"deny-list-container-capabilities":{"identifier":"deny-list-container-capabilities","description":"Denies the list_container_capabilities command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_capabilities"]}},"deny-list-container-files":{"identifier":"deny-list-container-files","description":"Denies the list_container_files command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_files"]}},"deny-list-marketplace-snapshots":{"identifier":"deny-list-marketplace-snapshots","description":"Denies the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_snapshots"]}},"deny-list-marketplace-updates":{"identifier":"deny-list-marketplace-updates","description":"Denies the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_updates"]}},"deny-list-notes":{"identifier":"deny-list-notes","description":"Denies the list_notes command without any pre-configured scope.","commands":{"allow":[],"deny":["list_notes"]}},"deny-list-projects":{"identifier":"deny-list-projects","description":"Denies the list_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["list_projects"]}},"deny-list-scheduled-tasks":{"identifier":"deny-list-scheduled-tasks","description":"Denies the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":[],"deny":["list_scheduled_tasks"]}},"deny-marketplace-gh-host-available":{"identifier":"deny-marketplace-gh-host-available","description":"Denies the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_gh_host_available"]}},"deny-marketplace-item-diff":{"identifier":"deny-marketplace-item-diff","description":"Denies the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_item_diff"]}},"deny-migrate-project-to-base":{"identifier":"deny-migrate-project-to-base","description":"Denies the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":[],"deny":["migrate_project_to_base"]}},"deny-open-browser-view-popout":{"identifier":"deny-open-browser-view-popout","description":"Denies the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["open_browser_view_popout"]}},"deny-open-file-viewer":{"identifier":"deny-open-file-viewer","description":"Denies the open_file_viewer command without any pre-configured scope.","commands":{"allow":[],"deny":["open_file_viewer"]}},"deny-open-page-in-container-browser":{"identifier":"deny-open-page-in-container-browser","description":"Denies the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["open_page_in_container_browser"]}},"deny-open-terminal-session":{"identifier":"deny-open-terminal-session","description":"Denies the open_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["open_terminal_session"]}},"deny-open-url-external":{"identifier":"deny-open-url-external","description":"Denies the open_url_external command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url_external"]}},"deny-paste-image-to-terminal":{"identifier":"deny-paste-image-to-terminal","description":"Denies the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["paste_image_to_terminal"]}},"deny-preview-settings-import":{"identifier":"deny-preview-settings-import","description":"Denies the preview_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["preview_settings_import"]}},"deny-pull-gateway-image":{"identifier":"deny-pull-gateway-image","description":"Denies the pull_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_gateway_image"]}},"deny-pull-image":{"identifier":"deny-pull-image","description":"Denies the pull_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_image"]}},"deny-pull-stt-image":{"identifier":"deny-pull-stt-image","description":"Denies the pull_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_stt_image"]}},"deny-read-container-file":{"identifier":"deny-read-container-file","description":"Denies the read_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_container_file"]}},"deny-rebuild-project-container":{"identifier":"deny-rebuild-project-container","description":"Denies the rebuild_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["rebuild_project_container"]}},"deny-reconcile-project-statuses":{"identifier":"deny-reconcile-project-statuses","description":"Denies the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":[],"deny":["reconcile_project_statuses"]}},"deny-refresh-marketplaces":{"identifier":"deny-refresh-marketplaces","description":"Denies the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":[],"deny":["refresh_marketplaces"]}},"deny-regenerate-gateway-auth-token":{"identifier":"deny-regenerate-gateway-auth-token","description":"Denies the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_gateway_auth_token"]}},"deny-regenerate-web-terminal-token":{"identifier":"deny-regenerate-web-terminal-token","description":"Denies the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_web_terminal_token"]}},"deny-remove-host-connection":{"identifier":"deny-remove-host-connection","description":"Denies the remove_host_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_host_connection"]}},"deny-remove-marketplace":{"identifier":"deny-remove-marketplace","description":"Denies the remove_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace"]}},"deny-remove-marketplace-account":{"identifier":"deny-remove-marketplace-account","description":"Denies the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace_account"]}},"deny-remove-project":{"identifier":"deny-remove-project","description":"Denies the remove_project command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_project"]}},"deny-remove-scheduled-task":{"identifier":"deny-remove-scheduled-task","description":"Denies the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_scheduled_task"]}},"deny-rename-container-path":{"identifier":"deny-rename-container-path","description":"Denies the rename_container_path command without any pre-configured scope.","commands":{"allow":[],"deny":["rename_container_path"]}},"deny-rollback-migration":{"identifier":"deny-rollback-migration","description":"Denies the rollback_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["rollback_migration"]}},"deny-run-docker-install":{"identifier":"deny-run-docker-install","description":"Denies the run_docker_install command without any pre-configured scope.","commands":{"allow":[],"deny":["run_docker_install"]}},"deny-run-scheduled-task-now":{"identifier":"deny-run-scheduled-task-now","description":"Denies the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":[],"deny":["run_scheduled_task_now"]}},"deny-save-note":{"identifier":"deny-save-note","description":"Denies the save_note command without any pre-configured scope.","commands":{"allow":[],"deny":["save_note"]}},"deny-send-audio-data":{"identifier":"deny-send-audio-data","description":"Denies the send_audio_data command without any pre-configured scope.","commands":{"allow":[],"deny":["send_audio_data"]}},"deny-set-auth-bridge-enabled":{"identifier":"deny-set-auth-bridge-enabled","description":"Denies the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_auth_bridge_enabled"]}},"deny-set-browser-view-enabled":{"identifier":"deny-set-browser-view-enabled","description":"Denies the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_enabled"]}},"deny-set-browser-view-match-window":{"identifier":"deny-set-browser-view-match-window","description":"Denies the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_match_window"]}},"deny-set-browser-view-popout-always-on-top":{"identifier":"deny-set-browser-view-popout-always-on-top","description":"Denies the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_popout_always_on_top"]}},"deny-set-container-page-viewport":{"identifier":"deny-set-container-page-viewport","description":"Denies the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":[],"deny":["set_container_page_viewport"]}},"deny-set-gateway-api-key":{"identifier":"deny-set-gateway-api-key","description":"Denies the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["set_gateway_api_key"]}},"deny-set-global-item-disabled":{"identifier":"deny-set-global-item-disabled","description":"Denies the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_global_item_disabled"]}},"deny-set-scheduled-task-enabled":{"identifier":"deny-set-scheduled-task-enabled","description":"Denies the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_scheduled_task_enabled"]}},"deny-start-audio-bridge":{"identifier":"deny-start-audio-bridge","description":"Denies the start_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["start_audio_bridge"]}},"deny-start-gateway":{"identifier":"deny-start-gateway","description":"Denies the start_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["start_gateway"]}},"deny-start-marketplace-gh-container-login":{"identifier":"deny-start-marketplace-gh-container-login","description":"Denies the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":[],"deny":["start_marketplace_gh_container_login"]}},"deny-start-project-container":{"identifier":"deny-start-project-container","description":"Denies the start_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["start_project_container"]}},"deny-start-stt":{"identifier":"deny-start-stt","description":"Denies the start_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["start_stt"]}},"deny-start-web-terminal":{"identifier":"deny-start-web-terminal","description":"Denies the start_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["start_web_terminal"]}},"deny-stop-audio-bridge":{"identifier":"deny-stop-audio-bridge","description":"Denies the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_audio_bridge"]}},"deny-stop-gateway":{"identifier":"deny-stop-gateway","description":"Denies the stop_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_gateway"]}},"deny-stop-project-container":{"identifier":"deny-stop-project-container","description":"Denies the stop_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_project_container"]}},"deny-stop-stt":{"identifier":"deny-stop-stt","description":"Denies the stop_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_stt"]}},"deny-stop-web-terminal":{"identifier":"deny-stop-web-terminal","description":"Denies the stop_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_web_terminal"]}},"deny-submit-claude-token-code":{"identifier":"deny-submit-claude-token-code","description":"Denies the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":[],"deny":["submit_claude_token_code"]}},"deny-sweep-claude-token-snapshots":{"identifier":"deny-sweep-claude-token-snapshots","description":"Denies the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["sweep_claude_token_snapshots"]}},"deny-terminal-input":{"identifier":"deny-terminal-input","description":"Denies the terminal_input command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_input"]}},"deny-terminal-resize":{"identifier":"deny-terminal-resize","description":"Denies the terminal_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_resize"]}},"deny-test-marketplace-account":{"identifier":"deny-test-marketplace-account","description":"Denies the test_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["test_marketplace_account"]}},"deny-transcribe-audio":{"identifier":"deny-transcribe-audio","description":"Denies the transcribe_audio command without any pre-configured scope.","commands":{"allow":[],"deny":["transcribe_audio"]}},"deny-uninstall-marketplace-item":{"identifier":"deny-uninstall-marketplace-item","description":"Denies the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["uninstall_marketplace_item"]}},"deny-update-aws-connection":{"identifier":"deny-update-aws-connection","description":"Denies the update_aws_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_connection"]}},"deny-update-marketplace":{"identifier":"deny-update-marketplace","description":"Denies the update_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace"]}},"deny-update-marketplace-item":{"identifier":"deny-update-marketplace-item","description":"Denies the update_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace_item"]}},"deny-update-project":{"identifier":"deny-update-project","description":"Denies the update_project command without any pre-configured scope.","commands":{"allow":[],"deny":["update_project"]}},"deny-update-scheduled-task":{"identifier":"deny-update-scheduled-task","description":"Denies the update_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["update_scheduled_task"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-upload-files-to-container":{"identifier":"deny-upload-files-to-container","description":"Denies the upload_files_to_container command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_files_to_container"]}},"deny-upload-host-file-to-terminal":{"identifier":"deny-upload-host-file-to-terminal","description":"Denies the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_host_file_to_terminal"]}},"deny-viewer-choose-file":{"identifier":"deny-viewer-choose-file","description":"Denies the viewer_choose_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_choose_file"]}},"deny-viewer-get-state":{"identifier":"deny-viewer-get-state","description":"Denies the viewer_get_state command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_get_state"]}},"deny-viewer-poll-file":{"identifier":"deny-viewer-poll-file","description":"Denies the viewer_poll_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_poll_file"]}},"deny-viewer-read-file":{"identifier":"deny-viewer-read-file","description":"Denies the viewer_read_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_read_file"]}},"deny-viewer-write-file":{"identifier":"deny-viewer-write-file","description":"Denies the viewer_write_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_write_file"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-aws-deployment":{"identifier":"allow-cancel-aws-deployment","description":"Enables the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":["cancel_aws_deployment"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["clear_scheduler_notifications"],"deny":[]}},"allow-close-browser-view-popout":{"identifier":"allow-close-browser-view-popout","description":"Enables the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":["close_browser_view_popout"],"deny":[]}},"allow-close-container-page":{"identifier":"allow-close-container-page","description":"Enables the close_container_page command without any pre-configured scope.","commands":{"allow":["close_container_page"],"deny":[]}},"allow-close-terminal-session":{"identifier":"allow-close-terminal-session","description":"Enables the close_terminal_session command without any pre-configured scope.","commands":{"allow":["close_terminal_session"],"deny":[]}},"allow-confirm-migration":{"identifier":"allow-confirm-migration","description":"Enables the confirm_migration command without any pre-configured scope.","commands":{"allow":["confirm_migration"],"deny":[]}},"allow-create-container-directory":{"identifier":"allow-create-container-directory","description":"Enables the create_container_directory command without any pre-configured scope.","commands":{"allow":["create_container_directory"],"deny":[]}},"allow-delete-note":{"identifier":"allow-delete-note","description":"Enables the delete_note command without any pre-configured scope.","commands":{"allow":["delete_note"],"deny":[]}},"allow-deploy-aws-environment":{"identifier":"allow-deploy-aws-environment","description":"Enables the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":["deploy_aws_environment"],"deny":[]}},"allow-deploy-aws-permissions-boundary":{"identifier":"allow-deploy-aws-permissions-boundary","description":"Enables the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":["deploy_aws_permissions_boundary"],"deny":[]}},"allow-destroy-incomplete-aws-environment":{"identifier":"allow-destroy-incomplete-aws-environment","description":"Enables the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":["destroy_incomplete_aws_environment"],"deny":[]}},"allow-detect-aws-config":{"identifier":"allow-detect-aws-config","description":"Enables the detect_aws_config command without any pre-configured scope.","commands":{"allow":["detect_aws_config"],"deny":[]}},"allow-detect-aws-environments":{"identifier":"allow-detect-aws-environments","description":"Enables the detect_aws_environments command without any pre-configured scope.","commands":{"allow":["detect_aws_environments"],"deny":[]}},"allow-detect-host-timezone":{"identifier":"allow-detect-host-timezone","description":"Enables the detect_host_timezone command without any pre-configured scope.","commands":{"allow":["detect_host_timezone"],"deny":[]}},"allow-detect-install-options":{"identifier":"allow-detect-install-options","description":"Enables the detect_install_options command without any pre-configured scope.","commands":{"allow":["detect_install_options"],"deny":[]}},"allow-download-container-backup":{"identifier":"allow-download-container-backup","description":"Enables the download_container_backup command without any pre-configured scope.","commands":{"allow":["download_container_backup"],"deny":[]}},"allow-download-container-file":{"identifier":"allow-download-container-file","description":"Enables the download_container_file command without any pre-configured scope.","commands":{"allow":["download_container_file"],"deny":[]}},"allow-export-settings":{"identifier":"allow-export-settings","description":"Enables the export_settings command without any pre-configured scope.","commands":{"allow":["export_settings"],"deny":[]}},"allow-follow-aws-deployment":{"identifier":"allow-follow-aws-deployment","description":"Enables the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":["follow_aws_deployment"],"deny":[]}},"allow-forget-marketplace-installs":{"identifier":"allow-forget-marketplace-installs","description":"Enables the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":["forget_marketplace_installs"],"deny":[]}},"allow-get-app-version":{"identifier":"allow-get-app-version","description":"Enables the get_app_version command without any pre-configured scope.","commands":{"allow":["get_app_version"],"deny":[]}},"allow-get-auth-bridge-status":{"identifier":"allow-get-auth-bridge-status","description":"Enables the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":["get_auth_bridge_status"],"deny":[]}},"allow-get-browser-view-match-window":{"identifier":"allow-get-browser-view-match-window","description":"Enables the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["get_browser_view_match_window"],"deny":[]}},"allow-get-browser-view-popout-state":{"identifier":"allow-get-browser-view-popout-state","description":"Enables the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":["get_browser_view_popout_state"],"deny":[]}},"allow-get-browser-view-status":{"identifier":"allow-get-browser-view-status","description":"Enables the get_browser_view_status command without any pre-configured scope.","commands":{"allow":["get_browser_view_status"],"deny":[]}},"allow-get-container-info":{"identifier":"allow-get-container-info","description":"Enables the get_container_info command without any pre-configured scope.","commands":{"allow":["get_container_info"],"deny":[]}},"allow-get-container-page-state":{"identifier":"allow-get-container-page-state","description":"Enables the get_container_page_state command without any pre-configured scope.","commands":{"allow":["get_container_page_state"],"deny":[]}},"allow-get-container-staleness":{"identifier":"allow-get-container-staleness","description":"Enables the get_container_staleness command without any pre-configured scope.","commands":{"allow":["get_container_staleness"],"deny":[]}},"allow-get-gateway-auth-token":{"identifier":"allow-get-gateway-auth-token","description":"Enables the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["get_gateway_auth_token"],"deny":[]}},"allow-get-gateway-status":{"identifier":"allow-get-gateway-status","description":"Enables the get_gateway_status command without any pre-configured scope.","commands":{"allow":["get_gateway_status"],"deny":[]}},"allow-get-help-content":{"identifier":"allow-get-help-content","description":"Enables the get_help_content command without any pre-configured scope.","commands":{"allow":["get_help_content"],"deny":[]}},"allow-get-marketplace-sync-report":{"identifier":"allow-get-marketplace-sync-report","description":"Enables the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":["get_marketplace_sync_report"],"deny":[]}},"allow-get-migration-state":{"identifier":"allow-get-migration-state","description":"Enables the get_migration_state command without any pre-configured scope.","commands":{"allow":["get_migration_state"],"deny":[]}},"allow-get-scheduled-task-log":{"identifier":"allow-get-scheduled-task-log","description":"Enables the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":["get_scheduled_task_log"],"deny":[]}},"allow-get-scheduler-notifications":{"identifier":"allow-get-scheduler-notifications","description":"Enables the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["get_scheduler_notifications"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-get-stt-status":{"identifier":"allow-get-stt-status","description":"Enables the get_stt_status command without any pre-configured scope.","commands":{"allow":["get_stt_status"],"deny":[]}},"allow-get-web-terminal-status":{"identifier":"allow-get-web-terminal-status","description":"Enables the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":["get_web_terminal_status"],"deny":[]}},"allow-has-claude-token":{"identifier":"allow-has-claude-token","description":"Enables the has_claude_token command without any pre-configured scope.","commands":{"allow":["has_claude_token"],"deny":[]}},"allow-inspect-ca-cert-path":{"identifier":"allow-inspect-ca-cert-path","description":"Enables the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":["inspect_ca_cert_path"],"deny":[]}},"allow-install-browser-view-browser":{"identifier":"allow-install-browser-view-browser","description":"Enables the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":["install_browser_view_browser"],"deny":[]}},"allow-install-browser-view-support":{"identifier":"allow-install-browser-view-support","description":"Enables the install_browser_view_support command without any pre-configured scope.","commands":{"allow":["install_browser_view_support"],"deny":[]}},"allow-install-marketplace-item":{"identifier":"allow-install-marketplace-item","description":"Enables the install_marketplace_item command without any pre-configured scope.","commands":{"allow":["install_marketplace_item"],"deny":[]}},"allow-list-aws-profiles":{"identifier":"allow-list-aws-profiles","description":"Enables the list_aws_profiles command without any pre-configured scope.","commands":{"allow":["list_aws_profiles"],"deny":[]}},"allow-list-claude-sessions":{"identifier":"allow-list-claude-sessions","description":"Enables the list_claude_sessions command without any pre-configured scope.","commands":{"allow":["list_claude_sessions"],"deny":[]}},"allow-list-container-capabilities":{"identifier":"allow-list-container-capabilities","description":"Enables the list_container_capabilities command without any pre-configured scope.","commands":{"allow":["list_container_capabilities"],"deny":[]}},"allow-list-container-files":{"identifier":"allow-list-container-files","description":"Enables the list_container_files command without any pre-configured scope.","commands":{"allow":["list_container_files"],"deny":[]}},"allow-list-marketplace-snapshots":{"identifier":"allow-list-marketplace-snapshots","description":"Enables the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":["list_marketplace_snapshots"],"deny":[]}},"allow-list-marketplace-updates":{"identifier":"allow-list-marketplace-updates","description":"Enables the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":["list_marketplace_updates"],"deny":[]}},"allow-list-notes":{"identifier":"allow-list-notes","description":"Enables the list_notes command without any pre-configured scope.","commands":{"allow":["list_notes"],"deny":[]}},"allow-list-projects":{"identifier":"allow-list-projects","description":"Enables the list_projects command without any pre-configured scope.","commands":{"allow":["list_projects"],"deny":[]}},"allow-list-scheduled-tasks":{"identifier":"allow-list-scheduled-tasks","description":"Enables the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":["list_scheduled_tasks"],"deny":[]}},"allow-marketplace-gh-host-available":{"identifier":"allow-marketplace-gh-host-available","description":"Enables the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":["marketplace_gh_host_available"],"deny":[]}},"allow-marketplace-item-diff":{"identifier":"allow-marketplace-item-diff","description":"Enables the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":["marketplace_item_diff"],"deny":[]}},"allow-migrate-project-to-base":{"identifier":"allow-migrate-project-to-base","description":"Enables the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":["migrate_project_to_base"],"deny":[]}},"allow-onboard-aws-user":{"identifier":"allow-onboard-aws-user","description":"Enables the onboard_aws_user command without any pre-configured scope.","commands":{"allow":["onboard_aws_user"],"deny":[]}},"allow-open-browser-view-popout":{"identifier":"allow-open-browser-view-popout","description":"Enables the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":["open_browser_view_popout"],"deny":[]}},"allow-open-file-viewer":{"identifier":"allow-open-file-viewer","description":"Enables the open_file_viewer command without any pre-configured scope.","commands":{"allow":["open_file_viewer"],"deny":[]}},"allow-open-page-in-container-browser":{"identifier":"allow-open-page-in-container-browser","description":"Enables the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":["open_page_in_container_browser"],"deny":[]}},"allow-open-terminal-session":{"identifier":"allow-open-terminal-session","description":"Enables the open_terminal_session command without any pre-configured scope.","commands":{"allow":["open_terminal_session"],"deny":[]}},"allow-open-url-external":{"identifier":"allow-open-url-external","description":"Enables the open_url_external command without any pre-configured scope.","commands":{"allow":["open_url_external"],"deny":[]}},"allow-paste-image-to-terminal":{"identifier":"allow-paste-image-to-terminal","description":"Enables the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":["paste_image_to_terminal"],"deny":[]}},"allow-preview-settings-import":{"identifier":"allow-preview-settings-import","description":"Enables the preview_settings_import command without any pre-configured scope.","commands":{"allow":["preview_settings_import"],"deny":[]}},"allow-pull-gateway-image":{"identifier":"allow-pull-gateway-image","description":"Enables the pull_gateway_image command without any pre-configured scope.","commands":{"allow":["pull_gateway_image"],"deny":[]}},"allow-pull-image":{"identifier":"allow-pull-image","description":"Enables the pull_image command without any pre-configured scope.","commands":{"allow":["pull_image"],"deny":[]}},"allow-pull-stt-image":{"identifier":"allow-pull-stt-image","description":"Enables the pull_stt_image command without any pre-configured scope.","commands":{"allow":["pull_stt_image"],"deny":[]}},"allow-read-container-file":{"identifier":"allow-read-container-file","description":"Enables the read_container_file command without any pre-configured scope.","commands":{"allow":["read_container_file"],"deny":[]}},"allow-rebuild-project-container":{"identifier":"allow-rebuild-project-container","description":"Enables the rebuild_project_container command without any pre-configured scope.","commands":{"allow":["rebuild_project_container"],"deny":[]}},"allow-reconcile-project-statuses":{"identifier":"allow-reconcile-project-statuses","description":"Enables the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":["reconcile_project_statuses"],"deny":[]}},"allow-refresh-marketplaces":{"identifier":"allow-refresh-marketplaces","description":"Enables the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":["refresh_marketplaces"],"deny":[]}},"allow-regenerate-gateway-auth-token":{"identifier":"allow-regenerate-gateway-auth-token","description":"Enables the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["regenerate_gateway_auth_token"],"deny":[]}},"allow-regenerate-web-terminal-token":{"identifier":"allow-regenerate-web-terminal-token","description":"Enables the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":["regenerate_web_terminal_token"],"deny":[]}},"allow-remove-host-connection":{"identifier":"allow-remove-host-connection","description":"Enables the remove_host_connection command without any pre-configured scope.","commands":{"allow":["remove_host_connection"],"deny":[]}},"allow-remove-marketplace":{"identifier":"allow-remove-marketplace","description":"Enables the remove_marketplace command without any pre-configured scope.","commands":{"allow":["remove_marketplace"],"deny":[]}},"allow-remove-marketplace-account":{"identifier":"allow-remove-marketplace-account","description":"Enables the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":["remove_marketplace_account"],"deny":[]}},"allow-remove-project":{"identifier":"allow-remove-project","description":"Enables the remove_project command without any pre-configured scope.","commands":{"allow":["remove_project"],"deny":[]}},"allow-remove-scheduled-task":{"identifier":"allow-remove-scheduled-task","description":"Enables the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":["remove_scheduled_task"],"deny":[]}},"allow-rename-container-path":{"identifier":"allow-rename-container-path","description":"Enables the rename_container_path command without any pre-configured scope.","commands":{"allow":["rename_container_path"],"deny":[]}},"allow-repair-aws-environment":{"identifier":"allow-repair-aws-environment","description":"Enables the repair_aws_environment command without any pre-configured scope.","commands":{"allow":["repair_aws_environment"],"deny":[]}},"allow-rollback-migration":{"identifier":"allow-rollback-migration","description":"Enables the rollback_migration command without any pre-configured scope.","commands":{"allow":["rollback_migration"],"deny":[]}},"allow-run-docker-install":{"identifier":"allow-run-docker-install","description":"Enables the run_docker_install command without any pre-configured scope.","commands":{"allow":["run_docker_install"],"deny":[]}},"allow-run-scheduled-task-now":{"identifier":"allow-run-scheduled-task-now","description":"Enables the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":["run_scheduled_task_now"],"deny":[]}},"allow-save-aws-admin-bundle":{"identifier":"allow-save-aws-admin-bundle","description":"Enables the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":["save_aws_admin_bundle"],"deny":[]}},"allow-save-note":{"identifier":"allow-save-note","description":"Enables the save_note command without any pre-configured scope.","commands":{"allow":["save_note"],"deny":[]}},"allow-send-audio-data":{"identifier":"allow-send-audio-data","description":"Enables the send_audio_data command without any pre-configured scope.","commands":{"allow":["send_audio_data"],"deny":[]}},"allow-set-auth-bridge-enabled":{"identifier":"allow-set-auth-bridge-enabled","description":"Enables the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":["set_auth_bridge_enabled"],"deny":[]}},"allow-set-browser-view-enabled":{"identifier":"allow-set-browser-view-enabled","description":"Enables the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":["set_browser_view_enabled"],"deny":[]}},"allow-set-browser-view-match-window":{"identifier":"allow-set-browser-view-match-window","description":"Enables the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["set_browser_view_match_window"],"deny":[]}},"allow-set-browser-view-popout-always-on-top":{"identifier":"allow-set-browser-view-popout-always-on-top","description":"Enables the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":["set_browser_view_popout_always_on_top"],"deny":[]}},"allow-set-container-page-viewport":{"identifier":"allow-set-container-page-viewport","description":"Enables the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":["set_container_page_viewport"],"deny":[]}},"allow-set-gateway-api-key":{"identifier":"allow-set-gateway-api-key","description":"Enables the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":["set_gateway_api_key"],"deny":[]}},"allow-set-global-item-disabled":{"identifier":"allow-set-global-item-disabled","description":"Enables the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":["set_global_item_disabled"],"deny":[]}},"allow-set-scheduled-task-enabled":{"identifier":"allow-set-scheduled-task-enabled","description":"Enables the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":["set_scheduled_task_enabled"],"deny":[]}},"allow-start-audio-bridge":{"identifier":"allow-start-audio-bridge","description":"Enables the start_audio_bridge command without any pre-configured scope.","commands":{"allow":["start_audio_bridge"],"deny":[]}},"allow-start-gateway":{"identifier":"allow-start-gateway","description":"Enables the start_gateway command without any pre-configured scope.","commands":{"allow":["start_gateway"],"deny":[]}},"allow-start-marketplace-gh-container-login":{"identifier":"allow-start-marketplace-gh-container-login","description":"Enables the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":["start_marketplace_gh_container_login"],"deny":[]}},"allow-start-project-container":{"identifier":"allow-start-project-container","description":"Enables the start_project_container command without any pre-configured scope.","commands":{"allow":["start_project_container"],"deny":[]}},"allow-start-stt":{"identifier":"allow-start-stt","description":"Enables the start_stt command without any pre-configured scope.","commands":{"allow":["start_stt"],"deny":[]}},"allow-start-web-terminal":{"identifier":"allow-start-web-terminal","description":"Enables the start_web_terminal command without any pre-configured scope.","commands":{"allow":["start_web_terminal"],"deny":[]}},"allow-stop-audio-bridge":{"identifier":"allow-stop-audio-bridge","description":"Enables the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":["stop_audio_bridge"],"deny":[]}},"allow-stop-gateway":{"identifier":"allow-stop-gateway","description":"Enables the stop_gateway command without any pre-configured scope.","commands":{"allow":["stop_gateway"],"deny":[]}},"allow-stop-project-container":{"identifier":"allow-stop-project-container","description":"Enables the stop_project_container command without any pre-configured scope.","commands":{"allow":["stop_project_container"],"deny":[]}},"allow-stop-stt":{"identifier":"allow-stop-stt","description":"Enables the stop_stt command without any pre-configured scope.","commands":{"allow":["stop_stt"],"deny":[]}},"allow-stop-web-terminal":{"identifier":"allow-stop-web-terminal","description":"Enables the stop_web_terminal command without any pre-configured scope.","commands":{"allow":["stop_web_terminal"],"deny":[]}},"allow-submit-claude-token-code":{"identifier":"allow-submit-claude-token-code","description":"Enables the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":["submit_claude_token_code"],"deny":[]}},"allow-sweep-claude-token-snapshots":{"identifier":"allow-sweep-claude-token-snapshots","description":"Enables the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":["sweep_claude_token_snapshots"],"deny":[]}},"allow-terminal-input":{"identifier":"allow-terminal-input","description":"Enables the terminal_input command without any pre-configured scope.","commands":{"allow":["terminal_input"],"deny":[]}},"allow-terminal-resize":{"identifier":"allow-terminal-resize","description":"Enables the terminal_resize command without any pre-configured scope.","commands":{"allow":["terminal_resize"],"deny":[]}},"allow-test-marketplace-account":{"identifier":"allow-test-marketplace-account","description":"Enables the test_marketplace_account command without any pre-configured scope.","commands":{"allow":["test_marketplace_account"],"deny":[]}},"allow-transcribe-audio":{"identifier":"allow-transcribe-audio","description":"Enables the transcribe_audio command without any pre-configured scope.","commands":{"allow":["transcribe_audio"],"deny":[]}},"allow-uninstall-marketplace-item":{"identifier":"allow-uninstall-marketplace-item","description":"Enables the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":["uninstall_marketplace_item"],"deny":[]}},"allow-unlock-aws-state":{"identifier":"allow-unlock-aws-state","description":"Enables the unlock_aws_state command without any pre-configured scope.","commands":{"allow":["unlock_aws_state"],"deny":[]}},"allow-update-aws-connection":{"identifier":"allow-update-aws-connection","description":"Enables the update_aws_connection command without any pre-configured scope.","commands":{"allow":["update_aws_connection"],"deny":[]}},"allow-update-aws-environment":{"identifier":"allow-update-aws-environment","description":"Enables the update_aws_environment command without any pre-configured scope.","commands":{"allow":["update_aws_environment"],"deny":[]}},"allow-update-marketplace":{"identifier":"allow-update-marketplace","description":"Enables the update_marketplace command without any pre-configured scope.","commands":{"allow":["update_marketplace"],"deny":[]}},"allow-update-marketplace-item":{"identifier":"allow-update-marketplace-item","description":"Enables the update_marketplace_item command without any pre-configured scope.","commands":{"allow":["update_marketplace_item"],"deny":[]}},"allow-update-project":{"identifier":"allow-update-project","description":"Enables the update_project command without any pre-configured scope.","commands":{"allow":["update_project"],"deny":[]}},"allow-update-scheduled-task":{"identifier":"allow-update-scheduled-task","description":"Enables the update_scheduled_task command without any pre-configured scope.","commands":{"allow":["update_scheduled_task"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-upload-files-to-container":{"identifier":"allow-upload-files-to-container","description":"Enables the upload_files_to_container command without any pre-configured scope.","commands":{"allow":["upload_files_to_container"],"deny":[]}},"allow-upload-host-file-to-terminal":{"identifier":"allow-upload-host-file-to-terminal","description":"Enables the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":["upload_host_file_to_terminal"],"deny":[]}},"allow-viewer-choose-file":{"identifier":"allow-viewer-choose-file","description":"Enables the viewer_choose_file command without any pre-configured scope.","commands":{"allow":["viewer_choose_file"],"deny":[]}},"allow-viewer-get-state":{"identifier":"allow-viewer-get-state","description":"Enables the viewer_get_state command without any pre-configured scope.","commands":{"allow":["viewer_get_state"],"deny":[]}},"allow-viewer-poll-file":{"identifier":"allow-viewer-poll-file","description":"Enables the viewer_poll_file command without any pre-configured scope.","commands":{"allow":["viewer_poll_file"],"deny":[]}},"allow-viewer-read-file":{"identifier":"allow-viewer-read-file","description":"Enables the viewer_read_file command without any pre-configured scope.","commands":{"allow":["viewer_read_file"],"deny":[]}},"allow-viewer-write-file":{"identifier":"allow-viewer-write-file","description":"Enables the viewer_write_file command without any pre-configured scope.","commands":{"allow":["viewer_write_file"],"deny":[]}},"deny-acquire-claude-token":{"identifier":"deny-acquire-claude-token","description":"Denies the acquire_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["acquire_claude_token"]}},"deny-add-aws-keys-connection":{"identifier":"deny-add-aws-keys-connection","description":"Denies the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_keys_connection"]}},"deny-add-aws-profile-connection":{"identifier":"deny-add-aws-profile-connection","description":"Denies the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_profile_connection"]}},"deny-add-marketplace":{"identifier":"deny-add-marketplace","description":"Denies the add_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace"]}},"deny-add-marketplace-gh-host-account":{"identifier":"deny-add-marketplace-gh-host-account","description":"Denies the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_gh_host_account"]}},"deny-add-marketplace-token-account":{"identifier":"deny-add-marketplace-token-account","description":"Denies the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_token_account"]}},"deny-add-project":{"identifier":"deny-add-project","description":"Denies the add_project command without any pre-configured scope.","commands":{"allow":[],"deny":["add_project"]}},"deny-add-scheduled-task":{"identifier":"deny-add-scheduled-task","description":"Denies the add_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["add_scheduled_task"]}},"deny-apply-marketplace-now":{"identifier":"deny-apply-marketplace-now","description":"Denies the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_marketplace_now"]}},"deny-apply-settings-import":{"identifier":"deny-apply-settings-import","description":"Denies the apply_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_settings_import"]}},"deny-aws-connection-identity":{"identifier":"deny-aws-connection-identity","description":"Denies the aws_connection_identity command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_identity"]}},"deny-aws-connection-sso-login":{"identifier":"deny-aws-connection-sso-login","description":"Denies the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_sso_login"]}},"deny-aws-policy-document":{"identifier":"deny-aws-policy-document","description":"Denies the aws_policy_document command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_policy_document"]}},"deny-aws-sso-refresh":{"identifier":"deny-aws-sso-refresh","description":"Denies the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_sso_refresh"]}},"deny-build-gateway-image":{"identifier":"deny-build-gateway-image","description":"Denies the build_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_gateway_image"]}},"deny-build-image":{"identifier":"deny-build-image","description":"Denies the build_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_image"]}},"deny-build-stt-image":{"identifier":"deny-build-stt-image","description":"Denies the build_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_stt_image"]}},"deny-cancel-aws-deployment":{"identifier":"deny-cancel-aws-deployment","description":"Denies the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_aws_deployment"]}},"deny-cancel-claude-token":{"identifier":"deny-cancel-claude-token","description":"Denies the cancel_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_claude_token"]}},"deny-cancel-marketplace-gh-login":{"identifier":"deny-cancel-marketplace-gh-login","description":"Denies the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_marketplace_gh_login"]}},"deny-check-aws-permissions":{"identifier":"deny-check-aws-permissions","description":"Denies the check_aws_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_aws_permissions"]}},"deny-check-browser-view-support":{"identifier":"deny-check-browser-view-support","description":"Denies the check_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["check_browser_view_support"]}},"deny-check-docker":{"identifier":"deny-check-docker","description":"Denies the check_docker command without any pre-configured scope.","commands":{"allow":[],"deny":["check_docker"]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-check-gateway-health":{"identifier":"deny-check-gateway-health","description":"Denies the check_gateway_health command without any pre-configured scope.","commands":{"allow":[],"deny":["check_gateway_health"]}},"deny-check-image-exists":{"identifier":"deny-check-image-exists","description":"Denies the check_image_exists command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_exists"]}},"deny-check-image-update":{"identifier":"deny-check-image-update","description":"Denies the check_image_update command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_update"]}},"deny-clear-claude-token":{"identifier":"deny-clear-claude-token","description":"Denies the clear_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_claude_token"]}},"deny-clear-gateway-api-key":{"identifier":"deny-clear-gateway-api-key","description":"Denies the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_gateway_api_key"]}},"deny-clear-scheduler-notifications":{"identifier":"deny-clear-scheduler-notifications","description":"Denies the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_scheduler_notifications"]}},"deny-close-browser-view-popout":{"identifier":"deny-close-browser-view-popout","description":"Denies the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["close_browser_view_popout"]}},"deny-close-container-page":{"identifier":"deny-close-container-page","description":"Denies the close_container_page command without any pre-configured scope.","commands":{"allow":[],"deny":["close_container_page"]}},"deny-close-terminal-session":{"identifier":"deny-close-terminal-session","description":"Denies the close_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["close_terminal_session"]}},"deny-confirm-migration":{"identifier":"deny-confirm-migration","description":"Denies the confirm_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm_migration"]}},"deny-create-container-directory":{"identifier":"deny-create-container-directory","description":"Denies the create_container_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["create_container_directory"]}},"deny-delete-note":{"identifier":"deny-delete-note","description":"Denies the delete_note command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_note"]}},"deny-deploy-aws-environment":{"identifier":"deny-deploy-aws-environment","description":"Denies the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_environment"]}},"deny-deploy-aws-permissions-boundary":{"identifier":"deny-deploy-aws-permissions-boundary","description":"Denies the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_permissions_boundary"]}},"deny-destroy-incomplete-aws-environment":{"identifier":"deny-destroy-incomplete-aws-environment","description":"Denies the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy_incomplete_aws_environment"]}},"deny-detect-aws-config":{"identifier":"deny-detect-aws-config","description":"Denies the detect_aws_config command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_config"]}},"deny-detect-aws-environments":{"identifier":"deny-detect-aws-environments","description":"Denies the detect_aws_environments command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_environments"]}},"deny-detect-host-timezone":{"identifier":"deny-detect-host-timezone","description":"Denies the detect_host_timezone command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_host_timezone"]}},"deny-detect-install-options":{"identifier":"deny-detect-install-options","description":"Denies the detect_install_options command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_install_options"]}},"deny-download-container-backup":{"identifier":"deny-download-container-backup","description":"Denies the download_container_backup command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_backup"]}},"deny-download-container-file":{"identifier":"deny-download-container-file","description":"Denies the download_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_file"]}},"deny-export-settings":{"identifier":"deny-export-settings","description":"Denies the export_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["export_settings"]}},"deny-follow-aws-deployment":{"identifier":"deny-follow-aws-deployment","description":"Denies the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["follow_aws_deployment"]}},"deny-forget-marketplace-installs":{"identifier":"deny-forget-marketplace-installs","description":"Denies the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":[],"deny":["forget_marketplace_installs"]}},"deny-get-app-version":{"identifier":"deny-get-app-version","description":"Denies the get_app_version command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_version"]}},"deny-get-auth-bridge-status":{"identifier":"deny-get-auth-bridge-status","description":"Denies the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_auth_bridge_status"]}},"deny-get-browser-view-match-window":{"identifier":"deny-get-browser-view-match-window","description":"Denies the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_match_window"]}},"deny-get-browser-view-popout-state":{"identifier":"deny-get-browser-view-popout-state","description":"Denies the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_popout_state"]}},"deny-get-browser-view-status":{"identifier":"deny-get-browser-view-status","description":"Denies the get_browser_view_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_status"]}},"deny-get-container-info":{"identifier":"deny-get-container-info","description":"Denies the get_container_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_info"]}},"deny-get-container-page-state":{"identifier":"deny-get-container-page-state","description":"Denies the get_container_page_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_page_state"]}},"deny-get-container-staleness":{"identifier":"deny-get-container-staleness","description":"Denies the get_container_staleness command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_staleness"]}},"deny-get-gateway-auth-token":{"identifier":"deny-get-gateway-auth-token","description":"Denies the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_auth_token"]}},"deny-get-gateway-status":{"identifier":"deny-get-gateway-status","description":"Denies the get_gateway_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_status"]}},"deny-get-help-content":{"identifier":"deny-get-help-content","description":"Denies the get_help_content command without any pre-configured scope.","commands":{"allow":[],"deny":["get_help_content"]}},"deny-get-marketplace-sync-report":{"identifier":"deny-get-marketplace-sync-report","description":"Denies the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":[],"deny":["get_marketplace_sync_report"]}},"deny-get-migration-state":{"identifier":"deny-get-migration-state","description":"Denies the get_migration_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_migration_state"]}},"deny-get-scheduled-task-log":{"identifier":"deny-get-scheduled-task-log","description":"Denies the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduled_task_log"]}},"deny-get-scheduler-notifications":{"identifier":"deny-get-scheduler-notifications","description":"Denies the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduler_notifications"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-get-stt-status":{"identifier":"deny-get-stt-status","description":"Denies the get_stt_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_stt_status"]}},"deny-get-web-terminal-status":{"identifier":"deny-get-web-terminal-status","description":"Denies the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_web_terminal_status"]}},"deny-has-claude-token":{"identifier":"deny-has-claude-token","description":"Denies the has_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["has_claude_token"]}},"deny-inspect-ca-cert-path":{"identifier":"deny-inspect-ca-cert-path","description":"Denies the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":[],"deny":["inspect_ca_cert_path"]}},"deny-install-browser-view-browser":{"identifier":"deny-install-browser-view-browser","description":"Denies the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_browser"]}},"deny-install-browser-view-support":{"identifier":"deny-install-browser-view-support","description":"Denies the install_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_support"]}},"deny-install-marketplace-item":{"identifier":"deny-install-marketplace-item","description":"Denies the install_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["install_marketplace_item"]}},"deny-list-aws-profiles":{"identifier":"deny-list-aws-profiles","description":"Denies the list_aws_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_aws_profiles"]}},"deny-list-claude-sessions":{"identifier":"deny-list-claude-sessions","description":"Denies the list_claude_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["list_claude_sessions"]}},"deny-list-container-capabilities":{"identifier":"deny-list-container-capabilities","description":"Denies the list_container_capabilities command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_capabilities"]}},"deny-list-container-files":{"identifier":"deny-list-container-files","description":"Denies the list_container_files command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_files"]}},"deny-list-marketplace-snapshots":{"identifier":"deny-list-marketplace-snapshots","description":"Denies the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_snapshots"]}},"deny-list-marketplace-updates":{"identifier":"deny-list-marketplace-updates","description":"Denies the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_updates"]}},"deny-list-notes":{"identifier":"deny-list-notes","description":"Denies the list_notes command without any pre-configured scope.","commands":{"allow":[],"deny":["list_notes"]}},"deny-list-projects":{"identifier":"deny-list-projects","description":"Denies the list_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["list_projects"]}},"deny-list-scheduled-tasks":{"identifier":"deny-list-scheduled-tasks","description":"Denies the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":[],"deny":["list_scheduled_tasks"]}},"deny-marketplace-gh-host-available":{"identifier":"deny-marketplace-gh-host-available","description":"Denies the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_gh_host_available"]}},"deny-marketplace-item-diff":{"identifier":"deny-marketplace-item-diff","description":"Denies the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_item_diff"]}},"deny-migrate-project-to-base":{"identifier":"deny-migrate-project-to-base","description":"Denies the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":[],"deny":["migrate_project_to_base"]}},"deny-onboard-aws-user":{"identifier":"deny-onboard-aws-user","description":"Denies the onboard_aws_user command without any pre-configured scope.","commands":{"allow":[],"deny":["onboard_aws_user"]}},"deny-open-browser-view-popout":{"identifier":"deny-open-browser-view-popout","description":"Denies the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["open_browser_view_popout"]}},"deny-open-file-viewer":{"identifier":"deny-open-file-viewer","description":"Denies the open_file_viewer command without any pre-configured scope.","commands":{"allow":[],"deny":["open_file_viewer"]}},"deny-open-page-in-container-browser":{"identifier":"deny-open-page-in-container-browser","description":"Denies the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["open_page_in_container_browser"]}},"deny-open-terminal-session":{"identifier":"deny-open-terminal-session","description":"Denies the open_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["open_terminal_session"]}},"deny-open-url-external":{"identifier":"deny-open-url-external","description":"Denies the open_url_external command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url_external"]}},"deny-paste-image-to-terminal":{"identifier":"deny-paste-image-to-terminal","description":"Denies the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["paste_image_to_terminal"]}},"deny-preview-settings-import":{"identifier":"deny-preview-settings-import","description":"Denies the preview_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["preview_settings_import"]}},"deny-pull-gateway-image":{"identifier":"deny-pull-gateway-image","description":"Denies the pull_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_gateway_image"]}},"deny-pull-image":{"identifier":"deny-pull-image","description":"Denies the pull_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_image"]}},"deny-pull-stt-image":{"identifier":"deny-pull-stt-image","description":"Denies the pull_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_stt_image"]}},"deny-read-container-file":{"identifier":"deny-read-container-file","description":"Denies the read_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_container_file"]}},"deny-rebuild-project-container":{"identifier":"deny-rebuild-project-container","description":"Denies the rebuild_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["rebuild_project_container"]}},"deny-reconcile-project-statuses":{"identifier":"deny-reconcile-project-statuses","description":"Denies the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":[],"deny":["reconcile_project_statuses"]}},"deny-refresh-marketplaces":{"identifier":"deny-refresh-marketplaces","description":"Denies the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":[],"deny":["refresh_marketplaces"]}},"deny-regenerate-gateway-auth-token":{"identifier":"deny-regenerate-gateway-auth-token","description":"Denies the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_gateway_auth_token"]}},"deny-regenerate-web-terminal-token":{"identifier":"deny-regenerate-web-terminal-token","description":"Denies the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_web_terminal_token"]}},"deny-remove-host-connection":{"identifier":"deny-remove-host-connection","description":"Denies the remove_host_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_host_connection"]}},"deny-remove-marketplace":{"identifier":"deny-remove-marketplace","description":"Denies the remove_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace"]}},"deny-remove-marketplace-account":{"identifier":"deny-remove-marketplace-account","description":"Denies the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace_account"]}},"deny-remove-project":{"identifier":"deny-remove-project","description":"Denies the remove_project command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_project"]}},"deny-remove-scheduled-task":{"identifier":"deny-remove-scheduled-task","description":"Denies the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_scheduled_task"]}},"deny-rename-container-path":{"identifier":"deny-rename-container-path","description":"Denies the rename_container_path command without any pre-configured scope.","commands":{"allow":[],"deny":["rename_container_path"]}},"deny-repair-aws-environment":{"identifier":"deny-repair-aws-environment","description":"Denies the repair_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["repair_aws_environment"]}},"deny-rollback-migration":{"identifier":"deny-rollback-migration","description":"Denies the rollback_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["rollback_migration"]}},"deny-run-docker-install":{"identifier":"deny-run-docker-install","description":"Denies the run_docker_install command without any pre-configured scope.","commands":{"allow":[],"deny":["run_docker_install"]}},"deny-run-scheduled-task-now":{"identifier":"deny-run-scheduled-task-now","description":"Denies the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":[],"deny":["run_scheduled_task_now"]}},"deny-save-aws-admin-bundle":{"identifier":"deny-save-aws-admin-bundle","description":"Denies the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":[],"deny":["save_aws_admin_bundle"]}},"deny-save-note":{"identifier":"deny-save-note","description":"Denies the save_note command without any pre-configured scope.","commands":{"allow":[],"deny":["save_note"]}},"deny-send-audio-data":{"identifier":"deny-send-audio-data","description":"Denies the send_audio_data command without any pre-configured scope.","commands":{"allow":[],"deny":["send_audio_data"]}},"deny-set-auth-bridge-enabled":{"identifier":"deny-set-auth-bridge-enabled","description":"Denies the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_auth_bridge_enabled"]}},"deny-set-browser-view-enabled":{"identifier":"deny-set-browser-view-enabled","description":"Denies the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_enabled"]}},"deny-set-browser-view-match-window":{"identifier":"deny-set-browser-view-match-window","description":"Denies the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_match_window"]}},"deny-set-browser-view-popout-always-on-top":{"identifier":"deny-set-browser-view-popout-always-on-top","description":"Denies the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_popout_always_on_top"]}},"deny-set-container-page-viewport":{"identifier":"deny-set-container-page-viewport","description":"Denies the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":[],"deny":["set_container_page_viewport"]}},"deny-set-gateway-api-key":{"identifier":"deny-set-gateway-api-key","description":"Denies the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["set_gateway_api_key"]}},"deny-set-global-item-disabled":{"identifier":"deny-set-global-item-disabled","description":"Denies the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_global_item_disabled"]}},"deny-set-scheduled-task-enabled":{"identifier":"deny-set-scheduled-task-enabled","description":"Denies the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_scheduled_task_enabled"]}},"deny-start-audio-bridge":{"identifier":"deny-start-audio-bridge","description":"Denies the start_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["start_audio_bridge"]}},"deny-start-gateway":{"identifier":"deny-start-gateway","description":"Denies the start_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["start_gateway"]}},"deny-start-marketplace-gh-container-login":{"identifier":"deny-start-marketplace-gh-container-login","description":"Denies the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":[],"deny":["start_marketplace_gh_container_login"]}},"deny-start-project-container":{"identifier":"deny-start-project-container","description":"Denies the start_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["start_project_container"]}},"deny-start-stt":{"identifier":"deny-start-stt","description":"Denies the start_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["start_stt"]}},"deny-start-web-terminal":{"identifier":"deny-start-web-terminal","description":"Denies the start_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["start_web_terminal"]}},"deny-stop-audio-bridge":{"identifier":"deny-stop-audio-bridge","description":"Denies the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_audio_bridge"]}},"deny-stop-gateway":{"identifier":"deny-stop-gateway","description":"Denies the stop_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_gateway"]}},"deny-stop-project-container":{"identifier":"deny-stop-project-container","description":"Denies the stop_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_project_container"]}},"deny-stop-stt":{"identifier":"deny-stop-stt","description":"Denies the stop_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_stt"]}},"deny-stop-web-terminal":{"identifier":"deny-stop-web-terminal","description":"Denies the stop_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_web_terminal"]}},"deny-submit-claude-token-code":{"identifier":"deny-submit-claude-token-code","description":"Denies the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":[],"deny":["submit_claude_token_code"]}},"deny-sweep-claude-token-snapshots":{"identifier":"deny-sweep-claude-token-snapshots","description":"Denies the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["sweep_claude_token_snapshots"]}},"deny-terminal-input":{"identifier":"deny-terminal-input","description":"Denies the terminal_input command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_input"]}},"deny-terminal-resize":{"identifier":"deny-terminal-resize","description":"Denies the terminal_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_resize"]}},"deny-test-marketplace-account":{"identifier":"deny-test-marketplace-account","description":"Denies the test_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["test_marketplace_account"]}},"deny-transcribe-audio":{"identifier":"deny-transcribe-audio","description":"Denies the transcribe_audio command without any pre-configured scope.","commands":{"allow":[],"deny":["transcribe_audio"]}},"deny-uninstall-marketplace-item":{"identifier":"deny-uninstall-marketplace-item","description":"Denies the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["uninstall_marketplace_item"]}},"deny-unlock-aws-state":{"identifier":"deny-unlock-aws-state","description":"Denies the unlock_aws_state command without any pre-configured scope.","commands":{"allow":[],"deny":["unlock_aws_state"]}},"deny-update-aws-connection":{"identifier":"deny-update-aws-connection","description":"Denies the update_aws_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_connection"]}},"deny-update-aws-environment":{"identifier":"deny-update-aws-environment","description":"Denies the update_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_environment"]}},"deny-update-marketplace":{"identifier":"deny-update-marketplace","description":"Denies the update_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace"]}},"deny-update-marketplace-item":{"identifier":"deny-update-marketplace-item","description":"Denies the update_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace_item"]}},"deny-update-project":{"identifier":"deny-update-project","description":"Denies the update_project command without any pre-configured scope.","commands":{"allow":[],"deny":["update_project"]}},"deny-update-scheduled-task":{"identifier":"deny-update-scheduled-task","description":"Denies the update_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["update_scheduled_task"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-upload-files-to-container":{"identifier":"deny-upload-files-to-container","description":"Denies the upload_files_to_container command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_files_to_container"]}},"deny-upload-host-file-to-terminal":{"identifier":"deny-upload-host-file-to-terminal","description":"Denies the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_host_file_to_terminal"]}},"deny-viewer-choose-file":{"identifier":"deny-viewer-choose-file","description":"Denies the viewer_choose_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_choose_file"]}},"deny-viewer-get-state":{"identifier":"deny-viewer-get-state","description":"Denies the viewer_get_state command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_get_state"]}},"deny-viewer-poll-file":{"identifier":"deny-viewer-poll-file","description":"Denies the viewer_poll_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_poll_file"]}},"deny-viewer-read-file":{"identifier":"deny-viewer-read-file","description":"Denies the viewer_read_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_read_file"]}},"deny-viewer-write-file":{"identifier":"deny-viewer-write-file","description":"Denies the viewer_write_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_write_file"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/capabilities.json b/app/src-tauri/gen/schemas/capabilities.json index 012eebb..721e428 100644 --- a/app/src-tauri/gen/schemas/capabilities.json +++ b/app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate field and Backup. Two commands still take a host path over IPC as a string — the terminal drop and `download_container_backup` — and for those `validate_host_path` is the boundary rather than defence in depth. This file is the reviewed threat model of record, so keep this census accurate: a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). A second capability file, `file-viewer.json`, covers the `file-viewer-*` windows the terminal file viewer opens on `viewer.html`; it is the only other local-origin window, its grants are listed and justified there, and its one non-obvious grant (`core:window:allow-destroy`) exists because `onCloseRequested` cannot close a window without it. App commands are gated by this file too. `build.rs` declares a Tauri `AppManifest` listing every command in `generate_handler!`, which is what makes tauri 2.11.0 apply the ACL to app commands at all (`webview/mod.rs:1794` skips it when no app manifest exists), and the bare `allow-` entries below are the complete list of app commands the main window may call. `build.rs` refuses to build unless every registered command has exactly one such grant, in the file whose `windows` its name says it belongs to (`viewer_*` in `file-viewer.json`, everything else here), and unless every bare entry names a registered command — so a forgotten, misspelled, duplicated or misfiled grant is a failed `cargo check`, not a feature that dies at runtime with `not allowed by ACL`. `deny-*` is banned by the same check: in tauri 2.11.0 a deny matches regardless of window or origin, so a deny meant for the viewer would deny main too. Hand-written files under `permissions/` are refused for the same reason — they would be grants this census cannot see. Because the census can only vouch for what it reads, `build.rs` also refuses any capability it did not check: anything in `capabilities/` other than a top-level `*.json` file (tauri also loads `.toml`/`.json5` there, and subdirectories), a `webviews` or `remote` key in a capability file (either would extend grants beyond what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`, any `tauri..conf.json`, or `TAURI_CONFIG`, and a tauri config in a format the census cannot parse (JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, editor swap files) is recognised and skipped in `capabilities/` and `permissions/` rather than refused, since tauri never loads it either. Each failure names the check that failed (\"stray entry in capabilities/\", \"capabilities declared outside capabilities/\", \"hand-written permission\", …) rather than always reading as a grant/handler mismatch. Known gap: adding a new `tauri..conf.json` to a tree that has already been built once only takes effect on a clean build or in CI — cargo's incremental build has no reason to notice a file that did not exist on the previous build. The `*marketplace*` commands fetch user-configured https git repos on the host and push pinned files into containers; account tokens stay in the OS keychain and never cross IPC outward — the only inbound one is the token pasted into `add_marketplace_token_account`. The pop-out stays capability-less. The Rust label gates in `commands/file_viewer_commands.rs` remain, because the ACL says *which* window may call a command and the label says *whose* registry entry it acts on; they are not redundant. The rules live in `src/command_census.rs`, which is unit-tested, and `src/test/capabilities.test.ts` checks the other direction: that the code that runs in each window imports only the wrappers that window is granted. On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save","allow-check-docker","allow-check-image-exists","allow-build-image","allow-get-container-info","allow-list-projects","allow-add-project","allow-remove-project","allow-update-project","allow-start-project-container","allow-stop-project-container","allow-rebuild-project-container","allow-reconcile-project-statuses","allow-list-notes","allow-save-note","allow-delete-note","allow-get-container-staleness","allow-migrate-project-to-base","allow-confirm-migration","allow-rollback-migration","allow-get-migration-state","allow-set-auth-bridge-enabled","allow-get-auth-bridge-status","allow-set-browser-view-enabled","allow-get-browser-view-status","allow-check-browser-view-support","allow-install-browser-view-support","allow-install-browser-view-browser","allow-open-browser-view-popout","allow-close-browser-view-popout","allow-get-browser-view-popout-state","allow-set-browser-view-popout-always-on-top","allow-open-page-in-container-browser","allow-set-container-page-viewport","allow-get-container-page-state","allow-close-container-page","allow-set-browser-view-match-window","allow-get-browser-view-match-window","allow-acquire-claude-token","allow-submit-claude-token-code","allow-cancel-claude-token","allow-has-claude-token","allow-clear-claude-token","allow-sweep-claude-token-snapshots","allow-get-settings","allow-update-settings","allow-pull-image","allow-detect-aws-config","allow-inspect-ca-cert-path","allow-list-aws-profiles","allow-add-aws-profile-connection","allow-add-aws-keys-connection","allow-update-aws-connection","allow-remove-host-connection","allow-aws-connection-identity","allow-aws-connection-sso-login","allow-check-aws-permissions","allow-aws-policy-document","allow-detect-host-timezone","allow-export-settings","allow-preview-settings-import","allow-apply-settings-import","allow-open-terminal-session","allow-terminal-input","allow-terminal-resize","allow-close-terminal-session","allow-paste-image-to-terminal","allow-upload-host-file-to-terminal","allow-start-audio-bridge","allow-send-audio-data","allow-stop-audio-bridge","allow-list-container-files","allow-download-container-backup","allow-download-container-file","allow-upload-files-to-container","allow-read-container-file","allow-rename-container-path","allow-create-container-directory","allow-open-file-viewer","allow-aws-sso-refresh","allow-get-app-version","allow-check-for-updates","allow-check-image-update","allow-get-help-content","allow-open-url-external","allow-detect-install-options","allow-run-docker-install","allow-start-web-terminal","allow-stop-web-terminal","allow-get-web-terminal-status","allow-regenerate-web-terminal-token","allow-get-stt-status","allow-start-stt","allow-stop-stt","allow-build-stt-image","allow-pull-stt-image","allow-transcribe-audio","allow-get-gateway-status","allow-start-gateway","allow-stop-gateway","allow-check-gateway-health","allow-build-gateway-image","allow-pull-gateway-image","allow-set-gateway-api-key","allow-clear-gateway-api-key","allow-get-gateway-auth-token","allow-regenerate-gateway-auth-token","allow-list-claude-sessions","allow-list-container-capabilities","allow-list-scheduled-tasks","allow-add-scheduled-task","allow-update-scheduled-task","allow-get-scheduled-task-log","allow-set-scheduled-task-enabled","allow-run-scheduled-task-now","allow-remove-scheduled-task","allow-get-scheduler-notifications","allow-clear-scheduler-notifications","allow-list-marketplace-snapshots","allow-refresh-marketplaces","allow-add-marketplace","allow-update-marketplace","allow-remove-marketplace","allow-install-marketplace-item","allow-uninstall-marketplace-item","allow-set-global-item-disabled","allow-forget-marketplace-installs","allow-list-marketplace-updates","allow-marketplace-item-diff","allow-update-marketplace-item","allow-apply-marketplace-now","allow-get-marketplace-sync-report","allow-add-marketplace-token-account","allow-add-marketplace-gh-host-account","allow-start-marketplace-gh-container-login","allow-cancel-marketplace-gh-login","allow-test-marketplace-account","allow-remove-marketplace-account","allow-marketplace-gh-host-available"]},"file-viewer":{"identifier":"file-viewer","description":"The terminal file viewer windows (`file-viewer-`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.","local":true,"windows":["file-viewer-*"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:window:allow-destroy","core:webview:allow-internal-toggle-devtools","allow-viewer-get-state","allow-viewer-choose-file","allow-viewer-read-file","allow-viewer-poll-file","allow-viewer-write-file"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate field and Backup. Two commands still take a host path over IPC as a string — the terminal drop and `download_container_backup` — and for those `validate_host_path` is the boundary rather than defence in depth. This file is the reviewed threat model of record, so keep this census accurate: a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). A second capability file, `file-viewer.json`, covers the `file-viewer-*` windows the terminal file viewer opens on `viewer.html`; it is the only other local-origin window, its grants are listed and justified there, and its one non-obvious grant (`core:window:allow-destroy`) exists because `onCloseRequested` cannot close a window without it. App commands are gated by this file too. `build.rs` declares a Tauri `AppManifest` listing every command in `generate_handler!`, which is what makes tauri 2.11.0 apply the ACL to app commands at all (`webview/mod.rs:1794` skips it when no app manifest exists), and the bare `allow-` entries below are the complete list of app commands the main window may call. `build.rs` refuses to build unless every registered command has exactly one such grant, in the file whose `windows` its name says it belongs to (`viewer_*` in `file-viewer.json`, everything else here), and unless every bare entry names a registered command — so a forgotten, misspelled, duplicated or misfiled grant is a failed `cargo check`, not a feature that dies at runtime with `not allowed by ACL`. `deny-*` is banned by the same check: in tauri 2.11.0 a deny matches regardless of window or origin, so a deny meant for the viewer would deny main too. Hand-written files under `permissions/` are refused for the same reason — they would be grants this census cannot see. Because the census can only vouch for what it reads, `build.rs` also refuses any capability it did not check: anything in `capabilities/` other than a top-level `*.json` file (tauri also loads `.toml`/`.json5` there, and subdirectories), a `webviews` or `remote` key in a capability file (either would extend grants beyond what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`, any `tauri..conf.json`, or `TAURI_CONFIG`, and a tauri config in a format the census cannot parse (JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, editor swap files) is recognised and skipped in `capabilities/` and `permissions/` rather than refused, since tauri never loads it either. Each failure names the check that failed (\"stray entry in capabilities/\", \"capabilities declared outside capabilities/\", \"hand-written permission\", …) rather than always reading as a grant/handler mismatch. Known gap: adding a new `tauri..conf.json` to a tree that has already been built once only takes effect on a clean build or in CI — cargo's incremental build has no reason to notice a file that did not exist on the previous build. The `*marketplace*` commands fetch user-configured https git repos on the host and push pinned files into containers; account tokens stay in the OS keychain and never cross IPC outward — the only inbound one is the token pasted into `add_marketplace_token_account`. The pop-out stays capability-less. The Rust label gates in `commands/file_viewer_commands.rs` remain, because the ACL says *which* window may call a command and the label says *whose* registry entry it acts on; they are not redundant. The rules live in `src/command_census.rs`, which is unit-tested, and `src/test/capabilities.test.ts` checks the other direction: that the code that runs in each window imports only the wrappers that window is granted. On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save","allow-check-docker","allow-check-image-exists","allow-build-image","allow-get-container-info","allow-list-projects","allow-add-project","allow-remove-project","allow-update-project","allow-start-project-container","allow-stop-project-container","allow-rebuild-project-container","allow-reconcile-project-statuses","allow-list-notes","allow-save-note","allow-delete-note","allow-get-container-staleness","allow-migrate-project-to-base","allow-confirm-migration","allow-rollback-migration","allow-get-migration-state","allow-set-auth-bridge-enabled","allow-get-auth-bridge-status","allow-set-browser-view-enabled","allow-get-browser-view-status","allow-check-browser-view-support","allow-install-browser-view-support","allow-install-browser-view-browser","allow-open-browser-view-popout","allow-close-browser-view-popout","allow-get-browser-view-popout-state","allow-set-browser-view-popout-always-on-top","allow-open-page-in-container-browser","allow-set-container-page-viewport","allow-get-container-page-state","allow-close-container-page","allow-set-browser-view-match-window","allow-get-browser-view-match-window","allow-acquire-claude-token","allow-submit-claude-token-code","allow-cancel-claude-token","allow-has-claude-token","allow-clear-claude-token","allow-sweep-claude-token-snapshots","allow-get-settings","allow-update-settings","allow-pull-image","allow-detect-aws-config","allow-inspect-ca-cert-path","allow-list-aws-profiles","allow-add-aws-profile-connection","allow-add-aws-keys-connection","allow-update-aws-connection","allow-remove-host-connection","allow-aws-connection-identity","allow-aws-connection-sso-login","allow-check-aws-permissions","allow-aws-policy-document","allow-detect-aws-environments","allow-deploy-aws-environment","allow-update-aws-environment","allow-repair-aws-environment","allow-destroy-incomplete-aws-environment","allow-unlock-aws-state","allow-follow-aws-deployment","allow-cancel-aws-deployment","allow-onboard-aws-user","allow-deploy-aws-permissions-boundary","allow-save-aws-admin-bundle","allow-detect-host-timezone","allow-export-settings","allow-preview-settings-import","allow-apply-settings-import","allow-open-terminal-session","allow-terminal-input","allow-terminal-resize","allow-close-terminal-session","allow-paste-image-to-terminal","allow-upload-host-file-to-terminal","allow-start-audio-bridge","allow-send-audio-data","allow-stop-audio-bridge","allow-list-container-files","allow-download-container-backup","allow-download-container-file","allow-upload-files-to-container","allow-read-container-file","allow-rename-container-path","allow-create-container-directory","allow-open-file-viewer","allow-aws-sso-refresh","allow-get-app-version","allow-check-for-updates","allow-check-image-update","allow-get-help-content","allow-open-url-external","allow-detect-install-options","allow-run-docker-install","allow-start-web-terminal","allow-stop-web-terminal","allow-get-web-terminal-status","allow-regenerate-web-terminal-token","allow-get-stt-status","allow-start-stt","allow-stop-stt","allow-build-stt-image","allow-pull-stt-image","allow-transcribe-audio","allow-get-gateway-status","allow-start-gateway","allow-stop-gateway","allow-check-gateway-health","allow-build-gateway-image","allow-pull-gateway-image","allow-set-gateway-api-key","allow-clear-gateway-api-key","allow-get-gateway-auth-token","allow-regenerate-gateway-auth-token","allow-list-claude-sessions","allow-list-container-capabilities","allow-list-scheduled-tasks","allow-add-scheduled-task","allow-update-scheduled-task","allow-get-scheduled-task-log","allow-set-scheduled-task-enabled","allow-run-scheduled-task-now","allow-remove-scheduled-task","allow-get-scheduler-notifications","allow-clear-scheduler-notifications","allow-list-marketplace-snapshots","allow-refresh-marketplaces","allow-add-marketplace","allow-update-marketplace","allow-remove-marketplace","allow-install-marketplace-item","allow-uninstall-marketplace-item","allow-set-global-item-disabled","allow-forget-marketplace-installs","allow-list-marketplace-updates","allow-marketplace-item-diff","allow-update-marketplace-item","allow-apply-marketplace-now","allow-get-marketplace-sync-report","allow-add-marketplace-token-account","allow-add-marketplace-gh-host-account","allow-start-marketplace-gh-container-login","allow-cancel-marketplace-gh-login","allow-test-marketplace-account","allow-remove-marketplace-account","allow-marketplace-gh-host-available"]},"file-viewer":{"identifier":"file-viewer","description":"The terminal file viewer windows (`file-viewer-`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.","local":true,"windows":["file-viewer-*"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:window:allow-destroy","core:webview:allow-internal-toggle-devtools","allow-viewer-get-state","allow-viewer-choose-file","allow-viewer-read-file","allow-viewer-poll-file","allow-viewer-write-file"]}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/desktop-schema.json b/app/src-tauri/gen/schemas/desktop-schema.json index e55b0f2..048f82a 100644 --- a/app/src-tauri/gen/schemas/desktop-schema.json +++ b/app/src-tauri/gen/schemas/desktop-schema.json @@ -446,6 +446,12 @@ "const": "allow-build-stt-image", "markdownDescription": "Enables the build_stt_image command without any pre-configured scope." }, + { + "description": "Enables the cancel_aws_deployment command without any pre-configured scope.", + "type": "string", + "const": "allow-cancel-aws-deployment", + "markdownDescription": "Enables the cancel_aws_deployment command without any pre-configured scope." + }, { "description": "Enables the cancel_claude_token command without any pre-configured scope.", "type": "string", @@ -554,12 +560,36 @@ "const": "allow-delete-note", "markdownDescription": "Enables the delete_note command without any pre-configured scope." }, + { + "description": "Enables the deploy_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "allow-deploy-aws-environment", + "markdownDescription": "Enables the deploy_aws_environment command without any pre-configured scope." + }, + { + "description": "Enables the deploy_aws_permissions_boundary command without any pre-configured scope.", + "type": "string", + "const": "allow-deploy-aws-permissions-boundary", + "markdownDescription": "Enables the deploy_aws_permissions_boundary command without any pre-configured scope." + }, + { + "description": "Enables the destroy_incomplete_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "allow-destroy-incomplete-aws-environment", + "markdownDescription": "Enables the destroy_incomplete_aws_environment command without any pre-configured scope." + }, { "description": "Enables the detect_aws_config command without any pre-configured scope.", "type": "string", "const": "allow-detect-aws-config", "markdownDescription": "Enables the detect_aws_config command without any pre-configured scope." }, + { + "description": "Enables the detect_aws_environments command without any pre-configured scope.", + "type": "string", + "const": "allow-detect-aws-environments", + "markdownDescription": "Enables the detect_aws_environments command without any pre-configured scope." + }, { "description": "Enables the detect_host_timezone command without any pre-configured scope.", "type": "string", @@ -590,6 +620,12 @@ "const": "allow-export-settings", "markdownDescription": "Enables the export_settings command without any pre-configured scope." }, + { + "description": "Enables the follow_aws_deployment command without any pre-configured scope.", + "type": "string", + "const": "allow-follow-aws-deployment", + "markdownDescription": "Enables the follow_aws_deployment command without any pre-configured scope." + }, { "description": "Enables the forget_marketplace_installs command without any pre-configured scope.", "type": "string", @@ -806,6 +842,12 @@ "const": "allow-migrate-project-to-base", "markdownDescription": "Enables the migrate_project_to_base command without any pre-configured scope." }, + { + "description": "Enables the onboard_aws_user command without any pre-configured scope.", + "type": "string", + "const": "allow-onboard-aws-user", + "markdownDescription": "Enables the onboard_aws_user command without any pre-configured scope." + }, { "description": "Enables the open_browser_view_popout command without any pre-configured scope.", "type": "string", @@ -938,6 +980,12 @@ "const": "allow-rename-container-path", "markdownDescription": "Enables the rename_container_path command without any pre-configured scope." }, + { + "description": "Enables the repair_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "allow-repair-aws-environment", + "markdownDescription": "Enables the repair_aws_environment command without any pre-configured scope." + }, { "description": "Enables the rollback_migration command without any pre-configured scope.", "type": "string", @@ -956,6 +1004,12 @@ "const": "allow-run-scheduled-task-now", "markdownDescription": "Enables the run_scheduled_task_now command without any pre-configured scope." }, + { + "description": "Enables the save_aws_admin_bundle command without any pre-configured scope.", + "type": "string", + "const": "allow-save-aws-admin-bundle", + "markdownDescription": "Enables the save_aws_admin_bundle command without any pre-configured scope." + }, { "description": "Enables the save_note command without any pre-configured scope.", "type": "string", @@ -1124,12 +1178,24 @@ "const": "allow-uninstall-marketplace-item", "markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope." }, + { + "description": "Enables the unlock_aws_state command without any pre-configured scope.", + "type": "string", + "const": "allow-unlock-aws-state", + "markdownDescription": "Enables the unlock_aws_state command without any pre-configured scope." + }, { "description": "Enables the update_aws_connection command without any pre-configured scope.", "type": "string", "const": "allow-update-aws-connection", "markdownDescription": "Enables the update_aws_connection command without any pre-configured scope." }, + { + "description": "Enables the update_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "allow-update-aws-environment", + "markdownDescription": "Enables the update_aws_environment command without any pre-configured scope." + }, { "description": "Enables the update_marketplace command without any pre-configured scope.", "type": "string", @@ -1304,6 +1370,12 @@ "const": "deny-build-stt-image", "markdownDescription": "Denies the build_stt_image command without any pre-configured scope." }, + { + "description": "Denies the cancel_aws_deployment command without any pre-configured scope.", + "type": "string", + "const": "deny-cancel-aws-deployment", + "markdownDescription": "Denies the cancel_aws_deployment command without any pre-configured scope." + }, { "description": "Denies the cancel_claude_token command without any pre-configured scope.", "type": "string", @@ -1412,12 +1484,36 @@ "const": "deny-delete-note", "markdownDescription": "Denies the delete_note command without any pre-configured scope." }, + { + "description": "Denies the deploy_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "deny-deploy-aws-environment", + "markdownDescription": "Denies the deploy_aws_environment command without any pre-configured scope." + }, + { + "description": "Denies the deploy_aws_permissions_boundary command without any pre-configured scope.", + "type": "string", + "const": "deny-deploy-aws-permissions-boundary", + "markdownDescription": "Denies the deploy_aws_permissions_boundary command without any pre-configured scope." + }, + { + "description": "Denies the destroy_incomplete_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "deny-destroy-incomplete-aws-environment", + "markdownDescription": "Denies the destroy_incomplete_aws_environment command without any pre-configured scope." + }, { "description": "Denies the detect_aws_config command without any pre-configured scope.", "type": "string", "const": "deny-detect-aws-config", "markdownDescription": "Denies the detect_aws_config command without any pre-configured scope." }, + { + "description": "Denies the detect_aws_environments command without any pre-configured scope.", + "type": "string", + "const": "deny-detect-aws-environments", + "markdownDescription": "Denies the detect_aws_environments command without any pre-configured scope." + }, { "description": "Denies the detect_host_timezone command without any pre-configured scope.", "type": "string", @@ -1448,6 +1544,12 @@ "const": "deny-export-settings", "markdownDescription": "Denies the export_settings command without any pre-configured scope." }, + { + "description": "Denies the follow_aws_deployment command without any pre-configured scope.", + "type": "string", + "const": "deny-follow-aws-deployment", + "markdownDescription": "Denies the follow_aws_deployment command without any pre-configured scope." + }, { "description": "Denies the forget_marketplace_installs command without any pre-configured scope.", "type": "string", @@ -1664,6 +1766,12 @@ "const": "deny-migrate-project-to-base", "markdownDescription": "Denies the migrate_project_to_base command without any pre-configured scope." }, + { + "description": "Denies the onboard_aws_user command without any pre-configured scope.", + "type": "string", + "const": "deny-onboard-aws-user", + "markdownDescription": "Denies the onboard_aws_user command without any pre-configured scope." + }, { "description": "Denies the open_browser_view_popout command without any pre-configured scope.", "type": "string", @@ -1796,6 +1904,12 @@ "const": "deny-rename-container-path", "markdownDescription": "Denies the rename_container_path command without any pre-configured scope." }, + { + "description": "Denies the repair_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "deny-repair-aws-environment", + "markdownDescription": "Denies the repair_aws_environment command without any pre-configured scope." + }, { "description": "Denies the rollback_migration command without any pre-configured scope.", "type": "string", @@ -1814,6 +1928,12 @@ "const": "deny-run-scheduled-task-now", "markdownDescription": "Denies the run_scheduled_task_now command without any pre-configured scope." }, + { + "description": "Denies the save_aws_admin_bundle command without any pre-configured scope.", + "type": "string", + "const": "deny-save-aws-admin-bundle", + "markdownDescription": "Denies the save_aws_admin_bundle command without any pre-configured scope." + }, { "description": "Denies the save_note command without any pre-configured scope.", "type": "string", @@ -1982,12 +2102,24 @@ "const": "deny-uninstall-marketplace-item", "markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope." }, + { + "description": "Denies the unlock_aws_state command without any pre-configured scope.", + "type": "string", + "const": "deny-unlock-aws-state", + "markdownDescription": "Denies the unlock_aws_state command without any pre-configured scope." + }, { "description": "Denies the update_aws_connection command without any pre-configured scope.", "type": "string", "const": "deny-update-aws-connection", "markdownDescription": "Denies the update_aws_connection command without any pre-configured scope." }, + { + "description": "Denies the update_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "deny-update-aws-environment", + "markdownDescription": "Denies the update_aws_environment command without any pre-configured scope." + }, { "description": "Denies the update_marketplace command without any pre-configured scope.", "type": "string", diff --git a/app/src-tauri/gen/schemas/linux-schema.json b/app/src-tauri/gen/schemas/linux-schema.json index e55b0f2..048f82a 100644 --- a/app/src-tauri/gen/schemas/linux-schema.json +++ b/app/src-tauri/gen/schemas/linux-schema.json @@ -446,6 +446,12 @@ "const": "allow-build-stt-image", "markdownDescription": "Enables the build_stt_image command without any pre-configured scope." }, + { + "description": "Enables the cancel_aws_deployment command without any pre-configured scope.", + "type": "string", + "const": "allow-cancel-aws-deployment", + "markdownDescription": "Enables the cancel_aws_deployment command without any pre-configured scope." + }, { "description": "Enables the cancel_claude_token command without any pre-configured scope.", "type": "string", @@ -554,12 +560,36 @@ "const": "allow-delete-note", "markdownDescription": "Enables the delete_note command without any pre-configured scope." }, + { + "description": "Enables the deploy_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "allow-deploy-aws-environment", + "markdownDescription": "Enables the deploy_aws_environment command without any pre-configured scope." + }, + { + "description": "Enables the deploy_aws_permissions_boundary command without any pre-configured scope.", + "type": "string", + "const": "allow-deploy-aws-permissions-boundary", + "markdownDescription": "Enables the deploy_aws_permissions_boundary command without any pre-configured scope." + }, + { + "description": "Enables the destroy_incomplete_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "allow-destroy-incomplete-aws-environment", + "markdownDescription": "Enables the destroy_incomplete_aws_environment command without any pre-configured scope." + }, { "description": "Enables the detect_aws_config command without any pre-configured scope.", "type": "string", "const": "allow-detect-aws-config", "markdownDescription": "Enables the detect_aws_config command without any pre-configured scope." }, + { + "description": "Enables the detect_aws_environments command without any pre-configured scope.", + "type": "string", + "const": "allow-detect-aws-environments", + "markdownDescription": "Enables the detect_aws_environments command without any pre-configured scope." + }, { "description": "Enables the detect_host_timezone command without any pre-configured scope.", "type": "string", @@ -590,6 +620,12 @@ "const": "allow-export-settings", "markdownDescription": "Enables the export_settings command without any pre-configured scope." }, + { + "description": "Enables the follow_aws_deployment command without any pre-configured scope.", + "type": "string", + "const": "allow-follow-aws-deployment", + "markdownDescription": "Enables the follow_aws_deployment command without any pre-configured scope." + }, { "description": "Enables the forget_marketplace_installs command without any pre-configured scope.", "type": "string", @@ -806,6 +842,12 @@ "const": "allow-migrate-project-to-base", "markdownDescription": "Enables the migrate_project_to_base command without any pre-configured scope." }, + { + "description": "Enables the onboard_aws_user command without any pre-configured scope.", + "type": "string", + "const": "allow-onboard-aws-user", + "markdownDescription": "Enables the onboard_aws_user command without any pre-configured scope." + }, { "description": "Enables the open_browser_view_popout command without any pre-configured scope.", "type": "string", @@ -938,6 +980,12 @@ "const": "allow-rename-container-path", "markdownDescription": "Enables the rename_container_path command without any pre-configured scope." }, + { + "description": "Enables the repair_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "allow-repair-aws-environment", + "markdownDescription": "Enables the repair_aws_environment command without any pre-configured scope." + }, { "description": "Enables the rollback_migration command without any pre-configured scope.", "type": "string", @@ -956,6 +1004,12 @@ "const": "allow-run-scheduled-task-now", "markdownDescription": "Enables the run_scheduled_task_now command without any pre-configured scope." }, + { + "description": "Enables the save_aws_admin_bundle command without any pre-configured scope.", + "type": "string", + "const": "allow-save-aws-admin-bundle", + "markdownDescription": "Enables the save_aws_admin_bundle command without any pre-configured scope." + }, { "description": "Enables the save_note command without any pre-configured scope.", "type": "string", @@ -1124,12 +1178,24 @@ "const": "allow-uninstall-marketplace-item", "markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope." }, + { + "description": "Enables the unlock_aws_state command without any pre-configured scope.", + "type": "string", + "const": "allow-unlock-aws-state", + "markdownDescription": "Enables the unlock_aws_state command without any pre-configured scope." + }, { "description": "Enables the update_aws_connection command without any pre-configured scope.", "type": "string", "const": "allow-update-aws-connection", "markdownDescription": "Enables the update_aws_connection command without any pre-configured scope." }, + { + "description": "Enables the update_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "allow-update-aws-environment", + "markdownDescription": "Enables the update_aws_environment command without any pre-configured scope." + }, { "description": "Enables the update_marketplace command without any pre-configured scope.", "type": "string", @@ -1304,6 +1370,12 @@ "const": "deny-build-stt-image", "markdownDescription": "Denies the build_stt_image command without any pre-configured scope." }, + { + "description": "Denies the cancel_aws_deployment command without any pre-configured scope.", + "type": "string", + "const": "deny-cancel-aws-deployment", + "markdownDescription": "Denies the cancel_aws_deployment command without any pre-configured scope." + }, { "description": "Denies the cancel_claude_token command without any pre-configured scope.", "type": "string", @@ -1412,12 +1484,36 @@ "const": "deny-delete-note", "markdownDescription": "Denies the delete_note command without any pre-configured scope." }, + { + "description": "Denies the deploy_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "deny-deploy-aws-environment", + "markdownDescription": "Denies the deploy_aws_environment command without any pre-configured scope." + }, + { + "description": "Denies the deploy_aws_permissions_boundary command without any pre-configured scope.", + "type": "string", + "const": "deny-deploy-aws-permissions-boundary", + "markdownDescription": "Denies the deploy_aws_permissions_boundary command without any pre-configured scope." + }, + { + "description": "Denies the destroy_incomplete_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "deny-destroy-incomplete-aws-environment", + "markdownDescription": "Denies the destroy_incomplete_aws_environment command without any pre-configured scope." + }, { "description": "Denies the detect_aws_config command without any pre-configured scope.", "type": "string", "const": "deny-detect-aws-config", "markdownDescription": "Denies the detect_aws_config command without any pre-configured scope." }, + { + "description": "Denies the detect_aws_environments command without any pre-configured scope.", + "type": "string", + "const": "deny-detect-aws-environments", + "markdownDescription": "Denies the detect_aws_environments command without any pre-configured scope." + }, { "description": "Denies the detect_host_timezone command without any pre-configured scope.", "type": "string", @@ -1448,6 +1544,12 @@ "const": "deny-export-settings", "markdownDescription": "Denies the export_settings command without any pre-configured scope." }, + { + "description": "Denies the follow_aws_deployment command without any pre-configured scope.", + "type": "string", + "const": "deny-follow-aws-deployment", + "markdownDescription": "Denies the follow_aws_deployment command without any pre-configured scope." + }, { "description": "Denies the forget_marketplace_installs command without any pre-configured scope.", "type": "string", @@ -1664,6 +1766,12 @@ "const": "deny-migrate-project-to-base", "markdownDescription": "Denies the migrate_project_to_base command without any pre-configured scope." }, + { + "description": "Denies the onboard_aws_user command without any pre-configured scope.", + "type": "string", + "const": "deny-onboard-aws-user", + "markdownDescription": "Denies the onboard_aws_user command without any pre-configured scope." + }, { "description": "Denies the open_browser_view_popout command without any pre-configured scope.", "type": "string", @@ -1796,6 +1904,12 @@ "const": "deny-rename-container-path", "markdownDescription": "Denies the rename_container_path command without any pre-configured scope." }, + { + "description": "Denies the repair_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "deny-repair-aws-environment", + "markdownDescription": "Denies the repair_aws_environment command without any pre-configured scope." + }, { "description": "Denies the rollback_migration command without any pre-configured scope.", "type": "string", @@ -1814,6 +1928,12 @@ "const": "deny-run-scheduled-task-now", "markdownDescription": "Denies the run_scheduled_task_now command without any pre-configured scope." }, + { + "description": "Denies the save_aws_admin_bundle command without any pre-configured scope.", + "type": "string", + "const": "deny-save-aws-admin-bundle", + "markdownDescription": "Denies the save_aws_admin_bundle command without any pre-configured scope." + }, { "description": "Denies the save_note command without any pre-configured scope.", "type": "string", @@ -1982,12 +2102,24 @@ "const": "deny-uninstall-marketplace-item", "markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope." }, + { + "description": "Denies the unlock_aws_state command without any pre-configured scope.", + "type": "string", + "const": "deny-unlock-aws-state", + "markdownDescription": "Denies the unlock_aws_state command without any pre-configured scope." + }, { "description": "Denies the update_aws_connection command without any pre-configured scope.", "type": "string", "const": "deny-update-aws-connection", "markdownDescription": "Denies the update_aws_connection command without any pre-configured scope." }, + { + "description": "Denies the update_aws_environment command without any pre-configured scope.", + "type": "string", + "const": "deny-update-aws-environment", + "markdownDescription": "Denies the update_aws_environment command without any pre-configured scope." + }, { "description": "Denies the update_marketplace command without any pre-configured scope.", "type": "string", diff --git a/app/src-tauri/src/aws/environments.rs b/app/src-tauri/src/aws/environments.rs index 20a78d9..9521132 100644 --- a/app/src-tauri/src/aws/environments.rs +++ b/app/src-tauri/src/aws/environments.rs @@ -50,7 +50,6 @@ const MAX_OBJECTS_PER_PREFIX: usize = 5000; const MAX_LOCKS: usize = 200; /// An environment's record, schema 1 (`aws/terraform/environment/main.tf`). -#[allow(dead_code)] // used by phase-4 commands #[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct EnvironmentInfo { pub schema: u32, @@ -89,7 +88,6 @@ fn is_hex_id(s: &str, prefix: &str) -> bool { /// Every field is checked against what the module writes, and every ARN /// must be in `account` and `region`. The error names the field, never its /// value. -#[allow(dead_code)] // used by phase-4 commands pub fn parse_environment_record( path_name: &str, value: &str, @@ -188,7 +186,6 @@ pub fn parse_environment_record( } /// A held state lock, as shown to the user. -#[allow(dead_code)] // used by phase-4 commands #[derive(Serialize, Clone, Debug, PartialEq)] pub struct LockInfo { pub who: String, @@ -223,7 +220,6 @@ fn sanitize_lock_string(s: &str) -> String { /// A lock from its object's contents (S3-native locking's JSON: `ID`, /// `Operation`, `Info`, `Who`, `Version`, `Created`, `Path`). Contents that /// are too large or not JSON still mean a held lock, with nothing to show. -#[allow(dead_code)] // used by phase-4 commands pub fn lock_info(json: &str, age_secs: u64, local_process: bool) -> LockInfo { let parsed = (json.len() <= MAX_UNTRUSTED_BYTES) .then(|| serde_json::from_str::(json).ok()) @@ -245,7 +241,6 @@ pub fn lock_info(json: &str, age_secs: u64, local_process: bool) -> LockInfo { } } -#[allow(dead_code)] // used by phase-4 commands #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(tag = "state", rename_all = "snake_case")] pub enum EnvironmentState { @@ -264,7 +259,6 @@ pub enum EnvironmentState { UnreadableRecord { reason: String }, } -#[allow(dead_code)] // used by phase-4 commands #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(rename_all = "snake_case")] pub enum EnvironmentAction { @@ -276,7 +270,6 @@ pub enum EnvironmentAction { Unlock, } -#[allow(dead_code)] // used by phase-4 commands #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(tag = "state", rename_all = "snake_case")] pub enum OnboardingState { @@ -286,7 +279,6 @@ pub enum OnboardingState { Incomplete, } -#[allow(dead_code)] // used by phase-4 commands #[derive(Serialize, Clone, Debug, PartialEq)] pub struct DetectedEnvironment { pub name: String, @@ -298,7 +290,6 @@ pub struct DetectedEnvironment { pub onboarding: Option, } -#[allow(dead_code)] // used by phase-4 commands #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(rename_all = "snake_case")] pub enum Knowable { @@ -308,7 +299,6 @@ pub enum Knowable { Unknown, } -#[allow(dead_code)] // used by phase-4 commands #[derive(Serialize, Clone, Debug)] pub struct EnvironmentScan { pub region: String, @@ -323,7 +313,6 @@ pub struct EnvironmentScan { /// The state bucket's listing under `environments/`, `users/` and /// `boundary/`. -#[allow(dead_code)] // used by phase-4 commands #[derive(Debug, Default)] pub struct StateObjects { /// State object keys. @@ -434,7 +423,6 @@ fn detect_one( /// environments, sorted by name. `objects` is `None` when the caller can't /// read the bucket (or there is none). Onboarding is left unresolved /// ([`scan`] does that). -#[allow(dead_code)] // used by phase-4 commands pub fn merge( records: Vec<(String, Result)>, objects: Option<&StateObjects>, @@ -481,7 +469,6 @@ fn onboarding_from_objects( /// is `Send` (a Tauri command needs that) for any implementation; an impl /// may still write them as `async fn`. Access denied is an answer /// (`Unknown` / `None`), not an error. -#[allow(dead_code)] // used by phase-4 commands pub trait DetectApi: Send + Sync { /// (parameter name, value) under `/triple-c/environments/`. fn environment_records( @@ -504,7 +491,6 @@ pub trait DetectApi: Send + Sync { } /// Everything the account holds for this caller in `region`. -#[allow(dead_code)] // used by phase-4 commands pub async fn scan( d: &D, who: &CallerIdentity, @@ -635,7 +621,6 @@ where } /// The real thing, from a connection's `SdkConfig`. -#[allow(dead_code)] // used by phase-4 commands pub struct SdkDetectApi { ssm: aws_sdk_ssm::Client, s3: aws_sdk_s3::Client, @@ -645,7 +630,6 @@ pub struct SdkDetectApi { } impl SdkDetectApi { - #[allow(dead_code)] // used by phase-4 commands pub fn new(cfg: &SdkConfig, account: &str) -> Self { SdkDetectApi { ssm: aws_sdk_ssm::Client::new(cfg), @@ -934,7 +918,6 @@ fn state_bucket_policy(bucket: &str) -> String { /// a TLS-only policy. Idempotent. Every call after the create carries /// `expected_bucket_owner`, so a bucket of another account's is never /// configured or trusted. -#[allow(dead_code)] // used by phase-4 commands pub async fn ensure_state_bucket( cfg: &SdkConfig, account: &str, diff --git a/app/src-tauri/src/aws/mod.rs b/app/src-tauri/src/aws/mod.rs index 98e76f0..1cdeb07 100644 --- a/app/src-tauri/src/aws/mod.rs +++ b/app/src-tauri/src/aws/mod.rs @@ -17,5 +17,5 @@ pub mod tofu; pub mod tofu_modules; pub use connection::{sdk_config, sdk_config_for_keys}; -pub use error::AwsError; +pub use error::{classify_sdk_error, AwsError}; pub use identity::{caller_identity, CallerIdentity}; diff --git a/app/src-tauri/src/aws/tofu.rs b/app/src-tauri/src/aws/tofu.rs index 27e2fd0..61fc691 100644 --- a/app/src-tauri/src/aws/tofu.rs +++ b/app/src-tauri/src/aws/tofu.rs @@ -219,7 +219,6 @@ pub fn verify_and_extract( /// A binary already there is reused only if it still hashes to what was /// recorded when it was extracted from a verified archive. Also prepares /// the plugin cache and CLI config [`child_env`] points at. -#[allow(dead_code)] // used by phase-4 commands pub async fn ensure_tofu(root: &Path) -> Result { let (file, sha) = release_asset(std::env::consts::OS, std::env::consts::ARCH) .ok_or_else(|| other("OpenTofu isn't available for this platform"))?; @@ -323,7 +322,6 @@ fn safe_segment(s: &str) -> bool { /// The work folder of one deployment, `/work///`, without creating or writing anything: what /// [`materialize`] writes into and what [`is_running`] asks about. -#[allow(dead_code)] // used by phase-4 commands pub fn workdir_for(root: &Path, deployment: Deployment, bucket: &str) -> Result { if !safe_segment(bucket) { return Err(other("Invalid state bucket name")); @@ -345,7 +343,6 @@ pub fn workdir_for(root: &Path, deployment: Deployment, bucket: &str) -> Result< /// `terraform.tfvars.json` (0600 on Unix). Refuses while a run is in progress there. `.tf` files and the lock /// file left by an older module version are removed first, so a file a later /// version dropped can't linger and still be loaded. -#[allow(dead_code)] // used by phase-4 commands pub fn materialize( root: &Path, kind: ModuleKind, @@ -383,16 +380,22 @@ pub fn materialize( for (name, text) in module_files(kind) { fs::write(wd.dir.join(name), text).map_err(io)?; } - let backend = format!( - "bucket = \"{bucket}\"\nkey = \"{key}\"\nregion = \"{region}\"\nencrypt = true\nuse_lockfile = true\n" - ); - fs::write(wd.dir.join("backend.hcl"), backend).map_err(io)?; + fs::write(wd.dir.join("backend.hcl"), backend_hcl(bucket, &key, ®ion)).map_err(io)?; let json = serde_json::to_vec_pretty(vars) .map_err(|e| other(format!("Could not encode the module variables: {e}")))?; write_private(&wd.dir.join("terraform.tfvars.json"), &json).map_err(io)?; Ok(wd) } +/// `backend.hcl` for a state object: the work folder's and an admin +/// bundle's (D5) are the same text. The values are names the app built +/// (bucket, state key, validated region), never user text. +pub fn backend_hcl(bucket: &str, key: &str, region: &str) -> String { + format!( + "bucket = \"{bucket}\"\nkey = \"{key}\"\nregion = \"{region}\"\nencrypt = true\nuse_lockfile = true\n" + ) +} + /// Replace `path` with a file only its owner can read (Unix). The old file /// is removed first, since opening an existing file keeps its mode. fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { @@ -533,7 +536,6 @@ fn expand_home(value: &str, parent: &[(String, String)]) -> String { /// `TF_INPUT=0`, `CHECKPOINT_DISABLE=1`. /// /// `root` is the one [`ensure_tofu`] prepared. -#[allow(dead_code)] // used by phase-4 commands pub fn child_env( conn: &HostConnection, keys: Option<&AwsKeys>, @@ -690,7 +692,6 @@ pub struct Started { /// `apply.exit` within [`RUNNER_START_TIMEOUT`] is killed, and this fails. /// Blocks for that wait (normally milliseconds); call it from a blocking /// task. -#[allow(dead_code)] // used by phase-4 commands pub fn start_detached( tofu: &Path, wd: &Workdir, @@ -814,6 +815,18 @@ fn start_detached_with( Ok(Started { pid }) } +/// The runner's PID from `apply.pid`, if readable. Cancel needs it (and +/// Unix); without it a run is followable but not cancellable. +pub fn runner_pid(wd: &Workdir) -> Option { + read_pid(wd) +} + +/// Whether this folder has a run to follow: one in progress, or a finished +/// one whose log is still there. +pub fn has_run(wd: &Workdir) -> bool { + wd.dir.join(LOG_FILE).exists() || wd.dir.join(EXIT_FILE).exists() || is_running(wd) +} + fn read_pid(wd: &Workdir) -> Option { fs::read_to_string(wd.dir.join(PID_FILE)) .ok()? @@ -851,7 +864,6 @@ pub fn is_running(wd: &Workdir) -> bool { /// it is running, which stops gracefully and saves state, and still writes /// `apply.exit`. Only signals while the runner holds its lock, so a stale /// PID never reaches another process. -#[allow(dead_code)] // used by phase-4 commands pub fn interrupt(wd: &Workdir) -> Result<(), AwsError> { if !is_running(wd) { return Err(other("No deploy is running for this deployment")); @@ -980,7 +992,6 @@ fn read_new(path: &Path, offset: u64) -> io::Result> { /// `on_events` exactly once, until `apply.exit` appears. Reattaching after a /// restart is the same call. If the runner dies without an exit code (killed, /// power loss) the outcome is a failure rather than waiting forever. -#[allow(dead_code)] // used by phase-4 commands pub async fn follow( wd: &Workdir, poll: Duration, diff --git a/app/src-tauri/src/aws/tofu_modules.rs b/app/src-tauri/src/aws/tofu_modules.rs index 10448ec..b7c4c8d 100644 --- a/app/src-tauri/src/aws/tofu_modules.rs +++ b/app/src-tauri/src/aws/tofu_modules.rs @@ -15,17 +15,14 @@ use crate::models::host_connection::validate_supported_region; /// `locals.template_version` (and the `triple-c:template-version` default /// tag) of `aws/terraform/environment`. -#[allow(dead_code)] // used by phase-4 commands pub const ENVIRONMENT_MODULE_VERSION: u32 = 1; /// The same for the user-onboarding module. -#[allow(dead_code)] // used by phase-4 commands +#[allow(dead_code)] // the onboarding module's version; checked against its HCL by a test pub const ONBOARD_MODULE_VERSION: u32 = 1; /// The permissions boundary every module-created role carries (no path). /// Equal to [`super::policies::PERMISSIONS_BOUNDARY_NAME`]; a test checks it. -#[allow(dead_code)] // used by phase-4 commands pub const BOUNDARY_POLICY_NAME: &str = "triple-c-permissions-boundary"; -#[allow(dead_code)] // used by phase-4 commands #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] pub enum ModuleKind { @@ -69,7 +66,6 @@ const PERMISSIONS_BOUNDARY_FILES: &[(&str, &str)] = module!("permissions-boundar /// The module's files as (file name relative to the module directory, /// contents), lock file included, for writing into a work or bundle folder. -#[allow(dead_code)] // used by phase-4 commands pub fn module_files(kind: ModuleKind) -> &'static [(&'static str, &'static str)] { match kind { ModuleKind::Environment => ENVIRONMENT_FILES, @@ -80,7 +76,6 @@ pub fn module_files(kind: ModuleKind) -> &'static [(&'static str, &'static str)] /// `^[a-z][a-z0-9-]{0,19}$`, not ending in `-`, with no `--` — the same rule /// as the module's `environment_name` validation. -#[allow(dead_code)] // used by phase-4 commands pub fn validate_environment_name(name: &str) -> Result { let b = name.as_bytes(); let ok = !b.is_empty() @@ -102,14 +97,12 @@ pub fn validate_environment_name(name: &str) -> Result { } /// The SSM parameter holding an environment's record. -#[allow(dead_code)] // used by phase-4 commands pub fn environment_parameter_name(env: &str) -> String { format!("/triple-c/environments/{env}") } /// The first 12 hex characters of `sha256(aws:userid)`. A `UserId` holds a /// `:`, which IAM role names reject, so per-user names use this instead. -#[allow(dead_code)] // used by phase-4 commands pub fn owner_key(user_id: &str) -> String { Sha256::digest(user_id.as_bytes()) .iter() @@ -119,7 +112,6 @@ pub fn owner_key(user_id: &str) -> String { } /// A user's (task role, execution role) names. At most 47 characters. -#[allow(dead_code)] // used by phase-4 commands pub fn user_role_names(env: &str, owner_key: &str) -> (String, String) { ( format!("triple-c-{env}-{owner_key}-task"), @@ -128,13 +120,11 @@ pub fn user_role_names(env: &str, owner_key: &str) -> (String, String) { } /// The per-account, per-region state bucket. -#[allow(dead_code)] // used by phase-4 commands pub fn state_bucket_name(account: &str, region: &str) -> String { format!("triple-c-tfstate-{account}-{region}") } /// One deployment, i.e. one state object in the bucket (D9). -#[allow(dead_code)] // used by phase-4 commands #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Deployment<'a> { /// The environment module for this environment name. @@ -147,7 +137,6 @@ pub enum Deployment<'a> { /// A deployment's state object in [`state_bucket_name`] (D9); the lock is the /// same key plus `.tflock`. -#[allow(dead_code)] // used by phase-4 commands pub fn state_key(d: Deployment) -> String { match d { Deployment::Environment(env) => format!("environments/{env}/terraform.tfstate"), @@ -159,7 +148,6 @@ pub fn state_key(d: Deployment) -> String { } /// How an environment's tasks reach the network (D4). -#[allow(dead_code)] // used by phase-4 commands #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "mode", rename_all = "kebab-case")] pub enum NetworkChoice { @@ -185,7 +173,6 @@ fn is_aws_id(id: &str, prefix: &str) -> bool { /// The environment module's `terraform.tfvars.json`. Applies the same rules /// as the module's variable validations, so a bad value fails here first. -#[allow(dead_code)] // used by phase-4 commands pub fn environment_vars( env: &str, region: &str, @@ -238,7 +225,6 @@ pub fn environment_vars( /// The onboarding module's `terraform.tfvars.json` for the user whose /// `aws:userid` is `owner_id`. Bedrock stays on the workspace user's own /// credentials unless a later setting opts the task role in. -#[allow(dead_code)] // used by phase-4 commands pub fn onboarding_vars(env: &str, region: &str, owner_id: &str) -> serde_json::Value { json!({ "environment_name": env, @@ -251,7 +237,6 @@ pub fn onboarding_vars(env: &str, region: &str, owner_id: &str) -> serde_json::V /// The boundary module's `terraform.tfvars.json`: the shipped boundary /// document itself, so the module holds no second copy. -#[allow(dead_code)] // used by phase-4 commands pub fn boundary_vars(region: &str) -> serde_json::Value { json!({ "region": region, diff --git a/app/src-tauri/src/commands/environment_commands.rs b/app/src-tauri/src/commands/environment_commands.rs new file mode 100644 index 0000000..fd8b9eb --- /dev/null +++ b/app/src-tauri/src/commands/environment_commands.rs @@ -0,0 +1,952 @@ +//! Phase 4: AWS environments, deployed by the app with OpenTofu (D1–D10). +//! +//! Every command takes a connection id and a region, re-validates both, and +//! re-runs detection on this side before it changes anything: the frontend's +//! idea of what an environment allows is never trusted. Destroy runs only +//! when detection offers Destroy (no valid record, D6 / R12), Unlock only on +//! a stale lock this machine isn't holding, Repair and Update only when +//! offered. +//! +//! Applies are detached (D10). Each started or followed run gets one task +//! here that follows its log and emits [`PROGRESS_EVENT`] batches, then a +//! final `done` with the result. A deployment is followed by at most one task +//! per process ([`FOLLOWERS`]). +//! +//! Credentials: a keys connection's keys are read from the keychain here +//! and go only into [`tofu::child_env`]; they are never logged, written or +//! put in an event. The container's (or the app's) own `AWS_*` variables +//! are never used: `child_env` builds the runner's whole environment. +//! +//! The admin bundle (D5) is written into a folder the user picks in a +//! Rust-side dialog, for the same reason `file_commands::pick_save_path` +//! exists: no host path crosses IPC as an input. + +use std::collections::{BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use aws_config::SdkConfig; +use serde::Serialize; +use serde_json::Value; +use tauri::{AppHandle, Emitter, State}; + +use crate::aws::environments::{ + ensure_state_bucket, scan, DetectedEnvironment, EnvironmentAction, EnvironmentInfo, + EnvironmentScan, EnvironmentState, Knowable, OnboardingState, SdkDetectApi, +}; +use crate::aws::tofu::{ + self, backend_hcl, StartError, TofuEvent, TofuOp, TofuOutcome, Workdir, +}; +use crate::aws::tofu_modules::{ + boundary_vars, environment_vars, module_files, onboarding_vars, owner_key, state_bucket_name, + state_key, validate_environment_name, Deployment, ModuleKind, NetworkChoice, +}; +use crate::aws::{caller_identity, sdk_config, AwsError, CallerIdentity}; +use crate::models::{validate_supported_region, AwsAuth, HostConnection, HostKind}; +use crate::storage::secure; +use crate::AppState; + +/// The Tauri event carrying a run's progress ([`DeployProgress`]). +pub const PROGRESS_EVENT: &str = "aws-deploy-progress"; +/// How often a follower polls the run's log. +const FOLLOW_POLL: Duration = Duration::from_millis(500); +/// At most this many events per emitted batch (a reattach replays the +/// whole log at once). +const MAX_EVENTS_PER_BATCH: usize = 200; +/// A state object read back for its outputs larger than this is refused. +const MAX_STATE_BYTES: usize = 32 * 1024 * 1024; + +fn other(message: impl Into) -> AwsError { + AwsError::Other { + message: message.into(), + } +} + +// --------------------------------------------------------------------------- +// Pure helpers (unit-tested) + +/// An owned [`Deployment`]: what a state key from the frontend parses into. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DeploymentKey { + Environment(String), + User { env: String, owner_key: String }, + Boundary, +} + +impl DeploymentKey { + pub(crate) fn deployment(&self) -> Deployment<'_> { + match self { + DeploymentKey::Environment(env) => Deployment::Environment(env), + DeploymentKey::User { env, owner_key } => Deployment::User { env, owner_key }, + DeploymentKey::Boundary => Deployment::Boundary, + } + } + + /// How a message names it. + fn label(&self) -> String { + match self { + DeploymentKey::Environment(env) => format!("environment \"{env}\""), + DeploymentKey::User { env, .. } => format!("the onboarding to \"{env}\""), + DeploymentKey::Boundary => "the permissions boundary".to_string(), + } + } +} + +fn is_owner_key(s: &str) -> bool { + s.len() == 12 && s.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f')) +} + +/// A state key from the frontend, accepted only if [`state_key`] could have +/// produced it: a valid environment name, a 12-hex-character owner key, and +/// nothing else. The parse is checked by rebuilding the key. +pub(crate) fn parse_state_key(key: &str) -> Result { + let refuse = || other("Not a Triple-C deployment's state key"); + let parts: Vec<&str> = key.split('/').collect(); + let parsed = match parts.as_slice() { + ["environments", env, "terraform.tfstate"] => { + validate_environment_name(env).map_err(|_| refuse())?; + DeploymentKey::Environment(env.to_string()) + } + ["users", env, ok, "terraform.tfstate"] => { + validate_environment_name(env).map_err(|_| refuse())?; + if !is_owner_key(ok) { + return Err(refuse()); + } + DeploymentKey::User { + env: env.to_string(), + owner_key: ok.to_string(), + } + } + ["boundary", "terraform.tfstate"] => DeploymentKey::Boundary, + _ => return Err(refuse()), + }; + if state_key(parsed.deployment()) != key { + return Err(refuse()); + } + Ok(parsed) +} + +fn find<'a>(scan: &'a EnvironmentScan, name: &str) -> Option<&'a DetectedEnvironment> { + scan.environments.iter().find(|e| e.name == name) +} + +/// Why a new environment named `name` can't be deployed: an invalid name, +/// a missing permissions boundary (every module role needs it), or a record +/// or state for the name already. A boundary the caller can't see is let +/// through; the apply reports it. +pub(crate) fn deploy_refusal(scan: &EnvironmentScan, name: &str) -> Result<(), String> { + let name = validate_environment_name(name)?; + if scan.boundary == Knowable::No { + return Err( + "The account has no Triple-C permissions boundary yet; deploy the permissions \ + boundary first" + .into(), + ); + } + if find(scan, &name).is_some() { + return Err(format!( + "An environment named \"{name}\" already exists in this account and region" + )); + } + Ok(()) +} + +/// The environment's network as deployed, from its record: an update or a +/// repair never changes it. The record has no VPC id, so an `existing` +/// network takes it from the state's `vpc_id` output. +pub(crate) fn network_from_record( + info: &EnvironmentInfo, + vpc_id: Option<&str>, +) -> Result { + match info.network_mode.as_str() { + "public" => Ok(NetworkChoice::Public), + "private-nat" => Ok(NetworkChoice::PrivateNat), + "existing" => { + let vpc_id = vpc_id.ok_or_else(|| { + "The environment's VPC could not be read from its state".to_string() + })?; + let choice = NetworkChoice::Existing { + vpc_id: vpc_id.to_string(), + subnet_ids: info.subnet_ids.clone(), + assign_public_ip: info.assign_public_ip, + }; + // The same checks as the apply's variables. + environment_vars(&info.name, "us-east-1", &choice)?; + Ok(choice) + } + _ => Err("The environment record names an unknown network mode".into()), + } +} + +/// `outputs.vpc_id.value` of a state object, if it is a VPC id. +pub(crate) fn vpc_id_from_state(state: &[u8]) -> Option { + let v: Value = serde_json::from_slice(state).ok()?; + let id = v.pointer("/outputs/vpc_id/value")?.as_str()?; + let hex = id.strip_prefix("vpc-")?; + ((hex.len() == 8 || hex.len() == 17) + && hex.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f'))) + .then(|| id.to_string()) +} + +/// `^[A-Z0-9]{16,128}(:[A-Za-z0-9+=,.@_-]{1,64})?$`: an `aws:userid` (a +/// user's unique id, or a role's id and session name). The onboarding +/// module tags with it and hashes it into role names. +pub(crate) fn validate_owner_id(id: &str) -> Result { + let (unique, session) = match id.split_once(':') { + Some((u, s)) => (u, Some(s)), + None => (id, None), + }; + let unique_ok = (16..=128).contains(&unique.len()) + && unique + .bytes() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()); + let session_ok = session.is_none_or(|s| { + (1..=64).contains(&s.len()) + && s.bytes() + .all(|c| c.is_ascii_alphanumeric() || b"+=,.@_-".contains(&c)) + }); + if unique_ok && session_ok { + Ok(id.to_string()) + } else { + Err("The owner id is not an AWS user id (aws:userid, e.g. AIDA… or AROA…:session)".into()) + } +} + +/// The environment, if detection offers `action` on it. +pub(crate) fn require_action<'a>( + scan: &'a EnvironmentScan, + name: &str, + action: EnvironmentAction, +) -> Result<&'a DetectedEnvironment, String> { + let env = find(scan, name) + .ok_or_else(|| format!("No environment named \"{name}\" was found"))?; + if env.actions.contains(&action) { + Ok(env) + } else { + Err(format!( + "{} is not available for environment \"{name}\" in its current state", + match action { + EnvironmentAction::Join => "Joining", + EnvironmentAction::Update => "Update", + EnvironmentAction::Repair => "Repair", + EnvironmentAction::Destroy => "Destroy", + EnvironmentAction::Follow => "Follow", + EnvironmentAction::Unlock => "Unlock", + } + )) + } +} + +/// Unlock only a stale lock no local runner holds: detection offers Unlock +/// and the environment is Incomplete with such a lock. +pub(crate) fn unlock_refusal(scan: &EnvironmentScan, name: &str) -> Result<(), String> { + let env = require_action(scan, name, EnvironmentAction::Unlock)?; + match &env.state { + EnvironmentState::Incomplete { lock: Some(l) } if !l.local_process => Ok(()), + _ => Err(format!( + "Environment \"{name}\" has no stale state lock to remove" + )), + } +} + +/// Self-onboarding needs the boundary (not known missing), an environment +/// that can be joined, and no onboarding already running. +pub(crate) fn onboard_refusal(scan: &EnvironmentScan, name: &str) -> Result<(), String> { + if scan.boundary == Knowable::No { + return Err("The account has no Triple-C permissions boundary yet".into()); + } + let env = require_action(scan, name, EnvironmentAction::Join)?; + if env.onboarding == Some(OnboardingState::InProgress) { + return Err(format!( + "Your onboarding to \"{name}\" is already in progress" + )); + } + Ok(()) +} + +/// `AlreadyRunning` is told apart before it becomes an `AwsError`. +pub(crate) fn start_error(e: StartError, label: &str) -> AwsError { + match e { + StartError::AlreadyRunning => other(format!( + "A deployment is already running for {label}; use Follow to watch it" + )), + StartError::Failed(e) => e, + } +} + +fn module_kind(d: Deployment) -> ModuleKind { + match d { + Deployment::Environment(_) => ModuleKind::Environment, + Deployment::User { .. } => ModuleKind::UserOnboard, + Deployment::Boundary => ModuleKind::PermissionsBoundary, + } +} + +const README: &str = "README.txt"; +const BACKEND: &str = "backend.hcl"; +const TFVARS: &str = "terraform.tfvars.json"; + +fn bundle_readme(d: Deployment, account: &str, region: &str, bucket: &str, key: &str) -> String { + let what = match d { + Deployment::Environment(env) => format!( + "the Triple-C environment \"{env}\" (ECS cluster, security group, KMS key, log \ + group and network; the network is set in terraform.tfvars.json)" + ), + Deployment::User { env, .. } => format!( + "one user's onboarding to the Triple-C environment \"{env}\" (their task and \ + execution roles and EFS file system; the user is the owner_id in \ + terraform.tfvars.json)" + ), + Deployment::Boundary => { + "the account's Triple-C permissions boundary policy (triple-c-permissions-boundary)" + .to_string() + } + }; + let location = if region == "us-east-1" { + String::new() + } else { + format!(" --create-bucket-configuration LocationConstraint={region}") + }; + format!( + "Triple-C admin bundle +===================== + +This folder deploys {what} +into AWS account {account}, region {region}. + +OpenTofu state: s3://{bucket}/{key} +(S3-native locking: {key}.tflock is held while a run is in progress.) + +Requirements +------------ +- OpenTofu 1.10 or later, or Terraform 1.10 or later. Both are needed for + S3-native state locking (use_lockfile in backend.hcl). +- Credentials for account {account} that carry the Triple-C environment-admin + policies. +- .terraform.lock.hcl pins the AWS provider's hashes for OpenTofu + (registry.opentofu.org). With Terraform, first run + terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 -platform=windows_amd64 + for the platforms you use (or delete .terraform.lock.hcl and let + terraform init write a new one). + +1. The state bucket +------------------- +The bucket {bucket} must exist first. Either deploy anything from +Triple-C once with this account and region (the app creates and configures +it), or create it by hand (POSIX shell syntax): + + aws s3api create-bucket --bucket {bucket} --region {region}{location} + aws s3api put-bucket-versioning --bucket {bucket} --region {region} --expected-bucket-owner {account} --versioning-configuration Status=Enabled + aws s3api put-bucket-encryption --bucket {bucket} --region {region} --expected-bucket-owner {account} --server-side-encryption-configuration '{{\"Rules\":[{{\"ApplyServerSideEncryptionByDefault\":{{\"SSEAlgorithm\":\"AES256\"}}}}]}}' + aws s3api put-public-access-block --bucket {bucket} --region {region} --expected-bucket-owner {account} --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true + aws s3api put-bucket-tagging --bucket {bucket} --region {region} --expected-bucket-owner {account} --tagging 'TagSet=[{{Key=triple-c:component,Value=state}}]' + +2. Deploy +--------- +From this folder: + + tofu init -backend-config=backend.hcl + tofu apply + +(With Terraform: terraform init -backend-config=backend.hcl, then +terraform apply.) + +3. Remove +--------- +From this folder, after tofu init: + + tofu destroy + +This is irreversible. Remove every user's onboarding to an environment +before destroying the environment itself. +" + ) +} + +/// The files a bundle for `d` consists of, as (name, contents). +fn bundle_files( + d: Deployment, + account: &str, + region: &str, + vars: &Value, +) -> Result, String> { + let region = validate_supported_region(region)?; + let bucket = state_bucket_name(account, ®ion); + let key = state_key(d); + let mut files: Vec<(String, String)> = module_files(module_kind(d)) + .iter() + .map(|(n, t)| (n.to_string(), t.to_string())) + .collect(); + files.push((BACKEND.into(), backend_hcl(&bucket, &key, ®ion))); + let json = serde_json::to_string_pretty(vars) + .map_err(|e| format!("Could not encode the module variables: {e}"))?; + files.push((TFVARS.into(), json + "\n")); + files.push((README.into(), bundle_readme(d, account, ®ion, &bucket, &key))); + Ok(files) +} + +/// Write the D5 bundle for `d` into `dir`: the module, `backend.hcl`, +/// `terraform.tfvars.json` and `README.txt`. Refuses a folder holding +/// anything the bundle wouldn't write itself (an unrelated file, or a +/// folder such as `.terraform`), so nothing of the user's is overwritten. +/// Returns the names written. +pub(crate) fn write_bundle( + dir: &Path, + d: Deployment, + account: &str, + region: &str, + vars: &Value, +) -> Result, String> { + let files = bundle_files(d, account, region, vars)?; + let ours: BTreeSet<&str> = files.iter().map(|(n, _)| n.as_str()).collect(); + let io = |e: std::io::Error| format!("Could not write the bundle: {e}"); + let mut foreign = Vec::new(); + for entry in std::fs::read_dir(dir).map_err(io)? { + let entry = entry.map_err(io)?; + let name = entry.file_name().to_string_lossy().into_owned(); + let is_file = entry.file_type().map_err(io)?.is_file(); + if !(is_file && ours.contains(name.as_str())) { + foreign.push(name); + } + } + if !foreign.is_empty() { + foreign.sort(); + return Err(format!( + "The folder already holds files that aren't part of this bundle ({}); choose an \ + empty folder", + foreign.join(", ") + )); + } + for (name, text) in &files { + std::fs::write(dir.join(name), text).map_err(io)?; + } + Ok(files.into_iter().map(|(n, _)| n).collect()) +} + +/// One [`PROGRESS_EVENT`] payload. +#[derive(Serialize, Clone, Debug)] +pub struct DeployProgress { + pub op_id: String, + pub state_key: String, + pub events: Vec, + pub done: bool, + /// Cancel can reach the runner (its PID is known, and not Windows). + pub cancellable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, +} + +// --------------------------------------------------------------------------- +// Plumbing + +/// Work folders being followed by this process, with their op id. +static FOLLOWERS: OnceLock>> = OnceLock::new(); + +fn followers() -> std::sync::MutexGuard<'static, HashMap> { + FOLLOWERS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|p| p.into_inner()) +} + +/// `/triple-c/aws/`: OpenTofu, its plugin cache and the work +/// folders. +fn work_root() -> Result { + let root = dirs::data_dir() + .ok_or_else(|| other("Could not find the app data folder"))? + .join("triple-c") + .join("aws"); + std::fs::create_dir_all(&root) + .map_err(|e| other(format!("Could not create {}: {e}", root.display())))?; + Ok(root) +} + +/// A connection, its SDK config and caller in one validated region. +struct Ctx { + conn: HostConnection, + cfg: SdkConfig, + who: CallerIdentity, + region: String, + root: PathBuf, +} + +impl Ctx { + fn bucket(&self) -> String { + state_bucket_name(&self.who.account, &self.region) + } + + fn workdir(&self, d: Deployment) -> Result { + tofu::workdir_for(&self.root, d, &self.bucket()) + } +} + +async fn context( + state: &State<'_, AppState>, + connection_id: &str, + region: &str, +) -> Result { + let conn = super::host_commands::aws_conn(connection_id, &state.settings_store.get()) + .map_err(other)?; + let region = validate_supported_region(region).map_err(other)?; + let cfg = sdk_config(&conn, ®ion).await?; + let who = caller_identity(&cfg).await?; + Ok(Ctx { + conn, + cfg, + who, + region, + root: work_root()?, + }) +} + +async fn detect(ctx: &Ctx) -> Result { + let api = SdkDetectApi::new(&ctx.cfg, &ctx.who.account); + let (root, bucket) = (ctx.root.clone(), ctx.bucket()); + let running = move |d: Deployment| { + tofu::workdir_for(&root, d, &bucket) + .map(|wd| tofu::is_running(&wd)) + .unwrap_or(false) + }; + scan(&api, &ctx.who, &ctx.region, &running).await +} + +/// The environment's VPC from its state's outputs (an `existing` network's +/// VPC isn't in the record). +async fn recorded_vpc_id(ctx: &Ctx, env: &str) -> Result, AwsError> { + let s3 = aws_sdk_s3::Client::new(&ctx.cfg); + let obj = s3 + .get_object() + .bucket(ctx.bucket()) + .key(state_key(Deployment::Environment(env))) + .expected_bucket_owner(&ctx.who.account) + .send() + .await + .map_err(|e| crate::aws::classify_sdk_error("s3:GetObject", &e))?; + if obj + .content_length() + .is_some_and(|n| n < 0 || n as usize > MAX_STATE_BYTES) + { + return Err(other("The environment's state is implausibly large")); + } + let bytes = obj + .body + .collect() + .await + .map_err(|e| other(format!("Could not read the environment's state: {e}")))? + .into_bytes(); + Ok(vpc_id_from_state(&bytes)) +} + +/// The network to re-apply an environment with: the recorded one. +async fn recorded_network(ctx: &Ctx, info: &EnvironmentInfo) -> Result { + let vpc = if info.network_mode == "existing" { + recorded_vpc_id(ctx, &info.name).await? + } else { + None + }; + network_from_record(info, vpc.as_deref()).map_err(other) +} + +fn cancellable(wd: &Workdir) -> bool { + cfg!(unix) && tofu::runner_pid(wd).is_some() +} + +fn emit_progress(app: &AppHandle, p: &DeployProgress) { + if let Err(e) = app.emit(PROGRESS_EVENT, p) { + log::warn!("Could not emit deploy progress: {e}"); + } +} + +/// Follow `wd` in a task of its own, unless this process already does; the +/// op id of whichever task follows it. +fn spawn_follower(app: AppHandle, wd: Workdir, key: String, can_cancel: bool) -> String { + let op_id = { + let mut map = followers(); + if let Some(existing) = map.get(&wd.dir) { + return existing.clone(); + } + let op_id = uuid::Uuid::new_v4().to_string(); + map.insert(wd.dir.clone(), op_id.clone()); + op_id + }; + let id = op_id.clone(); + tauri::async_runtime::spawn(async move { + let base = DeployProgress { + op_id: id, + state_key: key, + events: Vec::new(), + done: false, + cancellable: can_cancel, + result: None, + }; + let outcome = tofu::follow(&wd, FOLLOW_POLL, |events| { + for chunk in events.chunks(MAX_EVENTS_PER_BATCH) { + emit_progress( + &app, + &DeployProgress { + events: chunk.to_vec(), + ..base.clone() + }, + ); + } + }) + .await + .unwrap_or_else(|e| TofuOutcome { + ok: false, + exit_code: -1, + summary: Some(e.to_string()), + }); + followers().remove(&wd.dir); + emit_progress( + &app, + &DeployProgress { + done: true, + cancellable: false, + result: Some(outcome), + ..base + }, + ); + }); + op_id +} + +/// Ensure OpenTofu and the state bucket, write the work folder and start a +/// detached `init` + `op`; returns the op id its progress is emitted under. +async fn launch( + app: AppHandle, + ctx: &Ctx, + key: &DeploymentKey, + vars: &Value, + op: TofuOp, +) -> Result { + let d = key.deployment(); + let label = key.label(); + let wd = ctx.workdir(d)?; + if tofu::is_running(&wd) { + return Err(start_error(StartError::AlreadyRunning, &label)); + } + if followers().contains_key(&wd.dir) { + return Err(other(format!( + "The previous run for {label} is still finishing; try again in a moment" + ))); + } + let tofu_bin = tofu::ensure_tofu(&ctx.root).await?; + // StateBucketTaken is passed on as it is. + let bucket = ensure_state_bucket(&ctx.cfg, &ctx.who.account, &ctx.region).await?; + let wd = tofu::materialize(&ctx.root, module_kind(d), d, &bucket, &ctx.region, vars)?; + + let HostKind::Aws { auth, .. } = &ctx.conn.kind; + let keys = match auth { + AwsAuth::Keys { keyring_ref } => Some( + secure::get_aws_keys(keyring_ref) + .map_err(other)? + .ok_or(AwsError::KeysMissing)?, + ), + AwsAuth::Profile { .. } => None, + }; + let env = tofu::child_env(&ctx.conn, keys.as_ref(), &ctx.root, std::env::vars())?; + drop(keys); + + let start_wd = wd.clone(); + let started = tokio::task::spawn_blocking(move || { + tofu::start_detached(&tofu_bin, &start_wd, op, &env) + }) + .await + .map_err(|e| other(format!("The deploy could not be started: {e}")))? + .map_err(|e| start_error(e, &label))?; + let can_cancel = cfg!(unix) && started.pid.is_some(); + Ok(spawn_follower(app, wd, state_key(d), can_cancel)) +} + +// --------------------------------------------------------------------------- +// Commands + +/// What the account holds in `region`: environments, the boundary, the +/// state bucket and the caller's onboarding. +#[tauri::command] +pub async fn detect_aws_environments( + connection_id: String, + region: String, + state: State<'_, AppState>, +) -> Result { + let ctx = context(&state, &connection_id, ®ion).await?; + detect(&ctx).await +} + +/// Deploy a new environment. Refused for a name that has a record or +/// state, and when the permissions boundary is missing. +#[tauri::command] +pub async fn deploy_aws_environment( + connection_id: String, + region: String, + name: String, + network: NetworkChoice, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let ctx = context(&state, &connection_id, ®ion).await?; + let found = detect(&ctx).await?; + deploy_refusal(&found, &name).map_err(other)?; + let vars = environment_vars(&name, &ctx.region, &network).map_err(other)?; + let key = DeploymentKey::Environment(name); + launch(app, &ctx, &key, &vars, TofuOp::Apply).await +} + +/// Re-apply an environment at the app's module version, with the network +/// read back from its record (an update never changes the network). +#[tauri::command] +pub async fn update_aws_environment( + connection_id: String, + region: String, + name: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let ctx = context(&state, &connection_id, ®ion).await?; + let found = detect(&ctx).await?; + let env = require_action(&found, &name, EnvironmentAction::Update).map_err(other)?; + let info = env + .info + .as_ref() + .ok_or_else(|| other("The environment has no readable record"))?; + let network = recorded_network(&ctx, info).await?; + let vars = environment_vars(&env.name, &ctx.region, &network).map_err(other)?; + let key = DeploymentKey::Environment(env.name.clone()); + launch(app, &ctx, &key, &vars, TofuOp::Apply).await +} + +/// Re-apply an Incomplete environment. With a valid record its network is +/// the recorded one (`network` is ignored); without one, `network` is +/// required. +#[tauri::command] +pub async fn repair_aws_environment( + connection_id: String, + region: String, + name: String, + network: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let ctx = context(&state, &connection_id, ®ion).await?; + let found = detect(&ctx).await?; + let env = require_action(&found, &name, EnvironmentAction::Repair).map_err(other)?; + let network = match (&env.info, network) { + (Some(info), _) => recorded_network(&ctx, info).await?, + (None, Some(n)) => n, + (None, None) => { + return Err(other( + "The environment has no record to take its network from; choose one", + )) + } + }; + let vars = environment_vars(&env.name, &ctx.region, &network).map_err(other)?; + let key = DeploymentKey::Environment(env.name.clone()); + launch(app, &ctx, &key, &vars, TofuOp::Apply).await +} + +/// Destroy an incomplete deployment: only when detection offers Destroy, +/// i.e. there is no valid record (D6). A working environment is destroyed +/// by an admin from a bundle. +#[tauri::command] +pub async fn destroy_incomplete_aws_environment( + connection_id: String, + region: String, + name: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let ctx = context(&state, &connection_id, ®ion).await?; + let found = detect(&ctx).await?; + let env = require_action(&found, &name, EnvironmentAction::Destroy).map_err(other)?; + if env.info.is_some() { + return Err(other("A working environment can't be destroyed from the app")); + } + // A destroy plans the removal of everything in state whatever the + // network variables say; they only have to be valid. + let vars = environment_vars(&env.name, &ctx.region, &NetworkChoice::Public).map_err(other)?; + let key = DeploymentKey::Environment(env.name.clone()); + launch(app, &ctx, &key, &vars, TofuOp::Destroy).await +} + +/// Delete an environment's stale `.tflock`. Only an environment's lock, +/// only when detection offers Unlock, the lock is stale and no runner on +/// this machine holds the deployment. +#[tauri::command] +pub async fn unlock_aws_state( + connection_id: String, + region: String, + state_key: String, + state: State<'_, AppState>, +) -> Result<(), AwsError> { + let key = parse_state_key(&state_key)?; + let DeploymentKey::Environment(name) = &key else { + return Err(other("Only an environment's state lock can be removed from the app")); + }; + let ctx = context(&state, &connection_id, ®ion).await?; + let found = detect(&ctx).await?; + unlock_refusal(&found, name).map_err(other)?; + if tofu::is_running(&ctx.workdir(key.deployment())?) { + return Err(other("A deploy on this machine holds the lock")); + } + let lock_key = format!("{state_key}.tflock"); + aws_sdk_s3::Client::new(&ctx.cfg) + .delete_object() + .bucket(ctx.bucket()) + .key(&lock_key) + .expected_bucket_owner(&ctx.who.account) + .send() + .await + .map_err(|e| crate::aws::classify_sdk_error("s3:DeleteObject", &e))?; + log::info!("Removed the stale state lock {lock_key}"); + Ok(()) +} + +/// Reattach to a run on this machine (in progress, or finished with its +/// log still there): replays its log as progress. Returns the op id; a run +/// already followed keeps its follower and op id. +#[tauri::command] +pub async fn follow_aws_deployment( + connection_id: String, + region: String, + state_key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let key = parse_state_key(&state_key)?; + let ctx = context(&state, &connection_id, ®ion).await?; + let wd = ctx.workdir(key.deployment())?; + if !tofu::has_run(&wd) { + return Err(other(format!( + "There is no deploy of {} on this machine to follow", + key.label() + ))); + } + let can_cancel = tofu::is_running(&wd) && cancellable(&wd); + Ok(spawn_follower(app, wd, state_key, can_cancel)) +} + +/// Cancel: interrupt the runner, which stops OpenTofu gracefully. +#[tauri::command] +pub async fn cancel_aws_deployment( + connection_id: String, + region: String, + state_key: String, + state: State<'_, AppState>, +) -> Result<(), AwsError> { + let key = parse_state_key(&state_key)?; + let ctx = context(&state, &connection_id, ®ion).await?; + tofu::interrupt(&ctx.workdir(key.deployment())?) +} + +/// Onboard the caller to `environment`, as the owner their own +/// `aws:userid` names. +#[tauri::command] +pub async fn onboard_aws_user( + connection_id: String, + region: String, + environment: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let env = validate_environment_name(&environment).map_err(other)?; + let ctx = context(&state, &connection_id, ®ion).await?; + let owner = validate_owner_id(&ctx.who.user_id).map_err(other)?; + let found = detect(&ctx).await?; + onboard_refusal(&found, &env).map_err(other)?; + let vars = onboarding_vars(&env, &ctx.region, &owner); + let key = DeploymentKey::User { + owner_key: owner_key(&owner), + env, + }; + launch(app, &ctx, &key, &vars, TofuOp::Apply).await +} + +/// Deploy (or re-apply) the account's permissions boundary. +#[tauri::command] +pub async fn deploy_aws_permissions_boundary( + connection_id: String, + region: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let ctx = context(&state, &connection_id, ®ion).await?; + let vars = boundary_vars(&ctx.region); + launch(app, &ctx, &DeploymentKey::Boundary, &vars, TofuOp::Apply).await +} + +/// Ask for a folder from Rust (see `file_commands::pick_save_path`). +async fn pick_folder(window: &tauri::Window) -> Option { + use tauri_plugin_dialog::DialogExt; + let (tx, rx) = tokio::sync::oneshot::channel(); + window + .dialog() + .file() + .set_parent(window) + .set_title("Save the admin bundle into a folder") + .pick_folder(move |picked| { + let _ = tx.send(picked); + }); + rx.await.ok().flatten().and_then(|p| p.into_path().ok()) +} + +/// Write an admin bundle (D5) for `kind` into a folder the user picks: +/// `environment` names the environment (environment and onboarding), +/// `owner_id` the user to onboard (an `aws:userid`), `network` an +/// environment's network (public when omitted). `None` when the dialog was +/// dismissed; otherwise the folder written. +#[allow(clippy::too_many_arguments)] +#[tauri::command] +pub async fn save_aws_admin_bundle( + connection_id: String, + region: String, + kind: ModuleKind, + environment: Option, + owner_id: Option, + network: Option, + window: tauri::Window, + state: State<'_, AppState>, +) -> Result, AwsError> { + let need_env = || { + environment + .as_deref() + .ok_or_else(|| other("An environment name is required")) + .and_then(|e| validate_environment_name(e).map_err(other)) + }; + // Validate everything before any AWS call or dialog. + let region_ok = validate_supported_region(®ion).map_err(other)?; + let (key, vars) = match kind { + ModuleKind::Environment => { + let env = need_env()?; + let vars = environment_vars(&env, ®ion_ok, &network.unwrap_or(NetworkChoice::Public)) + .map_err(other)?; + (DeploymentKey::Environment(env), vars) + } + ModuleKind::UserOnboard => { + let env = need_env()?; + let owner = owner_id + .as_deref() + .map(str::trim) + .ok_or_else(|| other("The user's owner id (aws:userid) is required")) + .and_then(|o| validate_owner_id(o).map_err(other))?; + let vars = onboarding_vars(&env, ®ion_ok, &owner); + ( + DeploymentKey::User { + owner_key: owner_key(&owner), + env, + }, + vars, + ) + } + ModuleKind::PermissionsBoundary => (DeploymentKey::Boundary, boundary_vars(®ion_ok)), + }; + let ctx = context(&state, &connection_id, ®ion).await?; + let Some(dir) = pick_folder(&window).await else { + return Ok(None); + }; + write_bundle(&dir, key.deployment(), &ctx.who.account, &ctx.region, &vars).map_err(other)?; + Ok(Some(dir.to_string_lossy().into_owned())) +} + +#[cfg(test)] +mod tests; diff --git a/app/src-tauri/src/commands/environment_commands/tests.rs b/app/src-tauri/src/commands/environment_commands/tests.rs new file mode 100644 index 0000000..83513f9 --- /dev/null +++ b/app/src-tauri/src/commands/environment_commands/tests.rs @@ -0,0 +1,359 @@ +use std::fs; + +use serde_json::json; + +use super::*; +use crate::aws::environments::{ + EnvironmentState, Knowable, LockInfo, OnboardingState, +}; +use crate::aws::tofu_modules::{ + environment_vars, module_files, onboarding_vars, owner_key, state_key, ModuleKind, +}; + +const ACCOUNT: &str = "123456789012"; +const REGION: &str = "eu-west-1"; +const USER_ID: &str = "AROAEXAMPLEID12345678:alice"; + +fn info(name: &str, mode: &str, assign_public_ip: bool) -> EnvironmentInfo { + EnvironmentInfo { + schema: 1, + name: name.into(), + template_version: 1, + cluster_arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-{name}"), + security_group_id: "sg-0123456789abcdef0".into(), + subnet_ids: vec![ + "subnet-0123456789abcdef0".into(), + "subnet-0fedcba9876543210".into(), + ], + assign_public_ip, + kms_key_arn: format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234abcd-12ab"), + log_group: format!("/triple-c/{name}"), + network_mode: mode.into(), + tenancy: "strict".into(), + } +} + +fn env(name: &str, state: EnvironmentState, actions: Vec) -> DetectedEnvironment { + DetectedEnvironment { + name: name.into(), + template_version: None, + state, + actions, + info: None, + onboarding: None, + } +} + +fn scan(boundary: Knowable, environments: Vec) -> EnvironmentScan { + EnvironmentScan { + region: REGION.into(), + account: ACCOUNT.into(), + owner_id: USER_ID.into(), + owner_key: owner_key(USER_ID), + state_bucket: Knowable::Yes, + boundary, + environments, + } +} + +fn lock(age_secs: u64, local_process: bool) -> LockInfo { + LockInfo { + who: "someone@host".into(), + created: String::new(), + operation: "OperationTypeApply".into(), + age_secs, + local_process, + } +} + +#[test] +fn only_our_state_keys_are_accepted() { + let ok = owner_key(USER_ID); + let good = [ + ( + "environments/dev/terraform.tfstate".to_string(), + DeploymentKey::Environment("dev".into()), + ), + ( + format!("users/dev/{ok}/terraform.tfstate"), + DeploymentKey::User { env: "dev".into(), owner_key: ok.clone() }, + ), + ("boundary/terraform.tfstate".to_string(), DeploymentKey::Boundary), + ]; + for (key, want) in good { + let parsed = parse_state_key(&key).unwrap(); + assert_eq!(parsed, want); + assert_eq!(state_key(parsed.deployment()), key, "round trip"); + } + let bad = [ + "", + "environments/dev/terraform.tfstate.tflock", + "environments/Dev/terraform.tfstate", + "environments/dev-/terraform.tfstate", + "environments/../terraform.tfstate", + "environments/dev/../../boundary/terraform.tfstate", + "/environments/dev/terraform.tfstate", + "environments//terraform.tfstate", + "environments/dev/x/terraform.tfstate", + "environments/dev/terraform.tfstate/", + "users/dev/terraform.tfstate", + "users/dev/ABCDEF012345/terraform.tfstate", + "users/dev/abcdef01234/terraform.tfstate", + "users/dev/abcdef0123456/terraform.tfstate", + "users/dev/abcdef01234g/terraform.tfstate", + "boundary/x/terraform.tfstate", + "boundary/terraform.tfstate ", + "something/else", + "environments\\dev\\terraform.tfstate", + ]; + for key in bad { + assert!(parse_state_key(key).is_err(), "{key:?} must be refused"); + } +} + +#[test] +fn deploy_refuses_an_existing_name_and_a_missing_boundary() { + use EnvironmentAction::*; + let existing = vec![ + env("dev", EnvironmentState::Ready, vec![Join]), + env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]), + ]; + let s = scan(Knowable::Yes, existing.clone()); + assert!(deploy_refusal(&s, "prod").is_ok()); + let e = deploy_refusal(&s, "dev").unwrap_err(); + assert!(e.contains("already exists"), "{e}"); + assert!(deploy_refusal(&s, "half").is_err(), "state without a record is taken too"); + assert!(deploy_refusal(&s, "Not Valid").is_err()); + + let no_boundary = scan(Knowable::No, existing.clone()); + let e = deploy_refusal(&no_boundary, "prod").unwrap_err(); + assert!(e.contains("permissions boundary"), "{e}"); + // A caller who can't tell is let through (the apply itself would fail). + assert!(deploy_refusal(&scan(Knowable::Unknown, existing), "prod").is_ok()); +} + +#[test] +fn update_reuses_the_recorded_network() { + assert_eq!( + network_from_record(&info("dev", "public", true), None).unwrap(), + NetworkChoice::Public + ); + assert_eq!( + network_from_record(&info("dev", "private-nat", false), None).unwrap(), + NetworkChoice::PrivateNat + ); + let existing = info("dev", "existing", false); + assert_eq!( + network_from_record(&existing, Some("vpc-0123456789abcdef0")).unwrap(), + NetworkChoice::Existing { + vpc_id: "vpc-0123456789abcdef0".into(), + subnet_ids: existing.subnet_ids.clone(), + assign_public_ip: false, + } + ); + // The VPC isn't in the record: without it from the state, refuse rather + // than guess. + assert!(network_from_record(&existing, None).is_err()); + assert!(network_from_record(&existing, Some("not-a-vpc")).is_err()); + assert!(network_from_record(&info("dev", "carrier-pigeon", false), None).is_err()); + // And it round-trips into the module's variables unchanged. + let vars = environment_vars( + "dev", + REGION, + &network_from_record(&existing, Some("vpc-0123456789abcdef0")).unwrap(), + ) + .unwrap(); + assert_eq!(vars["network_mode"], "existing"); + assert_eq!(vars["subnet_ids"], json!(existing.subnet_ids)); +} + +#[test] +fn the_vpc_is_read_from_the_state_outputs() { + let state = json!({ + "version": 4, + "outputs": { "vpc_id": { "value": "vpc-0123456789abcdef0", "type": "string" } }, + }) + .to_string(); + assert_eq!( + vpc_id_from_state(state.as_bytes()).as_deref(), + Some("vpc-0123456789abcdef0") + ); + assert_eq!(vpc_id_from_state(b"{}"), None); + assert_eq!(vpc_id_from_state(b"not json"), None); + let hostile = json!({"outputs": {"vpc_id": {"value": "vpc-1\"; rm -rf /"}}}).to_string(); + assert_eq!(vpc_id_from_state(hostile.as_bytes()), None); +} + +#[test] +fn bundles_contain_the_module_backend_vars_and_readme() { + let dir = tempfile::tempdir().unwrap(); + let ok = owner_key(USER_ID); + let d = Deployment::User { env: "dev", owner_key: &ok }; + let vars = onboarding_vars("dev", REGION, USER_ID); + let written = write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap(); + + for (name, text) in module_files(ModuleKind::UserOnboard) { + assert_eq!(&fs::read_to_string(dir.path().join(name)).unwrap(), text); + assert!(written.contains(&name.to_string())); + } + let bucket = format!("triple-c-tfstate-{ACCOUNT}-{REGION}"); + let key = state_key(d); + let backend = fs::read_to_string(dir.path().join("backend.hcl")).unwrap(); + assert!(backend.contains(&format!("bucket = \"{bucket}\"")), "{backend}"); + assert!(backend.contains(&format!("key = \"{key}\"")), "{backend}"); + assert!(backend.contains("use_lockfile = true"), "{backend}"); + let tfvars: Value = + serde_json::from_str(&fs::read_to_string(dir.path().join("terraform.tfvars.json")).unwrap()) + .unwrap(); + assert_eq!(tfvars, vars); + + let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap(); + for needle in [ + bucket.as_str(), + key.as_str(), + ACCOUNT, + REGION, + "tofu init -backend-config=backend.hcl", + "tofu apply", + "terraform providers lock", + "registry.opentofu.org", + "1.10", + "aws s3api create-bucket", + ] { + assert!(readme.contains(needle), "README lacks {needle:?}:\n{readme}"); + } + // Outside us-east-1 the bucket needs a location constraint. + assert!(readme.contains(&format!("LocationConstraint={REGION}")), "{readme}"); + + // Writing again over our own files is fine... + write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap(); + // ...but an unrelated file (or folder) means the wrong folder. + fs::write(dir.path().join("notes.txt"), "mine").unwrap(); + let e = write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap_err(); + assert!(e.contains("notes.txt"), "{e}"); + assert_eq!(fs::read_to_string(dir.path().join("notes.txt")).unwrap(), "mine"); + fs::remove_file(dir.path().join("notes.txt")).unwrap(); + fs::create_dir(dir.path().join(".terraform")).unwrap(); + assert!(write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).is_err()); +} + +#[test] +fn a_us_east_1_bundle_creates_its_bucket_without_a_location_constraint() { + let dir = tempfile::tempdir().unwrap(); + let vars = environment_vars("dev", "us-east-1", &NetworkChoice::Public).unwrap(); + write_bundle(dir.path(), Deployment::Environment("dev"), ACCOUNT, "us-east-1", &vars).unwrap(); + let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap(); + assert!(readme.contains("aws s3api create-bucket"), "{readme}"); + assert!(!readme.contains("LocationConstraint"), "{readme}"); + assert!(readme.contains("tofu destroy"), "{readme}"); + for (name, _) in module_files(ModuleKind::Environment) { + assert!(dir.path().join(name).is_file(), "{name}"); + } +} + +#[test] +fn owner_ids_must_look_like_an_aws_user_id() { + for ok in ["AIDAEXAMPLEUSERID1234", "AROAEXAMPLEID12345678:alice", "AROA1234567890ABCDEF:a.b@c_d-e+f=g,h"] { + assert_eq!(validate_owner_id(ok).unwrap(), ok); + } + for bad in [ + "", + "123456789012", + "aidaexampleuserid1234", + "AROAEXAMPLEID12345678:", + "AROAEXAMPLEID12345678:a b", + "AROAEXAMPLEID12345678:a:b", + "AROAEXAMPLEID12345678:\"", + &format!("AROAEXAMPLEID12345678:{}", "a".repeat(65)), + &"A".repeat(129), + ] { + assert!(validate_owner_id(bad).is_err(), "{bad:?}"); + } +} + +#[test] +fn server_side_rechecks_follow_the_detected_actions() { + use EnvironmentAction::*; + let s = scan( + Knowable::Yes, + vec![ + env("broken", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]), + env( + "working", + EnvironmentState::Incomplete { lock: Some(lock(9000, false)) }, + vec![Repair, Unlock], + ), + env("ready", EnvironmentState::Ready, vec![Join]), + env("busy", EnvironmentState::Deploying { lock: Some(lock(10, true)) }, vec![Follow]), + ], + ); + assert!(require_action(&s, "broken", Destroy).is_ok()); + assert!(require_action(&s, "working", Destroy).is_err(), "never destroy a working env (D6)"); + assert!(require_action(&s, "ready", Destroy).is_err()); + assert!(require_action(&s, "missing", Destroy).is_err()); + assert!(require_action(&s, "working", Repair).is_ok()); + assert!(require_action(&s, "ready", Update).is_err()); + + assert!(unlock_refusal(&s, "working").is_ok()); + assert!(unlock_refusal(&s, "broken").is_err(), "no lock to remove"); + assert!(unlock_refusal(&s, "busy").is_err(), "a local runner holds it"); + let local_stale = scan( + Knowable::Yes, + vec![env( + "x", + EnvironmentState::Incomplete { lock: Some(lock(9000, true)) }, + vec![Unlock], + )], + ); + assert!(unlock_refusal(&local_stale, "x").is_err()); +} + +#[test] +fn onboarding_needs_a_joinable_environment_and_the_boundary() { + use EnvironmentAction::*; + let mut ready = env("dev", EnvironmentState::Ready, vec![Join]); + ready.onboarding = Some(OnboardingState::NotJoined); + let mut busy = env("busy", EnvironmentState::Ready, vec![Join]); + busy.onboarding = Some(OnboardingState::InProgress); + let half = env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]); + let s = scan(Knowable::Yes, vec![ready.clone(), busy, half]); + assert!(onboard_refusal(&s, "dev").is_ok()); + assert!(onboard_refusal(&s, "busy").is_err()); + assert!(onboard_refusal(&s, "half").is_err()); + assert!(onboard_refusal(&s, "nope").is_err()); + assert!(onboard_refusal(&scan(Knowable::No, vec![ready]), "dev").is_err()); +} + +#[test] +fn progress_events_have_the_documented_shape() { + let p = DeployProgress { + op_id: "op".into(), + state_key: "boundary/terraform.tfstate".into(), + events: vec![], + done: false, + cancellable: true, + result: None, + }; + let v = serde_json::to_value(&p).unwrap(); + assert_eq!( + v, + json!({"op_id": "op", "state_key": "boundary/terraform.tfstate", "events": [], "done": false, "cancellable": true}) + ); + let done = DeployProgress { + done: true, + result: Some(TofuOutcome { ok: false, exit_code: 1, summary: Some("boom".into()) }), + ..p + }; + let v = serde_json::to_value(&done).unwrap(); + assert_eq!(v["result"], json!({"ok": false, "exit_code": 1, "summary": "boom"})); +} + +#[test] +fn an_already_running_start_names_the_deployment_and_suggests_follow() { + let e = start_error(StartError::AlreadyRunning, "environment \"dev\""); + let msg = e.to_string(); + assert!(msg.contains("already running for environment \"dev\""), "{msg}"); + assert!(msg.contains("Follow"), "{msg}"); + let inner = AwsError::KeysMissing; + assert_eq!(start_error(StartError::Failed(inner.clone()), "x"), inner); +} diff --git a/app/src-tauri/src/commands/host_commands.rs b/app/src-tauri/src/commands/host_commands.rs index 03e1cd4..823a0d8 100644 --- a/app/src-tauri/src/commands/host_commands.rs +++ b/app/src-tauri/src/commands/host_commands.rs @@ -118,7 +118,7 @@ async fn real_identity(c: Credentials) -> Result { caller_identity(&cfg).await } -fn aws_conn(id: &str, settings: &AppSettings) -> Result { +pub(crate) fn aws_conn(id: &str, settings: &AppSettings) -> Result { check_id(id)?; let conn = settings .hosts diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index b7660e0..44678b7 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod auth_bridge_commands; pub mod auth_token_commands; pub mod aws_commands; pub mod docker_commands; +pub mod environment_commands; pub mod file_commands; pub mod file_viewer_commands; pub mod gateway_commands; diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 3c12540..fe3243d 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -610,6 +610,17 @@ pub fn run() { commands::host_commands::aws_connection_sso_login, commands::host_commands::check_aws_permissions, commands::host_commands::aws_policy_document, + commands::environment_commands::detect_aws_environments, + commands::environment_commands::deploy_aws_environment, + commands::environment_commands::update_aws_environment, + commands::environment_commands::repair_aws_environment, + commands::environment_commands::destroy_incomplete_aws_environment, + commands::environment_commands::unlock_aws_state, + commands::environment_commands::follow_aws_deployment, + commands::environment_commands::cancel_aws_deployment, + commands::environment_commands::onboard_aws_user, + commands::environment_commands::deploy_aws_permissions_boundary, + commands::environment_commands::save_aws_admin_bundle, commands::settings_commands::detect_host_timezone, // Settings export/import commands::settings_export_commands::export_settings, diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index b03b9bd..1918d22 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport, HostConnection, CallerIdentity, PermissionReport, PolicyFile, Tier } from "./types"; +import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport, HostConnection, CallerIdentity, PermissionReport, PolicyFile, Tier, EnvironmentScan, NetworkChoice, ModuleKind } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -79,6 +79,45 @@ export const checkAwsPermissions = (id: string, region?: string) => /** Every policy file `tier` needs, in order: attach all of them. */ export const awsPolicyDocument = (tier: Tier) => invoke("aws_policy_document", { tier }); + +// AWS environments (OpenTofu). Reject with an `AwsError`. Starting commands resolve to an op id; +// progress arrives on the `AWS_DEPLOY_PROGRESS_EVENT` event (`DeployProgress`, in ./types) under that op id. +export const detectAwsEnvironments = (connectionId: string, region: string) => + invoke("detect_aws_environments", { connectionId, region }); +export const deployAwsEnvironment = (connectionId: string, region: string, name: string, network: NetworkChoice) => + invoke("deploy_aws_environment", { connectionId, region, name, network }); +export const updateAwsEnvironment = (connectionId: string, region: string, name: string) => + invoke("update_aws_environment", { connectionId, region, name }); +/** `network` is required only when the environment has no record. */ +export const repairAwsEnvironment = (connectionId: string, region: string, name: string, network?: NetworkChoice) => + invoke("repair_aws_environment", { connectionId, region, name, network: network ?? null }); +export const destroyIncompleteAwsEnvironment = (connectionId: string, region: string, name: string) => + invoke("destroy_incomplete_aws_environment", { connectionId, region, name }); +export const unlockAwsState = (connectionId: string, region: string, stateKey: string) => + invoke("unlock_aws_state", { connectionId, region, stateKey }); +export const followAwsDeployment = (connectionId: string, region: string, stateKey: string) => + invoke("follow_aws_deployment", { connectionId, region, stateKey }); +export const cancelAwsDeployment = (connectionId: string, region: string, stateKey: string) => + invoke("cancel_aws_deployment", { connectionId, region, stateKey }); +export const onboardAwsUser = (connectionId: string, region: string, environment: string) => + invoke("onboard_aws_user", { connectionId, region, environment }); +export const deployAwsPermissionsBoundary = (connectionId: string, region: string) => + invoke("deploy_aws_permissions_boundary", { connectionId, region }); +/** Opens a folder picker in Rust; resolves to the folder written, or null if dismissed. */ +export const saveAwsAdminBundle = ( + connectionId: string, + region: string, + kind: ModuleKind, + options: { environment?: string; ownerId?: string; network?: NetworkChoice } = {}, +) => + invoke("save_aws_admin_bundle", { + connectionId, + region, + kind, + environment: options.environment ?? null, + ownerId: options.ownerId ?? null, + network: options.network ?? null, + }); /** Check a corporate CA path and report what would be installed. Never * rejects for a bad path — the reason comes back in `error`. */ export const inspectCaCertPath = (path: string) => diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 07c2f92..d6c661f 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -1191,3 +1191,107 @@ export interface PermissionReport { /** `[workspace_user, environment_admin]`. */ tiers: TierResult[]; } + +// ── AWS environments (phase 4; `aws::environments`, `commands::environment_commands`) ── + +/** How an environment's tasks reach the network (D4). `public` is the default and costs $0. */ +export type NetworkChoice = + | { mode: "public" } + | { mode: "private-nat" } + | { mode: "existing"; vpc_id: string; subnet_ids: string[]; assign_public_ip: boolean }; + +export type ModuleKind = "environment" | "user_onboard" | "permissions_boundary"; + +/** An environment's validated SSM record (schema 1). */ +export interface EnvironmentInfo { + schema: number; + name: string; + template_version: number; + cluster_arn: string; + security_group_id: string; + subnet_ids: string[]; + assign_public_ip: boolean; + kms_key_arn: string; + log_group: string; + network_mode: "public" | "private-nat" | "existing"; + tenancy: string; +} + +/** A held state lock. `who`/`created`/`operation` are sanitized untrusted text. */ +export interface LockInfo { + who: string; + created: string; + operation: string; + age_secs: number; + local_process: boolean; +} + +export type EnvironmentState = + | { state: "ready" } + | { state: "deploying"; lock: LockInfo | null } + | { state: "update_available" } + | { state: "incomplete"; lock: LockInfo | null } + | { state: "externally_managed" } + | { state: "unreadable_record"; reason: string }; + +export type EnvironmentAction = "join" | "update" | "repair" | "destroy" | "follow" | "unlock"; + +export type OnboardingState = + | { state: "joined" } + | { state: "not_joined" } + | { state: "in_progress" } + | { state: "incomplete" }; + +export type Knowable = "yes" | "no" | "unknown"; + +export interface DetectedEnvironment { + name: string; + template_version: number | null; + state: EnvironmentState; + actions: EnvironmentAction[]; + info: EnvironmentInfo | null; + onboarding: OnboardingState | null; +} + +export interface EnvironmentScan { + region: string; + account: string; + /** The caller's `aws:userid`. */ + owner_id: string; + owner_key: string; + state_bucket: Knowable; + boundary: Knowable; + environments: DetectedEnvironment[]; +} + +/** One line of an OpenTofu run's log. */ +export interface TofuEvent { + ts: string; + level: string; + /** The `-json` message `type`, or `"log"` for plain text. */ + kind: string; + resource: string | null; + action: string | null; + message: string; +} + +export interface TofuOutcome { + ok: boolean; + exit_code: number; + summary: string | null; +} + +/** The Tauri event a deploy's progress is emitted on (`environment_commands::PROGRESS_EVENT`). */ +export const AWS_DEPLOY_PROGRESS_EVENT = "aws-deploy-progress"; + +/** Payload of the `aws-deploy-progress` event. `result` is set only when `done`. */ +export interface DeployProgress { + op_id: string; + /** `environments//terraform.tfstate`, `users///terraform.tfstate` or `boundary/terraform.tfstate`. */ + state_key: string; + events: TofuEvent[]; + done: boolean; + /** False when Cancel can't reach the run (its PID is unknown, or Windows). */ + cancellable: boolean; + result?: TofuOutcome; +} -- 2.52.0 From 964ab993c88bd4e7f7bb1d1ee893de41ce6a5483 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 22:38:18 -0800 Subject: [PATCH 15/24] fix(aws): gate progress behind the op id, conditional unlock, launch slots Followers hold their first event until the command returns its op id; a re-follow replays the whole log. Unlock re-reads the lock and deletes it only if its ETag is unchanged and it is still stale. A launch claims its work folder so overlapping launches and follows are refused. Repair, update and destroy refuse under a held lock; destroy's guard is a tested helper; the boundary deploy refuses a boundary that exists without state here; the state read for a VPC id is capped while streaming. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/environments.rs | 2 +- .../src/commands/environment_commands.rs | 441 ++++++++++++++---- .../commands/environment_commands/tests.rs | 141 ++++++ app/src/lib/tauri-commands.ts | 8 +- app/src/lib/types.ts | 2 +- 5 files changed, 496 insertions(+), 98 deletions(-) diff --git a/app/src-tauri/src/aws/environments.rs b/app/src-tauri/src/aws/environments.rs index 9521132..260e73a 100644 --- a/app/src-tauri/src/aws/environments.rs +++ b/app/src-tauri/src/aws/environments.rs @@ -41,7 +41,7 @@ const MAX_UNTRUSTED_BYTES: usize = 4096; const MAX_LOCK_STRING_CHARS: usize = 128; /// A lock younger than this is a deploy in progress somewhere else; an older /// one is an apply that died holding it. -const LIVE_LOCK_SECS: u64 = 2 * 3600; +pub const LIVE_LOCK_SECS: u64 = 2 * 3600; const RECORD_SCHEMA: u64 = 1; /// Bounds on what one scan lists, so a bucket or parameter path full of /// junk can't make it unbounded. diff --git a/app/src-tauri/src/commands/environment_commands.rs b/app/src-tauri/src/commands/environment_commands.rs index fd8b9eb..5833413 100644 --- a/app/src-tauri/src/commands/environment_commands.rs +++ b/app/src-tauri/src/commands/environment_commands.rs @@ -8,9 +8,12 @@ //! offered. //! //! Applies are detached (D10). Each started or followed run gets one task -//! here that follows its log and emits [`PROGRESS_EVENT`] batches, then a -//! final `done` with the result. A deployment is followed by at most one task -//! per process ([`FOLLOWERS`]). +//! here that follows its log from byte 0 and emits [`PROGRESS_EVENT`] +//! batches, then a final `done` with the result. The task emits nothing +//! until the command is about to return its op id ([`Gate`]), so a listener +//! registered before the invoke sees everything. Per work folder this +//! process keeps one [`Slot`]: a launch in progress (which refuses a second +//! launch or a follow) or the one follower (a re-follow replaces it). //! //! Credentials: a keys connection's keys are read from the keychain here //! and go only into [`tofu::child_env`]; they are never logged, written or @@ -31,8 +34,11 @@ use serde::Serialize; use serde_json::Value; use tauri::{AppHandle, Emitter, State}; +use aws_sdk_s3::error::ProvideErrorMetadata; +use tokio::sync::oneshot; + use crate::aws::environments::{ - ensure_state_bucket, scan, DetectedEnvironment, EnvironmentAction, EnvironmentInfo, + ensure_state_bucket, scan, LIVE_LOCK_SECS, DetectedEnvironment, EnvironmentAction, EnvironmentInfo, EnvironmentScan, EnvironmentState, Knowable, OnboardingState, SdkDetectApi, }; use crate::aws::tofu::{ @@ -55,7 +61,7 @@ const FOLLOW_POLL: Duration = Duration::from_millis(500); /// whole log at once). const MAX_EVENTS_PER_BATCH: usize = 200; /// A state object read back for its outputs larger than this is refused. -const MAX_STATE_BYTES: usize = 32 * 1024 * 1024; +const MAX_STATE_BYTES: usize = 1024 * 1024; fn other(message: impl Into) -> AwsError { AwsError::Other { @@ -275,6 +281,81 @@ pub(crate) fn start_error(e: StartError, label: &str) -> AwsError { } } +/// A held state lock on the environment, if detection saw one. +fn held_lock(env: &DetectedEnvironment) -> bool { + match &env.state { + EnvironmentState::Deploying { .. } => true, + EnvironmentState::Incomplete { lock } => lock.is_some(), + _ => false, + } +} + +/// Update and Repair: the action is offered and no lock is held. A re-apply +/// under a held lock would only fail inside the detached run. +pub(crate) fn reapply_refusal<'a>( + scan: &'a EnvironmentScan, + name: &str, + action: EnvironmentAction, +) -> Result<&'a DetectedEnvironment, String> { + let env = require_action(scan, name, action)?; + if held_lock(env) { + return Err(format!( + "Environment \"{name}\"'s state is locked by an interrupted run; Unlock it first" + )); + } + Ok(env) +} + +/// Destroy from the app (D6, R12): offered by detection, no valid record +/// (a working environment is an admin's `tofu destroy`), and no held lock. +pub(crate) fn destroy_refusal<'a>( + scan: &'a EnvironmentScan, + name: &str, +) -> Result<&'a DetectedEnvironment, String> { + let env = require_action(scan, name, EnvironmentAction::Destroy)?; + if env.info.is_some() { + return Err(format!( + "Environment \"{name}\" has a valid record, so it may be working; it can't be \ + destroyed from the app (an admin destroys it from a bundle)" + )); + } + if held_lock(env) { + return Err(format!( + "Environment \"{name}\"'s state is locked by an interrupted run; Unlock it first" + )); + } + Ok(env) +} + +/// The lock object about to be deleted must still be stale, by its own +/// `LastModified` read just now. +pub(crate) fn stale_lock_refusal(age_secs: u64) -> Result<(), String> { + if age_secs < LIVE_LOCK_SECS { + Err("The state lock is recent, so a deploy may be running; detect again".into()) + } else { + Ok(()) + } +} + +/// S3's answer to a conditional delete whose ETag no longer matches. +pub(crate) fn is_precondition_failed(code: Option<&str>, status: Option) -> bool { + code == Some("PreconditionFailed") || status == Some(412) +} + +/// The boundary policy is account-wide; its state is per region. A boundary +/// that exists without state in this region's bucket was deployed from +/// another region or by hand, and an apply here would fail on the name. +/// `has_state` is `None` when the caller can't tell. +pub(crate) fn boundary_refusal(boundary: &Knowable, has_state: Option) -> Result<(), String> { + if *boundary == Knowable::Yes && has_state == Some(false) { + Err("The permissions boundary already exists in this account (deployed from another \ + region or by hand), so it can't be deployed from here" + .into()) + } else { + Ok(()) + } +} + fn module_kind(d: Deployment) -> ModuleKind { match d { Deployment::Environment(_) => ModuleKind::Environment, @@ -437,19 +518,113 @@ pub struct DeployProgress { pub result: Option, } +/// Holds a follower's first emit back until the command that started it is +/// about to return its op id, so no event (a finished run's whole replay +/// and its `done` included) can arrive before the caller knows the op id. +/// Opening it, or dropping it, releases the follower. +pub(crate) struct Gate(oneshot::Sender<()>); + +impl Gate { + pub(crate) fn new() -> (Gate, oneshot::Receiver<()>) { + let (tx, rx) = oneshot::channel(); + (Gate(tx), rx) + } + + /// Call immediately before the command's final `Ok(op_id)`. + pub(crate) fn open(self) { + let _ = self.0.send(()); + } +} + +/// A follower: wait for the gate, follow the log from byte 0 emitting +/// batches of at most [`MAX_EVENTS_PER_BATCH`], then emit `done` with the +/// result (a follow error is a failed result). +pub(crate) async fn follow_and_report( + gate: oneshot::Receiver<()>, + wd: Workdir, + base: DeployProgress, + poll: Duration, + mut emit: impl FnMut(&DeployProgress), +) { + // A dropped gate means the command returned without opening it. + let _ = gate.await; + let outcome = tofu::follow(&wd, poll, |events| { + for chunk in events.chunks(MAX_EVENTS_PER_BATCH) { + emit(&DeployProgress { + events: chunk.to_vec(), + ..base.clone() + }); + } + }) + .await + .unwrap_or_else(|e| TofuOutcome { + ok: false, + exit_code: -1, + summary: Some(e.to_string()), + }); + emit(&DeployProgress { + done: true, + cancellable: false, + result: Some(outcome), + ..base + }); +} + // --------------------------------------------------------------------------- // Plumbing -/// Work folders being followed by this process, with their op id. -static FOLLOWERS: OnceLock>> = OnceLock::new(); +/// What this process is doing with a work folder. +enum Slot { + /// A launch is between its checks and its start; nothing else may + /// materialize, start or follow the folder. + Launching, + /// One task follows the folder's run under `op_id`. + Following { + op_id: String, + task: tauri::async_runtime::JoinHandle<()>, + }, +} -fn followers() -> std::sync::MutexGuard<'static, HashMap> { - FOLLOWERS +static SLOTS: OnceLock>> = OnceLock::new(); + +fn slots() -> std::sync::MutexGuard<'static, HashMap> { + SLOTS .get_or_init(Default::default) .lock() .unwrap_or_else(|p| p.into_inner()) } +/// The folder's `Launching` slot, released on every exit path unless a +/// follower has replaced it. +struct LaunchGuard(PathBuf); + +impl LaunchGuard { + fn take(dir: &Path, label: &str) -> Result { + let mut map = slots(); + match map.get(dir) { + Some(Slot::Launching) => Err(other(format!( + "A deployment is starting for {label}; wait for it" + ))), + Some(Slot::Following { .. }) => Err(other(format!( + "The run for {label} is still being followed; wait for it to finish" + ))), + None => { + map.insert(dir.to_path_buf(), Slot::Launching); + Ok(LaunchGuard(dir.to_path_buf())) + } + } + } +} + +impl Drop for LaunchGuard { + fn drop(&mut self) { + let mut map = slots(); + if matches!(map.get(&self.0), Some(Slot::Launching)) { + map.remove(&self.0); + } + } +} + /// `/triple-c/aws/`: OpenTofu, its plugin cache and the work /// folders. fn work_root() -> Result { @@ -523,21 +698,49 @@ async fn recorded_vpc_id(ctx: &Ctx, env: &str) -> Result, AwsErro .send() .await .map_err(|e| crate::aws::classify_sdk_error("s3:GetObject", &e))?; + let too_large = || other("The environment's state is implausibly large"); if obj .content_length() .is_some_and(|n| n < 0 || n as usize > MAX_STATE_BYTES) { - return Err(other("The environment's state is implausibly large")); + return Err(too_large()); } - let bytes = obj - .body - .collect() + // Capped while reading too: the length header may be absent. + let mut body = obj.body; + let mut bytes = Vec::new(); + while let Some(chunk) = body + .try_next() .await .map_err(|e| other(format!("Could not read the environment's state: {e}")))? - .into_bytes(); + { + if bytes.len() + chunk.len() > MAX_STATE_BYTES { + return Err(too_large()); + } + bytes.extend_from_slice(&chunk); + } Ok(vpc_id_from_state(&bytes)) } +/// Whether this region's bucket holds the boundary's state; `None` when +/// the caller may not look. +async fn boundary_state_exists(ctx: &Ctx) -> Result, AwsError> { + let res = aws_sdk_s3::Client::new(&ctx.cfg) + .head_object() + .bucket(ctx.bucket()) + .key(state_key(Deployment::Boundary)) + .expected_bucket_owner(&ctx.who.account) + .send() + .await; + match res { + Ok(_) => Ok(Some(true)), + Err(e) => match e.raw_response().map(|r| r.status().as_u16()) { + Some(404) => Ok(Some(false)), + Some(403) => Ok(None), + _ => Err(crate::aws::classify_sdk_error("s3:GetObject", &e)), + }, + } +} + /// The network to re-apply an environment with: the recorded one. async fn recorded_network(ctx: &Ctx, info: &EnvironmentInfo) -> Result { let vpc = if info.network_mode == "existing" { @@ -558,79 +761,65 @@ fn emit_progress(app: &AppHandle, p: &DeployProgress) { } } -/// Follow `wd` in a task of its own, unless this process already does; the -/// op id of whichever task follows it. -fn spawn_follower(app: AppHandle, wd: Workdir, key: String, can_cancel: bool) -> String { - let op_id = { - let mut map = followers(); - if let Some(existing) = map.get(&wd.dir) { - return existing.clone(); - } - let op_id = uuid::Uuid::new_v4().to_string(); - map.insert(wd.dir.clone(), op_id.clone()); - op_id +/// Start the folder's follower under a new op id, replacing (aborting) any +/// follower this process already has for it, so a re-follow (a reloaded +/// window) replays the whole log. The follower emits nothing until the +/// returned [`Gate`] is opened. The caller has checked the folder isn't +/// launching, or holds its [`LaunchGuard`]. +fn install_follower(app: AppHandle, wd: Workdir, key: String, can_cancel: bool) -> (String, Gate) { + let op_id = uuid::Uuid::new_v4().to_string(); + let (gate, rx) = Gate::new(); + let base = DeployProgress { + op_id: op_id.clone(), + state_key: key, + events: Vec::new(), + done: false, + cancellable: can_cancel, + result: None, }; + let dir = wd.dir.clone(); + let mut map = slots(); + // Spawned under the lock, so the task's own clean-up (which takes it) + // can't run before its slot is in place. let id = op_id.clone(); - tauri::async_runtime::spawn(async move { - let base = DeployProgress { - op_id: id, - state_key: key, - events: Vec::new(), - done: false, - cancellable: can_cancel, - result: None, - }; - let outcome = tofu::follow(&wd, FOLLOW_POLL, |events| { - for chunk in events.chunks(MAX_EVENTS_PER_BATCH) { - emit_progress( - &app, - &DeployProgress { - events: chunk.to_vec(), - ..base.clone() - }, - ); - } - }) - .await - .unwrap_or_else(|e| TofuOutcome { - ok: false, - exit_code: -1, - summary: Some(e.to_string()), - }); - followers().remove(&wd.dir); - emit_progress( - &app, - &DeployProgress { - done: true, - cancellable: false, - result: Some(outcome), - ..base - }, - ); + let task = tauri::async_runtime::spawn(async move { + let dir = wd.dir.clone(); + follow_and_report(rx, wd, base, FOLLOW_POLL, |p| emit_progress(&app, p)).await; + let mut map = slots(); + if matches!(map.get(&dir), Some(Slot::Following { op_id, .. }) if *op_id == id) { + map.remove(&dir); + } }); - op_id + if let Some(Slot::Following { task: old, .. }) = map.insert( + dir, + Slot::Following { + op_id: op_id.clone(), + task, + }, + ) { + old.abort(); + } + (op_id, gate) } /// Ensure OpenTofu and the state bucket, write the work folder and start a -/// detached `init` + `op`; returns the op id its progress is emitted under. +/// detached `init` + `op`. Returns the op id its progress is emitted under +/// and the gate the command opens just before returning it. async fn launch( app: AppHandle, ctx: &Ctx, key: &DeploymentKey, vars: &Value, op: TofuOp, -) -> Result { +) -> Result<(String, Gate), AwsError> { let d = key.deployment(); let label = key.label(); let wd = ctx.workdir(d)?; if tofu::is_running(&wd) { return Err(start_error(StartError::AlreadyRunning, &label)); } - if followers().contains_key(&wd.dir) { - return Err(other(format!( - "The previous run for {label} is still finishing; try again in a moment" - ))); - } + // Held until a follower replaces it, or released on any error below. + let _launching = LaunchGuard::take(&wd.dir, &label)?; let tofu_bin = tofu::ensure_tofu(&ctx.root).await?; // StateBucketTaken is passed on as it is. let bucket = ensure_state_bucket(&ctx.cfg, &ctx.who.account, &ctx.region).await?; @@ -646,7 +835,6 @@ async fn launch( AwsAuth::Profile { .. } => None, }; let env = tofu::child_env(&ctx.conn, keys.as_ref(), &ctx.root, std::env::vars())?; - drop(keys); let start_wd = wd.clone(); let started = tokio::task::spawn_blocking(move || { @@ -656,7 +844,15 @@ async fn launch( .map_err(|e| other(format!("The deploy could not be started: {e}")))? .map_err(|e| start_error(e, &label))?; let can_cancel = cfg!(unix) && started.pid.is_some(); - Ok(spawn_follower(app, wd, state_key(d), can_cancel)) + Ok(install_follower(app, wd, state_key(d), can_cancel)) +} + +/// Open a launch's gate and hand back its op id: the last thing a command +/// does, so its events follow the op id to the caller. +fn opened(launched: Result<(String, Gate), AwsError>) -> Result { + let (op_id, gate) = launched?; + gate.open(); + Ok(op_id) } // --------------------------------------------------------------------------- @@ -690,7 +886,7 @@ pub async fn deploy_aws_environment( deploy_refusal(&found, &name).map_err(other)?; let vars = environment_vars(&name, &ctx.region, &network).map_err(other)?; let key = DeploymentKey::Environment(name); - launch(app, &ctx, &key, &vars, TofuOp::Apply).await + opened(launch(app, &ctx, &key, &vars, TofuOp::Apply).await) } /// Re-apply an environment at the app's module version, with the network @@ -705,7 +901,7 @@ pub async fn update_aws_environment( ) -> Result { let ctx = context(&state, &connection_id, ®ion).await?; let found = detect(&ctx).await?; - let env = require_action(&found, &name, EnvironmentAction::Update).map_err(other)?; + let env = reapply_refusal(&found, &name, EnvironmentAction::Update).map_err(other)?; let info = env .info .as_ref() @@ -713,7 +909,7 @@ pub async fn update_aws_environment( let network = recorded_network(&ctx, info).await?; let vars = environment_vars(&env.name, &ctx.region, &network).map_err(other)?; let key = DeploymentKey::Environment(env.name.clone()); - launch(app, &ctx, &key, &vars, TofuOp::Apply).await + opened(launch(app, &ctx, &key, &vars, TofuOp::Apply).await) } /// Re-apply an Incomplete environment. With a valid record its network is @@ -730,7 +926,7 @@ pub async fn repair_aws_environment( ) -> Result { let ctx = context(&state, &connection_id, ®ion).await?; let found = detect(&ctx).await?; - let env = require_action(&found, &name, EnvironmentAction::Repair).map_err(other)?; + let env = reapply_refusal(&found, &name, EnvironmentAction::Repair).map_err(other)?; let network = match (&env.info, network) { (Some(info), _) => recorded_network(&ctx, info).await?, (None, Some(n)) => n, @@ -742,12 +938,12 @@ pub async fn repair_aws_environment( }; let vars = environment_vars(&env.name, &ctx.region, &network).map_err(other)?; let key = DeploymentKey::Environment(env.name.clone()); - launch(app, &ctx, &key, &vars, TofuOp::Apply).await + opened(launch(app, &ctx, &key, &vars, TofuOp::Apply).await) } /// Destroy an incomplete deployment: only when detection offers Destroy, -/// i.e. there is no valid record (D6). A working environment is destroyed -/// by an admin from a bundle. +/// there is no valid record (D6) and no lock is held ([`destroy_refusal`]). +/// A working environment is destroyed by an admin from a bundle. #[tauri::command] pub async fn destroy_incomplete_aws_environment( connection_id: String, @@ -758,20 +954,19 @@ pub async fn destroy_incomplete_aws_environment( ) -> Result { let ctx = context(&state, &connection_id, ®ion).await?; let found = detect(&ctx).await?; - let env = require_action(&found, &name, EnvironmentAction::Destroy).map_err(other)?; - if env.info.is_some() { - return Err(other("A working environment can't be destroyed from the app")); - } + let env = destroy_refusal(&found, &name).map_err(other)?; // A destroy plans the removal of everything in state whatever the // network variables say; they only have to be valid. let vars = environment_vars(&env.name, &ctx.region, &NetworkChoice::Public).map_err(other)?; let key = DeploymentKey::Environment(env.name.clone()); - launch(app, &ctx, &key, &vars, TofuOp::Destroy).await + opened(launch(app, &ctx, &key, &vars, TofuOp::Destroy).await) } /// Delete an environment's stale `.tflock`. Only an environment's lock, -/// only when detection offers Unlock, the lock is stale and no runner on -/// this machine holds the deployment. +/// only when detection offers Unlock and no runner on this machine holds +/// the deployment. The delete is conditional on the ETag of the lock object +/// as just re-read (and re-checked stale), so a lock taken since is never +/// removed; there is no unconditional fallback. #[tauri::command] pub async fn unlock_aws_state( connection_id: String, @@ -790,21 +985,60 @@ pub async fn unlock_aws_state( return Err(other("A deploy on this machine holds the lock")); } let lock_key = format!("{state_key}.tflock"); - aws_sdk_s3::Client::new(&ctx.cfg) - .delete_object() + let s3 = aws_sdk_s3::Client::new(&ctx.cfg); + let head = match s3 + .head_object() .bucket(ctx.bucket()) .key(&lock_key) .expected_bucket_owner(&ctx.who.account) .send() .await - .map_err(|e| crate::aws::classify_sdk_error("s3:DeleteObject", &e))?; + { + Ok(h) => h, + Err(e) if e.raw_response().map(|r| r.status().as_u16()) == Some(404) => { + return Err(other("The state lock is already gone; detect again")); + } + Err(e) => return Err(crate::aws::classify_sdk_error("s3:GetObject", &e)), + }; + let etag = head + .e_tag() + .filter(|t| !t.is_empty()) + .ok_or_else(|| other("The state lock has no ETag, so it can't be removed safely"))? + .to_string(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let age = head + .last_modified() + .map(|t| now.saturating_sub(t.secs()).max(0) as u64) + .ok_or_else(|| other("The state lock's age is unknown, so it can't be removed safely"))?; + stale_lock_refusal(age).map_err(other)?; + if let Err(e) = s3 + .delete_object() + .bucket(ctx.bucket()) + .key(&lock_key) + .if_match(&etag) + .expected_bucket_owner(&ctx.who.account) + .send() + .await + { + let status = e.raw_response().map(|r| r.status().as_u16()); + if is_precondition_failed(e.code(), status) { + return Err(other( + "The state lock changed since it was checked; detect again", + )); + } + return Err(crate::aws::classify_sdk_error("s3:DeleteObject", &e)); + } log::info!("Removed the stale state lock {lock_key}"); Ok(()) } /// Reattach to a run on this machine (in progress, or finished with its -/// log still there): replays its log as progress. Returns the op id; a run -/// already followed keeps its follower and op id. +/// log still there): replays its whole log as progress under a new op id. +/// A follower this process already had for it is replaced, so a reloaded +/// window gets the full history. Refused while a launch is starting there. #[tauri::command] pub async fn follow_aws_deployment( connection_id: String, @@ -822,8 +1056,19 @@ pub async fn follow_aws_deployment( key.label() ))); } + if matches!(slots().get(&wd.dir), Some(Slot::Launching)) { + return Err(other(format!( + "A deployment is starting for {}; wait for it", + key.label() + ))); + } let can_cancel = tofu::is_running(&wd) && cancellable(&wd); - Ok(spawn_follower(app, wd, state_key, can_cancel)) + // A launch can't start here between the check and the install: it + // needs a runner-free folder or an empty slot, and either way the + // follower then follows that run from byte 0. + let (op_id, gate) = install_follower(app, wd, state_key, can_cancel); + gate.open(); + Ok(op_id) } /// Cancel: interrupt the runner, which stops OpenTofu gracefully. @@ -859,10 +1104,11 @@ pub async fn onboard_aws_user( owner_key: owner_key(&owner), env, }; - launch(app, &ctx, &key, &vars, TofuOp::Apply).await + opened(launch(app, &ctx, &key, &vars, TofuOp::Apply).await) } -/// Deploy (or re-apply) the account's permissions boundary. +/// Deploy (or re-apply) the account's permissions boundary. Refused when +/// the boundary exists but this region's bucket has no state for it. #[tauri::command] pub async fn deploy_aws_permissions_boundary( connection_id: String, @@ -871,8 +1117,15 @@ pub async fn deploy_aws_permissions_boundary( state: State<'_, AppState>, ) -> Result { let ctx = context(&state, &connection_id, ®ion).await?; + let found = detect(&ctx).await?; + let has_state = match found.state_bucket { + Knowable::Yes => boundary_state_exists(&ctx).await?, + Knowable::No => Some(false), + Knowable::Unknown => None, + }; + boundary_refusal(&found.boundary, has_state).map_err(other)?; let vars = boundary_vars(&ctx.region); - launch(app, &ctx, &DeploymentKey::Boundary, &vars, TofuOp::Apply).await + opened(launch(app, &ctx, &DeploymentKey::Boundary, &vars, TofuOp::Apply).await) } /// Ask for a folder from Rust (see `file_commands::pick_save_path`). diff --git a/app/src-tauri/src/commands/environment_commands/tests.rs b/app/src-tauri/src/commands/environment_commands/tests.rs index 83513f9..031e464 100644 --- a/app/src-tauri/src/commands/environment_commands/tests.rs +++ b/app/src-tauri/src/commands/environment_commands/tests.rs @@ -1,8 +1,10 @@ use std::fs; +use std::time::Duration; use serde_json::json; use super::*; +use crate::aws::tofu::Workdir; use crate::aws::environments::{ EnvironmentState, Knowable, LockInfo, OnboardingState, }; @@ -357,3 +359,142 @@ fn an_already_running_start_names_the_deployment_and_suggests_follow() { let inner = AwsError::KeysMissing; assert_eq!(start_error(StartError::Failed(inner.clone()), "x"), inner); } + +#[tokio::test] +async fn a_follower_emits_nothing_until_its_gate_opens() { + use std::sync::{Arc, Mutex}; + // A finished run: the whole replay and `done` are available at once, + // the case where events could otherwise beat the op id to the caller. + let dir = tempfile::tempdir().unwrap(); + let lines: String = (0..450).map(|i| format!("line {i}\n")).collect(); + fs::write(dir.path().join("apply.log"), lines).unwrap(); + fs::write(dir.path().join("apply.exit"), "0\n").unwrap(); + let wd = Workdir { dir: dir.path().to_path_buf() }; + let base = DeployProgress { + op_id: "op-1".into(), + state_key: "boundary/terraform.tfstate".into(), + events: vec![], + done: false, + cancellable: true, + result: None, + }; + let seen: Arc>> = Arc::default(); + let sink = seen.clone(); + let (gate, rx) = Gate::new(); + let task = tokio::spawn(follow_and_report( + rx, + wd, + base, + Duration::from_millis(10), + move |p| sink.lock().unwrap().push(p.clone()), + )); + tokio::time::sleep(Duration::from_millis(150)).await; + assert!(seen.lock().unwrap().is_empty(), "emitted before the gate opened"); + + gate.open(); + task.await.unwrap(); + let seen = seen.lock().unwrap(); + let batches: Vec = seen.iter().filter(|p| !p.done).map(|p| p.events.len()).collect(); + assert_eq!(batches, vec![200, 200, 50], "the whole log, in capped batches"); + assert!(seen.iter().all(|p| p.op_id == "op-1" && p.state_key == "boundary/terraform.tfstate")); + let last = seen.last().unwrap(); + assert!(last.done && !last.cancellable); + assert_eq!(last.result, Some(TofuOutcome { ok: true, exit_code: 0, summary: None })); + assert_eq!(seen.iter().filter(|p| p.done).count(), 1); +} + +#[tokio::test] +async fn a_dropped_gate_still_releases_the_follower() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("apply.log"), "x\n").unwrap(); + fs::write(dir.path().join("apply.exit"), "1\n").unwrap(); + let (gate, rx) = Gate::new(); + drop(gate); + let mut n = 0; + follow_and_report( + rx, + Workdir { dir: dir.path().to_path_buf() }, + DeployProgress { + op_id: "o".into(), + state_key: "k".into(), + events: vec![], + done: false, + cancellable: false, + result: None, + }, + Duration::from_millis(10), + |_| n += 1, + ) + .await; + assert_eq!(n, 2); +} + +#[test] +fn destroy_needs_the_action_no_record_and_no_lock() { + use EnvironmentAction::*; + let mut stale_with_record = env( + "rec", + EnvironmentState::Incomplete { lock: Some(lock(9000, false)) }, + vec![Repair, Destroy, Unlock], + ); + // Stale lock + record + no state: detection offers Destroy; the guard + // must still refuse (the record says it may be working). + stale_with_record.info = Some(info("rec", "public", true)); + let mut record_unlocked = env("rec2", EnvironmentState::Incomplete { lock: None }, vec![Destroy]); + record_unlocked.info = Some(info("rec2", "public", true)); + let s = scan( + Knowable::Yes, + vec![ + stale_with_record, + record_unlocked, + env("locked", EnvironmentState::Incomplete { lock: Some(lock(9000, false)) }, vec![Repair, Destroy, Unlock]), + env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]), + env("ready", EnvironmentState::Ready, vec![Join]), + ], + ); + let e = destroy_refusal(&s, "rec").unwrap_err(); + assert!(e.contains("valid record"), "{e}"); + assert!(destroy_refusal(&s, "rec2").unwrap_err().contains("valid record")); + assert!(destroy_refusal(&s, "locked").unwrap_err().contains("Unlock it first")); + assert!(destroy_refusal(&s, "ready").is_err()); + assert!(destroy_refusal(&s, "missing").is_err()); + assert_eq!(destroy_refusal(&s, "half").unwrap().name, "half"); +} + +#[test] +fn repair_and_update_are_refused_under_a_held_lock() { + use EnvironmentAction::*; + let s = scan( + Knowable::Yes, + vec![ + env("locked", EnvironmentState::Incomplete { lock: Some(lock(9000, false)) }, vec![Repair, Unlock]), + env("free", EnvironmentState::Incomplete { lock: None }, vec![Repair]), + env("old", EnvironmentState::UpdateAvailable, vec![Join, Update]), + ], + ); + let e = reapply_refusal(&s, "locked", Repair).unwrap_err(); + assert!(e.contains("Unlock it first"), "{e}"); + assert!(reapply_refusal(&s, "free", Repair).is_ok()); + assert!(reapply_refusal(&s, "old", Update).is_ok()); + assert!(reapply_refusal(&s, "free", Update).is_err()); +} + +#[test] +fn unlock_deletes_only_a_still_stale_lock_and_reads_412_as_changed() { + assert!(stale_lock_refusal(0).is_err()); + assert!(stale_lock_refusal(LIVE_LOCK_SECS - 1).is_err()); + assert!(stale_lock_refusal(LIVE_LOCK_SECS).is_ok()); + assert!(is_precondition_failed(Some("PreconditionFailed"), Some(412))); + assert!(is_precondition_failed(None, Some(412))); + assert!(!is_precondition_failed(Some("AccessDenied"), Some(403))); + assert!(!is_precondition_failed(None, Some(404))); +} + +#[test] +fn an_existing_boundary_without_state_here_is_refused() { + assert!(boundary_refusal(&Knowable::Yes, Some(false)).unwrap_err().contains("already exists")); + assert!(boundary_refusal(&Knowable::Yes, Some(true)).is_ok(), "re-apply our own"); + assert!(boundary_refusal(&Knowable::Yes, None).is_ok(), "can't tell"); + assert!(boundary_refusal(&Knowable::No, Some(false)).is_ok()); + assert!(boundary_refusal(&Knowable::Unknown, Some(false)).is_ok()); +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 1918d22..3d7dbf7 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -80,8 +80,12 @@ export const checkAwsPermissions = (id: string, region?: string) => export const awsPolicyDocument = (tier: Tier) => invoke("aws_policy_document", { tier }); -// AWS environments (OpenTofu). Reject with an `AwsError`. Starting commands resolve to an op id; -// progress arrives on the `AWS_DEPLOY_PROGRESS_EVENT` event (`DeployProgress`, in ./types) under that op id. +// AWS environments (OpenTofu). Reject with an `AwsError`. Starting commands (and follow) resolve +// to an op id; progress arrives on the `AWS_DEPLOY_PROGRESS_EVENT` event (`DeployProgress`, in +// ./types). Contract: subscribe to aws-deploy-progress BEFORE invoking; events carry `op_id` and +// `state_key`. The backend holds a run's first event until the command is about to return, so a +// listener registered before the invoke sees the whole run, `done` included. A re-follow (e.g. +// after a reload) replays the whole log under a new op id. export const detectAwsEnvironments = (connectionId: string, region: string) => invoke("detect_aws_environments", { connectionId, region }); export const deployAwsEnvironment = (connectionId: string, region: string, name: string, network: NetworkChoice) => diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index d6c661f..6b6c220 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -1284,7 +1284,7 @@ export interface TofuOutcome { /** The Tauri event a deploy's progress is emitted on (`environment_commands::PROGRESS_EVENT`). */ export const AWS_DEPLOY_PROGRESS_EVENT = "aws-deploy-progress"; -/** Payload of the `aws-deploy-progress` event. `result` is set only when `done`. */ +/** Payload of the `aws-deploy-progress` event. Subscribe before invoking the command; match on `op_id` (or `state_key`). `result` is set only when `done`. */ export interface DeployProgress { op_id: string; /** `environments//terraform.tfstate`, `users///terraform.tfstate` or `boundary/terraform.tfstate`. */ -- 2.52.0 From a8b83ca63a4af8fb40328f28d8a29075494ecab9 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 22:41:29 -0800 Subject: [PATCH 16/24] fix(aws): follow checks the launch slot under the same lock it installs in The work-folder registry is a pure Slots whose launch, follow and clean-up transitions each run under one lock acquisition, with tests; HeadObject errors name the action as s3:GetObject (HeadObject). Co-Authored-By: Claude Opus 5.5 --- .../src/commands/environment_commands.rs | 194 ++++++++++++------ .../commands/environment_commands/tests.rs | 61 ++++++ 2 files changed, 195 insertions(+), 60 deletions(-) diff --git a/app/src-tauri/src/commands/environment_commands.rs b/app/src-tauri/src/commands/environment_commands.rs index 5833413..b071f57 100644 --- a/app/src-tauri/src/commands/environment_commands.rs +++ b/app/src-tauri/src/commands/environment_commands.rs @@ -573,21 +573,113 @@ pub(crate) async fn follow_and_report( // --------------------------------------------------------------------------- // Plumbing -/// What this process is doing with a work folder. -enum Slot { +/// What this process is doing with a work folder. `T` is the follower +/// task's handle (generic so the registry is unit-testable). +pub(crate) enum Slot { /// A launch is between its checks and its start; nothing else may /// materialize, start or follow the folder. Launching, /// One task follows the folder's run under `op_id`. - Following { - op_id: String, - task: tauri::async_runtime::JoinHandle<()>, - }, + Following { op_id: String, task: T }, } -static SLOTS: OnceLock>> = OnceLock::new(); +/// Why a folder's slot refused. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum SlotBusy { + Launching, + Following, +} -fn slots() -> std::sync::MutexGuard<'static, HashMap> { +impl SlotBusy { + fn error(&self, label: &str) -> AwsError { + other(match self { + SlotBusy::Launching => format!("A deployment is starting for {label}; wait for it"), + SlotBusy::Following => format!( + "The run for {label} is still being followed; wait for it to finish" + ), + }) + } +} + +/// Per work folder: a launch in progress or the one follower. Every +/// check-and-change happens in one method call, i.e. under one acquisition +/// of the registry's lock. +pub(crate) struct Slots(HashMap>); + +impl Default for Slots { + fn default() -> Self { + Slots(HashMap::new()) + } +} + +impl Slots { + /// Claim `dir` for a launch: refused if anything holds it. + pub(crate) fn begin_launch(&mut self, dir: &Path) -> Result<(), SlotBusy> { + match self.0.get(dir) { + Some(Slot::Launching) => Err(SlotBusy::Launching), + Some(Slot::Following { .. }) => Err(SlotBusy::Following), + None => { + self.0.insert(dir.to_path_buf(), Slot::Launching); + Ok(()) + } + } + } + + /// A launch is over without a follower: clear `Launching`, never a + /// follower's slot. + pub(crate) fn end_launch(&mut self, dir: &Path) { + if matches!(self.0.get(dir), Some(Slot::Launching)) { + self.0.remove(dir); + } + } + + /// Install a follower made by `spawn`. From a launch (`from_launch`, the + /// caller holds `Launching`) it replaces the launch's slot; from a + /// follow it is refused while a launch holds the folder, and otherwise + /// replaces any earlier follower, whose handle is returned to abort. + /// `spawn` runs only once the check has passed, under the same lock. + pub(crate) fn install_follower( + &mut self, + dir: &Path, + op_id: &str, + from_launch: bool, + spawn: impl FnOnce() -> T, + ) -> Result, SlotBusy> { + if !from_launch && matches!(self.0.get(dir), Some(Slot::Launching)) { + return Err(SlotBusy::Launching); + } + let task = spawn(); + let old = self.0.insert( + dir.to_path_buf(), + Slot::Following { + op_id: op_id.to_string(), + task, + }, + ); + Ok(match old { + Some(Slot::Following { task, .. }) => Some(task), + _ => None, + }) + } + + /// A follower ended: remove the slot only if it is still that one's. + pub(crate) fn finish_follower(&mut self, dir: &Path, op_id: &str) { + if matches!(self.0.get(dir), Some(Slot::Following { op_id: id, .. }) if id == op_id) { + self.0.remove(dir); + } + } + + #[cfg(test)] + pub(crate) fn is_free(&self, dir: &Path) -> bool { + !self.0.contains_key(dir) + } +} + +type FollowerTask = tauri::async_runtime::JoinHandle<()>; + +static SLOTS: OnceLock>> = OnceLock::new(); + +fn slots() -> std::sync::MutexGuard<'static, Slots> { SLOTS .get_or_init(Default::default) .lock() @@ -600,28 +692,14 @@ struct LaunchGuard(PathBuf); impl LaunchGuard { fn take(dir: &Path, label: &str) -> Result { - let mut map = slots(); - match map.get(dir) { - Some(Slot::Launching) => Err(other(format!( - "A deployment is starting for {label}; wait for it" - ))), - Some(Slot::Following { .. }) => Err(other(format!( - "The run for {label} is still being followed; wait for it to finish" - ))), - None => { - map.insert(dir.to_path_buf(), Slot::Launching); - Ok(LaunchGuard(dir.to_path_buf())) - } - } + slots().begin_launch(dir).map_err(|b| b.error(label))?; + Ok(LaunchGuard(dir.to_path_buf())) } } impl Drop for LaunchGuard { fn drop(&mut self) { - let mut map = slots(); - if matches!(map.get(&self.0), Some(Slot::Launching)) { - map.remove(&self.0); - } + slots().end_launch(&self.0); } } @@ -736,7 +814,7 @@ async fn boundary_state_exists(ctx: &Ctx) -> Result, AwsError> { Err(e) => match e.raw_response().map(|r| r.status().as_u16()) { Some(404) => Ok(Some(false)), Some(403) => Ok(None), - _ => Err(crate::aws::classify_sdk_error("s3:GetObject", &e)), + _ => Err(crate::aws::classify_sdk_error("s3:GetObject (HeadObject)", &e)), }, } } @@ -764,9 +842,17 @@ fn emit_progress(app: &AppHandle, p: &DeployProgress) { /// Start the folder's follower under a new op id, replacing (aborting) any /// follower this process already has for it, so a re-follow (a reloaded /// window) replays the whole log. The follower emits nothing until the -/// returned [`Gate`] is opened. The caller has checked the folder isn't -/// launching, or holds its [`LaunchGuard`]. -fn install_follower(app: AppHandle, wd: Workdir, key: String, can_cancel: bool) -> (String, Gate) { +/// returned [`Gate`] is opened. A follow (`from_launch == false`) is refused +/// while a launch holds the folder; the check, the spawn and the slot change +/// happen under one lock acquisition, so no launch can slip in between. +fn install_follower( + app: AppHandle, + wd: Workdir, + key: String, + label: &str, + can_cancel: bool, + from_launch: bool, +) -> Result<(String, Gate), AwsError> { let op_id = uuid::Uuid::new_v4().to_string(); let (gate, rx) = Gate::new(); let base = DeployProgress { @@ -778,28 +864,22 @@ fn install_follower(app: AppHandle, wd: Workdir, key: String, can_cancel: bool) result: None, }; let dir = wd.dir.clone(); - let mut map = slots(); - // Spawned under the lock, so the task's own clean-up (which takes it) - // can't run before its slot is in place. let id = op_id.clone(); - let task = tauri::async_runtime::spawn(async move { - let dir = wd.dir.clone(); - follow_and_report(rx, wd, base, FOLLOW_POLL, |p| emit_progress(&app, p)).await; - let mut map = slots(); - if matches!(map.get(&dir), Some(Slot::Following { op_id, .. }) if *op_id == id) { - map.remove(&dir); - } - }); - if let Some(Slot::Following { task: old, .. }) = map.insert( - dir, - Slot::Following { - op_id: op_id.clone(), - task, - }, - ) { + // The task's own clean-up takes the same lock, so it can't run before + // its slot is in place. + let old = slots() + .install_follower(&dir, &op_id, from_launch, move || { + tauri::async_runtime::spawn(async move { + let dir = wd.dir.clone(); + follow_and_report(rx, wd, base, FOLLOW_POLL, |p| emit_progress(&app, p)).await; + slots().finish_follower(&dir, &id); + }) + }) + .map_err(|b| b.error(label))?; + if let Some(old) = old { old.abort(); } - (op_id, gate) + Ok((op_id, gate)) } /// Ensure OpenTofu and the state bucket, write the work folder and start a @@ -844,7 +924,7 @@ async fn launch( .map_err(|e| other(format!("The deploy could not be started: {e}")))? .map_err(|e| start_error(e, &label))?; let can_cancel = cfg!(unix) && started.pid.is_some(); - Ok(install_follower(app, wd, state_key(d), can_cancel)) + install_follower(app, wd, state_key(d), &label, can_cancel, true) } /// Open a launch's gate and hand back its op id: the last thing a command @@ -998,7 +1078,8 @@ pub async fn unlock_aws_state( Err(e) if e.raw_response().map(|r| r.status().as_u16()) == Some(404) => { return Err(other("The state lock is already gone; detect again")); } - Err(e) => return Err(crate::aws::classify_sdk_error("s3:GetObject", &e)), + // HeadObject is authorised by s3:GetObject. + Err(e) => return Err(crate::aws::classify_sdk_error("s3:GetObject (HeadObject)", &e)), }; let etag = head .e_tag() @@ -1056,17 +1137,10 @@ pub async fn follow_aws_deployment( key.label() ))); } - if matches!(slots().get(&wd.dir), Some(Slot::Launching)) { - return Err(other(format!( - "A deployment is starting for {}; wait for it", - key.label() - ))); - } let can_cancel = tofu::is_running(&wd) && cancellable(&wd); - // A launch can't start here between the check and the install: it - // needs a runner-free folder or an empty slot, and either way the - // follower then follows that run from byte 0. - let (op_id, gate) = install_follower(app, wd, state_key, can_cancel); + // Refused inside install_follower, under the registry lock, while a + // launch holds the folder. + let (op_id, gate) = install_follower(app, wd, state_key, &key.label(), can_cancel, false)?; gate.open(); Ok(op_id) } diff --git a/app/src-tauri/src/commands/environment_commands/tests.rs b/app/src-tauri/src/commands/environment_commands/tests.rs index 031e464..63ab0f3 100644 --- a/app/src-tauri/src/commands/environment_commands/tests.rs +++ b/app/src-tauri/src/commands/environment_commands/tests.rs @@ -498,3 +498,64 @@ fn an_existing_boundary_without_state_here_is_refused() { assert!(boundary_refusal(&Knowable::No, Some(false)).is_ok()); assert!(boundary_refusal(&Knowable::Unknown, Some(false)).is_ok()); } + +#[test] +fn a_follow_is_refused_while_a_launch_holds_the_folder() { + let dir = Path::new("/w/env/dev"); + let mut slots: Slots = Slots::default(); + slots.begin_launch(dir).unwrap(); + let mut spawned = false; + let r = slots.install_follower(dir, "f1", false, || { + spawned = true; + 1 + }); + assert_eq!(r.unwrap_err(), SlotBusy::Launching); + assert!(!spawned, "nothing is spawned for a refused follow"); + // The launch's own follower replaces its Launching slot. + assert_eq!(slots.install_follower(dir, "l1", true, || 2).unwrap(), None); + slots.end_launch(dir); // the guard dropping afterwards + assert!(!slots.is_free(dir), "end_launch must not clear a follower"); +} + +#[test] +fn a_launch_is_refused_while_a_follower_or_launch_holds_the_folder() { + let dir = Path::new("/w/env/dev"); + let mut slots: Slots = Slots::default(); + slots.install_follower(dir, "f1", false, || 1).unwrap(); + assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Following); + slots.finish_follower(dir, "f1"); + slots.begin_launch(dir).unwrap(); + assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Launching); + slots.end_launch(dir); + assert!(slots.is_free(dir)); + // Other folders are independent. + slots.begin_launch(dir).unwrap(); + slots.begin_launch(Path::new("/w/env/prod")).unwrap(); +} + +#[test] +fn a_finishing_follower_only_removes_its_own_slot() { + let dir = Path::new("/w/env/dev"); + let mut slots: Slots = Slots::default(); + slots.install_follower(dir, "old", false, || 1).unwrap(); + // A re-follow replaces it and hands back the old task to abort. + assert_eq!(slots.install_follower(dir, "new", false, || 2).unwrap(), Some(1)); + slots.finish_follower(dir, "old"); + assert!(!slots.is_free(dir), "the old follower must not remove the new one's slot"); + slots.finish_follower(dir, "new"); + assert!(slots.is_free(dir)); + // Nor a launch's slot. + slots.begin_launch(dir).unwrap(); + slots.finish_follower(dir, "new"); + assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Launching); +} + +#[test] +fn ending_a_launch_never_clears_a_following_slot() { + let dir = Path::new("/w/env/dev"); + let mut slots: Slots = Slots::default(); + slots.install_follower(dir, "f", false, || 1).unwrap(); + slots.end_launch(dir); + assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Following); + slots.end_launch(Path::new("/w/env/none")); // nothing there: no-op +} -- 2.52.0 From cf9b5eab3a1d39558fe478d22e6ae7f782b72518 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 22:50:23 -0800 Subject: [PATCH 17/24] =?UTF-8?q?feat(aws):=20environments=20panel=20?= =?UTF-8?q?=E2=80=94=20detect,=20deploy,=20update,=20repair,=20join?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnvironmentsPanel under each AWS connection: region select and detection, environment rows with state, lock owner/age, module version and the detected actions (Join, Update, Repair, Destroy, Follow, Unlock), typed confirmations for Destroy and Unlock, self-onboarding for environment admins and the onboarding bundle for everyone else (R15). DeployEnvironmentModal (name rule mirrored from Rust, D4 networks with cost lines, boundary-first step), AdminBundleModal (D5 bundles) and DeployProgress, whose hook subscribes before invoking and buffers events by state key until the op id is known. awsErrorMessage moves to lib/aws-error; PolicyFiles to its own module. Co-Authored-By: Claude Opus 5.5 --- app/src/components/settings/HostsSettings.tsx | 96 +--- .../settings/hosts/AdminBundleModal.test.tsx | 70 +++ .../settings/hosts/AdminBundleModal.tsx | 152 ++++++ .../hosts/DeployEnvironmentModal.test.tsx | 156 ++++++ .../settings/hosts/DeployEnvironmentModal.tsx | 167 ++++++ .../settings/hosts/DeployProgress.tsx | 281 ++++++++++ .../settings/hosts/EnvironmentsPanel.test.tsx | 329 ++++++++++++ .../settings/hosts/EnvironmentsPanel.tsx | 490 ++++++++++++++++++ .../settings/hosts/NetworkFields.tsx | 67 +++ .../components/settings/hosts/PolicyFiles.tsx | 70 +++ .../settings/hosts/environmentFormat.ts | 97 ++++ app/src/components/settings/hosts/testkit.ts | 64 +++ app/src/lib/aws-error.ts | 16 + 13 files changed, 1965 insertions(+), 90 deletions(-) create mode 100644 app/src/components/settings/hosts/AdminBundleModal.test.tsx create mode 100644 app/src/components/settings/hosts/AdminBundleModal.tsx create mode 100644 app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx create mode 100644 app/src/components/settings/hosts/DeployEnvironmentModal.tsx create mode 100644 app/src/components/settings/hosts/DeployProgress.tsx create mode 100644 app/src/components/settings/hosts/EnvironmentsPanel.test.tsx create mode 100644 app/src/components/settings/hosts/EnvironmentsPanel.tsx create mode 100644 app/src/components/settings/hosts/NetworkFields.tsx create mode 100644 app/src/components/settings/hosts/PolicyFiles.tsx create mode 100644 app/src/components/settings/hosts/environmentFormat.ts create mode 100644 app/src/components/settings/hosts/testkit.ts diff --git a/app/src/components/settings/HostsSettings.tsx b/app/src/components/settings/HostsSettings.tsx index b6713cd..1a6360d 100644 --- a/app/src/components/settings/HostsSettings.tsx +++ b/app/src/components/settings/HostsSettings.tsx @@ -5,50 +5,29 @@ import { addAwsProfileConnection, awsConnectionIdentity, awsConnectionSsoLogin, - awsPolicyDocument, checkAwsPermissions, listAwsProfiles, removeHostConnection, updateAwsConnection, } from "../../lib/tauri-commands"; -import { toAwsError } from "../../lib/aws-error"; +import { awsErrorMessage, toAwsError } from "../../lib/aws-error"; +import PolicyFiles from "./hosts/PolicyFiles"; +import EnvironmentsPanel from "./hosts/EnvironmentsPanel"; +import { COMMON_REGIONS } from "./hosts/environmentFormat"; import type { AwsError, HostConnection, PermissionReport, PermissionSummary, PolicyFile, - Tier, } from "../../lib/types"; import Button from "../ui/Button"; import SegmentedControl from "../ui/SegmentedControl"; import Field, { inputClass, selectClass } from "../ui/Field"; -const COMMON_REGIONS = [ - "us-east-1", "us-east-2", "us-west-1", "us-west-2", "ca-central-1", - "eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1", - "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ap-northeast-2", "ap-south-1", - "sa-east-1", -]; const OWN_PRINCIPAL_ACTIONS = ["iam:GetRole", "iam:SimulatePrincipalPolicy"]; -/** Human-readable copy for a failed AWS command; never raw JSON. */ -export function awsErrorMessage(e: AwsError): string { - switch (e.kind) { - case "sso_expired": - return `Your SSO session for profile ${e.profile} has expired.`; - case "profile_not_found": - return `Profile ${e.profile} was not found in your AWS config, or has no credentials.`; - case "keys_missing": - return "The access keys for this connection are missing from the keychain. Remove and re-add the connection."; - case "state_bucket_taken": - return `The state bucket name ${e.bucket} is already taken by another AWS account.`; - default: - return e.message; - } -} - const SUMMARY_LABEL: Record = { environment_admin: "Environment admin", workspace_user: "Workspace user", @@ -96,70 +75,6 @@ function ErrorLine({ ); } -/** A tier's policy files, loaded on demand: each one shown and copied on its own. */ -function PolicyFiles({ - tier, - label, - onCopy, -}: { - tier: Tier; - label: string; - onCopy: (file: PolicyFile) => void; -}) { - const [files, setFiles] = useState(null); - const [error, setError] = useState(null); - - const load = async () => { - setError(null); - try { - setFiles(await awsPolicyDocument(tier)); - } catch (e) { - setError(`Could not load the policy: ${awsErrorMessage(toAwsError(e))}`); - } - }; - - if (!files) { - return ( -

- - {error && ( -

- {error} -

- )} -
- ); - } - return ( -
-

- {tier === "workspace_user" ? "Workspace user" : "Environment admin"}:{" "} - {files.length === 1 - ? "attach this file as a customer managed policy." - : `Attach all ${files.length} files, each as its own customer managed policy.`} -

- {files.map((f) => ( -
-
- {f.name} - -
-
- Show JSON -
-              {f.json}
-            
-
-
- ))} -
- ); -} - function PermissionReportView({ report }: { report: PermissionReport }) { const [copyNote, setCopyNote] = useState(null); const probed = typeof report.method === "object"; @@ -399,6 +314,7 @@ function ConnectionRow({ host }: { host: HostConnection }) { {busy && !editing && !confirmRemove &&

Working…

} {failure && } {report && } + ); } @@ -591,7 +507,7 @@ export default function HostsSettings() { return (

- Where project environments run. AWS connections are used to check what your credentials can do. + Where project environments run. AWS connections check what your credentials can do, and find, deploy and join environments.

  • diff --git a/app/src/components/settings/hosts/AdminBundleModal.test.tsx b/app/src/components/settings/hosts/AdminBundleModal.test.tsx new file mode 100644 index 0000000..de5e1f5 --- /dev/null +++ b/app/src/components/settings/hosts/AdminBundleModal.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import AdminBundleModal from "./AdminBundleModal"; + +const invoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string, args?: unknown) => invoke(cmd, args), +})); + +describe("AdminBundleModal", () => { + beforeEach(() => vi.clearAllMocks()); + + it("saves the bundle and shows the folder and the two commands, with Copy", async () => { + invoke.mockResolvedValue("/home/me/triple-c-onboard-dev"); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + render( + , + ); + expect(screen.getByRole("dialog", { name: "Onboarding bundle" })).toBeInTheDocument(); + expect(screen.getByText(/environment admin/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" })); + expect(await screen.findByText("/home/me/triple-c-onboard-dev")).toBeInTheDocument(); + expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", { + connectionId: "h1", + region: "eu-west-1", + kind: "user_onboard", + environment: "dev", + ownerId: "AIDAEXAMPLE", + network: null, + }); + const commands = "tofu init -backend-config=backend.hcl\ntofu apply"; + expect(screen.getByText((_, el) => el?.tagName === "PRE" && el.textContent === commands)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Copy commands" })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(commands)); + }); + + it("shows nothing new when the folder picker is dismissed", async () => { + invoke.mockResolvedValue(null); + render(); + fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" })); + await waitFor(() => expect(invoke).toHaveBeenCalled()); + expect(screen.queryByRole("button", { name: "Copy commands" })).toBeNull(); + }); + + it("an environment bundle asks for the name and network", async () => { + invoke.mockResolvedValue("/tmp/env"); + render(); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "team" } }); + fireEvent.click(screen.getByRole("radio", { name: "Private + NAT" })); + fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", { + connectionId: "h1", + region: "eu-west-1", + kind: "environment", + environment: "team", + ownerId: null, + network: { mode: "private-nat" }, + }), + ); + }); + + it("shows a save failure through the AWS error copy", async () => { + invoke.mockRejectedValue({ kind: "sso_expired", profile: "prod-sso" }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" })); + expect(await screen.findByText("Your SSO session for profile prod-sso has expired.")).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/hosts/AdminBundleModal.tsx b/app/src/components/settings/hosts/AdminBundleModal.tsx new file mode 100644 index 0000000..6630be5 --- /dev/null +++ b/app/src/components/settings/hosts/AdminBundleModal.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import { saveAwsAdminBundle } from "../../../lib/tauri-commands"; +import { awsErrorMessage, toAwsError } from "../../../lib/aws-error"; +import type { AwsError, ModuleKind, NetworkChoice } from "../../../lib/types"; +import Button from "../../ui/Button"; +import Field, { inputClass } from "../../ui/Field"; +import Modal from "../../ui/Modal"; +import NetworkFields from "./NetworkFields"; +import { DEFAULT_NETWORK, environmentNameError, networkFromDraft, type NetworkDraft } from "./environmentFormat"; + +const COMMANDS = "tofu init -backend-config=backend.hcl\ntofu apply"; + +const TITLE: Record = { + environment: "Environment bundle", + user_onboard: "Onboarding bundle", + permissions_boundary: "Permissions boundary bundle", +}; + +/** + * An admin request (D5): Triple-C writes the module, `backend.hcl`, + * `terraform.tfvars.json` and a README into a folder picked in Rust, and the + * admin runs the two commands from there. + */ +export default function AdminBundleModal({ + connectionId, + region, + kind, + environment, + ownerId, + network, + reason, + onClose, +}: { + connectionId: string; + region: string; + kind: ModuleKind; + /** Fixed environment; when absent for an environment bundle, the user picks it here. */ + environment?: string; + ownerId?: string; + network?: NetworkChoice; + /** Why the bundle is needed (e.g. the refusal that led here). */ + reason?: string; + onClose: () => void; +}) { + const askEnvironment = kind === "environment" && !environment; + const [name, setName] = useState("main"); + const [draft, setDraft] = useState(DEFAULT_NETWORK); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(null); + const [error, setError] = useState(null); + const [copyNote, setCopyNote] = useState(null); + + const nameError = askEnvironment ? environmentNameError(name) : null; + const picked = askEnvironment ? networkFromDraft(draft) : null; + const ready = !nameError && (!picked || "network" in picked); + + const save = async () => { + setSaving(true); + setError(null); + try { + const folder = await saveAwsAdminBundle(connectionId, region, kind, { + environment: askEnvironment ? name : environment, + ownerId, + network: picked && "network" in picked ? picked.network : network, + }); + if (folder) setSaved(folder); + } catch (e) { + setError(toAwsError(e)); + } finally { + setSaving(false); + } + }; + + const copy = async () => { + try { + await navigator.clipboard.writeText(COMMANDS); + setCopyNote("Commands copied."); + } catch (e) { + setCopyNote(`Could not copy: ${awsErrorMessage(toAwsError(e))}`); + } + }; + + return ( + + Close + + } + > +
    + {reason && ( +

    + Doing this from here was refused: {reason} +

    + )} + {kind === "environment" && ( +

    + Deploying an environment needs an environment admin. Triple-C writes the OpenTofu module with its + backend settings and variables into a folder; send it to an admin, who deploys it from there. +

    + )} + {kind === "user_onboard" && ( +

    + Joining {environment} needs your own task role and file system, which an environment admin creates + by applying this bundle. Send them the folder; once it's applied, Detect again shows you as joined. +

    + )} + {kind === "permissions_boundary" && ( +

    + Every role Triple-C creates is capped by the account's permissions boundary, and creating it needs an + account administrator. Send them this folder. +

    + )} +

    The bundle works with OpenTofu or Terraform 1.10 and later. Its README lists every step.

    + {askEnvironment && ( +
    + + {(id) => setName(e.target.value)} />} + + +
    + )} + + {error && ( +

    + {awsErrorMessage(error)} +

    + )} + {saved && ( +
    +

    + Saved to {saved} +

    +

    From that folder, run:

    +
    +              {COMMANDS}
    +            
    + + {copyNote &&

    {copyNote}

    } +
    + )} +
    +
    + ); +} diff --git a/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx b/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx new file mode 100644 index 0000000..6003815 --- /dev/null +++ b/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import DeployEnvironmentModal from "./DeployEnvironmentModal"; +import type { DeployProgress } from "../../../lib/types"; +import { conn, env, progress, scan, tofuEvent } from "./testkit"; + +const invoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string, args?: unknown) => invoke(cmd, args), +})); + +type Handler = (e: { payload: DeployProgress }) => void; +const handlers = new Set(); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async (_name: string, handler: Handler) => { + handlers.add(handler); + return () => handlers.delete(handler); + }), +})); +function emit(p: DeployProgress) { + act(() => { + for (const h of [...handlers]) h({ payload: p }); + }); +} + +const BOUNDARY_KEY = "boundary/terraform.tfstate"; + +function renderModal(over: Partial[0]> = {}) { + const props = { + connectionId: conn.id, + region: "eu-west-1", + scan: scan(), + onClose: vi.fn(), + onDeploy: vi.fn().mockResolvedValue(null), + onDetect: vi.fn(), + ...over, + }; + render(); + return props; +} + +describe("DeployEnvironmentModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + handlers.clear(); + }); + + it("the public network mode is the default and says it costs nothing", () => { + const props = renderModal(); + expect(screen.getByRole("radio", { name: "Public" })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByText("$0 — tasks get a public IP, no inbound access")).toBeInTheDocument(); + expect(screen.getByText("First deploy downloads OpenTofu (~30 MB) and the AWS provider (~150 MB).")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("radio", { name: "Private + NAT" })); + expect(screen.getByText("about $33/month for the NAT gateway")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("radio", { name: "Public" })); + expect(screen.getByLabelText("Name")).toHaveValue("main"); + fireEvent.click(screen.getByRole("button", { name: "Deploy" })); + expect(props.onDeploy).toHaveBeenCalledWith("main", { mode: "public" }); + }); + + it("validates the name with the backend's rule", () => { + const props = renderModal({ scan: scan({ environments: [env({ name: "main" })] }) }); + const deploy = screen.getByRole("button", { name: "Deploy" }); + expect(screen.getByText(/already exists/)).toBeInTheDocument(); + expect(deploy).toBeDisabled(); + for (const bad of ["Dev", "a--b", "dev-", "1dev", "a".repeat(21), ""]) { + fireEvent.change(screen.getByLabelText("Name"), { target: { value: bad } }); + expect(deploy).toBeDisabled(); + } + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "team-2" } }); + expect(deploy).toBeEnabled(); + fireEvent.click(deploy); + expect(props.onDeploy).toHaveBeenCalledWith("team-2", { mode: "public" }); + }); + + it("an existing network needs a VPC and two distinct subnets", () => { + const props = renderModal(); + fireEvent.click(screen.getByRole("radio", { name: "Existing VPC" })); + const deploy = screen.getByRole("button", { name: "Deploy" }); + expect(deploy).toBeDisabled(); + fireEvent.change(screen.getByLabelText("VPC ID"), { target: { value: "vpc-0123abcd" } }); + fireEvent.change(screen.getByLabelText("Subnet ID 1"), { target: { value: "subnet-0123abcd" } }); + fireEvent.change(screen.getByLabelText("Subnet ID 2"), { target: { value: "subnet-0123abcd" } }); + expect(deploy).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Subnet ID 2"), { target: { value: "subnet-4567abcd" } }); + fireEvent.click(screen.getByRole("switch", { name: "Assign a public IP" })); + fireEvent.click(deploy); + expect(props.onDeploy).toHaveBeenCalledWith("main", { + mode: "existing", + vpc_id: "vpc-0123abcd", + subnet_ids: ["subnet-0123abcd", "subnet-4567abcd"], + assign_public_ip: true, + }); + }); + + it("keeps the dialog open and shows the server's refusal", async () => { + const props = renderModal({ + onDeploy: vi.fn().mockResolvedValue({ kind: "other", message: "A deployment is starting for environment \"main\"; wait for it" }), + }); + fireEvent.click(screen.getByRole("button", { name: "Deploy" })); + expect(await screen.findByText(/A deployment is starting/)).toBeInTheDocument(); + expect(props.onClose).not.toHaveBeenCalled(); + }); + + it("deploy modal blocks on a missing boundary and offers to create it", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "deploy_aws_permissions_boundary") return "b1"; + throw new Error(`unexpected ${cmd}`); + }); + const props = renderModal({ scan: scan({ boundary: "no" }) }); + expect(screen.queryByLabelText("Name")).toBeNull(); + expect(screen.queryByRole("button", { name: "Deploy" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("deploy_aws_permissions_boundary", { connectionId: "h1", region: "eu-west-1" }), + ); + emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, events: [tofuEvent("Creating boundary")] })); + expect(screen.getByText("Creating boundary")).toBeInTheDocument(); + emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "Apply complete!" } })); + expect(await screen.findByLabelText("Name")).toBeInTheDocument(); + expect(props.onDetect).toHaveBeenCalled(); + }); + + it("opens the boundary bundle when creating the boundary is denied", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "deploy_aws_permissions_boundary") throw { kind: "access_denied", action: "iam:CreatePolicy", message: "denied iam:CreatePolicy" }; + if (cmd === "save_aws_admin_bundle") return "/tmp/b"; + throw new Error(`unexpected ${cmd}`); + }); + renderModal({ scan: scan({ boundary: "no" }) }); + fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" })); + const dialog = await screen.findByRole("dialog", { name: "Permissions boundary bundle" }); + expect(within(dialog).getByText(/denied iam:CreatePolicy/)).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", { + connectionId: "h1", + region: "eu-west-1", + kind: "permissions_boundary", + environment: null, + ownerId: null, + network: null, + }), + ); + }); + + it("repairs a named environment and warns that a different network replaces it", () => { + const props = renderModal({ repair: "dev" }); + expect(screen.getByRole("dialog", { name: "Repair dev" })).toBeInTheDocument(); + expect(screen.queryByLabelText("Name")).toBeNull(); + expect(screen.getByText(/different network than the one it was deployed with replaces/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("radio", { name: "Private + NAT" })); + fireEvent.click(screen.getByRole("button", { name: "Repair" })); + expect(props.onDeploy).toHaveBeenCalledWith("dev", { mode: "private-nat" }); + }); +}); diff --git a/app/src/components/settings/hosts/DeployEnvironmentModal.tsx b/app/src/components/settings/hosts/DeployEnvironmentModal.tsx new file mode 100644 index 0000000..6fccbea --- /dev/null +++ b/app/src/components/settings/hosts/DeployEnvironmentModal.tsx @@ -0,0 +1,167 @@ +import { useState } from "react"; +import { deployAwsPermissionsBoundary } from "../../../lib/tauri-commands"; +import { awsErrorMessage } from "../../../lib/aws-error"; +import type { AwsError, EnvironmentScan, NetworkChoice } from "../../../lib/types"; +import Button from "../../ui/Button"; +import Field, { inputClass } from "../../ui/Field"; +import Modal from "../../ui/Modal"; +import AdminBundleModal from "./AdminBundleModal"; +import DeployProgress, { useDeployRuns } from "./DeployProgress"; +import NetworkFields from "./NetworkFields"; +import { + BOUNDARY_STATE_KEY, + DEFAULT_NETWORK, + environmentNameError, + networkFromDraft, + type NetworkDraft, +} from "./environmentFormat"; + +/** + * Deploy a new environment (name + D4 network), or — with `repair` — pick the + * network for an environment that has no record to take it from. A missing + * permissions boundary is a first step before the form. + */ +export default function DeployEnvironmentModal({ + connectionId, + region, + scan, + repair, + onClose, + onDeploy, + onDetect, +}: { + connectionId: string; + region: string; + scan: EnvironmentScan; + /** The environment to repair; absent for a new deploy. */ + repair?: string; + onClose: () => void; + /** Starts the run; resolves to the command's error, or null once started. */ + onDeploy: (name: string, network: NetworkChoice) => Promise; + onDetect: () => void; +}) { + const [name, setName] = useState("main"); + const [draft, setDraft] = useState(DEFAULT_NETWORK); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [boundaryDone, setBoundaryDone] = useState(false); + const [boundaryError, setBoundaryError] = useState(null); + const [bundle, setBundle] = useState<{ reason?: string } | null>(null); + const boundary = useDeployRuns(({ result }) => { + if (result?.ok) setBoundaryDone(true); + onDetect(); + }); + const boundaryRun = boundary.runs[BOUNDARY_STATE_KEY]; + + if (bundle) { + return ( + setBundle(null)} + /> + ); + } + + const needsBoundary = !repair && scan.boundary === "no" && !boundaryDone; + const target = repair ?? name; + const nameError = repair + ? null + : environmentNameError(name) ?? + (scan.environments.some((e) => e.name === name) ? `An environment named ${name} already exists here.` : null); + const picked = networkFromDraft(draft); + const ready = !nameError && "network" in picked && !submitting; + + const createBoundary = async () => { + setBoundaryError(null); + const err = await boundary.start(BOUNDARY_STATE_KEY, region, "Permissions boundary", () => + deployAwsPermissionsBoundary(connectionId, region), + ); + if (err?.kind === "access_denied") setBundle({ reason: awsErrorMessage(err) }); + else if (err) setBoundaryError(err); + }; + + const submit = async () => { + if (!("network" in picked)) return; + setSubmitting(true); + setError(null); + const err = await onDeploy(target, picked.network); + setSubmitting(false); + if (err) setError(err); + else onClose(); + }; + + const verb = repair ? "Repair" : "Deploy"; + return ( + + + {!needsBoundary && ( + + )} + + } + > + {needsBoundary ? ( +
    +

    + This account has no Triple-C permissions boundary yet. Every role an environment creates is capped by + it, so it comes first. +

    +
    + + +
    + {boundaryError && ( +

    + {awsErrorMessage(boundaryError)} +

    + )} + {boundaryRun && } +
    + ) : ( +
    + {repair ? ( +

    + {repair} has no record, so Triple-C can't tell which network it was deployed with. Choose the same one: + a different network than the one it was deployed with replaces the network resources (VPC, subnets, + NAT gateway). +

    + ) : ( + + {(id) => setName(e.target.value)} />} + + )} + +

    + First deploy downloads OpenTofu (~30 MB) and the AWS provider (~150 MB). +

    + {error && ( +

    + {awsErrorMessage(error)} +

    + )} +
    + )} +
    + ); +} diff --git a/app/src/components/settings/hosts/DeployProgress.tsx b/app/src/components/settings/hosts/DeployProgress.tsx new file mode 100644 index 0000000..7f61ae6 --- /dev/null +++ b/app/src/components/settings/hosts/DeployProgress.tsx @@ -0,0 +1,281 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { cancelAwsDeployment } from "../../../lib/tauri-commands"; +import { awsErrorMessage, toAwsError } from "../../../lib/aws-error"; +import { + AWS_DEPLOY_PROGRESS_EVENT, + type AwsError, + type DeployProgress as ProgressPayload, + type TofuEvent, + type TofuOutcome, +} from "../../../lib/types"; +import Button from "../../ui/Button"; +import Modal from "../../ui/Modal"; +import StatusIndicator from "../../ui/StatusIndicator"; + +/** One OpenTofu run this view is showing: a start, or a follow of one already running. */ +export interface DeployRun { + stateKey: string; + region: string; + label: string; + /** Null until the starting command resolves. */ + opId: string | null; + events: TofuEvent[]; + done: boolean; + cancellable: boolean; + result: TofuOutcome | null; +} + +type Subscription = { unlisten: (() => void) | null }; + +/** + * Runs keyed by state key. `start` subscribes to `aws-deploy-progress` before + * invoking, keeps every event for the state key it started until the op id + * is known (the backend may emit just before the invoke resolves), then keeps + * only that op's events. Resolves to the command's error, or null once started. + */ +export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOutcome | null }) => void) { + const [runs, setRuns] = useState>({}); + const subs = useRef(new Map()); + const mounted = useRef(true); + const onDoneRef = useRef(onDone); + onDoneRef.current = onDone; + + useEffect(() => { + mounted.current = true; + const all = subs.current; + return () => { + mounted.current = false; + for (const s of all.values()) s.unlisten?.(); + all.clear(); + }; + }, []); + + const start = useCallback( + async (stateKey: string, region: string, label: string, run: () => Promise): Promise => { + subs.current.get(stateKey)?.unlisten?.(); + const sub: Subscription = { unlisten: null }; + subs.current.set(stateKey, sub); + const current = () => mounted.current && subs.current.get(stateKey) === sub; + const stop = () => { + sub.unlisten?.(); + sub.unlisten = null; + if (subs.current.get(stateKey) === sub) subs.current.delete(stateKey); + }; + + let opId: string | null = null; + const early: ProgressPayload[] = []; + const apply = (p: ProgressPayload) => { + setRuns((prev) => { + const r = prev[stateKey]; + if (!r || r.opId !== p.op_id || r.done) return prev; + return { + ...prev, + [stateKey]: { + ...r, + events: p.events.length ? [...r.events, ...p.events] : r.events, + done: p.done, + cancellable: p.done ? false : p.cancellable, + result: p.result ?? r.result, + }, + }; + }); + if (p.done) { + stop(); + onDoneRef.current?.({ stateKey, result: p.result ?? null }); + } + }; + + setRuns((prev) => ({ + ...prev, + [stateKey]: { stateKey, region, label, opId: null, events: [], done: false, cancellable: false, result: null }, + })); + + const unlisten = await listen(AWS_DEPLOY_PROGRESS_EVENT, ({ payload }) => { + if (!current() || payload.state_key !== stateKey) return; + if (opId === null) early.push(payload); + else if (payload.op_id === opId) apply(payload); + }); + if (!current()) { + unlisten(); + return null; + } + sub.unlisten = unlisten; + + try { + opId = await run(); + } catch (e) { + stop(); + if (mounted.current) { + setRuns((prev) => { + const { [stateKey]: _gone, ...rest } = prev; + return rest; + }); + } + return toAwsError(e); + } + if (!current()) return null; + const id = opId; + setRuns((prev) => (prev[stateKey] ? { ...prev, [stateKey]: { ...prev[stateKey], opId: id } } : prev)); + for (const p of early.splice(0)) if (p.op_id === id) apply(p); + return null; + }, + [], + ); + + const dismiss = useCallback((stateKey: string) => { + setRuns((prev) => { + if (!prev[stateKey]?.done) return prev; + const { [stateKey]: _gone, ...rest } = prev; + return rest; + }); + }, []); + + return { runs, start, dismiss }; +} + +function EventLine({ e }: { e: TofuEvent }) { + const isError = e.level === "error"; + return ( +
  • + {(e.resource || e.action) && ( + + {e.resource && {e.resource}} + {e.action && {e.action}} + + )} + {isError ? ( + <> + Error: +
    {e.message}
    + + ) : ( + {e.message} + )} +
  • + ); +} + +/** A run's events in order, its final result, and Cancel (after a confirmation). */ +export default function DeployProgress({ + run, + connectionId, + onDismiss, +}: { + run: DeployRun; + connectionId: string; + onDismiss?: () => void; +}) { + const [confirming, setConfirming] = useState(false); + const [cancelling, setCancelling] = useState(false); + const [cancelError, setCancelError] = useState(null); + const listRef = useRef(null); + + useEffect(() => { + const el = listRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [run.events.length]); + + const cancel = async () => { + setCancelling(true); + setCancelError(null); + try { + await cancelAwsDeployment(connectionId, run.region, run.stateKey); + setConfirming(false); + } catch (e) { + setCancelError(toAwsError(e)); + } finally { + setCancelling(false); + } + }; + + const status = run.done + ? null + : run.opId === null + ? { tone: "busy" as const, label: "Starting…" } + : { tone: "busy" as const, label: "Running" }; + + return ( +
    +
    +
    + {run.label} + {status && } +
    +
    + {!run.done && ( + + )} + {run.done && onDismiss && ( + + )} +
    +
    + {run.events.length > 0 && ( +
      + {run.events.map((e, i) => ( + + ))} +
    + )} + {run.done && run.result && ( +

    + {run.result.ok ? ( + + ) : ( + + )} +

    + )} + {cancelError && !confirming && ( +

    + {awsErrorMessage(cancelError)} +

    + )} + {confirming && ( + setConfirming(false)} + footer={ + <> + + + + } + > +
    +

    + OpenTofu stops gracefully and saves its state. Anything it already created stays; Repair or Update + finishes the job later. +

    + {cancelError && ( +

    + {awsErrorMessage(cancelError)} +

    + )} +
    +
    + )} +
    + ); +} diff --git a/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx b/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx new file mode 100644 index 0000000..937f8af --- /dev/null +++ b/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx @@ -0,0 +1,329 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import EnvironmentsPanel from "./EnvironmentsPanel"; +import type { DeployProgress, EnvironmentScan } from "../../../lib/types"; +import { conn, env, progress, scan, tofuEvent } from "./testkit"; + +const invoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string, args?: unknown) => invoke(cmd, args), +})); + +type Handler = (e: { payload: DeployProgress }) => void; +const handlers = new Set(); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async (_name: string, handler: Handler) => { + handlers.add(handler); + return () => handlers.delete(handler); + }), +})); +function emit(p: DeployProgress) { + act(() => { + for (const h of [...handlers]) h({ payload: p }); + }); +} + +/** Mock the backend: `detect` answers detection; `rest` answers everything else. */ +function backend(detect: () => EnvironmentScan, rest: (cmd: string, args: unknown) => unknown = () => { + throw new Error("unexpected"); +}) { + invoke.mockImplementation(async (cmd: string, args: unknown) => { + if (cmd === "detect_aws_environments") return detect(); + return rest(cmd, args); + }); +} + +const detectCalls = () => invoke.mock.calls.filter((c) => c[0] === "detect_aws_environments").length; + +async function open() { + fireEvent.click(screen.getByRole("button", { name: "Environments" })); + await waitFor(() => expect(detectCalls()).toBeGreaterThan(0)); +} + +describe("EnvironmentsPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + handlers.clear(); + }); + + it("detects in the connection's default region when opened, and again on a region change", async () => { + backend(() => scan()); + render(); + expect(detectCalls()).toBe(0); + await open(); + expect(invoke).toHaveBeenCalledWith("detect_aws_environments", { connectionId: "h1", region: "eu-west-1" }); + fireEvent.change(screen.getByLabelText("Region"), { target: { value: "us-east-1" } }); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("detect_aws_environments", { connectionId: "h1", region: "us-east-1" }), + ); + fireEvent.click(await screen.findByRole("button", { name: "Detect again" })); + await waitFor(() => expect(detectCalls()).toBe(3)); + }); + + it("shows deploy as primary when nothing is found and the user is an admin", async () => { + backend(() => scan()); + render(); + await open(); + const deploy = await screen.findByRole("button", { name: "Deploy a new environment" }); + expect(deploy.className).toContain("--accent-emphasis"); + expect(screen.getByText(/No Triple-C environment in eu-west-1/)).toBeInTheDocument(); + fireEvent.click(deploy); + expect(screen.getByRole("dialog", { name: "Deploy a new environment" })).toBeInTheDocument(); + }); + + it("deploy is secondary when environments exist", async () => { + backend(() => scan({ environments: [env({ onboarding: { state: "joined" }, actions: [] })] })); + render(); + await open(); + const deploy = await screen.findByRole("button", { name: "Deploy a new environment" }); + expect(deploy.className).not.toContain("--accent-emphasis"); + expect(within(screen.getByTestId("env-dev")).getByText("Joined")).toBeInTheDocument(); + }); + + it("offers an admin bundle instead of deploy for a workspace user with nothing found", async () => { + backend( + () => scan(), + (cmd) => { + if (cmd === "aws_policy_document") return [{ name: "environment-admin.json", json: "{}" }]; + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + expect(await screen.findByText(/needs an environment admin/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Deploy a new environment" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Show the environment-admin policies" })); + expect(await screen.findByText("environment-admin.json")).toBeInTheDocument(); + expect(invoke).toHaveBeenCalledWith("aws_policy_document", { tier: "environment_admin" }); + fireEvent.click(screen.getByRole("button", { name: "Request an environment from an admin…" })); + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByRole("button", { name: "Save bundle to folder…" })).toBeInTheDocument(); + }); + + it("treats a connection with no permission report as not an admin", async () => { + backend(() => scan()); + render(); + await open(); + expect(await screen.findByRole("button", { name: "Request an environment from an admin…" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Deploy a new environment" })).toBeNull(); + }); + + it("an incomplete environment offers repair and destroy, destroy only after typing its name", async () => { + backend( + () => scan({ environments: [env({ state: { state: "incomplete", lock: null }, actions: ["repair", "destroy"], info: null, onboarding: null, template_version: null })] }), + (cmd) => { + if (cmd === "destroy_incomplete_aws_environment") return "op9"; + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + const row = await screen.findByTestId("env-dev"); + expect(within(row).getByText("Incomplete")).toBeInTheDocument(); + expect(within(row).getByRole("button", { name: "Repair dev" })).toBeInTheDocument(); + + // Repair without a record asks for the network and warns about a change. + fireEvent.click(within(row).getByRole("button", { name: "Repair dev" })); + const repair = screen.getByRole("dialog", { name: "Repair dev" }); + expect(within(repair).getByRole("radio", { name: "Public" })).toHaveAttribute("aria-checked", "true"); + expect(within(repair).getByText(/different network than the one it was deployed with replaces/)).toBeInTheDocument(); + fireEvent.click(within(repair).getByRole("button", { name: "Cancel" })); + + fireEvent.click(within(row).getByRole("button", { name: "Destroy dev" })); + const dialog = screen.getByRole("dialog", { name: "Destroy dev" }); + const confirm = within(dialog).getByRole("button", { name: "Destroy" }); + expect(confirm).toBeDisabled(); + fireEvent.change(within(dialog).getByLabelText(/Type dev to confirm/), { target: { value: "de" } }); + expect(confirm).toBeDisabled(); + fireEvent.change(within(dialog).getByLabelText(/Type dev to confirm/), { target: { value: "dev" } }); + expect(confirm).toBeEnabled(); + fireEvent.click(confirm); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("destroy_incomplete_aws_environment", { + connectionId: "h1", + region: "eu-west-1", + name: "dev", + }), + ); + }); + + it("a stale lock offers unlock with its owner and age", async () => { + const lock = { who: "alice@laptop", created: "2026-11-14T07:00:00Z", operation: "OperationTypeApply", age_secs: 3 * 3600 + 300, local_process: false }; + backend( + () => scan({ environments: [env({ state: { state: "incomplete", lock }, actions: ["repair", "unlock"], onboarding: null })] }), + (cmd) => { + if (cmd === "unlock_aws_state") return undefined; + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + const row = await screen.findByTestId("env-dev"); + expect(within(row).getByText(/Locked by alice@laptop/)).toBeInTheDocument(); + expect(within(row).getByText(/3 h 5 min ago/)).toBeInTheDocument(); + expect(within(row).queryByRole("button", { name: "Destroy dev" })).toBeNull(); + + fireEvent.click(within(row).getByRole("button", { name: "Unlock dev" })); + const dialog = screen.getByRole("dialog", { name: "Unlock dev" }); + expect(within(dialog).getByText(/alice@laptop/)).toBeInTheDocument(); + expect(within(dialog).getByText(/3 h 5 min ago/)).toBeInTheDocument(); + const confirm = within(dialog).getByRole("button", { name: "Unlock" }); + expect(confirm).toBeDisabled(); + fireEvent.change(within(dialog).getByLabelText(/Type dev to confirm/), { target: { value: "dev" } }); + fireEvent.click(confirm); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("unlock_aws_state", { + connectionId: "h1", + region: "eu-west-1", + stateKey: "environments/dev/terraform.tfstate", + }), + ); + await waitFor(() => expect(detectCalls()).toBe(2)); + }); + + it("shows the server's refusal when a lock is still held", async () => { + backend( + () => scan({ environments: [env({ state: { state: "incomplete", lock: null }, actions: ["repair", "destroy"], onboarding: null })] }), + (cmd) => { + if (cmd === "repair_aws_environment") throw { kind: "other", message: "The state is locked; Unlock first" }; + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + // With a record, Repair runs straight away on the recorded network. + fireEvent.click(await screen.findByRole("button", { name: "Repair dev" })); + expect(await screen.findByText("The state is locked; Unlock first")).toBeInTheDocument(); + expect(invoke).toHaveBeenCalledWith("repair_aws_environment", { connectionId: "h1", region: "eu-west-1", name: "dev", network: null }); + }); + + it("join onboards an admin and shows progress until done, then re-detects", async () => { + const key = "users/dev/abc123abc123/terraform.tfstate"; + let detects = 0; + backend( + () => { + detects += 1; + return scan({ environments: [env({ onboarding: detects > 1 ? { state: "joined" } : { state: "not_joined" } })] }); + }, + async (cmd) => { + if (cmd === "onboard_aws_user") { + // The backend can emit before the invoke resolves: an old follower's + // line (another op) and this run's first line. + emit(progress({ op_id: "old", state_key: key, events: [tofuEvent("stale line")] })); + emit(progress({ op_id: "op1", state_key: key, events: [tofuEvent("Planning")], cancellable: false })); + return "op1"; + } + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + fireEvent.click(await screen.findByRole("button", { name: "Join dev" })); + expect(await screen.findByText("Planning")).toBeInTheDocument(); + expect(screen.queryByText("stale line")).toBeNull(); + expect(invoke).toHaveBeenCalledWith("onboard_aws_user", { connectionId: "h1", region: "eu-west-1", environment: "dev" }); + expect(screen.getByRole("button", { name: "Cancel deployment" })).toBeDisabled(); + + emit(progress({ op_id: "op1", state_key: key, events: [tofuEvent("Creating...", { resource: "aws_iam_role.task", action: "create" })] })); + expect(screen.getByText("aws_iam_role.task")).toBeInTheDocument(); + emit(progress({ op_id: "other", state_key: "environments/x/terraform.tfstate", events: [tofuEvent("unrelated")] })); + expect(screen.queryByText("unrelated")).toBeNull(); + expect(detectCalls()).toBe(1); + + emit(progress({ op_id: "op1", state_key: key, events: [], done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "Apply complete! Resources: 4 added" } })); + expect(await screen.findByText(/Apply complete! Resources: 4 added/)).toBeInTheDocument(); + await waitFor(() => expect(detectCalls()).toBe(2)); + expect(await within(screen.getByTestId("env-dev")).findByText("Joined")).toBeInTheDocument(); + }); + + it("a workspace user's join opens the onboarding bundle and waits for an admin", async () => { + backend( + () => scan({ environments: [env()] }), + (cmd) => { + if (cmd === "save_aws_admin_bundle") return "/home/me/onboard-dev"; + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + fireEvent.click(await screen.findByRole("button", { name: "Join dev" })); + const dialog = screen.getByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", { + connectionId: "h1", + region: "eu-west-1", + kind: "user_onboard", + environment: "dev", + ownerId: "AIDAEXAMPLEEXAMPLE01", + network: null, + }), + ); + expect(invoke.mock.calls.some((c) => c[0] === "onboard_aws_user")).toBe(false); + fireEvent.click(within(dialog).getByRole("button", { name: "Close" })); + expect(within(screen.getByTestId("env-dev")).getByText("Waiting for an admin to onboard you")).toBeInTheDocument(); + }); + + it("follows a deployment running on this machine when opened, and cancels after confirming", async () => { + const lock = { who: "me@here", created: "x", operation: "OperationTypeApply", age_secs: 60, local_process: true }; + backend( + () => scan({ environments: [env({ state: { state: "deploying", lock }, actions: ["follow"], onboarding: null })] }), + async (cmd) => { + if (cmd === "follow_aws_deployment") { + emit(progress({ op_id: "f1", events: [tofuEvent("replayed line")] })); + return "f1"; + } + if (cmd === "cancel_aws_deployment") return undefined; + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + expect(await screen.findByText("replayed line")).toBeInTheDocument(); + expect(invoke).toHaveBeenCalledWith("follow_aws_deployment", { + connectionId: "h1", + region: "eu-west-1", + stateKey: "environments/dev/terraform.tfstate", + }); + fireEvent.click(screen.getByRole("button", { name: "Cancel deployment" })); + const dialog = screen.getByRole("dialog", { name: "Cancel this deployment?" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel deployment" })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("cancel_aws_deployment", { + connectionId: "h1", + region: "eu-west-1", + stateKey: "environments/dev/terraform.tfstate", + }), + ); + }); + + it("shows a failed run's errors in full", async () => { + backend( + () => scan({ environments: [env({ state: { state: "update_available" }, actions: ["join", "update"], onboarding: { state: "joined" } })] }), + async (cmd) => { + if (cmd === "update_aws_environment") return "u1"; + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + fireEvent.click(await screen.findByRole("button", { name: "Update dev" })); + await waitFor(() => expect(invoke).toHaveBeenCalledWith("update_aws_environment", { connectionId: "h1", region: "eu-west-1", name: "dev" })); + const long = "Error: creating IAM Role (triple-c-dev-task): AccessDenied: User is not authorized to perform iam:CreateRole\n with aws_iam_role.task"; + emit(progress({ op_id: "u1", events: [tofuEvent(long, { level: "error", kind: "diagnostic" })] })); + emit(progress({ op_id: "u1", events: [], done: true, cancellable: false, result: { ok: false, exit_code: 1, summary: "Apply failed" } })); + expect(await screen.findByText(/Failed \(exit 1\): Apply failed/)).toBeInTheDocument(); + expect(screen.getByText((_, el) => el?.textContent === long && el.tagName === "PRE")).toBeInTheDocument(); + }); + + it("routes a detection failure through the AWS error copy", async () => { + backend(() => { + throw { kind: "state_bucket_taken", bucket: "triple-c-tfstate-123456789012-eu-west-1" }; + }); + render(); + await open(); + expect( + await screen.findByText("The state bucket name triple-c-tfstate-123456789012-eu-west-1 is already taken by another AWS account."), + ).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/hosts/EnvironmentsPanel.tsx b/app/src/components/settings/hosts/EnvironmentsPanel.tsx new file mode 100644 index 0000000..d2cb958 --- /dev/null +++ b/app/src/components/settings/hosts/EnvironmentsPanel.tsx @@ -0,0 +1,490 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { + deployAwsEnvironment, + destroyIncompleteAwsEnvironment, + detectAwsEnvironments, + followAwsDeployment, + onboardAwsUser, + repairAwsEnvironment, + unlockAwsState, + updateAwsEnvironment, +} from "../../../lib/tauri-commands"; +import { awsErrorMessage, toAwsError } from "../../../lib/aws-error"; +import type { + AwsError, + DetectedEnvironment, + EnvironmentScan, + HostConnection, + ModuleKind, + NetworkChoice, + PermissionSummary, + PolicyFile, +} from "../../../lib/types"; +import Button from "../../ui/Button"; +import Field, { inputClass, selectClass } from "../../ui/Field"; +import Modal from "../../ui/Modal"; +import StatusIndicator from "../../ui/StatusIndicator"; +import AdminBundleModal from "./AdminBundleModal"; +import DeployEnvironmentModal from "./DeployEnvironmentModal"; +import DeployProgress, { useDeployRuns, type DeployRun } from "./DeployProgress"; +import PolicyFiles from "./PolicyFiles"; +import { + COMMON_REGIONS, + environmentStateKey, + formatAge, + lockLine, + onboardingStateKey, + stateLock, + stateStatus, +} from "./environmentFormat"; + +/** A typed confirmation: the action runs only once the environment's name is typed. */ +function TypedConfirmModal({ + title, + name, + confirmLabel, + children, + onConfirm, + onClose, +}: { + title: string; + name: string; + confirmLabel: string; + children: ReactNode; + /** Resolves to the command's error, or null when done. */ + onConfirm: () => Promise; + onClose: () => void; +}) { + const [typed, setTyped] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const confirm = async () => { + setBusy(true); + setError(null); + const err = await onConfirm(); + setBusy(false); + if (err) setError(err); + else onClose(); + }; + return ( + + + + + } + > +
    + {children} + + {(id) => ( + setTyped(e.target.value)} /> + )} + + {error && ( +

    + {awsErrorMessage(error)} +

    + )} +
    +
    + ); +} + +type Bundle = { kind: ModuleKind; environment?: string; ownerId?: string }; + +/** The environment a run's state key belongs to, if any. */ +function runEnvironment(stateKey: string): string | null { + const m = /^(?:environments|users)\/([^/]+)\//.exec(stateKey); + return m ? m[1] : null; +} + +/** + * The environments a connection can reach in a region (spec §2): detection, + * each environment's state and actions, and deploy/join for this caller's tier. + */ +export default function EnvironmentsPanel({ + connection, + tier, +}: { + connection: HostConnection; + /** The connection's last permission report; absent means unknown, treated as not an admin. */ + tier?: PermissionSummary; +}) { + const isAdmin = tier === "environment_admin"; + const [open, setOpen] = useState(false); + const [region, setRegion] = useState(connection.kind.default_region); + const [scan, setScan] = useState(null); + const [detecting, setDetecting] = useState(false); + const [detectError, setDetectError] = useState(null); + const [rowErrors, setRowErrors] = useState>({}); + const [pending, setPending] = useState>({}); + const [waiting, setWaiting] = useState>(new Set()); + const [deployOpen, setDeployOpen] = useState(false); + const [repairOf, setRepairOf] = useState(null); + const [destroyOf, setDestroyOf] = useState(null); + const [unlockOf, setUnlockOf] = useState(null); + const [bundle, setBundle] = useState(null); + const [copyNote, setCopyNote] = useState(null); + const request = useRef(0); + const followed = useRef(new Set()); + + const detect = useCallback(async () => { + const id = ++request.current; + setDetecting(true); + setDetectError(null); + try { + const found = await detectAwsEnvironments(connection.id, region); + if (id === request.current) setScan(found); + } catch (e) { + if (id === request.current) { + setScan(null); + setDetectError(toAwsError(e)); + } + } finally { + if (id === request.current) setDetecting(false); + } + }, [connection.id, region]); + + // A finished run (its result line shown) re-detects. + const detectRef = useRef(detect); + detectRef.current = detect; + const { runs, start, dismiss } = useDeployRuns(() => void detectRef.current()); + + useEffect(() => { + if (open) void detect(); + }, [open, detect]); + + const launch = useCallback( + async (envName: string, stateKey: string, label: string, run: () => Promise) => { + setRowErrors(({ [envName]: _gone, ...rest }) => rest); + setPending((p) => ({ ...p, [envName]: true })); + const err = await start(stateKey, region, label, run); + setPending(({ [envName]: _gone, ...rest }) => rest); + if (err) setRowErrors((r) => ({ ...r, [envName]: err })); + return err; + }, + [start, region], + ); + + // Reattach to a deploy running on this machine (a re-follow replays its whole log). + useEffect(() => { + if (!scan) return; + for (const e of scan.environments) { + const key = environmentStateKey(e.name); + const seen = `${scan.region}:${key}`; + if (e.actions.includes("follow") && !runs[key] && !followed.current.has(seen)) { + followed.current.add(seen); + void launch(e.name, key, `Deploy of ${e.name}`, () => followAwsDeployment(connection.id, scan.region, key)); + } + } + }, [scan, runs, launch, connection.id]); + + const regions = COMMON_REGIONS.includes(connection.kind.default_region) + ? COMMON_REGIONS + : [connection.kind.default_region, ...COMMON_REGIONS]; + + const copy = async (file: PolicyFile) => { + try { + await navigator.clipboard.writeText(file.json); + setCopyNote(`${file.name} copied to clipboard.`); + } catch (e) { + setCopyNote(`Could not copy ${file.name}: ${awsErrorMessage(toAwsError(e))}`); + } + }; + + const deploy = (name: string, network: NetworkChoice) => + launch(name, environmentStateKey(name), `Deploy of ${name}`, () => + deployAwsEnvironment(connection.id, region, name, network), + ); + const repair = (name: string, network?: NetworkChoice) => + launch(name, environmentStateKey(name), `Repair of ${name}`, () => + repairAwsEnvironment(connection.id, region, name, network), + ); + + const join = (e: DetectedEnvironment) => { + if (!scan) return; + if (isAdmin) { + void launch(e.name, onboardingStateKey(e.name, scan.owner_key), `Joining ${e.name}`, () => + onboardAwsUser(connection.id, region, e.name), + ); + } else { + setWaiting((w) => new Set(w).add(e.name)); + setBundle({ kind: "user_onboard", environment: e.name, ownerId: scan.owner_id }); + } + }; + + const rowRuns = (name: string) => + Object.values(runs).filter((r) => r.region === region && runEnvironment(r.stateKey) === name); + const shown = new Set(scan?.environments.map((e) => e.name) ?? []); + const looseRuns = Object.values(runs).filter( + (r) => r.region !== region || !shown.has(runEnvironment(r.stateKey) ?? ""), + ); + const progress = (r: DeployRun) => ( + dismiss(r.stateKey)} + /> + ); + + const envRow = (e: DetectedEnvironment) => { + const status = stateStatus(e.state); + const lock = stateLock(e.state); + const has = (a: DetectedEnvironment["actions"][number]) => e.actions.includes(a); + const onboarding = e.onboarding?.state; + const busy = !!pending[e.name]; + const rowError = rowErrors[e.name]; + return ( +
  • +
    +
    +
    + {e.name} + + {onboarding === "joined" && } + {onboarding === "in_progress" && } + {onboarding === "incomplete" && } +
    +
    + {e.template_version !== null ? `Module version ${e.template_version}` : "Module version unknown"} + {e.info && ` · ${e.info.network_mode} network`} +
    + {lock && ( +
    + {lockLine(lock)} + {lock.local_process && " · running on this machine"} +
    + )} + {e.state.state === "unreadable_record" && ( +
    The environment's record can't be used: {e.state.reason}
    + )} + {!isAdmin && waiting.has(e.name) && onboarding !== "joined" && ( +
    Waiting for an admin to onboard you
    + )} +
    +
    + {has("join") && onboarding !== "joined" && onboarding !== "in_progress" && ( + + )} + {has("follow") && ( + + )} + {has("update") && ( + + )} + {has("repair") && ( + + )} + {has("unlock") && ( + + )} + {has("destroy") && ( + + )} +
    +
    + {rowError && ( +

    + {awsErrorMessage(rowError)} +

    + )} + {rowRuns(e.name).map(progress)} +
  • + ); + }; + + const nothingFound = !!scan && scan.environments.length === 0; + + return ( +
    + + {open && ( +
    +
    + + {(id) => ( + + )} + + +
    + {detecting && ( +

    + Detecting… +

    + )} + {detectError && ( +

    + {awsErrorMessage(detectError)} +

    + )} + {scan && isAdmin && scan.boundary === "no" && ( +

    + This account has no Triple-C permissions boundary yet; deploying creates it first. +

    + )} + {scan && scan.environments.length > 0 &&
      {scan.environments.map(envRow)}
    } + {nothingFound && ( +

    + No Triple-C environment in {scan.region}. + {!isAdmin && + " Deploying one needs an environment admin: attach the environment-admin policies to an identity, or send an admin a bundle to deploy."} + {!tier && " Run Check to find out whether you can deploy one yourself."} +

    + )} + {scan && isAdmin && ( + + )} + {nothingFound && !isAdmin && ( +
    + + + {copyNote && ( +

    + {copyNote} +

    + )} +
    + )} + {looseRuns.map(progress)} +
    + )} + {scan && (deployOpen || repairOf) && ( + { + setDeployOpen(false); + setRepairOf(null); + }} + onDeploy={repairOf ? (name, network) => repair(name, network) : deploy} + onDetect={() => void detect()} + /> + )} + {destroyOf && ( + setDestroyOf(null)} + onConfirm={() => + launch(destroyOf, environmentStateKey(destroyOf), `Destroy of ${destroyOf}`, () => + destroyIncompleteAwsEnvironment(connection.id, region, destroyOf), + ) + } + > +

    + {destroyOf} never finished deploying, so OpenTofu removes everything it created for it. This can't be + undone. +

    +
    + )} + {unlockOf && ( + setUnlockOf(null)} + onConfirm={async () => { + try { + await unlockAwsState(connection.id, region, environmentStateKey(unlockOf.name)); + } catch (e) { + return toAwsError(e); + } + void detect(); + return null; + }} + > + {(() => { + const lock = stateLock(unlockOf.state); + return lock ? ( +

    + Held by {lock.who || "unknown"} since{" "} + {formatAge(lock.age_secs)} ago ({lock.created} + {lock.operation && `, ${lock.operation}`}). +

    + ) : null; + })()} +

    + Unlock only if that deployment has stopped. Removing the lock of a run that is still going lets two runs + write the state at once. +

    +
    + )} + {bundle && ( + setBundle(null)} + /> + )} +
    + ); +} diff --git a/app/src/components/settings/hosts/NetworkFields.tsx b/app/src/components/settings/hosts/NetworkFields.tsx new file mode 100644 index 0000000..d4499b2 --- /dev/null +++ b/app/src/components/settings/hosts/NetworkFields.tsx @@ -0,0 +1,67 @@ +import type { NetworkChoice } from "../../../lib/types"; +import Field, { SwitchRow, monoInputClass } from "../../ui/Field"; +import SegmentedControl from "../../ui/SegmentedControl"; +import Toggle from "../../ui/Toggle"; +import type { NetworkDraft } from "./environmentFormat"; + +/** D4's modes and their cost lines. */ +const COST: Record = { + public: "$0 — tasks get a public IP, no inbound access", + "private-nat": "about $33/month for the NAT gateway", + existing: "No new network resources — tasks run in your VPC and two subnets in different availability zones", +}; + +/** The network picker shared by deploy, repair and the environment bundle. */ +export default function NetworkFields({ + value, + onChange, + error, +}: { + value: NetworkDraft; + onChange: (next: NetworkDraft) => void; + /** Why the existing-VPC fields aren't usable yet. */ + error?: string | null; +}) { + const set = (patch: Partial) => onChange({ ...value, ...patch }); + return ( +
    +
    Network
    + + label="Network" + value={value.mode} + onChange={(mode) => set({ mode })} + segments={[ + { value: "public", label: "Public" }, + { value: "private-nat", label: "Private + NAT" }, + { value: "existing", label: "Existing VPC" }, + ]} + /> +

    {COST[value.mode]}

    + {value.mode === "existing" && ( +
    + + {(id) => ( + set({ vpcId: e.target.value })} /> + )} + + + {(id) => ( + set({ subnet1: e.target.value })} /> + )} + + + {(id) => ( + set({ subnet2: e.target.value })} /> + )} + + set({ assignPublicIp: v })} />} + /> + {error &&

    {error}

    } +
    + )} +
    + ); +} diff --git a/app/src/components/settings/hosts/PolicyFiles.tsx b/app/src/components/settings/hosts/PolicyFiles.tsx new file mode 100644 index 0000000..703b2c7 --- /dev/null +++ b/app/src/components/settings/hosts/PolicyFiles.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { awsPolicyDocument } from "../../../lib/tauri-commands"; +import { awsErrorMessage, toAwsError } from "../../../lib/aws-error"; +import type { PolicyFile, Tier } from "../../../lib/types"; +import Button from "../../ui/Button"; + +/** A tier's policy files, loaded on demand: each one shown and copied on its own. */ +export default function PolicyFiles({ + tier, + label, + onCopy, +}: { + tier: Tier; + label: string; + onCopy: (file: PolicyFile) => void; +}) { + const [files, setFiles] = useState(null); + const [error, setError] = useState(null); + + const load = async () => { + setError(null); + try { + setFiles(await awsPolicyDocument(tier)); + } catch (e) { + setError(`Could not load the policy: ${awsErrorMessage(toAwsError(e))}`); + } + }; + + if (!files) { + return ( +
    + + {error && ( +

    + {error} +

    + )} +
    + ); + } + return ( +
    +

    + {tier === "workspace_user" ? "Workspace user" : "Environment admin"}:{" "} + {files.length === 1 + ? "attach this file as a customer managed policy." + : `Attach all ${files.length} files, each as its own customer managed policy.`} +

    + {files.map((f) => ( +
    +
    + {f.name} + +
    +
    + Show JSON +
    +              {f.json}
    +            
    +
    +
    + ))} +
    + ); +} + diff --git a/app/src/components/settings/hosts/environmentFormat.ts b/app/src/components/settings/hosts/environmentFormat.ts new file mode 100644 index 0000000..ab1e49a --- /dev/null +++ b/app/src/components/settings/hosts/environmentFormat.ts @@ -0,0 +1,97 @@ +// Pure helpers shared by the environments UI. The name and id rules mirror +// `aws/tofu_modules.rs`; the server re-validates everything. +import type { EnvironmentState, LockInfo, NetworkChoice } from "../../../lib/types"; +import type { StatusTone } from "../../ui/StatusIndicator"; + +export const COMMON_REGIONS = [ + "us-east-1", "us-east-2", "us-west-1", "us-west-2", "ca-central-1", + "eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1", + "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ap-northeast-2", "ap-south-1", + "sa-east-1", +]; + +/** `validate_environment_name`: null when valid, else why not. */ +export function environmentNameError(name: string): string | null { + const ok = + /^[a-z][a-z0-9-]{0,19}$/.test(name) && !name.endsWith("-") && !name.includes("--"); + return ok + ? null + : "Use 1 to 20 lowercase letters, digits and single hyphens, starting with a letter and not ending in a hyphen."; +} + +/** `is_aws_id`: the prefix, then 8 or 17 lowercase hex characters. */ +export function isAwsId(id: string, prefix: "vpc-" | "subnet-"): boolean { + return new RegExp(`^${prefix}([0-9a-f]{8}|[0-9a-f]{17})$`).test(id); +} + +/** The fields behind the network picker, kept as text while the user types. */ +export interface NetworkDraft { + mode: NetworkChoice["mode"]; + vpcId: string; + subnet1: string; + subnet2: string; + assignPublicIp: boolean; +} + +export const DEFAULT_NETWORK: NetworkDraft = { + mode: "public", + vpcId: "", + subnet1: "", + subnet2: "", + assignPublicIp: false, +}; + +/** The draft as a `NetworkChoice`, or why it can't be one yet. */ +export function networkFromDraft(d: NetworkDraft): { network: NetworkChoice } | { error: string } { + if (d.mode === "public") return { network: { mode: "public" } }; + if (d.mode === "private-nat") return { network: { mode: "private-nat" } }; + const vpc = d.vpcId.trim(); + const subnets = [d.subnet1.trim(), d.subnet2.trim()]; + if (!isAwsId(vpc, "vpc-")) return { error: "Enter a VPC ID (vpc- and 8 or 17 hex characters)." }; + if (!subnets.every((s) => isAwsId(s, "subnet-"))) { + return { error: "Enter two subnet IDs (subnet- and 8 or 17 hex characters), in different availability zones." }; + } + if (subnets[0] === subnets[1]) return { error: "The two subnets must be different." }; + return { network: { mode: "existing", vpc_id: vpc, subnet_ids: subnets, assign_public_ip: d.assignPublicIp } }; +} + +export const environmentStateKey = (env: string) => `environments/${env}/terraform.tfstate`; +export const onboardingStateKey = (env: string, ownerKey: string) => `users/${env}/${ownerKey}/terraform.tfstate`; +export const BOUNDARY_STATE_KEY = "boundary/terraform.tfstate"; + +/** "45 s", "12 min", "3 h 5 min", "2 days". */ +export function formatAge(secs: number): string { + if (secs < 60) return `${Math.max(0, Math.floor(secs))} s`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins} min`; + const hours = Math.floor(mins / 60); + if (hours < 48) return mins % 60 ? `${hours} h ${mins % 60} min` : `${hours} h`; + return `${Math.floor(hours / 24)} days`; +} + +/** "Locked by · ago ()". */ +export function lockLine(lock: LockInfo): string { + const op = lock.operation ? ` (${lock.operation})` : ""; + return `Locked by ${lock.who || "unknown"} · ${formatAge(lock.age_secs)} ago${op}`; +} + +export function stateStatus(s: EnvironmentState): { tone: StatusTone; label: string } { + switch (s.state) { + case "ready": + return { tone: "ok", label: "Ready" }; + case "update_available": + return { tone: "ok", label: "Update available" }; + case "deploying": + return { tone: "busy", label: "Deploying" }; + case "incomplete": + return { tone: "error", label: "Incomplete" }; + case "externally_managed": + return { tone: "ok", label: "Available" }; + case "unreadable_record": + return { tone: "error", label: "Unreadable record" }; + } +} + +export function stateLock(s: EnvironmentState): LockInfo | null { + return s.state === "deploying" || s.state === "incomplete" ? s.lock : null; +} diff --git a/app/src/components/settings/hosts/testkit.ts b/app/src/components/settings/hosts/testkit.ts new file mode 100644 index 0000000..1532b68 --- /dev/null +++ b/app/src/components/settings/hosts/testkit.ts @@ -0,0 +1,64 @@ +// Shared fixtures for the environments UI tests (not a test file itself). +import type { + DeployProgress, + DetectedEnvironment, + EnvironmentInfo, + EnvironmentScan, + HostConnection, + TofuEvent, +} from "../../../lib/types"; + +export const conn: HostConnection = { + id: "h1", + name: "Prod", + kind: { type: "aws", auth: { method: "profile", name: "prod-sso" }, default_region: "eu-west-1" }, +}; + +export function scan(over: Partial = {}): EnvironmentScan { + return { + region: "eu-west-1", + account: "123456789012", + owner_id: "AIDAEXAMPLEEXAMPLE01", + owner_key: "abc123abc123", + state_bucket: "yes", + boundary: "yes", + environments: [], + ...over, + }; +} + +export function info(name = "dev"): EnvironmentInfo { + return { + schema: 1, + name, + template_version: 1, + cluster_arn: `arn:aws:ecs:eu-west-1:123456789012:cluster/triple-c-${name}`, + security_group_id: "sg-0123abcd", + subnet_ids: ["subnet-0123abcd", "subnet-4567abcd"], + assign_public_ip: true, + kms_key_arn: "arn:aws:kms:eu-west-1:123456789012:key/k", + log_group: `/triple-c/${name}`, + network_mode: "public", + tenancy: "strict", + }; +} + +export function env(over: Partial = {}): DetectedEnvironment { + return { + name: "dev", + template_version: 1, + state: { state: "ready" }, + actions: ["join"], + info: info(over.name ?? "dev"), + onboarding: { state: "not_joined" }, + ...over, + }; +} + +export function tofuEvent(message: string, over: Partial = {}): TofuEvent { + return { ts: "2026-11-14T10:00:00Z", level: "info", kind: "apply_progress", resource: null, action: null, message, ...over }; +} + +export function progress(over: Partial): DeployProgress { + return { op_id: "op1", state_key: "environments/dev/terraform.tfstate", events: [], done: false, cancellable: true, ...over }; +} diff --git a/app/src/lib/aws-error.ts b/app/src/lib/aws-error.ts index d4bbfe3..c55d627 100644 --- a/app/src/lib/aws-error.ts +++ b/app/src/lib/aws-error.ts @@ -21,3 +21,19 @@ export function toAwsError(e: unknown): AwsError { } return { kind: "other", message: typeof e === "string" ? e : e instanceof Error ? e.message : "Unknown error" }; } + +/** Human-readable copy for a failed AWS command; never raw JSON. */ +export function awsErrorMessage(e: AwsError): string { + switch (e.kind) { + case "sso_expired": + return `Your SSO session for profile ${e.profile} has expired.`; + case "profile_not_found": + return `Profile ${e.profile} was not found in your AWS config, or has no credentials.`; + case "keys_missing": + return "The access keys for this connection are missing from the keychain. Remove and re-add the connection."; + case "state_bucket_taken": + return `The state bucket name ${e.bucket} is already taken by another AWS account.`; + default: + return e.message; + } +} -- 2.52.0 From bc23fc2deee75760506f9694d3114aeeaf5f01d5 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 22:56:36 -0800 Subject: [PATCH 18/24] fix(aws): environments panel acts on the scanned region; runs keyed per region Rows and actions belong to the scan they came from: a region change hides the old rows, detection in flight disables actions, and every action and confirmation targets scan.region. Runs, pending state and errors are keyed by region as well as state key. A boundary run that fails in the apply now offers the boundary bundle with its summary; waiting-for-admin shows only after a bundle is saved; clearer Destroy copy. Tests for each, and no act() warnings in the new tests. Co-Authored-By: Claude Opus 5.5 --- .../settings/hosts/AdminBundleModal.tsx | 8 +- .../hosts/DeployEnvironmentModal.test.tsx | 43 ++++- .../settings/hosts/DeployEnvironmentModal.tsx | 23 ++- .../settings/hosts/DeployProgress.test.tsx | 77 +++++++++ .../settings/hosts/DeployProgress.tsx | 37 +++-- .../settings/hosts/EnvironmentsPanel.test.tsx | 93 +++++++++++ .../settings/hosts/EnvironmentsPanel.tsx | 157 ++++++++++-------- 7 files changed, 347 insertions(+), 91 deletions(-) create mode 100644 app/src/components/settings/hosts/DeployProgress.test.tsx diff --git a/app/src/components/settings/hosts/AdminBundleModal.tsx b/app/src/components/settings/hosts/AdminBundleModal.tsx index 6630be5..2707669 100644 --- a/app/src/components/settings/hosts/AdminBundleModal.tsx +++ b/app/src/components/settings/hosts/AdminBundleModal.tsx @@ -29,6 +29,7 @@ export default function AdminBundleModal({ ownerId, network, reason, + onSaved, onClose, }: { connectionId: string; @@ -40,6 +41,8 @@ export default function AdminBundleModal({ network?: NetworkChoice; /** Why the bundle is needed (e.g. the refusal that led here). */ reason?: string; + /** Called with the folder once a bundle was actually written. */ + onSaved?: (folder: string) => void; onClose: () => void; }) { const askEnvironment = kind === "environment" && !environment; @@ -63,7 +66,10 @@ export default function AdminBundleModal({ ownerId, network: picked && "network" in picked ? picked.network : network, }); - if (folder) setSaved(folder); + if (folder) { + setSaved(folder); + onSaved?.(folder); + } } catch (e) { setError(toAwsError(e)); } finally { diff --git a/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx b/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx index 6003815..307442b 100644 --- a/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx +++ b/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx @@ -45,7 +45,7 @@ describe("DeployEnvironmentModal", () => { handlers.clear(); }); - it("the public network mode is the default and says it costs nothing", () => { + it("the public network mode is the default and says it costs nothing", async () => { const props = renderModal(); expect(screen.getByRole("radio", { name: "Public" })).toHaveAttribute("aria-checked", "true"); expect(screen.getByText("$0 — tasks get a public IP, no inbound access")).toBeInTheDocument(); @@ -56,9 +56,10 @@ describe("DeployEnvironmentModal", () => { expect(screen.getByLabelText("Name")).toHaveValue("main"); fireEvent.click(screen.getByRole("button", { name: "Deploy" })); expect(props.onDeploy).toHaveBeenCalledWith("main", { mode: "public" }); + await waitFor(() => expect(props.onClose).toHaveBeenCalled()); }); - it("validates the name with the backend's rule", () => { + it("validates the name with the backend's rule", async () => { const props = renderModal({ scan: scan({ environments: [env({ name: "main" })] }) }); const deploy = screen.getByRole("button", { name: "Deploy" }); expect(screen.getByText(/already exists/)).toBeInTheDocument(); @@ -71,9 +72,10 @@ describe("DeployEnvironmentModal", () => { expect(deploy).toBeEnabled(); fireEvent.click(deploy); expect(props.onDeploy).toHaveBeenCalledWith("team-2", { mode: "public" }); + await waitFor(() => expect(props.onClose).toHaveBeenCalled()); }); - it("an existing network needs a VPC and two distinct subnets", () => { + it("an existing network needs a VPC and two distinct subnets", async () => { const props = renderModal(); fireEvent.click(screen.getByRole("radio", { name: "Existing VPC" })); const deploy = screen.getByRole("button", { name: "Deploy" }); @@ -91,6 +93,7 @@ describe("DeployEnvironmentModal", () => { subnet_ids: ["subnet-0123abcd", "subnet-4567abcd"], assign_public_ip: true, }); + await waitFor(() => expect(props.onClose).toHaveBeenCalled()); }); it("keeps the dialog open and shows the server's refusal", async () => { @@ -111,9 +114,9 @@ describe("DeployEnvironmentModal", () => { expect(screen.queryByLabelText("Name")).toBeNull(); expect(screen.queryByRole("button", { name: "Deploy" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" })); - await waitFor(() => - expect(invoke).toHaveBeenCalledWith("deploy_aws_permissions_boundary", { connectionId: "h1", region: "eu-west-1" }), - ); + // Running (op id known) is the visible sign the invoke resolved. + expect(await screen.findByText("Running")).toBeInTheDocument(); + expect(invoke).toHaveBeenCalledWith("deploy_aws_permissions_boundary", { connectionId: "h1", region: "eu-west-1" }); emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, events: [tofuEvent("Creating boundary")] })); expect(screen.getByText("Creating boundary")).toBeInTheDocument(); emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "Apply complete!" } })); @@ -121,7 +124,29 @@ describe("DeployEnvironmentModal", () => { expect(props.onDetect).toHaveBeenCalled(); }); - it("opens the boundary bundle when creating the boundary is denied", async () => { + it("offers the boundary bundle when the boundary run fails, with its summary as the reason", async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === "deploy_aws_permissions_boundary") return "b1"; + if (cmd === "save_aws_admin_bundle") return "/tmp/b"; + throw new Error(`unexpected ${cmd}`); + }); + const props = renderModal({ scan: scan({ boundary: "no" }) }); + fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" })); + expect(await screen.findByText("Running")).toBeInTheDocument(); + const summary = "Error: creating IAM Policy (triple-c-permissions-boundary): AccessDenied: not authorized to perform iam:CreatePolicy"; + emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, done: true, cancellable: false, result: { ok: false, exit_code: 1, summary } })); + expect(props.onDetect).toHaveBeenCalled(); + // Offered, not opened. + expect(screen.queryByRole("dialog", { name: "Permissions boundary bundle" })).toBeNull(); + expect(screen.queryByLabelText("Name")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Get the boundary bundle for an administrator" })); + const dialog = screen.getByRole("dialog", { name: "Permissions boundary bundle" }); + expect(within(dialog).getByRole("alert")).toHaveTextContent(summary); + fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" })); + expect(await within(dialog).findByText("/tmp/b")).toBeInTheDocument(); + }); + + it("opens the boundary bundle when the boundary command itself is denied", async () => { invoke.mockImplementation(async (cmd: string) => { if (cmd === "deploy_aws_permissions_boundary") throw { kind: "access_denied", action: "iam:CreatePolicy", message: "denied iam:CreatePolicy" }; if (cmd === "save_aws_admin_bundle") return "/tmp/b"; @@ -142,9 +167,10 @@ describe("DeployEnvironmentModal", () => { network: null, }), ); + expect(await within(dialog).findByText("/tmp/b")).toBeInTheDocument(); }); - it("repairs a named environment and warns that a different network replaces it", () => { + it("repairs a named environment and warns that a different network replaces it", async () => { const props = renderModal({ repair: "dev" }); expect(screen.getByRole("dialog", { name: "Repair dev" })).toBeInTheDocument(); expect(screen.queryByLabelText("Name")).toBeNull(); @@ -152,5 +178,6 @@ describe("DeployEnvironmentModal", () => { fireEvent.click(screen.getByRole("radio", { name: "Private + NAT" })); fireEvent.click(screen.getByRole("button", { name: "Repair" })); expect(props.onDeploy).toHaveBeenCalledWith("dev", { mode: "private-nat" }); + await waitFor(() => expect(props.onClose).toHaveBeenCalled()); }); }); diff --git a/app/src/components/settings/hosts/DeployEnvironmentModal.tsx b/app/src/components/settings/hosts/DeployEnvironmentModal.tsx index 6fccbea..c061795 100644 --- a/app/src/components/settings/hosts/DeployEnvironmentModal.tsx +++ b/app/src/components/settings/hosts/DeployEnvironmentModal.tsx @@ -6,7 +6,7 @@ import Button from "../../ui/Button"; import Field, { inputClass } from "../../ui/Field"; import Modal from "../../ui/Modal"; import AdminBundleModal from "./AdminBundleModal"; -import DeployProgress, { useDeployRuns } from "./DeployProgress"; +import DeployProgress, { runKey, useDeployRuns } from "./DeployProgress"; import NetworkFields from "./NetworkFields"; import { BOUNDARY_STATE_KEY, @@ -51,7 +51,8 @@ export default function DeployEnvironmentModal({ if (result?.ok) setBoundaryDone(true); onDetect(); }); - const boundaryRun = boundary.runs[BOUNDARY_STATE_KEY]; + const boundaryRun = boundary.runs[runKey(region, BOUNDARY_STATE_KEY)]; + const boundaryFailed = boundaryRun?.done && boundaryRun.result && !boundaryRun.result.ok ? boundaryRun.result : null; if (bundle) { return ( @@ -137,6 +138,24 @@ export default function DeployEnvironmentModal({

    )} {boundaryRun && } + {boundaryFailed && ( +
    +

    + Creating the boundary failed. If it was refused, an account administrator can create it from a + bundle. +

    + +
    + )}
) : (
diff --git a/app/src/components/settings/hosts/DeployProgress.test.tsx b/app/src/components/settings/hosts/DeployProgress.test.tsx new file mode 100644 index 0000000..0e4e17c --- /dev/null +++ b/app/src/components/settings/hosts/DeployProgress.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { runKey, useDeployRuns } from "./DeployProgress"; +import type { DeployProgress } from "../../../lib/types"; +import { progress, tofuEvent } from "./testkit"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +type Handler = (e: { payload: DeployProgress }) => void; +const handlers = new Set(); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async (_name: string, handler: Handler) => { + handlers.add(handler); + return () => handlers.delete(handler); + }), +})); +function emit(p: DeployProgress) { + act(() => { + for (const h of [...handlers]) h({ payload: p }); + }); +} + +const KEY = "environments/main/terraform.tfstate"; + +describe("useDeployRuns", () => { + beforeEach(() => handlers.clear()); + + it("tracks runs for the same state key in two regions independently", async () => { + const onDone = vi.fn(); + const { result } = renderHook(() => useDeployRuns(onDone)); + await act(async () => { + await result.current.start(KEY, "eu-west-1", "Deploy of main", async () => "eu"); + }); + await act(async () => { + await result.current.start(KEY, "us-east-1", "Deploy of main", async () => "us"); + }); + expect(handlers.size).toBe(2); + emit(progress({ op_id: "eu", state_key: KEY, events: [tofuEvent("eu line")] })); + emit(progress({ op_id: "us", state_key: KEY, events: [tofuEvent("us line")] })); + emit(progress({ op_id: "us", state_key: KEY, done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "ok" } })); + + const eu = result.current.runs[runKey("eu-west-1", KEY)]; + const us = result.current.runs[runKey("us-east-1", KEY)]; + expect(eu.events.map((e) => e.message)).toEqual(["eu line"]); + expect(eu.done).toBe(false); + expect(us.events.map((e) => e.message)).toEqual(["us line"]); + expect(us.done).toBe(true); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onDone).toHaveBeenCalledWith({ stateKey: KEY, region: "us-east-1", result: { ok: true, exit_code: 0, summary: "ok" } }); + // The finished run's listener is gone; the other region's is still there. + expect(handlers.size).toBe(1); + }); + + it("removes its progress listeners on unmount", async () => { + const { result, unmount } = renderHook(() => useDeployRuns()); + await act(async () => { + await result.current.start(KEY, "eu-west-1", "Deploy of main", async () => "a"); + await result.current.start("boundary/terraform.tfstate", "eu-west-1", "Boundary", async () => "b"); + }); + expect(handlers.size).toBe(2); + unmount(); + expect(handlers.size).toBe(0); + }); + + it("a rejected start removes its listener and returns the AWS error", async () => { + const { result } = renderHook(() => useDeployRuns()); + let err: unknown; + await act(async () => { + err = await result.current.start(KEY, "eu-west-1", "Deploy of main", async () => { + throw { kind: "other", message: "A deployment is already running" }; + }); + }); + expect(err).toEqual({ kind: "other", message: "A deployment is already running" }); + expect(handlers.size).toBe(0); + expect(result.current.runs).toEqual({}); + }); +}); diff --git a/app/src/components/settings/hosts/DeployProgress.tsx b/app/src/components/settings/hosts/DeployProgress.tsx index 7f61ae6..bd51d1b 100644 --- a/app/src/components/settings/hosts/DeployProgress.tsx +++ b/app/src/components/settings/hosts/DeployProgress.tsx @@ -28,13 +28,18 @@ export interface DeployRun { type Subscription = { unlisten: (() => void) | null }; +/** Runs are tracked per region and state key: the same key exists in every region. */ +export const runKey = (region: string, stateKey: string) => `${region}:${stateKey}`; + /** - * Runs keyed by state key. `start` subscribes to `aws-deploy-progress` before + * Runs keyed by `runKey(region, stateKey)`. `start` subscribes to `aws-deploy-progress` before * invoking, keeps every event for the state key it started until the op id * is known (the backend may emit just before the invoke resolves), then keeps * only that op's events. Resolves to the command's error, or null once started. */ -export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOutcome | null }) => void) { +export function useDeployRuns( + onDone?: (run: { stateKey: string; region: string; result: TofuOutcome | null }) => void, +) { const [runs, setRuns] = useState>({}); const subs = useRef(new Map()); const mounted = useRef(true); @@ -53,25 +58,26 @@ export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOut const start = useCallback( async (stateKey: string, region: string, label: string, run: () => Promise): Promise => { - subs.current.get(stateKey)?.unlisten?.(); + const k = runKey(region, stateKey); + subs.current.get(k)?.unlisten?.(); const sub: Subscription = { unlisten: null }; - subs.current.set(stateKey, sub); - const current = () => mounted.current && subs.current.get(stateKey) === sub; + subs.current.set(k, sub); + const current = () => mounted.current && subs.current.get(k) === sub; const stop = () => { sub.unlisten?.(); sub.unlisten = null; - if (subs.current.get(stateKey) === sub) subs.current.delete(stateKey); + if (subs.current.get(k) === sub) subs.current.delete(k); }; let opId: string | null = null; const early: ProgressPayload[] = []; const apply = (p: ProgressPayload) => { setRuns((prev) => { - const r = prev[stateKey]; + const r = prev[k]; if (!r || r.opId !== p.op_id || r.done) return prev; return { ...prev, - [stateKey]: { + [k]: { ...r, events: p.events.length ? [...r.events, ...p.events] : r.events, done: p.done, @@ -82,13 +88,13 @@ export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOut }); if (p.done) { stop(); - onDoneRef.current?.({ stateKey, result: p.result ?? null }); + onDoneRef.current?.({ stateKey, region, result: p.result ?? null }); } }; setRuns((prev) => ({ ...prev, - [stateKey]: { stateKey, region, label, opId: null, events: [], done: false, cancellable: false, result: null }, + [k]: { stateKey, region, label, opId: null, events: [], done: false, cancellable: false, result: null }, })); const unlisten = await listen(AWS_DEPLOY_PROGRESS_EVENT, ({ payload }) => { @@ -108,7 +114,7 @@ export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOut stop(); if (mounted.current) { setRuns((prev) => { - const { [stateKey]: _gone, ...rest } = prev; + const { [k]: _gone, ...rest } = prev; return rest; }); } @@ -116,17 +122,18 @@ export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOut } if (!current()) return null; const id = opId; - setRuns((prev) => (prev[stateKey] ? { ...prev, [stateKey]: { ...prev[stateKey], opId: id } } : prev)); + setRuns((prev) => (prev[k] ? { ...prev, [k]: { ...prev[k], opId: id } } : prev)); for (const p of early.splice(0)) if (p.op_id === id) apply(p); return null; }, [], ); - const dismiss = useCallback((stateKey: string) => { + /** `key` is a `runKey`. */ + const dismiss = useCallback((key: string) => { setRuns((prev) => { - if (!prev[stateKey]?.done) return prev; - const { [stateKey]: _gone, ...rest } = prev; + if (!prev[key]?.done) return prev; + const { [key]: _gone, ...rest } = prev; return rest; }); }, []); diff --git a/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx b/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx index 937f8af..f6f37dc 100644 --- a/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx +++ b/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx @@ -131,6 +131,7 @@ describe("EnvironmentsPanel", () => { fireEvent.click(within(row).getByRole("button", { name: "Destroy dev" })); const dialog = screen.getByRole("dialog", { name: "Destroy dev" }); + expect(within(dialog).getByText(/deployment is incomplete \(no valid environment record\)/)).toBeInTheDocument(); const confirm = within(dialog).getByRole("button", { name: "Destroy" }); expect(confirm).toBeDisabled(); fireEvent.change(within(dialog).getByLabelText(/Type dev to confirm/), { target: { value: "de" } }); @@ -248,7 +249,10 @@ describe("EnvironmentsPanel", () => { await open(); fireEvent.click(await screen.findByRole("button", { name: "Join dev" })); const dialog = screen.getByRole("dialog"); + // Opening the bundle is not a request yet. + expect(screen.queryByText("Waiting for an admin to onboard you")).toBeNull(); fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" })); + expect(await within(dialog).findByText("/home/me/onboard-dev")).toBeInTheDocument(); await waitFor(() => expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", { connectionId: "h1", @@ -316,6 +320,95 @@ describe("EnvironmentsPanel", () => { expect(screen.getByText((_, el) => el?.textContent === long && el.tagName === "PRE")).toBeInTheDocument(); }); + it("a workspace user who closes the bundle without saving is not waiting", async () => { + backend(() => scan({ environments: [env()] })); + render(); + await open(); + fireEvent.click(await screen.findByRole("button", { name: "Join dev" })); + fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close" })); + expect(screen.queryByText("Waiting for an admin to onboard you")).toBeNull(); + expect(within(screen.getByTestId("env-dev")).getByRole("button", { name: "Join dev" })).toHaveTextContent("Join"); + }); + + it("never offers Destroy on a ready environment", async () => { + backend(() => scan({ environments: [env({ actions: ["join", "update"] })] })); + render(); + await open(); + const row = await screen.findByTestId("env-dev"); + expect(within(row).getByText("Ready")).toBeInTheDocument(); + expect(within(row).queryByRole("button", { name: "Destroy dev" })).toBeNull(); + expect(within(row).queryByRole("button", { name: "Unlock dev" })).toBeNull(); + }); + + it("after a region change, the old region's rows can't be acted on, and actions use the new region", async () => { + let resolveUs: (s: EnvironmentScan) => void = () => {}; + invoke.mockImplementation(async (cmd: string, args: { region?: string }) => { + if (cmd === "detect_aws_environments") { + if (args.region === "us-east-1") return new Promise((r) => (resolveUs = r)); + return scan({ environments: [env({ state: { state: "incomplete", lock: null }, actions: ["repair", "destroy"], info: null, onboarding: null })] }); + } + if (cmd === "update_aws_environment") return "u1"; + throw new Error(`unexpected ${cmd}`); + }); + render(); + await open(); + expect(await screen.findByRole("button", { name: "Destroy dev" })).toBeEnabled(); + fireEvent.change(screen.getByLabelText("Region"), { target: { value: "us-east-1" } }); + // Before the new scan answers, nothing from eu-west-1 can be clicked. + expect(screen.queryByTestId("env-dev")).toBeNull(); + expect(screen.queryByRole("button", { name: "Destroy dev" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Deploy a new environment" })).toBeNull(); + await act(async () => + resolveUs(scan({ region: "us-east-1", environments: [env({ state: { state: "update_available" }, actions: ["update"], onboarding: { state: "joined" } })] })), + ); + fireEvent.click(await screen.findByRole("button", { name: "Update dev" })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("update_aws_environment", { connectionId: "h1", region: "us-east-1", name: "dev" }), + ); + expect(await screen.findByText("Running")).toBeInTheDocument(); + }); + + it("disables row actions while detection is in flight", async () => { + let calls = 0; + let release: () => void = () => {}; + invoke.mockImplementation(async (cmd: string) => { + if (cmd !== "detect_aws_environments") throw new Error(`unexpected ${cmd}`); + calls += 1; + const found = scan({ environments: [env({ actions: ["join", "update"], onboarding: { state: "joined" } })] }); + if (calls === 1) return found; + return new Promise((r) => (release = () => r(found))); + }); + render(); + await open(); + expect(await screen.findByRole("button", { name: "Update dev" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Detect again" })); + expect(screen.getByRole("button", { name: "Update dev" })).toBeDisabled(); + await act(async () => release()); + expect(screen.getByRole("button", { name: "Update dev" })).toBeEnabled(); + }); + + it("a done that arrives before the invoke resolves is shown and re-detects", async () => { + backend( + () => scan({ environments: [env({ state: { state: "update_available" }, actions: ["update"], onboarding: { state: "joined" } })] }), + async (cmd) => { + if (cmd === "update_aws_environment") { + emit(progress({ op_id: "u1", events: [tofuEvent("Refreshing state")] })); + emit(progress({ op_id: "u1", events: [], done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "No changes." } })); + return "u1"; + } + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + fireEvent.click(await screen.findByRole("button", { name: "Update dev" })); + expect(await screen.findByText(/Finished: No changes\./)).toBeInTheDocument(); + expect(screen.getByText("Refreshing state")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Cancel deployment" })).toBeNull(); + await waitFor(() => expect(detectCalls()).toBe(2)); + expect(handlers.size).toBe(0); + }); + it("routes a detection failure through the AWS error copy", async () => { backend(() => { throw { kind: "state_bucket_taken", bucket: "triple-c-tfstate-123456789012-eu-west-1" }; diff --git a/app/src/components/settings/hosts/EnvironmentsPanel.tsx b/app/src/components/settings/hosts/EnvironmentsPanel.tsx index d2cb958..bec40ad 100644 --- a/app/src/components/settings/hosts/EnvironmentsPanel.tsx +++ b/app/src/components/settings/hosts/EnvironmentsPanel.tsx @@ -26,7 +26,7 @@ import Modal from "../../ui/Modal"; import StatusIndicator from "../../ui/StatusIndicator"; import AdminBundleModal from "./AdminBundleModal"; import DeployEnvironmentModal from "./DeployEnvironmentModal"; -import DeployProgress, { useDeployRuns, type DeployRun } from "./DeployProgress"; +import DeployProgress, { runKey, useDeployRuns, type DeployRun } from "./DeployProgress"; import PolicyFiles from "./PolicyFiles"; import { COMMON_REGIONS, @@ -98,7 +98,9 @@ function TypedConfirmModal({ ); } -type Bundle = { kind: ModuleKind; environment?: string; ownerId?: string }; +type Bundle = { kind: ModuleKind; region: string; environment?: string; ownerId?: string }; +/** A row-level key: the same environment name can exist in every region. */ +const rowKey = (region: string, name: string) => `${region}:${name}`; /** The environment a run's state key belongs to, if any. */ function runEnvironment(stateKey: string): string | null { @@ -129,8 +131,8 @@ export default function EnvironmentsPanel({ const [waiting, setWaiting] = useState>(new Set()); const [deployOpen, setDeployOpen] = useState(false); const [repairOf, setRepairOf] = useState(null); - const [destroyOf, setDestroyOf] = useState(null); - const [unlockOf, setUnlockOf] = useState(null); + const [destroyOf, setDestroyOf] = useState<{ name: string; region: string } | null>(null); + const [unlockOf, setUnlockOf] = useState<{ env: DetectedEnvironment; region: string } | null>(null); const [bundle, setBundle] = useState(null); const [copyNote, setCopyNote] = useState(null); const request = useRef(0); @@ -162,16 +164,18 @@ export default function EnvironmentsPanel({ if (open) void detect(); }, [open, detect]); + /** Start a run in `runRegion` (always the region of the scan the action came from). */ const launch = useCallback( - async (envName: string, stateKey: string, label: string, run: () => Promise) => { - setRowErrors(({ [envName]: _gone, ...rest }) => rest); - setPending((p) => ({ ...p, [envName]: true })); - const err = await start(stateKey, region, label, run); - setPending(({ [envName]: _gone, ...rest }) => rest); - if (err) setRowErrors((r) => ({ ...r, [envName]: err })); + async (runRegion: string, envName: string, stateKey: string, label: string, run: () => Promise) => { + const rk = rowKey(runRegion, envName); + setRowErrors(({ [rk]: _gone, ...rest }) => rest); + setPending((p) => ({ ...p, [rk]: true })); + const err = await start(stateKey, runRegion, label, run); + setPending(({ [rk]: _gone, ...rest }) => rest); + if (err) setRowErrors((r) => ({ ...r, [rk]: err })); return err; }, - [start, region], + [start], ); // Reattach to a deploy running on this machine (a re-follow replays its whole log). @@ -179,10 +183,12 @@ export default function EnvironmentsPanel({ if (!scan) return; for (const e of scan.environments) { const key = environmentStateKey(e.name); - const seen = `${scan.region}:${key}`; - if (e.actions.includes("follow") && !runs[key] && !followed.current.has(seen)) { + const seen = runKey(scan.region, key); + if (e.actions.includes("follow") && !runs[seen] && !followed.current.has(seen)) { followed.current.add(seen); - void launch(e.name, key, `Deploy of ${e.name}`, () => followAwsDeployment(connection.id, scan.region, key)); + void launch(scan.region, e.name, key, `Deploy of ${e.name}`, () => + followAwsDeployment(connection.id, scan.region, key), + ); } } }, [scan, runs, launch, connection.id]); @@ -200,49 +206,55 @@ export default function EnvironmentsPanel({ } }; - const deploy = (name: string, network: NetworkChoice) => - launch(name, environmentStateKey(name), `Deploy of ${name}`, () => - deployAwsEnvironment(connection.id, region, name, network), + // Rows (and every action on them) belong to the scan they came from. After a + // region change the old scan's rows are hidden, and while any detection is in + // flight their actions are disabled; actions always target `scan.region`. + const current = scan && scan.region === region ? scan : null; + const stale = detecting || !current; + + const deploy = (runRegion: string, name: string, network: NetworkChoice) => + launch(runRegion, name, environmentStateKey(name), `Deploy of ${name}`, () => + deployAwsEnvironment(connection.id, runRegion, name, network), ); - const repair = (name: string, network?: NetworkChoice) => - launch(name, environmentStateKey(name), `Repair of ${name}`, () => - repairAwsEnvironment(connection.id, region, name, network), + const repair = (runRegion: string, name: string, network?: NetworkChoice) => + launch(runRegion, name, environmentStateKey(name), `Repair of ${name}`, () => + repairAwsEnvironment(connection.id, runRegion, name, network), ); - const join = (e: DetectedEnvironment) => { - if (!scan) return; + const join = (found: EnvironmentScan, e: DetectedEnvironment) => { if (isAdmin) { - void launch(e.name, onboardingStateKey(e.name, scan.owner_key), `Joining ${e.name}`, () => - onboardAwsUser(connection.id, region, e.name), + void launch(found.region, e.name, onboardingStateKey(e.name, found.owner_key), `Joining ${e.name}`, () => + onboardAwsUser(connection.id, found.region, e.name), ); } else { - setWaiting((w) => new Set(w).add(e.name)); - setBundle({ kind: "user_onboard", environment: e.name, ownerId: scan.owner_id }); + setBundle({ kind: "user_onboard", region: found.region, environment: e.name, ownerId: found.owner_id }); } }; const rowRuns = (name: string) => - Object.values(runs).filter((r) => r.region === region && runEnvironment(r.stateKey) === name); - const shown = new Set(scan?.environments.map((e) => e.name) ?? []); + Object.values(runs).filter((r) => r.region === current?.region && runEnvironment(r.stateKey) === name); + const shown = new Set(current?.environments.map((e) => e.name) ?? []); const looseRuns = Object.values(runs).filter( - (r) => r.region !== region || !shown.has(runEnvironment(r.stateKey) ?? ""), + (r) => r.region !== current?.region || !shown.has(runEnvironment(r.stateKey) ?? ""), ); const progress = (r: DeployRun) => ( dismiss(r.stateKey)} + onDismiss={() => dismiss(runKey(r.region, r.stateKey))} /> ); - const envRow = (e: DetectedEnvironment) => { + const envRow = (found: EnvironmentScan, e: DetectedEnvironment) => { + const at = found.region; const status = stateStatus(e.state); const lock = stateLock(e.state); const has = (a: DetectedEnvironment["actions"][number]) => e.actions.includes(a); const onboarding = e.onboarding?.state; - const busy = !!pending[e.name]; - const rowError = rowErrors[e.name]; + const busy = stale || !!pending[rowKey(at, e.name)]; + const rowError = rowErrors[rowKey(at, e.name)]; + const isWaiting = waiting.has(rowKey(at, e.name)); return (
  • The environment's record can't be used: {e.state.reason}
  • )} - {!isAdmin && waiting.has(e.name) && onboarding !== "joined" && ( + {!isAdmin && isWaiting && onboarding !== "joined" && (
    Waiting for an admin to onboard you
    )}
    {has("join") && onboarding !== "joined" && onboarding !== "in_progress" && ( - )} {has("follow") && ( @@ -288,7 +300,7 @@ export default function EnvironmentsPanel({ aria-label={`Follow ${e.name}`} onClick={() => { const key = environmentStateKey(e.name); - void launch(e.name, key, `Deploy of ${e.name}`, () => followAwsDeployment(connection.id, region, key)); + void launch(at, e.name, key, `Deploy of ${e.name}`, () => followAwsDeployment(connection.id, at, key)); }} > Follow @@ -300,8 +312,8 @@ export default function EnvironmentsPanel({ disabled={busy} aria-label={`Update ${e.name}`} onClick={() => - void launch(e.name, environmentStateKey(e.name), `Update of ${e.name}`, () => - updateAwsEnvironment(connection.id, region, e.name), + void launch(at, e.name, environmentStateKey(e.name), `Update of ${e.name}`, () => + updateAwsEnvironment(connection.id, at, e.name), ) } > @@ -313,18 +325,18 @@ export default function EnvironmentsPanel({ size="sm" disabled={busy} aria-label={`Repair ${e.name}`} - onClick={() => (e.info ? void repair(e.name) : setRepairOf(e.name))} + onClick={() => (e.info ? void repair(at, e.name) : setRepairOf(e.name))} > Repair )} {has("unlock") && ( - )} {has("destroy") && ( - )} @@ -340,7 +352,7 @@ export default function EnvironmentsPanel({ ); }; - const nothingFound = !!scan && scan.environments.length === 0; + const nothingFound = !!current && current.environments.length === 0; return (
    @@ -376,29 +388,36 @@ export default function EnvironmentsPanel({ {awsErrorMessage(detectError)}

    )} - {scan && isAdmin && scan.boundary === "no" && ( + {current && isAdmin && current.boundary === "no" && (

    This account has no Triple-C permissions boundary yet; deploying creates it first.

    )} - {scan && scan.environments.length > 0 &&
      {scan.environments.map(envRow)}
    } + {current && current.environments.length > 0 && ( +
      {current.environments.map((e) => envRow(current, e))}
    + )} {nothingFound && (

    - No Triple-C environment in {scan.region}. + No Triple-C environment in {current.region}. {!isAdmin && " Deploying one needs an environment admin: attach the environment-admin policies to an identity, or send an admin a bundle to deploy."} {!tier && " Run Check to find out whether you can deploy one yourself."}

    )} - {scan && isAdmin && ( - )} {nothingFound && !isAdmin && (
    - {copyNote && ( @@ -411,47 +430,49 @@ export default function EnvironmentsPanel({ {looseRuns.map(progress)}
    )} - {scan && (deployOpen || repairOf) && ( + {current && (deployOpen || repairOf) && ( { setDeployOpen(false); setRepairOf(null); }} - onDeploy={repairOf ? (name, network) => repair(name, network) : deploy} + onDeploy={(name, network) => + repairOf ? repair(current.region, name, network) : deploy(current.region, name, network) + } onDetect={() => void detect()} /> )} {destroyOf && ( setDestroyOf(null)} onConfirm={() => - launch(destroyOf, environmentStateKey(destroyOf), `Destroy of ${destroyOf}`, () => - destroyIncompleteAwsEnvironment(connection.id, region, destroyOf), + launch(destroyOf.region, destroyOf.name, environmentStateKey(destroyOf.name), `Destroy of ${destroyOf.name}`, () => + destroyIncompleteAwsEnvironment(connection.id, destroyOf.region, destroyOf.name), ) } >

    - {destroyOf} never finished deploying, so OpenTofu removes everything it created for it. This can't be - undone. + This environment's deployment is incomplete (no valid environment record). OpenTofu removes everything + it created for {destroyOf.name} in {destroyOf.region}. This can't be undone.

    )} {unlockOf && ( setUnlockOf(null)} onConfirm={async () => { try { - await unlockAwsState(connection.id, region, environmentStateKey(unlockOf.name)); + await unlockAwsState(connection.id, unlockOf.region, environmentStateKey(unlockOf.env.name)); } catch (e) { return toAwsError(e); } @@ -460,7 +481,7 @@ export default function EnvironmentsPanel({ }} > {(() => { - const lock = stateLock(unlockOf.state); + const lock = stateLock(unlockOf.env.state); return lock ? (

    Held by {lock.who || "unknown"} since{" "} @@ -478,10 +499,16 @@ export default function EnvironmentsPanel({ {bundle && ( { + if (bundle.kind === "user_onboard" && bundle.environment) { + const rk = rowKey(bundle.region, bundle.environment); + setWaiting((w) => new Set(w).add(rk)); + } + }} onClose={() => setBundle(null)} /> )} -- 2.52.0 From 2f5305eeb1cf0d6e2fe5266bfbc574ae238ab2cc Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 23:31:49 -0800 Subject: [PATCH 19/24] fix(aws): admin policy gaps found by the live OpenTofu run - s3:TagResource with s3:CreateBucket: a create with tags authorizes it (same request-tag and in-account conditions). - ec2:CreateSecurityGroup on the VPC: EC2 shows the VPC no request tags, so that side is unconditioned; the group itself still needs the tag. - ec2:DisassociateAddress on the literal */* only (${*}/${*}): destroying the NAT gateway's address disassociates it after the association is gone. The evaluator learns ${*}. Checks, MODULE_CALLS and aws/README.md (including the Environments section and the phase-4 live results) change with them. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/policies.rs | 69 ++++++--- app/src-tauri/src/aws/policy_eval.rs | 18 ++- app/src-tauri/src/aws/tofu_modules/tests.rs | 11 +- aws/README.md | 153 ++++++++++++++++---- aws/policies/environment-admin-network.json | 17 ++- aws/policies/environment-admin.json | 3 +- 6 files changed, 214 insertions(+), 57 deletions(-) diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index e9d6b94..f943b43 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -190,6 +190,9 @@ const PROBE_ROUTE_TABLE: &str = "arn:aws:ec2:{region}:{account}:route-table/rtb- const PROBE_ELASTIC_IP: &str = "arn:aws:ec2:{region}:{account}:elastic-ip/eipalloc-00000000000000000"; const PROBE_NAT_GATEWAY: &str = "arn:aws:ec2:{region}:{account}:natgateway/nat-00000000000000000"; +/// What EC2 names when a call's resources no longer exist (an address +/// association that went with its NAT gateway): a literal `*/*`. +const PROBE_GONE_ASSOCIATION: &str = "arn:aws:ec2:{region}:{account}:*/*"; const PROBE_NETWORK_INTERFACE: &str = "arn:aws:ec2:{region}:{account}:network-interface/eni-00000000000000000"; const PROBE_SECURITY_GROUP_RULE: &str = @@ -475,7 +478,9 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ REQUESTED_IN_TRIPLE_C_PARENT, ), // The one create that may name an untagged (existing-network) VPC. - write("ec2:CreateSecurityGroup", PROBE_VPC, COMPONENT_REQUESTED), + // The VPC side of the request carries no request tag (verified live), + // so only the group's side is tag-checked. + write("ec2:CreateSecurityGroup", PROBE_VPC, &[]), write( "ec2:CreateTags", PROBE_SECURITY_GROUP, @@ -533,6 +538,10 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ ), write("ec2:DisassociateRouteTable", PROBE_SUBNET, COMPONENT_TAGGED), write("ec2:ReleaseAddress", PROBE_ELASTIC_IP, COMPONENT_TAGGED), + // Destroying the NAT gateway's address disassociates it first; the NAT + // gateway is gone by then, so EC2 authorizes the call against the + // literal `*/*` (verified live), which is all the policy allows. + write("ec2:DisassociateAddress", PROBE_GONE_ASSOCIATION, &[]), write("ec2:DeleteNatGateway", PROBE_NAT_GATEWAY, COMPONENT_TAGGED), write( "ec2:DeleteSecurityGroup", @@ -715,6 +724,8 @@ 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), + // CreateBucket with tags also authorizes s3:TagResource (verified live). + write("s3:TagResource", PROBE_STATE_BUCKET, STATE_REQUESTED), write("s3:PutBucketVersioning", PROBE_STATE_BUCKET, IN_MY_ACCOUNT), write( "s3:PutEncryptionConfiguration", @@ -1765,21 +1776,21 @@ mod tests { "{action} on {elsewhere}" ); } - assert!(allowed( - tier, - &write("s3:CreateBucket", PROBE_STATE_BUCKET, STATE_REQUESTED), - ME, - ME - )); - for (bucket, context) in [ - (PROBE_STATE_BUCKET, IN_MY_ACCOUNT), - (PROBE_STATE_BUCKET, OTHER_ACCOUNT_REQUESTED), - (OTHER_BUCKET, STATE_REQUESTED), - ] { + for action in ["s3:CreateBucket", "s3:TagResource"] { assert!( - !allowed(tier, &write("s3:CreateBucket", bucket, context), ME, ME), - "s3:CreateBucket {bucket} with {context:?}" + allowed(tier, &write(action, PROBE_STATE_BUCKET, STATE_REQUESTED), ME, ME), + "{action}" ); + for (bucket, context) in [ + (PROBE_STATE_BUCKET, IN_MY_ACCOUNT), + (PROBE_STATE_BUCKET, OTHER_ACCOUNT_REQUESTED), + (OTHER_BUCKET, STATE_REQUESTED), + ] { + assert!( + !allowed(tier, &write(action, bucket, context), ME, ME), + "{action} {bucket} with {context:?}" + ); + } } } @@ -1824,18 +1835,36 @@ mod tests { "{action} on an untagged {resource}" ); } + // A group may be created in any VPC (EC2 shows the VPC no request + // tag), but the group itself must carry the component tag. assert!(allowed( - tier, - &write("ec2:CreateSecurityGroup", PROBE_VPC, COMPONENT_REQUESTED), - ME, - ME - )); - assert!(!allowed( tier, &write("ec2:CreateSecurityGroup", PROBE_VPC, &[]), ME, ME )); + assert!(!allowed( + tier, + &write("ec2:CreateSecurityGroup", PROBE_SECURITY_GROUP, &[]), + ME, + ME + )); + // Only the literal `*/*` of an association that is already gone: a + // live address, Triple-C's or anyone's, is never disassociated. + assert!(allowed( + tier, + &write("ec2:DisassociateAddress", PROBE_GONE_ASSOCIATION, &[]), + ME, + ME + )); + for resource in [PROBE_ELASTIC_IP, PROBE_NETWORK_INTERFACE] { + for context in [&[][..], COMPONENT_TAGGED] { + assert!( + !allowed(tier, &write("ec2:DisassociateAddress", resource, context), ME, ME), + "ec2:DisassociateAddress on {resource} with {context:?}" + ); + } + } // 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( diff --git a/app/src-tauri/src/aws/policy_eval.rs b/app/src-tauri/src/aws/policy_eval.rs index dff5662..fa3ab92 100644 --- a/app/src-tauri/src/aws/policy_eval.rs +++ b/app/src-tauri/src/aws/policy_eval.rs @@ -16,15 +16,21 @@ pub(crate) const AT: Placeholders = Placeholders { pub(crate) const ME: &str = "AROAEXAMPLEME:alice@example.com"; pub(crate) const SOMEONE_ELSE: &str = "AROAEXAMPLEOTHER:bob@example.com"; +/// What [`Request::resolve`] turns IAM's `${*}` into: a literal `*` in a +/// pattern, which [`glob`] matches only against a `*`. +const LITERAL_STAR: char = '\u{e000}'; + /// IAM wildcard match: `*` is any run of characters (including `:` and -/// `/`), `?` is exactly one. +/// `/`), `?` is exactly one; [`LITERAL_STAR`] is a plain `*`. pub(crate) fn glob(pattern: &str, text: &str) -> bool { let p: Vec = pattern.chars().collect(); let t: Vec = text.chars().collect(); let (mut pi, mut ti) = (0, 0); let (mut star, mut mark) = (None, 0); while ti < t.len() { - if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { + if pi < p.len() + && (p[pi] == '?' || p[pi] == t[ti] || (p[pi] == LITERAL_STAR && t[ti] == '*')) + { pi += 1; ti += 1; } else if pi < p.len() && p[pi] == '*' { @@ -126,7 +132,9 @@ impl Request<'_> { out.push_str(&rest[..start]); let end = rest[start..].find('}').expect("unterminated ${") + start; let name = &rest[start + 2..end]; - let value = if name == "aws:userid" { + let value = if name == "*" { + Some(LITERAL_STAR.to_string()) + } else if name == "aws:userid" { Some(self.caller.to_string()) } else { self.context_value(name) @@ -254,6 +262,10 @@ mod tests { )); assert!(glob("a?c", "abc")); assert!(!glob("a?c", "ac")); + // `${*}` (resolved to LITERAL_STAR) matches only a real `*`. + let literal = format!("arn:aws:ec2:*:*:{LITERAL_STAR}/{LITERAL_STAR}"); + assert!(glob(&literal, "arn:aws:ec2:us-west-2:1:*/*")); + assert!(!glob(&literal, "arn:aws:ec2:us-west-2:1:elastic-ip/eipalloc-1")); } #[test] diff --git a/app/src-tauri/src/aws/tofu_modules/tests.rs b/app/src-tauri/src/aws/tofu_modules/tests.rs index 15ee715..c0682aa 100644 --- a/app/src-tauri/src/aws/tofu_modules/tests.rs +++ b/app/src-tauri/src/aws/tofu_modules/tests.rs @@ -1374,6 +1374,7 @@ const EIP: &str = "arn:aws:ec2:{region}:{account}:elastic-ip/eipalloc-0000000000 const NAT: &str = "arn:aws:ec2:{region}:{account}:natgateway/nat-00000000000000000"; const SG: &str = "arn:aws:ec2:{region}:{account}:security-group/sg-00000000000000000"; const SG_RULE: &str = "arn:aws:ec2:{region}:{account}:security-group-rule/sgr-00000000000000000"; +const GONE_ASSOCIATION: &str = "arn:aws:ec2:{region}:{account}:*/*"; const ENI: &str = "arn:aws:ec2:{region}:{account}:network-interface/eni-00000000000000000"; const FS: &str = "arn:aws:elasticfilesystem:{region}:{account}:file-system/fs-00000000"; const TASK_ROLE: &str = "arn:aws:iam::{account}:role/triple-c-probe-0123456789ab-task"; @@ -1480,8 +1481,9 @@ const MODULE_CALLS: &[(&str, &[Check])] = &[ &[ 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), + // `existing`-network environment's is the user's own. EC2 shows + // the VPC no request tag (verified live). + call("ec2:CreateSecurityGroup", VPC, NONE), call("ec2:CreateTags", SG, created_by!("CreateSecurityGroup")), call("ec2:DescribeSecurityGroups", "*", NONE), call("ec2:DescribeSecurityGroupRules", "*", NONE), @@ -1654,6 +1656,10 @@ const MODULE_CALLS: &[(&str, &[Check])] = &[ call("ec2:DescribeAddresses", "*", NONE), call("ec2:DescribeAddressesAttribute", "*", NONE), call("ec2:CreateTags", EIP, ENV_TAGGED), + // Delete disassociates first. The NAT gateway that held the + // association is gone by then, so EC2 authorizes it against a + // literal `*/*` (verified live). + call("ec2:DisassociateAddress", GONE_ASSOCIATION, NONE), call("ec2:ReleaseAddress", EIP, ENV_TAGGED), ], ), @@ -1973,6 +1979,7 @@ fn every_onboarding_role_stays_inside_the_boundary() { const APP_ONLY_WRITES: &[&str] = &[ // The state bucket's setup (the backend only reads and writes objects). "s3:CreateBucket", + "s3:TagResource", "s3:PutBucketVersioning", "s3:PutEncryptionConfiguration", "s3:PutBucketPublicAccessBlock", diff --git a/aws/README.md b/aws/README.md index bdb2b1c..84af0b6 100644 --- a/aws/README.md +++ b/aws/README.md @@ -104,15 +104,16 @@ capacity adds them back. `app/src-tauri/src/aws/tofu_modules/tests.rs` (`MODULE_CALLS`) maps every resource type, data source and the S3 backend the modules use to the IAM actions the AWS provider calls to create, -read, tag, update and delete it, and a test evaluates each against the admin files. It is the -**hypothesis**; a live apply and destroy is the proof, and any action that shows missing is added -to the table, the policy, the check table in `policies.rs` and this list together. +read, tag, update and delete it, and a test evaluates each against the admin files. A live apply +and destroy of every module as a role holding only the admin files (see "Live verification") is +the proof; an action it shows missing is added to the table, the policy, the check table in +`policies.rs` and this list together. | Resource | Actions (authorized against) | |---|---| | `aws_ecs_cluster` | `ecs:CreateCluster`, `TagResource` (at create), `DescribeClusters`, `ListTagsForResource`, `UpdateCluster`, `UntagResource`, `DeleteCluster` (cluster `triple-c-*`) | | `aws_ecs_cluster_capacity_providers` | `ecs:PutClusterCapacityProviders`, `DescribeClusters` (the cluster; untaggable) | -| `aws_security_group` | `ec2:CreateSecurityGroup` (group and VPC, request tag), `CreateTags` (at create), `RevokeSecurityGroupEgress` (AWS's default rule), `Authorize`/`RevokeSecurityGroupIngress`/`Egress` and `UpdateSecurityGroupRuleDescriptions*` (tagged group; new rules: `security-group-rule/*`), `CreateTags`/`DeleteTags`, `DeleteSecurityGroup` (tagged), `Describe*` | +| `aws_security_group` | `ec2:CreateSecurityGroup` (group: request tag; VPC: any, since EC2 shows the VPC no request tag), `CreateTags` (at create), `RevokeSecurityGroupEgress` (AWS's default rule), `Authorize`/`RevokeSecurityGroupIngress`/`Egress` and `UpdateSecurityGroupRuleDescriptions*` (tagged group; new rules: `security-group-rule/*`), `CreateTags`/`DeleteTags`, `DeleteSecurityGroup` (tagged), `Describe*` | | `aws_kms_key` | `kms:CreateKey` (request tag), `TagResource`, `DescribeKey`, `GetKeyPolicy`, `GetKeyRotationStatus`, `ListResourceTags`, `EnableKeyRotation`, `PutKeyPolicy`, `UpdateKeyDescription`, `UntagResource`, `ScheduleKeyDeletion` (tagged key) | | `aws_kms_alias` | `kms:CreateAlias`/`UpdateAlias`/`DeleteAlias` (alias `triple-c-*` and the tagged key; untaggable), `ListAliases` | | `aws_cloudwatch_log_group` | `logs:CreateLogGroup`, `TagResource`, `PutRetentionPolicy`, `ListTagsForResource`, `UntagResource`, `DeleteRetentionPolicy`, `DeleteLogGroup` (`/triple-c/*`), `DescribeLogGroups` (`*`) | @@ -123,18 +124,18 @@ to the table, the policy, the check table in `policies.rs` and this list togethe | `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`, `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_eip` | `ec2:AllocateAddress` (request tag), `CreateTags` (at create), `ReleaseAddress` (tagged), `DisassociateAddress` (only the literal `*/*` EC2 names once the NAT gateway holding the association is gone), `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:CalledViaLast = elasticfilesystem.amazonaws.com`; in the environment's subnets, which in `existing` mode are the user's own) | +| `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`, verified live; 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), 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`, +The app also creates the state bucket: `s3:CreateBucket` with a `triple-c:component` request tag +(and `s3:TagResource`, which a create with tags also needs, under the same conditions), then `PutBucketVersioning`, `PutEncryptionConfiguration`, `PutBucketPublicAccessBlock`, `PutBucketTagging`, `PutBucketPolicy`, `GetBucket*` and `ListBucket`, all on `triple-c-tfstate-*` only. No bucket can be deleted. @@ -194,21 +195,83 @@ account-wide: `triple-c:component` request tag. - `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. + user's own, untagged VPC. It is the only create that may name an untagged parent. The VPC side + of the grant has no condition at all: EC2 does not show the VPC the request's tags (a request-tag + condition there denied every create, verified live), so the group's own side carries the tag + rule. +- `ec2:DisassociateAddress` on `arn:aws:ec2:*:*:${*}/${*}`, i.e. the literal resource `*/*` and + nothing else (`${*}` is IAM's escaped `*`). Destroying the NAT gateway's address disassociates + it first; by then the NAT gateway and its association are gone and EC2 authorizes the call + against `*/*` (verified live: denied without this, then `InvalidAssociationID.NotFound`, which + the provider ignores). A live association names its address and interface, so no real address + can be disassociated through this statement. - `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 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. + `existing` environment's are untagged). Verified live: EFS sets `aws:CalledViaLast` for both, + and mount targets are created and deleted under this statement alone. - Reads on `*`: `ec2:Describe*`, `elasticfilesystem:Describe*`, the KMS key reads and `ListAliases`, `logs:DescribeLogGroups` and `ssm:DescribeParameters`. - `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. +## Environments (OpenTofu) + +An environment is deployed with [OpenTofu](https://opentofu.org) from the modules in `terraform/`. +The app runs them itself (it downloads OpenTofu 1.13.9, verified against a pinned SHA-256 before +it is unpacked), and an admin can run the very same files by hand from a bundle. + +| Module | Creates | State key | +|---|---|---| +| `terraform/permissions-boundary` | the account's `triple-c-permissions-boundary` policy (an account administrator applies it; the admin tier cannot) | `boundary/terraform.tfstate` | +| `terraform/environment` | what every user shares: ECS cluster (Fargate, Fargate Spot), the tasks security group (no inbound rules), KMS key and alias, log group `/triple-c/`, the network (`network_mode`: `public`, `private-nat`, `existing`) and, last, the record `/triple-c/environments/` | `environments//terraform.tfstate` | +| `terraform/user-onboard` | one user's task and execution roles (with the boundary) and their own EFS file system, its `-efs` security group, two mount targets and a file-system policy that admits only their task role | `users///terraform.tfstate` | + +Every module pins the AWS provider (`hashicorp/aws` 6.73.0, hashes in `.terraform.lock.hcl`), +sets `region` explicitly, never overrides an endpoint, and tags everything through `default_tags` +(`triple-c:component`, plus the environment, owner and template version). The onboarding module +reads the environment only from its record, never from its state. + +**The state bucket** is `triple-c-tfstate--`, one per account and region. The app +creates it on the first deploy (versioning, SSE-S3, all four public-access blocks, a TLS-only +bucket policy, tagged `triple-c:component=state`); every call carries `expected_bucket_owner`, so a +bucket of that name in another account is refused, never used. Locking is S3-native +(`use_lockfile = true`): a run holds `.tflock`, and a held lock is what "deploying" +means to every other copy of the app. The app removes a stale lock (older than two hours, an +environment's only) with a conditional delete on the lock's ETag, so a lock taken since is never +removed. `tofu destroy` leaves an *empty* state object behind; detection reads an empty state as +no deployment at all. + +**How the app runs a deployment.** It writes the module into a work folder +(`/triple-c/aws/work///`) with `backend.hcl` and +`terraform.tfvars.json`, then starts a detached runner (the app binary re-executed with +`--tofu-runner`) that runs `tofu init` and `tofu apply` (or `destroy`) in its own process group, +logging to `apply.log`. Closing the app does not stop it; the next start reattaches by following the +log. Cancel interrupts OpenTofu, which stops gracefully and saves its state. OpenTofu's environment +is built from scratch: no inherited `AWS_*`, `TF_*` or `TOFU_*` variables, the connection's profile +name or its keys (in the environment only, never in arguments or files), and the app's own CLI +config, so a `~/.tofurc` cannot redirect provider downloads. The app destroys only an incomplete +deployment (state without a record, or a failed apply); a working environment is removed by an +admin with `tofu destroy` from a bundle. + +**Admin bundles.** When the signed-in identity cannot deploy (or the admin prefers to), the app +saves a folder with the module, `backend.hcl`, `terraform.tfvars.json` and a `README.txt` holding +the exact commands. By hand, with credentials carrying the admin files (OpenTofu or Terraform 1.10 +or later): + +```sh +cd +tofu init -backend-config=backend.hcl +tofu apply # or: tofu destroy +``` + +Remove every user's onboarding before destroying their environment. With Terraform, first run +`terraform providers lock` for your platforms (the lock file carries OpenTofu's registry hashes). +The bundle's `README.txt` also shows how to create the state bucket by hand if the app never has. + ## Invariants the modules must keep (phase 4) The policies rely on the following. The modules in `terraform/` establish them (their tests pin @@ -249,8 +312,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,790 (350) -and `environment-admin-network.json` about 3,280; a unit test (`every_policy_fits_a_managed_policy`) +about 6,040 (roughly 100 characters of headroom), `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 (the "up to 32,768 characters" limit is unverified) or reference the managed policies. @@ -271,14 +334,15 @@ aws iam attach-user-policy --user-name \ - **Per-user execution roles.** A workspace user can pass only roles tagged with their own owner ID, so each user's onboarding module creates their execution role as well as their task role (done: `terraform/user-onboard/iam.tf`). -- **The admin's deploy permissions are a hypothesis until a live apply.** The module table above - 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: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. +- **The admin's deploy permissions are verified for `public` and `private-nat`, not `existing`.** + A live apply, update and destroy of every module as a role holding only the admin files passed + (see "Live verification"). The `existing` network mode (a user's own, untagged VPC and subnets) + was not deployed live; its extra grants (`CreateSecurityGroup` in any VPC, mount-target + interfaces in untagged subnets) are the same statements the other modes exercised. Which + tag-on-create actions are strictly *needed* was not tested one by one: the listed ones are + sufficient, and only `s3:TagResource` turned out missing. 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 boundary also allows every `triple-c/*` secret and every Triple-C file system. As a result, isolating users *from the admin* depends on the onboarding module's per-user policies, not on @@ -351,6 +415,45 @@ Still open for the phases that create the resources: `RunTask`'s tag keys and `i `iam:ResourceTag` on a real run, the owner tag on a real task for `StopTask`/`ExecuteCommand`/ `StartSession`, session ownership for an SSO-started session, and the EFS and security-group-rule 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. +check. The phase-4 additions (the network, the state bucket, the module table above) are +verified below; the owner-scoped `CreateAccessPoint` waits for phase 6's access points. + +### Phase 4: the modules, applied for real (2026-11-14) + +```sh +cd app/src-tauri +cargo build # the runner is the app binary, re-executed with --tofu-runner +TRIPLEC_AWS_E2E_PROFILE= TRIPLEC_AWS_E2E_REGION=us-west-2 \ + cargo test aws::live_tests::environment -- --ignored --test-threads=1 +``` + +`an_environment_admin_can_deploy_join_update_and_remove_an_environment` creates a role holding +**only** the admin files (as `triple-c-e2e--envadmin*` managed policies) and does everything as +a session of it, passing its keys to OpenTofu as a keys connection does: `ensure_state_bucket` on a +throwaway `triple-c-tfstate---` bucket (`TRIPLEC_E2E_BUCKET_SUFFIX`, random +by default), deploy a `public` environment through the real detached runner, `scan` (Ready, a valid +record, joinable), self-onboard (Joined, both roles carry the boundary), update (no changes), +destroy the onboarding, then the environment. If the account has no boundary, the account admin +applies the boundary module first and destroys it at the end. `the_private_nat_mode_deploys` does +the same for `private-nat` (about $0.05: one NAT gateway for a few minutes). A guard destroys +whatever is still applied, as the account admin, and deletes the bucket with every version, on +failure too; every denied action is printed by name. The test drives the same functions as the +commands (`ensure_tofu`, `materialize`, `child_env`, the runner, `follow`, `scan`, the refusal +checks, the conditional lock delete); the Tauri commands' own bookkeeping (launch slots, the +progress events) is not exercised. + +| Point | Result | +|---|---| +| `s3:CreateBucket` with a request tag and `s3:ResourceAccount` | ✓ both conditions are evaluated and pass for the admin's own account. ✗→fixed: a create with tags also authorizes **`s3:TagResource`**, which the policy lacked (added to `CreateTheStateBucket`, same conditions) | +| `ec2:CreateSecurityGroup` in a VPC | ✗→fixed: denied on the VPC resource, which EC2 does not show the request's tags, so `CreateSecurityGroupsInAnyVpc` lost its request-tag condition. The group itself still needs the tag | +| Tag-on-create (ECS, KMS, Logs, SSM, EFS, EC2, IAM) | ✓ every create passed with the listed actions; none was missing. Not tested one by one whether each is strictly needed | +| EFS mount-target interfaces | ✓ EFS sets `aws:CalledViaLast = elasticfilesystem.amazonaws.com`: both mount targets were created and deleted with `MountTargetInterfacesThroughEfs` as the only grant of `ec2:CreateNetworkInterface`/`DeleteNetworkInterface`. Ruling R9's fallback was not needed | +| EFS file-system policy naming a just-created role | ✓ no invalid-principal error in any onboarding apply (two applies, each created in 4 s, right after the role; whether the provider retried internally is not visible without its debug log); no module change. If it ever fails, applying the onboarding again completes it | +| NAT address on destroy | ✗→fixed: the provider disassociates the address before releasing it, after the NAT gateway (and the association) are gone, and EC2 authorizes that call against the literal `*/*`. `DisassociateGoneAddresses` allows exactly `arn:aws:ec2:*:*:${*}/${*}`; CloudTrail shows the retried call reaching `InvalidAssociationID.NotFound` | +| Conditional unlock | ✓ on the versioned state bucket, `DeleteObject` with `If-Match` and a mismatched ETag gets 412 (the app's "lock changed" answer) and leaves the lock; with the current ETag it deletes it. A fresh foreign lock scans as Deploying with its `Who` | +| Update | ✓ re-applying with the network read back from the record: "0 added, 0 changed, 0 destroyed" | +| After `tofu destroy` | ✗→fixed (app): the state object stays, empty, so a destroyed environment kept scanning as Incomplete (and a removed onboarding as Joined). Detection now reads a small state object and treats one with no resources as absent | +| Cleanup | ✓ nothing `triple-c` left in us-west-2 (roles, policies, log groups, clusters, VPCs, subnets, gateways, NAT gateways, addresses, security groups, file systems, parameters, aliases, buckets); five environment KMS keys pending deletion (7 days); the boundary, which the account did not have, deleted again | + +Not deployed live: the `existing` network mode, and an onboarding by a workspace user from an +admin bundle (the same module, applied with admin credentials). diff --git a/aws/policies/environment-admin-network.json b/aws/policies/environment-admin-network.json index c05954f..da213a6 100644 --- a/aws/policies/environment-admin-network.json +++ b/aws/policies/environment-admin-network.json @@ -94,12 +94,7 @@ ], "Resource": [ "arn:aws:ec2:*:*:vpc/*" - ], - "Condition": { - "Null": { - "aws:RequestTag/triple-c:component": "false" - } - } + ] }, { "Sid": "TagNetworkAtCreate", @@ -197,6 +192,16 @@ } } }, + { + "Sid": "DisassociateGoneAddresses", + "Effect": "Allow", + "Action": [ + "ec2:DisassociateAddress" + ], + "Resource": [ + "arn:aws:ec2:*:*:${*}/${*}" + ] + }, { "Sid": "DescribeEc2", "Effect": "Allow", diff --git a/aws/policies/environment-admin.json b/aws/policies/environment-admin.json index cce320f..137e1b6 100644 --- a/aws/policies/environment-admin.json +++ b/aws/policies/environment-admin.json @@ -302,7 +302,8 @@ "Sid": "CreateTheStateBucket", "Effect": "Allow", "Action": [ - "s3:CreateBucket" + "s3:CreateBucket", + "s3:TagResource" ], "Resource": [ "arn:aws:s3:::triple-c-tfstate-*" -- 2.52.0 From 5f38e0d0d29a86e73849befa9d5c264279735f1b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 23:31:49 -0800 Subject: [PATCH 20/24] fix(aws): an empty state is no deployment tofu destroy leaves the state object behind, empty, so a destroyed environment kept scanning as Incomplete and a removed onboarding as Joined. Detection reads small state objects and drops those that manage no resources. Also lets a test scan and create a throwaway state bucket (scan_in_bucket, ensure_named_state_bucket). Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/environments.rs | 59 +++++++++++++++++++-- app/src-tauri/src/aws/environments/tests.rs | 13 +++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/app/src-tauri/src/aws/environments.rs b/app/src-tauri/src/aws/environments.rs index 260e73a..cd589f2 100644 --- a/app/src-tauri/src/aws/environments.rs +++ b/app/src-tauri/src/aws/environments.rs @@ -496,6 +496,19 @@ pub async fn scan( who: &CallerIdentity, region: &str, running_locally: RunningLocally<'_>, +) -> Result { + let bucket = state_bucket_name(&who.account, region); + scan_in_bucket(d, who, region, &bucket, running_locally).await +} + +/// [`scan`] against a given state bucket instead of the account's: the live +/// tests use a throwaway bucket so they never touch a real one. +pub(crate) async fn scan_in_bucket( + d: &D, + who: &CallerIdentity, + region: &str, + bucket: &str, + running_locally: RunningLocally<'_>, ) -> Result { let prefix = environment_parameter_name(""); let records = d @@ -513,10 +526,9 @@ pub async fn scan( }) .collect(); - let bucket = state_bucket_name(&who.account, region); - let state_bucket = d.bucket_exists(&bucket).await?; + let state_bucket = d.bucket_exists(bucket).await?; let objects = match state_bucket { - Knowable::Yes => d.state_objects(&bucket).await?, + Knowable::Yes => d.state_objects(bucket).await?, _ => None, }; let boundary = d.boundary_exists(&who.account).await?; @@ -561,6 +573,16 @@ pub async fn scan( }) } +/// An OpenTofu state that manages nothing (what `tofu destroy` leaves): +/// valid JSON whose `resources` is an empty list. Anything else, unreadable +/// contents included, counts as a deployment. +pub(crate) fn is_empty_state(text: &str) -> bool { + serde_json::from_str::(text) + .ok() + .and_then(|v| v.get("resources").and_then(Value::as_array).map(Vec::is_empty)) + .unwrap_or(false) +} + /// What a failed lookup means. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Lookup { @@ -693,8 +715,9 @@ impl SdkDetectApi { Ok(Some(out)) } - /// A lock object's contents; empty when it is too large to read or can't - /// be read, `None` when it was released since the listing. + /// A small object's contents (a lock, or a state that may be empty); + /// empty when it is too large to read or can't be read, `None` when it + /// was deleted since the listing. async fn lock_contents( &self, bucket: &str, @@ -788,6 +811,19 @@ impl DetectApi for SdkDetectApi { }; for (key, age, size) in listed { if key.ends_with("/terraform.tfstate") { + // `tofu destroy` leaves the state object behind, empty. + // An empty state is no deployment: without this an + // environment destroyed from a bundle (or by the app's + // own Destroy) would read as Incomplete for ever, and a + // removed onboarding as Joined. Only a small object can + // be empty, so only those are read. + if (0..=MAX_UNTRUSTED_BYTES as i64).contains(&size) { + match self.lock_contents(bucket, &key, size).await? { + None => continue, + Some(text) if is_empty_state(&text) => continue, + Some(_) => {} + } + } objects.states.push(key); } else if key.ends_with("/terraform.tfstate.tflock") { lock_keys.push((key, age, size)); @@ -926,6 +962,19 @@ pub async fn ensure_state_bucket( let region = validate_supported_region(region).map_err(|message| AwsError::Other { message })?; let bucket = state_bucket_name(account, ®ion); + ensure_named_state_bucket(cfg, account, ®ion, &bucket).await +} + +/// [`ensure_state_bucket`] for a given bucket name (the live tests' own +/// throwaway bucket); `region` is already validated. +pub(crate) async fn ensure_named_state_bucket( + cfg: &SdkConfig, + account: &str, + region: &str, + bucket: &str, +) -> Result { + let region = region.to_string(); + let bucket = bucket.to_string(); let s3 = aws_sdk_s3::Client::new(cfg); if let Err(e) = s3 diff --git a/app/src-tauri/src/aws/environments/tests.rs b/app/src-tauri/src/aws/environments/tests.rs index ecf3108..90c2751 100644 --- a/app/src-tauri/src/aws/environments/tests.rs +++ b/app/src-tauri/src/aws/environments/tests.rs @@ -826,3 +826,16 @@ fn the_scan_serializes_for_the_frontend() { assert_eq!(v["environments"][0]["actions"], json!(["join"])); assert_eq!(v["environments"][0]["onboarding"]["state"], "not_joined"); } + +#[test] +fn only_a_state_that_manages_nothing_is_empty() { + // What `tofu destroy` leaves behind (OpenTofu 1.13). + let destroyed = r#"{"version":4,"terraform_version":"1.13.9","serial":31,"lineage":"0b6e3a0e-6f0c-4c35-a0f3-1d0f3f6c9a11","outputs":{},"resources":[],"check_results":null}"#; + assert!(is_empty_state(destroyed)); + let live = r#"{"version":4,"serial":3,"outputs":{},"resources":[{"mode":"managed","type":"aws_iam_policy","name":"boundary","instances":[]}]}"#; + assert!(!is_empty_state(live)); + // Unreadable or odd contents are a deployment, not nothing. + for odd in ["", "not json", "{}", r#"{"resources":null}"#, r#"{"resources":{}}"#, "[]"] { + assert!(!is_empty_state(odd), "{odd:?}"); + } +} -- 2.52.0 From ba88323febfcc3e9db36239b83c7290aca012a00 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 23:31:49 -0800 Subject: [PATCH 21/24] test(aws): live environment lifecycle with OpenTofu as an environment admin; docs The environment live tests apply the real modules through the real detached runner (the built app binary) as a role holding only the admin files: state bucket, public deploy, scan, self-onboard, update with no changes, destroy; the conditional lock delete (412 on a stale ETag); and a private-nat deploy. A guard destroys what is left as the account admin and deletes the throwaway bucket. CLAUDE.md and the spec describe the OpenTofu design (D2-D10) in place of CloudFormation; phase 4 is done. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 22 +- app/src-tauri/src/aws/live_tests.rs | 709 +++++++++++++++++- app/src-tauri/src/aws/tofu.rs | 5 +- .../src/commands/environment_commands.rs | 30 +- .../specs/2026-11-13-aws-ecs-host-design.md | 159 +++- 5 files changed, 874 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ae7a67b..c0ee221 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -327,7 +327,27 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li admin credentials for a disposable account; `cargo test aws::live_tests -- --ignored --test-threads=1`) runs the check and real calls against throwaway `triple-c-e2e-*` roles, secrets and keys, and cleans them up. Re-run it after any policy change; the results are in - `aws/README.md` ("Live verification"). + `aws/README.md` ("Live verification"). Its `environment` tests apply the real modules as a role + holding only the admin files (`cargo build` first: the runner they start is the built app + binary), in a throwaway state bucket. + **Environments are OpenTofu modules** (`aws/terraform/{environment,user-onboard,permissions-boundary}`; + spec D2–D10). `tofu_modules.rs` embeds them and is the **only** place the shared names are built + (environment, `/triple-c/environments/` record, owner key, per-user roles, state bucket + `triple-c-tfstate--`, state keys); its tests pin the HCL to those names, the + versions and tags, and `MODULE_CALLS` (every provider call) to the admin policy. + `tofu.rs` downloads the pinned OpenTofu (SHA-256 checked **before** extraction), writes a work + folder (`materialize`), builds tofu's environment from scratch (`child_env`: no inherited + `AWS_*`/`TF_*`, keys only in the environment) and runs `init` + `apply|destroy` **detached + (D10)**: the app binary re-executed with `--tofu-runner` (`main.rs` checks that first), in its + own session, logging to `apply.log`; liveness is the runner's `apply.lock`, never a PID; closing + the app never stops a run and the next start re-follows the log. `environments.rs` is detection + (records + state objects + locks; records are untrusted and capped; an empty state, which + `tofu destroy` leaves behind, is no deployment) and `ensure_state_bucket`; + `commands/environment_commands.rs` sequences ensure_tofu → ensure_state_bucket → materialize → + start → follow. **D2: strict tenancy gives each user their own EFS file system** (its policy + admits only their task role) from their onboarding module; the environment keeps only the shared + pieces. The app destroys only incomplete deployments (D6); a working environment is removed with + `tofu destroy` from an admin bundle (D5). - **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server: - `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades - `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect diff --git a/app/src-tauri/src/aws/live_tests.rs b/app/src-tauri/src/aws/live_tests.rs index 9385318..77be47b 100644 --- a/app/src-tauri/src/aws/live_tests.rs +++ b/app/src-tauri/src/aws/live_tests.rs @@ -289,6 +289,11 @@ impl E2e { /// An SDK config for a session of `role`, once IAM lets it be assumed /// and its policies have propagated. async fn assume(&self, role: &Role) -> SdkConfig { + sdk_config_for_keys(&self.assume_keys(role).await, &self.region) + } + + /// A session of `role` as keys (see [`E2e::assume`]). + async fn assume_keys(&self, role: &Role) -> AwsKeys { let mut last = None; for _ in 0..20 { match self @@ -309,7 +314,7 @@ impl E2e { // New sessions see a just-attached policy after a few // seconds; denials in that window would be noise. tokio::time::sleep(Duration::from_secs(10)).await; - return sdk_config_for_keys(&keys, &self.region); + return keys; } Err(e) => { last = Some(classify_sdk_error("sts:AssumeRole", &e).to_string()); @@ -957,3 +962,705 @@ async fn the_admin_tier_creates_only_bounded_roles_and_tagged_keys() { }) .await; } + +/// Phase 4: OpenTofu environments, deployed for real by a role that holds +/// only the environment-admin policies. +/// +/// ```sh +/// cd app/src-tauri +/// cargo build # the runner is the app binary re-executed (see below) +/// TRIPLEC_AWS_E2E_PROFILE=… TRIPLEC_AWS_E2E_REGION=us-west-2 \ +/// cargo test aws::live_tests::environment -- --ignored --test-threads=1 +/// ``` +/// +/// What runs the way the app runs it: `ensure_tofu` (the pinned download), +/// `ensure_state_bucket`'s configuration, `materialize`, `child_env` (a keys +/// connection for the environment admin, a profile connection for the +/// account admin), the detached runner — the **built app binary** +/// (`target/debug/triple-c`, or `TRIPLEC_E2E_APP_BIN`) re-executed with +/// `--tofu-runner`, because the test harness is not the app and can't +/// re-execute itself as one — `follow`, `scan`, the refusal checks the +/// commands make, and the conditional lock delete. What doesn't: the Tauri +/// commands themselves (they need an `AppHandle` and saved settings), so the +/// slot/follower bookkeeping and the event stream are not exercised here. +/// +/// Extra variables: +/// - `TRIPLEC_E2E_BUCKET_SUFFIX`: the state bucket is +/// `triple-c-tfstate---` (default: random), so the +/// account's real one is never touched; +/// - `TRIPLEC_E2E_TOFU_ROOT`: where OpenTofu, the provider cache and the work +/// folders go (default: `/triple-c-e2e-tofu`, kept between runs). +mod environment { + use std::path::PathBuf; + use std::sync::Arc; + use std::time::Duration; + + use aws_config::SdkConfig; + + use super::{e2e, ok, E2e, Role}; + use crate::aws::environments::{ + ensure_named_state_bucket, is_empty_state, scan_in_bucket, EnvironmentAction, EnvironmentState, Knowable, + OnboardingState, SdkDetectApi, + }; + use crate::aws::policies::{policy_files, Tier, PERMISSIONS_BOUNDARY_NAME}; + use crate::aws::tofu::{ + self, child_env, ensure_tofu, follow, materialize, start_detached_with, TofuOp, + TofuOutcome, Workdir, + }; + use crate::aws::tofu_modules::{ + boundary_vars, environment_vars, onboarding_vars, owner_key, state_bucket_name, state_key, + user_role_names, Deployment, ModuleKind, NetworkChoice, + }; + use crate::aws::{caller_identity, sdk_config_for_keys, CallerIdentity}; + use crate::commands::environment_commands::{ + delete_lock_if_unchanged, deploy_refusal, network_from_record, onboard_refusal, + validate_owner_id, LOCK_CHANGED, + }; + use crate::models::host_connection::{AwsAuth, HostConnection, HostKind}; + use crate::storage::secure::AwsKeys; + + /// The environment admin: a role holding the admin tier's policy files + /// as managed policies, and nothing else. + struct Admin { + role: Role, + } + + struct Env { + e: Arc, + root: PathBuf, + tofu: PathBuf, + app_bin: PathBuf, + bucket: String, + /// Deployments applied (work folder, still to destroy), newest last. + applied: std::sync::Mutex>, + /// The test created the boundary (through its module). + boundary_created: std::sync::Mutex, + bucket_created: std::sync::Mutex, + } + + fn app_bin() -> PathBuf { + if let Ok(p) = std::env::var("TRIPLEC_E2E_APP_BIN") { + return PathBuf::from(p); + } + // target/debug/deps/ -> target/debug/triple-c + let exe = std::env::current_exe().expect("current exe"); + let dir = exe.parent().and_then(|d| d.parent()).expect("target dir"); + dir.join(format!("triple-c{}", std::env::consts::EXE_SUFFIX)) + } + + fn keys_connection(region: &str) -> HostConnection { + HostConnection { + id: "e2e-envadmin".into(), + name: "e2e-envadmin".into(), + kind: HostKind::Aws { + auth: AwsAuth::Keys { + keyring_ref: "unused".into(), + }, + default_region: region.to_string(), + }, + } + } + + fn profile_connection(region: &str) -> HostConnection { + HostConnection { + id: "e2e".into(), + name: "e2e".into(), + kind: HostKind::Aws { + auth: AwsAuth::Profile { + name: super::var("TRIPLEC_AWS_E2E_PROFILE"), + }, + default_region: region.to_string(), + }, + } + } + + /// `not authorized to perform: ` (and the KMS/S3 phrasings) in a + /// log message. + fn denied_actions(message: &str) -> Vec { + let mut out = Vec::new(); + for marker in ["not authorized to perform: ", "is not authorized to perform "] { + let mut rest = message; + while let Some(i) = rest.find(marker) { + rest = &rest[i + marker.len()..]; + let action: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '*') + .collect(); + if !action.is_empty() && !out.contains(&action) { + out.push(action); + } + } + } + if out.is_empty() + && (message.contains("AccessDenied") || message.contains("UnauthorizedOperation")) + { + out.push("(unnamed: see the message above)".into()); + } + out + } + + impl Env { + async fn new(e: Arc) -> Arc { + let root = std::env::var("TRIPLEC_E2E_TOFU_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir().join("triple-c-e2e-tofu")); + std::fs::create_dir_all(&root).unwrap(); + let app_bin = app_bin(); + assert!( + app_bin.is_file(), + "{} is missing: run `cargo build` first (the runner is the app binary)", + app_bin.display() + ); + let tofu = ensure_tofu(&root).await.expect("ensure_tofu"); + let suffix = std::env::var("TRIPLEC_E2E_BUCKET_SUFFIX").unwrap_or_else(|_| { + format!("e2e{}", &uuid::Uuid::new_v4().simple().to_string()[..8]) + }); + let bucket = format!("{}-{suffix}", state_bucket_name(&e.account, &e.region)); + assert!(bucket.len() <= 63, "{bucket} is too long for a bucket name"); + Arc::new(Env { + e, + root, + tofu, + app_bin, + bucket, + applied: Default::default(), + boundary_created: Default::default(), + bucket_created: Default::default(), + }) + } + + /// The environment admin role (no boundary on the role itself: the + /// boundary allows only what tasks do, so an admin under it could + /// administer nothing). + async fn admin(&self) -> Admin { + let role = self.e.role("envadmin", None, &[]).await; + for (i, (file, json)) in policy_files(Tier::EnvironmentAdmin).iter().enumerate() { + let arn = self.e.managed_policy(&format!("envadmin{i}"), json).await; + eprintln!("envadmin: {file} -> {arn}"); + self.e.attach(&role, &arn).await; + } + Admin { role } + } + + /// A fresh session of the admin role (role chaining caps one at an + /// hour, so each phase takes its own). + async fn session(&self, admin: &Admin) -> (AwsKeys, SdkConfig, CallerIdentity) { + let keys = self.e.assume_keys(&admin.role).await; + let cfg = sdk_config_for_keys(&keys, &self.e.region); + let who = caller_identity(&cfg).await.expect("envadmin identity"); + (keys, cfg, who) + } + + async fn scan(&self, cfg: &SdkConfig, who: &CallerIdentity) -> crate::aws::environments::EnvironmentScan { + let api = SdkDetectApi::new(cfg, &who.account); + let (root, bucket) = (self.root.clone(), self.bucket.clone()); + let running = move |d: Deployment| { + tofu::workdir_for(&root, d, &bucket) + .map(|wd| tofu::is_running(&wd)) + .unwrap_or(false) + }; + scan_in_bucket(&api, who, &self.e.region, &self.bucket, &running) + .await + .expect("scan") + } + + /// Write the module and run `op` through the detached runner, printing + /// the log and every denied action. + async fn run( + &self, + kind: ModuleKind, + d: Deployment<'_>, + vars: &serde_json::Value, + op: TofuOp, + env: Vec<(String, String)>, + ) -> (Workdir, TofuOutcome, Vec) { + let wd = materialize(&self.root, kind, d, &self.bucket, &self.e.region, vars) + .expect("materialize"); + if op == TofuOp::Apply { + let mut applied = self.applied.lock().unwrap(); + if !applied.iter().any(|(w, _)| *w == wd) { + applied.push((wd.clone(), true)); + } + } + let label = state_key(d); + eprintln!("=== tofu {op:?} {label} ({})", wd.dir.display()); + let (bin, tofu_bin, start_wd) = (self.app_bin.clone(), self.tofu.clone(), wd.clone()); + let started = tokio::task::spawn_blocking(move || { + start_detached_with(&bin, Duration::from_secs(30), &tofu_bin, &start_wd, op, &env) + }) + .await + .unwrap() + .unwrap_or_else(|e| panic!("start {label}: {e}")); + assert!(started.pid.is_some(), "the runner wrote no PID"); + let mut denied = Vec::new(); + let outcome = follow(&wd, Duration::from_millis(500), |events| { + for ev in events { + let resource = ev.resource.as_deref().unwrap_or(""); + let short: String = ev.message.chars().take(300).collect(); + eprintln!(" [{}] {} {} {}", ev.level, ev.kind, resource, short); + if ev.level == "error" { + eprintln!(" ERROR DETAIL: {}", ev.message); + for a in denied_actions(&ev.message) { + eprintln!(" DENIED: {a} ({resource})"); + if !denied.contains(&a) { + denied.push(a); + } + } + } + } + }) + .await + .expect("follow"); + eprintln!("=== {label}: {outcome:?}"); + if op == TofuOp::Destroy && outcome.ok { + for (w, pending) in self.applied.lock().unwrap().iter_mut() { + if *w == wd { + *pending = false; + } + } + } + (wd, outcome, denied) + } + + fn keys_env(&self, keys: &AwsKeys) -> Vec<(String, String)> { + child_env( + &keys_connection(&self.e.region), + Some(keys), + &self.root, + std::env::vars(), + ) + .expect("child env") + } + + fn admin_env(&self) -> Vec<(String, String)> { + child_env(&profile_connection(&self.e.region), None, &self.root, std::env::vars()) + .expect("child env") + } + + async fn ensure_bucket(&self, cfg: &SdkConfig, who: &CallerIdentity) { + *self.bucket_created.lock().unwrap() = true; + let b = ensure_named_state_bucket(cfg, &who.account, &self.e.region, &self.bucket) + .await + .unwrap_or_else(|e| panic!("ensure_state_bucket as the environment admin: {e}")); + assert_eq!(b, self.bucket); + } + + /// The boundary, through its module as the account admin, if the + /// account has none. + async fn ensure_boundary(&self) { + let arn = format!( + "arn:aws:iam::{}:policy/{PERMISSIONS_BOUNDARY_NAME}", + self.e.account + ); + if self.e.iam.get_policy().policy_arn(&arn).send().await.is_ok() { + eprintln!("the account already has the boundary; using it"); + return; + } + *self.boundary_created.lock().unwrap() = true; + let (_, outcome, denied) = self + .run( + ModuleKind::PermissionsBoundary, + Deployment::Boundary, + &boundary_vars(&self.e.region), + TofuOp::Apply, + self.admin_env(), + ) + .await; + assert!(outcome.ok, "boundary apply: {outcome:?} denied {denied:?}"); + } + + /// As the account admin: destroy whatever is still applied (newest + /// first), the bucket with every version, and the boundary if this + /// test made it. Logs rather than fails, so one error strands nothing + /// else. + async fn cleanup(&self) { + let pending: Vec = self + .applied + .lock() + .unwrap() + .iter() + .rev() + .filter(|(_, p)| *p) + .map(|(w, _)| w.clone()) + .collect(); + for wd in pending { + eprintln!("cleanup: destroying {} as the account admin", wd.dir.display()); + let env = self.admin_env(); + let (bin, tofu_bin, w) = (self.app_bin.clone(), self.tofu.clone(), wd.clone()); + let started = tokio::task::spawn_blocking(move || { + start_detached_with(&bin, Duration::from_secs(30), &tofu_bin, &w, TofuOp::Destroy, &env) + }) + .await; + match started { + Ok(Ok(_)) => { + let outcome = follow(&wd, Duration::from_millis(500), |events| { + for ev in events.iter().filter(|e| e.level == "error") { + eprintln!(" cleanup error: {}", ev.message); + } + }) + .await; + eprintln!("cleanup: destroy {}: {outcome:?}", wd.dir.display()); + } + other => eprintln!("cleanup: could not start destroy: {other:?}"), + } + } + if *self.boundary_created.lock().unwrap() { + let arn = format!( + "arn:aws:iam::{}:policy/{PERMISSIONS_BOUNDARY_NAME}", + self.e.account + ); + if self.e.iam.get_policy().policy_arn(&arn).send().await.is_ok() { + eprintln!("cleanup: deleting the boundary directly"); + if let Ok(out) = self.e.iam.list_entities_for_policy().policy_arn(&arn).send().await { + for r in out.policy_roles() { + if let Some(name) = r.role_name() { + let _ = self + .e + .iam + .delete_role_permissions_boundary() + .role_name(name) + .send() + .await; + } + } + } + if let Err(e) = self.e.iam.delete_policy().policy_arn(&arn).send().await { + eprintln!("cleanup: boundary: {e:?}"); + } + } + } + if *self.bucket_created.lock().unwrap() { + self.delete_bucket().await; + } + } + + async fn delete_bucket(&self) { + let s3 = aws_sdk_s3::Client::new(&self.e.cfg); + loop { + let page = match s3.list_object_versions().bucket(&self.bucket).send().await { + Ok(p) => p, + Err(e) => { + use aws_sdk_s3::error::ProvideErrorMetadata; + if e.code() != Some("NoSuchBucket") { + eprintln!("cleanup: list versions of {}: {e:?}", self.bucket); + } + return; + } + }; + let mut ids = Vec::new(); + for v in page.versions() { + ids.push((v.key().unwrap_or_default().to_string(), v.version_id().map(str::to_string))); + } + for m in page.delete_markers() { + ids.push((m.key().unwrap_or_default().to_string(), m.version_id().map(str::to_string))); + } + if ids.is_empty() { + break; + } + for (key, version) in ids { + if let Err(e) = s3 + .delete_object() + .bucket(&self.bucket) + .key(&key) + .set_version_id(version) + .send() + .await + { + eprintln!("cleanup: delete {key}: {e:?}"); + return; + } + } + } + match s3.delete_bucket().bucket(&self.bucket).send().await { + Ok(_) => eprintln!("cleanup: deleted bucket {}", self.bucket), + Err(e) => eprintln!("cleanup: delete bucket {}: {e:?}", self.bucket), + } + } + } + + /// `e2e` plus the environment fixture's own cleanup, which runs first + /// (it needs the account admin, and the roles it destroys hold the + /// boundary). + async fn env_e2e(body: F) + where + F: FnOnce(Arc) -> Fut, + Fut: std::future::Future, + { + use futures_util::FutureExt; + e2e(|e| async move { + let env = Env::new(e).await; + let result = std::panic::AssertUnwindSafe(body(env.clone())) + .catch_unwind() + .await; + env.cleanup().await; + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } + }) + .await; + } + + fn assert_ok(what: &str, outcome: &TofuOutcome, denied: &[String]) { + assert!( + outcome.ok, + "{what} failed: {:?}; denied actions: {denied:?}", + outcome.summary + ); + assert!(denied.is_empty(), "{what} reported denied actions: {denied:?}"); + } + + #[tokio::test] + #[ignore] + async fn an_environment_admin_can_deploy_join_update_and_remove_an_environment() { + env_e2e(|t| async move { + let admin = t.admin().await; + let (keys, cfg, who) = t.session(&admin).await; + assert!(who.arn.contains(":assumed-role/"), "{}", who.arn); + + // The state bucket, created and configured by the environment + // admin (s3:CreateBucket with the request tag, in-account). + t.ensure_bucket(&cfg, &who).await; + // Idempotent. + t.ensure_bucket(&cfg, &who).await; + + t.ensure_boundary().await; + + let name = format!("e2e{}", &uuid::Uuid::new_v4().simple().to_string()[..6]); + let found = t.scan(&cfg, &who).await; + assert_eq!(found.state_bucket, Knowable::Yes); + assert_eq!(found.boundary, Knowable::Yes); + deploy_refusal(&found, &name).expect("deploy is offered"); + + // Deploy, public network, as the environment admin only. + let vars = environment_vars(&name, &t.e.region, &NetworkChoice::Public).unwrap(); + let (_, outcome, denied) = t + .run( + ModuleKind::Environment, + Deployment::Environment(&name), + &vars, + TofuOp::Apply, + t.keys_env(&keys), + ) + .await; + assert_ok("deploy", &outcome, &denied); + + // Detected Ready with a valid record, joinable, not joined yet. + let found = t.scan(&cfg, &who).await; + let env = found + .environments + .iter() + .find(|x| x.name == name) + .unwrap_or_else(|| panic!("{name} not detected: {found:#?}")); + assert_eq!(env.state, EnvironmentState::Ready, "{env:#?}"); + let info = env.info.clone().expect("a valid record"); + assert_eq!(info.network_mode, "public"); + assert!(info.assign_public_ip); + assert_eq!(info.subnet_ids.len(), 2); + assert!(env.actions.contains(&EnvironmentAction::Join), "{env:#?}"); + assert_eq!(env.onboarding, Some(OnboardingState::NotJoined)); + + // A stale lock's conditional delete: a mismatched ETag is refused + // (412), the current one removes it. + { + let s3 = aws_sdk_s3::Client::new(&cfg); + let lock_key = format!("{}.tflock", state_key(Deployment::Environment(&name))); + let put = ok( + "put a fake lock", + s3.put_object() + .bucket(&t.bucket) + .key(&lock_key) + .body(r#"{"ID":"e2e","Operation":"OperationTypeApply","Who":"e2e@test","Created":"2026-01-01T00:00:00Z"}"#.as_bytes().to_vec().into()) + .expected_bucket_owner(&who.account) + .send() + .await, + ); + let etag = put.e_tag().expect("etag").to_string(); + let head = ok( + "head the lock", + s3.head_object().bucket(&t.bucket).key(&lock_key).send().await, + ); + assert_eq!(head.e_tag(), Some(etag.as_str())); + let found = t.scan(&cfg, &who).await; + let env = found.environments.iter().find(|x| x.name == name).unwrap(); + assert!( + matches!(&env.state, EnvironmentState::Deploying { lock: Some(l) } if l.who == "e2e@test"), + "a fresh foreign lock reads as Deploying: {env:#?}" + ); + let wrong = delete_lock_if_unchanged( + &s3, + &t.bucket, + &who.account, + &lock_key, + "\"00000000000000000000000000000000\"", + ) + .await + .expect_err("a mismatched ETag must not delete the lock"); + assert!(wrong.to_string().contains(LOCK_CHANGED), "{wrong}"); + ok( + "the lock survives a mismatched delete", + s3.head_object().bucket(&t.bucket).key(&lock_key).send().await, + ); + delete_lock_if_unchanged(&s3, &t.bucket, &who.account, &lock_key, &etag) + .await + .unwrap_or_else(|e| panic!("conditional delete with the current ETag: {e}")); + let gone = s3.head_object().bucket(&t.bucket).key(&lock_key).send().await; + assert!(gone.is_err(), "the lock is still there after the delete"); + eprintln!("conditional unlock: 412 on a mismatched ETag, deleted with the current one"); + } + + // Self-onboarding as the environment admin. + let (keys, cfg, who) = t.session(&admin).await; + let found = t.scan(&cfg, &who).await; + onboard_refusal(&found, &name).expect("join is offered"); + let owner = validate_owner_id(&who.user_id).expect("owner id"); + let okey = owner_key(&owner); + let (_, outcome, denied) = t + .run( + ModuleKind::UserOnboard, + Deployment::User { env: &name, owner_key: &okey }, + &onboarding_vars(&name, &t.e.region, &owner), + TofuOp::Apply, + t.keys_env(&keys), + ) + .await; + assert_ok("onboard", &outcome, &denied); + let found = t.scan(&cfg, &who).await; + let env = found.environments.iter().find(|x| x.name == name).unwrap(); + assert_eq!(env.onboarding, Some(OnboardingState::Joined), "{env:#?}"); + let (task_role, exec_role) = user_role_names(&name, &okey); + for r in [&task_role, &exec_role] { + let role = ok("get the onboarded role", t.e.iam.get_role().role_name(r).send().await); + let boundary = role + .role() + .and_then(|r| r.permissions_boundary()) + .and_then(|b| b.permissions_boundary_arn()) + .unwrap_or_default() + .to_string(); + assert!(boundary.ends_with(&format!(":policy/{PERMISSIONS_BOUNDARY_NAME}")), "{r}: {boundary}"); + } + + // Update: the recorded network, the same module version: nothing + // to change. + let network = network_from_record(&info, None).expect("network from record"); + let vars = environment_vars(&name, &t.e.region, &network).unwrap(); + let (_, outcome, denied) = t + .run( + ModuleKind::Environment, + Deployment::Environment(&name), + &vars, + TofuOp::Apply, + t.keys_env(&keys), + ) + .await; + assert_ok("update", &outcome, &denied); + let summary = outcome.summary.clone().unwrap_or_default(); + assert!( + summary.contains("0 added, 0 changed, 0 destroyed"), + "the update changed something: {summary}" + ); + + // Remove: the onboarding, then the environment (an admin's + // `tofu destroy` from a bundle; the same module and state). + let (keys, _, _) = t.session(&admin).await; + let (_, outcome, denied) = t + .run( + ModuleKind::UserOnboard, + Deployment::User { env: &name, owner_key: &okey }, + &onboarding_vars(&name, &t.e.region, &owner), + TofuOp::Destroy, + t.keys_env(&keys), + ) + .await; + assert_ok("destroy the onboarding", &outcome, &denied); + let (_, outcome, denied) = t + .run( + ModuleKind::Environment, + Deployment::Environment(&name), + &vars, + TofuOp::Destroy, + t.keys_env(&keys), + ) + .await; + assert_ok("destroy the environment", &outcome, &denied); + + // `tofu destroy` leaves an empty state object, which detection + // must read as no deployment at all. + let state = ok( + "read the destroyed state", + aws_sdk_s3::Client::new(&cfg) + .get_object() + .bucket(&t.bucket) + .key(state_key(Deployment::Environment(&name))) + .send() + .await, + ); + let bytes = state.body.collect().await.unwrap().into_bytes(); + assert!( + is_empty_state(&String::from_utf8_lossy(&bytes)), + "the destroyed state is not empty: {}", + String::from_utf8_lossy(&bytes) + ); + let found = t.scan(&cfg, &who).await; + assert!( + found.environments.iter().all(|x| x.name != name), + "{name} still detected after destroy: {found:#?}" + ); + + // The boundary, by its module, if this test made it. + if *t.boundary_created.lock().unwrap() { + let (_, outcome, denied) = t + .run( + ModuleKind::PermissionsBoundary, + Deployment::Boundary, + &boundary_vars(&t.e.region), + TofuOp::Destroy, + t.admin_env(), + ) + .await; + assert_ok("destroy the boundary", &outcome, &denied); + } + }) + .await; + } + + /// About $0.05: one NAT gateway and its address for a few minutes. + #[tokio::test] + #[ignore] + async fn the_private_nat_mode_deploys() { + env_e2e(|t| async move { + let admin = t.admin().await; + let (keys, cfg, who) = t.session(&admin).await; + t.ensure_bucket(&cfg, &who).await; + let name = format!("e2e{}", &uuid::Uuid::new_v4().simple().to_string()[..6]); + let vars = environment_vars(&name, &t.e.region, &NetworkChoice::PrivateNat).unwrap(); + let (_, outcome, denied) = t + .run( + ModuleKind::Environment, + Deployment::Environment(&name), + &vars, + TofuOp::Apply, + t.keys_env(&keys), + ) + .await; + assert_ok("deploy private-nat", &outcome, &denied); + let found = t.scan(&cfg, &who).await; + let env = found.environments.iter().find(|x| x.name == name).unwrap(); + assert_eq!(env.state, EnvironmentState::Ready, "{env:#?}"); + let info = env.info.as_ref().unwrap(); + assert_eq!(info.network_mode, "private-nat"); + assert!(!info.assign_public_ip); + let (keys, _, _) = t.session(&admin).await; + let (_, outcome, denied) = t + .run( + ModuleKind::Environment, + Deployment::Environment(&name), + &vars, + TofuOp::Destroy, + t.keys_env(&keys), + ) + .await; + assert_ok("destroy private-nat", &outcome, &denied); + }) + .await; + } +} diff --git a/app/src-tauri/src/aws/tofu.rs b/app/src-tauri/src/aws/tofu.rs index 61fc691..3ab431c 100644 --- a/app/src-tauri/src/aws/tofu.rs +++ b/app/src-tauri/src/aws/tofu.rs @@ -706,7 +706,10 @@ pub fn start_detached( /// After the runner takes its lock, how long to wait for its PID file. const PID_GRACE: Duration = Duration::from_secs(2); -fn start_detached_with( +/// [`start_detached`] with the runner program and start timeout given: the +/// unit tests pass a fake runner, the live tests the built app binary (the +/// test harness is not the app, so it can't re-execute itself as one). +pub(crate) fn start_detached_with( program: &Path, timeout: Duration, tofu: &Path, diff --git a/app/src-tauri/src/commands/environment_commands.rs b/app/src-tauri/src/commands/environment_commands.rs index b071f57..17e38cd 100644 --- a/app/src-tauri/src/commands/environment_commands.rs +++ b/app/src-tauri/src/commands/environment_commands.rs @@ -1095,24 +1095,38 @@ pub async fn unlock_aws_state( .map(|t| now.saturating_sub(t.secs()).max(0) as u64) .ok_or_else(|| other("The state lock's age is unknown, so it can't be removed safely"))?; stale_lock_refusal(age).map_err(other)?; + delete_lock_if_unchanged(&s3, &ctx.bucket(), &ctx.who.account, &lock_key, &etag).await?; + log::info!("Removed the stale state lock {lock_key}"); + Ok(()) +} + +/// The lock was replaced since it was read: its ETag no longer matches. +pub(crate) const LOCK_CHANGED: &str = "The state lock changed since it was checked; detect again"; + +/// Delete `lock_key` only if its ETag is still `etag` (S3 conditional +/// delete, `If-Match`); [`LOCK_CHANGED`] when it is not. +pub(crate) async fn delete_lock_if_unchanged( + s3: &aws_sdk_s3::Client, + bucket: &str, + account: &str, + lock_key: &str, + etag: &str, +) -> Result<(), AwsError> { if let Err(e) = s3 .delete_object() - .bucket(ctx.bucket()) - .key(&lock_key) - .if_match(&etag) - .expected_bucket_owner(&ctx.who.account) + .bucket(bucket) + .key(lock_key) + .if_match(etag) + .expected_bucket_owner(account) .send() .await { let status = e.raw_response().map(|r| r.status().as_u16()); if is_precondition_failed(e.code(), status) { - return Err(other( - "The state lock changed since it was checked; detect again", - )); + return Err(other(LOCK_CHANGED)); } return Err(crate::aws::classify_sdk_error("s3:DeleteObject", &e)); } - log::info!("Removed the stale state lock {lock_key}"); Ok(()) } diff --git a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md index 69ea4aa..5215ae9 100644 --- a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md +++ b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md @@ -1,7 +1,7 @@ # Triple-C on AWS (ECS on EC2 / Fargate) — design Date: 2026-11-13 -Status: **draft for review** (user decisions recorded 2026-11-13). The user set the direction (local or AWS, login like the AWS CLI, +Status: **draft for review** (user decisions recorded 2026-11-13 and 2026-11-14; phase 4 done). The user set the direction (local or AWS, login like the AWS CLI, permission check, hosting options, secrets pushed automatically, shared accounts). Everything else here is a proposal. @@ -31,6 +31,48 @@ The AWS flow, in the user's words: | Tenancy | **Strict from v1, and the default.** Most people will set this up in their own accounts. Strict costs a single user nothing extra (they onboard themselves automatically), and it means the setup is already safe the day a second person joins | | Connection model | **C, hybrid.** A connection is a login (profile or keys) plus a **default region**. Environments are detected per region when needed; each project records `(connection, region, environment)`. See [Connection model](#connection-model) | +### Decisions (user, 2026-11-14): environments are OpenTofu + +The user chose **Terraform over CloudFormation**, the **app runs it** itself, with **OpenTofu** +(MPL-2.0, safe for the app to download and run), and **state in an S3 bucket** in the account. +Phase 4 added these decisions: + +- **D2. Strict tenancy gives each user their own EFS file system**, created by their onboarding + module. EFS can't bind an access point to its owner's task role (`ClientMount` sees the access + point's ARN, not its tags, and access-point IDs are random), so the file-system policy admits + only that user's task role. An empty file system costs nothing. The environment keeps the shared + pieces: cluster, security group, KMS key, log group and network. +- **D3. No ECR in v1.** Tasks pull `ghcr.io/shadowdao/triple-c-sandbox` straight from GHCR (the + default when an environment has no ECR mirror). +- **D4. Network modes** (`network_mode`): `public` (**default**: a new VPC, two public subnets, + tasks get a public IP, the security group has no inbound rules, no NAT, $0); `private-nat` (a new + VPC with two public and two private subnets and one NAT gateway; the UI says about $33/month); + `existing` (the user's `vpc_id`, exactly two `subnet_ids` in different AZs, and + `assign_public_ip`). +- **D5. An admin request is a folder bundle**, written into a folder picked through a Rust-side + dialog: the module's files, `backend.hcl`, `terraform.tfvars.json` and a `README.txt` with the + exact commands (`tofu init -backend-config=backend.hcl`, then `tofu apply`; Terraform 1.10+ + works too). +- **D6. The app destroys only incomplete deployments** (state without an environment record, or + a failed apply). A working environment is destroyed by an admin with `tofu destroy` from a + bundle, which is the irreversible case. +- **D7. The admin tier spans several policy files** (`environment-admin.json` and + `environment-admin-network.json`), all required; `aws_policy_document` returns every file of a + tier. +- **D8. CloudFormation left the policies**, with its actions, checks and probes. Detection for a + workspace user reads only `ssm:GetParameter*` on `/triple-c/*` and `iam:GetRole` on + `role/triple-c-*`; the bucket (state objects and locks) is admin-only, so a workspace user sees + only environments that have a record. +- **D9. One state key per deployment**, in the per-account, per-region bucket + `triple-c-tfstate--`: `environments//terraform.tfstate`, + `users///terraform.tfstate`, `boundary/terraform.tfstate`. The lock is the + matching `.tflock` object (S3-native locking, `use_lockfile = true`); a held lock is a deploy in + progress, and its `Who`/`Created`/`Operation` are shown. +- **D10. Applies are detached.** `tofu apply` runs in its own process group, output to + `/apply.log`, PID in `/apply.pid`. Closing the app doesn't interrupt it; on the + next run the app sees the live runner or the held lock and reattaches by following the log. It + interrupts only when the user presses Cancel, and OpenTofu then stops gracefully and saves state. + ### Non-goals (v1) - Kubernetes/EKS, other clouds, or remote Docker hosts. The host abstraction below allows them @@ -83,40 +125,48 @@ users don't need the AWS CLI. Only SSO login needs it, until the device flow lan Triple-C looks for existing Triple-C infrastructure in the region in two ways, so it finds an environment even if one of them is missing: -- the SSM parameters `/triple-c/environments/*`, written by the environment stack, whose JSON - value lists the environment's ARNs and IDs; -- CloudFormation stacks tagged `triple-c:component=environment`. This catches a stack whose - parameter was deleted, or one that is half-deployed (`*_IN_PROGRESS`, `*_FAILED`). Those are - shown with their state and a "Repair or delete" action instead of being silently ignored. +- the SSM parameters `/triple-c/environments/*`, written last by the environment module, whose + JSON value (`EnvironmentInfo`, schema 1) lists the environment's ARNs and IDs. The record is + untrusted input: every field is validated and the value capped at 4 KiB; +- the OpenTofu state objects and `.tflock` locks in the state bucket (D9; admins only, D8). This + catches a deployment whose record is missing (a failed or interrupted apply) or one in progress. + A state without a record, a failed apply or a stale lock (over two hours old) is shown as + **Incomplete** with Repair, Unlock or, without a record, Destroy (D6), instead of being silently + ignored; a fresh lock is **Deploying** (Follow when it is this machine's run). An empty state + (what `tofu destroy` leaves behind) counts as no deployment. -Detection also reads the template version each stack was deployed from, so an environment deployed -by an older Triple-C shows **"Update available"**. +The record carries the module version it was deployed from, so an environment deployed by an older +Triple-C shows **"Update available"**. What the user sees depends on what was found and what they're allowed to do: -| Found | Can deploy (stack permissions) | Offered | +| Found | Can deploy (admin tier) | Offered | |---|---|---| | Nothing | Yes | **Deploy a new environment** (the default path for a personal account) | -| Nothing | No | Explanation, plus the policy JSON and a pre-filled CloudFormation console link to send to whoever owns the account | +| Nothing | No | Explanation, plus the policy JSON and an admin bundle (D5) to send to whoever owns the account | | One or more | Yes | **Join** `` (default) or **Deploy a new one** (for example a separate region or cost boundary) | | One or more | No | **Join** `` | -**Deploy** uses the **CloudFormation template that ships with the app** -(`aws/triple-c-environment.yaml`). A CloudFormation stack is auditable, an admin can deploy it -without Triple-C, and deleting the stack removes everything. The app never creates environment -resources one API call at a time. Deploy progress streams from the stack events into the same -progress view used for local image pulls. +**Deploy** applies the **OpenTofu module that ships with the app** (`aws/terraform/environment`) +with the caller's own credentials. The app downloads a pinned OpenTofu (checked against its SHA-256 +before it is unpacked), creates the state bucket if needed, writes a work folder and starts a +detached `tofu init` + `tofu apply` (D10); progress streams from OpenTofu's JSON log into the +deploy view. An admin can apply the same files without Triple-C from a bundle (D5), and +`tofu destroy` removes everything the module created. The app never creates environment resources +one API call at a time. -**Joining in strict tenancy** (the default) also needs the user's **onboarding stack**: their own -task role, EFS access-point policy entry and, if they use EC2, a dedicated capacity provider. The -app checks whether the caller already has one: +**Joining in strict tenancy** (the default) also needs the user's **onboarding** +(`aws/terraform/user-onboard`): their own task and execution roles (with the permissions +boundary) and their own EFS file system (D2), and later, if they use EC2, a dedicated capacity +provider. The app checks whether the caller already has one (their state object, or their task +role): -- **The caller can deploy it** (always true in their own account): Triple-C deploys - `triple-c-user-onboard.yaml` for them as part of joining. For the person who just deployed the +- **The caller can deploy it** (an environment admin; always true in their own account): Triple-C + applies the onboarding module for them as part of joining. For the person who just deployed the environment, this happens in the same flow, so a single user never sees "onboarding" as a step. - **The caller can't**: Triple-C produces an onboarding request for whoever runs the environment — - a pre-filled CloudFormation console link plus the exact parameters — and the user's "Join" stays - pending until the stack exists. The app re-checks whenever the user opens the dialog. + an admin bundle (D5) with the user's `aws:userid` filled in — and the user's "Join" stays + pending until their roles exist. The app re-checks whenever the user opens the dialog. ### 3. Permission check @@ -124,7 +174,7 @@ There are three tiers, and the check reports which one the user is in: | Tier | Can do | |---|---| -| **Environment admin** | Deploy or update the environment stack, onboard users (strict tenancy) | +| **Environment admin** | Deploy or update an environment (the OpenTofu modules), onboard users (strict tenancy) | | **Workspace user** | Create, start, stop, connect to and delete **their own** workspaces in an existing environment | | **Insufficient** | Lists exactly which actions are missing, and offers the IAM policy JSON to send to an admin | @@ -270,7 +320,7 @@ other. - ECR pull-through cache of `ghcr.io/shadowdao/triple-c-sandbox` (GHCR as an upstream needs a GitHub token stored in Secrets Manager), or -- the environment stack's ECR repo, filled by an admin or CI with `crane copy`. +- an environment's ECR repo (not in v1, D3), filled by an admin or CI with `crane copy`. Pulling straight from GHCR over NAT also works, and is the default when the environment has no ECR mirror. Image updates use the existing "new image available" flow: the next start uses the new digest. @@ -390,7 +440,7 @@ before release.** | Mode | For | Task role | EC2 options | Isolation | |---|---|---|---|---| -| **Strict** (**default, v1**) | Everyone, from a solo account to a team | **Per-user** task role, created by the user's onboarding stack (self-deployed, or by an admin) | Privileged and Docker allowed **only on a per-user capacity provider** (dedicated EC2 instances, never shared with another owner) | Per-user IAM all the way down. EFS file system policy allows each access point only to its owner's task role | +| **Strict** (**default, v1**) | Everyone, from a solo account to a team | **Per-user** task role, created by the user's onboarding module (self-deployed, or by an admin) | Privileged and Docker allowed **only on a per-user capacity provider** (dedicated EC2 instances, never shared with another owner) | Per-user IAM all the way down. EFS file system policy allows each access point only to its owner's task role | | **Standard** (opt-in, later) | Large teams that want fewer IAM roles | Shared, minimal (logs, SSM messages) | **Fargate only**, or EC2 with **no** privileged mode, Docker socket or `NET_ADMIN` | Containers are unprivileged and can't mount NFS, can't read secrets, and can't reach each other over the network | There is no separate "personal" mode. A solo user on strict tenancy is simply the only onboarded @@ -422,7 +472,7 @@ fails because of a quota, the app says so and doesn't retry. | Start time | About 30–90 s (image pull every time; SOCI lazy-loading **to evaluate**) | Fast when the image is cached on a warm instance | | Docker access / privileged / VPN | No | Yes, on dedicated capacity | | GPU | No | Yes | -| Ops | None | ASG + capacity provider, defined in the environment stack. The app only picks a capacity provider | +| Ops | None | ASG + capacity provider, defined in the environment module. The app only picks a capacity provider | | Cost when idle | 0 (task stopped) | Instances keep running unless the ASG scales in | | Max size | 16 vCPU / 120 GB | Instance type | @@ -560,19 +610,23 @@ Existing `projects.json` files deserialize unchanged (`host_id` missing → Loca ``` aws/ - triple-c-environment.yaml # CloudFormation: cluster, capacity providers, SG, EFS + policy, - # KMS key, ECR repo / pull-through rule, log group, exec+task roles, - # SSM environment parameter. Params: VPC/subnets (or create), - # tenancy mode, limits, Bedrock-on-task-role. - triple-c-user-onboard.yaml # strict tenancy: per-user task role + capacity provider + README.md # the policies, the modules, running a bundle by hand, live results + terraform/ # OpenTofu modules (hashicorp/aws pinned, .terraform.lock.hcl) + permissions-boundary/ # the account's triple-c-permissions-boundary policy + environment/ # cluster, tasks SG (no ingress), KMS key + alias, log group, + # network (public | private-nat | existing), SSM record (last) + user-onboard/ # per user: task + exec roles (bounded), own EFS file system, + # its SG, two mount targets, file-system policy (D2) policies/ - environment-admin.json workspace-user.json - permissions-boundary.json # every triple-c-* role carries it (phase 3) - task-execution-role.json - task-role-standard.json + environment-admin.json # the admin tier, part 1 (D7) + environment-admin-network.json # the admin tier, part 2: the network + permissions-boundary.json # every triple-c-* role carries it ``` +State lives in `triple-c-tfstate--` (versioned, SSE-S3, public access blocked, +TLS only, tagged `triple-c:component=state`), one object per deployment (D9). + ## Phases 1. **Host abstraction refactor.** `ProjectHost` trait, `DockerHost`, every call site moved. No @@ -582,13 +636,15 @@ aws/ 3. **AWS connection + permission check.** Settings UI, SDK wiring, `GetCallerIdentity`, the tiered simulation and fallback probes, policy files. **Done 2026-11-14**; the policies were verified against real IAM (see "Phase 3: policy verification and rulings"). -4. **Environment + onboarding stacks.** CloudFormation templates for both, detection (SSM - parameters + tagged stacks, including half-deployed and outdated ones), join or deploy, - self-onboarding or an admin request, **strict tenancy**. +4. **Environment + onboarding modules.** OpenTofu modules for both and the boundary, run by the + app (detached) or by an admin from a bundle; detection (SSM records + state objects and locks, + including incomplete, in-progress and outdated ones), join or deploy, self-onboarding or an + admin request, **strict tenancy**. **Done 2026-11-14**; deployed, joined, updated and destroyed + for real as a role holding only the admin policies (see "Phase 4: live verification"). 5. **Fargate workspaces.** Ephemeral and Hybrid storage, Secrets Manager delivery, git bootstrap, start/stop/status, stop guard with WIP push on by default, idle auto-stop, logs. 6. **EFS mode, Copy-at-connect, setup scripts, cost estimate.** -7. **ECS on EC2.** Per-user capacity providers in the onboarding stack, privileged/Docker/VPN on +7. **ECS on EC2.** Per-user capacity providers in the onboarding module, privileged/Docker/VPN on dedicated capacity. 8. **Standard tenancy** (opt-in), only if demand appears. @@ -664,6 +720,29 @@ Left for the phases with real ECS/EFS/EC2 resources: `RunTask`'s `aws:TagKeys` ( `aws:ecs:*` or propagated tags), `iam:PassRole` with `iam:ResourceTag` on a real run, the owner tag on a real task for stop/exec/session, session ownership for an SSO-started session, EFS `CreateAction`/`CreateAccessPoint`/`ClientMount`, and `security-group-rule` authorization. +(Phase 4 has since created security groups and rules for real; see below.) + +### Phase 4: live verification (2026-11-14) + +`app/src-tauri/src/aws/live_tests.rs` (`environment`) applies the modules for real, in us-west-2, +as a role holding **only** the admin files: state bucket, a `public` environment through the real +detached runner, detection, self-onboarding, an update ("No changes"), destroy; and a +`private-nat` environment. What reality changed (details in `aws/README.md`): + +- `s3:CreateBucket` with tags also needs `s3:TagResource`. Its `aws:RequestTag` and + `s3:ResourceAccount` conditions are evaluated and pass. +- `ec2:CreateSecurityGroup` is authorized against the VPC without the request's tags, so the VPC + side of the grant is unconditioned (the group still needs the tag). +- Destroying the NAT gateway's address disassociates it after the association is gone; EC2 + authorizes that against the literal `*/*`, which is all the policy grants (`${*}/${*}`). +- `tofu destroy` leaves an empty state object; detection treats it as no deployment (otherwise a + destroyed environment read as Incomplete for ever). +- Confirmed as designed: EFS sets `aws:CalledViaLast` for mount-target interfaces; the EFS + file-system policy accepted the just-created task role; conditional `DeleteObject` (`If-Match`) + on the versioned bucket returns 412 on a stale ETag. + +Still to verify: the `existing` network mode against a user's own VPC, and Fargate tasks in the +deployed environment (phase 5). ## Connection model @@ -684,7 +763,7 @@ The options: | Several regions or environments, same login | Duplicate connections with the same credentials. Keys are stored twice; SSO shares a token cache, so it's only clutter | One connection | One connection | | Permission check | Once per connection, cached | Per environment, run lazily the first time a project uses it | Per environment, run lazily | | Detection cost | One region | Every region the user might use (`DescribeRegions` + a scan, slow) or a hand-kept list | Default region straight away; other regions only when the user picks one | -| Risk of deploying to the wrong place | Low: the place is in the connection's name | Higher: an easy region-picker mistake leaves a stack in a region nobody looks at | Low: default region preselected, others must be chosen deliberately | +| Risk of deploying to the wrong place | Low: the place is in the connection's name | Higher: an easy region-picker mistake leaves an environment in a region nobody looks at | Low: default region preselected, others must be chosen deliberately | | Moving a project to another region or environment | Re-point the project to another connection | Change a field | Change a field | | Settings export/import | Each connection is self-contained | Projects reference environments that may not exist on the importing machine | Same as B | | Data model / UI | Smallest | Largest (environment picker and per-environment status everywhere) | Medium | -- 2.52.0 From 0b3b100a9121bffd3f4f56eafff98e46d0b0c035 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 23:38:21 -0800 Subject: [PATCH 22/24] fix(aws): review round for the live environment tests and detection - Live tests never delete a boundary or bucket they did not create: the boundary goes only through its module's destroy (no direct-delete fallback), ownership is taken only on NoSuchEntity, and the bucket is deleted only if this run created it. - Detection reads at most 200 state objects of up to 2 KiB to find empty ones, and a failed read keeps the entry instead of failing the scan. - Docs: DisassociateAddress on a live association is expected-denied, not verified (probe listed); the e2e admin role carries no boundary, and why; the spec's tenancy text follows D2. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/environments.rs | 40 +++++++++-- app/src-tauri/src/aws/environments/tests.rs | 23 +++++++ app/src-tauri/src/aws/live_tests.rs | 68 ++++++++++++------- app/src-tauri/src/aws/policies.rs | 9 ++- aws/README.md | 27 ++++++-- .../specs/2026-11-13-aws-ecs-host-design.md | 7 +- 6 files changed, 132 insertions(+), 42 deletions(-) diff --git a/app/src-tauri/src/aws/environments.rs b/app/src-tauri/src/aws/environments.rs index cd589f2..430b536 100644 --- a/app/src-tauri/src/aws/environments.rs +++ b/app/src-tauri/src/aws/environments.rs @@ -48,6 +48,15 @@ const RECORD_SCHEMA: u64 = 1; const MAX_RECORDS: usize = 500; const MAX_OBJECTS_PER_PREFIX: usize = 5000; const MAX_LOCKS: usize = 200; +/// A state object larger than this is not read to see whether it is empty. +/// What `tofu destroy` leaves is about 180 bytes plus about 110 per +/// checkable object (OpenTofu 1.13 keeps `check_results` for each resource +/// or data source with a pre/postcondition): 453 bytes for two, measured. +/// The modules have at most a handful, so 2 KiB leaves a wide margin. +const EMPTY_STATE_MAX_BYTES: i64 = 2048; +/// At most this many small state objects are read per scan; the rest count +/// as deployments. +const MAX_STATE_PEEKS: usize = 200; /// An environment's record, schema 1 (`aws/terraform/environment/main.tf`). #[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] @@ -583,6 +592,27 @@ pub(crate) fn is_empty_state(text: &str) -> bool { .unwrap_or(false) } +/// Whether a state object of `size` bytes is read to see if it is empty: +/// only a small one, and only the first [`MAX_STATE_PEEKS`] per scan. +fn may_be_empty_state(size: i64, peeks_so_far: usize) -> bool { + (0..=EMPTY_STATE_MAX_BYTES).contains(&size) && peeks_so_far < MAX_STATE_PEEKS +} + +/// Whether a read state object still counts as a deployment. Fails closed: +/// only a state read in full and found empty is dropped (or one deleted +/// since the listing); unreadable, denied or failed reads keep it, and one +/// failed read never fails the whole scan. +fn keep_state(key: &str, read: Result, AwsError>) -> bool { + match read { + Ok(None) => false, + Ok(Some(text)) => !is_empty_state(&text), + Err(e) => { + log::warn!("Could not read the state object {key} ({e}); counting it as a deployment"); + true + } + } +} + /// What a failed lookup means. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Lookup { @@ -805,6 +835,7 @@ impl DetectApi for SdkDetectApi { async fn state_objects(&self, bucket: &str) -> Result, AwsError> { let mut objects = StateObjects::default(); let mut lock_keys = Vec::new(); + let mut peeks = 0usize; for prefix in ["environments/", "users/", "boundary/"] { let Some(listed) = self.list(bucket, prefix).await? else { return Ok(None); @@ -817,11 +848,10 @@ impl DetectApi for SdkDetectApi { // own Destroy) would read as Incomplete for ever, and a // removed onboarding as Joined. Only a small object can // be empty, so only those are read. - if (0..=MAX_UNTRUSTED_BYTES as i64).contains(&size) { - match self.lock_contents(bucket, &key, size).await? { - None => continue, - Some(text) if is_empty_state(&text) => continue, - Some(_) => {} + if may_be_empty_state(size, peeks) { + peeks += 1; + if !keep_state(&key, self.lock_contents(bucket, &key, size).await) { + continue; } } objects.states.push(key); diff --git a/app/src-tauri/src/aws/environments/tests.rs b/app/src-tauri/src/aws/environments/tests.rs index 90c2751..b485bf9 100644 --- a/app/src-tauri/src/aws/environments/tests.rs +++ b/app/src-tauri/src/aws/environments/tests.rs @@ -839,3 +839,26 @@ fn only_a_state_that_manages_nothing_is_empty() { assert!(!is_empty_state(odd), "{odd:?}"); } } + +#[test] +fn only_small_states_are_read_and_only_up_to_the_cap() { + assert!(may_be_empty_state(0, 0)); + assert!(may_be_empty_state(453, 0)); + assert!(may_be_empty_state(EMPTY_STATE_MAX_BYTES, MAX_STATE_PEEKS - 1)); + assert!(!may_be_empty_state(EMPTY_STATE_MAX_BYTES + 1, 0)); + assert!(!may_be_empty_state(-1, 0)); + assert!(!may_be_empty_state(100, MAX_STATE_PEEKS)); +} + +#[test] +fn a_state_that_cannot_be_read_still_counts() { + let empty = r#"{"version":4,"outputs":{},"resources":[],"check_results":[{"object_kind":"resource","config_addr":"terraform_data.b","status":"unknown","objects":null}]}"#; + assert!(!keep_state("k", Ok(Some(empty.into())))); + // Deleted since the listing. + assert!(!keep_state("k", Ok(None))); + // Denied reads arrive as Some("") from lock_contents. + assert!(keep_state("k", Ok(Some(String::new())))); + assert!(keep_state("k", Ok(Some(r#"{"resources":[{"type":"x"}]}"#.into())))); + // An S3 error on one object degrades that entry, not the scan. + assert!(keep_state("k", Err(AwsError::Other { message: "boom".into() }))); +} diff --git a/app/src-tauri/src/aws/live_tests.rs b/app/src-tauri/src/aws/live_tests.rs index 77be47b..803aea8 100644 --- a/app/src-tauri/src/aws/live_tests.rs +++ b/app/src-tauri/src/aws/live_tests.rs @@ -1237,8 +1237,29 @@ mod environment { .expect("child env") } + /// `ensure_state_bucket` as the environment admin. The bucket is + /// marked for deletion only if this call is what creates it: a bucket + /// that already exists (a fixed `TRIPLEC_E2E_BUCKET_SUFFIX`) is never + /// deleted by the cleanup. async fn ensure_bucket(&self, cfg: &SdkConfig, who: &CallerIdentity) { - *self.bucket_created.lock().unwrap() = true; + use aws_sdk_s3::error::ProvideErrorMetadata; + let head = aws_sdk_s3::Client::new(&self.e.cfg) + .head_bucket() + .bucket(&self.bucket) + .send() + .await; + match head { + Ok(_) => eprintln!("{} already exists; the cleanup leaves it", self.bucket), + Err(e) + if e.raw_response().map(|r| r.status().as_u16()) == Some(404) + || e.code() == Some("NotFound") => + { + // Set before the create, so a create that half-succeeds + // is still cleaned up. + *self.bucket_created.lock().unwrap() = true; + } + Err(e) => panic!("HeadBucket {}: {e:?}", self.bucket), + } let b = ensure_named_state_bucket(cfg, &who.account, &self.e.region, &self.bucket) .await .unwrap_or_else(|e| panic!("ensure_state_bucket as the environment admin: {e}")); @@ -1252,10 +1273,19 @@ mod environment { "arn:aws:iam::{}:policy/{PERMISSIONS_BOUNDARY_NAME}", self.e.account ); - if self.e.iam.get_policy().policy_arn(&arn).send().await.is_ok() { - eprintln!("the account already has the boundary; using it"); - return; + match self.e.iam.get_policy().policy_arn(&arn).send().await { + Ok(_) => { + eprintln!("the account already has the boundary; using it"); + return; + } + Err(e) if e.as_service_error().is_some_and(|s| s.is_no_such_entity_exception()) => {} + // Anything else is not "absent": never take ownership of a + // boundary this run can't see. + Err(e) => panic!("iam:GetPolicy on the boundary: {e:?}"), } + // Ownership is the module's state: the cleanup removes the + // boundary only with `tofu destroy` of that state, so a failed + // apply (EntityAlreadyExists in a race) destroys nothing. *self.boundary_created.lock().unwrap() = true; let (_, outcome, denied) = self .run( @@ -1270,9 +1300,9 @@ mod environment { } /// As the account admin: destroy whatever is still applied (newest - /// first), the bucket with every version, and the boundary if this - /// test made it. Logs rather than fails, so one error strands nothing - /// else. + /// first; the boundary only through its own module's state), then the + /// bucket with every version if this run created it. Logs rather than + /// fails, so one error strands nothing else. async fn cleanup(&self) { let pending: Vec = self .applied @@ -1304,29 +1334,19 @@ mod environment { other => eprintln!("cleanup: could not start destroy: {other:?}"), } } + // No direct delete: the boundary module's `tofu destroy` above is + // the only thing that removes it, because its state proves this + // run created it. If that failed, say so loudly. if *self.boundary_created.lock().unwrap() { let arn = format!( "arn:aws:iam::{}:policy/{PERMISSIONS_BOUNDARY_NAME}", self.e.account ); if self.e.iam.get_policy().policy_arn(&arn).send().await.is_ok() { - eprintln!("cleanup: deleting the boundary directly"); - if let Ok(out) = self.e.iam.list_entities_for_policy().policy_arn(&arn).send().await { - for r in out.policy_roles() { - if let Some(name) = r.role_name() { - let _ = self - .e - .iam - .delete_role_permissions_boundary() - .role_name(name) - .send() - .await; - } - } - } - if let Err(e) = self.e.iam.delete_policy().policy_arn(&arn).send().await { - eprintln!("cleanup: boundary: {e:?}"); - } + eprintln!( + "cleanup: WARNING: {arn} still exists after its module's destroy; \ + remove it by hand if this run created it" + ); } } if *self.bucket_created.lock().unwrap() { diff --git a/app/src-tauri/src/aws/policies.rs b/app/src-tauri/src/aws/policies.rs index f943b43..c1bfe33 100644 --- a/app/src-tauri/src/aws/policies.rs +++ b/app/src-tauri/src/aws/policies.rs @@ -540,7 +540,8 @@ const ENVIRONMENT_ADMIN_CHECKS: &[Check] = &[ write("ec2:ReleaseAddress", PROBE_ELASTIC_IP, COMPONENT_TAGGED), // Destroying the NAT gateway's address disassociates it first; the NAT // gateway is gone by then, so EC2 authorizes the call against the - // literal `*/*` (verified live), which is all the policy allows. + // literal `*/*` (verified live), which is all the policy allows. That a + // live association never matches it is expected, not verified. write("ec2:DisassociateAddress", PROBE_GONE_ASSOCIATION, &[]), write("ec2:DeleteNatGateway", PROBE_NAT_GATEWAY, COMPONENT_TAGGED), write( @@ -1849,8 +1850,10 @@ mod tests { ME, ME )); - // Only the literal `*/*` of an association that is already gone: a - // live address, Triple-C's or anyone's, is never disassociated. + // Only the literal `*/*` of an association that is already gone. That + // a live association is authorized against its address and interface + // ARNs (so never matches) is expected, NOT live-verified: see "Still + // open" in aws/README.md for the probe. assert!(allowed( tier, &write("ec2:DisassociateAddress", PROBE_GONE_ASSOCIATION, &[]), diff --git a/aws/README.md b/aws/README.md index 84af0b6..7f3774c 100644 --- a/aws/README.md +++ b/aws/README.md @@ -203,8 +203,10 @@ account-wide: nothing else (`${*}` is IAM's escaped `*`). Destroying the NAT gateway's address disassociates it first; by then the NAT gateway and its association are gone and EC2 authorizes the call against `*/*` (verified live: denied without this, then `InvalidAssociationID.NotFound`, which - the provider ignores). A live association names its address and interface, so no real address - can be disassociated through this statement. + the provider ignores). **Expected, not live-verified:** a live association should be + authorized against its address and interface ARNs, which `${*}/${*}` does not match, so no real + address should be disassociable through this statement. Only the gone-association case was + observed; the probe that would settle it is listed under "Still open" below. - `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 EFS makes @@ -436,8 +438,9 @@ record, joinable), self-onboard (Joined, both roles carry the boundary), update destroy the onboarding, then the environment. If the account has no boundary, the account admin applies the boundary module first and destroys it at the end. `the_private_nat_mode_deploys` does the same for `private-nat` (about $0.05: one NAT gateway for a few minutes). A guard destroys -whatever is still applied, as the account admin, and deletes the bucket with every version, on -failure too; every denied action is printed by name. The test drives the same functions as the +whatever is still applied, as the account admin (the boundary only through its module's state, so +a boundary the run did not create is never touched), and deletes the bucket with every version if +the run created it, on failure too; every denied action is printed by name. The test drives the same functions as the commands (`ensure_tofu`, `materialize`, `child_env`, the runner, `follow`, `scan`, the refusal checks, the conditional lock delete); the Tauri commands' own bookkeeping (launch slots, the progress events) is not exercised. @@ -452,8 +455,18 @@ progress events) is not exercised. | NAT address on destroy | ✗→fixed: the provider disassociates the address before releasing it, after the NAT gateway (and the association) are gone, and EC2 authorizes that call against the literal `*/*`. `DisassociateGoneAddresses` allows exactly `arn:aws:ec2:*:*:${*}/${*}`; CloudTrail shows the retried call reaching `InvalidAssociationID.NotFound` | | Conditional unlock | ✓ on the versioned state bucket, `DeleteObject` with `If-Match` and a mismatched ETag gets 412 (the app's "lock changed" answer) and leaves the lock; with the current ETag it deletes it. A fresh foreign lock scans as Deploying with its `Who` | | Update | ✓ re-applying with the network read back from the record: "0 added, 0 changed, 0 destroyed" | -| After `tofu destroy` | ✗→fixed (app): the state object stays, empty, so a destroyed environment kept scanning as Incomplete (and a removed onboarding as Joined). Detection now reads a small state object and treats one with no resources as absent | +| After `tofu destroy` | ✗→fixed (app): the state object stays, empty, so a destroyed environment kept scanning as Incomplete (and a removed onboarding as Joined). Detection now reads small state objects (up to 2 KiB, at most 200 per scan) and treats one with no resources as absent; a read that fails counts as a deployment | | Cleanup | ✓ nothing `triple-c` left in us-west-2 (roles, policies, log groups, clusters, VPCs, subnets, gateways, NAT gateways, addresses, security groups, file systems, parameters, aliases, buckets); five environment KMS keys pending deletion (7 days); the boundary, which the account did not have, deleted again | -Not deployed live: the `existing` network mode, and an onboarding by a workspace user from an -admin bundle (the same module, applied with admin credentials). +The environment-admin role in these tests carries **no permissions boundary**, deliberately: the +boundary is the ceiling for the task and execution roles the modules create, not for admins (it +allows only what a task does, so an admin under it could administer nothing). The test instead +asserts that every role the admin creates carries the boundary. + +Still open: + +- 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 + Elastic IP, associate it with a test ENI, and call `ec2:DisassociateAddress` on that association + as the environment admin; it must be denied (then release both). diff --git a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md index 5215ae9..1c77bbc 100644 --- a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md +++ b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md @@ -333,9 +333,10 @@ updates use the existing "new image available" flow: the next start uses the new | **Hybrid** (default) | task storage | EFS | Settings, sessions, memory, history: yes. Working tree: only what's pushed | Fast builds, fast git | Small EFS footprint | | **EFS** | EFS | EFS | Everything | git, `node_modules` and builds are noticeably slower over NFS | Grows with repo and dependency size | -- **One EFS file system per environment, one access point per workspace.** Access point root: +- **One EFS file system per user (D2), one access point per workspace.** The file system comes + from the user's onboarding module and its policy admits only their task role. Access point root: `/triple-c//`, with POSIX user and group 1000. The access point is created by - the user and tagged with the owner (see policies). + the user, on their own file system, and tagged with the owner (see policies). - **WIP push is on by default** (`wip_push_on_stop: true`, with a serde default per the `default_full_permissions` pattern) and can be turned off per project. - **Ephemeral and Hybrid have a stop guard.** Before stopping, the app asks the agent for @@ -440,7 +441,7 @@ before release.** | Mode | For | Task role | EC2 options | Isolation | |---|---|---|---|---| -| **Strict** (**default, v1**) | Everyone, from a solo account to a team | **Per-user** task role, created by the user's onboarding module (self-deployed, or by an admin) | Privileged and Docker allowed **only on a per-user capacity provider** (dedicated EC2 instances, never shared with another owner) | Per-user IAM all the way down. EFS file system policy allows each access point only to its owner's task role | +| **Strict** (**default, v1**) | Everyone, from a solo account to a team | **Per-user** task role, created by the user's onboarding module (self-deployed, or by an admin) | Privileged and Docker allowed **only on a per-user capacity provider** (dedicated EC2 instances, never shared with another owner) | Per-user IAM all the way down. Each user has their own EFS file system (D2), whose policy admits only that user's task role | | **Standard** (opt-in, later) | Large teams that want fewer IAM roles | Shared, minimal (logs, SSM messages) | **Fargate only**, or EC2 with **no** privileged mode, Docker socket or `NET_ADMIN` | Containers are unprivileged and can't mount NFS, can't read secrets, and can't reach each other over the network | There is no separate "personal" mode. A solo user on strict tenancy is simply the only onboarded -- 2.52.0 From 6911b9de27276f531bdf6c7dc1a04f0989d6ff61 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 00:01:32 -0800 Subject: [PATCH 23/24] fix(aws): final review round for the environment stacks Critical - Owner key is sha256(":") (12 hex): IAM role names are account-global and environments regional, so the same user and env name in two regions no longer collide. Every caller, the onboarding module's owner_key validation, docs and a two-region test. Important - A failed onboarding is Incomplete, not Joined: Joined needs the task role and, where the bucket is readable, the state's new onboarding_complete output (depends_on every resource); a failed or errored local run or a stale lock is Incomplete. Join stays as the retry; Follow, Unlock (own key only, stale, conditional delete) and Push saved state work on the caller's own onboarding. - errored.tfstate: the runner logs an Error line naming it, follow's summary says so, materialize refuses while it exists, and the new push_aws_saved_state command runs `tofu state push errored.tfstate` through the same runner (file set aside once pushed); UI on the row. - Bundle README: the bucket is created with the component tag in --create-bucket-configuration (the policy's RequestTag condition), plus the TLS-only bucket policy; the boundary bundle names an account admin WITHOUT the environment-admin files. - Destroy-incomplete refuses while any users// state remains. - existing network: data.aws_subnet lookups and a precondition (same VPC, different AZs) on the task security group. Minor - The TLS statement is added to an existing bucket policy, never put over it. - Deploy progress is emitted to the main window only. - materialize checks and writes under start.lock. - UI: managed-elsewhere label and explanation, Incomplete explained, Repair/Destroy disabled under a held lock ("Unlock first"). - Cargo.toml libc comment; README: Cancel unavailable on Windows, session. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 +- app/src-tauri/Cargo.toml | 13 +- app/src-tauri/capabilities/default.json | 1 + app/src-tauri/gen/schemas/acl-manifests.json | 2 +- app/src-tauri/gen/schemas/capabilities.json | 2 +- app/src-tauri/gen/schemas/desktop-schema.json | 12 + app/src-tauri/gen/schemas/linux-schema.json | 12 + app/src-tauri/src/aws/environments.rs | 268 +++++++++++++++--- app/src-tauri/src/aws/environments/tests.rs | 253 +++++++++++++++-- app/src-tauri/src/aws/live_tests.rs | 10 +- app/src-tauri/src/aws/tofu.rs | 99 ++++++- app/src-tauri/src/aws/tofu/tests.rs | 124 ++++++++ app/src-tauri/src/aws/tofu_modules.rs | 13 +- app/src-tauri/src/aws/tofu_modules/tests.rs | 94 +++++- .../src/commands/environment_commands.rs | 193 ++++++++++--- .../commands/environment_commands/tests.rs | 80 +++++- app/src-tauri/src/lib.rs | 1 + .../settings/hosts/EnvironmentsPanel.test.tsx | 127 +++++++++ .../settings/hosts/EnvironmentsPanel.tsx | 160 +++++++++-- .../settings/hosts/environmentFormat.ts | 4 +- app/src/components/settings/hosts/testkit.ts | 2 + app/src/lib/tauri-commands.ts | 3 + app/src/lib/types.ts | 12 +- aws/README.md | 45 ++- aws/terraform/environment/main.tf | 11 + aws/terraform/environment/network.tf | 9 + aws/terraform/user-onboard/outputs.tf | 19 ++ aws/terraform/user-onboard/variables.tf | 6 +- .../specs/2026-11-13-aws-ecs-host-design.md | 19 +- 29 files changed, 1436 insertions(+), 161 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c0ee221..f1d2409 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -332,7 +332,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li binary), in a throwaway state bucket. **Environments are OpenTofu modules** (`aws/terraform/{environment,user-onboard,permissions-boundary}`; spec D2–D10). `tofu_modules.rs` embeds them and is the **only** place the shared names are built - (environment, `/triple-c/environments/` record, owner key, per-user roles, state bucket + (environment, `/triple-c/environments/` record, owner key — `sha256(":")`, + 12 hex, regional because role names are account-global — per-user roles, state bucket `triple-c-tfstate--`, state keys); its tests pin the HCL to those names, the versions and tags, and `MODULE_CALLS` (every provider call) to the admin policy. `tofu.rs` downloads the pinned OpenTofu (SHA-256 checked **before** extraction), writes a work diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 9457a6b..d6c7c0f 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -78,15 +78,16 @@ aws-runtime = { version = "1.10.4", default-features = false } aws-smithy-async = { version = "1.3.1", default-features = false, features = ["rt-tokio"] } aws-sdk-s3 = { version = "1.159.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } -# Linux only: WebKitGTK refuses getUserMedia until the embedder enables media -# streams and answers the permission request (see src/linux_media.rs). Same -# version tauri/wry already resolve, so this adds no second copy. -# aws/tofu.rs: `setsid` for the detached OpenTofu runner, SIGINT to its -# process group on Cancel, and a liveness probe for its PID. Already in the -# tree through tokio and friends. +# aws/tofu.rs: `setsid` puts the detached OpenTofu runner in its own session, +# and Cancel sends SIGINT to the runner process alone (never a group), which +# forwards it to `tofu`. Liveness is the runner's `apply.lock`, not a PID. +# Already in the tree through tokio and friends. [target.'cfg(unix)'.dependencies] libc = "0.2" +# Linux only: WebKitGTK refuses getUserMedia until the embedder enables media +# streams and answers the permission request (see src/linux_media.rs). Same +# version tauri/wry already resolve, so this adds no second copy. [target.'cfg(target_os = "linux")'.dependencies] webkit2gtk = "2.0.2" diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 97f14b5..5005837 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -73,6 +73,7 @@ "allow-unlock-aws-state", "allow-follow-aws-deployment", "allow-cancel-aws-deployment", + "allow-push-aws-saved-state", "allow-onboard-aws-user", "allow-deploy-aws-permissions-boundary", "allow-save-aws-admin-bundle", diff --git a/app/src-tauri/gen/schemas/acl-manifests.json b/app/src-tauri/gen/schemas/acl-manifests.json index ebb41ab..8ca189d 100644 --- a/app/src-tauri/gen/schemas/acl-manifests.json +++ b/app/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-aws-deployment":{"identifier":"allow-cancel-aws-deployment","description":"Enables the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":["cancel_aws_deployment"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["clear_scheduler_notifications"],"deny":[]}},"allow-close-browser-view-popout":{"identifier":"allow-close-browser-view-popout","description":"Enables the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":["close_browser_view_popout"],"deny":[]}},"allow-close-container-page":{"identifier":"allow-close-container-page","description":"Enables the close_container_page command without any pre-configured scope.","commands":{"allow":["close_container_page"],"deny":[]}},"allow-close-terminal-session":{"identifier":"allow-close-terminal-session","description":"Enables the close_terminal_session command without any pre-configured scope.","commands":{"allow":["close_terminal_session"],"deny":[]}},"allow-confirm-migration":{"identifier":"allow-confirm-migration","description":"Enables the confirm_migration command without any pre-configured scope.","commands":{"allow":["confirm_migration"],"deny":[]}},"allow-create-container-directory":{"identifier":"allow-create-container-directory","description":"Enables the create_container_directory command without any pre-configured scope.","commands":{"allow":["create_container_directory"],"deny":[]}},"allow-delete-note":{"identifier":"allow-delete-note","description":"Enables the delete_note command without any pre-configured scope.","commands":{"allow":["delete_note"],"deny":[]}},"allow-deploy-aws-environment":{"identifier":"allow-deploy-aws-environment","description":"Enables the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":["deploy_aws_environment"],"deny":[]}},"allow-deploy-aws-permissions-boundary":{"identifier":"allow-deploy-aws-permissions-boundary","description":"Enables the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":["deploy_aws_permissions_boundary"],"deny":[]}},"allow-destroy-incomplete-aws-environment":{"identifier":"allow-destroy-incomplete-aws-environment","description":"Enables the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":["destroy_incomplete_aws_environment"],"deny":[]}},"allow-detect-aws-config":{"identifier":"allow-detect-aws-config","description":"Enables the detect_aws_config command without any pre-configured scope.","commands":{"allow":["detect_aws_config"],"deny":[]}},"allow-detect-aws-environments":{"identifier":"allow-detect-aws-environments","description":"Enables the detect_aws_environments command without any pre-configured scope.","commands":{"allow":["detect_aws_environments"],"deny":[]}},"allow-detect-host-timezone":{"identifier":"allow-detect-host-timezone","description":"Enables the detect_host_timezone command without any pre-configured scope.","commands":{"allow":["detect_host_timezone"],"deny":[]}},"allow-detect-install-options":{"identifier":"allow-detect-install-options","description":"Enables the detect_install_options command without any pre-configured scope.","commands":{"allow":["detect_install_options"],"deny":[]}},"allow-download-container-backup":{"identifier":"allow-download-container-backup","description":"Enables the download_container_backup command without any pre-configured scope.","commands":{"allow":["download_container_backup"],"deny":[]}},"allow-download-container-file":{"identifier":"allow-download-container-file","description":"Enables the download_container_file command without any pre-configured scope.","commands":{"allow":["download_container_file"],"deny":[]}},"allow-export-settings":{"identifier":"allow-export-settings","description":"Enables the export_settings command without any pre-configured scope.","commands":{"allow":["export_settings"],"deny":[]}},"allow-follow-aws-deployment":{"identifier":"allow-follow-aws-deployment","description":"Enables the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":["follow_aws_deployment"],"deny":[]}},"allow-forget-marketplace-installs":{"identifier":"allow-forget-marketplace-installs","description":"Enables the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":["forget_marketplace_installs"],"deny":[]}},"allow-get-app-version":{"identifier":"allow-get-app-version","description":"Enables the get_app_version command without any pre-configured scope.","commands":{"allow":["get_app_version"],"deny":[]}},"allow-get-auth-bridge-status":{"identifier":"allow-get-auth-bridge-status","description":"Enables the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":["get_auth_bridge_status"],"deny":[]}},"allow-get-browser-view-match-window":{"identifier":"allow-get-browser-view-match-window","description":"Enables the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["get_browser_view_match_window"],"deny":[]}},"allow-get-browser-view-popout-state":{"identifier":"allow-get-browser-view-popout-state","description":"Enables the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":["get_browser_view_popout_state"],"deny":[]}},"allow-get-browser-view-status":{"identifier":"allow-get-browser-view-status","description":"Enables the get_browser_view_status command without any pre-configured scope.","commands":{"allow":["get_browser_view_status"],"deny":[]}},"allow-get-container-info":{"identifier":"allow-get-container-info","description":"Enables the get_container_info command without any pre-configured scope.","commands":{"allow":["get_container_info"],"deny":[]}},"allow-get-container-page-state":{"identifier":"allow-get-container-page-state","description":"Enables the get_container_page_state command without any pre-configured scope.","commands":{"allow":["get_container_page_state"],"deny":[]}},"allow-get-container-staleness":{"identifier":"allow-get-container-staleness","description":"Enables the get_container_staleness command without any pre-configured scope.","commands":{"allow":["get_container_staleness"],"deny":[]}},"allow-get-gateway-auth-token":{"identifier":"allow-get-gateway-auth-token","description":"Enables the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["get_gateway_auth_token"],"deny":[]}},"allow-get-gateway-status":{"identifier":"allow-get-gateway-status","description":"Enables the get_gateway_status command without any pre-configured scope.","commands":{"allow":["get_gateway_status"],"deny":[]}},"allow-get-help-content":{"identifier":"allow-get-help-content","description":"Enables the get_help_content command without any pre-configured scope.","commands":{"allow":["get_help_content"],"deny":[]}},"allow-get-marketplace-sync-report":{"identifier":"allow-get-marketplace-sync-report","description":"Enables the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":["get_marketplace_sync_report"],"deny":[]}},"allow-get-migration-state":{"identifier":"allow-get-migration-state","description":"Enables the get_migration_state command without any pre-configured scope.","commands":{"allow":["get_migration_state"],"deny":[]}},"allow-get-scheduled-task-log":{"identifier":"allow-get-scheduled-task-log","description":"Enables the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":["get_scheduled_task_log"],"deny":[]}},"allow-get-scheduler-notifications":{"identifier":"allow-get-scheduler-notifications","description":"Enables the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["get_scheduler_notifications"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-get-stt-status":{"identifier":"allow-get-stt-status","description":"Enables the get_stt_status command without any pre-configured scope.","commands":{"allow":["get_stt_status"],"deny":[]}},"allow-get-web-terminal-status":{"identifier":"allow-get-web-terminal-status","description":"Enables the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":["get_web_terminal_status"],"deny":[]}},"allow-has-claude-token":{"identifier":"allow-has-claude-token","description":"Enables the has_claude_token command without any pre-configured scope.","commands":{"allow":["has_claude_token"],"deny":[]}},"allow-inspect-ca-cert-path":{"identifier":"allow-inspect-ca-cert-path","description":"Enables the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":["inspect_ca_cert_path"],"deny":[]}},"allow-install-browser-view-browser":{"identifier":"allow-install-browser-view-browser","description":"Enables the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":["install_browser_view_browser"],"deny":[]}},"allow-install-browser-view-support":{"identifier":"allow-install-browser-view-support","description":"Enables the install_browser_view_support command without any pre-configured scope.","commands":{"allow":["install_browser_view_support"],"deny":[]}},"allow-install-marketplace-item":{"identifier":"allow-install-marketplace-item","description":"Enables the install_marketplace_item command without any pre-configured scope.","commands":{"allow":["install_marketplace_item"],"deny":[]}},"allow-list-aws-profiles":{"identifier":"allow-list-aws-profiles","description":"Enables the list_aws_profiles command without any pre-configured scope.","commands":{"allow":["list_aws_profiles"],"deny":[]}},"allow-list-claude-sessions":{"identifier":"allow-list-claude-sessions","description":"Enables the list_claude_sessions command without any pre-configured scope.","commands":{"allow":["list_claude_sessions"],"deny":[]}},"allow-list-container-capabilities":{"identifier":"allow-list-container-capabilities","description":"Enables the list_container_capabilities command without any pre-configured scope.","commands":{"allow":["list_container_capabilities"],"deny":[]}},"allow-list-container-files":{"identifier":"allow-list-container-files","description":"Enables the list_container_files command without any pre-configured scope.","commands":{"allow":["list_container_files"],"deny":[]}},"allow-list-marketplace-snapshots":{"identifier":"allow-list-marketplace-snapshots","description":"Enables the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":["list_marketplace_snapshots"],"deny":[]}},"allow-list-marketplace-updates":{"identifier":"allow-list-marketplace-updates","description":"Enables the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":["list_marketplace_updates"],"deny":[]}},"allow-list-notes":{"identifier":"allow-list-notes","description":"Enables the list_notes command without any pre-configured scope.","commands":{"allow":["list_notes"],"deny":[]}},"allow-list-projects":{"identifier":"allow-list-projects","description":"Enables the list_projects command without any pre-configured scope.","commands":{"allow":["list_projects"],"deny":[]}},"allow-list-scheduled-tasks":{"identifier":"allow-list-scheduled-tasks","description":"Enables the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":["list_scheduled_tasks"],"deny":[]}},"allow-marketplace-gh-host-available":{"identifier":"allow-marketplace-gh-host-available","description":"Enables the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":["marketplace_gh_host_available"],"deny":[]}},"allow-marketplace-item-diff":{"identifier":"allow-marketplace-item-diff","description":"Enables the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":["marketplace_item_diff"],"deny":[]}},"allow-migrate-project-to-base":{"identifier":"allow-migrate-project-to-base","description":"Enables the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":["migrate_project_to_base"],"deny":[]}},"allow-onboard-aws-user":{"identifier":"allow-onboard-aws-user","description":"Enables the onboard_aws_user command without any pre-configured scope.","commands":{"allow":["onboard_aws_user"],"deny":[]}},"allow-open-browser-view-popout":{"identifier":"allow-open-browser-view-popout","description":"Enables the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":["open_browser_view_popout"],"deny":[]}},"allow-open-file-viewer":{"identifier":"allow-open-file-viewer","description":"Enables the open_file_viewer command without any pre-configured scope.","commands":{"allow":["open_file_viewer"],"deny":[]}},"allow-open-page-in-container-browser":{"identifier":"allow-open-page-in-container-browser","description":"Enables the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":["open_page_in_container_browser"],"deny":[]}},"allow-open-terminal-session":{"identifier":"allow-open-terminal-session","description":"Enables the open_terminal_session command without any pre-configured scope.","commands":{"allow":["open_terminal_session"],"deny":[]}},"allow-open-url-external":{"identifier":"allow-open-url-external","description":"Enables the open_url_external command without any pre-configured scope.","commands":{"allow":["open_url_external"],"deny":[]}},"allow-paste-image-to-terminal":{"identifier":"allow-paste-image-to-terminal","description":"Enables the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":["paste_image_to_terminal"],"deny":[]}},"allow-preview-settings-import":{"identifier":"allow-preview-settings-import","description":"Enables the preview_settings_import command without any pre-configured scope.","commands":{"allow":["preview_settings_import"],"deny":[]}},"allow-pull-gateway-image":{"identifier":"allow-pull-gateway-image","description":"Enables the pull_gateway_image command without any pre-configured scope.","commands":{"allow":["pull_gateway_image"],"deny":[]}},"allow-pull-image":{"identifier":"allow-pull-image","description":"Enables the pull_image command without any pre-configured scope.","commands":{"allow":["pull_image"],"deny":[]}},"allow-pull-stt-image":{"identifier":"allow-pull-stt-image","description":"Enables the pull_stt_image command without any pre-configured scope.","commands":{"allow":["pull_stt_image"],"deny":[]}},"allow-read-container-file":{"identifier":"allow-read-container-file","description":"Enables the read_container_file command without any pre-configured scope.","commands":{"allow":["read_container_file"],"deny":[]}},"allow-rebuild-project-container":{"identifier":"allow-rebuild-project-container","description":"Enables the rebuild_project_container command without any pre-configured scope.","commands":{"allow":["rebuild_project_container"],"deny":[]}},"allow-reconcile-project-statuses":{"identifier":"allow-reconcile-project-statuses","description":"Enables the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":["reconcile_project_statuses"],"deny":[]}},"allow-refresh-marketplaces":{"identifier":"allow-refresh-marketplaces","description":"Enables the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":["refresh_marketplaces"],"deny":[]}},"allow-regenerate-gateway-auth-token":{"identifier":"allow-regenerate-gateway-auth-token","description":"Enables the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["regenerate_gateway_auth_token"],"deny":[]}},"allow-regenerate-web-terminal-token":{"identifier":"allow-regenerate-web-terminal-token","description":"Enables the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":["regenerate_web_terminal_token"],"deny":[]}},"allow-remove-host-connection":{"identifier":"allow-remove-host-connection","description":"Enables the remove_host_connection command without any pre-configured scope.","commands":{"allow":["remove_host_connection"],"deny":[]}},"allow-remove-marketplace":{"identifier":"allow-remove-marketplace","description":"Enables the remove_marketplace command without any pre-configured scope.","commands":{"allow":["remove_marketplace"],"deny":[]}},"allow-remove-marketplace-account":{"identifier":"allow-remove-marketplace-account","description":"Enables the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":["remove_marketplace_account"],"deny":[]}},"allow-remove-project":{"identifier":"allow-remove-project","description":"Enables the remove_project command without any pre-configured scope.","commands":{"allow":["remove_project"],"deny":[]}},"allow-remove-scheduled-task":{"identifier":"allow-remove-scheduled-task","description":"Enables the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":["remove_scheduled_task"],"deny":[]}},"allow-rename-container-path":{"identifier":"allow-rename-container-path","description":"Enables the rename_container_path command without any pre-configured scope.","commands":{"allow":["rename_container_path"],"deny":[]}},"allow-repair-aws-environment":{"identifier":"allow-repair-aws-environment","description":"Enables the repair_aws_environment command without any pre-configured scope.","commands":{"allow":["repair_aws_environment"],"deny":[]}},"allow-rollback-migration":{"identifier":"allow-rollback-migration","description":"Enables the rollback_migration command without any pre-configured scope.","commands":{"allow":["rollback_migration"],"deny":[]}},"allow-run-docker-install":{"identifier":"allow-run-docker-install","description":"Enables the run_docker_install command without any pre-configured scope.","commands":{"allow":["run_docker_install"],"deny":[]}},"allow-run-scheduled-task-now":{"identifier":"allow-run-scheduled-task-now","description":"Enables the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":["run_scheduled_task_now"],"deny":[]}},"allow-save-aws-admin-bundle":{"identifier":"allow-save-aws-admin-bundle","description":"Enables the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":["save_aws_admin_bundle"],"deny":[]}},"allow-save-note":{"identifier":"allow-save-note","description":"Enables the save_note command without any pre-configured scope.","commands":{"allow":["save_note"],"deny":[]}},"allow-send-audio-data":{"identifier":"allow-send-audio-data","description":"Enables the send_audio_data command without any pre-configured scope.","commands":{"allow":["send_audio_data"],"deny":[]}},"allow-set-auth-bridge-enabled":{"identifier":"allow-set-auth-bridge-enabled","description":"Enables the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":["set_auth_bridge_enabled"],"deny":[]}},"allow-set-browser-view-enabled":{"identifier":"allow-set-browser-view-enabled","description":"Enables the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":["set_browser_view_enabled"],"deny":[]}},"allow-set-browser-view-match-window":{"identifier":"allow-set-browser-view-match-window","description":"Enables the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["set_browser_view_match_window"],"deny":[]}},"allow-set-browser-view-popout-always-on-top":{"identifier":"allow-set-browser-view-popout-always-on-top","description":"Enables the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":["set_browser_view_popout_always_on_top"],"deny":[]}},"allow-set-container-page-viewport":{"identifier":"allow-set-container-page-viewport","description":"Enables the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":["set_container_page_viewport"],"deny":[]}},"allow-set-gateway-api-key":{"identifier":"allow-set-gateway-api-key","description":"Enables the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":["set_gateway_api_key"],"deny":[]}},"allow-set-global-item-disabled":{"identifier":"allow-set-global-item-disabled","description":"Enables the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":["set_global_item_disabled"],"deny":[]}},"allow-set-scheduled-task-enabled":{"identifier":"allow-set-scheduled-task-enabled","description":"Enables the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":["set_scheduled_task_enabled"],"deny":[]}},"allow-start-audio-bridge":{"identifier":"allow-start-audio-bridge","description":"Enables the start_audio_bridge command without any pre-configured scope.","commands":{"allow":["start_audio_bridge"],"deny":[]}},"allow-start-gateway":{"identifier":"allow-start-gateway","description":"Enables the start_gateway command without any pre-configured scope.","commands":{"allow":["start_gateway"],"deny":[]}},"allow-start-marketplace-gh-container-login":{"identifier":"allow-start-marketplace-gh-container-login","description":"Enables the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":["start_marketplace_gh_container_login"],"deny":[]}},"allow-start-project-container":{"identifier":"allow-start-project-container","description":"Enables the start_project_container command without any pre-configured scope.","commands":{"allow":["start_project_container"],"deny":[]}},"allow-start-stt":{"identifier":"allow-start-stt","description":"Enables the start_stt command without any pre-configured scope.","commands":{"allow":["start_stt"],"deny":[]}},"allow-start-web-terminal":{"identifier":"allow-start-web-terminal","description":"Enables the start_web_terminal command without any pre-configured scope.","commands":{"allow":["start_web_terminal"],"deny":[]}},"allow-stop-audio-bridge":{"identifier":"allow-stop-audio-bridge","description":"Enables the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":["stop_audio_bridge"],"deny":[]}},"allow-stop-gateway":{"identifier":"allow-stop-gateway","description":"Enables the stop_gateway command without any pre-configured scope.","commands":{"allow":["stop_gateway"],"deny":[]}},"allow-stop-project-container":{"identifier":"allow-stop-project-container","description":"Enables the stop_project_container command without any pre-configured scope.","commands":{"allow":["stop_project_container"],"deny":[]}},"allow-stop-stt":{"identifier":"allow-stop-stt","description":"Enables the stop_stt command without any pre-configured scope.","commands":{"allow":["stop_stt"],"deny":[]}},"allow-stop-web-terminal":{"identifier":"allow-stop-web-terminal","description":"Enables the stop_web_terminal command without any pre-configured scope.","commands":{"allow":["stop_web_terminal"],"deny":[]}},"allow-submit-claude-token-code":{"identifier":"allow-submit-claude-token-code","description":"Enables the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":["submit_claude_token_code"],"deny":[]}},"allow-sweep-claude-token-snapshots":{"identifier":"allow-sweep-claude-token-snapshots","description":"Enables the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":["sweep_claude_token_snapshots"],"deny":[]}},"allow-terminal-input":{"identifier":"allow-terminal-input","description":"Enables the terminal_input command without any pre-configured scope.","commands":{"allow":["terminal_input"],"deny":[]}},"allow-terminal-resize":{"identifier":"allow-terminal-resize","description":"Enables the terminal_resize command without any pre-configured scope.","commands":{"allow":["terminal_resize"],"deny":[]}},"allow-test-marketplace-account":{"identifier":"allow-test-marketplace-account","description":"Enables the test_marketplace_account command without any pre-configured scope.","commands":{"allow":["test_marketplace_account"],"deny":[]}},"allow-transcribe-audio":{"identifier":"allow-transcribe-audio","description":"Enables the transcribe_audio command without any pre-configured scope.","commands":{"allow":["transcribe_audio"],"deny":[]}},"allow-uninstall-marketplace-item":{"identifier":"allow-uninstall-marketplace-item","description":"Enables the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":["uninstall_marketplace_item"],"deny":[]}},"allow-unlock-aws-state":{"identifier":"allow-unlock-aws-state","description":"Enables the unlock_aws_state command without any pre-configured scope.","commands":{"allow":["unlock_aws_state"],"deny":[]}},"allow-update-aws-connection":{"identifier":"allow-update-aws-connection","description":"Enables the update_aws_connection command without any pre-configured scope.","commands":{"allow":["update_aws_connection"],"deny":[]}},"allow-update-aws-environment":{"identifier":"allow-update-aws-environment","description":"Enables the update_aws_environment command without any pre-configured scope.","commands":{"allow":["update_aws_environment"],"deny":[]}},"allow-update-marketplace":{"identifier":"allow-update-marketplace","description":"Enables the update_marketplace command without any pre-configured scope.","commands":{"allow":["update_marketplace"],"deny":[]}},"allow-update-marketplace-item":{"identifier":"allow-update-marketplace-item","description":"Enables the update_marketplace_item command without any pre-configured scope.","commands":{"allow":["update_marketplace_item"],"deny":[]}},"allow-update-project":{"identifier":"allow-update-project","description":"Enables the update_project command without any pre-configured scope.","commands":{"allow":["update_project"],"deny":[]}},"allow-update-scheduled-task":{"identifier":"allow-update-scheduled-task","description":"Enables the update_scheduled_task command without any pre-configured scope.","commands":{"allow":["update_scheduled_task"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-upload-files-to-container":{"identifier":"allow-upload-files-to-container","description":"Enables the upload_files_to_container command without any pre-configured scope.","commands":{"allow":["upload_files_to_container"],"deny":[]}},"allow-upload-host-file-to-terminal":{"identifier":"allow-upload-host-file-to-terminal","description":"Enables the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":["upload_host_file_to_terminal"],"deny":[]}},"allow-viewer-choose-file":{"identifier":"allow-viewer-choose-file","description":"Enables the viewer_choose_file command without any pre-configured scope.","commands":{"allow":["viewer_choose_file"],"deny":[]}},"allow-viewer-get-state":{"identifier":"allow-viewer-get-state","description":"Enables the viewer_get_state command without any pre-configured scope.","commands":{"allow":["viewer_get_state"],"deny":[]}},"allow-viewer-poll-file":{"identifier":"allow-viewer-poll-file","description":"Enables the viewer_poll_file command without any pre-configured scope.","commands":{"allow":["viewer_poll_file"],"deny":[]}},"allow-viewer-read-file":{"identifier":"allow-viewer-read-file","description":"Enables the viewer_read_file command without any pre-configured scope.","commands":{"allow":["viewer_read_file"],"deny":[]}},"allow-viewer-write-file":{"identifier":"allow-viewer-write-file","description":"Enables the viewer_write_file command without any pre-configured scope.","commands":{"allow":["viewer_write_file"],"deny":[]}},"deny-acquire-claude-token":{"identifier":"deny-acquire-claude-token","description":"Denies the acquire_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["acquire_claude_token"]}},"deny-add-aws-keys-connection":{"identifier":"deny-add-aws-keys-connection","description":"Denies the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_keys_connection"]}},"deny-add-aws-profile-connection":{"identifier":"deny-add-aws-profile-connection","description":"Denies the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_profile_connection"]}},"deny-add-marketplace":{"identifier":"deny-add-marketplace","description":"Denies the add_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace"]}},"deny-add-marketplace-gh-host-account":{"identifier":"deny-add-marketplace-gh-host-account","description":"Denies the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_gh_host_account"]}},"deny-add-marketplace-token-account":{"identifier":"deny-add-marketplace-token-account","description":"Denies the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_token_account"]}},"deny-add-project":{"identifier":"deny-add-project","description":"Denies the add_project command without any pre-configured scope.","commands":{"allow":[],"deny":["add_project"]}},"deny-add-scheduled-task":{"identifier":"deny-add-scheduled-task","description":"Denies the add_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["add_scheduled_task"]}},"deny-apply-marketplace-now":{"identifier":"deny-apply-marketplace-now","description":"Denies the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_marketplace_now"]}},"deny-apply-settings-import":{"identifier":"deny-apply-settings-import","description":"Denies the apply_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_settings_import"]}},"deny-aws-connection-identity":{"identifier":"deny-aws-connection-identity","description":"Denies the aws_connection_identity command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_identity"]}},"deny-aws-connection-sso-login":{"identifier":"deny-aws-connection-sso-login","description":"Denies the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_sso_login"]}},"deny-aws-policy-document":{"identifier":"deny-aws-policy-document","description":"Denies the aws_policy_document command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_policy_document"]}},"deny-aws-sso-refresh":{"identifier":"deny-aws-sso-refresh","description":"Denies the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_sso_refresh"]}},"deny-build-gateway-image":{"identifier":"deny-build-gateway-image","description":"Denies the build_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_gateway_image"]}},"deny-build-image":{"identifier":"deny-build-image","description":"Denies the build_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_image"]}},"deny-build-stt-image":{"identifier":"deny-build-stt-image","description":"Denies the build_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_stt_image"]}},"deny-cancel-aws-deployment":{"identifier":"deny-cancel-aws-deployment","description":"Denies the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_aws_deployment"]}},"deny-cancel-claude-token":{"identifier":"deny-cancel-claude-token","description":"Denies the cancel_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_claude_token"]}},"deny-cancel-marketplace-gh-login":{"identifier":"deny-cancel-marketplace-gh-login","description":"Denies the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_marketplace_gh_login"]}},"deny-check-aws-permissions":{"identifier":"deny-check-aws-permissions","description":"Denies the check_aws_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_aws_permissions"]}},"deny-check-browser-view-support":{"identifier":"deny-check-browser-view-support","description":"Denies the check_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["check_browser_view_support"]}},"deny-check-docker":{"identifier":"deny-check-docker","description":"Denies the check_docker command without any pre-configured scope.","commands":{"allow":[],"deny":["check_docker"]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-check-gateway-health":{"identifier":"deny-check-gateway-health","description":"Denies the check_gateway_health command without any pre-configured scope.","commands":{"allow":[],"deny":["check_gateway_health"]}},"deny-check-image-exists":{"identifier":"deny-check-image-exists","description":"Denies the check_image_exists command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_exists"]}},"deny-check-image-update":{"identifier":"deny-check-image-update","description":"Denies the check_image_update command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_update"]}},"deny-clear-claude-token":{"identifier":"deny-clear-claude-token","description":"Denies the clear_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_claude_token"]}},"deny-clear-gateway-api-key":{"identifier":"deny-clear-gateway-api-key","description":"Denies the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_gateway_api_key"]}},"deny-clear-scheduler-notifications":{"identifier":"deny-clear-scheduler-notifications","description":"Denies the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_scheduler_notifications"]}},"deny-close-browser-view-popout":{"identifier":"deny-close-browser-view-popout","description":"Denies the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["close_browser_view_popout"]}},"deny-close-container-page":{"identifier":"deny-close-container-page","description":"Denies the close_container_page command without any pre-configured scope.","commands":{"allow":[],"deny":["close_container_page"]}},"deny-close-terminal-session":{"identifier":"deny-close-terminal-session","description":"Denies the close_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["close_terminal_session"]}},"deny-confirm-migration":{"identifier":"deny-confirm-migration","description":"Denies the confirm_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm_migration"]}},"deny-create-container-directory":{"identifier":"deny-create-container-directory","description":"Denies the create_container_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["create_container_directory"]}},"deny-delete-note":{"identifier":"deny-delete-note","description":"Denies the delete_note command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_note"]}},"deny-deploy-aws-environment":{"identifier":"deny-deploy-aws-environment","description":"Denies the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_environment"]}},"deny-deploy-aws-permissions-boundary":{"identifier":"deny-deploy-aws-permissions-boundary","description":"Denies the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_permissions_boundary"]}},"deny-destroy-incomplete-aws-environment":{"identifier":"deny-destroy-incomplete-aws-environment","description":"Denies the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy_incomplete_aws_environment"]}},"deny-detect-aws-config":{"identifier":"deny-detect-aws-config","description":"Denies the detect_aws_config command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_config"]}},"deny-detect-aws-environments":{"identifier":"deny-detect-aws-environments","description":"Denies the detect_aws_environments command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_environments"]}},"deny-detect-host-timezone":{"identifier":"deny-detect-host-timezone","description":"Denies the detect_host_timezone command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_host_timezone"]}},"deny-detect-install-options":{"identifier":"deny-detect-install-options","description":"Denies the detect_install_options command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_install_options"]}},"deny-download-container-backup":{"identifier":"deny-download-container-backup","description":"Denies the download_container_backup command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_backup"]}},"deny-download-container-file":{"identifier":"deny-download-container-file","description":"Denies the download_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_file"]}},"deny-export-settings":{"identifier":"deny-export-settings","description":"Denies the export_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["export_settings"]}},"deny-follow-aws-deployment":{"identifier":"deny-follow-aws-deployment","description":"Denies the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["follow_aws_deployment"]}},"deny-forget-marketplace-installs":{"identifier":"deny-forget-marketplace-installs","description":"Denies the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":[],"deny":["forget_marketplace_installs"]}},"deny-get-app-version":{"identifier":"deny-get-app-version","description":"Denies the get_app_version command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_version"]}},"deny-get-auth-bridge-status":{"identifier":"deny-get-auth-bridge-status","description":"Denies the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_auth_bridge_status"]}},"deny-get-browser-view-match-window":{"identifier":"deny-get-browser-view-match-window","description":"Denies the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_match_window"]}},"deny-get-browser-view-popout-state":{"identifier":"deny-get-browser-view-popout-state","description":"Denies the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_popout_state"]}},"deny-get-browser-view-status":{"identifier":"deny-get-browser-view-status","description":"Denies the get_browser_view_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_status"]}},"deny-get-container-info":{"identifier":"deny-get-container-info","description":"Denies the get_container_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_info"]}},"deny-get-container-page-state":{"identifier":"deny-get-container-page-state","description":"Denies the get_container_page_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_page_state"]}},"deny-get-container-staleness":{"identifier":"deny-get-container-staleness","description":"Denies the get_container_staleness command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_staleness"]}},"deny-get-gateway-auth-token":{"identifier":"deny-get-gateway-auth-token","description":"Denies the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_auth_token"]}},"deny-get-gateway-status":{"identifier":"deny-get-gateway-status","description":"Denies the get_gateway_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_status"]}},"deny-get-help-content":{"identifier":"deny-get-help-content","description":"Denies the get_help_content command without any pre-configured scope.","commands":{"allow":[],"deny":["get_help_content"]}},"deny-get-marketplace-sync-report":{"identifier":"deny-get-marketplace-sync-report","description":"Denies the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":[],"deny":["get_marketplace_sync_report"]}},"deny-get-migration-state":{"identifier":"deny-get-migration-state","description":"Denies the get_migration_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_migration_state"]}},"deny-get-scheduled-task-log":{"identifier":"deny-get-scheduled-task-log","description":"Denies the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduled_task_log"]}},"deny-get-scheduler-notifications":{"identifier":"deny-get-scheduler-notifications","description":"Denies the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduler_notifications"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-get-stt-status":{"identifier":"deny-get-stt-status","description":"Denies the get_stt_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_stt_status"]}},"deny-get-web-terminal-status":{"identifier":"deny-get-web-terminal-status","description":"Denies the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_web_terminal_status"]}},"deny-has-claude-token":{"identifier":"deny-has-claude-token","description":"Denies the has_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["has_claude_token"]}},"deny-inspect-ca-cert-path":{"identifier":"deny-inspect-ca-cert-path","description":"Denies the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":[],"deny":["inspect_ca_cert_path"]}},"deny-install-browser-view-browser":{"identifier":"deny-install-browser-view-browser","description":"Denies the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_browser"]}},"deny-install-browser-view-support":{"identifier":"deny-install-browser-view-support","description":"Denies the install_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_support"]}},"deny-install-marketplace-item":{"identifier":"deny-install-marketplace-item","description":"Denies the install_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["install_marketplace_item"]}},"deny-list-aws-profiles":{"identifier":"deny-list-aws-profiles","description":"Denies the list_aws_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_aws_profiles"]}},"deny-list-claude-sessions":{"identifier":"deny-list-claude-sessions","description":"Denies the list_claude_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["list_claude_sessions"]}},"deny-list-container-capabilities":{"identifier":"deny-list-container-capabilities","description":"Denies the list_container_capabilities command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_capabilities"]}},"deny-list-container-files":{"identifier":"deny-list-container-files","description":"Denies the list_container_files command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_files"]}},"deny-list-marketplace-snapshots":{"identifier":"deny-list-marketplace-snapshots","description":"Denies the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_snapshots"]}},"deny-list-marketplace-updates":{"identifier":"deny-list-marketplace-updates","description":"Denies the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_updates"]}},"deny-list-notes":{"identifier":"deny-list-notes","description":"Denies the list_notes command without any pre-configured scope.","commands":{"allow":[],"deny":["list_notes"]}},"deny-list-projects":{"identifier":"deny-list-projects","description":"Denies the list_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["list_projects"]}},"deny-list-scheduled-tasks":{"identifier":"deny-list-scheduled-tasks","description":"Denies the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":[],"deny":["list_scheduled_tasks"]}},"deny-marketplace-gh-host-available":{"identifier":"deny-marketplace-gh-host-available","description":"Denies the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_gh_host_available"]}},"deny-marketplace-item-diff":{"identifier":"deny-marketplace-item-diff","description":"Denies the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_item_diff"]}},"deny-migrate-project-to-base":{"identifier":"deny-migrate-project-to-base","description":"Denies the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":[],"deny":["migrate_project_to_base"]}},"deny-onboard-aws-user":{"identifier":"deny-onboard-aws-user","description":"Denies the onboard_aws_user command without any pre-configured scope.","commands":{"allow":[],"deny":["onboard_aws_user"]}},"deny-open-browser-view-popout":{"identifier":"deny-open-browser-view-popout","description":"Denies the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["open_browser_view_popout"]}},"deny-open-file-viewer":{"identifier":"deny-open-file-viewer","description":"Denies the open_file_viewer command without any pre-configured scope.","commands":{"allow":[],"deny":["open_file_viewer"]}},"deny-open-page-in-container-browser":{"identifier":"deny-open-page-in-container-browser","description":"Denies the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["open_page_in_container_browser"]}},"deny-open-terminal-session":{"identifier":"deny-open-terminal-session","description":"Denies the open_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["open_terminal_session"]}},"deny-open-url-external":{"identifier":"deny-open-url-external","description":"Denies the open_url_external command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url_external"]}},"deny-paste-image-to-terminal":{"identifier":"deny-paste-image-to-terminal","description":"Denies the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["paste_image_to_terminal"]}},"deny-preview-settings-import":{"identifier":"deny-preview-settings-import","description":"Denies the preview_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["preview_settings_import"]}},"deny-pull-gateway-image":{"identifier":"deny-pull-gateway-image","description":"Denies the pull_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_gateway_image"]}},"deny-pull-image":{"identifier":"deny-pull-image","description":"Denies the pull_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_image"]}},"deny-pull-stt-image":{"identifier":"deny-pull-stt-image","description":"Denies the pull_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_stt_image"]}},"deny-read-container-file":{"identifier":"deny-read-container-file","description":"Denies the read_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_container_file"]}},"deny-rebuild-project-container":{"identifier":"deny-rebuild-project-container","description":"Denies the rebuild_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["rebuild_project_container"]}},"deny-reconcile-project-statuses":{"identifier":"deny-reconcile-project-statuses","description":"Denies the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":[],"deny":["reconcile_project_statuses"]}},"deny-refresh-marketplaces":{"identifier":"deny-refresh-marketplaces","description":"Denies the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":[],"deny":["refresh_marketplaces"]}},"deny-regenerate-gateway-auth-token":{"identifier":"deny-regenerate-gateway-auth-token","description":"Denies the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_gateway_auth_token"]}},"deny-regenerate-web-terminal-token":{"identifier":"deny-regenerate-web-terminal-token","description":"Denies the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_web_terminal_token"]}},"deny-remove-host-connection":{"identifier":"deny-remove-host-connection","description":"Denies the remove_host_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_host_connection"]}},"deny-remove-marketplace":{"identifier":"deny-remove-marketplace","description":"Denies the remove_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace"]}},"deny-remove-marketplace-account":{"identifier":"deny-remove-marketplace-account","description":"Denies the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace_account"]}},"deny-remove-project":{"identifier":"deny-remove-project","description":"Denies the remove_project command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_project"]}},"deny-remove-scheduled-task":{"identifier":"deny-remove-scheduled-task","description":"Denies the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_scheduled_task"]}},"deny-rename-container-path":{"identifier":"deny-rename-container-path","description":"Denies the rename_container_path command without any pre-configured scope.","commands":{"allow":[],"deny":["rename_container_path"]}},"deny-repair-aws-environment":{"identifier":"deny-repair-aws-environment","description":"Denies the repair_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["repair_aws_environment"]}},"deny-rollback-migration":{"identifier":"deny-rollback-migration","description":"Denies the rollback_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["rollback_migration"]}},"deny-run-docker-install":{"identifier":"deny-run-docker-install","description":"Denies the run_docker_install command without any pre-configured scope.","commands":{"allow":[],"deny":["run_docker_install"]}},"deny-run-scheduled-task-now":{"identifier":"deny-run-scheduled-task-now","description":"Denies the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":[],"deny":["run_scheduled_task_now"]}},"deny-save-aws-admin-bundle":{"identifier":"deny-save-aws-admin-bundle","description":"Denies the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":[],"deny":["save_aws_admin_bundle"]}},"deny-save-note":{"identifier":"deny-save-note","description":"Denies the save_note command without any pre-configured scope.","commands":{"allow":[],"deny":["save_note"]}},"deny-send-audio-data":{"identifier":"deny-send-audio-data","description":"Denies the send_audio_data command without any pre-configured scope.","commands":{"allow":[],"deny":["send_audio_data"]}},"deny-set-auth-bridge-enabled":{"identifier":"deny-set-auth-bridge-enabled","description":"Denies the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_auth_bridge_enabled"]}},"deny-set-browser-view-enabled":{"identifier":"deny-set-browser-view-enabled","description":"Denies the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_enabled"]}},"deny-set-browser-view-match-window":{"identifier":"deny-set-browser-view-match-window","description":"Denies the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_match_window"]}},"deny-set-browser-view-popout-always-on-top":{"identifier":"deny-set-browser-view-popout-always-on-top","description":"Denies the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_popout_always_on_top"]}},"deny-set-container-page-viewport":{"identifier":"deny-set-container-page-viewport","description":"Denies the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":[],"deny":["set_container_page_viewport"]}},"deny-set-gateway-api-key":{"identifier":"deny-set-gateway-api-key","description":"Denies the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["set_gateway_api_key"]}},"deny-set-global-item-disabled":{"identifier":"deny-set-global-item-disabled","description":"Denies the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_global_item_disabled"]}},"deny-set-scheduled-task-enabled":{"identifier":"deny-set-scheduled-task-enabled","description":"Denies the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_scheduled_task_enabled"]}},"deny-start-audio-bridge":{"identifier":"deny-start-audio-bridge","description":"Denies the start_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["start_audio_bridge"]}},"deny-start-gateway":{"identifier":"deny-start-gateway","description":"Denies the start_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["start_gateway"]}},"deny-start-marketplace-gh-container-login":{"identifier":"deny-start-marketplace-gh-container-login","description":"Denies the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":[],"deny":["start_marketplace_gh_container_login"]}},"deny-start-project-container":{"identifier":"deny-start-project-container","description":"Denies the start_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["start_project_container"]}},"deny-start-stt":{"identifier":"deny-start-stt","description":"Denies the start_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["start_stt"]}},"deny-start-web-terminal":{"identifier":"deny-start-web-terminal","description":"Denies the start_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["start_web_terminal"]}},"deny-stop-audio-bridge":{"identifier":"deny-stop-audio-bridge","description":"Denies the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_audio_bridge"]}},"deny-stop-gateway":{"identifier":"deny-stop-gateway","description":"Denies the stop_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_gateway"]}},"deny-stop-project-container":{"identifier":"deny-stop-project-container","description":"Denies the stop_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_project_container"]}},"deny-stop-stt":{"identifier":"deny-stop-stt","description":"Denies the stop_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_stt"]}},"deny-stop-web-terminal":{"identifier":"deny-stop-web-terminal","description":"Denies the stop_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_web_terminal"]}},"deny-submit-claude-token-code":{"identifier":"deny-submit-claude-token-code","description":"Denies the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":[],"deny":["submit_claude_token_code"]}},"deny-sweep-claude-token-snapshots":{"identifier":"deny-sweep-claude-token-snapshots","description":"Denies the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["sweep_claude_token_snapshots"]}},"deny-terminal-input":{"identifier":"deny-terminal-input","description":"Denies the terminal_input command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_input"]}},"deny-terminal-resize":{"identifier":"deny-terminal-resize","description":"Denies the terminal_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_resize"]}},"deny-test-marketplace-account":{"identifier":"deny-test-marketplace-account","description":"Denies the test_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["test_marketplace_account"]}},"deny-transcribe-audio":{"identifier":"deny-transcribe-audio","description":"Denies the transcribe_audio command without any pre-configured scope.","commands":{"allow":[],"deny":["transcribe_audio"]}},"deny-uninstall-marketplace-item":{"identifier":"deny-uninstall-marketplace-item","description":"Denies the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["uninstall_marketplace_item"]}},"deny-unlock-aws-state":{"identifier":"deny-unlock-aws-state","description":"Denies the unlock_aws_state command without any pre-configured scope.","commands":{"allow":[],"deny":["unlock_aws_state"]}},"deny-update-aws-connection":{"identifier":"deny-update-aws-connection","description":"Denies the update_aws_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_connection"]}},"deny-update-aws-environment":{"identifier":"deny-update-aws-environment","description":"Denies the update_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_environment"]}},"deny-update-marketplace":{"identifier":"deny-update-marketplace","description":"Denies the update_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace"]}},"deny-update-marketplace-item":{"identifier":"deny-update-marketplace-item","description":"Denies the update_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace_item"]}},"deny-update-project":{"identifier":"deny-update-project","description":"Denies the update_project command without any pre-configured scope.","commands":{"allow":[],"deny":["update_project"]}},"deny-update-scheduled-task":{"identifier":"deny-update-scheduled-task","description":"Denies the update_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["update_scheduled_task"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-upload-files-to-container":{"identifier":"deny-upload-files-to-container","description":"Denies the upload_files_to_container command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_files_to_container"]}},"deny-upload-host-file-to-terminal":{"identifier":"deny-upload-host-file-to-terminal","description":"Denies the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_host_file_to_terminal"]}},"deny-viewer-choose-file":{"identifier":"deny-viewer-choose-file","description":"Denies the viewer_choose_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_choose_file"]}},"deny-viewer-get-state":{"identifier":"deny-viewer-get-state","description":"Denies the viewer_get_state command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_get_state"]}},"deny-viewer-poll-file":{"identifier":"deny-viewer-poll-file","description":"Denies the viewer_poll_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_poll_file"]}},"deny-viewer-read-file":{"identifier":"deny-viewer-read-file","description":"Denies the viewer_read_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_read_file"]}},"deny-viewer-write-file":{"identifier":"deny-viewer-write-file","description":"Denies the viewer_write_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_write_file"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-aws-deployment":{"identifier":"allow-cancel-aws-deployment","description":"Enables the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":["cancel_aws_deployment"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["clear_scheduler_notifications"],"deny":[]}},"allow-close-browser-view-popout":{"identifier":"allow-close-browser-view-popout","description":"Enables the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":["close_browser_view_popout"],"deny":[]}},"allow-close-container-page":{"identifier":"allow-close-container-page","description":"Enables the close_container_page command without any pre-configured scope.","commands":{"allow":["close_container_page"],"deny":[]}},"allow-close-terminal-session":{"identifier":"allow-close-terminal-session","description":"Enables the close_terminal_session command without any pre-configured scope.","commands":{"allow":["close_terminal_session"],"deny":[]}},"allow-confirm-migration":{"identifier":"allow-confirm-migration","description":"Enables the confirm_migration command without any pre-configured scope.","commands":{"allow":["confirm_migration"],"deny":[]}},"allow-create-container-directory":{"identifier":"allow-create-container-directory","description":"Enables the create_container_directory command without any pre-configured scope.","commands":{"allow":["create_container_directory"],"deny":[]}},"allow-delete-note":{"identifier":"allow-delete-note","description":"Enables the delete_note command without any pre-configured scope.","commands":{"allow":["delete_note"],"deny":[]}},"allow-deploy-aws-environment":{"identifier":"allow-deploy-aws-environment","description":"Enables the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":["deploy_aws_environment"],"deny":[]}},"allow-deploy-aws-permissions-boundary":{"identifier":"allow-deploy-aws-permissions-boundary","description":"Enables the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":["deploy_aws_permissions_boundary"],"deny":[]}},"allow-destroy-incomplete-aws-environment":{"identifier":"allow-destroy-incomplete-aws-environment","description":"Enables the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":["destroy_incomplete_aws_environment"],"deny":[]}},"allow-detect-aws-config":{"identifier":"allow-detect-aws-config","description":"Enables the detect_aws_config command without any pre-configured scope.","commands":{"allow":["detect_aws_config"],"deny":[]}},"allow-detect-aws-environments":{"identifier":"allow-detect-aws-environments","description":"Enables the detect_aws_environments command without any pre-configured scope.","commands":{"allow":["detect_aws_environments"],"deny":[]}},"allow-detect-host-timezone":{"identifier":"allow-detect-host-timezone","description":"Enables the detect_host_timezone command without any pre-configured scope.","commands":{"allow":["detect_host_timezone"],"deny":[]}},"allow-detect-install-options":{"identifier":"allow-detect-install-options","description":"Enables the detect_install_options command without any pre-configured scope.","commands":{"allow":["detect_install_options"],"deny":[]}},"allow-download-container-backup":{"identifier":"allow-download-container-backup","description":"Enables the download_container_backup command without any pre-configured scope.","commands":{"allow":["download_container_backup"],"deny":[]}},"allow-download-container-file":{"identifier":"allow-download-container-file","description":"Enables the download_container_file command without any pre-configured scope.","commands":{"allow":["download_container_file"],"deny":[]}},"allow-export-settings":{"identifier":"allow-export-settings","description":"Enables the export_settings command without any pre-configured scope.","commands":{"allow":["export_settings"],"deny":[]}},"allow-follow-aws-deployment":{"identifier":"allow-follow-aws-deployment","description":"Enables the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":["follow_aws_deployment"],"deny":[]}},"allow-forget-marketplace-installs":{"identifier":"allow-forget-marketplace-installs","description":"Enables the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":["forget_marketplace_installs"],"deny":[]}},"allow-get-app-version":{"identifier":"allow-get-app-version","description":"Enables the get_app_version command without any pre-configured scope.","commands":{"allow":["get_app_version"],"deny":[]}},"allow-get-auth-bridge-status":{"identifier":"allow-get-auth-bridge-status","description":"Enables the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":["get_auth_bridge_status"],"deny":[]}},"allow-get-browser-view-match-window":{"identifier":"allow-get-browser-view-match-window","description":"Enables the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["get_browser_view_match_window"],"deny":[]}},"allow-get-browser-view-popout-state":{"identifier":"allow-get-browser-view-popout-state","description":"Enables the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":["get_browser_view_popout_state"],"deny":[]}},"allow-get-browser-view-status":{"identifier":"allow-get-browser-view-status","description":"Enables the get_browser_view_status command without any pre-configured scope.","commands":{"allow":["get_browser_view_status"],"deny":[]}},"allow-get-container-info":{"identifier":"allow-get-container-info","description":"Enables the get_container_info command without any pre-configured scope.","commands":{"allow":["get_container_info"],"deny":[]}},"allow-get-container-page-state":{"identifier":"allow-get-container-page-state","description":"Enables the get_container_page_state command without any pre-configured scope.","commands":{"allow":["get_container_page_state"],"deny":[]}},"allow-get-container-staleness":{"identifier":"allow-get-container-staleness","description":"Enables the get_container_staleness command without any pre-configured scope.","commands":{"allow":["get_container_staleness"],"deny":[]}},"allow-get-gateway-auth-token":{"identifier":"allow-get-gateway-auth-token","description":"Enables the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["get_gateway_auth_token"],"deny":[]}},"allow-get-gateway-status":{"identifier":"allow-get-gateway-status","description":"Enables the get_gateway_status command without any pre-configured scope.","commands":{"allow":["get_gateway_status"],"deny":[]}},"allow-get-help-content":{"identifier":"allow-get-help-content","description":"Enables the get_help_content command without any pre-configured scope.","commands":{"allow":["get_help_content"],"deny":[]}},"allow-get-marketplace-sync-report":{"identifier":"allow-get-marketplace-sync-report","description":"Enables the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":["get_marketplace_sync_report"],"deny":[]}},"allow-get-migration-state":{"identifier":"allow-get-migration-state","description":"Enables the get_migration_state command without any pre-configured scope.","commands":{"allow":["get_migration_state"],"deny":[]}},"allow-get-scheduled-task-log":{"identifier":"allow-get-scheduled-task-log","description":"Enables the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":["get_scheduled_task_log"],"deny":[]}},"allow-get-scheduler-notifications":{"identifier":"allow-get-scheduler-notifications","description":"Enables the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":["get_scheduler_notifications"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-get-stt-status":{"identifier":"allow-get-stt-status","description":"Enables the get_stt_status command without any pre-configured scope.","commands":{"allow":["get_stt_status"],"deny":[]}},"allow-get-web-terminal-status":{"identifier":"allow-get-web-terminal-status","description":"Enables the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":["get_web_terminal_status"],"deny":[]}},"allow-has-claude-token":{"identifier":"allow-has-claude-token","description":"Enables the has_claude_token command without any pre-configured scope.","commands":{"allow":["has_claude_token"],"deny":[]}},"allow-inspect-ca-cert-path":{"identifier":"allow-inspect-ca-cert-path","description":"Enables the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":["inspect_ca_cert_path"],"deny":[]}},"allow-install-browser-view-browser":{"identifier":"allow-install-browser-view-browser","description":"Enables the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":["install_browser_view_browser"],"deny":[]}},"allow-install-browser-view-support":{"identifier":"allow-install-browser-view-support","description":"Enables the install_browser_view_support command without any pre-configured scope.","commands":{"allow":["install_browser_view_support"],"deny":[]}},"allow-install-marketplace-item":{"identifier":"allow-install-marketplace-item","description":"Enables the install_marketplace_item command without any pre-configured scope.","commands":{"allow":["install_marketplace_item"],"deny":[]}},"allow-list-aws-profiles":{"identifier":"allow-list-aws-profiles","description":"Enables the list_aws_profiles command without any pre-configured scope.","commands":{"allow":["list_aws_profiles"],"deny":[]}},"allow-list-claude-sessions":{"identifier":"allow-list-claude-sessions","description":"Enables the list_claude_sessions command without any pre-configured scope.","commands":{"allow":["list_claude_sessions"],"deny":[]}},"allow-list-container-capabilities":{"identifier":"allow-list-container-capabilities","description":"Enables the list_container_capabilities command without any pre-configured scope.","commands":{"allow":["list_container_capabilities"],"deny":[]}},"allow-list-container-files":{"identifier":"allow-list-container-files","description":"Enables the list_container_files command without any pre-configured scope.","commands":{"allow":["list_container_files"],"deny":[]}},"allow-list-marketplace-snapshots":{"identifier":"allow-list-marketplace-snapshots","description":"Enables the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":["list_marketplace_snapshots"],"deny":[]}},"allow-list-marketplace-updates":{"identifier":"allow-list-marketplace-updates","description":"Enables the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":["list_marketplace_updates"],"deny":[]}},"allow-list-notes":{"identifier":"allow-list-notes","description":"Enables the list_notes command without any pre-configured scope.","commands":{"allow":["list_notes"],"deny":[]}},"allow-list-projects":{"identifier":"allow-list-projects","description":"Enables the list_projects command without any pre-configured scope.","commands":{"allow":["list_projects"],"deny":[]}},"allow-list-scheduled-tasks":{"identifier":"allow-list-scheduled-tasks","description":"Enables the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":["list_scheduled_tasks"],"deny":[]}},"allow-marketplace-gh-host-available":{"identifier":"allow-marketplace-gh-host-available","description":"Enables the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":["marketplace_gh_host_available"],"deny":[]}},"allow-marketplace-item-diff":{"identifier":"allow-marketplace-item-diff","description":"Enables the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":["marketplace_item_diff"],"deny":[]}},"allow-migrate-project-to-base":{"identifier":"allow-migrate-project-to-base","description":"Enables the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":["migrate_project_to_base"],"deny":[]}},"allow-onboard-aws-user":{"identifier":"allow-onboard-aws-user","description":"Enables the onboard_aws_user command without any pre-configured scope.","commands":{"allow":["onboard_aws_user"],"deny":[]}},"allow-open-browser-view-popout":{"identifier":"allow-open-browser-view-popout","description":"Enables the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":["open_browser_view_popout"],"deny":[]}},"allow-open-file-viewer":{"identifier":"allow-open-file-viewer","description":"Enables the open_file_viewer command without any pre-configured scope.","commands":{"allow":["open_file_viewer"],"deny":[]}},"allow-open-page-in-container-browser":{"identifier":"allow-open-page-in-container-browser","description":"Enables the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":["open_page_in_container_browser"],"deny":[]}},"allow-open-terminal-session":{"identifier":"allow-open-terminal-session","description":"Enables the open_terminal_session command without any pre-configured scope.","commands":{"allow":["open_terminal_session"],"deny":[]}},"allow-open-url-external":{"identifier":"allow-open-url-external","description":"Enables the open_url_external command without any pre-configured scope.","commands":{"allow":["open_url_external"],"deny":[]}},"allow-paste-image-to-terminal":{"identifier":"allow-paste-image-to-terminal","description":"Enables the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":["paste_image_to_terminal"],"deny":[]}},"allow-preview-settings-import":{"identifier":"allow-preview-settings-import","description":"Enables the preview_settings_import command without any pre-configured scope.","commands":{"allow":["preview_settings_import"],"deny":[]}},"allow-pull-gateway-image":{"identifier":"allow-pull-gateway-image","description":"Enables the pull_gateway_image command without any pre-configured scope.","commands":{"allow":["pull_gateway_image"],"deny":[]}},"allow-pull-image":{"identifier":"allow-pull-image","description":"Enables the pull_image command without any pre-configured scope.","commands":{"allow":["pull_image"],"deny":[]}},"allow-pull-stt-image":{"identifier":"allow-pull-stt-image","description":"Enables the pull_stt_image command without any pre-configured scope.","commands":{"allow":["pull_stt_image"],"deny":[]}},"allow-push-aws-saved-state":{"identifier":"allow-push-aws-saved-state","description":"Enables the push_aws_saved_state command without any pre-configured scope.","commands":{"allow":["push_aws_saved_state"],"deny":[]}},"allow-read-container-file":{"identifier":"allow-read-container-file","description":"Enables the read_container_file command without any pre-configured scope.","commands":{"allow":["read_container_file"],"deny":[]}},"allow-rebuild-project-container":{"identifier":"allow-rebuild-project-container","description":"Enables the rebuild_project_container command without any pre-configured scope.","commands":{"allow":["rebuild_project_container"],"deny":[]}},"allow-reconcile-project-statuses":{"identifier":"allow-reconcile-project-statuses","description":"Enables the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":["reconcile_project_statuses"],"deny":[]}},"allow-refresh-marketplaces":{"identifier":"allow-refresh-marketplaces","description":"Enables the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":["refresh_marketplaces"],"deny":[]}},"allow-regenerate-gateway-auth-token":{"identifier":"allow-regenerate-gateway-auth-token","description":"Enables the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":["regenerate_gateway_auth_token"],"deny":[]}},"allow-regenerate-web-terminal-token":{"identifier":"allow-regenerate-web-terminal-token","description":"Enables the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":["regenerate_web_terminal_token"],"deny":[]}},"allow-remove-host-connection":{"identifier":"allow-remove-host-connection","description":"Enables the remove_host_connection command without any pre-configured scope.","commands":{"allow":["remove_host_connection"],"deny":[]}},"allow-remove-marketplace":{"identifier":"allow-remove-marketplace","description":"Enables the remove_marketplace command without any pre-configured scope.","commands":{"allow":["remove_marketplace"],"deny":[]}},"allow-remove-marketplace-account":{"identifier":"allow-remove-marketplace-account","description":"Enables the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":["remove_marketplace_account"],"deny":[]}},"allow-remove-project":{"identifier":"allow-remove-project","description":"Enables the remove_project command without any pre-configured scope.","commands":{"allow":["remove_project"],"deny":[]}},"allow-remove-scheduled-task":{"identifier":"allow-remove-scheduled-task","description":"Enables the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":["remove_scheduled_task"],"deny":[]}},"allow-rename-container-path":{"identifier":"allow-rename-container-path","description":"Enables the rename_container_path command without any pre-configured scope.","commands":{"allow":["rename_container_path"],"deny":[]}},"allow-repair-aws-environment":{"identifier":"allow-repair-aws-environment","description":"Enables the repair_aws_environment command without any pre-configured scope.","commands":{"allow":["repair_aws_environment"],"deny":[]}},"allow-rollback-migration":{"identifier":"allow-rollback-migration","description":"Enables the rollback_migration command without any pre-configured scope.","commands":{"allow":["rollback_migration"],"deny":[]}},"allow-run-docker-install":{"identifier":"allow-run-docker-install","description":"Enables the run_docker_install command without any pre-configured scope.","commands":{"allow":["run_docker_install"],"deny":[]}},"allow-run-scheduled-task-now":{"identifier":"allow-run-scheduled-task-now","description":"Enables the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":["run_scheduled_task_now"],"deny":[]}},"allow-save-aws-admin-bundle":{"identifier":"allow-save-aws-admin-bundle","description":"Enables the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":["save_aws_admin_bundle"],"deny":[]}},"allow-save-note":{"identifier":"allow-save-note","description":"Enables the save_note command without any pre-configured scope.","commands":{"allow":["save_note"],"deny":[]}},"allow-send-audio-data":{"identifier":"allow-send-audio-data","description":"Enables the send_audio_data command without any pre-configured scope.","commands":{"allow":["send_audio_data"],"deny":[]}},"allow-set-auth-bridge-enabled":{"identifier":"allow-set-auth-bridge-enabled","description":"Enables the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":["set_auth_bridge_enabled"],"deny":[]}},"allow-set-browser-view-enabled":{"identifier":"allow-set-browser-view-enabled","description":"Enables the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":["set_browser_view_enabled"],"deny":[]}},"allow-set-browser-view-match-window":{"identifier":"allow-set-browser-view-match-window","description":"Enables the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":["set_browser_view_match_window"],"deny":[]}},"allow-set-browser-view-popout-always-on-top":{"identifier":"allow-set-browser-view-popout-always-on-top","description":"Enables the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":["set_browser_view_popout_always_on_top"],"deny":[]}},"allow-set-container-page-viewport":{"identifier":"allow-set-container-page-viewport","description":"Enables the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":["set_container_page_viewport"],"deny":[]}},"allow-set-gateway-api-key":{"identifier":"allow-set-gateway-api-key","description":"Enables the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":["set_gateway_api_key"],"deny":[]}},"allow-set-global-item-disabled":{"identifier":"allow-set-global-item-disabled","description":"Enables the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":["set_global_item_disabled"],"deny":[]}},"allow-set-scheduled-task-enabled":{"identifier":"allow-set-scheduled-task-enabled","description":"Enables the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":["set_scheduled_task_enabled"],"deny":[]}},"allow-start-audio-bridge":{"identifier":"allow-start-audio-bridge","description":"Enables the start_audio_bridge command without any pre-configured scope.","commands":{"allow":["start_audio_bridge"],"deny":[]}},"allow-start-gateway":{"identifier":"allow-start-gateway","description":"Enables the start_gateway command without any pre-configured scope.","commands":{"allow":["start_gateway"],"deny":[]}},"allow-start-marketplace-gh-container-login":{"identifier":"allow-start-marketplace-gh-container-login","description":"Enables the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":["start_marketplace_gh_container_login"],"deny":[]}},"allow-start-project-container":{"identifier":"allow-start-project-container","description":"Enables the start_project_container command without any pre-configured scope.","commands":{"allow":["start_project_container"],"deny":[]}},"allow-start-stt":{"identifier":"allow-start-stt","description":"Enables the start_stt command without any pre-configured scope.","commands":{"allow":["start_stt"],"deny":[]}},"allow-start-web-terminal":{"identifier":"allow-start-web-terminal","description":"Enables the start_web_terminal command without any pre-configured scope.","commands":{"allow":["start_web_terminal"],"deny":[]}},"allow-stop-audio-bridge":{"identifier":"allow-stop-audio-bridge","description":"Enables the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":["stop_audio_bridge"],"deny":[]}},"allow-stop-gateway":{"identifier":"allow-stop-gateway","description":"Enables the stop_gateway command without any pre-configured scope.","commands":{"allow":["stop_gateway"],"deny":[]}},"allow-stop-project-container":{"identifier":"allow-stop-project-container","description":"Enables the stop_project_container command without any pre-configured scope.","commands":{"allow":["stop_project_container"],"deny":[]}},"allow-stop-stt":{"identifier":"allow-stop-stt","description":"Enables the stop_stt command without any pre-configured scope.","commands":{"allow":["stop_stt"],"deny":[]}},"allow-stop-web-terminal":{"identifier":"allow-stop-web-terminal","description":"Enables the stop_web_terminal command without any pre-configured scope.","commands":{"allow":["stop_web_terminal"],"deny":[]}},"allow-submit-claude-token-code":{"identifier":"allow-submit-claude-token-code","description":"Enables the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":["submit_claude_token_code"],"deny":[]}},"allow-sweep-claude-token-snapshots":{"identifier":"allow-sweep-claude-token-snapshots","description":"Enables the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":["sweep_claude_token_snapshots"],"deny":[]}},"allow-terminal-input":{"identifier":"allow-terminal-input","description":"Enables the terminal_input command without any pre-configured scope.","commands":{"allow":["terminal_input"],"deny":[]}},"allow-terminal-resize":{"identifier":"allow-terminal-resize","description":"Enables the terminal_resize command without any pre-configured scope.","commands":{"allow":["terminal_resize"],"deny":[]}},"allow-test-marketplace-account":{"identifier":"allow-test-marketplace-account","description":"Enables the test_marketplace_account command without any pre-configured scope.","commands":{"allow":["test_marketplace_account"],"deny":[]}},"allow-transcribe-audio":{"identifier":"allow-transcribe-audio","description":"Enables the transcribe_audio command without any pre-configured scope.","commands":{"allow":["transcribe_audio"],"deny":[]}},"allow-uninstall-marketplace-item":{"identifier":"allow-uninstall-marketplace-item","description":"Enables the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":["uninstall_marketplace_item"],"deny":[]}},"allow-unlock-aws-state":{"identifier":"allow-unlock-aws-state","description":"Enables the unlock_aws_state command without any pre-configured scope.","commands":{"allow":["unlock_aws_state"],"deny":[]}},"allow-update-aws-connection":{"identifier":"allow-update-aws-connection","description":"Enables the update_aws_connection command without any pre-configured scope.","commands":{"allow":["update_aws_connection"],"deny":[]}},"allow-update-aws-environment":{"identifier":"allow-update-aws-environment","description":"Enables the update_aws_environment command without any pre-configured scope.","commands":{"allow":["update_aws_environment"],"deny":[]}},"allow-update-marketplace":{"identifier":"allow-update-marketplace","description":"Enables the update_marketplace command without any pre-configured scope.","commands":{"allow":["update_marketplace"],"deny":[]}},"allow-update-marketplace-item":{"identifier":"allow-update-marketplace-item","description":"Enables the update_marketplace_item command without any pre-configured scope.","commands":{"allow":["update_marketplace_item"],"deny":[]}},"allow-update-project":{"identifier":"allow-update-project","description":"Enables the update_project command without any pre-configured scope.","commands":{"allow":["update_project"],"deny":[]}},"allow-update-scheduled-task":{"identifier":"allow-update-scheduled-task","description":"Enables the update_scheduled_task command without any pre-configured scope.","commands":{"allow":["update_scheduled_task"],"deny":[]}},"allow-update-settings":{"identifier":"allow-update-settings","description":"Enables the update_settings command without any pre-configured scope.","commands":{"allow":["update_settings"],"deny":[]}},"allow-upload-files-to-container":{"identifier":"allow-upload-files-to-container","description":"Enables the upload_files_to_container command without any pre-configured scope.","commands":{"allow":["upload_files_to_container"],"deny":[]}},"allow-upload-host-file-to-terminal":{"identifier":"allow-upload-host-file-to-terminal","description":"Enables the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":["upload_host_file_to_terminal"],"deny":[]}},"allow-viewer-choose-file":{"identifier":"allow-viewer-choose-file","description":"Enables the viewer_choose_file command without any pre-configured scope.","commands":{"allow":["viewer_choose_file"],"deny":[]}},"allow-viewer-get-state":{"identifier":"allow-viewer-get-state","description":"Enables the viewer_get_state command without any pre-configured scope.","commands":{"allow":["viewer_get_state"],"deny":[]}},"allow-viewer-poll-file":{"identifier":"allow-viewer-poll-file","description":"Enables the viewer_poll_file command without any pre-configured scope.","commands":{"allow":["viewer_poll_file"],"deny":[]}},"allow-viewer-read-file":{"identifier":"allow-viewer-read-file","description":"Enables the viewer_read_file command without any pre-configured scope.","commands":{"allow":["viewer_read_file"],"deny":[]}},"allow-viewer-write-file":{"identifier":"allow-viewer-write-file","description":"Enables the viewer_write_file command without any pre-configured scope.","commands":{"allow":["viewer_write_file"],"deny":[]}},"deny-acquire-claude-token":{"identifier":"deny-acquire-claude-token","description":"Denies the acquire_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["acquire_claude_token"]}},"deny-add-aws-keys-connection":{"identifier":"deny-add-aws-keys-connection","description":"Denies the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_keys_connection"]}},"deny-add-aws-profile-connection":{"identifier":"deny-add-aws-profile-connection","description":"Denies the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["add_aws_profile_connection"]}},"deny-add-marketplace":{"identifier":"deny-add-marketplace","description":"Denies the add_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace"]}},"deny-add-marketplace-gh-host-account":{"identifier":"deny-add-marketplace-gh-host-account","description":"Denies the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_gh_host_account"]}},"deny-add-marketplace-token-account":{"identifier":"deny-add-marketplace-token-account","description":"Denies the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":[],"deny":["add_marketplace_token_account"]}},"deny-add-project":{"identifier":"deny-add-project","description":"Denies the add_project command without any pre-configured scope.","commands":{"allow":[],"deny":["add_project"]}},"deny-add-scheduled-task":{"identifier":"deny-add-scheduled-task","description":"Denies the add_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["add_scheduled_task"]}},"deny-apply-marketplace-now":{"identifier":"deny-apply-marketplace-now","description":"Denies the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_marketplace_now"]}},"deny-apply-settings-import":{"identifier":"deny-apply-settings-import","description":"Denies the apply_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["apply_settings_import"]}},"deny-aws-connection-identity":{"identifier":"deny-aws-connection-identity","description":"Denies the aws_connection_identity command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_identity"]}},"deny-aws-connection-sso-login":{"identifier":"deny-aws-connection-sso-login","description":"Denies the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_connection_sso_login"]}},"deny-aws-policy-document":{"identifier":"deny-aws-policy-document","description":"Denies the aws_policy_document command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_policy_document"]}},"deny-aws-sso-refresh":{"identifier":"deny-aws-sso-refresh","description":"Denies the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":[],"deny":["aws_sso_refresh"]}},"deny-build-gateway-image":{"identifier":"deny-build-gateway-image","description":"Denies the build_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_gateway_image"]}},"deny-build-image":{"identifier":"deny-build-image","description":"Denies the build_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_image"]}},"deny-build-stt-image":{"identifier":"deny-build-stt-image","description":"Denies the build_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["build_stt_image"]}},"deny-cancel-aws-deployment":{"identifier":"deny-cancel-aws-deployment","description":"Denies the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_aws_deployment"]}},"deny-cancel-claude-token":{"identifier":"deny-cancel-claude-token","description":"Denies the cancel_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_claude_token"]}},"deny-cancel-marketplace-gh-login":{"identifier":"deny-cancel-marketplace-gh-login","description":"Denies the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel_marketplace_gh_login"]}},"deny-check-aws-permissions":{"identifier":"deny-check-aws-permissions","description":"Denies the check_aws_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_aws_permissions"]}},"deny-check-browser-view-support":{"identifier":"deny-check-browser-view-support","description":"Denies the check_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["check_browser_view_support"]}},"deny-check-docker":{"identifier":"deny-check-docker","description":"Denies the check_docker command without any pre-configured scope.","commands":{"allow":[],"deny":["check_docker"]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-check-gateway-health":{"identifier":"deny-check-gateway-health","description":"Denies the check_gateway_health command without any pre-configured scope.","commands":{"allow":[],"deny":["check_gateway_health"]}},"deny-check-image-exists":{"identifier":"deny-check-image-exists","description":"Denies the check_image_exists command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_exists"]}},"deny-check-image-update":{"identifier":"deny-check-image-update","description":"Denies the check_image_update command without any pre-configured scope.","commands":{"allow":[],"deny":["check_image_update"]}},"deny-clear-claude-token":{"identifier":"deny-clear-claude-token","description":"Denies the clear_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_claude_token"]}},"deny-clear-gateway-api-key":{"identifier":"deny-clear-gateway-api-key","description":"Denies the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_gateway_api_key"]}},"deny-clear-scheduler-notifications":{"identifier":"deny-clear-scheduler-notifications","description":"Denies the clear_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_scheduler_notifications"]}},"deny-close-browser-view-popout":{"identifier":"deny-close-browser-view-popout","description":"Denies the close_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["close_browser_view_popout"]}},"deny-close-container-page":{"identifier":"deny-close-container-page","description":"Denies the close_container_page command without any pre-configured scope.","commands":{"allow":[],"deny":["close_container_page"]}},"deny-close-terminal-session":{"identifier":"deny-close-terminal-session","description":"Denies the close_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["close_terminal_session"]}},"deny-confirm-migration":{"identifier":"deny-confirm-migration","description":"Denies the confirm_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm_migration"]}},"deny-create-container-directory":{"identifier":"deny-create-container-directory","description":"Denies the create_container_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["create_container_directory"]}},"deny-delete-note":{"identifier":"deny-delete-note","description":"Denies the delete_note command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_note"]}},"deny-deploy-aws-environment":{"identifier":"deny-deploy-aws-environment","description":"Denies the deploy_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_environment"]}},"deny-deploy-aws-permissions-boundary":{"identifier":"deny-deploy-aws-permissions-boundary","description":"Denies the deploy_aws_permissions_boundary command without any pre-configured scope.","commands":{"allow":[],"deny":["deploy_aws_permissions_boundary"]}},"deny-destroy-incomplete-aws-environment":{"identifier":"deny-destroy-incomplete-aws-environment","description":"Denies the destroy_incomplete_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy_incomplete_aws_environment"]}},"deny-detect-aws-config":{"identifier":"deny-detect-aws-config","description":"Denies the detect_aws_config command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_config"]}},"deny-detect-aws-environments":{"identifier":"deny-detect-aws-environments","description":"Denies the detect_aws_environments command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_aws_environments"]}},"deny-detect-host-timezone":{"identifier":"deny-detect-host-timezone","description":"Denies the detect_host_timezone command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_host_timezone"]}},"deny-detect-install-options":{"identifier":"deny-detect-install-options","description":"Denies the detect_install_options command without any pre-configured scope.","commands":{"allow":[],"deny":["detect_install_options"]}},"deny-download-container-backup":{"identifier":"deny-download-container-backup","description":"Denies the download_container_backup command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_backup"]}},"deny-download-container-file":{"identifier":"deny-download-container-file","description":"Denies the download_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["download_container_file"]}},"deny-export-settings":{"identifier":"deny-export-settings","description":"Denies the export_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["export_settings"]}},"deny-follow-aws-deployment":{"identifier":"deny-follow-aws-deployment","description":"Denies the follow_aws_deployment command without any pre-configured scope.","commands":{"allow":[],"deny":["follow_aws_deployment"]}},"deny-forget-marketplace-installs":{"identifier":"deny-forget-marketplace-installs","description":"Denies the forget_marketplace_installs command without any pre-configured scope.","commands":{"allow":[],"deny":["forget_marketplace_installs"]}},"deny-get-app-version":{"identifier":"deny-get-app-version","description":"Denies the get_app_version command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_version"]}},"deny-get-auth-bridge-status":{"identifier":"deny-get-auth-bridge-status","description":"Denies the get_auth_bridge_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_auth_bridge_status"]}},"deny-get-browser-view-match-window":{"identifier":"deny-get-browser-view-match-window","description":"Denies the get_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_match_window"]}},"deny-get-browser-view-popout-state":{"identifier":"deny-get-browser-view-popout-state","description":"Denies the get_browser_view_popout_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_popout_state"]}},"deny-get-browser-view-status":{"identifier":"deny-get-browser-view-status","description":"Denies the get_browser_view_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_browser_view_status"]}},"deny-get-container-info":{"identifier":"deny-get-container-info","description":"Denies the get_container_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_info"]}},"deny-get-container-page-state":{"identifier":"deny-get-container-page-state","description":"Denies the get_container_page_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_page_state"]}},"deny-get-container-staleness":{"identifier":"deny-get-container-staleness","description":"Denies the get_container_staleness command without any pre-configured scope.","commands":{"allow":[],"deny":["get_container_staleness"]}},"deny-get-gateway-auth-token":{"identifier":"deny-get-gateway-auth-token","description":"Denies the get_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_auth_token"]}},"deny-get-gateway-status":{"identifier":"deny-get-gateway-status","description":"Denies the get_gateway_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_gateway_status"]}},"deny-get-help-content":{"identifier":"deny-get-help-content","description":"Denies the get_help_content command without any pre-configured scope.","commands":{"allow":[],"deny":["get_help_content"]}},"deny-get-marketplace-sync-report":{"identifier":"deny-get-marketplace-sync-report","description":"Denies the get_marketplace_sync_report command without any pre-configured scope.","commands":{"allow":[],"deny":["get_marketplace_sync_report"]}},"deny-get-migration-state":{"identifier":"deny-get-migration-state","description":"Denies the get_migration_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_migration_state"]}},"deny-get-scheduled-task-log":{"identifier":"deny-get-scheduled-task-log","description":"Denies the get_scheduled_task_log command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduled_task_log"]}},"deny-get-scheduler-notifications":{"identifier":"deny-get-scheduler-notifications","description":"Denies the get_scheduler_notifications command without any pre-configured scope.","commands":{"allow":[],"deny":["get_scheduler_notifications"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-get-stt-status":{"identifier":"deny-get-stt-status","description":"Denies the get_stt_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_stt_status"]}},"deny-get-web-terminal-status":{"identifier":"deny-get-web-terminal-status","description":"Denies the get_web_terminal_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_web_terminal_status"]}},"deny-has-claude-token":{"identifier":"deny-has-claude-token","description":"Denies the has_claude_token command without any pre-configured scope.","commands":{"allow":[],"deny":["has_claude_token"]}},"deny-inspect-ca-cert-path":{"identifier":"deny-inspect-ca-cert-path","description":"Denies the inspect_ca_cert_path command without any pre-configured scope.","commands":{"allow":[],"deny":["inspect_ca_cert_path"]}},"deny-install-browser-view-browser":{"identifier":"deny-install-browser-view-browser","description":"Denies the install_browser_view_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_browser"]}},"deny-install-browser-view-support":{"identifier":"deny-install-browser-view-support","description":"Denies the install_browser_view_support command without any pre-configured scope.","commands":{"allow":[],"deny":["install_browser_view_support"]}},"deny-install-marketplace-item":{"identifier":"deny-install-marketplace-item","description":"Denies the install_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["install_marketplace_item"]}},"deny-list-aws-profiles":{"identifier":"deny-list-aws-profiles","description":"Denies the list_aws_profiles command without any pre-configured scope.","commands":{"allow":[],"deny":["list_aws_profiles"]}},"deny-list-claude-sessions":{"identifier":"deny-list-claude-sessions","description":"Denies the list_claude_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["list_claude_sessions"]}},"deny-list-container-capabilities":{"identifier":"deny-list-container-capabilities","description":"Denies the list_container_capabilities command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_capabilities"]}},"deny-list-container-files":{"identifier":"deny-list-container-files","description":"Denies the list_container_files command without any pre-configured scope.","commands":{"allow":[],"deny":["list_container_files"]}},"deny-list-marketplace-snapshots":{"identifier":"deny-list-marketplace-snapshots","description":"Denies the list_marketplace_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_snapshots"]}},"deny-list-marketplace-updates":{"identifier":"deny-list-marketplace-updates","description":"Denies the list_marketplace_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["list_marketplace_updates"]}},"deny-list-notes":{"identifier":"deny-list-notes","description":"Denies the list_notes command without any pre-configured scope.","commands":{"allow":[],"deny":["list_notes"]}},"deny-list-projects":{"identifier":"deny-list-projects","description":"Denies the list_projects command without any pre-configured scope.","commands":{"allow":[],"deny":["list_projects"]}},"deny-list-scheduled-tasks":{"identifier":"deny-list-scheduled-tasks","description":"Denies the list_scheduled_tasks command without any pre-configured scope.","commands":{"allow":[],"deny":["list_scheduled_tasks"]}},"deny-marketplace-gh-host-available":{"identifier":"deny-marketplace-gh-host-available","description":"Denies the marketplace_gh_host_available command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_gh_host_available"]}},"deny-marketplace-item-diff":{"identifier":"deny-marketplace-item-diff","description":"Denies the marketplace_item_diff command without any pre-configured scope.","commands":{"allow":[],"deny":["marketplace_item_diff"]}},"deny-migrate-project-to-base":{"identifier":"deny-migrate-project-to-base","description":"Denies the migrate_project_to_base command without any pre-configured scope.","commands":{"allow":[],"deny":["migrate_project_to_base"]}},"deny-onboard-aws-user":{"identifier":"deny-onboard-aws-user","description":"Denies the onboard_aws_user command without any pre-configured scope.","commands":{"allow":[],"deny":["onboard_aws_user"]}},"deny-open-browser-view-popout":{"identifier":"deny-open-browser-view-popout","description":"Denies the open_browser_view_popout command without any pre-configured scope.","commands":{"allow":[],"deny":["open_browser_view_popout"]}},"deny-open-file-viewer":{"identifier":"deny-open-file-viewer","description":"Denies the open_file_viewer command without any pre-configured scope.","commands":{"allow":[],"deny":["open_file_viewer"]}},"deny-open-page-in-container-browser":{"identifier":"deny-open-page-in-container-browser","description":"Denies the open_page_in_container_browser command without any pre-configured scope.","commands":{"allow":[],"deny":["open_page_in_container_browser"]}},"deny-open-terminal-session":{"identifier":"deny-open-terminal-session","description":"Denies the open_terminal_session command without any pre-configured scope.","commands":{"allow":[],"deny":["open_terminal_session"]}},"deny-open-url-external":{"identifier":"deny-open-url-external","description":"Denies the open_url_external command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url_external"]}},"deny-paste-image-to-terminal":{"identifier":"deny-paste-image-to-terminal","description":"Denies the paste_image_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["paste_image_to_terminal"]}},"deny-preview-settings-import":{"identifier":"deny-preview-settings-import","description":"Denies the preview_settings_import command without any pre-configured scope.","commands":{"allow":[],"deny":["preview_settings_import"]}},"deny-pull-gateway-image":{"identifier":"deny-pull-gateway-image","description":"Denies the pull_gateway_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_gateway_image"]}},"deny-pull-image":{"identifier":"deny-pull-image","description":"Denies the pull_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_image"]}},"deny-pull-stt-image":{"identifier":"deny-pull-stt-image","description":"Denies the pull_stt_image command without any pre-configured scope.","commands":{"allow":[],"deny":["pull_stt_image"]}},"deny-push-aws-saved-state":{"identifier":"deny-push-aws-saved-state","description":"Denies the push_aws_saved_state command without any pre-configured scope.","commands":{"allow":[],"deny":["push_aws_saved_state"]}},"deny-read-container-file":{"identifier":"deny-read-container-file","description":"Denies the read_container_file command without any pre-configured scope.","commands":{"allow":[],"deny":["read_container_file"]}},"deny-rebuild-project-container":{"identifier":"deny-rebuild-project-container","description":"Denies the rebuild_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["rebuild_project_container"]}},"deny-reconcile-project-statuses":{"identifier":"deny-reconcile-project-statuses","description":"Denies the reconcile_project_statuses command without any pre-configured scope.","commands":{"allow":[],"deny":["reconcile_project_statuses"]}},"deny-refresh-marketplaces":{"identifier":"deny-refresh-marketplaces","description":"Denies the refresh_marketplaces command without any pre-configured scope.","commands":{"allow":[],"deny":["refresh_marketplaces"]}},"deny-regenerate-gateway-auth-token":{"identifier":"deny-regenerate-gateway-auth-token","description":"Denies the regenerate_gateway_auth_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_gateway_auth_token"]}},"deny-regenerate-web-terminal-token":{"identifier":"deny-regenerate-web-terminal-token","description":"Denies the regenerate_web_terminal_token command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_web_terminal_token"]}},"deny-remove-host-connection":{"identifier":"deny-remove-host-connection","description":"Denies the remove_host_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_host_connection"]}},"deny-remove-marketplace":{"identifier":"deny-remove-marketplace","description":"Denies the remove_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace"]}},"deny-remove-marketplace-account":{"identifier":"deny-remove-marketplace-account","description":"Denies the remove_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_marketplace_account"]}},"deny-remove-project":{"identifier":"deny-remove-project","description":"Denies the remove_project command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_project"]}},"deny-remove-scheduled-task":{"identifier":"deny-remove-scheduled-task","description":"Denies the remove_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_scheduled_task"]}},"deny-rename-container-path":{"identifier":"deny-rename-container-path","description":"Denies the rename_container_path command without any pre-configured scope.","commands":{"allow":[],"deny":["rename_container_path"]}},"deny-repair-aws-environment":{"identifier":"deny-repair-aws-environment","description":"Denies the repair_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["repair_aws_environment"]}},"deny-rollback-migration":{"identifier":"deny-rollback-migration","description":"Denies the rollback_migration command without any pre-configured scope.","commands":{"allow":[],"deny":["rollback_migration"]}},"deny-run-docker-install":{"identifier":"deny-run-docker-install","description":"Denies the run_docker_install command without any pre-configured scope.","commands":{"allow":[],"deny":["run_docker_install"]}},"deny-run-scheduled-task-now":{"identifier":"deny-run-scheduled-task-now","description":"Denies the run_scheduled_task_now command without any pre-configured scope.","commands":{"allow":[],"deny":["run_scheduled_task_now"]}},"deny-save-aws-admin-bundle":{"identifier":"deny-save-aws-admin-bundle","description":"Denies the save_aws_admin_bundle command without any pre-configured scope.","commands":{"allow":[],"deny":["save_aws_admin_bundle"]}},"deny-save-note":{"identifier":"deny-save-note","description":"Denies the save_note command without any pre-configured scope.","commands":{"allow":[],"deny":["save_note"]}},"deny-send-audio-data":{"identifier":"deny-send-audio-data","description":"Denies the send_audio_data command without any pre-configured scope.","commands":{"allow":[],"deny":["send_audio_data"]}},"deny-set-auth-bridge-enabled":{"identifier":"deny-set-auth-bridge-enabled","description":"Denies the set_auth_bridge_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_auth_bridge_enabled"]}},"deny-set-browser-view-enabled":{"identifier":"deny-set-browser-view-enabled","description":"Denies the set_browser_view_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_enabled"]}},"deny-set-browser-view-match-window":{"identifier":"deny-set-browser-view-match-window","description":"Denies the set_browser_view_match_window command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_match_window"]}},"deny-set-browser-view-popout-always-on-top":{"identifier":"deny-set-browser-view-popout-always-on-top","description":"Denies the set_browser_view_popout_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_browser_view_popout_always_on_top"]}},"deny-set-container-page-viewport":{"identifier":"deny-set-container-page-viewport","description":"Denies the set_container_page_viewport command without any pre-configured scope.","commands":{"allow":[],"deny":["set_container_page_viewport"]}},"deny-set-gateway-api-key":{"identifier":"deny-set-gateway-api-key","description":"Denies the set_gateway_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["set_gateway_api_key"]}},"deny-set-global-item-disabled":{"identifier":"deny-set-global-item-disabled","description":"Denies the set_global_item_disabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_global_item_disabled"]}},"deny-set-scheduled-task-enabled":{"identifier":"deny-set-scheduled-task-enabled","description":"Denies the set_scheduled_task_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_scheduled_task_enabled"]}},"deny-start-audio-bridge":{"identifier":"deny-start-audio-bridge","description":"Denies the start_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["start_audio_bridge"]}},"deny-start-gateway":{"identifier":"deny-start-gateway","description":"Denies the start_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["start_gateway"]}},"deny-start-marketplace-gh-container-login":{"identifier":"deny-start-marketplace-gh-container-login","description":"Denies the start_marketplace_gh_container_login command without any pre-configured scope.","commands":{"allow":[],"deny":["start_marketplace_gh_container_login"]}},"deny-start-project-container":{"identifier":"deny-start-project-container","description":"Denies the start_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["start_project_container"]}},"deny-start-stt":{"identifier":"deny-start-stt","description":"Denies the start_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["start_stt"]}},"deny-start-web-terminal":{"identifier":"deny-start-web-terminal","description":"Denies the start_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["start_web_terminal"]}},"deny-stop-audio-bridge":{"identifier":"deny-stop-audio-bridge","description":"Denies the stop_audio_bridge command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_audio_bridge"]}},"deny-stop-gateway":{"identifier":"deny-stop-gateway","description":"Denies the stop_gateway command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_gateway"]}},"deny-stop-project-container":{"identifier":"deny-stop-project-container","description":"Denies the stop_project_container command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_project_container"]}},"deny-stop-stt":{"identifier":"deny-stop-stt","description":"Denies the stop_stt command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_stt"]}},"deny-stop-web-terminal":{"identifier":"deny-stop-web-terminal","description":"Denies the stop_web_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_web_terminal"]}},"deny-submit-claude-token-code":{"identifier":"deny-submit-claude-token-code","description":"Denies the submit_claude_token_code command without any pre-configured scope.","commands":{"allow":[],"deny":["submit_claude_token_code"]}},"deny-sweep-claude-token-snapshots":{"identifier":"deny-sweep-claude-token-snapshots","description":"Denies the sweep_claude_token_snapshots command without any pre-configured scope.","commands":{"allow":[],"deny":["sweep_claude_token_snapshots"]}},"deny-terminal-input":{"identifier":"deny-terminal-input","description":"Denies the terminal_input command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_input"]}},"deny-terminal-resize":{"identifier":"deny-terminal-resize","description":"Denies the terminal_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_resize"]}},"deny-test-marketplace-account":{"identifier":"deny-test-marketplace-account","description":"Denies the test_marketplace_account command without any pre-configured scope.","commands":{"allow":[],"deny":["test_marketplace_account"]}},"deny-transcribe-audio":{"identifier":"deny-transcribe-audio","description":"Denies the transcribe_audio command without any pre-configured scope.","commands":{"allow":[],"deny":["transcribe_audio"]}},"deny-uninstall-marketplace-item":{"identifier":"deny-uninstall-marketplace-item","description":"Denies the uninstall_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["uninstall_marketplace_item"]}},"deny-unlock-aws-state":{"identifier":"deny-unlock-aws-state","description":"Denies the unlock_aws_state command without any pre-configured scope.","commands":{"allow":[],"deny":["unlock_aws_state"]}},"deny-update-aws-connection":{"identifier":"deny-update-aws-connection","description":"Denies the update_aws_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_connection"]}},"deny-update-aws-environment":{"identifier":"deny-update-aws-environment","description":"Denies the update_aws_environment command without any pre-configured scope.","commands":{"allow":[],"deny":["update_aws_environment"]}},"deny-update-marketplace":{"identifier":"deny-update-marketplace","description":"Denies the update_marketplace command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace"]}},"deny-update-marketplace-item":{"identifier":"deny-update-marketplace-item","description":"Denies the update_marketplace_item command without any pre-configured scope.","commands":{"allow":[],"deny":["update_marketplace_item"]}},"deny-update-project":{"identifier":"deny-update-project","description":"Denies the update_project command without any pre-configured scope.","commands":{"allow":[],"deny":["update_project"]}},"deny-update-scheduled-task":{"identifier":"deny-update-scheduled-task","description":"Denies the update_scheduled_task command without any pre-configured scope.","commands":{"allow":[],"deny":["update_scheduled_task"]}},"deny-update-settings":{"identifier":"deny-update-settings","description":"Denies the update_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["update_settings"]}},"deny-upload-files-to-container":{"identifier":"deny-upload-files-to-container","description":"Denies the upload_files_to_container command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_files_to_container"]}},"deny-upload-host-file-to-terminal":{"identifier":"deny-upload-host-file-to-terminal","description":"Denies the upload_host_file_to_terminal command without any pre-configured scope.","commands":{"allow":[],"deny":["upload_host_file_to_terminal"]}},"deny-viewer-choose-file":{"identifier":"deny-viewer-choose-file","description":"Denies the viewer_choose_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_choose_file"]}},"deny-viewer-get-state":{"identifier":"deny-viewer-get-state","description":"Denies the viewer_get_state command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_get_state"]}},"deny-viewer-poll-file":{"identifier":"deny-viewer-poll-file","description":"Denies the viewer_poll_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_poll_file"]}},"deny-viewer-read-file":{"identifier":"deny-viewer-read-file","description":"Denies the viewer_read_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_read_file"]}},"deny-viewer-write-file":{"identifier":"deny-viewer-write-file","description":"Denies the viewer_write_file command without any pre-configured scope.","commands":{"allow":[],"deny":["viewer_write_file"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/capabilities.json b/app/src-tauri/gen/schemas/capabilities.json index 721e428..951b16e 100644 --- a/app/src-tauri/gen/schemas/capabilities.json +++ b/app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate field and Backup. Two commands still take a host path over IPC as a string — the terminal drop and `download_container_backup` — and for those `validate_host_path` is the boundary rather than defence in depth. This file is the reviewed threat model of record, so keep this census accurate: a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). A second capability file, `file-viewer.json`, covers the `file-viewer-*` windows the terminal file viewer opens on `viewer.html`; it is the only other local-origin window, its grants are listed and justified there, and its one non-obvious grant (`core:window:allow-destroy`) exists because `onCloseRequested` cannot close a window without it. App commands are gated by this file too. `build.rs` declares a Tauri `AppManifest` listing every command in `generate_handler!`, which is what makes tauri 2.11.0 apply the ACL to app commands at all (`webview/mod.rs:1794` skips it when no app manifest exists), and the bare `allow-` entries below are the complete list of app commands the main window may call. `build.rs` refuses to build unless every registered command has exactly one such grant, in the file whose `windows` its name says it belongs to (`viewer_*` in `file-viewer.json`, everything else here), and unless every bare entry names a registered command — so a forgotten, misspelled, duplicated or misfiled grant is a failed `cargo check`, not a feature that dies at runtime with `not allowed by ACL`. `deny-*` is banned by the same check: in tauri 2.11.0 a deny matches regardless of window or origin, so a deny meant for the viewer would deny main too. Hand-written files under `permissions/` are refused for the same reason — they would be grants this census cannot see. Because the census can only vouch for what it reads, `build.rs` also refuses any capability it did not check: anything in `capabilities/` other than a top-level `*.json` file (tauri also loads `.toml`/`.json5` there, and subdirectories), a `webviews` or `remote` key in a capability file (either would extend grants beyond what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`, any `tauri..conf.json`, or `TAURI_CONFIG`, and a tauri config in a format the census cannot parse (JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, editor swap files) is recognised and skipped in `capabilities/` and `permissions/` rather than refused, since tauri never loads it either. Each failure names the check that failed (\"stray entry in capabilities/\", \"capabilities declared outside capabilities/\", \"hand-written permission\", …) rather than always reading as a grant/handler mismatch. Known gap: adding a new `tauri..conf.json` to a tree that has already been built once only takes effect on a clean build or in CI — cargo's incremental build has no reason to notice a file that did not exist on the previous build. The `*marketplace*` commands fetch user-configured https git repos on the host and push pinned files into containers; account tokens stay in the OS keychain and never cross IPC outward — the only inbound one is the token pasted into `add_marketplace_token_account`. The pop-out stays capability-less. The Rust label gates in `commands/file_viewer_commands.rs` remain, because the ACL says *which* window may call a command and the label says *whose* registry entry it acts on; they are not redundant. The rules live in `src/command_census.rs`, which is unit-tested, and `src/test/capabilities.test.ts` checks the other direction: that the code that runs in each window imports only the wrappers that window is granted. On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save","allow-check-docker","allow-check-image-exists","allow-build-image","allow-get-container-info","allow-list-projects","allow-add-project","allow-remove-project","allow-update-project","allow-start-project-container","allow-stop-project-container","allow-rebuild-project-container","allow-reconcile-project-statuses","allow-list-notes","allow-save-note","allow-delete-note","allow-get-container-staleness","allow-migrate-project-to-base","allow-confirm-migration","allow-rollback-migration","allow-get-migration-state","allow-set-auth-bridge-enabled","allow-get-auth-bridge-status","allow-set-browser-view-enabled","allow-get-browser-view-status","allow-check-browser-view-support","allow-install-browser-view-support","allow-install-browser-view-browser","allow-open-browser-view-popout","allow-close-browser-view-popout","allow-get-browser-view-popout-state","allow-set-browser-view-popout-always-on-top","allow-open-page-in-container-browser","allow-set-container-page-viewport","allow-get-container-page-state","allow-close-container-page","allow-set-browser-view-match-window","allow-get-browser-view-match-window","allow-acquire-claude-token","allow-submit-claude-token-code","allow-cancel-claude-token","allow-has-claude-token","allow-clear-claude-token","allow-sweep-claude-token-snapshots","allow-get-settings","allow-update-settings","allow-pull-image","allow-detect-aws-config","allow-inspect-ca-cert-path","allow-list-aws-profiles","allow-add-aws-profile-connection","allow-add-aws-keys-connection","allow-update-aws-connection","allow-remove-host-connection","allow-aws-connection-identity","allow-aws-connection-sso-login","allow-check-aws-permissions","allow-aws-policy-document","allow-detect-aws-environments","allow-deploy-aws-environment","allow-update-aws-environment","allow-repair-aws-environment","allow-destroy-incomplete-aws-environment","allow-unlock-aws-state","allow-follow-aws-deployment","allow-cancel-aws-deployment","allow-onboard-aws-user","allow-deploy-aws-permissions-boundary","allow-save-aws-admin-bundle","allow-detect-host-timezone","allow-export-settings","allow-preview-settings-import","allow-apply-settings-import","allow-open-terminal-session","allow-terminal-input","allow-terminal-resize","allow-close-terminal-session","allow-paste-image-to-terminal","allow-upload-host-file-to-terminal","allow-start-audio-bridge","allow-send-audio-data","allow-stop-audio-bridge","allow-list-container-files","allow-download-container-backup","allow-download-container-file","allow-upload-files-to-container","allow-read-container-file","allow-rename-container-path","allow-create-container-directory","allow-open-file-viewer","allow-aws-sso-refresh","allow-get-app-version","allow-check-for-updates","allow-check-image-update","allow-get-help-content","allow-open-url-external","allow-detect-install-options","allow-run-docker-install","allow-start-web-terminal","allow-stop-web-terminal","allow-get-web-terminal-status","allow-regenerate-web-terminal-token","allow-get-stt-status","allow-start-stt","allow-stop-stt","allow-build-stt-image","allow-pull-stt-image","allow-transcribe-audio","allow-get-gateway-status","allow-start-gateway","allow-stop-gateway","allow-check-gateway-health","allow-build-gateway-image","allow-pull-gateway-image","allow-set-gateway-api-key","allow-clear-gateway-api-key","allow-get-gateway-auth-token","allow-regenerate-gateway-auth-token","allow-list-claude-sessions","allow-list-container-capabilities","allow-list-scheduled-tasks","allow-add-scheduled-task","allow-update-scheduled-task","allow-get-scheduled-task-log","allow-set-scheduled-task-enabled","allow-run-scheduled-task-now","allow-remove-scheduled-task","allow-get-scheduler-notifications","allow-clear-scheduler-notifications","allow-list-marketplace-snapshots","allow-refresh-marketplaces","allow-add-marketplace","allow-update-marketplace","allow-remove-marketplace","allow-install-marketplace-item","allow-uninstall-marketplace-item","allow-set-global-item-disabled","allow-forget-marketplace-installs","allow-list-marketplace-updates","allow-marketplace-item-diff","allow-update-marketplace-item","allow-apply-marketplace-now","allow-get-marketplace-sync-report","allow-add-marketplace-token-account","allow-add-marketplace-gh-host-account","allow-start-marketplace-gh-container-login","allow-cancel-marketplace-gh-login","allow-test-marketplace-account","allow-remove-marketplace-account","allow-marketplace-gh-host-available"]},"file-viewer":{"identifier":"file-viewer","description":"The terminal file viewer windows (`file-viewer-`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.","local":true,"windows":["file-viewer-*"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:window:allow-destroy","core:webview:allow-internal-toggle-devtools","allow-viewer-get-state","allow-viewer-choose-file","allow-viewer-read-file","allow-viewer-poll-file","allow-viewer-write-file"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate field and Backup. Two commands still take a host path over IPC as a string — the terminal drop and `download_container_backup` — and for those `validate_host_path` is the boundary rather than defence in depth. This file is the reviewed threat model of record, so keep this census accurate: a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). A second capability file, `file-viewer.json`, covers the `file-viewer-*` windows the terminal file viewer opens on `viewer.html`; it is the only other local-origin window, its grants are listed and justified there, and its one non-obvious grant (`core:window:allow-destroy`) exists because `onCloseRequested` cannot close a window without it. App commands are gated by this file too. `build.rs` declares a Tauri `AppManifest` listing every command in `generate_handler!`, which is what makes tauri 2.11.0 apply the ACL to app commands at all (`webview/mod.rs:1794` skips it when no app manifest exists), and the bare `allow-` entries below are the complete list of app commands the main window may call. `build.rs` refuses to build unless every registered command has exactly one such grant, in the file whose `windows` its name says it belongs to (`viewer_*` in `file-viewer.json`, everything else here), and unless every bare entry names a registered command — so a forgotten, misspelled, duplicated or misfiled grant is a failed `cargo check`, not a feature that dies at runtime with `not allowed by ACL`. `deny-*` is banned by the same check: in tauri 2.11.0 a deny matches regardless of window or origin, so a deny meant for the viewer would deny main too. Hand-written files under `permissions/` are refused for the same reason — they would be grants this census cannot see. Because the census can only vouch for what it reads, `build.rs` also refuses any capability it did not check: anything in `capabilities/` other than a top-level `*.json` file (tauri also loads `.toml`/`.json5` there, and subdirectories), a `webviews` or `remote` key in a capability file (either would extend grants beyond what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`, any `tauri..conf.json`, or `TAURI_CONFIG`, and a tauri config in a format the census cannot parse (JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, editor swap files) is recognised and skipped in `capabilities/` and `permissions/` rather than refused, since tauri never loads it either. Each failure names the check that failed (\"stray entry in capabilities/\", \"capabilities declared outside capabilities/\", \"hand-written permission\", …) rather than always reading as a grant/handler mismatch. Known gap: adding a new `tauri..conf.json` to a tree that has already been built once only takes effect on a clean build or in CI — cargo's incremental build has no reason to notice a file that did not exist on the previous build. The `*marketplace*` commands fetch user-configured https git repos on the host and push pinned files into containers; account tokens stay in the OS keychain and never cross IPC outward — the only inbound one is the token pasted into `add_marketplace_token_account`. The pop-out stays capability-less. The Rust label gates in `commands/file_viewer_commands.rs` remain, because the ACL says *which* window may call a command and the label says *whose* registry entry it acts on; they are not redundant. The rules live in `src/command_census.rs`, which is unit-tested, and `src/test/capabilities.test.ts` checks the other direction: that the code that runs in each window imports only the wrappers that window is granted. On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save","allow-check-docker","allow-check-image-exists","allow-build-image","allow-get-container-info","allow-list-projects","allow-add-project","allow-remove-project","allow-update-project","allow-start-project-container","allow-stop-project-container","allow-rebuild-project-container","allow-reconcile-project-statuses","allow-list-notes","allow-save-note","allow-delete-note","allow-get-container-staleness","allow-migrate-project-to-base","allow-confirm-migration","allow-rollback-migration","allow-get-migration-state","allow-set-auth-bridge-enabled","allow-get-auth-bridge-status","allow-set-browser-view-enabled","allow-get-browser-view-status","allow-check-browser-view-support","allow-install-browser-view-support","allow-install-browser-view-browser","allow-open-browser-view-popout","allow-close-browser-view-popout","allow-get-browser-view-popout-state","allow-set-browser-view-popout-always-on-top","allow-open-page-in-container-browser","allow-set-container-page-viewport","allow-get-container-page-state","allow-close-container-page","allow-set-browser-view-match-window","allow-get-browser-view-match-window","allow-acquire-claude-token","allow-submit-claude-token-code","allow-cancel-claude-token","allow-has-claude-token","allow-clear-claude-token","allow-sweep-claude-token-snapshots","allow-get-settings","allow-update-settings","allow-pull-image","allow-detect-aws-config","allow-inspect-ca-cert-path","allow-list-aws-profiles","allow-add-aws-profile-connection","allow-add-aws-keys-connection","allow-update-aws-connection","allow-remove-host-connection","allow-aws-connection-identity","allow-aws-connection-sso-login","allow-check-aws-permissions","allow-aws-policy-document","allow-detect-aws-environments","allow-deploy-aws-environment","allow-update-aws-environment","allow-repair-aws-environment","allow-destroy-incomplete-aws-environment","allow-unlock-aws-state","allow-follow-aws-deployment","allow-cancel-aws-deployment","allow-push-aws-saved-state","allow-onboard-aws-user","allow-deploy-aws-permissions-boundary","allow-save-aws-admin-bundle","allow-detect-host-timezone","allow-export-settings","allow-preview-settings-import","allow-apply-settings-import","allow-open-terminal-session","allow-terminal-input","allow-terminal-resize","allow-close-terminal-session","allow-paste-image-to-terminal","allow-upload-host-file-to-terminal","allow-start-audio-bridge","allow-send-audio-data","allow-stop-audio-bridge","allow-list-container-files","allow-download-container-backup","allow-download-container-file","allow-upload-files-to-container","allow-read-container-file","allow-rename-container-path","allow-create-container-directory","allow-open-file-viewer","allow-aws-sso-refresh","allow-get-app-version","allow-check-for-updates","allow-check-image-update","allow-get-help-content","allow-open-url-external","allow-detect-install-options","allow-run-docker-install","allow-start-web-terminal","allow-stop-web-terminal","allow-get-web-terminal-status","allow-regenerate-web-terminal-token","allow-get-stt-status","allow-start-stt","allow-stop-stt","allow-build-stt-image","allow-pull-stt-image","allow-transcribe-audio","allow-get-gateway-status","allow-start-gateway","allow-stop-gateway","allow-check-gateway-health","allow-build-gateway-image","allow-pull-gateway-image","allow-set-gateway-api-key","allow-clear-gateway-api-key","allow-get-gateway-auth-token","allow-regenerate-gateway-auth-token","allow-list-claude-sessions","allow-list-container-capabilities","allow-list-scheduled-tasks","allow-add-scheduled-task","allow-update-scheduled-task","allow-get-scheduled-task-log","allow-set-scheduled-task-enabled","allow-run-scheduled-task-now","allow-remove-scheduled-task","allow-get-scheduler-notifications","allow-clear-scheduler-notifications","allow-list-marketplace-snapshots","allow-refresh-marketplaces","allow-add-marketplace","allow-update-marketplace","allow-remove-marketplace","allow-install-marketplace-item","allow-uninstall-marketplace-item","allow-set-global-item-disabled","allow-forget-marketplace-installs","allow-list-marketplace-updates","allow-marketplace-item-diff","allow-update-marketplace-item","allow-apply-marketplace-now","allow-get-marketplace-sync-report","allow-add-marketplace-token-account","allow-add-marketplace-gh-host-account","allow-start-marketplace-gh-container-login","allow-cancel-marketplace-gh-login","allow-test-marketplace-account","allow-remove-marketplace-account","allow-marketplace-gh-host-available"]},"file-viewer":{"identifier":"file-viewer","description":"The terminal file viewer windows (`file-viewer-`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.","local":true,"windows":["file-viewer-*"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:window:allow-destroy","core:webview:allow-internal-toggle-devtools","allow-viewer-get-state","allow-viewer-choose-file","allow-viewer-read-file","allow-viewer-poll-file","allow-viewer-write-file"]}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/desktop-schema.json b/app/src-tauri/gen/schemas/desktop-schema.json index 048f82a..c957e8e 100644 --- a/app/src-tauri/gen/schemas/desktop-schema.json +++ b/app/src-tauri/gen/schemas/desktop-schema.json @@ -908,6 +908,12 @@ "const": "allow-pull-stt-image", "markdownDescription": "Enables the pull_stt_image command without any pre-configured scope." }, + { + "description": "Enables the push_aws_saved_state command without any pre-configured scope.", + "type": "string", + "const": "allow-push-aws-saved-state", + "markdownDescription": "Enables the push_aws_saved_state command without any pre-configured scope." + }, { "description": "Enables the read_container_file command without any pre-configured scope.", "type": "string", @@ -1832,6 +1838,12 @@ "const": "deny-pull-stt-image", "markdownDescription": "Denies the pull_stt_image command without any pre-configured scope." }, + { + "description": "Denies the push_aws_saved_state command without any pre-configured scope.", + "type": "string", + "const": "deny-push-aws-saved-state", + "markdownDescription": "Denies the push_aws_saved_state command without any pre-configured scope." + }, { "description": "Denies the read_container_file command without any pre-configured scope.", "type": "string", diff --git a/app/src-tauri/gen/schemas/linux-schema.json b/app/src-tauri/gen/schemas/linux-schema.json index 048f82a..c957e8e 100644 --- a/app/src-tauri/gen/schemas/linux-schema.json +++ b/app/src-tauri/gen/schemas/linux-schema.json @@ -908,6 +908,12 @@ "const": "allow-pull-stt-image", "markdownDescription": "Enables the pull_stt_image command without any pre-configured scope." }, + { + "description": "Enables the push_aws_saved_state command without any pre-configured scope.", + "type": "string", + "const": "allow-push-aws-saved-state", + "markdownDescription": "Enables the push_aws_saved_state command without any pre-configured scope." + }, { "description": "Enables the read_container_file command without any pre-configured scope.", "type": "string", @@ -1832,6 +1838,12 @@ "const": "deny-pull-stt-image", "markdownDescription": "Denies the pull_stt_image command without any pre-configured scope." }, + { + "description": "Denies the push_aws_saved_state command without any pre-configured scope.", + "type": "string", + "const": "deny-push-aws-saved-state", + "markdownDescription": "Denies the push_aws_saved_state command without any pre-configured scope." + }, { "description": "Denies the read_container_file command without any pre-configured scope.", "type": "string", diff --git a/app/src-tauri/src/aws/environments.rs b/app/src-tauri/src/aws/environments.rs index 430b536..48d1304 100644 --- a/app/src-tauri/src/aws/environments.rs +++ b/app/src-tauri/src/aws/environments.rs @@ -54,6 +54,9 @@ const MAX_LOCKS: usize = 200; /// or data source with a pre/postcondition): 453 bytes for two, measured. /// The modules have at most a handful, so 2 KiB leaves a wide margin. const EMPTY_STATE_MAX_BYTES: i64 = 2048; +/// The caller's onboarding state is read whole (for its outputs) only up to +/// this size; one user's module state is a few tens of KiB. +const MAX_STATE_DOCUMENT_BYTES: usize = 1024 * 1024; /// At most this many small state objects are read per scan; the rest count /// as deployments. const MAX_STATE_PEEKS: usize = 200; @@ -277,15 +280,25 @@ pub enum EnvironmentAction { Destroy, Follow, Unlock, + /// Push the state a failed run on this machine couldn't save + /// (`errored.tfstate`) back to the bucket. + PushState, } +/// The caller's onboarding to one environment. #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(tag = "state", rename_all = "snake_case")] pub enum OnboardingState { + /// Their task role exists and, where the bucket is readable, their state + /// records a finished apply (its `onboarding_complete` output). Joined, NotJoined, - InProgress, - Incomplete, + /// A local runner, or a recent lock. + InProgress { lock: Option }, + /// The last onboarding run didn't finish: a stale lock, a failed run on + /// this machine, or a state without the completion output. Join is a + /// re-apply and finishes it. + Incomplete { lock: Option }, } #[derive(Serialize, Clone, Debug, PartialEq)] @@ -297,6 +310,12 @@ pub struct DetectedEnvironment { pub info: Option, /// The caller's onboarding; resolved only for joinable environments. pub onboarding: Option, + /// What the caller can do with their own onboarding's run (`Follow`, + /// `Unlock`, `PushState`); Join stays in `actions`. + pub onboarding_actions: Vec, + /// How many users' onboarding states (`users//…`, non-empty) the + /// bucket holds; `None` when the caller can't list it. + pub onboarded_users: Option, } #[derive(Serialize, Clone, Debug, PartialEq)] @@ -355,9 +374,27 @@ fn environment_of_key(key: &str) -> Option<&str> { (validate_environment_name(env).is_ok()).then_some(env) } -/// Whether this machine's runner is applying a deployment -/// (`tofu::is_running(&tofu::workdir_for(..))`). -pub type RunningLocally<'a> = &'a (dyn Fn(Deployment) -> bool + Sync); +/// The environment name of a `users///terraform.tfstate` key. +fn user_environment_of_key(key: &str) -> Option<&str> { + let mut parts = key.strip_prefix("users/")?.split('/'); + let (env, _, file, rest) = (parts.next()?, parts.next()?, parts.next()?, parts.next()); + (file == "terraform.tfstate" && rest.is_none() && validate_environment_name(env).is_ok()) + .then_some(env) +} + +/// What this machine's work folder says of a deployment's runs. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct LocalRun { + /// A runner here is applying it (`tofu::is_running`). + pub running: bool, + /// The last run here finished with an error (`tofu::last_run_failed`). + pub failed: bool, + /// OpenTofu left `errored.tfstate` here (`tofu::has_errored_state`). + pub errored_state: bool, +} + +/// This machine's runs, per deployment. +pub type LocalRuns<'a> = &'a (dyn Fn(Deployment) -> LocalRun + Sync); /// One environment from its record (if any), the bucket listing (if this /// caller can read it) and whether a local runner is applying it. @@ -365,12 +402,13 @@ fn detect_one( name: &str, record: Option>, objects: Option<&StateObjects>, - running_locally: RunningLocally, + local_runs: LocalRuns, current_version: u32, ) -> DetectedEnvironment { use EnvironmentAction::*; let key = state_key(Deployment::Environment(name)); - let local = running_locally(Deployment::Environment(name)); + let run = local_runs(Deployment::Environment(name)); + let local = run.running; let lock = objects .and_then(|o| o.lock(&key)) .map(|(json, age)| lock_info(json, age, local)); @@ -418,6 +456,25 @@ fn detect_one( ) }; + // A saved state must go back before anything else runs here: nothing + // that would re-apply or destroy is offered until it has. + let actions = if run.errored_state && !local { + let mut kept: Vec<_> = actions + .into_iter() + .filter(|a| !matches!(a, Update | Repair | Destroy)) + .collect(); + kept.insert(0, PushState); + kept + } else { + actions + }; + let onboarded_users = objects.map(|o| { + o.states + .iter() + .filter(|k| user_environment_of_key(k) == Some(name)) + .count() as u32 + }); + DetectedEnvironment { name: name.to_string(), template_version, @@ -425,6 +482,8 @@ fn detect_one( actions, info, onboarding: None, + onboarding_actions: Vec::new(), + onboarded_users, } } @@ -435,7 +494,7 @@ fn detect_one( pub fn merge( records: Vec<(String, Result)>, objects: Option<&StateObjects>, - running_locally: RunningLocally, + local_runs: LocalRuns, current_version: u32, ) -> Vec { let mut by_name: BTreeMap>> = BTreeMap::new(); @@ -450,28 +509,50 @@ pub fn merge( } by_name .into_iter() - .map(|(name, record)| detect_one(&name, record, objects, running_locally, current_version)) + .map(|(name, record)| detect_one(&name, record, objects, local_runs, current_version)) .collect() } -/// The caller's onboarding from the bucket alone, or `None` when only -/// `iam:GetRole` on the task role can tell. -fn onboarding_from_objects( +/// Whether an onboarding state records a finished apply: the module's +/// `onboarding_complete` output, which depends on every resource it makes, +/// so a run that failed part-way never writes it. +pub(crate) fn onboarding_complete(state: &str) -> bool { + serde_json::from_str::(state) + .ok() + .and_then(|v| v.pointer("/outputs/onboarding_complete/value").cloned()) + == Some(Value::Bool(true)) +} + +/// The caller's onboarding as far as the lock, this machine's runs and the +/// bucket listing tell, and what they may do with its run. `None` (with +/// the actions) when the state must be read or the role looked up. +fn onboarding_from_runs( objects: Option<&StateObjects>, deployment: Deployment, - local: bool, -) -> Option { - if local { - return Some(OnboardingState::InProgress); - } - let objects = objects?; + run: LocalRun, +) -> (Option, Vec) { + use EnvironmentAction::*; let key = state_key(deployment); - match objects.lock(&key) { - Some((_, age)) if age < LIVE_LOCK_SECS => Some(OnboardingState::InProgress), - Some(_) => Some(OnboardingState::Incomplete), - None if objects.has_state(&key) => Some(OnboardingState::Joined), - None => None, + let lock = objects + .and_then(|o| o.lock(&key)) + .map(|(json, age)| lock_info(json, age, run.running)); + let mut actions = Vec::new(); + if run.running { + actions.push(Follow); + } else if run.errored_state { + actions.push(PushState); } + let state = if run.running || lock.as_ref().is_some_and(|l| l.age_secs < LIVE_LOCK_SECS) { + Some(OnboardingState::InProgress { lock }) + } else if lock.is_some() { + actions.push(Unlock); + Some(OnboardingState::Incomplete { lock }) + } else if run.failed || run.errored_state { + Some(OnboardingState::Incomplete { lock: None }) + } else { + None + }; + (state, actions) } /// What detection asks AWS. Each method returns a `Send` future so [`scan`] @@ -497,6 +578,13 @@ pub trait DetectApi: Send + Sync { account: &str, ) -> impl Future> + Send; fn role_exists(&self, name: &str) -> impl Future> + Send; + /// A state object's contents, capped; `None` when it is missing, can't + /// be read or is too large. + fn state_document( + &self, + bucket: &str, + key: &str, + ) -> impl Future, AwsError>> + Send; } /// Everything the account holds for this caller in `region`. @@ -504,10 +592,10 @@ pub async fn scan( d: &D, who: &CallerIdentity, region: &str, - running_locally: RunningLocally<'_>, + local_runs: LocalRuns<'_>, ) -> Result { let bucket = state_bucket_name(&who.account, region); - scan_in_bucket(d, who, region, &bucket, running_locally).await + scan_in_bucket(d, who, region, &bucket, local_runs).await } /// [`scan`] against a given state bucket instead of the account's: the live @@ -517,7 +605,7 @@ pub(crate) async fn scan_in_bucket( who: &CallerIdentity, region: &str, bucket: &str, - running_locally: RunningLocally<'_>, + local_runs: LocalRuns<'_>, ) -> Result { let prefix = environment_parameter_name(""); let records = d @@ -545,10 +633,10 @@ pub(crate) async fn scan_in_bucket( let mut environments = merge( records, objects.as_ref(), - running_locally, + local_runs, super::tofu_modules::ENVIRONMENT_MODULE_VERSION, ); - let key = owner_key(&who.user_id); + let key = owner_key(region, &who.user_id); for env in &mut environments { if !env.actions.contains(&EnvironmentAction::Join) { continue; @@ -557,10 +645,24 @@ pub(crate) async fn scan_in_bucket( env: &env.name, owner_key: &key, }; - let from_objects = - onboarding_from_objects(objects.as_ref(), deployment, running_locally(deployment)); - env.onboarding = Some(match from_objects { + let (from_runs, actions) = + onboarding_from_runs(objects.as_ref(), deployment, local_runs(deployment)); + let user_state = state_key(deployment); + env.onboarding_actions = actions; + env.onboarding = Some(match from_runs { Some(state) => state, + // A state the app can read must record a finished apply: a + // failed onboarding leaves a partial state (and maybe its task + // role), which is not Joined. + None if objects.as_ref().is_some_and(|o| o.has_state(&user_state)) => { + match d.state_document(bucket, &user_state).await? { + Some(text) if onboarding_complete(&text) => OnboardingState::Joined, + _ => OnboardingState::Incomplete { lock: None }, + } + } + // Without a state here (a workspace user, who can't list the + // bucket, or an onboarding managed elsewhere) the task role is + // the only signal. None => { let (task_role, _) = user_role_names(&env.name, &key); match d.role_exists(&task_role).await? { @@ -893,6 +995,46 @@ impl DetectApi for SdkDetectApi { let res = self.iam.get_role().role_name(name).send().await; knowable("iam:GetRole", res) } + + async fn state_document(&self, bucket: &str, key: &str) -> Result, AwsError> { + const ACTION: &str = "s3:GetObject"; + let res = self + .s3 + .get_object() + .bucket(bucket) + .key(key) + .expected_bucket_owner(&self.account) + .send() + .await; + let obj = match res { + Ok(o) => o, + Err(e) => { + let (code, status) = error_parts(&e); + return match lookup_outcome(code.as_deref(), status) { + Lookup::Missing | Lookup::Denied => Ok(None), + Lookup::Error => Err(classify_sdk_error(ACTION, &e)), + }; + } + }; + if obj + .content_length() + .is_some_and(|n| n < 0 || n as usize > MAX_STATE_DOCUMENT_BYTES) + { + return Ok(None); + } + // Capped while reading too: the length header may be absent. + let mut body = obj.body; + let mut bytes = Vec::new(); + while let Some(chunk) = body.try_next().await.map_err(|e| AwsError::Other { + message: format!("Could not read a state object: {e}"), + })? { + if bytes.len() + chunk.len() > MAX_STATE_DOCUMENT_BYTES { + return Ok(None); + } + bytes.extend_from_slice(&chunk); + } + Ok(Some(String::from_utf8_lossy(&bytes).into_owned())) + } } /// `s3:CreateBucket`'s answer, by error code: `Some(Ok)` for a bucket this @@ -961,7 +1103,7 @@ fn create_bucket_configuration(region: &str) -> CreateBucketConfiguration { } /// Deny every request to the bucket that doesn't use TLS. -fn state_bucket_policy(bucket: &str) -> String { +pub(crate) fn state_bucket_policy(bucket: &str) -> String { json!({ "Version": "2012-10-17", "Statement": [{ @@ -979,6 +1121,45 @@ fn state_bucket_policy(bucket: &str) -> String { .to_string() } +/// The bucket policy to put, given the bucket's current one: ours when it +/// has none, `None` when our TLS statement is already in it, and otherwise +/// the existing policy with our statement added (or a stale statement under +/// our Sid replaced). An organisation's own statements are never dropped. +/// Refuses a policy it can't parse rather than overwrite it. +pub(crate) fn state_bucket_policy_update( + existing: Option<&str>, + bucket: &str, +) -> Result, String> { + let ours: Value = serde_json::from_str(&state_bucket_policy(bucket)).expect("our policy is JSON"); + let Some(existing) = existing else { + return Ok(Some(ours.to_string())); + }; + let ours = ours["Statement"][0].clone(); + let sid = ours["Sid"].clone(); + let mut policy: Value = serde_json::from_str(existing) + .ok() + .filter(Value::is_object) + .ok_or("The state bucket's existing policy can't be read, so it was left as it is")?; + let statements = match policy.get("Statement") { + Some(Value::Array(a)) => a.clone(), + Some(single @ Value::Object(_)) => vec![single.clone()], + None => Vec::new(), + Some(_) => { + return Err("The state bucket's existing policy can't be read, so it was left as it is".into()) + } + }; + if statements.contains(&ours) { + return Ok(None); + } + let mut kept: Vec = statements.into_iter().filter(|s| s.get("Sid") != Some(&sid)).collect(); + kept.push(ours); + policy["Statement"] = Value::Array(kept); + if policy.get("Version").is_none() { + policy["Version"] = json!("2012-10-17"); + } + Ok(Some(policy.to_string())) +} + /// Create (or adopt) `triple-c-tfstate--` and configure it: /// the component tag, versioning, SSE-S3, all four public-access blocks and /// a TLS-only policy. Idempotent. Every call after the create carries @@ -1116,13 +1297,30 @@ pub(crate) async fn ensure_named_state_bucket( .send() .await .map_err(|e| classify_sdk_error("s3:PutBucketPublicAccessBlock", &e))?; - s3.put_bucket_policy() + // Only when the TLS statement is missing, and then added to whatever + // policy the bucket has (an organisation may have its own statements). + let existing = match s3 + .get_bucket_policy() .bucket(&bucket) .expected_bucket_owner(account) - .policy(state_bucket_policy(&bucket)) .send() .await - .map_err(|e| classify_sdk_error("s3:PutBucketPolicy", &e))?; + { + Ok(out) => out.policy().map(str::to_string), + Err(e) if e.code() == Some("NoSuchBucketPolicy") => None, + Err(e) => return Err(classify_sdk_error("s3:GetBucketPolicy", &e)), + }; + if let Some(policy) = state_bucket_policy_update(existing.as_deref(), &bucket) + .map_err(|message| AwsError::Other { message })? + { + s3.put_bucket_policy() + .bucket(&bucket) + .expected_bucket_owner(account) + .policy(policy) + .send() + .await + .map_err(|e| classify_sdk_error("s3:PutBucketPolicy", &e))?; + } Ok(bucket) } diff --git a/app/src-tauri/src/aws/environments/tests.rs b/app/src-tauri/src/aws/environments/tests.rs index b485bf9..4d4d22d 100644 --- a/app/src-tauri/src/aws/environments/tests.rs +++ b/app/src-tauri/src/aws/environments/tests.rs @@ -201,8 +201,15 @@ fn untrusted_environment_records_are_rejected() { ); } -fn nobody_local(_: Deployment) -> bool { - false +fn nobody_local(_: Deployment) -> LocalRun { + LocalRun::default() +} + +fn running() -> LocalRun { + LocalRun { + running: true, + ..LocalRun::default() + } } fn env_key(env: &str) -> String { @@ -383,7 +390,13 @@ fn interrupted_deployments_get_the_right_actions() { // A local process, even under an old lock: Deploying, Follow. let objs = objects(&[env_key("c")], &[(env_key("c"), stale)]); - let local_c = |d: Deployment| d == Deployment::Environment("c"); + let local_c = |d: Deployment| { + if d == Deployment::Environment("c") { + running() + } else { + LocalRun::default() + } + }; let envs = merge(Vec::new(), Some(&objs), &local_c, 1); let c = find(&envs, "c"); let EnvironmentState::Deploying { lock: Some(lock) } = &c.state else { @@ -447,6 +460,8 @@ struct FakeDetect { bucket: Knowable, boundary: Knowable, roles: HashMap, + /// State objects' contents, by key. + documents: HashMap, calls: Mutex>, } @@ -458,6 +473,7 @@ impl FakeDetect { bucket: Knowable::Yes, boundary: Knowable::Yes, roles: HashMap::new(), + documents: HashMap::new(), calls: Mutex::new(Vec::new()), } } @@ -500,6 +516,10 @@ impl DetectApi for FakeDetect { self.log(format!("role {name}")); Ok(self.roles.get(name).cloned().unwrap_or(Knowable::No)) } + async fn state_document(&self, bucket: &str, key: &str) -> Result, AwsError> { + self.log(format!("document {bucket} {key}")); + Ok(self.documents.get(key).cloned()) + } } fn who() -> CallerIdentity { @@ -510,7 +530,7 @@ fn who() -> CallerIdentity { } } -fn run_scan(d: &FakeDetect, local: &(dyn Fn(Deployment) -> bool + Sync)) -> EnvironmentScan { +fn run_scan(d: &FakeDetect, local: LocalRuns) -> EnvironmentScan { tokio::runtime::Builder::new_current_thread() .build() .unwrap() @@ -521,12 +541,12 @@ fn run_scan(d: &FakeDetect, local: &(dyn Fn(Deployment) -> bool + Sync)) -> Envi fn user_key(env: &str) -> String { state_key(Deployment::User { env, - owner_key: &owner_key(USER_ID), + owner_key: &owner_key(REGION, USER_ID), }) } fn task_role(env: &str) -> String { - user_role_names(env, &owner_key(USER_ID)).0 + user_role_names(env, &owner_key(REGION, USER_ID)).0 } #[test] @@ -554,16 +574,20 @@ fn onboarding_is_found_by_state_then_by_role() { }); d.roles.insert(task_role("byrole"), Knowable::Yes); d.roles.insert(task_role("denied"), Knowable::Unknown); + d.documents.insert(user_key("bystate"), completed_state()); let scan = run_scan(&d, &nobody_local); assert_eq!(scan.owner_id, USER_ID); - assert_eq!(scan.owner_key, owner_key(USER_ID)); + assert_eq!(scan.owner_key, owner_key(REGION, USER_ID)); let onboarding = |n: &str| find(&scan.environments, n).onboarding.clone(); assert_eq!(onboarding("bystate"), Some(OnboardingState::Joined)); assert_eq!(onboarding("byrole"), Some(OnboardingState::Joined)); assert_eq!(onboarding("denied"), Some(OnboardingState::NotJoined)); assert_eq!(onboarding("absent"), Some(OnboardingState::NotJoined)); - assert_eq!(onboarding("joining"), Some(OnboardingState::InProgress)); + assert!(matches!( + onboarding("joining"), + Some(OnboardingState::InProgress { lock: Some(_) }) + )); // The state key answered for `bystate` and the lock for `joining`, so // only the others cost a GetRole. @@ -588,22 +612,31 @@ fn onboarding_is_found_by_state_then_by_role() { 3 * 3600, )], }); - let ok = owner_key(USER_ID); + let ok = owner_key(REGION, USER_ID); let local_x = move |dep: Deployment| { - dep == Deployment::User { - env: "x", - owner_key: &ok, + if dep + == (Deployment::User { + env: "x", + owner_key: &ok, + }) + { + running() + } else { + LocalRun::default() } }; let scan = run_scan(&d, &local_x); - assert_eq!( - find(&scan.environments, "x").onboarding, - Some(OnboardingState::InProgress) - ); - assert_eq!( - find(&scan.environments, "y").onboarding, - Some(OnboardingState::Incomplete) - ); + let x = find(&scan.environments, "x"); + assert_eq!(x.onboarding, Some(OnboardingState::InProgress { lock: None })); + assert_eq!(x.onboarding_actions, vec![EnvironmentAction::Follow]); + let y = find(&scan.environments, "y"); + let Some(OnboardingState::Incomplete { lock: Some(lock) }) = &y.onboarding else { + panic!("{y:#?}"); + }; + assert!(!lock.local_process); + assert_eq!(y.onboarding_actions, vec![EnvironmentAction::Unlock]); + // Join stays: it is the retry. + assert!(y.actions.contains(&EnvironmentAction::Join)); // Not joinable, not resolved. let mut d = FakeDetect::new(); @@ -862,3 +895,183 @@ fn a_state_that_cannot_be_read_still_counts() { // An S3 error on one object degrades that entry, not the scan. assert!(keep_state("k", Err(AwsError::Other { message: "boom".into() }))); } + +fn completed_state() -> String { + json!({ + "version": 4, + "resources": [{"type": "aws_iam_role", "name": "task"}], + "outputs": {"onboarding_complete": {"value": true, "type": "bool"}}, + }) + .to_string() +} + +#[test] +fn a_failed_onboarding_is_incomplete_not_joined() { + // A partial state (the task role made, the file system not): the role + // exists, but the state has no completion output. + let mut d = FakeDetect::new() + .with_record("partial", 1) + .with_record("done", 1) + .with_record("failedhere", 1) + .with_record("errored", 1); + let partial = json!({ + "version": 4, + "resources": [{"type": "aws_iam_role", "name": "task"}], + "outputs": {"task_role_arn": {"value": "arn:aws:iam::1:role/x", "type": "string"}}, + }) + .to_string(); + d.objects = Some(StateObjects { + states: ["partial", "done", "failedhere", "errored"] + .iter() + .flat_map(|e| [env_key(e), user_key(e)]) + .collect(), + locks: Vec::new(), + }); + d.documents.insert(user_key("partial"), partial); + d.documents.insert(user_key("done"), completed_state()); + d.documents.insert(user_key("failedhere"), completed_state()); + d.documents.insert(user_key("errored"), completed_state()); + for e in ["partial", "done", "failedhere", "errored"] { + d.roles.insert(task_role(e), Knowable::Yes); + } + let ok = owner_key(REGION, USER_ID); + let local = move |dep: Deployment| match dep { + Deployment::User { env: "failedhere", owner_key } if owner_key == ok => LocalRun { + failed: true, + ..LocalRun::default() + }, + Deployment::User { env: "errored", owner_key } if owner_key == ok => LocalRun { + failed: true, + errored_state: true, + ..LocalRun::default() + }, + _ => LocalRun::default(), + }; + let scan = run_scan(&d, &local); + let row = |n: &str| find(&scan.environments, n).clone(); + + assert_eq!(row("done").onboarding, Some(OnboardingState::Joined)); + for name in ["partial", "failedhere", "errored"] { + let r = row(name); + assert_eq!( + r.onboarding, + Some(OnboardingState::Incomplete { lock: None }), + "{name}" + ); + // Join is kept: an idempotent re-apply finishes it. + assert!(r.actions.contains(&EnvironmentAction::Join), "{name}"); + } + assert!(row("partial").onboarding_actions.is_empty()); + // The saved state has to go back first. + assert_eq!( + row("errored").onboarding_actions, + vec![EnvironmentAction::PushState] + ); + // Neither the local failures nor the lock-free ones cost a GetRole. + assert!(!d.calls().iter().any(|c| c.starts_with("role ")), "{:?}", d.calls()); + assert!(onboarding_complete(&completed_state())); + assert!(!onboarding_complete("{}")); + assert!(!onboarding_complete( + &json!({"outputs": {"onboarding_complete": {"value": "true"}}}).to_string() + )); +} + +#[test] +fn an_errored_environment_state_offers_only_the_push() { + let objs = objects(&[env_key("e")], &[]); + let errored = |d: Deployment| LocalRun { + failed: true, + errored_state: d == Deployment::Environment("e"), + ..LocalRun::default() + }; + let envs = merge(Vec::new(), Some(&objs), &errored, 1); + // State without a record: normally Repair and Destroy. + assert_eq!(find(&envs, "e").actions, vec![EnvironmentAction::PushState]); +} + +#[test] +fn onboarded_users_are_counted_per_environment() { + let ok = owner_key(REGION, USER_ID); + let objs = objects( + &[ + env_key("a"), + env_key("b"), + state_key(Deployment::User { env: "a", owner_key: &ok }), + state_key(Deployment::User { env: "a", owner_key: "0123456789ab" }), + "users/a/x/y/terraform.tfstate".to_string(), + ], + &[], + ); + let envs = merge(Vec::new(), Some(&objs), &nobody_local, 1); + assert_eq!(find(&envs, "a").onboarded_users, Some(2)); + assert_eq!(find(&envs, "b").onboarded_users, Some(0)); + let envs = merge(vec![("c".into(), Ok(parsed("c", 1)))], None, &nobody_local, 1); + assert_eq!(find(&envs, "c").onboarded_users, None); +} + +#[test] +fn the_tls_statement_is_added_to_a_bucket_policy_never_replacing_it() { + let bucket = state_bucket_name(ACCOUNT, REGION); + let ours: Value = serde_json::from_str(&state_bucket_policy(&bucket)).unwrap(); + let tls = ours["Statement"][0].clone(); + + // No policy: ours. + let new: Value = + serde_json::from_str(&state_bucket_policy_update(None, &bucket).unwrap().unwrap()).unwrap(); + assert_eq!(new, ours); + + // Ours already there (statement order or key order aside): nothing to put. + let same = json!({"Statement": [tls.clone()], "Version": "2012-10-17"}).to_string(); + assert_eq!(state_bucket_policy_update(Some(&same), &bucket).unwrap(), None); + + // An organisation's statements are kept; ours is added once. + let org = json!({ + "Version": "2012-10-17", + "Statement": [{ + "Sid": "OrgDenyOutsideVpce", + "Effect": "Deny", + "Principal": "*", + "Action": "s3:*", + "Resource": format!("arn:aws:s3:::{bucket}/*"), + "Condition": {"StringNotEquals": {"aws:SourceVpce": "vpce-1234"}}, + }], + }); + let merged: Value = serde_json::from_str( + &state_bucket_policy_update(Some(&org.to_string()), &bucket) + .unwrap() + .unwrap(), + ) + .unwrap(); + let st = merged["Statement"].as_array().unwrap(); + assert_eq!(st.len(), 2); + assert_eq!(st[0], org["Statement"][0]); + assert_eq!(st[1], tls); + assert_eq!(merged["Version"], "2012-10-17"); + + // A single statement object (not a list) is kept too. + let single = json!({"Version": "2012-10-17", "Statement": org["Statement"][0].clone()}); + let merged: Value = serde_json::from_str( + &state_bucket_policy_update(Some(&single.to_string()), &bucket) + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(merged["Statement"].as_array().unwrap().len(), 2); + + // A stale statement under our Sid is replaced, not duplicated. + let mut old = tls.clone(); + old["Resource"] = json!("arn:aws:s3:::elsewhere"); + let stale = json!({"Version": "2012-10-17", "Statement": [org["Statement"][0].clone(), old]}); + let merged: Value = serde_json::from_str( + &state_bucket_policy_update(Some(&stale.to_string()), &bucket) + .unwrap() + .unwrap(), + ) + .unwrap(); + let st = merged["Statement"].as_array().unwrap(); + assert_eq!(st.len(), 2); + assert_eq!(st[1], tls); + + // Something that isn't a policy is never overwritten. + assert!(state_bucket_policy_update(Some("not json"), &bucket).is_err()); +} diff --git a/app/src-tauri/src/aws/live_tests.rs b/app/src-tauri/src/aws/live_tests.rs index 803aea8..a740cee 100644 --- a/app/src-tauri/src/aws/live_tests.rs +++ b/app/src-tauri/src/aws/live_tests.rs @@ -1156,8 +1156,12 @@ mod environment { let (root, bucket) = (self.root.clone(), self.bucket.clone()); let running = move |d: Deployment| { tofu::workdir_for(&root, d, &bucket) - .map(|wd| tofu::is_running(&wd)) - .unwrap_or(false) + .map(|wd| crate::aws::environments::LocalRun { + running: tofu::is_running(&wd), + failed: tofu::last_run_failed(&wd), + errored_state: tofu::has_errored_state(&wd), + }) + .unwrap_or_default() }; scan_in_bucket(&api, who, &self.e.region, &self.bucket, &running) .await @@ -1533,7 +1537,7 @@ mod environment { let found = t.scan(&cfg, &who).await; onboard_refusal(&found, &name).expect("join is offered"); let owner = validate_owner_id(&who.user_id).expect("owner id"); - let okey = owner_key(&owner); + let okey = owner_key(&t.e.region, &owner); let (_, outcome, denied) = t .run( ModuleKind::UserOnboard, diff --git a/app/src-tauri/src/aws/tofu.rs b/app/src-tauri/src/aws/tofu.rs index 3ab431c..1ce4ae3 100644 --- a/app/src-tauri/src/aws/tofu.rs +++ b/app/src-tauri/src/aws/tofu.rs @@ -100,6 +100,16 @@ const EXIT_FILE: &str = "apply.exit"; const LOCK_FILE: &str = "apply.lock"; /// Held by [`start_detached`] for its check → clear → spawn → wait. const START_LOCK_FILE: &str = "start.lock"; +/// What OpenTofu writes into its working directory when it can't save the +/// state to the backend after a run: the only copy of what it changed. +const ERRORED_STATE_FILE: &str = "errored.tfstate"; +/// Where a pushed `errored.tfstate` is set aside (kept, not deleted). +const PUSHED_STATE_FILE: &str = "errored.tfstate.pushed"; +/// The error a run leaving [`ERRORED_STATE_FILE`] ends with; the outcome's +/// summary and the UI's recovery both name it. +pub const ERRORED_STATE_MESSAGE: &str = "OpenTofu could not save this deployment's state to the \ + state bucket and kept it in errored.tfstate in the work folder. Nothing else can run here \ + until it is pushed back: use Push saved state"; /// The names (not values) of the variables the app gave the runner, so the /// runner can hand `tofu` exactly those (see [`tofu_env`]). const ENV_KEYS_VAR: &str = "TRIPLEC_TOFU_ENV_KEYS"; @@ -357,12 +367,36 @@ pub fn materialize( } let region = validate_supported_region(region).map_err(other)?; let key = state_key(deployment); - if is_running(&wd) { - return Err(other("A deploy is already running for this deployment")); - } let io = |e: io::Error| other(format!("Could not write the work folder: {e}")); fs::create_dir_all(&wd.dir).map_err(io)?; + // The same lock `start_detached` holds from its check to its spawn: a + // second app instance can't rewrite the folder between another's check + // and its start. + let start_lock = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(wd.dir.join(START_LOCK_FILE)) + .map_err(io)?; + match start_lock.try_lock() { + Ok(()) => {} + Err(fs::TryLockError::WouldBlock) => { + return Err(other("A deploy is already starting for this deployment")) + } + Err(fs::TryLockError::Error(e)) => return Err(io(e)), + } + if is_running(&wd) { + return Err(other("A deploy is already running for this deployment")); + } + if has_errored_state(&wd) { + return Err(other(format!( + "The last run could not save its state: OpenTofu kept it in {}. Push it back first \ + with Push saved state (or by hand, from that folder: tofu init \ + -backend-config=backend.hcl, then tofu state push errored.tfstate)", + wd.dir.join(ERRORED_STATE_FILE).display() + ))); + } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -384,6 +418,7 @@ pub fn materialize( let json = serde_json::to_vec_pretty(vars) .map_err(|e| other(format!("Could not encode the module variables: {e}")))?; write_private(&wd.dir.join("terraform.tfvars.json"), &json).map_err(io)?; + drop(start_lock); Ok(wd) } @@ -595,6 +630,9 @@ pub fn child_env( pub enum TofuOp { Apply, Destroy, + /// `tofu state push errored.tfstate`: the recovery after a run that + /// couldn't save its state ([`ERRORED_STATE_MESSAGE`]). + PushState, } impl TofuOp { @@ -602,6 +640,7 @@ impl TofuOp { match self { TofuOp::Apply => "apply", TofuOp::Destroy => "destroy", + TofuOp::PushState => "push-state", } } @@ -609,6 +648,7 @@ impl TofuOp { match s { "apply" => Some(TofuOp::Apply), "destroy" => Some(TofuOp::Destroy), + "push-state" => Some(TofuOp::PushState), _ => None, } } @@ -830,6 +870,20 @@ pub fn has_run(wd: &Workdir) -> bool { wd.dir.join(LOG_FILE).exists() || wd.dir.join(EXIT_FILE).exists() || is_running(wd) } +/// OpenTofu left `errored.tfstate` here: the state it couldn't save. +pub fn has_errored_state(wd: &Workdir) -> bool { + wd.dir.join(ERRORED_STATE_FILE).is_file() +} + +/// The last run here finished with a non-zero exit code (and none is +/// running now). +pub fn last_run_failed(wd: &Workdir) -> bool { + !is_running(wd) + && fs::read_to_string(wd.dir.join(EXIT_FILE)) + .ok() + .is_some_and(|t| t.trim().parse::() != Ok(0)) +} + fn read_pid(wd: &Workdir) -> Option { fs::read_to_string(wd.dir.join(PID_FILE)) .ok()? @@ -1006,6 +1060,8 @@ pub async fn follow( let mut pending: Vec = Vec::new(); let mut last_summary: Option = None; let mut last_error: Option = None; + // The run's own last error, apart from the runner's errored-state line. + let mut cause: Option = None; loop { // Checked before reading: the runner writes the exit code only after // tofu has written its last line, so this read sees everything. @@ -1031,6 +1087,9 @@ pub async fn follow( } if e.level == "error" { last_error = Some(e.message.clone()); + if !e.message.contains(ERRORED_STATE_FILE) { + cause = Some(e.message.clone()); + } } } if !events.is_empty() { @@ -1044,6 +1103,11 @@ pub async fn follow( let ok = exit_code == 0; let summary = if ok { last_summary + } else if has_errored_state(wd) { + Some(match cause.take() { + Some(c) => format!("{ERRORED_STATE_MESSAGE}. The run's error: {c}"), + None => ERRORED_STATE_MESSAGE.to_string(), + }) } else { last_error.or(last_summary) }; @@ -1231,9 +1295,34 @@ fn run_steps(dir: &Path, op: TofuOp, tofu: &Path, env: &[(String, String)], log: let _ = writeln!(log, "triple-c: cancelled before tofu {}", op.as_arg()); return 130; } - let run = [op.as_arg(), "-auto-approve", "-json", "-input=false"]; - let code = step(tofu, dir, &run, env, log); + let run: &[&str] = match op { + TofuOp::Apply => &["apply", "-auto-approve", "-json", "-input=false"], + TofuOp::Destroy => &["destroy", "-auto-approve", "-json", "-input=false"], + // No -force: tofu refuses a state of another lineage or an older + // serial, which is exactly what must not be pushed. + TofuOp::PushState => &["state", "push", ERRORED_STATE_FILE], + }; + let code = step(tofu, dir, run, env, log); let _ = writeln!(log, "triple-c: tofu {} finished (exit {code})", op.as_arg()); + if op == TofuOp::PushState && code == 0 { + // Kept, not deleted: it was the only copy until the push. + match fs::rename(dir.join(ERRORED_STATE_FILE), dir.join(PUSHED_STATE_FILE)) { + Ok(()) => { + let _ = writeln!(log, "triple-c: the saved state was pushed to the state bucket"); + } + Err(e) => { + let _ = writeln!( + log, + "Error: the saved state was pushed but errored.tfstate could not be set aside ({e}); remove it by hand" + ); + return 1; + } + } + } else if code != 0 && dir.join(ERRORED_STATE_FILE).is_file() { + // A plain `Error:` line is an error event, and the last one is the + // outcome's summary. + let _ = writeln!(log, "Error: {ERRORED_STATE_MESSAGE}"); + } code } diff --git a/app/src-tauri/src/aws/tofu/tests.rs b/app/src-tauri/src/aws/tofu/tests.rs index 18349dd..249f747 100644 --- a/app/src-tauri/src/aws/tofu/tests.rs +++ b/app/src-tauri/src/aws/tofu/tests.rs @@ -997,3 +997,127 @@ fn a_started_runner_reports_its_pid() { assert_eq!(started, Started { pid: Some(31337) }); } + +/// A failed apply whose state OpenTofu could not save leaves +/// `errored.tfstate`: the runner says so in an error line, the outcome's +/// summary names it, and nothing new is materialized over it. +#[cfg(unix)] +#[tokio::test] +async fn an_errored_state_is_reported_and_blocks_the_next_launch() { + let _turn = runner_turn(); + let bin = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let bucket = "triple-c-tfstate-123456789012-us-east-1"; + let wd = materialize( + root.path(), + ModuleKind::PermissionsBoundary, + Deployment::Boundary, + bucket, + "us-east-1", + &boundary_vars("us-east-1"), + ) + .unwrap(); + // init succeeds; the apply "fails to persist" and leaves errored.tfstate. + let tofu = fake_tofu( + bin.path(), + "[ \"$1\" = init ] && exit 0\necho '{\"version\":4}' > errored.tfstate\nexit 1", + ); + let env = vec![("PATH".to_string(), "/usr/bin:/bin".to_string())]; + + assert_eq!(run_runner(&wd.dir, TofuOp::Apply, &tofu, &env), Some(1)); + assert!(has_errored_state(&wd)); + assert!(last_run_failed(&wd)); + let log = std::fs::read_to_string(wd.dir.join("apply.log")).unwrap(); + assert!( + log.lines() + .any(|l| l.starts_with("Error:") && l.contains("errored.tfstate")), + "{log}" + ); + let outcome = follow(&wd, Duration::from_millis(10), |_| {}) + .await + .unwrap(); + assert!(!outcome.ok); + let summary = outcome.summary.unwrap(); + assert!(summary.contains("errored.tfstate"), "{summary}"); + assert!(summary.contains("Push saved state"), "{summary}"); + + // Launching anything there now is refused, naming the recovery. + let err = materialize( + root.path(), + ModuleKind::PermissionsBoundary, + Deployment::Boundary, + bucket, + "us-east-1", + &boundary_vars("us-east-1"), + ) + .unwrap_err() + .to_string(); + assert!(err.contains("errored.tfstate"), "{err}"); + assert!(err.contains("tofu state push errored.tfstate"), "{err}"); + + // The recovery: push it back through the runner; it is set aside once + // pushed, and the folder can be used again. + let pushed = fake_tofu( + bin.path(), + "[ \"$1\" = init ] && exit 0\n[ \"$1 $2 $3\" = 'state push errored.tfstate' ] || exit 9\necho pushed", + ); + assert_eq!(run_runner(&wd.dir, TofuOp::PushState, &pushed, &env), Some(0)); + assert!(!has_errored_state(&wd)); + assert!(wd.dir.join("errored.tfstate.pushed").exists()); + assert!(!last_run_failed(&wd)); + assert!(materialize( + root.path(), + ModuleKind::PermissionsBoundary, + Deployment::Boundary, + bucket, + "us-east-1", + &boundary_vars("us-east-1"), + ) + .is_ok()); +} + +#[cfg(unix)] +#[test] +fn a_failed_push_keeps_the_saved_state() { + let _turn = runner_turn(); + let bin = tempfile::tempdir().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let wd = Workdir { + dir: tmp.path().to_path_buf(), + }; + std::fs::write(wd.dir.join("errored.tfstate"), "{}").unwrap(); + let tofu = fake_tofu(bin.path(), "[ \"$1\" = init ] && exit 0\nexit 1"); + let env = vec![("PATH".to_string(), "/usr/bin:/bin".to_string())]; + assert_eq!(run_runner(&wd.dir, TofuOp::PushState, &tofu, &env), Some(1)); + assert!(has_errored_state(&wd)); +} + +/// materialize checks for a running deploy under `start.lock`, so a second +/// app instance between its check and its start can't interleave. +#[test] +fn materialize_waits_its_turn_on_the_start_lock() { + let root = tempfile::tempdir().unwrap(); + let bucket = "triple-c-tfstate-123456789012-us-east-1"; + let wd = workdir_for(root.path(), Deployment::Boundary, bucket).unwrap(); + std::fs::create_dir_all(&wd.dir).unwrap(); + let starting = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(wd.dir.join("start.lock")) + .unwrap(); + starting.try_lock().unwrap(); + let err = materialize( + root.path(), + ModuleKind::PermissionsBoundary, + Deployment::Boundary, + bucket, + "us-east-1", + &boundary_vars("us-east-1"), + ) + .unwrap_err() + .to_string(); + assert!(err.contains("already"), "{err}"); + assert!(!wd.dir.join("backend.hcl").exists(), "materialize wrote under another's lock"); + drop(starting); +} diff --git a/app/src-tauri/src/aws/tofu_modules.rs b/app/src-tauri/src/aws/tofu_modules.rs index b7c4c8d..9561ee4 100644 --- a/app/src-tauri/src/aws/tofu_modules.rs +++ b/app/src-tauri/src/aws/tofu_modules.rs @@ -101,10 +101,13 @@ pub fn environment_parameter_name(env: &str) -> String { format!("/triple-c/environments/{env}") } -/// The first 12 hex characters of `sha256(aws:userid)`. A `UserId` holds a -/// `:`, which IAM role names reject, so per-user names use this instead. -pub fn owner_key(user_id: &str) -> String { - Sha256::digest(user_id.as_bytes()) +/// The first 12 hex characters of `sha256(":")`. A +/// `UserId` holds a `:`, which IAM role names reject, so per-user names use +/// this instead. The region is part of it because role names are +/// account-global while environments are regional: the same user in the same +/// environment name in two regions gets two sets of roles. +pub fn owner_key(region: &str, user_id: &str) -> String { + Sha256::digest(format!("{region}:{user_id}").as_bytes()) .iter() .take(6) .map(|b| format!("{b:02x}")) @@ -230,7 +233,7 @@ pub fn onboarding_vars(env: &str, region: &str, owner_id: &str) -> serde_json::V "environment_name": env, "region": region, "owner_id": owner_id, - "owner_key": owner_key(owner_id), + "owner_key": owner_key(region, owner_id), "bedrock_on_task_role": false, }) } diff --git a/app/src-tauri/src/aws/tofu_modules/tests.rs b/app/src-tauri/src/aws/tofu_modules/tests.rs index c0682aa..9416ed1 100644 --- a/app/src-tauri/src/aws/tofu_modules/tests.rs +++ b/app/src-tauri/src/aws/tofu_modules/tests.rs @@ -163,18 +163,41 @@ fn environment_names_are_validated() { ); } +#[test] +fn same_user_and_environment_in_two_regions_do_not_collide() { + // IAM role names are account-global but environments are regional: the + // same user joining "dev" in two regions needs distinct roles (and + // distinct onboarding state keys, though the buckets differ anyway). + let user = "AROAEXAMPLEID:alice@example.com"; + let west = owner_key("us-west-2", user); + let east = owner_key("us-east-1", user); + assert_ne!(west, east); + let (wt, we) = user_role_names("dev", &west); + let (et, ee) = user_role_names("dev", &east); + assert_ne!(wt, et); + assert_ne!(we, ee); + assert_ne!( + state_key(Deployment::User { env: "dev", owner_key: &west }), + state_key(Deployment::User { env: "dev", owner_key: &east }) + ); +} + #[test] fn owner_keys_are_stable_and_role_safe() { - let a = owner_key("AROAEXAMPLEID:alice@example.com"); + let a = owner_key("us-west-2", "AROAEXAMPLEID:alice@example.com"); assert_eq!(a.len(), 12); assert!( a.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f')), "{a}" ); - assert_eq!(a, owner_key("AROAEXAMPLEID:alice@example.com")); - assert_ne!(a, owner_key("AROAEXAMPLEID:bob@example.com")); - // The key is the first 12 hex characters of the SHA-256 of the user ID. - assert_eq!(owner_key("AIDAEXAMPLE"), sha256_prefix("AIDAEXAMPLE")); + assert_eq!(a, owner_key("us-west-2", "AROAEXAMPLEID:alice@example.com")); + assert_ne!(a, owner_key("us-west-2", "AROAEXAMPLEID:bob@example.com")); + // The key is the first 12 hex characters of the SHA-256 of + // ":". + assert_eq!( + owner_key("eu-west-1", "AIDAEXAMPLE"), + sha256_prefix("eu-west-1:AIDAEXAMPLE") + ); let env = "abcdefghij0123456789"; assert_eq!(env.len(), 20); @@ -259,6 +282,61 @@ fn subnet_availability_zones_are_stable_once_chosen() { } } +#[test] +fn an_existing_network_is_checked_before_anything_is_built() { + // `existing` takes the user's VPC and subnets on trust otherwise: the + // module looks the subnets up and refuses ones outside var.vpc_id or in + // one availability zone (the onboarding module's own check), before the + // security group, the first thing built in that VPC. + let body = parsed(ModuleKind::Environment); + let lookups: Vec<_> = top_blocks(&body, "data") + .filter(|b| label(b, 0) == Some("aws_subnet")) + .collect(); + assert_eq!(lookups.len(), 1, "one aws_subnet lookup"); + let lookup = lookups[0]; + assert_eq!(label(lookup, 1), Some("existing")); + let count = render(attr(&lookup.body, "count").expect("count")); + assert!(count.contains("var.network_mode") && count.contains("\"existing\""), "{count}"); + assert!(count.contains('2'), "{count}"); + let id = render(attr(&lookup.body, "id").expect("id")); + assert!(id.contains("var.subnet_ids[count.index]"), "{id}"); + + let sg = resource(&body, "aws_security_group", "tasks"); + let lifecycle = top_blocks(&sg.body, "lifecycle").next().expect("lifecycle"); + let pre = top_blocks(&lifecycle.body, "precondition") + .next() + .expect("a precondition on the task security group"); + let cond = render(attr(&pre.body, "condition").expect("condition")); + for needle in [ + "data.aws_subnet.existing", + ".vpc_id == var.vpc_id", + "availability_zone", + "distinct", + ] { + assert!(cond.contains(needle), "{needle} missing from {cond}"); + } + assert!(attr(&pre.body, "error_message").is_some()); +} + +#[test] +fn the_onboarding_completion_output_waits_for_every_resource() { + // Detection reads Joined only from a state holding this output, so it + // must depend on every resource the module makes. + let body = parsed(ModuleKind::UserOnboard); + let out = top_blocks(&body, "output") + .find(|b| label(b, 0) == Some("onboarding_complete")) + .expect("an onboarding_complete output"); + assert_eq!(attr(&out.body, "value"), Some(&Expression::Bool(true))); + let deps = render(attr(&out.body, "depends_on").expect("depends_on")); + let mut n = 0; + for r in top_blocks(&body, "resource") { + let addr = format!("{}.{}", label(r, 0).unwrap(), label(r, 1).unwrap()); + assert!(deps.contains(&addr), "onboarding_complete doesn't wait for {addr}: {deps}"); + n += 1; + } + assert!(n >= 9, "only {n} resources"); +} + #[test] fn the_module_versions_agree() { assert_eq!(ENVIRONMENT_MODULE_VERSION, 1); @@ -767,7 +845,7 @@ fn every_onboarding_role_carries_the_boundary_and_owner_tag() { // module doesn't spell out). let roles = resources(&body, "aws_iam_role"); assert_eq!(roles.len(), 2, "task and exec roles"); - let key = owner_key("AROAEXAMPLE:alice"); + let key = owner_key("us-west-2", "AROAEXAMPLE:alice"); let (task, exec) = user_role_names("dev", &key); for (name, want) in [("task", task), ("exec", exec)] { let role = resource(&body, "aws_iam_role", name); @@ -966,7 +1044,7 @@ fn the_environment_security_group_gains_no_ingress() { assert_eq!(groups.len(), 1); let sg = groups[0]; assert_eq!(label(sg, 1), Some("efs")); - let key = owner_key("AROAEXAMPLE:alice"); + let key = owner_key("us-west-2", "AROAEXAMPLE:alice"); let name = expand_locals(&body, &template_text(attr(&sg.body, "name").unwrap())) .replace("${var.environment_name}", "dev") .replace("${var.owner_key}", &key); @@ -1151,7 +1229,7 @@ fn onboarding_vars_name_the_owner() { "environment_name": "dev", "region": "us-west-2", "owner_id": owner, - "owner_key": owner_key(owner), + "owner_key": owner_key("us-west-2", owner), "bedrock_on_task_role": false, }) ); diff --git a/app/src-tauri/src/commands/environment_commands.rs b/app/src-tauri/src/commands/environment_commands.rs index 17e38cd..130530e 100644 --- a/app/src-tauri/src/commands/environment_commands.rs +++ b/app/src-tauri/src/commands/environment_commands.rs @@ -38,8 +38,9 @@ use aws_sdk_s3::error::ProvideErrorMetadata; use tokio::sync::oneshot; use crate::aws::environments::{ - ensure_state_bucket, scan, LIVE_LOCK_SECS, DetectedEnvironment, EnvironmentAction, EnvironmentInfo, - EnvironmentScan, EnvironmentState, Knowable, OnboardingState, SdkDetectApi, + ensure_state_bucket, scan, state_bucket_policy, LIVE_LOCK_SECS, DetectedEnvironment, + EnvironmentAction, EnvironmentInfo, EnvironmentScan, EnvironmentState, Knowable, LocalRun, + OnboardingState, SdkDetectApi, }; use crate::aws::tofu::{ self, backend_hcl, StartError, TofuEvent, TofuOp, TofuOutcome, Workdir, @@ -239,6 +240,7 @@ pub(crate) fn require_action<'a>( EnvironmentAction::Destroy => "Destroy", EnvironmentAction::Follow => "Follow", EnvironmentAction::Unlock => "Unlock", + EnvironmentAction::PushState => "Push saved state", } )) } @@ -256,6 +258,30 @@ pub(crate) fn unlock_refusal(scan: &EnvironmentScan, name: &str) -> Result<(), S } } +/// Unlock the caller's own onboarding state: only their own owner key, and +/// only a stale lock no local runner holds (detection offers Unlock on the +/// onboarding). +pub(crate) fn onboarding_unlock_refusal( + scan: &EnvironmentScan, + name: &str, + owner_key: &str, +) -> Result<(), String> { + if owner_key != scan.owner_key { + return Err("Only your own onboarding's state lock can be removed from the app".into()); + } + let env = find(scan, name).ok_or_else(|| format!("No environment named \"{name}\" was found"))?; + match &env.onboarding { + Some(OnboardingState::Incomplete { lock: Some(l) }) + if !l.local_process && env.onboarding_actions.contains(&EnvironmentAction::Unlock) => + { + Ok(()) + } + _ => Err(format!( + "Your onboarding to \"{name}\" has no stale state lock to remove" + )), + } +} + /// Self-onboarding needs the boundary (not known missing), an environment /// that can be joined, and no onboarding already running. pub(crate) fn onboard_refusal(scan: &EnvironmentScan, name: &str) -> Result<(), String> { @@ -263,7 +289,7 @@ pub(crate) fn onboard_refusal(scan: &EnvironmentScan, name: &str) -> Result<(), return Err("The account has no Triple-C permissions boundary yet".into()); } let env = require_action(scan, name, EnvironmentAction::Join)?; - if env.onboarding == Some(OnboardingState::InProgress) { + if matches!(env.onboarding, Some(OnboardingState::InProgress { .. })) { return Err(format!( "Your onboarding to \"{name}\" is already in progress" )); @@ -313,6 +339,26 @@ pub(crate) fn destroy_refusal<'a>( name: &str, ) -> Result<&'a DetectedEnvironment, String> { let env = require_action(scan, name, EnvironmentAction::Destroy)?; + // Each onboarded user's stack holds the environment's security group (a + // mount-target group references it) and reads its record: destroying + // the environment under them fails part-way and strands their stacks. + match env.onboarded_users { + Some(0) => {} + Some(n) => { + return Err(format!( + "Environment \"{name}\" still has {n} onboarded user{} (state under \ + users/{name}/); remove the onboarded users' stacks first (tofu destroy from each \ + user's onboarding bundle)", + if n == 1 { "" } else { "s" } + )) + } + None => { + return Err(format!( + "The state bucket can't list users onboarded to \"{name}\", so it can't be \ + destroyed from the app" + )) + } + } if env.info.is_some() { return Err(format!( "Environment \"{name}\" has a valid record, so it may be working; it can't be \ @@ -384,10 +430,29 @@ fn bundle_readme(d: Deployment, account: &str, region: &str, bucket: &str, key: .to_string() } }; - let location = if region == "us-east-1" { - String::new() + // The admin policy allows s3:CreateBucket only with the component tag in + // the request (aws:RequestTag), so it goes on the create; a later + // put-bucket-tagging can't satisfy that. us-east-1 refuses a + // LocationConstraint. + let tags = r#""Tags":[{"Key":"triple-c:component","Value":"state"}]"#; + let create_config = if region == "us-east-1" { + format!("{{{tags}}}") } else { - format!(" --create-bucket-configuration LocationConstraint={region}") + format!(r#"{{"LocationConstraint":"{region}",{tags}}}"#) + }; + let policy = state_bucket_policy(bucket); + let credentials = match d { + Deployment::Boundary => format!( + "- Credentials of an account administrator for account {account} WITHOUT + the Triple-C environment-admin policies attached: those policies deny + changing or deleting the boundary policy to anyone holding them, account + administrators included. (This bundle needs iam:CreatePolicy and, to + remove it, iam:DeletePolicy on triple-c-permissions-boundary.)" + ), + _ => format!( + "- Credentials for account {account} that carry the Triple-C environment-admin + policies." + ), }; format!( "Triple-C admin bundle @@ -403,8 +468,7 @@ Requirements ------------ - OpenTofu 1.10 or later, or Terraform 1.10 or later. Both are needed for S3-native state locking (use_lockfile in backend.hcl). -- Credentials for account {account} that carry the Triple-C environment-admin - policies. +{credentials} - .terraform.lock.hcl pins the AWS provider's hashes for OpenTofu (registry.opentofu.org). With Terraform, first run terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 -platform=windows_amd64 @@ -415,13 +479,14 @@ Requirements ------------------- The bucket {bucket} must exist first. Either deploy anything from Triple-C once with this account and region (the app creates and configures -it), or create it by hand (POSIX shell syntax): +it), or create it by hand exactly as the app does (POSIX shell syntax; an +AWS CLI v2 recent enough that create-bucket accepts Tags, e.g. 2.39): - aws s3api create-bucket --bucket {bucket} --region {region}{location} + aws s3api create-bucket --bucket {bucket} --region {region} --create-bucket-configuration '{create_config}' aws s3api put-bucket-versioning --bucket {bucket} --region {region} --expected-bucket-owner {account} --versioning-configuration Status=Enabled aws s3api put-bucket-encryption --bucket {bucket} --region {region} --expected-bucket-owner {account} --server-side-encryption-configuration '{{\"Rules\":[{{\"ApplyServerSideEncryptionByDefault\":{{\"SSEAlgorithm\":\"AES256\"}}}}]}}' aws s3api put-public-access-block --bucket {bucket} --region {region} --expected-bucket-owner {account} --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true - aws s3api put-bucket-tagging --bucket {bucket} --region {region} --expected-bucket-owner {account} --tagging 'TagSet=[{{Key=triple-c:component,Value=state}}]' + aws s3api put-bucket-policy --bucket {bucket} --region {region} --expected-bucket-owner {account} --policy '{policy}' 2. Deploy --------- @@ -758,8 +823,12 @@ async fn detect(ctx: &Ctx) -> Result { let (root, bucket) = (ctx.root.clone(), ctx.bucket()); let running = move |d: Deployment| { tofu::workdir_for(&root, d, &bucket) - .map(|wd| tofu::is_running(&wd)) - .unwrap_or(false) + .map(|wd| LocalRun { + running: tofu::is_running(&wd), + failed: tofu::last_run_failed(&wd), + errored_state: tofu::has_errored_state(&wd), + }) + .unwrap_or_default() }; scan(&api, &ctx.who, &ctx.region, &running).await } @@ -833,8 +902,9 @@ fn cancellable(wd: &Workdir) -> bool { cfg!(unix) && tofu::runner_pid(wd).is_some() } +/// Only the main window shows deploys; no other window gets the events. fn emit_progress(app: &AppHandle, p: &DeployProgress) { - if let Err(e) = app.emit(PROGRESS_EVENT, p) { + if let Err(e) = app.emit_to("main", PROGRESS_EVENT, p) { log::warn!("Could not emit deploy progress: {e}"); } } @@ -882,6 +952,21 @@ fn install_follower( Ok((op_id, gate)) } +/// The runner's whole environment ([`tofu::child_env`]): a keys +/// connection's keys come from the keychain and go nowhere else. +fn runner_env(ctx: &Ctx) -> Result, AwsError> { + let HostKind::Aws { auth, .. } = &ctx.conn.kind; + let keys = match auth { + AwsAuth::Keys { keyring_ref } => Some( + secure::get_aws_keys(keyring_ref) + .map_err(other)? + .ok_or(AwsError::KeysMissing)?, + ), + AwsAuth::Profile { .. } => None, + }; + tofu::child_env(&ctx.conn, keys.as_ref(), &ctx.root, std::env::vars()) +} + /// Ensure OpenTofu and the state bucket, write the work folder and start a /// detached `init` + `op`. Returns the op id its progress is emitted under /// and the gate the command opens just before returning it. @@ -905,16 +990,7 @@ async fn launch( let bucket = ensure_state_bucket(&ctx.cfg, &ctx.who.account, &ctx.region).await?; let wd = tofu::materialize(&ctx.root, module_kind(d), d, &bucket, &ctx.region, vars)?; - let HostKind::Aws { auth, .. } = &ctx.conn.kind; - let keys = match auth { - AwsAuth::Keys { keyring_ref } => Some( - secure::get_aws_keys(keyring_ref) - .map_err(other)? - .ok_or(AwsError::KeysMissing)?, - ), - AwsAuth::Profile { .. } => None, - }; - let env = tofu::child_env(&ctx.conn, keys.as_ref(), &ctx.root, std::env::vars())?; + let env = runner_env(ctx)?; let start_wd = wd.clone(); let started = tokio::task::spawn_blocking(move || { @@ -1042,9 +1118,9 @@ pub async fn destroy_incomplete_aws_environment( opened(launch(app, &ctx, &key, &vars, TofuOp::Destroy).await) } -/// Delete an environment's stale `.tflock`. Only an environment's lock, -/// only when detection offers Unlock and no runner on this machine holds -/// the deployment. The delete is conditional on the ETag of the lock object +/// Delete a stale `.tflock`: an environment's, or the caller's own +/// onboarding's (`users///…`), only when detection offers +/// Unlock on it and no runner on this machine holds the deployment. The delete is conditional on the ETag of the lock object /// as just re-read (and re-checked stale), so a lock taken since is never /// removed; there is no unconditional fallback. #[tauri::command] @@ -1055,12 +1131,19 @@ pub async fn unlock_aws_state( state: State<'_, AppState>, ) -> Result<(), AwsError> { let key = parse_state_key(&state_key)?; - let DeploymentKey::Environment(name) = &key else { - return Err(other("Only an environment's state lock can be removed from the app")); - }; + if key == DeploymentKey::Boundary { + return Err(other( + "The permissions boundary's state lock can't be removed from the app", + )); + } let ctx = context(&state, &connection_id, ®ion).await?; let found = detect(&ctx).await?; - unlock_refusal(&found, name).map_err(other)?; + match &key { + DeploymentKey::Environment(name) => unlock_refusal(&found, name), + DeploymentKey::User { env, owner_key } => onboarding_unlock_refusal(&found, env, owner_key), + DeploymentKey::Boundary => unreachable!("refused above"), + } + .map_err(other)?; if tofu::is_running(&ctx.workdir(key.deployment())?) { return Err(other("A deploy on this machine holds the lock")); } @@ -1159,6 +1242,50 @@ pub async fn follow_aws_deployment( Ok(op_id) } +/// Push the state a failed run on this machine couldn't save +/// (`errored.tfstate`) back to the bucket: `tofu state push errored.tfstate` +/// through the same detached runner and environment as a deploy, followed +/// like one. Once pushed the file is set aside and the deployment can be +/// re-applied (until then [`tofu::materialize`] refuses). +#[tauri::command] +pub async fn push_aws_saved_state( + connection_id: String, + region: String, + state_key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let key = parse_state_key(&state_key)?; + let ctx = context(&state, &connection_id, ®ion).await?; + opened(launch_push(app, &ctx, &key).await) +} + +async fn launch_push(app: AppHandle, ctx: &Ctx, key: &DeploymentKey) -> Result<(String, Gate), AwsError> { + let d = key.deployment(); + let label = key.label(); + let wd = ctx.workdir(d)?; + if tofu::is_running(&wd) { + return Err(start_error(StartError::AlreadyRunning, &label)); + } + if !tofu::has_errored_state(&wd) { + return Err(other(format!( + "There is no saved state of {label} on this machine to push" + ))); + } + let _launching = LaunchGuard::take(&wd.dir, &label)?; + let tofu_bin = tofu::ensure_tofu(&ctx.root).await?; + let env = runner_env(ctx)?; + let start_wd = wd.clone(); + let started = tokio::task::spawn_blocking(move || { + tofu::start_detached(&tofu_bin, &start_wd, TofuOp::PushState, &env) + }) + .await + .map_err(|e| other(format!("The push could not be started: {e}")))? + .map_err(|e| start_error(e, &label))?; + let can_cancel = cfg!(unix) && started.pid.is_some(); + install_follower(app, wd, state_key(d), &label, can_cancel, true) +} + /// Cancel: interrupt the runner, which stops OpenTofu gracefully. #[tauri::command] pub async fn cancel_aws_deployment( @@ -1189,7 +1316,7 @@ pub async fn onboard_aws_user( onboard_refusal(&found, &env).map_err(other)?; let vars = onboarding_vars(&env, &ctx.region, &owner); let key = DeploymentKey::User { - owner_key: owner_key(&owner), + owner_key: owner_key(&ctx.region, &owner), env, }; opened(launch(app, &ctx, &key, &vars, TofuOp::Apply).await) @@ -1273,7 +1400,7 @@ pub async fn save_aws_admin_bundle( let vars = onboarding_vars(&env, ®ion_ok, &owner); ( DeploymentKey::User { - owner_key: owner_key(&owner), + owner_key: owner_key(®ion_ok, &owner), env, }, vars, diff --git a/app/src-tauri/src/commands/environment_commands/tests.rs b/app/src-tauri/src/commands/environment_commands/tests.rs index 63ab0f3..0b0b5a1 100644 --- a/app/src-tauri/src/commands/environment_commands/tests.rs +++ b/app/src-tauri/src/commands/environment_commands/tests.rs @@ -43,6 +43,8 @@ fn env(name: &str, state: EnvironmentState, actions: Vec) -> actions, info: None, onboarding: None, + onboarding_actions: Vec::new(), + onboarded_users: Some(0), } } @@ -51,7 +53,7 @@ fn scan(boundary: Knowable, environments: Vec) -> Environme region: REGION.into(), account: ACCOUNT.into(), owner_id: USER_ID.into(), - owner_key: owner_key(USER_ID), + owner_key: owner_key(REGION, USER_ID), state_bucket: Knowable::Yes, boundary, environments, @@ -70,7 +72,7 @@ fn lock(age_secs: u64, local_process: bool) -> LockInfo { #[test] fn only_our_state_keys_are_accepted() { - let ok = owner_key(USER_ID); + let ok = owner_key(REGION, USER_ID); let good = [ ( "environments/dev/terraform.tfstate".to_string(), @@ -189,7 +191,7 @@ fn the_vpc_is_read_from_the_state_outputs() { #[test] fn bundles_contain_the_module_backend_vars_and_readme() { let dir = tempfile::tempdir().unwrap(); - let ok = owner_key(USER_ID); + let ok = owner_key(REGION, USER_ID); let d = Deployment::User { env: "dev", owner_key: &ok }; let vars = onboarding_vars("dev", REGION, USER_ID); let written = write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap(); @@ -225,7 +227,29 @@ fn bundles_contain_the_module_backend_vars_and_readme() { assert!(readme.contains(needle), "README lacks {needle:?}:\n{readme}"); } // Outside us-east-1 the bucket needs a location constraint. - assert!(readme.contains(&format!("LocationConstraint={REGION}")), "{readme}"); + assert!(readme.contains(&format!("\"LocationConstraint\":\"{REGION}\"")), "{readme}"); + // The admin policy allows s3:CreateBucket only with the component tag + // in the request, so the tag goes on the create itself; and the bucket + // is configured as the app configures it, TLS-only policy included. + let create = readme + .lines() + .find(|l| l.contains("aws s3api create-bucket")) + .unwrap(); + assert!( + create.contains(r#""Tags":[{"Key":"triple-c:component","Value":"state"}]"#), + "{create}" + ); + for needle in [ + "put-bucket-versioning", + "put-bucket-encryption", + "put-public-access-block", + "put-bucket-policy", + "aws:SecureTransport", + "DenyInsecureTransport", + ] { + assert!(readme.contains(needle), "README lacks {needle:?}:\n{readme}"); + } + assert!(!readme.contains("put-bucket-tagging"), "a later tag can't satisfy the create"); // Writing again over our own files is fine... write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap(); @@ -247,6 +271,7 @@ fn a_us_east_1_bundle_creates_its_bucket_without_a_location_constraint() { let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap(); assert!(readme.contains("aws s3api create-bucket"), "{readme}"); assert!(!readme.contains("LocationConstraint"), "{readme}"); + assert!(readme.contains(r#""Tags":[{"Key":"triple-c:component","Value":"state"}]"#), "{readme}"); assert!(readme.contains("tofu destroy"), "{readme}"); for (name, _) in module_files(ModuleKind::Environment) { assert!(dir.path().join(name).is_file(), "{name}"); @@ -316,7 +341,7 @@ fn onboarding_needs_a_joinable_environment_and_the_boundary() { let mut ready = env("dev", EnvironmentState::Ready, vec![Join]); ready.onboarding = Some(OnboardingState::NotJoined); let mut busy = env("busy", EnvironmentState::Ready, vec![Join]); - busy.onboarding = Some(OnboardingState::InProgress); + busy.onboarding = Some(OnboardingState::InProgress { lock: None }); let half = env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]); let s = scan(Knowable::Yes, vec![ready.clone(), busy, half]); assert!(onboard_refusal(&s, "dev").is_ok()); @@ -461,6 +486,51 @@ fn destroy_needs_the_action_no_record_and_no_lock() { assert_eq!(destroy_refusal(&s, "half").unwrap().name, "half"); } +#[test] +fn destroy_is_refused_while_users_are_onboarded() { + use EnvironmentAction::*; + let mut onboarded = env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]); + onboarded.onboarded_users = Some(2); + let mut unknown = env("blind", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]); + unknown.onboarded_users = None; + let s = scan(Knowable::Yes, vec![onboarded, unknown]); + let e = destroy_refusal(&s, "half").unwrap_err(); + assert!(e.contains("remove the onboarded users' stacks first"), "{e}"); + assert!(e.contains("users/half/"), "{e}"); + // Without a listing there's no telling, and nothing to destroy from. + assert!(destroy_refusal(&s, "blind").unwrap_err().contains("can't list")); +} + +#[test] +fn only_your_own_stale_onboarding_lock_can_be_removed() { + use EnvironmentAction::*; + let mut stale = env("dev", EnvironmentState::Ready, vec![Join]); + stale.onboarding = Some(OnboardingState::Incomplete { lock: Some(lock(9000, false)) }); + stale.onboarding_actions = vec![Unlock]; + let mut live = env("live", EnvironmentState::Ready, vec![Join]); + live.onboarding = Some(OnboardingState::InProgress { lock: Some(lock(10, false)) }); + let mut none = env("none", EnvironmentState::Ready, vec![Join]); + none.onboarding = Some(OnboardingState::Incomplete { lock: None }); + let s = scan(Knowable::Yes, vec![stale, live, none]); + let mine = owner_key(REGION, USER_ID); + assert!(onboarding_unlock_refusal(&s, "dev", &mine).is_ok()); + let e = onboarding_unlock_refusal(&s, "dev", "0123456789ab").unwrap_err(); + assert!(e.contains("your own"), "{e}"); + assert!(onboarding_unlock_refusal(&s, "live", &mine).is_err()); + assert!(onboarding_unlock_refusal(&s, "none", &mine).is_err()); + assert!(onboarding_unlock_refusal(&s, "missing", &mine).is_err()); +} + +#[test] +fn a_boundary_bundle_names_an_administrator_without_the_admin_files() { + let dir = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), Deployment::Boundary, ACCOUNT, REGION, &boundary_vars(REGION)).unwrap(); + let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap(); + assert!(readme.contains("account administrator"), "{readme}"); + assert!(readme.contains("WITHOUT"), "{readme}"); + assert!(!readme.contains("that carry the Triple-C environment-admin"), "{readme}"); +} + #[test] fn repair_and_update_are_refused_under_a_held_lock() { use EnvironmentAction::*; diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index fe3243d..b94e2b5 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -618,6 +618,7 @@ pub fn run() { commands::environment_commands::unlock_aws_state, commands::environment_commands::follow_aws_deployment, commands::environment_commands::cancel_aws_deployment, + commands::environment_commands::push_aws_saved_state, commands::environment_commands::onboard_aws_user, commands::environment_commands::deploy_aws_permissions_boundary, commands::environment_commands::save_aws_admin_bundle, diff --git a/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx b/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx index f6f37dc..93f2656 100644 --- a/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx +++ b/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx @@ -419,4 +419,131 @@ describe("EnvironmentsPanel", () => { await screen.findByText("The state bucket name triple-c-tfstate-123456789012-eu-west-1 is already taken by another AWS account."), ).toBeInTheDocument(); }); + it("explains an environment managed elsewhere and an incomplete one", async () => { + backend(() => + scan({ + environments: [ + env({ name: "shared", state: { state: "externally_managed" }, actions: ["join"] }), + env({ name: "half", state: { state: "incomplete", lock: null }, actions: ["repair", "destroy"], info: null, onboarding: null }), + ], + }), + ); + render(); + await open(); + const shared = await screen.findByTestId("env-shared"); + expect(within(shared).getByText("Managed outside this account's Triple-C state")).toBeInTheDocument(); + expect(within(shared).getByText(/can't be updated from here/)).toBeInTheDocument(); + const half = screen.getByTestId("env-half"); + expect(within(half).getByText(/The last change didn't finish/)).toBeInTheDocument(); + }); + + it("disables Repair and Destroy while a lock is held, saying Unlock first", async () => { + const lock = { who: "bob@desk", created: "x", operation: "OperationTypeApply", age_secs: 3 * 3600, local_process: false }; + backend(() => + scan({ environments: [env({ state: { state: "incomplete", lock }, actions: ["repair", "destroy", "unlock"], info: null, onboarding: null })] }), + ); + render(); + await open(); + const row = await screen.findByTestId("env-dev"); + expect(within(row).getByRole("button", { name: "Repair dev" })).toBeDisabled(); + expect(within(row).getByRole("button", { name: "Destroy dev" })).toBeDisabled(); + expect(within(row).getByRole("button", { name: "Unlock dev" })).toBeEnabled(); + expect(within(row).getByText("Unlock first")).toBeInTheDocument(); + }); + + it("an incomplete onboarding keeps Join and offers its own Unlock", async () => { + const lock = { who: "me@old", created: "x", operation: "OperationTypeApply", age_secs: 3 * 3600, local_process: false }; + backend( + () => + scan({ + environments: [env({ onboarding: { state: "incomplete", lock }, onboarding_actions: ["unlock"] })], + }), + (cmd) => { + if (cmd === "unlock_aws_state") return undefined; + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + const row = await screen.findByTestId("env-dev"); + expect(within(row).getByText("Onboarding incomplete")).toBeInTheDocument(); + expect(within(row).getByText(/Your last onboarding didn't finish/)).toBeInTheDocument(); + expect(within(row).getByRole("button", { name: "Join dev" })).toBeDisabled(); + fireEvent.click(within(row).getByRole("button", { name: "Unlock my onboarding to dev" })); + const dialog = screen.getByRole("dialog", { name: "Unlock your onboarding to dev" }); + expect(within(dialog).getByText(/me@old/)).toBeInTheDocument(); + fireEvent.change(within(dialog).getByLabelText(/Type dev to confirm/), { target: { value: "dev" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "Unlock" })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("unlock_aws_state", { + connectionId: "h1", + region: "eu-west-1", + stateKey: "users/dev/abc123abc123/terraform.tfstate", + }), + ); + }); + + it("pushes a saved state back from the environment and the onboarding rows", async () => { + backend( + () => + scan({ + environments: [ + env({ name: "a", state: { state: "incomplete", lock: null }, actions: ["push_state"], info: null, onboarding: null }), + env({ name: "b", onboarding: { state: "incomplete", lock: null }, onboarding_actions: ["push_state"] }), + ], + }), + (cmd) => { + if (cmd === "push_aws_saved_state") return "p1"; + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + const a = await screen.findByTestId("env-a"); + expect(within(a).getByText(/couldn't save its state/)).toBeInTheDocument(); + fireEvent.click(within(a).getByRole("button", { name: "Push saved state of a" })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("push_aws_saved_state", { + connectionId: "h1", + region: "eu-west-1", + stateKey: "environments/a/terraform.tfstate", + }), + ); + const b = screen.getByTestId("env-b"); + // Join would only be refused until the saved state is back. + expect(within(b).getByRole("button", { name: "Join b" })).toBeDisabled(); + fireEvent.click(within(b).getByRole("button", { name: "Push saved state of my onboarding to b" })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith("push_aws_saved_state", { + connectionId: "h1", + region: "eu-west-1", + stateKey: "users/b/abc123abc123/terraform.tfstate", + }), + ); + }); + + it("follows an onboarding running on this machine", async () => { + backend( + () => + scan({ + environments: [env({ onboarding: { state: "in_progress", lock: null }, onboarding_actions: ["follow"] })], + }), + async (cmd) => { + if (cmd === "follow_aws_deployment") { + emit(progress({ op_id: "f2", state_key: "users/dev/abc123abc123/terraform.tfstate", events: [tofuEvent("joining line")] })); + return "f2"; + } + throw new Error(`unexpected ${cmd}`); + }, + ); + render(); + await open(); + expect(await screen.findByText("joining line")).toBeInTheDocument(); + expect(invoke).toHaveBeenCalledWith("follow_aws_deployment", { + connectionId: "h1", + region: "eu-west-1", + stateKey: "users/dev/abc123abc123/terraform.tfstate", + }); + expect(within(screen.getByTestId("env-dev")).getByRole("button", { name: "Follow my onboarding to dev" })).toBeInTheDocument(); + }); }); diff --git a/app/src/components/settings/hosts/EnvironmentsPanel.tsx b/app/src/components/settings/hosts/EnvironmentsPanel.tsx index bec40ad..05d8002 100644 --- a/app/src/components/settings/hosts/EnvironmentsPanel.tsx +++ b/app/src/components/settings/hosts/EnvironmentsPanel.tsx @@ -5,6 +5,7 @@ import { detectAwsEnvironments, followAwsDeployment, onboardAwsUser, + pushAwsSavedState, repairAwsEnvironment, unlockAwsState, updateAwsEnvironment, @@ -15,6 +16,7 @@ import type { DetectedEnvironment, EnvironmentScan, HostConnection, + LockInfo, ModuleKind, NetworkChoice, PermissionSummary, @@ -132,7 +134,14 @@ export default function EnvironmentsPanel({ const [deployOpen, setDeployOpen] = useState(false); const [repairOf, setRepairOf] = useState(null); const [destroyOf, setDestroyOf] = useState<{ name: string; region: string } | null>(null); - const [unlockOf, setUnlockOf] = useState<{ env: DetectedEnvironment; region: string } | null>(null); + /** An Unlock waiting for its typed confirmation: an environment's lock or the caller's onboarding's. */ + const [unlockOf, setUnlockOf] = useState<{ + name: string; + region: string; + stateKey: string; + lock: LockInfo | null; + onboarding: boolean; + } | null>(null); const [bundle, setBundle] = useState(null); const [copyNote, setCopyNote] = useState(null); const request = useRef(0); @@ -178,17 +187,21 @@ export default function EnvironmentsPanel({ [start], ); - // Reattach to a deploy running on this machine (a re-follow replays its whole log). + // Reattach to a deploy (or the caller's onboarding) running on this machine; + // a re-follow replays its whole log. useEffect(() => { if (!scan) return; - for (const e of scan.environments) { - const key = environmentStateKey(e.name); + const reattach = (name: string, key: string, label: string) => { const seen = runKey(scan.region, key); - if (e.actions.includes("follow") && !runs[seen] && !followed.current.has(seen)) { + if (!runs[seen] && !followed.current.has(seen)) { followed.current.add(seen); - void launch(scan.region, e.name, key, `Deploy of ${e.name}`, () => - followAwsDeployment(connection.id, scan.region, key), - ); + void launch(scan.region, name, key, label, () => followAwsDeployment(connection.id, scan.region, key)); + } + }; + for (const e of scan.environments) { + if (e.actions.includes("follow")) reattach(e.name, environmentStateKey(e.name), `Deploy of ${e.name}`); + if (e.onboarding_actions.includes("follow")) { + reattach(e.name, onboardingStateKey(e.name, scan.owner_key), `Joining ${e.name}`); } } }, [scan, runs, launch, connection.id]); @@ -251,7 +264,19 @@ export default function EnvironmentsPanel({ const status = stateStatus(e.state); const lock = stateLock(e.state); const has = (a: DetectedEnvironment["actions"][number]) => e.actions.includes(a); + const mine = (a: DetectedEnvironment["actions"][number]) => e.onboarding_actions.includes(a); const onboarding = e.onboarding?.state; + const myKey = onboardingStateKey(e.name, found.owner_key); + const myLock = + e.onboarding && (e.onboarding.state === "incomplete" || e.onboarding.state === "in_progress") + ? e.onboarding.lock + : null; + // Someone else's run holds the state: a re-apply or destroy would only + // fail inside its run, so those wait for Unlock. + const lockHeld = !!lock && !lock.local_process; + // Join re-applies the onboarding: not under a held lock, nor before a + // saved state is pushed back. + const joinBlocked = (!!myLock && !myLock.local_process) || mine("push_state"); const busy = stale || !!pending[rowKey(at, e.name)]; const rowError = rowErrors[rowKey(at, e.name)]; const isWaiting = waiting.has(rowKey(at, e.name)); @@ -283,16 +308,102 @@ export default function EnvironmentsPanel({ {e.state.state === "unreadable_record" && (

    The environment's record can't be used: {e.state.reason}
    )} + {e.state.state === "externally_managed" && ( +
    + Its OpenTofu state isn't in this region's Triple-C state bucket (deployed from a bundle with another + bucket, or you can't read it), so it can't be updated from here. +
    + )} + {e.state.state === "incomplete" && ( +
    + {lockHeld + ? "The last change didn't finish: its run stopped while holding the state lock. Unlock first, then Repair." + : "The last change didn't finish: the deployment has state but no working environment record."} +
    + )} + {has("push_state") && ( +
    + The last run on this machine couldn't save its state (OpenTofu kept it in errored.tfstate). Push it back + before anything else runs here. +
    + )} + {onboarding === "incomplete" && ( +
    + {myLock && !myLock.local_process + ? `Your last onboarding didn't finish and still holds its state lock (${lockLine(myLock)}). Unlock it, then Join again to finish.` + : mine("push_state") + ? "Your last onboarding didn't finish and couldn't save its state; push the saved state back, then Join again." + : "Your last onboarding didn't finish; Join again to finish it."} +
    + )} {!isAdmin && isWaiting && onboarding !== "joined" && (
    Waiting for an admin to onboard you
    )}
    {has("join") && onboarding !== "joined" && onboarding !== "in_progress" && ( - )} + {mine("follow") && ( + + )} + {mine("push_state") && ( + + )} + {mine("unlock") && ( + + )} + {has("push_state") && ( + + )} {has("follow") && ( )} {has("unlock") && ( - )} {has("destroy") && ( - )} + {lockHeld && (has("repair") || has("destroy")) && ( + Unlock first + )}
    {rowError && ( @@ -466,13 +594,13 @@ export default function EnvironmentsPanel({ )} {unlockOf && ( setUnlockOf(null)} onConfirm={async () => { try { - await unlockAwsState(connection.id, unlockOf.region, environmentStateKey(unlockOf.env.name)); + await unlockAwsState(connection.id, unlockOf.region, unlockOf.stateKey); } catch (e) { return toAwsError(e); } @@ -481,7 +609,7 @@ export default function EnvironmentsPanel({ }} > {(() => { - const lock = stateLock(unlockOf.env.state); + const lock = unlockOf.lock; return lock ? (

    Held by {lock.who || "unknown"} since{" "} diff --git a/app/src/components/settings/hosts/environmentFormat.ts b/app/src/components/settings/hosts/environmentFormat.ts index ab1e49a..3ed82c7 100644 --- a/app/src/components/settings/hosts/environmentFormat.ts +++ b/app/src/components/settings/hosts/environmentFormat.ts @@ -86,7 +86,9 @@ export function stateStatus(s: EnvironmentState): { tone: StatusTone; label: str case "incomplete": return { tone: "error", label: "Incomplete" }; case "externally_managed": - return { tone: "ok", label: "Available" }; + // Its state isn't in this region's Triple-C bucket (an admin bundle's + // own bucket, by hand, or not readable here), so Update isn't offered. + return { tone: "ok", label: "Managed outside this account's Triple-C state" }; case "unreadable_record": return { tone: "error", label: "Unreadable record" }; } diff --git a/app/src/components/settings/hosts/testkit.ts b/app/src/components/settings/hosts/testkit.ts index 1532b68..0612e22 100644 --- a/app/src/components/settings/hosts/testkit.ts +++ b/app/src/components/settings/hosts/testkit.ts @@ -51,6 +51,8 @@ export function env(over: Partial = {}): DetectedEnvironmen actions: ["join"], info: info(over.name ?? "dev"), onboarding: { state: "not_joined" }, + onboarding_actions: [], + onboarded_users: 0, ...over, }; } diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 3d7dbf7..947d2af 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -103,6 +103,9 @@ export const followAwsDeployment = (connectionId: string, region: string, stateK invoke("follow_aws_deployment", { connectionId, region, stateKey }); export const cancelAwsDeployment = (connectionId: string, region: string, stateKey: string) => invoke("cancel_aws_deployment", { connectionId, region, stateKey }); +/** Push `errored.tfstate` (a failed run's unsaved state) back to the bucket; resolves to an op id like a deploy. */ +export const pushAwsSavedState = (connectionId: string, region: string, stateKey: string) => + invoke("push_aws_saved_state", { connectionId, region, stateKey }); export const onboardAwsUser = (connectionId: string, region: string, environment: string) => invoke("onboard_aws_user", { connectionId, region, environment }); export const deployAwsPermissionsBoundary = (connectionId: string, region: string) => diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 6b6c220..cbd4056 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -1234,13 +1234,15 @@ export type EnvironmentState = | { state: "externally_managed" } | { state: "unreadable_record"; reason: string }; -export type EnvironmentAction = "join" | "update" | "repair" | "destroy" | "follow" | "unlock"; +/** `push_state`: push the state a failed run on this machine couldn't save (`errored.tfstate`) back to the bucket. */ +export type EnvironmentAction = "join" | "update" | "repair" | "destroy" | "follow" | "unlock" | "push_state"; +/** `incomplete`: the last onboarding run didn't finish (Join re-applies and finishes it). */ export type OnboardingState = | { state: "joined" } | { state: "not_joined" } - | { state: "in_progress" } - | { state: "incomplete" }; + | { state: "in_progress"; lock: LockInfo | null } + | { state: "incomplete"; lock: LockInfo | null }; export type Knowable = "yes" | "no" | "unknown"; @@ -1251,6 +1253,10 @@ export interface DetectedEnvironment { actions: EnvironmentAction[]; info: EnvironmentInfo | null; onboarding: OnboardingState | null; + /** What the caller can do with their own onboarding's run: `follow`, `unlock`, `push_state`. */ + onboarding_actions: EnvironmentAction[]; + /** Users onboarded to it (non-empty `users//…` states); null when the bucket can't be listed. */ + onboarded_users: number | null; } export interface EnvironmentScan { diff --git a/aws/README.md b/aws/README.md index 7f3774c..e1696bc 100644 --- a/aws/README.md +++ b/aws/README.md @@ -232,6 +232,12 @@ it is unpacked), and an admin can run the very same files by hand from a bundle. | `terraform/environment` | what every user shares: ECS cluster (Fargate, Fargate Spot), the tasks security group (no inbound rules), KMS key and alias, log group `/triple-c/`, the network (`network_mode`: `public`, `private-nat`, `existing`) and, last, the record `/triple-c/environments/` | `environments//terraform.tfstate` | | `terraform/user-onboard` | one user's task and execution roles (with the boundary) and their own EFS file system, its `-efs` security group, two mount targets and a file-system policy that admits only their task role | `users///terraform.tfstate` | +The **owner key** is the first 12 hex characters of `sha256(":")`; it names +the user's roles, `triple-c---task` and `-exec` (a `UserId` holds a `:`, which +role names reject). The region is in it because IAM role names are account-wide while +environments are regional: the same user in an environment of the same name in two regions gets +two sets of roles. The module checks the key against `region` and `owner_id`. + Every module pins the AWS provider (`hashicorp/aws` 6.73.0, hashes in `.terraform.lock.hcl`), sets `region` explicitly, never overrides an endpoint, and tags everything through `default_tags` (`triple-c:component`, plus the environment, owner and template version). The onboarding module @@ -239,30 +245,46 @@ reads the environment only from its record, never from its state. **The state bucket** is `triple-c-tfstate--`, one per account and region. The app creates it on the first deploy (versioning, SSE-S3, all four public-access blocks, a TLS-only -bucket policy, tagged `triple-c:component=state`); every call carries `expected_bucket_owner`, so a +bucket policy, tagged `triple-c:component=state`; the tag goes on the create itself, which the +admin policy requires). An existing bucket policy is kept: the TLS-only statement is added to it +when missing, never put over an organisation's own statements. Every call carries `expected_bucket_owner`, so a bucket of that name in another account is refused, never used. Locking is S3-native (`use_lockfile = true`): a run holds `.tflock`, and a held lock is what "deploying" -means to every other copy of the app. The app removes a stale lock (older than two hours, an -environment's only) with a conditional delete on the lock's ETag, so a lock taken since is never +means to every other copy of the app. The app removes a stale lock (older than two hours: an +environment's, or the caller's own onboarding's) with a conditional delete on the lock's ETag, so a lock taken since is never removed. `tofu destroy` leaves an *empty* state object behind; detection reads an empty state as no deployment at all. **How the app runs a deployment.** It writes the module into a work folder (`/triple-c/aws/work///`) with `backend.hcl` and `terraform.tfvars.json`, then starts a detached runner (the app binary re-executed with -`--tofu-runner`) that runs `tofu init` and `tofu apply` (or `destroy`) in its own process group, +`--tofu-runner`) that runs `tofu init` and `tofu apply` (or `destroy`) in its own session, logging to `apply.log`. Closing the app does not stop it; the next start reattaches by following the -log. Cancel interrupts OpenTofu, which stops gracefully and saves its state. OpenTofu's environment +log. Cancel interrupts OpenTofu, which stops gracefully and saves its state. Cancel is not +available on Windows yet (a run there goes on to the end). If OpenTofu cannot save the state after +a run it keeps it in `errored.tfstate` in the work folder; the run ends with an error saying so, +nothing else is started for that deployment until it is pushed back, and **Push saved state** on +the row runs `tofu state push errored.tfstate` through the same runner (by hand: `tofu init +-backend-config=backend.hcl`, then `tofu state push errored.tfstate`, from the work folder). OpenTofu's environment is built from scratch: no inherited `AWS_*`, `TF_*` or `TOFU_*` variables, the connection's profile name or its keys (in the environment only, never in arguments or files), and the app's own CLI config, so a `~/.tofurc` cannot redirect provider downloads. The app destroys only an incomplete -deployment (state without a record, or a failed apply); a working environment is removed by an -admin with `tofu destroy` from a bundle. +deployment (state without a record, or a failed apply), and never while any user's onboarding +state for it remains; a working environment is removed by an admin with `tofu destroy` from a +bundle. + +**Onboarding detection.** A user is *Joined* when their task role exists and, where the bucket is +readable, their state holds the module's `onboarding_complete` output, which depends on every +resource the module creates, so a run that failed part-way never writes it. A partial state, a +failed (or errored) run on this machine, or a stale lock reads as *Incomplete*, and Join (an +idempotent re-apply) stays offered to finish it; Follow, Unlock and Push saved state work on the +user's own onboarding as on an environment. **Admin bundles.** When the signed-in identity cannot deploy (or the admin prefers to), the app saves a folder with the module, `backend.hcl`, `terraform.tfvars.json` and a `README.txt` holding the exact commands. By hand, with credentials carrying the admin files (OpenTofu or Terraform 1.10 -or later): +or later); the permissions-boundary bundle instead needs an account administrator **without** the +admin files attached, since their Deny on the boundary policy applies to anyone holding them: ```sh cd @@ -272,7 +294,12 @@ tofu apply # or: tofu destroy Remove every user's onboarding before destroying their environment. With Terraform, first run `terraform providers lock` for your platforms (the lock file carries OpenTofu's registry hashes). -The bundle's `README.txt` also shows how to create the state bucket by hand if the app never has. +The bundle's `README.txt` also shows how to create the state bucket by hand if the app never has, +exactly as the app configures it: `create-bucket` with the `triple-c:component=state` tag in its +`--create-bucket-configuration` (the admin policy allows the create only with that tag in the +request, so a later `put-bucket-tagging` would not do; this needs an AWS CLI v2 whose +`create-bucket` accepts `Tags`), then versioning, SSE-S3, the public-access block and the TLS-only +bucket policy. ## Invariants the modules must keep (phase 4) diff --git a/aws/terraform/environment/main.tf b/aws/terraform/environment/main.tf index 9cd9d74..9399947 100644 --- a/aws/terraform/environment/main.tf +++ b/aws/terraform/environment/main.tf @@ -61,6 +61,17 @@ resource "aws_security_group" "tasks" { cidr_blocks = ["0.0.0.0/0"] ipv6_cidr_blocks = ["::/0"] } + + lifecycle { + # Empty lists (a new VPC) pass: only an existing network is looked up. + precondition { + condition = ( + alltrue([for s in data.aws_subnet.existing : s.vpc_id == var.vpc_id]) + && length(distinct([for s in data.aws_subnet.existing : s.availability_zone])) == length(data.aws_subnet.existing) + ) + error_message = "The existing network's two subnets must both be in vpc_id and in different availability zones." + } + } } resource "aws_kms_key" "this" { diff --git a/aws/terraform/environment/network.tf b/aws/terraform/environment/network.tf index 72e4bb5..830856f 100644 --- a/aws/terraform/environment/network.tf +++ b/aws/terraform/environment/network.tf @@ -22,6 +22,15 @@ locals { ) } +# An existing network is the user's, taken by id: look the subnets up so the +# task security group's precondition (main.tf) can refuse subnets outside +# var.vpc_id or both in one availability zone before anything is built -- +# the same check the onboarding module makes of the record. +data "aws_subnet" "existing" { + count = var.network_mode == "existing" ? 2 : 0 + id = var.subnet_ids[count.index] +} + # The subnets' AZs must not move once chosen: a changed availability_zone # replaces the subnet (and the ENIs, NAT gateway, associations and record # hanging off it). So the list is not filtered on `state` -- a temporary diff --git a/aws/terraform/user-onboard/outputs.tf b/aws/terraform/user-onboard/outputs.tf index 7987212..a630c65 100644 --- a/aws/terraform/user-onboard/outputs.tf +++ b/aws/terraform/user-onboard/outputs.tf @@ -17,3 +17,22 @@ output "file_system_arn" { output "efs_security_group_id" { value = aws_security_group.efs.id } + +# Written only when every resource above was applied: a run that fails +# part-way never evaluates it, so the app reads its absence from the state +# as "the last onboarding didn't finish" (Incomplete, Join re-applies) +# rather than Joined. Keep depends_on listing every resource in the module. +output "onboarding_complete" { + value = true + depends_on = [ + aws_efs_file_system.this, + aws_security_group.efs, + aws_efs_mount_target.this, + aws_efs_file_system_policy.this, + aws_iam_role.task, + aws_iam_role_policy.task, + aws_iam_role_policy.task_bedrock, + aws_iam_role.exec, + aws_iam_role_policy.exec, + ] +} diff --git a/aws/terraform/user-onboard/variables.tf b/aws/terraform/user-onboard/variables.tf index 8565f59..eee1894 100644 --- a/aws/terraform/user-onboard/variables.tf +++ b/aws/terraform/user-onboard/variables.tf @@ -40,12 +40,12 @@ variable "owner_id" { } variable "owner_key" { - description = "The first 12 hex characters of sha256(owner_id); names the user's roles, which reject the ':' in owner_id." + description = "The first 12 hex characters of sha256(\":\"); names the user's roles, which reject the ':' in owner_id. The region is included because role names are account-wide and environments are regional." type = string validation { - condition = can(regex("^[0-9a-f]{12}$", var.owner_key)) && var.owner_key == substr(sha256(var.owner_id), 0, 12) - error_message = "owner_key must be the first 12 hex characters of sha256(owner_id)." + condition = can(regex("^[0-9a-f]{12}$", var.owner_key)) && var.owner_key == substr(sha256("${var.region}:${var.owner_id}"), 0, 12) + error_message = "owner_key must be the first 12 hex characters of sha256(\":\")." } } diff --git a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md index 1c77bbc..10a0ee7 100644 --- a/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md +++ b/docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md @@ -54,7 +54,7 @@ Phase 4 added these decisions: exact commands (`tofu init -backend-config=backend.hcl`, then `tofu apply`; Terraform 1.10+ works too). - **D6. The app destroys only incomplete deployments** (state without an environment record, or - a failed apply). A working environment is destroyed by an admin with `tofu destroy` from a + a failed apply), and never while any user's onboarding state for the environment remains. A working environment is destroyed by an admin with `tofu destroy` from a bundle, which is the irreversible case. - **D7. The admin tier spans several policy files** (`environment-admin.json` and `environment-admin-network.json`), all required; `aws_policy_document` returns every file of a @@ -65,13 +65,20 @@ Phase 4 added these decisions: only environments that have a record. - **D9. One state key per deployment**, in the per-account, per-region bucket `triple-c-tfstate--`: `environments//terraform.tfstate`, - `users///terraform.tfstate`, `boundary/terraform.tfstate`. The lock is the + `users///terraform.tfstate`, `boundary/terraform.tfstate`. The owner key is the + first 12 hex characters of `sha256(":")` and also names the user's roles + (`triple-c---task|exec`): role names are account-global and environments + regional, so the region is part of it (the same user and environment name in two regions must + not collide). The lock is the matching `.tflock` object (S3-native locking, `use_lockfile = true`); a held lock is a deploy in progress, and its `Who`/`Created`/`Operation` are shown. -- **D10. Applies are detached.** `tofu apply` runs in its own process group, output to - `/apply.log`, PID in `/apply.pid`. Closing the app doesn't interrupt it; on the - next run the app sees the live runner or the held lock and reattaches by following the log. It - interrupts only when the user presses Cancel, and OpenTofu then stops gracefully and saves state. +- **D10. Applies are detached.** `tofu apply` runs in its own session, output to + `/apply.log`, PID in `/apply.pid` (liveness is the runner's `apply.lock`). A + run that can't save its state leaves `errored.tfstate`; nothing else starts there until it is + pushed back (Push saved state: `tofu state push errored.tfstate` through the same runner). + Closing the app doesn't interrupt it; on the next run the app sees the live runner or the held + lock and reattaches by following the log. It interrupts only when the user presses Cancel (not + on Windows yet), and OpenTofu then stops gracefully and saves state. ### Non-goals (v1) -- 2.52.0 From be6f4f05a40f0a87c31040502c2658798ee53527 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 00:06:26 -0800 Subject: [PATCH 24/24] test(aws): live lifecycle checks Joined from the completion output and NotJoined after removal Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/aws/live_tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src-tauri/src/aws/live_tests.rs b/app/src-tauri/src/aws/live_tests.rs index a740cee..e36e505 100644 --- a/app/src-tauri/src/aws/live_tests.rs +++ b/app/src-tauri/src/aws/live_tests.rs @@ -1550,8 +1550,11 @@ mod environment { assert_ok("onboard", &outcome, &denied); let found = t.scan(&cfg, &who).await; let env = found.environments.iter().find(|x| x.name == name).unwrap(); + // Joined from the state's onboarding_complete output, not just the role. assert_eq!(env.onboarding, Some(OnboardingState::Joined), "{env:#?}"); + assert_eq!(env.onboarded_users, Some(1), "{env:#?}"); let (task_role, exec_role) = user_role_names(&name, &okey); + eprintln!("onboarded with region-scoped roles {task_role} / {exec_role}"); for r in [&task_role, &exec_role] { let role = ok("get the onboarded role", t.e.iam.get_role().role_name(r).send().await); let boundary = role @@ -1596,6 +1599,11 @@ mod environment { ) .await; assert_ok("destroy the onboarding", &outcome, &denied); + // The emptied onboarding state and the deleted roles: not joined. + let found = t.scan(&cfg, &who).await; + let env = found.environments.iter().find(|x| x.name == name).unwrap(); + assert_eq!(env.onboarding, Some(OnboardingState::NotJoined), "{env:#?}"); + assert_eq!(env.onboarded_users, Some(0), "{env:#?}"); let (_, outcome, denied) = t .run( ModuleKind::Environment, -- 2.52.0