Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 be6f4f05a4 test(aws): live lifecycle checks Joined from the completion output and NotJoined after removal
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 5m28s
Build App (Preview) / test (pull_request) Successful in 7m16s
Build App (Preview) / build-windows (pull_request) Successful in 10m8s
Build App (Preview) / build-linux (pull_request) Successful in 14m12s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 00:06:26 -08:00
shadowdaoandClaude Opus 5.5 6911b9de27 fix(aws): final review round for the environment stacks
Critical
- Owner key is sha256("<region>:<aws:userid>") (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/<env>/ 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 <noreply@anthropic.com>
2026-11-15 00:01:32 -08:00
shadowdaoandClaude Opus 5.5 0b3b100a91 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 <noreply@anthropic.com>
2026-11-14 23:38:21 -08:00
shadowdaoandClaude Opus 5.5 ba88323feb 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 <noreply@anthropic.com>
2026-11-14 23:31:49 -08:00
shadowdaoandClaude Opus 5.5 5f38e0d0d2 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 <noreply@anthropic.com>
2026-11-14 23:31:49 -08:00
shadowdaoandClaude Opus 5.5 2f5305eeb1 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 <noreply@anthropic.com>
2026-11-14 23:31:49 -08:00
shadowdaoandClaude Opus 5.5 bc23fc2dee 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 <noreply@anthropic.com>
2026-11-14 22:56:36 -08:00
shadowdaoandClaude Opus 5.5 cf9b5eab3a feat(aws): environments panel — detect, deploy, update, repair, join
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 <noreply@anthropic.com>
2026-11-14 22:50:23 -08:00
shadowdaoandClaude Opus 5.5 a8b83ca63a fix(aws): follow checks the launch slot under the same lock it installs in
The work-folder registry is a pure Slots<T> 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 <noreply@anthropic.com>
2026-11-14 22:41:29 -08:00
shadowdaoandClaude Opus 5.5 964ab993c8 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 <noreply@anthropic.com>
2026-11-14 22:38:18 -08:00
shadowdaoandClaude Opus 5.5 3324e3d6fe 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 <noreply@anthropic.com>
2026-11-14 22:29:57 -08:00
shadowdaoandClaude Opus 5.5 97e6a3e15a fix(aws): never offer Destroy on a working environment; surface credential 403s; keep bucket tags
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 22:20:34 -08:00
shadowdaoandClaude Opus 5.5 f26388969b feat(aws): state bucket, and detection of environments, locks and onboarding
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 22:16:05 -08:00
shadowdaoandClaude Opus 5.5 b7cdf02859 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 <noreply@anthropic.com>
2026-11-14 22:05:31 -08:00
shadowdaoandClaude Opus 5.5 9ad8b99d3c 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 <noreply@anthropic.com>
2026-11-14 22:00:41 -08:00
shadowdaoandClaude Opus 5.5 a000d64577 feat(aws): pinned OpenTofu download and a detached apply runner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 21:46:43 -08:00
shadowdaoandClaude Opus 5.5 02c8616512 fix(aws): pin the state bucket to the account, tag-scope network parents, drop v1-unused grants
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 21:34:57 -08:00
shadowdaoandClaude Opus 5.5 7dbf568696 feat(aws): admin policies cover the Terraform modules; drop CloudFormation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 21:25:48 -08:00
shadowdaoandClaude Opus 5.5 31c0a918e6 fix(aws): EC2-safe SG descriptions, deny other EFS principals, format-check the record
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 21:08:32 -08:00
shadowdaoandClaude Opus 5.5 f061ddb9a4 feat(aws): onboarding and permissions-boundary Terraform modules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 21:03:20 -08:00
shadowdaoandClaude Opus 5.5 6192b7c1e8 fix(aws): keep environment subnet AZs stable; state_key takes a Deployment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:54:42 -08:00
shadowdaoandClaude Opus 5.5 3583bee31e feat(aws): environment Terraform module and naming
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:50:55 -08:00
shadowdaoandClaude Opus 5.5 fe7642ee2c docs: phase 4 plan switches to OpenTofu with S3 state
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:43:51 -08:00
shadowdaoandClaude Opus 5.5 f924040f9e docs: phase 4 plan (environment + onboarding stacks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:34:00 -08:00
jknapp 3bc0e9fc3b Merge pull request 'AWS host phase 3: AWS connections and tiered permission check' (#80) from feat/aws-connection into main
Secret Scan / scan (push) Successful in 30s
2026-11-15 04:14:59 +00:00
shadowdaoandClaude Opus 5.5 8ec2cfece6 fix(aws): final review wave (incomplete badge, removable bad connections, partition regions, format chars, store modify, docs)
Secret Scan / scan (push) Successful in 11s
Build App (Preview) / compute-version (pull_request) Successful in 14s
Build App (Preview) / create-release (pull_request) Successful in 10s
Build App (Preview) / build-linux (pull_request) Successful in 11m3s
Build App (Preview) / build-macos (pull_request) Successful in 5m33s
Build App (Preview) / build-windows (pull_request) Successful in 11m27s
Build App (Preview) / prune-previews (pull_request) Successful in 21s
Secret Scan / scan (pull_request) Successful in 11s
Build App (Preview) / test (pull_request) Successful in 7m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 18:16:19 -08:00
shadowdaoandClaude Opus 5.5 4694ae60c2 fix(aws): narrow principal inspection to SSO and triple-c roles (R18)
InspectOwnPrincipal allowed iam:GetRole and iam:SimulatePrincipalPolicy on
role/*, letting any workspace user enumerate and simulate every role. IAM
refuses ${aws:PrincipalArn} as a Resource and has no role-name variable, so
the role part is now SSO permission-set roles
(role/aws-reserved/sso.amazonaws.com/*) and triple-c-* roles; IAM users stay
limited to themselves. Callers on other roles are probed and reported
Insufficient; the README says how an admin can add their sign-in roles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 18:04:28 -08:00
shadowdaoandClaude Opus 5.5 b527c82400 test(aws): live IAM verification of the tier policies; docs
Real IAM contradicted the policies in five places, now fixed:
- IAM refuses ${aws:PrincipalArn} as a Resource, so neither tier policy
  could be attached. Self-inspection is user/${aws:username} for IAM users
  and role/* for role sessions (no role-name variable exists).
- ecs:ExecuteCommand is authorized against the cluster as well as the task;
  added an unconditioned cluster statement (the owner rule stays on the task).
- Secrets Manager needs kms:Decrypt with GenerateDataKey on a customer key.
- elasticfilesystem:CreateFileSystem has no resource type (Resource "*").
- ssmmessages:OpenDataChannel on the caller's own sessions (AWS docs).

The permission check now uses adaptive retries for IAM (SimulatePrincipalPolicy
throttled a full check), and the UI no longer says the simulator ignores
permission boundaries (it applies the principal's own; verified).

aws::live_tests (ignored, env-gated) repeats the verification with throwaway
triple-c-e2e-* roles, secrets and keys. New drift tests: every Resource is an
ARN or *, and every policy fits a 6,144-character managed policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 17:45:42 -08:00
shadowdaoandClaude Opus 5.5 01eab26a48 fix(aws): Hosts UI sso_expired dead end, add retry safety, focus, profile errors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 17:04:26 -08:00
shadowdaoandClaude Opus 5.5 1bf9c1c959 feat(aws): Settings → Hosts with AWS connections and permission check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 17:02:26 -08:00
shadowdaoandClaude Opus 5.5 90b10d48d2 fix(aws): settings import keeps imported host connections
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:59:52 -08:00
shadowdaoandClaude Opus 5.5 1df7dfc0a8 fix(aws): host connections cannot be changed through update_settings; harden sso login and add path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:57:47 -08:00
shadowdaoandClaude Opus 5.5 1afddc0ba9 feat(aws): Tauri commands for AWS connections and permission checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:54:47 -08:00
shadowdaoandClaude Opus 5.5 c82b63f48a fix(aws): permission summary can't be claimed through errors or probes
check_permissions now returns Result: credential errors from any call,
and non-denied failures resolving the principal, are errors rather than
a report. Other per-check failures (throttling, network) make the row
Unverified with a note, and a noted row blocks its tier. A probed run
never claims EnvironmentAdmin (R15), and the own-principal check whose
denial forced probing is reported Denied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:49:34 -08:00
shadowdaoandClaude Opus 5.5 634d1f5a7e feat(aws): tiered permission check with simulation and probe fallback
check_permissions simulates every check of both tiers against the
caller's resolved principal (bounded to 4 in flight), rendering resources
and literal context values, sending OwnerId and aws:userid as the
caller's user id, and grouping repeated context keys into one StringList
entry. An unsimulatable principal, a denied iam:GetRole or a denied first
simulation falls back to four read-only probes. Results are one row per
action (worst verdict wins).

The trait returns Send futures so the check is Send for Tauri commands.
The module-wide dead_code/unused_imports allow is replaced by allows on
the Task 6 entry points and the re-exports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:43:35 -08:00
shadowdaoandClaude Opus 5.5 7fdd5dc277 fix(aws): explicitly deny Secrets Manager encryption with non-environment keys
The AWS-managed aws/secretsmanager key (or a customer key whose key policy
grants account principals) needs no identity-policy Allow, so the tagged-key
Allow alone did not keep user secrets on environment keys. An explicit Deny
on kms:Encrypt / kms:GenerateDataKey* via secretsmanager when the key lacks
triple-c:component does. README claim corrected; verify-live entry added.
Check.context doc now states the StringList rule as a requirement on the
simulation builder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:34:35 -08:00
shadowdaoandClaude Opus 5.5 9d1630eb97 fix(aws): user secrets on the environment key; no component tag from workspace users
- CreateOwnSecrets requires secretsmanager:KmsKeyId to be a key ARN, and a
  workspace user's kms:Encrypt/GenerateDataKey (via Secrets Manager) only
  works on component-tagged keys, so secrets can't land on the default
  aws/secretsmanager key that the boundary can't decrypt.
- Every workspace statement that sets the owner tag limits aws:TagKeys to
  triple-c:owner/project/environment (ForAllValues:StringEquals), so a user
  can never set triple-c:component.
- Evaluator: multi-valued context keys and ForAllValues (vacuous when
  absent); Check documents repeated keys as one StringList entry.
- README: environment-key invariant and new verify-live entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:31:38 -08:00
shadowdaoandClaude Opus 5.5 8e6983cf53 fix(aws): close secret claiming, CFN service-role and boundary-reach gaps
- workspace-user: TagOwnSecrets refuses component-tagged secrets, plus an
  explicit Deny on Tag/Untag/Put/Delete of component-tagged secrets;
  CreateAccessPoint only on the component-tagged file system.
- environment-admin: Deny PutRolePermissionsBoundary on triple-c-* roles to
  anything but the triple-c boundary; Deny Create/UpdateStack and
  CreateChangeSet on triple-c-* stacks with a service role.
- permissions boundary: kms:Decrypt and EFS client access only on
  component-tagged keys and file systems; ClientRootAccess dropped (access
  points fix the POSIX user).
- README: invariants for phase 4 templates, the actions that are on *, the
  phase-6 access-point root note, and the list to verify live.
- Tests: negated operators in the evaluator, both tiers evaluated together,
  secret claiming, service-role and boundary-reach cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:25:57 -08:00
shadowdaoandClaude Opus 5.5 02d88cb91d fix(aws): bound admin-created roles, tag-scope admin destruction, tighten workspace policy
- Ship aws/policies/permissions-boundary.json (triple-c-permissions-boundary):
  the most any Triple-C role may hold, nothing in IAM.
- environment-admin: role writes require iam:PermissionsBoundary = the
  boundary; explicit Denies keep the boundary on roles and the boundary
  policy unchanged. Security groups, EFS file systems and KMS keys need
  triple-c:component on create and to be modified or destroyed; tagging only
  at create or on already-tagged resources.
- workspace-user: RunTask needs an owner-tagged task definition; EFS access
  points create/delete by owner tag.
- Both tiers: iam:GetRole and iam:SimulatePrincipalPolicy on the caller's own
  ${aws:PrincipalArn} only, so SSO callers can be simulated.
- Checks carry the matching context; render() gains {principal}. The test
  evaluator resolves policy variables and applies explicit Denies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:15:57 -08:00
shadowdaoandClaude Opus 5.5 1c337f9198 feat(aws): shipped tier policies and the permission check table
aws/policies/{workspace-user,environment-admin}.json are the IAM policies
for the two tiers; aws::policies embeds them and holds one representative
simulator check per action. Tests keep table and JSON in lock-step and run
each check through a small IAM-style evaluator, including the isolation
rule: owner-scoped actions are denied for someone else's resource.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:06:33 -08:00
shadowdaoandClaude Opus 5.5 92b27a8e32 fix(aws): hand-built SdkConfig ignores env/profile endpoints and settings; revoked SSO session is SsoExpired
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:59:31 -08:00
shadowdaoandClaude Opus 5.5 4acc36187e feat(aws): SDK config from a connection, caller identity, principal resolution
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:53:47 -08:00
shadowdaoandClaude Opus 5.5 2dea30f4fb feat(aws): fail-closed overwrite check, name local connections, disclose removals in import preview
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:46:05 -08:00
shadowdaoandClaude Opus 5.5 664697b151 feat(aws): disclose imported AWS connections and keys in the import preview
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:43:41 -08:00
shadowdaoandClaude Opus 5.5 e42261bac8 refactor(aws): one definition of the keychain id rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:42:28 -08:00
shadowdaoandClaude Opus 5.5 60dad7b6b6 feat(aws): keyring storage for AWS access keys, carried by settings export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:40:56 -08:00
shadowdaoandClaude Opus 5.5 e8bbe90327 feat(aws): HostConnection model in app settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:37:49 -08:00
shadowdaoandClaude Opus 5.5 7e2800989f Plan: AWS host phase 3, connection and permission check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:27:15 -08:00
jknapp 95991c1309 Merge pull request 'AWS host phase 2b: SSM port-forward transport to the agent, and a Fargate-sized chown' (#78) from feat/ssm-transport into main
Secret Scan / scan (push) Successful in 12s
Build Container / build-container (push) Successful in 20m6s
2026-11-14 23:16:54 +00:00
jknapp b6e59154cc Merge pull request 'CI: cut releases by hand instead of on every merge to main' (#79) from ci/release-gate into main
Secret Scan / scan (push) Successful in 14s
Build STT Container / build-stt-container (push) Successful in 4m30s
Build Container / build-container (push) Successful in 15m20s
2026-11-14 23:16:38 +00:00
shadowdaoandClaude Opus 5.5 bd0a52269b CI: only main publishes :latest, and only from a tag on main
Secret Scan / scan (pull_request) Successful in 12s
Secret Scan / scan (push) Successful in 5s
Build STT Container / build-stt-container (pull_request) Successful in 2m33s
Build Container / build-container (pull_request) Successful in 18m56s
A dispatch from another branch, or a v* tag cut on a branch, could otherwise
become the image every user pulls. An older tag on main is still accepted
so a rollback works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 14:56:40 -08:00
shadowdaoandClaude Opus 5.5 8787f39a38 CI: cut releases by hand instead of on every merge to main
Secret Scan / scan (push) Successful in 25s
Secret Scan / scan (pull_request) Successful in 10s
Build Container / build-container (pull_request) Successful in 1m57s
Build STT Container / build-stt-container (pull_request) Successful in 4m12s
Every merge to main that touched app/ published a signed release, and every
merge touching container/ moved :latest, the image installed apps pull. That
made it impossible to land phased work (the AWS host) on main without
shipping each step.

- build-app.yml runs only on workflow_dispatch. Dispatched on main it
  releases; on any other branch it stays a build/sign/verify dry run.
- Release tags are pinned to the dispatched commit (target_commitish), and
  create-tag now fails if the tag points anywhere else.
- After tagging, the release dispatches build.yml and build-stt.yml with the
  tag. They build that tag and move :latest.
- Pushes to main publish containers as :edge and :<sha> only. The weekly
  security rebuild rebuilds the newest release tag, not main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 14:55:03 -08:00
shadowdaoandClaude Opus 5.5 7462cb05ec AWS spec: phase 2b measurements (SSM, Fargate start, health, re-attach)
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (push) Successful in 7s
Secret Scan / scan (pull_request) Successful in 10s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build Container / build-container (pull_request) Successful in 2m25s
Build App (Preview) / build-macos (pull_request) Successful in 4m36s
Build App (Preview) / build-windows (pull_request) Successful in 9m15s
Build App (Preview) / test (pull_request) Successful in 10m59s
Build App (Preview) / build-linux (pull_request) Successful in 15m27s
Build App (Preview) / prune-previews (pull_request) Successful in 5s
Session stability, idle timeout and the keepalive that defeats it, Fargate
pull and start times with the chown fix, the ECS health check against the
agent, and what survives the app quitting. The SSM supervisor's drop reason
now carries the plugin's exit status as well as its last output line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 12:08:12 -08:00
shadowdaoandClaude Opus 5.5 e3c81fd304 SSM transport: keep an Up tunnel busy so Session Manager's idle timeout never fires
Measured: a silent port forward was cut at 19.7 minutes ("timed out due to
inactivity"), taking every connection through it with it. Every 5 minutes
the supervisor now sends an unauthenticated GET /v1/health through the
tunnel; the agent answers 401 before counting activity, so this keeps the
session alive without defeating idle auto-stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 10:48:29 -08:00
shadowdaoandClaude Opus 5.5 50f3d51946 SSM transport: end sessions whose open is cancelled; cache plugin creds
Review fixes. A StartSession whose open future is dropped (a close racing a
reconnect, a caller timing out the first open) is now ended by a guard
instead of left to SSM's idle timeout. The plugin's credentials are cached
until 15 minutes before expiry, so a prompting credential_process is not
asked again on every reconnect. Fake-plugin tests run the script through
/bin/sh, so a parallel test's fork cannot fail the spawn with ETXTBSY.
CLAUDE.md: host/ssm.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 10:15:04 -08:00
shadowdaoandClaude Opus 5.5 1e85b4d05f SSM port-forward transport to the agent (phase 2b), and a Fargate-sized chown
host::ssm opens one AWS-StartPortForwardingSession to triple-c-agent in an
ECS task by running session-manager-plugin, supervises it, and reopens it
on the same loopback port when it drops, so an AgentEndpoint stays valid.
The StartSession token reaches the plugin through its environment, never
argv; the plugin gets the caller's credentials (and no other AWS_* vars)
so it can resume its own data channel. Unit tests drive the supervisor
with a fake plugin; ignored live tests run against a real task.

entrypoint.sh: chown only what is not already claude's. On Fargate,
/home/claude is the image's own copy, and chown -R copied all of it up:
the agent came up ~5.5 min after the container started; now 4 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 10:07:38 -08:00
97 changed files with 27698 additions and 164 deletions

No files matched your search

+66 -31
View File
@@ -1,20 +1,22 @@
name: Build App
# **Releases are cut by hand.** This runs only when someone dispatches it
# (Actions -> Build App -> Run workflow). Merging to main does not release:
# main can carry finished-but-dormant work (a feature landing in phases) and
# nothing reaches users until a release is cut on purpose.
#
# Dispatched on `main` it releases: Gitea and GitHub releases, the version tag,
# the Linux update channel, and then the container images' `:latest` (see the
# create-tag job). Dispatched on any other branch it is a dry run that builds,
# signs and verifies all three platforms and publishes nothing, which is how to
# exercise Windows signing before a merge. Every publishing step and job
# carries the same `if:` (dispatch event *and* ref main); keep them in step if
# one is ever added.
on:
push:
branches: [main]
paths:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml"
- "scripts/windows-*.ps1"
workflow_dispatch:
# Deliberately **not** on pull_request. Every publishing step here is gated on
# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and
# produced nothing — and it ran alongside build-app-preview.yml, which compiles
# the same three and publishes them. Six OS builds per push, one set of which
# was unreachable. Previews now carry the PR check; this workflow is releases.
# Deliberately **not** on pull_request: build-app-preview.yml carries the PR
# check (tests plus all three platforms), and this workflow is releases only.
env:
# The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after
@@ -262,7 +264,7 @@ jobs:
fi
- name: Upload to Gitea release
if: gitea.event_name == 'push'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
@@ -288,7 +290,7 @@ jobs:
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${{ gitea.sha }}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
;;
*)
@@ -340,7 +342,7 @@ jobs:
# from the versioned release above because the updater's URL must never
# move, and `releases/latest` does.
- name: Publish the Linux update channel
if: gitea.event_name == 'push'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -429,7 +431,7 @@ jobs:
ls -la artifacts/
- name: Upload to Gitea release
if: gitea.event_name == 'push'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
@@ -455,7 +457,7 @@ jobs:
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${{ gitea.sha }}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
;;
*)
@@ -717,7 +719,7 @@ jobs:
dir artifacts\
- name: Upload to Gitea release
if: gitea.event_name == 'push'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
shell: powershell
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -741,8 +743,9 @@ jobs:
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
Write-Host "Release $tag not found, creating"
$body = @{
tag_name = $tag
name = "Triple-C v$env:VERSION (Windows)"
tag_name = $tag
target_commitish = $env:COMMIT_SHA
name = "Triple-C v$env:VERSION (Windows)"
body = "Automated build from commit $env:COMMIT_SHA"
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Headers $headers `
@@ -778,7 +781,7 @@ jobs:
create-tag:
runs-on: ubuntu-latest
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -788,24 +791,56 @@ jobs:
- name: Create version tag
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.compute-version.outputs.version }}
SHA: ${{ gitea.sha }}
run: |
VERSION="${{ needs.compute-version.outputs.version }}"
set -euo pipefail
TAG="v${VERSION}"
echo "Creating tag ${TAG}..."
# Create annotated tag via Gitea API
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
# Usually already there: creating the Linux release creates its tag,
# at this commit (`target_commitish`). Create it if not, then insist
# it points here, because the container step below builds whatever
# the tag names.
if ! curl -fsS -o tag.json -H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}"; then
echo "Creating tag ${TAG} at ${SHA}"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${SHA}\", \"message\": \"Release ${TAG}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags" > tag.json
fi
TAGGED=$(python3 -c "import json; print(json.load(open('tag.json'))['commit']['sha'])")
if [ "${TAGGED}" != "${SHA}" ]; then
echo "Tag ${TAG} points at ${TAGGED}, not the released commit ${SHA}" >&2
exit 1
fi
echo "Tag ${TAG} is at ${SHA}"
echo "Tag ${TAG} created successfully"
# The containers' `:latest` is what every installed app pulls, so it
# moves with an app release and at no other time (pushes to main only
# publish `:edge` and `:<sha>`). Each container workflow builds the tag
# it is handed, not main, so a merge landing mid-release cannot ride
# along.
- name: Release the container images
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.compute-version.outputs.version }}
run: |
set -euo pipefail
for WORKFLOW in build.yml build-stt.yml; do
echo "Dispatching ${WORKFLOW} for v${VERSION}"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"ref\": \"main\", \"inputs\": {\"release_tag\": \"v${VERSION}\"}}" \
"${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/${WORKFLOW}/dispatches"
done
sync-to-github:
runs-on: ubuntu-latest
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITHUB_REPO: shadowdao/triple-c
+69 -6
View File
@@ -11,6 +11,12 @@ on:
paths:
- "stt-container/**"
- ".gitea/workflows/build-stt.yml"
# Dispatched by build-app.yml when a release is cut, with the release's tag.
workflow_dispatch:
inputs:
release_tag:
description: "Release tag to build as :latest (default: the newest vX.Y.Z)"
required: false
env:
REGISTRY: repo.anhonesthost.net
@@ -22,6 +28,65 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# **`:latest` is what installed apps pull, so only a release moves it.**
# A push to main publishes `:edge` and `:<sha>` (for testing a merged
# image by hand); a pull request publishes nothing. The weekly rebuild
# and a release dispatch build a release tag and publish it as `:latest`
# — the tag, not main, so unreleased container changes never reach users.
- name: Pick the source and the tags to publish
id: src
env:
EVENT: ${{ gitea.event_name }}
RELEASE_TAG: ${{ gitea.event.inputs.release_tag }}
REF: ${{ gitea.ref }}
run: |
set -euo pipefail
case "$EVENT" in
push|pull_request)
CHANNEL=edge
;;
schedule|workflow_dispatch)
CHANNEL=latest
# `:latest` reaches every user, so it only comes from main: a
# dispatch from another branch, or a tag cut on one, is refused.
# An older release tag is allowed on purpose — that is a rollback.
if [ "$REF" != "refs/heads/main" ]; then
echo "Refusing to publish :latest from $REF (only main)" >&2
exit 1
fi
git fetch --tags --force
git fetch origin main
if [ -z "${RELEASE_TAG:-}" ]; then
RELEASE_TAG=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
fi
if ! echo "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Not a release tag: '$RELEASE_TAG'" >&2
exit 1
fi
if ! git merge-base --is-ancestor "refs/tags/$RELEASE_TAG" FETCH_HEAD; then
echo "$RELEASE_TAG is not on main; refusing to publish it as :latest" >&2
exit 1
fi
git checkout --detach "refs/tags/$RELEASE_TAG"
echo "Building release $RELEASE_TAG"
;;
*)
echo "Unexpected event $EVENT" >&2
exit 1
;;
esac
SHA=$(git rev-parse HEAD)
{
echo "tags<<EOF"
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$CHANNEL"
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$SHA"
echo "ghcr.io/shadowdao/triple-c-stt:$CHANNEL"
echo "ghcr.io/shadowdao/triple-c-stt:$SHA"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -49,12 +114,10 @@ jobs:
context: ./stt-container
file: ./stt-container/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ gitea.event_name == 'push' }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
ghcr.io/shadowdao/triple-c-stt:latest
ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }}
# A pull_request run only validates; see the tag step for what each
# other event publishes.
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.src.outputs.tags }}
# The gha cache is served by the runner that picks the job up, and
# is unreachable from the job on some of ours (the home box's
# firewalled per-job bridge; ephemeral runners). Import failures
+69 -8
View File
@@ -13,9 +13,15 @@ on:
- ".gitea/workflows/build.yml"
# Weekly rebuild with no commit needed, so OS security fixes published since
# the last push reach the image (see APT_REFRESH in container/Dockerfile).
# Scheduled runs use the default branch, i.e. main.
# It rebuilds the newest *release*, not main, because it moves `:latest`.
schedule:
- cron: "17 4 * * 1"
# Dispatched by build-app.yml when a release is cut, with the release's tag.
workflow_dispatch:
inputs:
release_tag:
description: "Release tag to build as :latest (default: the newest vX.Y.Z)"
required: false
env:
REGISTRY: repo.anhonesthost.net
@@ -27,6 +33,65 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# **`:latest` is what installed apps pull, so only a release moves it.**
# A push to main publishes `:edge` and `:<sha>` (for testing a merged
# image by hand); a pull request publishes nothing. The weekly rebuild
# and a release dispatch build a release tag and publish it as `:latest`
# — the tag, not main, so unreleased container changes never reach users.
- name: Pick the source and the tags to publish
id: src
env:
EVENT: ${{ gitea.event_name }}
RELEASE_TAG: ${{ gitea.event.inputs.release_tag }}
REF: ${{ gitea.ref }}
run: |
set -euo pipefail
case "$EVENT" in
push|pull_request)
CHANNEL=edge
;;
schedule|workflow_dispatch)
CHANNEL=latest
# `:latest` reaches every user, so it only comes from main: a
# dispatch from another branch, or a tag cut on one, is refused.
# An older release tag is allowed on purpose — that is a rollback.
if [ "$REF" != "refs/heads/main" ]; then
echo "Refusing to publish :latest from $REF (only main)" >&2
exit 1
fi
git fetch --tags --force
git fetch origin main
if [ -z "${RELEASE_TAG:-}" ]; then
RELEASE_TAG=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
fi
if ! echo "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Not a release tag: '$RELEASE_TAG'" >&2
exit 1
fi
if ! git merge-base --is-ancestor "refs/tags/$RELEASE_TAG" FETCH_HEAD; then
echo "$RELEASE_TAG is not on main; refusing to publish it as :latest" >&2
exit 1
fi
git checkout --detach "refs/tags/$RELEASE_TAG"
echo "Building release $RELEASE_TAG"
;;
*)
echo "Unexpected event $EVENT" >&2
exit 1
;;
esac
SHA=$(git rev-parse HEAD)
{
echo "tags<<EOF"
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$CHANNEL"
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$SHA"
echo "ghcr.io/shadowdao/triple-c-sandbox:$CHANNEL"
echo "ghcr.io/shadowdao/triple-c-sandbox:$SHA"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -81,16 +146,12 @@ jobs:
context: ./container
file: ./container/Dockerfile
platforms: linux/amd64,linux/arm64
# Publish from main pushes and the weekly scheduled rebuild (which
# runs on main); a pull_request run only validates.
# A pull_request run only validates; see the tag step for what each
# other event publishes.
push: ${{ gitea.event_name != 'pull_request' }}
build-args: |
APT_REFRESH=${{ steps.apt.outputs.stamp }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
ghcr.io/shadowdao/triple-c-sandbox:latest
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
tags: ${{ steps.src.outputs.tags }}
# `ignore-error` is what stops a cache failure failing a build that
# already succeeded. act_runner emulates the GitHub Actions cache
# service on the runner host's LAN address, and the `docker-container`
+7
View File
@@ -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
+79 -4
View File
@@ -287,6 +287,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
`docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`; AWS planned —
`docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`)
wraps execs, file transfer and `is_running`; `sessions.rs` holds `ExecSessionManager`.
`ssm.rs` is the AWS transport to an `Agent` target: one SSM port-forward per task, run through
AWS's `session-manager-plugin` as a child process and reopened **on the same loopback port**
when it drops, so an `AgentEndpoint` stays valid. The `StartSession` token goes to the plugin
through its environment, never argv, and the plugin's environment is stripped of every
inherited `AWS_*` variable before the session's own credentials are added.
`agent.rs` reuses `docker/exec.rs`'s `OneshotOutput`, host-file reader and uid probe so the two
variants cannot drift; an `Agent` target's token never appears in `Debug` or error text.
**Container-interior code must go through `ContainerTarget`, never `docker::exec`, bollard or
@@ -299,6 +304,51 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
`docker/`; get a target from `ContainerTarget::for_project`, `ProjectHost`, or a session
(`ExecSessionManager::get_target`). Base-image migration is the one Docker-only exception
(`DOCKER_ONLY`); shrink it, never grow it.
- **`aws/`** — AWS connections (Settings → Hosts) and the permission check. `connection.rs`
builds a saved `HostConnection`'s `SdkConfig` **by hand, never with
`aws_config::defaults().load()`**: a profile is read through `ProfileFileCredentialsProvider`
for credentials only, so the app's own `AWS_*` variables, `AWS_ENDPOINT_URL*` and a profile's
endpoint/service settings can't redirect or re-credential a connection's signed requests.
`identity.rs` is `GetCallerIdentity` and the principal to simulate (an assumed role's ARN is
recovered with `iam:GetRole`, because SSO roles have an `/aws-reserved/…` path the session ARN
lacks); `error.rs` classifies SDK errors (SSO expired, access denied naming the action, …);
`policies.rs` embeds `aws/policies/*.json` (the tier policies — the admin tier spans several
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 (`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,
secrets and keys, and cleans them up. Re-run it after any policy change; the results are in
`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/<env>` record, owner key — `sha256("<region>:<aws:userid>")`,
12 hex, regional because role names are account-global — per-user roles, state bucket
`triple-c-tfstate-<account>-<region>`, 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
@@ -788,6 +838,12 @@ a project's Docker volumes are deliberately out of scope — this is not a proje
inside a generic "settings replaced" summary. Read this as the standing example of the class of
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
like config but is actually a live credential would have the same problem.
- **AWS connection keys travel in `ExportedSecrets::aws_connection_keys`** (keyed by connection id),
exported and restored like the marketplace tokens. `hosts` are **store-owned**: `update_settings`
restores them via `restore_host_fields`, and `apply_settings_import` re-applies them through
`StoreOwned`. `validate_imported_hosts` runs before any keychain write. The import preview
discloses AWS connections, key counts, overwrites (naming the local connection), failed key
checks and removals.
- **Marketplace account tokens travel in `ExportedSecrets`, not in `AppSettings`.** `Token` and
`GhContainer` accounts' tokens live in the keychain (`triple-c-marketplace-account-<id>`), so
they follow the same "carve out of the keychain, restore before the settings replace, only
@@ -907,6 +963,25 @@ a project's Docker volumes are deliberately out of scope — this is not a proje
## Packaging
### Releases are cut by hand
**Merging to `main` does not release anything users get.** A release is cut by dispatching
`build-app.yml` on `main` (Actions -> Build App -> Run workflow). That one run publishes the Gitea
and GitHub releases for all three platforms, the `vX.Y.Z` tag pinned to the dispatched commit, and
the Linux update channel. Its last step dispatches `build.yml` and `build-stt.yml` with the new tag,
and those rebuild that tag and move `:latest`, which is what installed apps pull.
- Dispatching `build-app.yml` on any other branch is a **dry run**. It builds, signs and verifies,
and publishes nothing. Every publishing step and job is gated on
`gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'`, so give any new
publishing step the same `if:`.
- A push to `main` that touches `container/` or `stt-container/` publishes only `:edge` and
`:<sha>`, never `:latest`. The weekly scheduled sandbox rebuild (OS security fixes) rebuilds the
**newest release tag**, not main.
- This exists so that main can carry dormant work (a feature landing in phases) without each
merge shipping. Until the gate, every merge to main touching `app/` released, and every merge
touching `container/` moved `:latest`.
Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
`build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to
build and publish for an audience the AppImage already serves, and neither could self-update. The
@@ -952,8 +1027,8 @@ quietly. **PR previews are deliberately not signed**, and `build-app-preview.yml
reference the signing secrets. Two reasons: signing is metered (about 1000 signatures a month,
against roughly 50 preview builds a month), and a PR's workflow runs the PR's own code, so a
secret available there is available to whoever can push a branch. To exercise signing before a
merge, dispatch `build-app.yml` on the branch. Every publishing step there is gated on
`gitea.event_name == 'push'`, so a dispatch builds, signs and verifies without releasing.
merge, dispatch `build-app.yml` on the branch. Off `main` it builds, signs and verifies
without releasing (see Releases above).
- `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client
(`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace,
@@ -986,8 +1061,8 @@ app"), never by the preview workflow, never echoed, and never on a command line.
public, so its Actions logs are too. Gitea masks the secret values, and the signing dlib's
`/debug` output carries no tokens (checked against its strings). Anyone who can push to this
repo can reach the secrets through a workflow file, so repo write access is the boundary.
`main` is branch-protected (no direct or force pushes; changes land by merging a PR), so a signed
release only ever comes from a merged, visible change. The
`main` is branch-protected (no direct or force pushes; changes land by merging a PR), and only a
dispatch on `main` publishes, so a signed release only ever comes from a merged, visible change. The
Azure side should hold the rest: an app registration with only the signer role on this one
certificate profile, and a client secret with an expiry. Four things are load-bearing:
+851 -27
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -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"
@@ -55,6 +58,32 @@ similar = "2"
# Marketplace repos are fetched on the host into a bare cache (spec §3).
# Blocking client + rustls: no git binary or OpenSSL needed on the host.
gix = { version = "0.88", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "sha1"] }
# AWS host (docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md). The
# service crates' default `rustls` feature is the *legacy* hyper 0.14 /
# rustls 0.21 client, a second TLS stack next to the one reqwest already
# brings; `default-https-client` is the current one, so defaults are off.
aws-config = { version = "1.13.2", default-features = false, features = ["default-https-client", "rt-tokio", "credentials-process", "sso", "behavior-version-latest"] }
aws-sdk-ssm = { version = "1.137.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
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-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).
# Both are the versions aws-config already resolves.
aws-credential-types = { version = "1.3.1", default-features = false }
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"] }
# 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
@@ -67,6 +96,11 @@ webkit2gtk = "2.0.2"
# their backoff schedule under a paused clock instead of in real seconds.
tokio = { version = "1", features = ["full", "test-util"] }
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 = [] }
+20
View File
@@ -57,6 +57,26 @@
"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",
+1 -1
View File
@@ -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-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-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-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-conLine truncated
{"__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-Line truncated
+1 -1
View File
@@ -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 fielLine truncated
{"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 fielLine truncated
@@ -350,6 +350,18 @@
"const": "allow-acquire-claude-token",
"markdownDescription": "Enables the acquire_claude_token command without any pre-configured scope."
},
{
"description": "Enables the add_aws_keys_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-add-aws-keys-connection",
"markdownDescription": "Enables the add_aws_keys_connection command without any pre-configured scope."
},
{
"description": "Enables the add_aws_profile_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-add-aws-profile-connection",
"markdownDescription": "Enables the add_aws_profile_connection command without any pre-configured scope."
},
{
"description": "Enables the add_marketplace command without any pre-configured scope.",
"type": "string",
@@ -392,6 +404,24 @@
"const": "allow-apply-settings-import",
"markdownDescription": "Enables the apply_settings_import command without any pre-configured scope."
},
{
"description": "Enables the aws_connection_identity command without any pre-configured scope.",
"type": "string",
"const": "allow-aws-connection-identity",
"markdownDescription": "Enables the aws_connection_identity command without any pre-configured scope."
},
{
"description": "Enables the aws_connection_sso_login command without any pre-configured scope.",
"type": "string",
"const": "allow-aws-connection-sso-login",
"markdownDescription": "Enables the aws_connection_sso_login command without any pre-configured scope."
},
{
"description": "Enables the aws_policy_document command without any pre-configured scope.",
"type": "string",
"const": "allow-aws-policy-document",
"markdownDescription": "Enables the aws_policy_document command without any pre-configured scope."
},
{
"description": "Enables the aws_sso_refresh command without any pre-configured scope.",
"type": "string",
@@ -416,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",
@@ -428,6 +464,12 @@
"const": "allow-cancel-marketplace-gh-login",
"markdownDescription": "Enables the cancel_marketplace_gh_login command without any pre-configured scope."
},
{
"description": "Enables the check_aws_permissions command without any pre-configured scope.",
"type": "string",
"const": "allow-check-aws-permissions",
"markdownDescription": "Enables the check_aws_permissions command without any pre-configured scope."
},
{
"description": "Enables the check_browser_view_support command without any pre-configured scope.",
"type": "string",
@@ -518,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",
@@ -554,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",
@@ -770,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",
@@ -830,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",
@@ -866,6 +950,12 @@
"const": "allow-regenerate-web-terminal-token",
"markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope."
},
{
"description": "Enables the remove_host_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-host-connection",
"markdownDescription": "Enables the remove_host_connection command without any pre-configured scope."
},
{
"description": "Enables the remove_marketplace command without any pre-configured scope.",
"type": "string",
@@ -896,6 +986,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",
@@ -914,6 +1010,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",
@@ -1082,6 +1184,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",
@@ -1160,6 +1280,18 @@
"const": "deny-acquire-claude-token",
"markdownDescription": "Denies the acquire_claude_token command without any pre-configured scope."
},
{
"description": "Denies the add_aws_keys_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-add-aws-keys-connection",
"markdownDescription": "Denies the add_aws_keys_connection command without any pre-configured scope."
},
{
"description": "Denies the add_aws_profile_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-add-aws-profile-connection",
"markdownDescription": "Denies the add_aws_profile_connection command without any pre-configured scope."
},
{
"description": "Denies the add_marketplace command without any pre-configured scope.",
"type": "string",
@@ -1202,6 +1334,24 @@
"const": "deny-apply-settings-import",
"markdownDescription": "Denies the apply_settings_import command without any pre-configured scope."
},
{
"description": "Denies the aws_connection_identity command without any pre-configured scope.",
"type": "string",
"const": "deny-aws-connection-identity",
"markdownDescription": "Denies the aws_connection_identity command without any pre-configured scope."
},
{
"description": "Denies the aws_connection_sso_login command without any pre-configured scope.",
"type": "string",
"const": "deny-aws-connection-sso-login",
"markdownDescription": "Denies the aws_connection_sso_login command without any pre-configured scope."
},
{
"description": "Denies the aws_policy_document command without any pre-configured scope.",
"type": "string",
"const": "deny-aws-policy-document",
"markdownDescription": "Denies the aws_policy_document command without any pre-configured scope."
},
{
"description": "Denies the aws_sso_refresh command without any pre-configured scope.",
"type": "string",
@@ -1226,6 +1376,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",
@@ -1238,6 +1394,12 @@
"const": "deny-cancel-marketplace-gh-login",
"markdownDescription": "Denies the cancel_marketplace_gh_login command without any pre-configured scope."
},
{
"description": "Denies the check_aws_permissions command without any pre-configured scope.",
"type": "string",
"const": "deny-check-aws-permissions",
"markdownDescription": "Denies the check_aws_permissions command without any pre-configured scope."
},
{
"description": "Denies the check_browser_view_support command without any pre-configured scope.",
"type": "string",
@@ -1328,12 +1490,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",
@@ -1364,6 +1550,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",
@@ -1580,6 +1772,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",
@@ -1640,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",
@@ -1676,6 +1880,12 @@
"const": "deny-regenerate-web-terminal-token",
"markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope."
},
{
"description": "Denies the remove_host_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-host-connection",
"markdownDescription": "Denies the remove_host_connection command without any pre-configured scope."
},
{
"description": "Denies the remove_marketplace command without any pre-configured scope.",
"type": "string",
@@ -1706,6 +1916,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",
@@ -1724,6 +1940,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",
@@ -1892,6 +2114,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",
+240
View File
@@ -350,6 +350,18 @@
"const": "allow-acquire-claude-token",
"markdownDescription": "Enables the acquire_claude_token command without any pre-configured scope."
},
{
"description": "Enables the add_aws_keys_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-add-aws-keys-connection",
"markdownDescription": "Enables the add_aws_keys_connection command without any pre-configured scope."
},
{
"description": "Enables the add_aws_profile_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-add-aws-profile-connection",
"markdownDescription": "Enables the add_aws_profile_connection command without any pre-configured scope."
},
{
"description": "Enables the add_marketplace command without any pre-configured scope.",
"type": "string",
@@ -392,6 +404,24 @@
"const": "allow-apply-settings-import",
"markdownDescription": "Enables the apply_settings_import command without any pre-configured scope."
},
{
"description": "Enables the aws_connection_identity command without any pre-configured scope.",
"type": "string",
"const": "allow-aws-connection-identity",
"markdownDescription": "Enables the aws_connection_identity command without any pre-configured scope."
},
{
"description": "Enables the aws_connection_sso_login command without any pre-configured scope.",
"type": "string",
"const": "allow-aws-connection-sso-login",
"markdownDescription": "Enables the aws_connection_sso_login command without any pre-configured scope."
},
{
"description": "Enables the aws_policy_document command without any pre-configured scope.",
"type": "string",
"const": "allow-aws-policy-document",
"markdownDescription": "Enables the aws_policy_document command without any pre-configured scope."
},
{
"description": "Enables the aws_sso_refresh command without any pre-configured scope.",
"type": "string",
@@ -416,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",
@@ -428,6 +464,12 @@
"const": "allow-cancel-marketplace-gh-login",
"markdownDescription": "Enables the cancel_marketplace_gh_login command without any pre-configured scope."
},
{
"description": "Enables the check_aws_permissions command without any pre-configured scope.",
"type": "string",
"const": "allow-check-aws-permissions",
"markdownDescription": "Enables the check_aws_permissions command without any pre-configured scope."
},
{
"description": "Enables the check_browser_view_support command without any pre-configured scope.",
"type": "string",
@@ -518,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",
@@ -554,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",
@@ -770,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",
@@ -830,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",
@@ -866,6 +950,12 @@
"const": "allow-regenerate-web-terminal-token",
"markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope."
},
{
"description": "Enables the remove_host_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-host-connection",
"markdownDescription": "Enables the remove_host_connection command without any pre-configured scope."
},
{
"description": "Enables the remove_marketplace command without any pre-configured scope.",
"type": "string",
@@ -896,6 +986,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",
@@ -914,6 +1010,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",
@@ -1082,6 +1184,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",
@@ -1160,6 +1280,18 @@
"const": "deny-acquire-claude-token",
"markdownDescription": "Denies the acquire_claude_token command without any pre-configured scope."
},
{
"description": "Denies the add_aws_keys_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-add-aws-keys-connection",
"markdownDescription": "Denies the add_aws_keys_connection command without any pre-configured scope."
},
{
"description": "Denies the add_aws_profile_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-add-aws-profile-connection",
"markdownDescription": "Denies the add_aws_profile_connection command without any pre-configured scope."
},
{
"description": "Denies the add_marketplace command without any pre-configured scope.",
"type": "string",
@@ -1202,6 +1334,24 @@
"const": "deny-apply-settings-import",
"markdownDescription": "Denies the apply_settings_import command without any pre-configured scope."
},
{
"description": "Denies the aws_connection_identity command without any pre-configured scope.",
"type": "string",
"const": "deny-aws-connection-identity",
"markdownDescription": "Denies the aws_connection_identity command without any pre-configured scope."
},
{
"description": "Denies the aws_connection_sso_login command without any pre-configured scope.",
"type": "string",
"const": "deny-aws-connection-sso-login",
"markdownDescription": "Denies the aws_connection_sso_login command without any pre-configured scope."
},
{
"description": "Denies the aws_policy_document command without any pre-configured scope.",
"type": "string",
"const": "deny-aws-policy-document",
"markdownDescription": "Denies the aws_policy_document command without any pre-configured scope."
},
{
"description": "Denies the aws_sso_refresh command without any pre-configured scope.",
"type": "string",
@@ -1226,6 +1376,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",
@@ -1238,6 +1394,12 @@
"const": "deny-cancel-marketplace-gh-login",
"markdownDescription": "Denies the cancel_marketplace_gh_login command without any pre-configured scope."
},
{
"description": "Denies the check_aws_permissions command without any pre-configured scope.",
"type": "string",
"const": "deny-check-aws-permissions",
"markdownDescription": "Denies the check_aws_permissions command without any pre-configured scope."
},
{
"description": "Denies the check_browser_view_support command without any pre-configured scope.",
"type": "string",
@@ -1328,12 +1490,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",
@@ -1364,6 +1550,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",
@@ -1580,6 +1772,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",
@@ -1640,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",
@@ -1676,6 +1880,12 @@
"const": "deny-regenerate-web-terminal-token",
"markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope."
},
{
"description": "Denies the remove_host_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-host-connection",
"markdownDescription": "Denies the remove_host_connection command without any pre-configured scope."
},
{
"description": "Denies the remove_marketplace command without any pre-configured scope.",
"type": "string",
@@ -1706,6 +1916,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",
@@ -1724,6 +1940,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",
@@ -1892,6 +2114,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",
+300
View File
@@ -0,0 +1,300 @@
//! An `SdkConfig` built from a saved `HostConnection`, never from the
//! process's default credentials chain.
use aws_config::identity::IdentityCache;
use aws_config::profile::ProfileFileCredentialsProvider;
use aws_config::provider_config::ProviderConfig;
use aws_config::retry::RetryConfig;
use aws_config::{BehaviorVersion, Region, SdkConfig};
use aws_credential_types::provider::error::CredentialsError;
use aws_credential_types::provider::{future, ProvideCredentials, SharedCredentialsProvider};
use aws_credential_types::Credentials;
use aws_runtime::env_config::file::EnvConfigFiles;
use aws_smithy_async::rt::sleep::default_async_sleep;
use aws_smithy_async::time::SharedTimeSource;
use super::error::{AwsError, ProfileCredentialsError};
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
use crate::storage::secure;
/// The SDK config for `conn` in `region`. Credentials are resolved lazily,
/// so a missing profile or an expired SSO session surfaces on the first
/// call and is classified there (`classify_sdk_error`).
pub async fn sdk_config(conn: &HostConnection, region: &str) -> Result<SdkConfig, AwsError> {
sdk_config_with(conn, region, None).await
}
/// `sdk_config` with the profile files overridden (tests pass temp files so
/// neither the process environment nor `~/.aws` is consulted).
pub(crate) async fn sdk_config_with(
conn: &HostConnection,
region: &str,
profile_files: Option<EnvConfigFiles>,
) -> Result<SdkConfig, AwsError> {
let HostKind::Aws { auth, .. } = &conn.kind;
let region = Region::new(region.to_string());
let credentials = match auth {
AwsAuth::Profile { name } => {
// The profile is read for credentials only (static keys, SSO,
// assume-role, credential_process), never for endpoints or
// service settings.
let mut builder = ProfileFileCredentialsProvider::builder()
.configure(&ProviderConfig::default().with_region(Some(region.clone())))
.profile_name(name);
if let Some(files) = profile_files {
builder = builder.profile_files(files);
}
SharedCredentialsProvider::new(NamedProfileProvider {
profile: name.clone(),
inner: builder.build(),
})
}
AwsAuth::Keys { keyring_ref } => {
let keys = secure::get_aws_keys(keyring_ref)
.map_err(|message| AwsError::Other { message })?
.ok_or(AwsError::KeysMissing)?;
keys_credentials(&keys)
}
};
Ok(build_sdk_config(region, credentials))
}
/// The SDK config for keys not yet saved (the add-connection check runs
/// before anything touches the keychain).
pub fn sdk_config_for_keys(keys: &secure::AwsKeys, region: &str) -> SdkConfig {
build_sdk_config(Region::new(region.to_string()), keys_credentials(keys))
}
fn keys_credentials(keys: &secure::AwsKeys) -> SharedCredentialsProvider {
SharedCredentialsProvider::new(Credentials::new(
keys.access_key_id.as_str(),
keys.secret_access_key.as_str(),
keys.session_token.clone(),
None,
"triple-c-connection",
))
}
/// Built by hand rather than with `aws_config::defaults(..).load()`, which
/// would read `AWS_ENDPOINT_URL[_<SERVICE>]`, FIPS/dual-stack, retry and
/// other settings from the process environment and the default profile. A
/// stray endpoint there would send every connection's signed requests (and
/// the permission check) somewhere other than AWS. Connections talk to the
/// real regional endpoints; a profile's own `endpoint_url` is ignored too.
/// The HTTP client, timeouts and stalled-stream protection are each service
/// client's defaults (the `default-https-client` stack every AWS crate here
/// is built with).
fn build_sdk_config(region: Region, credentials: SharedCredentialsProvider) -> SdkConfig {
let mut builder = SdkConfig::builder()
.behavior_version(BehaviorVersion::latest())
.region(region)
.credentials_provider(credentials)
.identity_cache(IdentityCache::lazy().build())
.retry_config(RetryConfig::standard())
.time_source(SharedTimeSource::default());
if let Some(sleep) = default_async_sleep() {
builder = builder.sleep_impl(sleep);
}
builder.build()
}
/// Puts the profile's name into the source chain of every credentials error,
/// so a failure deep inside the SDK can be reported against the profile.
#[derive(Debug)]
struct NamedProfileProvider {
profile: String,
inner: ProfileFileCredentialsProvider,
}
impl NamedProfileProvider {
async fn load(&self) -> Result<Credentials, CredentialsError> {
self.inner.provide_credentials().await.map_err(|e| {
let not_loaded = matches!(e, CredentialsError::CredentialsNotLoaded(_));
let timed_out = matches!(e, CredentialsError::ProviderTimedOut(_));
if timed_out {
return e;
}
let invalid = matches!(e, CredentialsError::InvalidConfiguration(_));
let wrapped = ProfileCredentialsError {
profile: self.profile.clone(),
not_loaded,
source: e,
};
// Keep the error's kind; only its context changes.
if not_loaded {
CredentialsError::not_loaded(wrapped)
} else if invalid {
CredentialsError::invalid_configuration(wrapped)
} else {
CredentialsError::provider_error(wrapped)
}
})
}
}
impl ProvideCredentials for NamedProfileProvider {
fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a>
where
Self: 'a,
{
future::ProvideCredentials::new(self.load())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aws::identity::caller_identity;
use crate::aws::AwsError;
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
use aws_runtime::env_config::file::{EnvConfigFileKind, EnvConfigFiles};
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-west-2".into(),
},
}
}
/// Empty config and credentials files, passed explicitly so neither the
/// process environment nor ~/.aws is consulted.
#[tokio::test]
async fn a_missing_profile_is_reported_by_name() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config");
let creds = dir.path().join("credentials");
std::fs::write(&config, "").unwrap();
std::fs::write(&creds, "").unwrap();
let files = EnvConfigFiles::builder()
.include_default_config_file(false)
.include_default_credentials_file(false)
.with_file(EnvConfigFileKind::Config, &config)
.with_file(EnvConfigFileKind::Credentials, &creds)
.build();
let cfg = sdk_config_with(&profile_conn("nope"), "us-west-2", Some(files))
.await
.unwrap();
let err = caller_identity(&cfg).await.unwrap_err();
assert_eq!(
err,
AwsError::ProfileNotFound {
profile: "nope".into()
}
);
}
/// A profile that the files do define but under another name is still
/// "not found" for the name the connection asked for.
#[tokio::test]
async fn a_profile_absent_from_populated_files_is_reported_by_name() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config");
std::fs::write(
&config,
"[profile other]\naws_access_key_id = AKIDTEST\naws_secret_access_key = s\n",
)
.unwrap();
let files = EnvConfigFiles::builder()
.include_default_config_file(false)
.include_default_credentials_file(false)
.with_file(EnvConfigFileKind::Config, &config)
.build();
let cfg = sdk_config_with(&profile_conn("nope"), "us-west-2", Some(files))
.await
.unwrap();
let err = caller_identity(&cfg).await.unwrap_err();
assert_eq!(
err,
AwsError::ProfileNotFound {
profile: "nope".into()
}
);
}
#[tokio::test]
async fn the_region_comes_from_the_argument() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config");
std::fs::write(&config, "[profile p]\nregion = eu-west-1\n").unwrap();
let files = EnvConfigFiles::builder()
.include_default_config_file(false)
.include_default_credentials_file(false)
.with_file(EnvConfigFileKind::Config, &config)
.build();
let cfg = sdk_config_with(&profile_conn("p"), "ap-south-1", Some(files))
.await
.unwrap();
assert_eq!(cfg.region().map(|r| r.as_ref()), Some("ap-south-1"));
}
/// Config files that try to redirect every call. A connection always
/// talks to the real regional AWS endpoints, so none of this may reach
/// the config: not the named profile's `endpoint_url`, not `[default]`'s,
/// not a per-service `services` section.
const REDIRECTING_CONFIG: &str = "[default]
endpoint_url = http://127.0.0.1:9
retry_mode = adaptive
max_attempts = 9
use_fips_endpoint = true
[profile p]
endpoint_url = http://127.0.0.1:9
retry_mode = adaptive
max_attempts = 9
use_dualstack_endpoint = true
services = redirect
aws_access_key_id = AKIDTEST
aws_secret_access_key = s
[services redirect]
sts =
endpoint_url = http://127.0.0.1:9
";
fn assert_isolated(cfg: &aws_config::SdkConfig) {
assert_eq!(cfg.endpoint_url(), None);
assert!(
cfg.service_config().is_none(),
"service config loaded from env/profile"
);
assert_eq!(cfg.use_fips(), None);
assert_eq!(cfg.use_dual_stack(), None);
let retry = cfg.retry_config().expect("retry config");
assert_eq!(retry.mode(), aws_config::retry::RetryMode::Standard);
assert_eq!(retry.max_attempts(), 3);
assert_eq!(cfg.region().map(|r| r.as_ref()), Some("us-west-2"));
}
#[tokio::test]
async fn a_profile_connection_ignores_endpoint_and_service_settings_in_the_profile() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config");
std::fs::write(&config, REDIRECTING_CONFIG).unwrap();
let files = EnvConfigFiles::builder()
.include_default_config_file(false)
.include_default_credentials_file(false)
.with_file(EnvConfigFileKind::Config, &config)
.build();
let cfg = sdk_config_with(&profile_conn("p"), "us-west-2", Some(files))
.await
.unwrap();
assert_isolated(&cfg);
}
/// The keys arm reads no files or environment at all: same isolation.
#[test]
fn a_keys_connection_config_is_isolated() {
let keys = crate::storage::secure::AwsKeys {
access_key_id: "AKIDTEST".into(),
secret_access_key: "s".into(),
session_token: None,
};
let cfg = build_sdk_config(Region::new("us-west-2"), keys_credentials(&keys));
assert_isolated(&cfg);
assert!(cfg.credentials_provider().is_some());
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+157
View File
@@ -0,0 +1,157 @@
//! What can go wrong talking to AWS, in the terms the UI acts on.
use std::error::Error;
use aws_sdk_sts::error::{DisplayErrorContext, ErrorMetadata};
use serde::Serialize;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AwsError {
/// The UI offers "Sign in".
SsoExpired {
profile: String,
},
ProfileNotFound {
profile: String,
},
/// The keychain entry for a keys connection is gone.
KeysMissing,
InvalidCredentials {
message: String,
},
AccessDenied {
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,
},
}
impl std::fmt::Display for AwsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AwsError::SsoExpired { profile } if profile.is_empty() => {
write!(f, "The AWS SSO session has expired; sign in again")
}
AwsError::SsoExpired { profile } => write!(
f,
"The AWS SSO session for profile \"{profile}\" has expired; sign in again"
),
AwsError::ProfileNotFound { profile } => write!(
f,
"AWS profile \"{profile}\" was not found, or has no credentials, in the AWS config and credentials files"
),
AwsError::KeysMissing => write!(
f,
"The access keys for this connection are no longer in the system keychain; enter them again"
),
AwsError::InvalidCredentials { message } => {
write!(f, "AWS rejected the credentials: {message}")
}
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),
}
}
}
/// Carried in the source chain of every credentials error from a profile
/// connection, so classification knows which profile failed and whether it
/// produced no credentials at all (absent, or present without credentials).
#[derive(Debug)]
pub(crate) struct ProfileCredentialsError {
pub profile: String,
pub not_loaded: bool,
pub source: aws_credential_types::provider::error::CredentialsError,
}
impl std::fmt::Display for ProfileCredentialsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "credentials for profile `{}`", self.profile)
}
}
impl Error for ProfileCredentialsError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
const SSO_EXPIRED: &[&str] = &[
"The SSO session associated with this profile has expired",
"Token has expired",
"the SSO token has expired",
];
const SSO_REVOKED: &[&str] = &[
"UnauthorizedException",
"Session token not found or invalid",
];
const DENIED_CODES: &[&str] = &[
"AccessDeniedException",
"AccessDenied",
"UnauthorizedOperation",
];
const BAD_CREDENTIAL_CODES: &[&str] = &[
"ExpiredToken",
"InvalidClientTokenId",
"SignatureDoesNotMatch",
"UnrecognizedClientException",
];
/// Classify any SDK error (an `SdkError`, a credentials error, ...) from
/// calling `action` (e.g. `iam:GetRole`). Matching is on the full display
/// chain, which carries both provider messages and service error codes.
pub fn classify_sdk_error(action: &str, err: &(dyn Error + 'static)) -> AwsError {
let text = DisplayErrorContext(err).to_string();
let mut chain = std::iter::successors(Some(err), |e| (*e).source());
let profile = chain
.clone()
.find_map(|e| e.downcast_ref::<ProfileCredentialsError>());
// The service's own message, when one is in the chain.
let service_message = chain
.find_map(|e| e.downcast_ref::<ErrorMetadata>())
.and_then(|m| m.message())
.map(str::to_string);
let message = service_message.unwrap_or_else(|| text.clone());
if SSO_EXPIRED.iter().any(|s| text.contains(s)) {
return AwsError::SsoExpired {
profile: profile.map(|p| p.profile.clone()).unwrap_or_default(),
};
}
// SSO's GetRoleCredentials on a revoked or signed-out session. Only
// inside a profile's credentials: other services use this code too.
if let Some(p) = profile.filter(|_| SSO_REVOKED.iter().any(|s| text.contains(s))) {
return AwsError::SsoExpired {
profile: p.profile.clone(),
};
}
if let Some(p) = profile.filter(|p| p.not_loaded) {
return AwsError::ProfileNotFound {
profile: p.profile.clone(),
};
}
if DENIED_CODES.iter().any(|c| text.contains(c)) {
return AwsError::AccessDenied {
action: action.to_string(),
message,
};
}
if BAD_CREDENTIAL_CODES.iter().any(|c| text.contains(c)) {
return AwsError::InvalidCredentials { message };
}
AwsError::Other { message: text }
}
+413
View File
@@ -0,0 +1,413 @@
//! Who the connection's credentials are, and which IAM principal the
//! policy simulator should be asked about.
use aws_config::SdkConfig;
use serde::Serialize;
use super::error::{classify_sdk_error, AwsError};
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CallerIdentity {
pub account: String,
pub arn: String,
/// STS `UserId`: the principal's unique id (for a role session,
/// `<role id>:<session name>`).
pub user_id: String,
}
/// `sts:GetCallerIdentity`, which needs no IAM permission: it is the
/// connection test itself.
pub async fn caller_identity(cfg: &SdkConfig) -> Result<CallerIdentity, AwsError> {
const ACTION: &str = "sts:GetCallerIdentity";
let out = aws_sdk_sts::Client::new(cfg)
.get_caller_identity()
.send()
.await
.map_err(|e| classify_sdk_error(ACTION, &e))?;
let field = |v: Option<&str>, name: &str| {
v.map(str::to_string).ok_or_else(|| AwsError::Other {
message: format!("{ACTION} returned no {name}"),
})
};
Ok(CallerIdentity {
account: field(out.account(), "Account")?,
arn: field(out.arn(), "Arn")?,
user_id: field(out.user_id(), "UserId")?,
})
}
/// The principal to simulate, as far as the caller's ARN tells us.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrincipalArn {
/// An IAM user or role ARN, usable as `PolicySourceArn` directly.
Exact(String),
/// A role session: the role's ARN carries a path the session ARN lacks,
/// so it has to be looked up (`resolve_principal`).
AssumedRole { account: String, role_name: String },
}
/// Map the caller's ARN to a simulatable principal. Root, federated users
/// and anything unrecognised cannot be simulated; the caller falls back to
/// probing instead.
pub fn principal_for_simulation(caller_arn: &str) -> Result<PrincipalArn, AwsError> {
let unsupported = || AwsError::Other {
message: format!("IAM policy simulation isn't possible for the principal {caller_arn}"),
};
// arn:<partition>:<service>:<region>:<account>:<resource>
let mut parts = caller_arn.splitn(6, ':');
let (Some("arn"), Some(partition), Some(service), Some(_region), Some(account), Some(resource)) = (
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
) else {
return Err(unsupported());
};
if partition.is_empty() || account.is_empty() {
return Err(unsupported());
}
match service {
"iam" if resource.starts_with("user/") || resource.starts_with("role/") => {
Ok(PrincipalArn::Exact(caller_arn.to_string()))
}
"sts" => match resource
.strip_prefix("assumed-role/")
.and_then(|r| r.split_once('/'))
{
Some((role_name, _session)) if !role_name.is_empty() => Ok(PrincipalArn::AssumedRole {
account: account.to_string(),
role_name: role_name.to_string(),
}),
_ => Err(unsupported()),
},
_ => Err(unsupported()),
}
}
/// The full ARN to simulate. An assumed role needs `iam:GetRole` to recover
/// the role's path (SSO roles live under `/aws-reserved/...`).
pub async fn resolve_principal(
iam: &aws_sdk_iam::Client,
p: PrincipalArn,
) -> Result<String, AwsError> {
const ACTION: &str = "iam:GetRole";
let (account, role_name) = match p {
PrincipalArn::Exact(arn) => return Ok(arn),
PrincipalArn::AssumedRole { account, role_name } => (account, role_name),
};
let out = iam
.get_role()
.role_name(&role_name)
.send()
.await
.map_err(|e| classify_sdk_error(ACTION, &e))?;
let arn = out
.role()
.map(|r| r.arn().to_string())
.filter(|a| !a.is_empty())
.ok_or_else(|| AwsError::Other {
message: format!("{ACTION} returned no role for {role_name}"),
})?;
// The role must be the session's own: same account.
if arn.split(':').nth(4) != Some(account.as_str()) {
return Err(AwsError::Other {
message: format!("{ACTION} for {role_name} returned {arn}, outside account {account}"),
});
}
Ok(arn)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aws::error::classify_sdk_error;
use crate::aws::AwsError;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn iam_user_arn_is_used_as_is() {
let arn = "arn:aws:iam::111122223333:user/alice";
assert_eq!(
principal_for_simulation(arn).unwrap(),
PrincipalArn::Exact(arn.into())
);
}
#[test]
fn iam_role_arn_is_used_as_is() {
let arn = "arn:aws:iam::111122223333:role/ops/deployer";
assert_eq!(
principal_for_simulation(arn).unwrap(),
PrincipalArn::Exact(arn.into())
);
}
#[test]
fn assumed_role_arn_maps_to_role_name() {
let arn = "arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Admin_abc/alice@x.com";
assert_eq!(
principal_for_simulation(arn).unwrap(),
PrincipalArn::AssumedRole {
account: "111122223333".into(),
role_name: "AWSReservedSSO_Admin_abc".into(),
}
);
}
#[test]
fn root_and_federated_arns_are_rejected_for_simulation() {
for arn in [
"arn:aws:iam::1:root",
"arn:aws:sts::1:federated-user/bob",
"not-an-arn",
] {
match principal_for_simulation(arn) {
Err(AwsError::Other { message }) => {
assert!(
message.contains("simulation isn't possible"),
"{arn}: {message}"
)
}
other => panic!("{arn}: expected Other, got {other:?}"),
}
}
}
/// A one-shot-per-connection HTTP server that answers every request with
/// `status` and `body`, and reports each request it saw.
async fn fake_endpoint(
status: &'static str,
body: &'static str,
) -> (String, tokio::sync::mpsc::UnboundedReceiver<String>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<String>();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let tx = tx.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 64 * 1024];
let mut req = Vec::new();
loop {
let n = match sock.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
req.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&req).into_owned();
let Some(head_end) = text.find("\r\n\r\n") else {
continue;
};
let len = text[..head_end]
.lines()
.find_map(|l| {
let (k, v) = l.split_once(':')?;
k.eq_ignore_ascii_case("content-length")
.then(|| v.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
if req.len() < head_end + 4 + len {
continue;
}
let _ = tx.send(text);
let resp = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/xml\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
req.clear();
}
});
}
});
(format!("http://127.0.0.1:{port}"), rx)
}
fn iam_client(endpoint: String) -> aws_sdk_iam::Client {
let conf = aws_sdk_iam::Config::builder()
.behavior_version(aws_sdk_iam::config::BehaviorVersion::latest())
.region(aws_sdk_iam::config::Region::new("us-east-1"))
.credentials_provider(aws_sdk_iam::config::Credentials::new(
"AKIDTEST", "secret", None, None, "test",
))
.endpoint_url(endpoint)
.build();
aws_sdk_iam::Client::from_conf(conf)
}
const ROLE_ARN: &str = "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Admin_abc";
#[tokio::test]
async fn assumed_role_arn_maps_to_role_arn_with_path() {
const BODY: &str = r#"<GetRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
<GetRoleResult>
<Role>
<Path>/aws-reserved/sso.amazonaws.com/us-west-2/</Path>
<RoleName>AWSReservedSSO_Admin_abc</RoleName>
<RoleId>AROAEXAMPLEID</RoleId>
<Arn>arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Admin_abc</Arn>
<CreateDate>2024-01-01T00:00:00Z</CreateDate>
</Role>
</GetRoleResult>
<ResponseMetadata><RequestId>req-1</RequestId></ResponseMetadata>
</GetRoleResponse>"#;
let (endpoint, mut seen) = fake_endpoint("200 OK", BODY).await;
let p = PrincipalArn::AssumedRole {
account: "111122223333".into(),
role_name: "AWSReservedSSO_Admin_abc".into(),
};
let arn = resolve_principal(&iam_client(endpoint), p).await.unwrap();
assert_eq!(arn, ROLE_ARN);
let req = seen.recv().await.unwrap();
assert!(req.contains("Action=GetRole"), "{req}");
assert!(req.contains("RoleName=AWSReservedSSO_Admin_abc"), "{req}");
}
#[tokio::test]
async fn an_exact_principal_needs_no_lookup() {
// No endpoint answers: any request would fail the test.
let client = iam_client("http://127.0.0.1:9".into());
let arn = "arn:aws:iam::111122223333:user/alice";
let got = resolve_principal(&client, PrincipalArn::Exact(arn.into()))
.await
.unwrap();
assert_eq!(got, arn);
}
#[tokio::test]
async fn a_denied_get_role_is_access_denied_for_get_role() {
const BODY: &str = r#"<ErrorResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
<Error>
<Type>Sender</Type>
<Code>AccessDenied</Code>
<Message>User: arn:aws:sts::111122223333:assumed-role/R/alice is not authorized to perform: iam:GetRole</Message>
</Error>
<RequestId>req-2</RequestId>
</ErrorResponse>"#;
let (endpoint, _seen) = fake_endpoint("403 Forbidden", BODY).await;
let p = PrincipalArn::AssumedRole {
account: "111122223333".into(),
role_name: "R".into(),
};
match resolve_principal(&iam_client(endpoint), p).await {
Err(AwsError::AccessDenied { action, message }) => {
assert_eq!(action, "iam:GetRole");
assert!(
message.contains("is not authorized to perform: iam:GetRole"),
"{message}"
);
}
other => panic!("expected AccessDenied, got {other:?}"),
}
}
#[derive(Debug)]
struct Msg(&'static str, Option<Box<dyn std::error::Error + 'static>>);
impl std::fmt::Display for Msg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for Msg {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.1.as_deref()
}
}
#[test]
fn an_expired_sso_token_is_reported_as_sso_expired() {
for leaf in [
"The SSO session associated with this profile has expired or is otherwise invalid",
"Token has expired and refresh failed",
] {
// Deep in the chain, as the SDK reports it.
let err = Msg("dispatch failure", Some(Box::new(Msg(leaf, None))));
assert!(
matches!(
classify_sdk_error("sts:GetCallerIdentity", &err),
AwsError::SsoExpired { .. }
),
"{leaf}"
);
}
}
/// SSO's GetRoleCredentials on a revoked or signed-out session. Inside a
/// profile connection's credentials that means "sign in again".
#[test]
fn a_revoked_sso_session_is_reported_as_sso_expired() {
use crate::aws::error::ProfileCredentialsError;
use aws_credential_types::provider::error::CredentialsError;
let leaf = CredentialsError::provider_error(
"service error: unhandled error (UnauthorizedException): Session token not found or invalid",
);
let err = CredentialsError::provider_error(ProfileCredentialsError {
profile: "dev".into(),
not_loaded: false,
source: leaf,
});
assert_eq!(
classify_sdk_error(
"sts:GetCallerIdentity",
&Msg("dispatch failure", Some(Box::new(err)))
),
AwsError::SsoExpired {
profile: "dev".into()
}
);
}
/// The same error code from some other service, outside any profile's
/// credentials, is not an SSO problem.
#[test]
fn an_unauthorized_exception_outside_profile_credentials_is_not_sso() {
let err = Msg(
"service error: unhandled error (UnauthorizedException): Session token not found or invalid",
None,
);
assert!(!matches!(
classify_sdk_error("ecs:ListClusters", &err),
AwsError::SsoExpired { .. }
));
}
#[test]
fn service_error_codes_are_classified() {
let denied = Msg(
"service error: unhandled error (AccessDeniedException)",
None,
);
assert!(matches!(
classify_sdk_error("ecs:ListClusters", &denied),
AwsError::AccessDenied { action, .. } if action == "ecs:ListClusters"
));
for code in [
"ExpiredToken",
"InvalidClientTokenId",
"SignatureDoesNotMatch",
] {
let text: &'static str =
Box::leak(format!("service error: unhandled error ({code})").into_boxed_str());
assert!(
matches!(
classify_sdk_error("sts:GetCallerIdentity", &Msg(text, None)),
AwsError::InvalidCredentials { .. }
),
"{code}"
);
}
assert!(matches!(
classify_sdk_error(
"sts:GetCallerIdentity",
&Msg("io error: connection refused", None)
),
AwsError::Other { .. }
));
}
}
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
//! AWS connection plumbing: an SDK config built from a saved
//! `HostConnection`, the caller's identity, the tier policies and the
//! permission check.
//!
mod connection;
pub mod environments;
mod error;
mod identity;
#[cfg(test)]
mod live_tests;
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};
pub use error::{classify_sdk_error, AwsError};
pub use identity::{caller_identity, CallerIdentity};
+544
View File
@@ -0,0 +1,544 @@
//! The permission check: which Triple-C tier the connection's credentials
//! can act as, by simulating each tier's checks (`policies::checks`) with
//! IAM's policy simulator, or — when the caller may not simulate itself —
//! by a few read-only probes.
use std::future::Future;
use futures_util::stream::{self, StreamExt, TryStreamExt};
use serde::Serialize;
use super::error::{classify_sdk_error, AwsError};
use super::identity::{self, principal_for_simulation, CallerIdentity, PrincipalArn};
use super::policies::{checks, render, Check, ContextValue, Placeholders, Tier};
/// How many simulations are in flight at once.
const CONCURRENCY: usize = 2;
/// One `SimulatePrincipalPolicy` request, fully rendered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SimulationRequest {
pub principal: String,
pub action: String,
pub resource: String,
pub context: Vec<ContextEntry>,
}
/// A context key with every value it carries in the request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextEntry {
pub key: String,
pub values: Vec<String>,
}
impl ContextEntry {
/// Sent as `StringList` when the key has more than one value, `String`
/// otherwise.
pub fn is_list(&self) -> bool {
self.values.len() > 1
}
}
/// Builds the simulation for `check`: the resource and every literal context
/// value rendered with `p`, `OwnerId` as `user_id`, and `aws:userid` =
/// `user_id` added. A key appearing more than once becomes one entry holding
/// all its values (in order, without duplicates).
pub fn simulation_request(check: &Check, p: &Placeholders, user_id: &str) -> SimulationRequest {
let mut context: Vec<ContextEntry> = Vec::new();
let mut add = |key: &str, value: String| match context.iter_mut().find(|e| e.key == key) {
Some(e) if e.values.contains(&value) => {}
Some(e) => e.values.push(value),
None => context.push(ContextEntry {
key: key.to_string(),
values: vec![value],
}),
};
for (key, value) in check.context {
let value = match value {
ContextValue::OwnerId => user_id.to_string(),
ContextValue::Literal(template) => render(template, p),
};
add(key, value);
}
add("aws:userid", user_id.to_string());
SimulationRequest {
principal: p.principal.to_string(),
action: check.action.to_string(),
resource: render(check.resource, p),
context,
}
}
/// The read-only calls used when simulation isn't possible.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Probe {
EcsListClusters,
SsmGetParametersByPath,
SecretsListSecrets,
}
impl Probe {
const ALL: [Probe; 3] = [
Probe::EcsListClusters,
Probe::SsmGetParametersByPath,
Probe::SecretsListSecrets,
];
/// The probe standing in for a read check's service, if there is one.
fn for_action(action: &str) -> Option<Probe> {
match action.split_once(':')?.0 {
"ecs" => Some(Probe::EcsListClusters),
"ssm" => Some(Probe::SsmGetParametersByPath),
"secretsmanager" => Some(Probe::SecretsListSecrets),
_ => None,
}
}
fn action(self) -> &'static str {
match self {
Probe::EcsListClusters => "ecs:ListClusters",
Probe::SsmGetParametersByPath => "ssm:GetParametersByPath",
Probe::SecretsListSecrets => "secretsmanager:ListSecrets",
}
}
}
/// What the permission check needs from AWS. The methods return `Send`
/// futures so `check_permissions` is `Send` (a Tauri command needs that) for
/// any implementation; an impl may still write them as `async fn`.
pub trait PermissionApi: Send + Sync {
/// The ARN to simulate for the caller's principal (`iam:GetRole` for a
/// role session).
fn resolve_principal(
&self,
principal: PrincipalArn,
) -> impl Future<Output = Result<String, AwsError>> + Send;
/// Whether the simulator says `allowed`.
fn simulate(
&self,
request: &SimulationRequest,
) -> impl Future<Output = Result<bool, AwsError>> + Send;
fn probe(&self, probe: Probe) -> impl Future<Output = Result<(), AwsError>> + Send;
}
/// The real thing, from a connection's `SdkConfig`.
pub struct SdkPermissionApi {
iam: aws_sdk_iam::Client,
ecs: aws_sdk_ecs::Client,
ssm: aws_sdk_ssm::Client,
secrets: aws_sdk_secretsmanager::Client,
}
impl SdkPermissionApi {
pub fn new(cfg: &aws_config::SdkConfig) -> Self {
// IAM throttles SimulatePrincipalPolicy hard: a full check (about a
// hundred simulations) failed a third of its rows with "Rate
// exceeded" under standard retries (verified live). Adaptive retry
// backs off client-side once throttled, with room to retry.
let iam = aws_sdk_iam::config::Builder::from(cfg)
.retry_config(aws_config::retry::RetryConfig::adaptive().with_max_attempts(10))
.build();
SdkPermissionApi {
iam: aws_sdk_iam::Client::from_conf(iam),
ecs: aws_sdk_ecs::Client::new(cfg),
ssm: aws_sdk_ssm::Client::new(cfg),
secrets: aws_sdk_secretsmanager::Client::new(cfg),
}
}
}
impl PermissionApi for SdkPermissionApi {
async fn resolve_principal(&self, principal: PrincipalArn) -> Result<String, AwsError> {
identity::resolve_principal(&self.iam, principal).await
}
async fn simulate(&self, request: &SimulationRequest) -> Result<bool, AwsError> {
use aws_sdk_iam::types::{ContextEntry as SdkEntry, ContextKeyTypeEnum};
const ACTION: &str = "iam:SimulatePrincipalPolicy";
let entries = request.context.iter().map(|e| {
SdkEntry::builder()
.context_key_name(&e.key)
.set_context_key_values(Some(e.values.clone()))
.context_key_type(if e.is_list() {
ContextKeyTypeEnum::StringList
} else {
ContextKeyTypeEnum::String
})
.build()
});
let out = self
.iam
.simulate_principal_policy()
.policy_source_arn(&request.principal)
.action_names(&request.action)
.resource_arns(&request.resource)
.set_context_entries(Some(entries.collect()))
.send()
.await
.map_err(|e| classify_sdk_error(ACTION, &e))?;
let decision = out
.evaluation_results()
.first()
.map(|r| r.eval_decision().as_str())
.ok_or_else(|| AwsError::Other {
message: format!("{ACTION} returned no result for {}", request.action),
})?;
Ok(decision == "allowed")
}
async fn probe(&self, probe: Probe) -> Result<(), AwsError> {
let action = probe.action();
let classify = |e: &(dyn std::error::Error + 'static)| classify_sdk_error(action, e);
match probe {
Probe::EcsListClusters => self
.ecs
.list_clusters()
.max_results(1)
.send()
.await
.map(drop)
.map_err(|e| classify(&e)),
Probe::SsmGetParametersByPath => self
.ssm
.get_parameters_by_path()
.path("/triple-c/environments")
.max_results(1)
.send()
.await
.map(drop)
.map_err(|e| classify(&e)),
Probe::SecretsListSecrets => self
.secrets
.list_secrets()
.max_results(1)
.send()
.await
.map(drop)
.map_err(|e| classify(&e)),
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Allowed,
Denied,
Unverified,
}
/// One row per action. An action checked against several resources (or
/// with several contexts) is `Denied` if any of them is, else `Unverified`
/// if any is, else `Allowed`.
#[derive(Debug, Clone, Serialize)]
pub struct ActionResult {
pub action: String,
pub verdict: Verdict,
/// Set when the row is `Unverified` because a call failed (throttling,
/// network, ...): the error, as `AwsError` displays it. Such a row
/// blocks its tier's claim; an `Unverified` row without a note (a write
/// in probe mode, an unprobed read) does not.
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TierResult {
pub tier: Tier,
pub results: Vec<ActionResult>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Method {
Simulated,
/// Simulation wasn't possible; `reason` says why.
Probed {
reason: String,
},
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Summary {
EnvironmentAdmin,
WorkspaceUser,
Insufficient,
}
#[derive(Debug, Clone, Serialize)]
pub struct PermissionReport {
pub identity: CallerIdentity,
pub region: String,
pub method: Method,
pub summary: Summary,
/// `[WorkspaceUser, EnvironmentAdmin]`.
pub tiers: Vec<TierResult>,
}
const TIERS: [Tier; 2] = [Tier::WorkspaceUser, Tier::EnvironmentAdmin];
/// One check's result, before checks are folded into per-action rows.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Outcome {
Allowed,
Denied,
/// Not checkable by design (a write in probe mode, an unprobed read).
Unverified,
/// The call failed for a reason that says nothing about the permission.
Failed(String),
}
/// The worst of `outcomes`: Denied, then Failed (as `Unverified` with the
/// first failure as its note), then Unverified, then Allowed.
fn aggregate(outcomes: &[Outcome]) -> (Verdict, Option<String>) {
if outcomes.contains(&Outcome::Denied) {
return (Verdict::Denied, None);
}
if let Some(note) = outcomes.iter().find_map(|o| match o {
Outcome::Failed(m) => Some(m.clone()),
_ => None,
}) {
return (Verdict::Unverified, Some(note));
}
if outcomes.contains(&Outcome::Unverified) {
return (Verdict::Unverified, None);
}
(Verdict::Allowed, None)
}
/// Folds per-check outcomes (in `checks(tier)` order) into one row per
/// action, in first-appearance order.
fn tier_result(tier: Tier, outcomes: Vec<Outcome>) -> TierResult {
let mut rows: Vec<(&'static str, Vec<Outcome>)> = Vec::new();
for (check, outcome) in checks(tier).iter().zip(outcomes) {
match rows.iter_mut().find(|(a, _)| *a == check.action) {
Some((_, os)) => os.push(outcome),
None => rows.push((check.action, vec![outcome])),
}
}
TierResult {
tier,
results: rows
.into_iter()
.map(|(action, os)| {
let (verdict, note) = aggregate(&os);
ActionResult {
action: action.to_string(),
verdict,
note,
}
})
.collect(),
}
}
/// A tier is claimed when none of its rows is `Denied` or failed (has a
/// note). Design-level `Unverified` rows don't block. A probed run never
/// claims the admin tier (R15): it is nearly all writes, which probes can't
/// see, so "nothing denied" would mean nothing.
fn summary(tiers: &[TierResult], probed: bool) -> Summary {
let clear = |t: Tier| {
tiers.iter().filter(|r| r.tier == t).all(|r| {
r.results
.iter()
.all(|a| a.verdict != Verdict::Denied && a.note.is_none())
})
};
if !clear(Tier::WorkspaceUser) {
Summary::Insufficient
} else if !probed && clear(Tier::EnvironmentAdmin) {
Summary::EnvironmentAdmin
} else {
Summary::WorkspaceUser
}
}
/// Errors that mean the credentials themselves don't work: the UI acts on
/// these (sign in, re-enter keys) rather than showing a report.
fn is_credential_error(e: &AwsError) -> bool {
matches!(
e,
AwsError::SsoExpired { .. }
| AwsError::InvalidCredentials { .. }
| AwsError::ProfileNotFound { .. }
| AwsError::KeysMissing
)
}
/// Which tiers the caller's credentials can act as, in `region`.
///
/// Simulates every check of both tiers against the caller's principal. It
/// falls back to probes when the principal can't be simulated (root,
/// federated user), resolving it is denied (`iam:GetRole`), or the first
/// simulation is denied. In probe mode a read check whose service has a
/// probe takes that probe's result; the check behind the fallback (the
/// caller's own `iam:GetRole` / `iam:SimulatePrincipalPolicy`) is `Denied`;
/// every other check is `Unverified`.
///
/// A credential error (SSO expired, invalid or missing credentials) from
/// any call is returned as `Err`, as is any other failure resolving the
/// principal. Other failures of individual checks (throttling, network)
/// make that row `Unverified` with a note, which blocks its tier's claim.
/// See [`summary`] for the claim rules.
pub async fn check_permissions<A: PermissionApi>(
api: &A,
identity: &CallerIdentity,
region: &str,
) -> Result<PermissionReport, AwsError> {
let (method, outcomes) = match simulate_all(api, identity, region).await? {
Simulation::Done(outcomes) => (Method::Simulated, outcomes),
Simulation::Fallback { reason, denied } => (
Method::Probed { reason },
probe_all(api, denied.as_deref()).await?,
),
};
let failed: Vec<&str> = outcomes
.iter()
.flatten()
.filter_map(|o| match o {
Outcome::Failed(m) => Some(m.as_str()),
_ => None,
})
.collect();
if let Some(first) = failed.first() {
let total: usize = outcomes.iter().map(Vec::len).sum();
log::warn!(
"AWS permission check: {} of {total} checks could not be evaluated (first: {first})",
failed.len()
);
}
let probed = matches!(method, Method::Probed { .. });
let tiers: Vec<TierResult> = TIERS
.into_iter()
.zip(outcomes)
.map(|(tier, os)| tier_result(tier, os))
.collect();
Ok(PermissionReport {
identity: identity.clone(),
region: region.to_string(),
method,
summary: summary(&tiers, probed),
tiers,
})
}
enum Simulation {
Done([Vec<Outcome>; 2]),
/// Simulation isn't possible. `denied` is the action whose denial
/// caused it, if one did.
Fallback {
reason: String,
denied: Option<String>,
},
}
impl Simulation {
fn fallback(e: AwsError) -> Self {
let denied = match &e {
AwsError::AccessDenied { action, .. } => Some(action.clone()),
_ => None,
};
Simulation::Fallback {
reason: e.to_string(),
denied,
}
}
}
fn simulated_outcome(r: Result<bool, AwsError>) -> Result<Outcome, AwsError> {
match r {
Ok(true) => Ok(Outcome::Allowed),
Ok(false) => Ok(Outcome::Denied),
Err(e) if is_credential_error(&e) => Err(e),
Err(e) => Ok(Outcome::Failed(e.to_string())),
}
}
/// Per-tier outcomes by simulation, or why simulation isn't possible.
async fn simulate_all<A: PermissionApi>(
api: &A,
identity: &CallerIdentity,
region: &str,
) -> Result<Simulation, AwsError> {
let principal = match principal_for_simulation(&identity.arn) {
Ok(p) => p,
Err(e) => return Ok(Simulation::fallback(e)),
};
let principal = match api.resolve_principal(principal).await {
Ok(p) => p,
Err(e @ AwsError::AccessDenied { .. }) => return Ok(Simulation::fallback(e)),
Err(e) => return Err(e),
};
let p = Placeholders {
region,
account: &identity.account,
principal: &principal,
};
let mut requests = TIERS
.into_iter()
.flat_map(|t| checks(t).iter())
.map(|c| simulation_request(c, &p, &identity.user_id))
.collect::<Vec<_>>()
.into_iter();
let Some(first) = requests.next() else {
return Ok(Simulation::Done([Vec::new(), Vec::new()]));
};
// The first simulation alone: if the caller may not simulate at all,
// that's the moment to fall back, before spending the rest.
let first = match api.simulate(&first).await {
Err(e @ AwsError::AccessDenied { .. }) => return Ok(Simulation::fallback(e)),
r => simulated_outcome(r)?,
};
// Owned requests: a closure over borrowed ones makes the future's
// `Send` unprovable (rustc's higher-ranked closure limitation).
let rest: Vec<Outcome> = stream::iter(requests)
.map(|r| async move { simulated_outcome(api.simulate(&r).await) })
.buffered(CONCURRENCY)
.try_collect()
.await?;
let mut all = std::iter::once(first).chain(rest);
Ok(Simulation::Done(
TIERS.map(|t| all.by_ref().take(checks(t).len()).collect()),
))
}
/// 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<A: PermissionApi>(
api: &A,
denied: Option<&str>,
) -> Result<[Vec<Outcome>; 2], AwsError> {
let results =
futures_util::future::join_all(Probe::ALL.map(|p| async move { (p, api.probe(p).await) }))
.await;
if let Some((_, Err(e))) = results
.iter()
.find(|(_, r)| matches!(r, Err(e) if is_credential_error(e)))
{
return Err(e.clone());
}
let probe_outcome = |probe: Probe| match results.iter().find(|(p, _)| *p == probe) {
Some((_, Ok(()))) => Outcome::Allowed,
Some((_, Err(AwsError::AccessDenied { .. }))) => Outcome::Denied,
Some((_, Err(e))) => Outcome::Failed(e.to_string()),
None => Outcome::Unverified,
};
Ok(TIERS.map(|t| {
checks(t)
.iter()
.map(|c| {
if denied == Some(c.action) && c.resource == "{principal}" {
return Outcome::Denied;
}
match Probe::for_action(c.action) {
Some(probe) if !c.write => probe_outcome(probe),
_ => Outcome::Unverified,
}
})
.collect()
}))
}
#[cfg(test)]
mod tests;
+630
View File
@@ -0,0 +1,630 @@
use std::collections::HashSet;
use std::sync::Mutex;
use super::*;
use crate::aws::policies::{checks, Check, ContextValue, Placeholders, Tier};
const USER_ID: &str = "AROAEXAMPLEID:alice@example.com";
const ROLE_ARN: &str =
"arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Ws_abc";
fn identity() -> CallerIdentity {
CallerIdentity {
account: "111122223333".into(),
arn: "arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Ws_abc/alice@example.com"
.into(),
user_id: USER_ID.into(),
}
}
const ALL_PROBES: [Probe; 3] = [
Probe::EcsListClusters,
Probe::SsmGetParametersByPath,
Probe::SecretsListSecrets,
];
/// A simulation call as the fake saw it.
#[derive(Debug, Clone)]
struct Simulated {
principal: String,
action: String,
resource: String,
context: Vec<ContextEntry>,
}
struct FakeApi {
allow: HashSet<&'static str>,
simulate_denied: bool,
get_role_denied: bool,
/// Returned by `resolve_principal` (after `get_role_denied`).
resolve_error: Option<AwsError>,
/// Returned by `simulate` for the named action.
simulate_errors: Vec<(&'static str, AwsError)>,
probes_ok: HashSet<Probe>,
/// Returned by every probe not in `probes_ok` (default: AccessDenied).
probe_error: Option<AwsError>,
simulated: Mutex<Vec<Simulated>>,
probed: Mutex<Vec<Probe>>,
}
impl FakeApi {
fn allowing(tiers: &[Tier]) -> Self {
let allow = tiers
.iter()
.flat_map(|t| checks(*t).iter().map(|c| c.action))
.collect();
FakeApi {
allow,
simulate_denied: false,
get_role_denied: false,
resolve_error: None,
simulate_errors: Vec::new(),
probes_ok: ALL_PROBES.into_iter().collect(),
probe_error: None,
simulated: Mutex::new(Vec::new()),
probed: Mutex::new(Vec::new()),
}
}
fn simulated(&self) -> Vec<Simulated> {
self.simulated.lock().unwrap().clone()
}
}
impl PermissionApi for FakeApi {
async fn resolve_principal(&self, principal: PrincipalArn) -> Result<String, AwsError> {
if self.get_role_denied {
return Err(AwsError::AccessDenied {
action: "iam:GetRole".into(),
message: "not authorized to perform: iam:GetRole".into(),
});
}
if let Some(e) = &self.resolve_error {
return Err(e.clone());
}
Ok(match principal {
PrincipalArn::Exact(arn) => arn,
PrincipalArn::AssumedRole { .. } => ROLE_ARN.into(),
})
}
async fn simulate(&self, request: &SimulationRequest) -> Result<bool, AwsError> {
self.simulated.lock().unwrap().push(Simulated {
principal: request.principal.clone(),
action: request.action.clone(),
resource: request.resource.clone(),
context: request.context.clone(),
});
if self.simulate_denied {
return Err(AwsError::AccessDenied {
action: "iam:SimulatePrincipalPolicy".into(),
message: "not authorized to perform: iam:SimulatePrincipalPolicy".into(),
});
}
if let Some((_, e)) = self
.simulate_errors
.iter()
.find(|(a, _)| *a == request.action)
{
return Err(e.clone());
}
Ok(self.allow.contains(request.action.as_str()))
}
async fn probe(&self, probe: Probe) -> Result<(), AwsError> {
self.probed.lock().unwrap().push(probe);
if self.probes_ok.contains(&probe) {
Ok(())
} else if let Some(e) = &self.probe_error {
Err(e.clone())
} else {
Err(AwsError::AccessDenied {
action: format!("{probe:?}"),
message: "denied".into(),
})
}
}
}
fn try_run(api: &FakeApi, id: &CallerIdentity) -> Result<PermissionReport, AwsError> {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(check_permissions(api, id, "eu-west-1"))
}
fn run(api: &FakeApi) -> PermissionReport {
try_run(api, &identity()).unwrap()
}
fn row<'a>(report: &'a PermissionReport, t: Tier, action: &str) -> &'a ActionResult {
tier(report, t)
.results
.iter()
.find(|r| r.action == action)
.unwrap_or_else(|| panic!("{action} not in {t:?}"))
}
fn throttled() -> AwsError {
AwsError::Other {
message: "Rate exceeded (Throttling)".into(),
}
}
fn tier(report: &PermissionReport, t: Tier) -> &TierResult {
report.tiers.iter().find(|r| r.tier == t).unwrap()
}
fn verdict(report: &PermissionReport, t: Tier, action: &str) -> Verdict {
tier(report, t)
.results
.iter()
.find(|r| r.action == action)
.unwrap_or_else(|| panic!("{action} not in {t:?}"))
.verdict
.clone()
}
#[test]
fn a_full_workspace_policy_reports_workspace_user() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
let report = run(&api);
assert_eq!(report.method, Method::Simulated);
assert_eq!(report.summary, Summary::WorkspaceUser);
assert_eq!(report.region, "eu-west-1");
assert_eq!(report.identity, identity());
let tiers: Vec<Tier> = report.tiers.iter().map(|t| t.tier).collect();
assert_eq!(tiers, [Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
assert!(tier(&report, Tier::WorkspaceUser)
.results
.iter()
.all(|r| r.verdict == Verdict::Allowed));
// Every check was simulated against the resolved role, not the session.
let sims = api.simulated();
let total = checks(Tier::WorkspaceUser).len() + checks(Tier::EnvironmentAdmin).len();
assert_eq!(sims.len(), total);
assert!(sims.iter().all(|s| s.principal == ROLE_ARN));
}
#[test]
fn admin_and_workspace_policies_report_environment_admin() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
let report = run(&api);
assert_eq!(report.method, Method::Simulated);
assert_eq!(report.summary, Summary::EnvironmentAdmin);
}
#[test]
fn a_missing_action_reports_insufficient_and_names_it() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
api.allow.remove("iam:PassRole");
let report = run(&api);
assert_eq!(report.summary, Summary::Insufficient);
assert_eq!(
verdict(&report, Tier::WorkspaceUser, "iam:PassRole"),
Verdict::Denied
);
assert_eq!(
verdict(&report, Tier::WorkspaceUser, "ecs:RunTask"),
Verdict::Allowed
);
}
#[test]
fn an_admin_gap_still_reports_workspace_user() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
api.allow.remove("ec2:CreateVpc");
let report = run(&api);
assert_eq!(report.summary, Summary::WorkspaceUser);
assert_eq!(
verdict(&report, Tier::EnvironmentAdmin, "ec2:CreateVpc"),
Verdict::Denied
);
}
/// An action checked against several resources is one row; any denial wins.
#[test]
fn an_action_checked_twice_is_one_row_and_denied_if_either_is() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
let report = run(&api);
let rows = tier(&report, Tier::WorkspaceUser)
.results
.iter()
.filter(|r| r.action == "ssm:StartSession")
.count();
assert_eq!(rows, 1);
let failed = || Outcome::Failed("Throttling".into());
assert_eq!(
aggregate(&[Outcome::Allowed, Outcome::Denied, failed()]),
(Verdict::Denied, None)
);
assert_eq!(
aggregate(&[Outcome::Allowed, Outcome::Unverified, failed()]),
(Verdict::Unverified, Some("Throttling".into()))
);
assert_eq!(
aggregate(&[Outcome::Allowed, Outcome::Unverified]),
(Verdict::Unverified, None)
);
assert_eq!(aggregate(&[Outcome::Allowed]), (Verdict::Allowed, None));
}
fn context_value<'a>(s: &'a Simulated, key: &str) -> Option<&'a ContextEntry> {
s.context.iter().find(|e| e.key == key)
}
#[test]
fn owner_context_is_sent_with_owner_scoped_checks() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
run(&api);
let sims = api.simulated();
let run_task = sims.iter().find(|s| s.action == "ecs:RunTask").unwrap();
let owner = context_value(run_task, "aws:RequestTag/triple-c:owner").unwrap();
assert_eq!(owner.values, [USER_ID]);
// Literals are rendered like resources.
let cluster = context_value(run_task, "ecs:cluster").unwrap();
assert_eq!(
cluster.values,
["arn:aws:ecs:eu-west-1:111122223333:cluster/triple-c-probe"]
);
assert_eq!(
run_task.resource,
"arn:aws:ecs:eu-west-1:111122223333:task-definition/triple-c-probe:1"
);
// aws:userid on every simulation.
for s in &sims {
let userid = context_value(s, "aws:userid")
.unwrap_or_else(|| panic!("{} has no aws:userid", s.action));
assert_eq!(userid.values, [USER_ID], "{}", s.action);
}
}
#[test]
fn the_principal_placeholder_is_the_resolved_role() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
run(&api);
let sims = api.simulated();
let get_role = sims
.iter()
.find(|s| s.action == "iam:GetRole" && s.resource == ROLE_ARN)
.expect("iam:GetRole on the caller's own role");
// The policies name a user's own ARN by `${aws:username}`.
assert_eq!(
context_value(get_role, "aws:username").unwrap().values,
[ROLE_ARN.rsplit('/').next().unwrap()]
);
}
#[test]
fn a_repeated_context_key_is_one_string_list_entry() {
static CTX: &[(&str, ContextValue)] = &[
("aws:TagKeys", ContextValue::Literal("triple-c:owner")),
(
"ecs:cluster",
ContextValue::Literal("arn:{region}:{account}"),
),
("aws:TagKeys", ContextValue::Literal("triple-c:component")),
("aws:TagKeys", ContextValue::OwnerId),
];
let check = Check {
action: "ecs:TagResource",
resource: "arn:aws:ecs:{region}:{account}:task/x",
context: CTX,
write: true,
};
let p = Placeholders {
region: "eu-west-1",
account: "111122223333",
principal: ROLE_ARN,
};
let req = simulation_request(&check, &p, USER_ID);
assert_eq!(req.principal, ROLE_ARN);
assert_eq!(req.action, "ecs:TagResource");
assert_eq!(req.resource, "arn:aws:ecs:eu-west-1:111122223333:task/x");
assert_eq!(
req.context,
[
ContextEntry {
key: "aws:TagKeys".into(),
values: vec![
"triple-c:owner".into(),
"triple-c:component".into(),
USER_ID.into()
],
},
ContextEntry {
key: "ecs:cluster".into(),
values: vec!["arn:eu-west-1:111122223333".into()],
},
ContextEntry {
key: "aws:userid".into(),
values: vec![USER_ID.into()],
},
]
);
assert!(req.context[0].is_list());
assert!(!req.context[1].is_list());
}
#[test]
fn a_check_naming_aws_userid_itself_is_not_sent_it_twice() {
static CTX: &[(&str, ContextValue)] = &[("aws:userid", ContextValue::OwnerId)];
let check = Check {
action: "sts:GetCallerIdentity",
resource: "*",
context: CTX,
write: false,
};
let p = Placeholders {
region: "r",
account: "a",
principal: "p",
};
let req = simulation_request(&check, &p, USER_ID);
assert_eq!(
req.context,
[ContextEntry {
key: "aws:userid".into(),
values: vec![USER_ID.into()],
}]
);
}
#[test]
fn a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
api.simulate_denied = true;
let report = run(&api);
match &report.method {
Method::Probed { reason } => {
assert!(reason.contains("iam:SimulatePrincipalPolicy"), "{reason}")
}
m => panic!("expected Probed, got {m:?}"),
}
// Only the first simulation was tried.
assert_eq!(api.simulated().len(), 1);
let mut probed = api.probed.lock().unwrap().clone();
probed.sort_by_key(|p| format!("{p:?}"));
let mut all = ALL_PROBES.to_vec();
all.sort_by_key(|p| format!("{p:?}"));
assert_eq!(probed, all);
let ws = Tier::WorkspaceUser;
assert_eq!(verdict(&report, ws, "ecs:RunTask"), Verdict::Unverified);
assert_eq!(verdict(&report, ws, "iam:PassRole"), Verdict::Unverified);
// Reads with a probe get its result.
assert_eq!(verdict(&report, ws, "ecs:ListClusters"), Verdict::Allowed);
assert_eq!(
verdict(&report, ws, "ecs:DescribeClusters"),
Verdict::Allowed
);
assert_eq!(
verdict(&report, ws, "ssm:GetParametersByPath"),
Verdict::Allowed
);
assert_eq!(
verdict(&report, ws, "secretsmanager:ListSecrets"),
Verdict::Allowed
);
// Reads with no probe are unverified.
assert_eq!(
verdict(&report, ws, "logs:FilterLogEvents"),
Verdict::Unverified
);
assert_eq!(verdict(&report, ws, "iam:GetRole"), Verdict::Unverified);
assert_eq!(
verdict(&report, ws, "sts:GetCallerIdentity"),
Verdict::Unverified
);
// The fallback itself is evidence: the caller may not simulate itself,
// which both tiers need.
for t in [ws, Tier::EnvironmentAdmin] {
assert_eq!(
verdict(&report, t, "iam:SimulatePrincipalPolicy"),
Verdict::Denied
);
}
assert_eq!(report.summary, Summary::Insufficient);
}
#[test]
fn a_denied_get_role_falls_back_to_probes() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
api.get_role_denied = true;
let report = run(&api);
match &report.method {
Method::Probed { reason } => assert!(reason.contains("iam:GetRole"), "{reason}"),
m => panic!("expected Probed, got {m:?}"),
}
assert!(api.simulated().is_empty());
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"),
Verdict::Denied
);
assert_eq!(report.summary, Summary::Insufficient);
}
#[test]
fn an_unsimulatable_principal_falls_back_to_probes() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
let mut id = identity();
id.arn = "arn:aws:iam::111122223333:root".into();
let report = try_run(&api, &id).unwrap();
match &report.method {
Method::Probed { reason } => assert!(reason.contains("simulation"), "{reason}"),
m => panic!("expected Probed, got {m:?}"),
}
assert!(api.simulated().is_empty());
}
/// R15: the admin tier is nearly all writes, so a probed run can't vouch
/// for it — every probe passing still caps at WorkspaceUser.
#[test]
fn a_probed_run_never_claims_environment_admin() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
let mut id = identity();
id.arn = "arn:aws:iam::111122223333:root".into();
let report = try_run(&api, &id).unwrap();
assert!(matches!(report.method, Method::Probed { .. }));
assert!(tier(&report, Tier::EnvironmentAdmin)
.results
.iter()
.all(|r| r.verdict != Verdict::Denied));
assert_eq!(report.summary, Summary::WorkspaceUser);
}
#[test]
fn throttled_simulations_block_the_tier_and_carry_notes() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
api.simulate_errors = vec![("ec2:CreateVpc", throttled())];
let report = run(&api);
assert_eq!(report.method, Method::Simulated);
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.
assert_eq!(row(&report, Tier::WorkspaceUser, "ecs:RunTask").note, None);
assert_eq!(report.summary, Summary::WorkspaceUser);
// In the workspace tier, it blocks everything.
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
api.simulate_errors = vec![("ecs:RunTask", throttled())];
let report = run(&api);
assert_eq!(report.summary, Summary::Insufficient);
assert!(row(&report, Tier::WorkspaceUser, "ecs:RunTask")
.note
.is_some());
}
#[test]
fn a_throttled_first_simulation_is_a_note_not_a_fallback() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
let first = checks(Tier::WorkspaceUser)[0].action;
api.simulate_errors = vec![(first, throttled())];
let report = run(&api);
assert_eq!(report.method, Method::Simulated);
assert!(row(&report, Tier::WorkspaceUser, first).note.is_some());
assert_eq!(report.summary, Summary::Insufficient);
}
#[test]
fn an_expired_sso_session_mid_check_is_an_error() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
let sso = AwsError::SsoExpired {
profile: "dev".into(),
};
api.simulate_errors = vec![("logs:FilterLogEvents", sso.clone())];
assert_eq!(try_run(&api, &identity()).unwrap_err(), sso);
}
#[test]
fn credential_errors_anywhere_are_errors() {
let bad = AwsError::InvalidCredentials {
message: "ExpiredToken".into(),
};
// From resolving the principal.
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
api.resolve_error = Some(bad.clone());
assert_eq!(try_run(&api, &identity()).unwrap_err(), bad);
// From the first simulation.
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
api.simulate_errors = vec![(checks(Tier::WorkspaceUser)[0].action, bad.clone())];
assert_eq!(try_run(&api, &identity()).unwrap_err(), bad);
// From a probe.
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
api.get_role_denied = true;
api.probes_ok.remove(&Probe::SecretsListSecrets);
api.probe_error = Some(AwsError::KeysMissing);
assert_eq!(
try_run(&api, &identity()).unwrap_err(),
AwsError::KeysMissing
);
}
#[test]
fn a_transport_error_resolving_the_principal_is_an_error() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
let net = AwsError::Other {
message: "dispatch failure: connection refused".into(),
};
api.resolve_error = Some(net.clone());
assert_eq!(try_run(&api, &identity()).unwrap_err(), net);
assert!(api.probed.lock().unwrap().is_empty());
}
#[test]
fn a_failed_probe_marks_its_reads_denied() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
api.simulate_denied = true;
api.probes_ok.remove(&Probe::EcsListClusters);
let report = run(&api);
let ws = Tier::WorkspaceUser;
assert_eq!(verdict(&report, ws, "ecs:ListClusters"), Verdict::Denied);
assert_eq!(
verdict(&report, ws, "ecs:DescribeClusters"),
Verdict::Denied
);
assert_eq!(
verdict(&report, ws, "secretsmanager:ListSecrets"),
Verdict::Allowed
);
assert_eq!(report.summary, Summary::Insufficient);
}
#[test]
fn a_probe_failing_for_another_reason_leaves_its_reads_unverified_and_blocks() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
let mut id = identity();
id.arn = "arn:aws:iam::111122223333:root".into();
api.probes_ok.remove(&Probe::EcsListClusters);
api.probe_error = Some(AwsError::Other {
message: "connection reset".into(),
});
let report = try_run(&api, &id).unwrap();
let r = row(&report, Tier::WorkspaceUser, "ecs:ListClusters");
assert_eq!(r.verdict, Verdict::Unverified);
assert!(r.note.as_deref().unwrap().contains("connection reset"));
// Design-level unverified rows carry no note.
assert_eq!(row(&report, Tier::WorkspaceUser, "ecs:RunTask").note, None);
assert_eq!(report.summary, Summary::Insufficient);
}
#[test]
fn the_report_serializes_for_the_ui() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
api.get_role_denied = true;
let v = serde_json::to_value(run(&api)).unwrap();
// Probed because iam:GetRole is denied, which the workspace tier needs.
assert_eq!(v["summary"], "insufficient");
assert!(v["tiers"][0]["results"][0]
.as_object()
.unwrap()
.contains_key("note"));
assert_eq!(v["tiers"][0]["tier"], "workspace_user");
assert!(v["method"]["probed"]["reason"].is_string(), "{v}");
assert!(["allowed", "denied", "unverified"]
.contains(&v["tiers"][0]["results"][0]["verdict"].as_str().unwrap()));
}
/// Tauri commands need a `Send` future.
#[allow(dead_code)]
fn check_permissions_is_send() {
fn assert_send<T: Send>(_: T) {}
fn with<A: PermissionApi>(api: &A, id: &CallerIdentity) {
assert_send(check_permissions(api, id, "r"));
}
let _ = with::<SdkPermissionApi>;
}
/// 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);
}
File diff suppressed because it is too large. Load diff
+303
View File
@@ -0,0 +1,303 @@
//! 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";
/// 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; [`LITERAL_STAR`] is a plain `*`.
pub(crate) fn glob(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let t: Vec<char> = 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] || (p[pi] == LITERAL_STAR && 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<Value> {
doc["Statement"]
.as_array()
.expect("policy has no Statement array")
.clone()
}
pub(crate) fn effect(stmt: &Value) -> &str {
stmt["Effect"].as_str().unwrap_or("<no Effect>")
}
/// `Action` / `Resource` / condition values: a string or an array of them.
pub(crate) fn strings(v: &Value) -> Vec<String> {
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<String> {
strings(&stmt["Action"])
}
pub(crate) fn resources(stmt: &Value) -> Vec<String> {
strings(&stmt["Resource"])
}
pub(crate) fn sid(stmt: &Value) -> &str {
stmt["Sid"].as_str().unwrap_or("<no Sid>")
}
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<String> {
self.context_values(key).into_iter().next()
}
/// All values of a (possibly multi-valued) key.
pub(crate) fn context_values(&self, key: &str) -> Vec<String> {
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<String> {
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 == "*" {
Some(LITERAL_STAR.to_string())
} else 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<String> = strings(want)
.iter()
.filter_map(|p| req.resolve(p))
.collect();
req.context_values(key).iter().all(|v| allowed.contains(v))
});
}
if op == "Null" {
return entries.iter().all(|(key, want)| {
let absent = req.context_value(key).is_none();
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
}
})
})
}
/// 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"));
// `${*}` (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]
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"))
])));
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+251
View File
@@ -0,0 +1,251 @@
//! 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`.
pub const ENVIRONMENT_MODULE_VERSION: u32 = 1;
/// The same for the user-onboarding module.
#[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.
pub const BOUNDARY_POLICY_NAME: &str = "triple-c-permissions-boundary";
#[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",
);
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.
pub fn module_files(kind: ModuleKind) -> &'static [(&'static str, &'static str)] {
match kind {
ModuleKind::Environment => ENVIRONMENT_FILES,
ModuleKind::UserOnboard => USER_ONBOARD_FILES,
ModuleKind::PermissionsBoundary => PERMISSIONS_BOUNDARY_FILES,
}
}
/// `^[a-z][a-z0-9-]{0,19}$`, not ending in `-`, with no `--` — the same rule
/// as the module's `environment_name` validation.
pub fn validate_environment_name(name: &str) -> Result<String, String> {
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.
pub fn environment_parameter_name(env: &str) -> String {
format!("/triple-c/environments/{env}")
}
/// The first 12 hex characters of `sha256("<region>:<aws:userid>")`. 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}"))
.collect()
}
/// A user's (task role, execution role) names. At most 47 characters.
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.
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).
#[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`.
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(),
}
}
/// How an environment's tasks reach the network (D4).
#[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<String>,
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.
pub fn environment_vars(
env: &str,
region: &str,
network: &NetworkChoice,
) -> Result<serde_json::Value, String> {
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,
}))
}
/// 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.
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(region, 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.
pub fn boundary_vars(region: &str) -> serde_json::Value {
json!({
"region": region,
"policy_json": super::policies::permissions_boundary_json(),
})
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -52,7 +52,8 @@ pub async fn run_sso_login(profile: &str) -> Result<(), String> {
log::info!("Running host-side AWS SSO login for profile '{}'", profile);
let status = tokio::process::Command::new("aws")
.args(["sso", "login", "--profile", profile])
// One argv element, so a name starting with `-` is never an option.
.args(["sso", "login", &format!("--profile={profile}")])
.status()
.await
.map_err(|e| format!("Failed to run aws sso login: {}", e))?;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,631 @@
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,
};
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<EnvironmentAction>) -> DetectedEnvironment {
DetectedEnvironment {
name: name.into(),
template_version: None,
state,
actions,
info: None,
onboarding: None,
onboarding_actions: Vec::new(),
onboarded_users: Some(0),
}
}
fn scan(boundary: Knowable, environments: Vec<DetectedEnvironment>) -> EnvironmentScan {
EnvironmentScan {
region: REGION.into(),
account: ACCOUNT.into(),
owner_id: USER_ID.into(),
owner_key: owner_key(REGION, 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(REGION, 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(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();
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}");
// 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();
// ...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(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}");
}
}
#[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 { 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());
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);
}
#[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<Mutex<Vec<DeployProgress>>> = 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<usize> = 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 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::*;
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());
}
#[test]
fn a_follow_is_refused_while_a_launch_holds_the_folder() {
let dir = Path::new("/w/env/dev");
let mut slots: Slots<u32> = 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<u32> = 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<u32> = 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<u32> = 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
}
+557
View File
@@ -0,0 +1,557 @@
//! Commands for host connections (AWS): add, update, remove, identity,
//! sign-in and the permission check.
use std::future::Future;
use tauri::State;
use crate::aws::permissions::{check_permissions, PermissionReport, SdkPermissionApi};
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,
HostConnection, HostKind,
};
use crate::storage::secure::{self, AwsKeys};
use crate::storage::settings_store::SettingsStore;
use crate::AppState;
fn other(message: impl Into<String>) -> AwsError {
AwsError::Other { message: message.into() }
}
fn check_id(id: &str) -> Result<(), String> {
if secure::is_valid_connection_id(id) {
Ok(())
} else {
Err("Invalid connection id".to_string())
}
}
/// What the identity check is asked to authenticate with.
pub(crate) enum Credentials {
/// A named host profile (the connection itself).
Profile(HostConnection),
/// Keys not yet in the keychain, and the region to call in.
Keys(AwsKeys, String),
}
/// Validate-free core of both add commands: `GetCallerIdentity` first, and
/// only on success keys are stored and the connection saved. An expired SSO
/// session on a profile still saves (the UI then offers "Sign in").
pub(crate) async fn add_connection_with<I, Fut>(
conn: HostConnection,
keys: Option<AwsKeys>,
identity: I,
store_keys: impl Fn(&str, &AwsKeys) -> Result<(), String>,
delete_keys: impl Fn(&str) -> Result<(), String>,
settings: &SettingsStore,
) -> Result<HostConnection, AwsError>
where
I: FnOnce(Credentials) -> Fut,
Fut: Future<Output = Result<CallerIdentity, AwsError>>,
{
let HostKind::Aws { auth, default_region } = &conn.kind;
let credentials = match (&keys, auth) {
(Some(k), AwsAuth::Keys { .. }) => Credentials::Keys(k.clone(), default_region.clone()),
(None, AwsAuth::Profile { .. }) => Credentials::Profile(conn.clone()),
_ => return Err(other("Connection credentials do not match its auth method")),
};
match identity(credentials).await {
Ok(_) => {}
Err(AwsError::SsoExpired { .. }) if keys.is_none() => {}
Err(e) => return Err(e),
}
if let Some(k) = &keys {
store_keys(&conn.id, k).map_err(other)?;
}
if let Err(e) = settings.modify(|all| {
all.hosts.push(conn.clone());
Ok(())
}) {
if keys.is_some() {
let _ = delete_keys(&conn.id);
}
return Err(other(e));
}
Ok(conn)
}
/// Removes the stored connection with this id, however malformed its other
/// fields are (a broken entry must stay removable). Its keychain entry is
/// deleted only for a `Keys` connection whose `keyring_ref` is its own valid id.
pub(crate) fn remove_connection_with(
id: &str,
delete_keys: impl Fn(&str) -> Result<(), String>,
settings: &SettingsStore,
) -> Result<AppSettings, String> {
check_id(id)?;
let existing = settings
.get()
.hosts
.into_iter()
.find(|h| h.id == id)
.ok_or_else(|| "Connection not found".to_string())?;
let HostKind::Aws { auth, .. } = &existing.kind;
if let AwsAuth::Keys { keyring_ref } = auth {
if keyring_ref == id && secure::is_valid_connection_id(keyring_ref) {
delete_keys(keyring_ref)?;
}
}
settings
.modify(|s| {
s.hosts.retain(|h| h.id != id);
Ok(())
})
.map(|(saved, ())| saved)
}
async fn real_identity(c: Credentials) -> Result<CallerIdentity, AwsError> {
let cfg = match c {
Credentials::Profile(conn) => {
let HostKind::Aws { default_region, .. } = &conn.kind;
sdk_config(&conn, default_region).await?
}
Credentials::Keys(keys, region) => sdk_config_for_keys(&keys, &region),
};
caller_identity(&cfg).await
}
pub(crate) fn aws_conn(id: &str, settings: &AppSettings) -> Result<HostConnection, String> {
check_id(id)?;
let conn = settings
.hosts
.iter()
.find(|h| h.id == id)
.cloned()
.ok_or_else(|| "Connection not found".to_string())?;
// Defense in depth: stored values are re-checked before they reach the
// keychain, the SDK or a subprocess.
let HostKind::Aws { auth, default_region } = &conn.kind;
validate_supported_region(default_region)?;
match auth {
AwsAuth::Profile { name } => {
validate_profile_name(name)?;
}
AwsAuth::Keys { keyring_ref } if keyring_ref != id => {
return Err("Connection's keychain reference does not match its id".to_string());
}
AwsAuth::Keys { .. } => {}
}
Ok(conn)
}
fn validated(name: &str, region: &str) -> Result<(String, String), AwsError> {
Ok((
validate_connection_name(name).map_err(other)?,
validate_supported_region(region).map_err(other)?,
))
}
#[tauri::command]
pub async fn add_aws_profile_connection(
name: String,
profile: String,
default_region: String,
state: State<'_, AppState>,
) -> Result<HostConnection, AwsError> {
let (name, default_region) = validated(&name, &default_region)?;
let profile = validate_profile_name(&profile).map_err(other)?;
let conn = HostConnection {
id: uuid::Uuid::new_v4().to_string(),
name,
kind: HostKind::Aws { auth: AwsAuth::Profile { name: profile }, default_region },
};
add_connection_with(
conn,
None,
real_identity,
secure::store_aws_keys,
secure::delete_aws_keys,
&state.settings_store,
)
.await
}
#[tauri::command]
pub async fn add_aws_keys_connection(
name: String,
access_key_id: String,
secret_access_key: String,
session_token: Option<String>,
default_region: String,
state: State<'_, AppState>,
) -> Result<HostConnection, AwsError> {
let (name, default_region) = validated(&name, &default_region)?;
let access_key_id = access_key_id.trim().to_string();
let secret_access_key = secret_access_key.trim().to_string();
let session_token = session_token.map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
if access_key_id.is_empty() || secret_access_key.is_empty() {
return Err(other("Access key ID and secret access key are required"));
}
let id = uuid::Uuid::new_v4().to_string();
let conn = HostConnection {
id: id.clone(),
name,
kind: HostKind::Aws { auth: AwsAuth::Keys { keyring_ref: id }, default_region },
};
let keys = AwsKeys { access_key_id, secret_access_key, session_token };
add_connection_with(
conn,
Some(keys),
real_identity,
secure::store_aws_keys,
secure::delete_aws_keys,
&state.settings_store,
)
.await
}
#[tauri::command]
pub async fn update_aws_connection(
id: String,
name: String,
default_region: String,
state: State<'_, AppState>,
) -> Result<HostConnection, AwsError> {
let (name, region) = validated(&name, &default_region)?;
let (_, updated) = state
.settings_store
.modify(|settings| {
aws_conn(&id, settings)?;
let conn = settings.hosts.iter_mut().find(|h| h.id == id).expect("checked above");
conn.name = name;
let HostKind::Aws { default_region, .. } = &mut conn.kind;
*default_region = region;
Ok(conn.clone())
})
.map_err(other)?;
Ok(updated)
}
#[tauri::command]
pub async fn remove_host_connection(
id: String,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
remove_connection_with(&id, secure::delete_aws_keys, &state.settings_store)
}
#[tauri::command]
pub async fn aws_connection_identity(
id: String,
state: State<'_, AppState>,
) -> Result<CallerIdentity, AwsError> {
let conn = aws_conn(&id, &state.settings_store.get()).map_err(other)?;
let HostKind::Aws { default_region, .. } = &conn.kind;
let cfg = sdk_config(&conn, default_region).await?;
caller_identity(&cfg).await
}
#[tauri::command]
pub async fn aws_connection_sso_login(
id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let conn = aws_conn(&id, &state.settings_store.get())?;
let HostKind::Aws { auth, .. } = &conn.kind;
let AwsAuth::Profile { name } = auth else {
return Err("Only profile connections use SSO".to_string());
};
let name = validate_profile_name(name)?;
super::aws_commands::run_sso_login(&name).await
}
#[tauri::command]
pub async fn check_aws_permissions(
id: String,
region: Option<String>,
state: State<'_, AppState>,
) -> Result<PermissionReport, AwsError> {
let conn = aws_conn(&id, &state.settings_store.get()).map_err(other)?;
let HostKind::Aws { default_region, .. } = &conn.kind;
let region = match region {
Some(r) => validate_supported_region(&r).map_err(other)?,
None => default_region.clone(),
};
let cfg = sdk_config(&conn, &region).await?;
let identity = caller_identity(&cfg).await?;
let api = SdkPermissionApi::new(&cfg);
check_permissions(&api, &identity, &region).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) -> Vec<PolicyFile> {
policy_files(tier)
.iter()
.map(|(name, json)| PolicyFile {
name: name.to_string(),
json: json.to_string(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn profile_conn() -> HostConnection {
HostConnection {
id: "c1".into(),
name: "Prod".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile { name: "dev".into() },
default_region: "us-west-2".into(),
},
}
}
fn keys_conn() -> HostConnection {
HostConnection {
id: "c2".into(),
name: "K".into(),
kind: HostKind::Aws {
auth: AwsAuth::Keys { keyring_ref: "c2".into() },
default_region: "us-west-2".into(),
},
}
}
fn keys() -> AwsKeys {
AwsKeys { access_key_id: "AKIA".into(), secret_access_key: "s".into(), session_token: None }
}
fn ident() -> CallerIdentity {
CallerIdentity { account: "1".into(), arn: "arn:aws:iam::1:user/u".into(), user_id: "U".into() }
}
fn store_in(dir: &tempfile::TempDir) -> SettingsStore {
SettingsStore::at_path(dir.path().join("settings.json"))
}
#[tokio::test]
async fn adding_saves_nothing_when_identity_fails() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let calls = RefCell::new(Vec::<String>::new());
let err = add_connection_with(
keys_conn(),
Some(keys()),
|_| async { Err(AwsError::InvalidCredentials { message: "bad".into() }) },
|id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) },
|id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) },
&settings,
)
.await
.unwrap_err();
assert!(matches!(err, AwsError::InvalidCredentials { .. }));
assert!(settings.get().hosts.is_empty());
assert!(!dir.path().join("settings.json").exists());
assert!(calls.borrow().is_empty(), "{:?}", calls.borrow());
}
#[tokio::test]
async fn an_sso_expired_profile_is_still_saved() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let saved = add_connection_with(
profile_conn(),
None,
|_| async { Err(AwsError::SsoExpired { profile: "dev".into() }) },
|_, _| Ok(()),
|_| Ok(()),
&settings,
)
.await
.unwrap();
assert_eq!(settings.get().hosts, vec![saved]);
}
#[tokio::test]
async fn a_successful_keys_add_stores_keys_then_settings() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let calls = RefCell::new(Vec::<String>::new());
let saved = add_connection_with(
keys_conn(),
Some(keys()),
|c| async move {
assert!(matches!(c, Credentials::Keys(..)));
Ok(ident())
},
|id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) },
|id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) },
&settings,
)
.await
.unwrap();
assert_eq!(*calls.borrow(), vec!["store c2".to_string()]);
assert_eq!(settings.get().hosts, vec![saved]);
}
#[tokio::test]
async fn keys_are_removed_if_saving_settings_fails() {
let dir = tempfile::tempdir().unwrap();
// The parent directory does not exist, so saving fails.
let settings = SettingsStore::at_path(dir.path().join("missing/settings.json"));
let calls = RefCell::new(Vec::<String>::new());
let err = add_connection_with(
keys_conn(),
Some(keys()),
|_| async { Ok(ident()) },
|id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) },
|id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) },
&settings,
)
.await
.unwrap_err();
assert!(matches!(err, AwsError::Other { .. }));
assert_eq!(*calls.borrow(), vec!["store c2".to_string(), "delete c2".to_string()]);
assert!(settings.get().hosts.is_empty(), "no unsaved connection in memory");
}
#[tokio::test]
async fn a_non_sso_identity_error_on_a_profile_saves_nothing() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let err = add_connection_with(
profile_conn(),
None,
|_| async { Err(AwsError::ProfileNotFound { profile: "dev".into() }) },
|_, _| Ok(()),
|_| Ok(()),
&settings,
)
.await
.unwrap_err();
assert!(matches!(err, AwsError::ProfileNotFound { .. }));
assert!(settings.get().hosts.is_empty());
assert!(!dir.path().join("settings.json").exists());
}
#[tokio::test]
async fn a_failing_store_keys_saves_nothing() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let deletes = RefCell::new(0);
let err = add_connection_with(
keys_conn(),
Some(keys()),
|_| async { Ok(ident()) },
|_, _| Err("keychain locked".to_string()),
|_| { *deletes.borrow_mut() += 1; Ok(()) },
&settings,
)
.await
.unwrap_err();
assert!(matches!(err, AwsError::Other { .. }));
assert!(settings.get().hosts.is_empty());
assert!(!dir.path().join("settings.json").exists());
}
#[test]
fn a_keys_connection_whose_keyring_ref_differs_from_its_id_is_refused() {
let mut s = AppSettings::default();
let mut c = keys_conn();
c.kind = HostKind::Aws {
auth: AwsAuth::Keys { keyring_ref: "other".into() },
default_region: "us-west-2".into(),
};
s.hosts.push(c);
assert!(aws_conn("c2", &s).is_err());
s.hosts = vec![keys_conn()];
assert!(aws_conn("c2", &s).is_ok());
}
#[test]
fn stored_regions_and_profiles_are_revalidated() {
let with = |profile: &str, region: &str| {
let mut c = profile_conn();
c.kind = HostKind::Aws {
auth: AwsAuth::Profile { name: profile.into() },
default_region: region.into(),
};
let mut s = AppSettings::default();
s.hosts.push(c);
s
};
assert!(aws_conn("c1", &with("dev", "us-west-2")).is_ok());
assert!(aws_conn("c1", &with("dev", "not a region")).is_err());
assert!(aws_conn("c1", &with("a b", "us-west-2")).is_err());
}
#[test]
fn a_connection_with_an_invalid_stored_region_can_still_be_removed() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let mut c = keys_conn();
c.kind = HostKind::Aws {
auth: AwsAuth::Keys { keyring_ref: "c2".into() },
default_region: "not a region".into(),
};
settings.update(AppSettings { hosts: vec![c, profile_conn()], ..AppSettings::default() }).unwrap();
let deleted = RefCell::new(Vec::<String>::new());
let after = remove_connection_with(
"c2",
|id| { deleted.borrow_mut().push(id.to_string()); Ok(()) },
&settings,
)
.unwrap();
assert_eq!(after.hosts, vec![profile_conn()]);
assert_eq!(settings.get().hosts, vec![profile_conn()]);
assert_eq!(*deleted.borrow(), vec!["c2".to_string()]);
}
#[test]
fn removal_leaves_the_keychain_alone_when_keyring_ref_is_not_the_id() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let mut c = keys_conn();
c.kind = HostKind::Aws {
auth: AwsAuth::Keys { keyring_ref: "someone-else".into() },
default_region: "us-west-2".into(),
};
settings.update(AppSettings { hosts: vec![c], ..AppSettings::default() }).unwrap();
remove_connection_with("c2", |_| panic!("must not touch the keychain"), &settings).unwrap();
assert!(settings.get().hosts.is_empty());
assert!(remove_connection_with("c2", |_| Ok(()), &settings).is_err());
assert!(remove_connection_with("bad id!", |_| Ok(()), &settings).is_err());
}
#[test]
fn china_and_govcloud_regions_are_refused_for_connections() {
for r in ["cn-north-1", "us-gov-west-1"] {
let e = validated("Prod", r).unwrap_err();
assert!(matches!(&e, AwsError::Other { message } if message == "China and GovCloud regions aren't supported yet."), "{e:?}");
}
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::<serde_json::Value>(&f.json).expect("policy JSON");
}
let ws = aws_policy_document(Tier::WorkspaceUser);
assert_eq!(ws.len(), 1);
assert_eq!(ws[0].name, "workspace-user.json");
assert_eq!(
serde_json::to_value(&ws[0]).unwrap()["name"],
"workspace-user.json"
);
}
}
@@ -173,10 +173,7 @@ pub(crate) mod ops {
/// directories, so an id from outside (an import) must be the shape the
/// commands mint: a UUID-like `[A-Za-z0-9-]{1,64}`.
pub fn validate_id(id: &str) -> Result<(), String> {
let ok = !id.is_empty()
&& id.len() <= 64
&& id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
if ok {
if crate::storage::secure::is_valid_connection_id(id) {
Ok(())
} else {
Err("An account or marketplace id is malformed.".to_string())
+2
View File
@@ -2,10 +2,12 @@ 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;
pub mod help_commands;
pub mod host_commands;
pub mod inspect_commands;
pub mod install_helper_commands;
pub mod marketplace_commands;
@@ -78,6 +78,13 @@ pub(crate) fn restore_marketplace_fields(incoming: &mut AppSettings, stored: &Ap
incoming.global_marketplace_installs = stored.global_marketplace_installs.clone();
}
/// Host connections change only through `host_commands` (which validates
/// them and owns their keychain entries) and settings import, never through
/// a settings save the frontend posts back.
pub(crate) fn restore_host_fields(incoming: &mut AppSettings, stored: &AppSettings) {
incoming.hosts = stored.hosts.clone();
}
#[tauri::command]
pub async fn update_settings(
mut settings: AppSettings,
@@ -87,6 +94,7 @@ pub async fn update_settings(
validate_settings_update(&before, &settings)?;
restore_marketplace_fields(&mut settings, &before);
restore_host_fields(&mut settings, &before);
let saved = state.settings_store.update(settings)?;
@@ -466,4 +474,27 @@ mod tests {
"the edit the save was for still applies"
);
}
#[test]
fn update_settings_cannot_change_hosts() {
use crate::models::{AwsAuth, HostConnection, HostKind};
let conn = |id: &str, region: &str| HostConnection {
id: id.into(),
name: "Prod".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile { name: "dev".into() },
default_region: region.into(),
},
};
let mut stored = AppSettings::default();
stored.hosts.push(conn("c1", "us-west-2"));
let mut incoming = AppSettings::default();
incoming.hosts = vec![conn("c1", "not a region"), conn("evil", "us-east-1")];
incoming.auto_check_updates = false;
restore_host_fields(&mut incoming, &stored);
assert_eq!(incoming.hosts, stored.hosts);
assert!(!incoming.auto_check_updates);
}
}
@@ -43,6 +43,7 @@ use zeroize::Zeroizing;
use std::collections::BTreeMap;
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
use crate::models::marketplace::{AccountMethod, MarketplaceAccount};
use crate::models::{
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
@@ -138,6 +139,7 @@ fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSec
&settings.marketplace_accounts,
secure::get_marketplace_token,
),
aws_connection_keys: exported_aws_connection_keys(&settings.hosts, secure::get_aws_keys),
};
(settings, secrets)
@@ -184,6 +186,107 @@ fn restore_marketplace_tokens(
warnings
}
/// The stored keys of every `Keys` host connection, by connection id, as the
/// `AwsKeys` JSON. A `Profile` connection holds none. A missing or unreadable
/// entry is left out, like the other keychain secrets above.
fn exported_aws_connection_keys(
hosts: &[HostConnection],
get: impl Fn(&str) -> Result<Option<secure::AwsKeys>, String>,
) -> BTreeMap<String, String> {
hosts
.iter()
.filter(|h| matches!(&h.kind, HostKind::Aws { auth: AwsAuth::Keys { .. }, .. }))
.filter_map(|h| {
let keys = get(&h.id).unwrap_or_default()?;
Some((h.id.clone(), serde_json::to_string(&keys).ok()?))
})
.collect()
}
/// Write each imported set of AWS keys to the keychain, returning a warning
/// (naming the connection id, never the value) for each that could not be.
fn restore_aws_connection_keys(
keys: &BTreeMap<String, String>,
mut store: impl FnMut(&str, &secure::AwsKeys) -> Result<(), String>,
) -> Vec<String> {
let mut warnings = Vec::new();
for (id, json) in keys {
if json.trim().is_empty() {
continue;
}
let Ok(parsed) = serde_json::from_str::<secure::AwsKeys>(json) else {
log::warn!("Settings import: the AWS keys of connection {} are malformed", id);
warnings.push(format!(
"The AWS access keys of connection {} in the file are malformed; enter them again.",
id
));
continue;
};
if let Err(e) = store(id, &parsed) {
log::warn!(
"Settings import: could not restore the AWS keys of connection {}: {}",
id,
e
);
warnings.push(format!(
"Could not restore the AWS access keys of connection {} ({}); enter them again.",
id, e
));
}
}
warnings
}
/// Checks the imported host connections with the rules the connection
/// commands use, before anything is written; refuses the import (like
/// `validate_imported_marketplace_state`) rather than applying a bad entry.
/// A `Keys` connection's `keyring_ref` must be its own (valid) id, and keys
/// may only be carried for a `Keys` connection in the file.
fn validate_imported_hosts(
settings: &mut AppSettings,
keys: &BTreeMap<String, String>,
) -> Result<(), String> {
use crate::models::host_connection::{
validate_connection_name, validate_profile_name, validate_supported_region,
};
let wrap = |e: String| format!("The file's host connections were refused: {e}");
let mut seen = std::collections::HashSet::new();
for h in &mut settings.hosts {
if !secure::is_valid_connection_id(&h.id) {
return Err(wrap("a connection has an invalid id.".into()));
}
if !seen.insert(h.id.clone()) {
return Err(wrap("a connection appears twice.".into()));
}
h.name = validate_connection_name(&h.name).map_err(wrap)?;
let HostKind::Aws { auth, default_region } = &h.kind;
validate_supported_region(default_region).map_err(wrap)?;
match auth {
AwsAuth::Profile { name } => {
validate_profile_name(name).map_err(wrap)?;
}
AwsAuth::Keys { keyring_ref } => {
if !secure::is_valid_connection_id(keyring_ref) || *keyring_ref != h.id {
return Err(wrap(format!(
"the connection \"{}\" has an invalid key reference.",
h.name
)));
}
}
}
}
for id in keys.keys() {
let is_keys_connection = settings.hosts.iter().any(|h| {
h.id == *id && matches!(&h.kind, HostKind::Aws { auth: AwsAuth::Keys { .. }, .. })
});
if !is_keys_connection {
return Err(wrap("access keys are present for an unknown connection.".into()));
}
}
Ok(())
}
/// Export the current global settings and secrets to a password-encrypted
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
/// deliberately distinguishable from one so the frontend shows nothing
@@ -260,7 +363,8 @@ pub async fn preview_settings_import(
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
let preview = SettingsImportPreview::from_payload(&payload);
let local_hosts = state.settings_store.get().hosts;
let preview = SettingsImportPreview::from_payload_against(&payload, &local_hosts, secure::aws_keys_entry_state);
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
path,
@@ -376,6 +480,9 @@ pub async fn apply_settings_import(
&marketplace_tokens,
)?;
let aws_keys = payload.secrets.aws_connection_keys;
validate_imported_hosts(&mut settings, &aws_keys)?;
let mut secret_restore_warnings = Vec::new();
let mut gateway_secret_changed = false;
@@ -423,24 +530,20 @@ pub async fn apply_settings_import(
secure::store_marketplace_token,
));
let imported_marketplace = (
settings.marketplace_accounts.clone(),
settings.marketplaces.clone(),
settings.global_marketplace_installs.clone(),
);
secret_restore_warnings.extend(restore_aws_connection_keys(
&aws_keys,
secure::store_aws_keys,
));
let imported_owned = StoreOwned::of(&settings);
let saved =
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
// `update_settings` keeps marketplace state store-owned. An import is the
// one caller entitled to replace it wholesale.
let saved = {
let mut s = saved;
(
s.marketplace_accounts,
s.marketplaces,
s.global_marketplace_installs,
) = imported_marketplace;
state.settings_store.update(s)?
};
// `update_settings` keeps marketplace state and host connections
// store-owned. An import is the one caller entitled to replace them
// wholesale.
let saved = state
.settings_store
.update(imported_owned.applied_to(saved))?;
// Caches of marketplaces the import dropped are dead weight now, and
// the pins must match the imported installs.
use crate::commands::marketplace_commands as mc;
@@ -481,6 +584,34 @@ pub async fn apply_settings_import(
})
}
/// The settings `update_settings` keeps store-owned (marketplace state and
/// host connections), which an import re-applies after that call.
struct StoreOwned {
marketplace_accounts: Vec<crate::models::marketplace::MarketplaceAccount>,
marketplaces: Vec<crate::models::marketplace::Marketplace>,
global_marketplace_installs: Vec<crate::models::marketplace::MarketplaceInstall>,
hosts: Vec<crate::models::HostConnection>,
}
impl StoreOwned {
fn of(s: &AppSettings) -> Self {
Self {
marketplace_accounts: s.marketplace_accounts.clone(),
marketplaces: s.marketplaces.clone(),
global_marketplace_installs: s.global_marketplace_installs.clone(),
hosts: s.hosts.clone(),
}
}
fn applied_to(self, mut s: AppSettings) -> AppSettings {
s.marketplace_accounts = self.marketplace_accounts;
s.marketplaces = self.marketplaces;
s.global_marketplace_installs = self.global_marketplace_installs;
s.hosts = self.hosts;
s
}
}
fn non_blank(value: Option<String>) -> Option<String> {
value.filter(|v| !v.trim().is_empty())
}
@@ -827,4 +958,141 @@ mod tests {
assert_eq!(warnings.len(), 1);
assert!(!warnings[0].contains("test-token-not-real"));
}
fn keys_host(id: &str) -> HostConnection {
HostConnection {
id: id.into(),
name: "Prod".into(),
kind: HostKind::Aws {
auth: AwsAuth::Keys { keyring_ref: id.into() },
default_region: "us-west-2".into(),
},
}
}
fn profile_host(id: &str) -> HostConnection {
HostConnection {
id: id.into(),
name: "Dev".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile { name: "default".into() },
default_region: "us-west-2".into(),
},
}
}
fn test_keys() -> secure::AwsKeys {
secure::AwsKeys {
access_key_id: "AKIATESTNOTREAL".into(),
secret_access_key: "test-secret-not-real".into(),
session_token: None,
}
}
#[test]
fn aws_connection_keys_round_trip_through_export() {
let hosts = vec![keys_host("k1"), profile_host("p1")];
let exported = exported_aws_connection_keys(&hosts, |id| match id {
"k1" => Ok(Some(test_keys())),
other => panic!("only a Keys connection is read, got {other}"),
});
assert_eq!(exported.keys().collect::<Vec<_>>(), vec!["k1"]);
assert_eq!(serde_json::from_str::<secure::AwsKeys>(&exported["k1"]).unwrap(), test_keys());
let mut stored = Vec::new();
let warnings = restore_aws_connection_keys(&exported, |id, keys| {
stored.push((id.to_string(), keys.clone()));
Ok(())
});
assert!(warnings.is_empty());
assert_eq!(stored, vec![("k1".to_string(), test_keys())]);
}
#[test]
fn aws_connection_keys_restore_failures_name_the_id_not_the_value() {
let keys = BTreeMap::from([
("bad".to_string(), "not json test-secret-not-real".to_string()),
("locked".to_string(), serde_json::to_string(&test_keys()).unwrap()),
]);
let warnings = restore_aws_connection_keys(&keys, |_, _| Err("keychain locked".into()));
assert_eq!(warnings.len(), 2);
assert!(warnings[0].contains("bad"));
assert!(warnings[1].contains("locked"));
assert!(warnings.iter().all(|w| !w.contains("test-secret-not-real")));
}
#[test]
fn imported_hosts_are_validated() {
let check = |hosts: Vec<HostConnection>, keys: BTreeMap<String, String>| {
let mut s = AppSettings::default();
s.hosts = hosts;
validate_imported_hosts(&mut s, &keys)
};
let none = BTreeMap::new();
assert!(check(vec![keys_host("k1"), profile_host("p1")], none.clone()).is_ok());
assert!(check(vec![keys_host("k1")], BTreeMap::from([("k1".into(), "{}".into())])).is_ok());
let mut bad_name = profile_host("p1");
bad_name.name = " ".into();
assert!(check(vec![bad_name], none.clone()).is_err());
let mut bad_region = profile_host("p1");
bad_region.kind = HostKind::Aws {
auth: AwsAuth::Profile { name: "default".into() },
default_region: "nowhere".into(),
};
assert!(check(vec![bad_region], none.clone()).is_err());
let mut cn = profile_host("p1");
cn.kind = HostKind::Aws {
auth: AwsAuth::Profile { name: "default".into() },
default_region: "cn-north-1".into(),
};
let err = check(vec![cn], none.clone()).unwrap_err();
assert!(err.contains("China and GovCloud regions aren't supported yet."), "{err}");
let mut bad_profile = profile_host("p1");
bad_profile.kind = HostKind::Aws {
auth: AwsAuth::Profile { name: "a b".into() },
default_region: "us-west-2".into(),
};
assert!(check(vec![bad_profile], none.clone()).is_err());
let mut other_ref = keys_host("k1");
other_ref.kind = HostKind::Aws {
auth: AwsAuth::Keys { keyring_ref: "someone-else".into() },
default_region: "us-west-2".into(),
};
assert!(check(vec![other_ref], none.clone()).is_err());
assert!(check(vec![profile_host("../x")], none.clone()).is_err());
assert!(check(vec![profile_host("p1"), profile_host("p1")], none.clone()).is_err());
// Keys carried for a connection that is not a Keys connection.
assert!(check(vec![profile_host("p1")], BTreeMap::from([("p1".into(), "{}".into())])).is_err());
assert!(check(vec![], BTreeMap::from([("ghost".into(), "{}".into())])).is_err());
}
#[test]
fn imported_hosts_survive_apply() {
use crate::models::{AwsAuth, HostConnection, HostKind};
let host = HostConnection {
id: "h1".into(),
name: "Imported".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile { name: "dev".into() },
default_region: "us-west-2".into(),
},
};
let stored = AppSettings::default();
let mut imported = AppSettings::default();
imported.hosts.push(host.clone());
let owned = StoreOwned::of(&imported);
// What `update_settings` does to the incoming settings.
let mut after_update = imported.clone();
crate::commands::settings_commands::restore_host_fields(&mut after_update, &stored);
assert!(after_update.hosts.is_empty(), "update_settings drops hosts");
assert_eq!(owned.applied_to(after_update).hosts, vec![host]);
}
}
+4
View File
@@ -30,6 +30,10 @@
pub mod agent;
pub mod lifecycle;
pub mod sessions;
// Nothing in the app opens an SSM tunnel until the AWS host lands (phase 5 of
// the AWS spec); its tests exercise it meanwhile. Remove this allow then.
#[allow(dead_code)]
pub mod ssm;
pub mod watch;
pub use crate::docker::exec::{
+842
View File
@@ -0,0 +1,842 @@
//! The transport to an agent in an ECS task: one SSM port-forwarding session
//! (`AWS-StartPortForwardingSession`) from a loopback port on this machine to
//! the agent's port inside the task, kept open for as long as the project is
//! connected and reopened when it drops. Everything else — execs, files,
//! tunnels — then goes through [`AgentEndpoint`] exactly as it does locally.
//! Design: `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`
//! ("Connectivity").
//!
//! The SSM data channel is a custom websocket protocol the Rust SDK does not
//! implement, so v1 runs AWS's `session-manager-plugin` as a child process,
//! the way the AWS CLI does: the app calls `ssm:StartSession` itself and hands
//! the plugin the stream URL and token.
//!
//! Three things here are deliberate:
//!
//! - **The session token never goes on a command line.** The plugin reads the
//! `StartSession` response from the environment variable named by its first
//! argument (`AWS_SSM_START_SESSION_RESPONSE`, what the AWS CLI does for
//! plugins that support it), so it is not visible in `ps`.
//! - **The plugin gets the caller's current credentials in its environment,
//! and nothing else of AWS's.** It needs them only to resume its data
//! channel after a network blip (`ssm:ResumeSession`), which keeps every TCP
//! connection through the tunnel — open terminals included — alive. Any
//! `AWS_*` variable this process happens to have is removed first, so the
//! plugin can never act as a different identity than the one that started
//! the session. When those credentials have expired a resume fails, the
//! plugin exits, and the supervisor here opens a fresh session with fresh
//! credentials on the **same local port**, so an [`AgentEndpoint`] built
//! once stays valid across reconnects (open connections do not survive that
//! path; new ones succeed).
//! - **Whoever holds a [`PortForward`] must `close()` it when the app exits.**
//! Tauri exits through `std::process::exit`, which drops no runtime tasks,
//! so `kill_on_drop` never fires and the plugin would keep the session and
//! the port until SSM's idle timeout.
//! - **The plugin listens on `127.0.0.1` only** (checked: plugin 1.2.896), and
//! the agent behind it still wants its bearer token. The SSM session's IAM
//! check is the outer gate.
use aws_config::SdkConfig;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::watch;
use tokio_util::sync::{CancellationToken, DropGuard};
use zeroize::Zeroizing;
use super::AgentEndpoint;
/// Where `triple-c-agent` listens inside the task (its default).
pub const AGENT_PORT: u16 = 47900;
const PORT_FORWARD_DOCUMENT: &str = "AWS-StartPortForwardingSession";
/// The environment variable the plugin reads the `StartSession` response from.
const RESPONSE_ENV: &str = "AWS_SSM_START_SESSION_RESPONSE";
/// What the plugin prints once its local listener is up.
const READY_LINE: &str = "Waiting for connections";
/// Plugin start to listener up. Measured at ~1-2 s; this bounds a wedged one.
const READY_TIMEOUT: Duration = if cfg!(test) {
Duration::from_secs(2)
} else {
Duration::from_secs(30)
};
/// The last lines of plugin output kept for error messages.
const OUTPUT_TAIL_LINES: usize = 20;
/// Reconnect delays; the last repeats. A reconnect that keeps failing for
/// about ten minutes gives up ([`TunnelState::Failed`]) — by then the task is
/// gone or the credentials need the user, and the host layer decides what the
/// project's state is.
const RECONNECT_BACKOFF: &[Duration] = &[
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(5),
Duration::from_secs(10),
Duration::from_secs(30),
];
const MAX_RECONNECT_ATTEMPTS: u32 = 25;
/// Attempts to find a free local port for the first connection.
const INITIAL_PORT_ATTEMPTS: u32 = 3;
const TERMINATE_TIMEOUT: Duration = Duration::from_secs(5);
/// How often an Up tunnel sends traffic of its own. Session Manager ends a
/// session after 20 minutes with no data (its default `idleSessionTimeout`;
/// measured: a silent tunnel was cut at 19.7 min), and with it every TCP
/// connection inside — an idle terminal tab included.
const KEEPALIVE_INTERVAL: Duration = if cfg!(test) {
Duration::from_millis(200)
} else {
Duration::from_secs(5 * 60)
};
const KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10);
// ── The task ─────────────────────────────────────────────────────────────────
/// An ECS task container that an SSM session can target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EcsTask {
/// The cluster's *name* (the SSM target wants the name, not the ARN).
pub cluster: String,
pub task_id: String,
/// The container's runtime id, from `DescribeTasks`.
pub runtime_id: String,
}
impl EcsTask {
/// `ecs:<cluster>_<taskId>_<runtimeId>`, the SSM target for one container
/// of one task.
pub fn ssm_target(&self) -> String {
format!("ecs:{}_{}_{}", self.cluster, self.task_id, self.runtime_id)
}
/// Look up `container` in `task` (an id or ARN) on `cluster` (a name or
/// ARN), and check it can take a session now: the task is running and
/// its ECS Exec agent is up. Fargate starts that agent a few seconds after
/// the container, so "not ready yet" is a normal answer just after start.
pub async fn resolve(
ecs: &aws_sdk_ecs::Client,
cluster: &str,
task: &str,
container: &str,
) -> Result<Self, String> {
let cluster_name = last_arn_segment(cluster, "cluster/");
let out = ecs
.describe_tasks()
.cluster(cluster)
.tasks(task)
.send()
.await
.map_err(|e| format!("Could not look up the ECS task: {}", sdk_error(&e)))?;
let t = out
.tasks()
.first()
.ok_or_else(|| format!("ECS task {task} was not found in cluster {cluster_name}"))?;
let task_arn = t.task_arn().unwrap_or(task);
let task_id = last_arn_segment(task_arn, "/").to_string();
let status = t.last_status().unwrap_or("UNKNOWN");
if status != "RUNNING" {
return Err(format!("ECS task {task_id} is {status}, not RUNNING"));
}
let c = t
.containers()
.iter()
.find(|c| c.name() == Some(container))
.ok_or_else(|| format!("ECS task {task_id} has no container named {container}"))?;
let runtime_id = c
.runtime_id()
.ok_or_else(|| format!("Container {container} in task {task_id} has no runtime id yet"))?
.to_string();
let exec_agent = c
.managed_agents()
.iter()
.find(|a| a.name().map(|n| n.as_str()) == Some("ExecuteCommandAgent"));
match exec_agent.and_then(|a| a.last_status()) {
Some("RUNNING") => {}
Some(s) => {
return Err(format!(
"Container {container} in task {task_id} is not ready for connections yet (ECS Exec agent {s})"
))
}
None => {
return Err(format!(
"ECS task {task_id} was started without ECS Exec, so it cannot be connected to"
))
}
}
Ok(Self {
cluster: cluster_name.to_string(),
task_id,
runtime_id,
})
}
}
/// The part of an ARN after the last `marker` (`.../cluster/name` → `name`),
/// or the whole string when it is not an ARN.
fn last_arn_segment<'a>(s: &'a str, marker: &str) -> &'a str {
if !s.starts_with("arn:") {
return s;
}
match s.rfind(marker) {
Some(i) => &s[i + marker.len()..],
None => s,
}
}
/// An SDK error with its cause chain (the bare `Display` is often just
/// "service error").
fn sdk_error(e: &(dyn std::error::Error + 'static)) -> String {
format!("{}", aws_sdk_ssm::error::DisplayErrorContext(e))
}
// ── The plugin ───────────────────────────────────────────────────────────────
/// Find `session-manager-plugin`: `TRIPLEC_SSM_PLUGIN` if set, then `PATH`,
/// then where AWS's installers put it (the GUI app on macOS does not get the
/// shell's `PATH`, so the install locations are not optional).
pub fn find_plugin() -> Result<PathBuf, String> {
if let Some(p) = std::env::var_os("TRIPLEC_SSM_PLUGIN").filter(|p| !p.is_empty()) {
let p = PathBuf::from(p);
return if p.is_file() {
Ok(p)
} else {
Err(format!("TRIPLEC_SSM_PLUGIN names {}, which is not a file", p.display()))
};
}
let name = if cfg!(windows) { "session-manager-plugin.exe" } else { "session-manager-plugin" };
let on_path = std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).map(|d| d.join(name)).collect::<Vec<_>>())
.unwrap_or_default();
let installed: Vec<PathBuf> = if cfg!(windows) {
["ProgramFiles", "ProgramFiles(x86)"]
.iter()
.filter_map(|v| std::env::var_os(v))
.map(|d| PathBuf::from(d).join(r"Amazon\SessionManagerPlugin\bin").join(name))
.collect()
} else {
[
"/usr/local/sessionmanagerplugin/bin",
"/usr/local/bin",
"/opt/homebrew/bin",
"/usr/bin",
]
.iter()
.map(|d| Path::new(d).join(name))
.collect()
};
on_path
.into_iter()
.chain(installed)
.find(|p| p.is_file())
.ok_or_else(|| {
"The AWS Session Manager plugin (session-manager-plugin) is not installed. It is \
what connects to a workspace on AWS; install it from \
https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html"
.to_string()
})
}
/// A running plugin process with a listener on the local port.
struct Running {
child: Child,
session_id: String,
output: OutputTail,
}
/// The last lines a plugin printed (stdout and stderr), for error text.
#[derive(Clone, Default)]
struct OutputTail(Arc<Mutex<Vec<String>>>);
impl OutputTail {
fn push(&self, line: String) {
let mut v = self.0.lock().unwrap();
v.push(line);
if v.len() > OUTPUT_TAIL_LINES {
v.remove(0);
}
}
/// The most informative recent line: the last non-blank one.
fn last(&self) -> Option<String> {
self.0.lock().unwrap().iter().rev().find(|l| !l.trim().is_empty()).cloned()
}
fn joined(&self) -> String {
self.0
.lock()
.unwrap()
.iter()
.filter(|l| !l.trim().is_empty())
.cloned()
.collect::<Vec<_>>()
.join("; ")
}
}
/// How to start the plugin for one session. Everything but `env_secret` is
/// safe to log; that is the `StartSession` response.
struct PluginLaunch {
program: PathBuf,
args: Vec<String>,
env: Vec<(String, Zeroizing<String>)>,
session_id: String,
}
/// Start the plugin and wait until it says its listener is up. Its output is
/// then drained in the background for good (a full pipe would stall it).
async fn spawn_plugin(launch: PluginLaunch) -> Result<Running, String> {
let mut cmd = Command::new(&launch.program);
cmd.args(&launch.args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
// Only the credentials this session was started with: nothing AWS-shaped
// from our own environment rides along.
for (k, _) in std::env::vars_os() {
if k.to_string_lossy().starts_with("AWS_") {
cmd.env_remove(&k);
}
}
for (k, v) in &launch.env {
cmd.env(k, v.as_str());
}
#[cfg(windows)]
{
// CREATE_NO_WINDOW: no console flashing up per (re)connect.
cmd.creation_flags(0x0800_0000);
}
let mut child = cmd
.spawn()
.map_err(|e| format!("Could not start {}: {e}", launch.program.display()))?;
let output = OutputTail::default();
let (ready_tx, mut ready_rx) = watch::channel(false);
let stdout = child.stdout.take().expect("stdout is piped");
let stderr = child.stderr.take().expect("stderr is piped");
drain(stdout, output.clone(), Some(ready_tx));
drain(stderr, output.clone(), None);
let ready = async {
loop {
if *ready_rx.borrow_and_update() {
return true;
}
if ready_rx.changed().await.is_err() {
// stdout closed without the ready line: the plugin is exiting.
return *ready_rx.borrow();
}
}
};
tokio::select! {
r = tokio::time::timeout(READY_TIMEOUT, ready) => match r {
Ok(true) => Ok(Running { child, session_id: launch.session_id, output }),
Ok(false) => {
let _ = child.wait().await;
Err(plugin_failed(&output))
}
Err(_) => {
let _ = child.kill().await;
Err(format!(
"The SSM port forward did not come up within {} s ({})",
READY_TIMEOUT.as_secs(),
output.joined()
))
}
},
// Exited before (or without) printing the ready line.
status = child.wait() => {
// Let the drains catch the last lines before reporting them.
tokio::time::sleep(Duration::from_millis(50)).await;
let _ = status;
Err(plugin_failed(&output))
}
}
}
fn plugin_failed(output: &OutputTail) -> String {
match output.joined() {
s if s.is_empty() => "The SSM port forward exited before it was ready".to_string(),
s => format!("The SSM port forward exited before it was ready: {s}"),
}
}
fn drain(
stream: impl AsyncRead + Unpin + Send + 'static,
tail: OutputTail,
ready: Option<watch::Sender<bool>>,
) {
tokio::spawn(async move {
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
log::debug!("session-manager-plugin: {line}");
if let Some(tx) = &ready {
if line.contains(READY_LINE) {
let _ = tx.send(true);
}
}
tail.push(line);
}
// `ready` drops here; a waiter sees the channel close.
});
}
// ── Opening sessions ─────────────────────────────────────────────────────────
/// Opens and ends sessions. The real one calls SSM and runs the plugin; tests
/// substitute a fake plugin without AWS.
trait SessionOpener: Send + Sync + 'static {
fn open(&self, local_port: u16) -> impl Future<Output = Result<Running, String>> + Send;
fn terminate(&self, session_id: String) -> impl Future<Output = ()> + Send;
}
struct SsmOpener {
sdk: SdkConfig,
/// The credentials last handed to the plugin, reused until they near
/// expiry. Each fetch from the provider can be a `credential_process`
/// (an MFA or biometric prompt), an STS call or an SSO call, and the
/// provider in `SdkConfig` is not the SDK's cached one.
plugin_creds: tokio::sync::Mutex<Option<aws_sdk_ssm::config::Credentials>>,
ssm: aws_sdk_ssm::Client,
region: String,
plugin: PathBuf,
target: String,
remote_port: u16,
}
impl SessionOpener for SsmOpener {
async fn open(&self, local_port: u16) -> Result<Running, String> {
let out = self
.ssm
.start_session()
.target(&self.target)
.document_name(PORT_FORWARD_DOCUMENT)
.parameters("portNumber", vec![self.remote_port.to_string()])
.parameters("localPortNumber", vec![local_port.to_string()])
.send()
.await
.map_err(|e| format!("Could not start the SSM session: {}", sdk_error(&e)))?;
let session_id = out.session_id().unwrap_or_default().to_string();
// From here the session exists on AWS's side. If this future is
// dropped before it returns — a close or drop racing a reconnect, a
// caller timing out the first open — the guard ends the session.
let guard = SessionGuard {
ssm: self.ssm.clone(),
session_id: Some(session_id.clone()),
};
let response = Zeroizing::new(
serde_json::json!({
"SessionId": session_id,
"StreamUrl": out.stream_url().unwrap_or_default(),
"TokenValue": out.token_value().unwrap_or_default(),
})
.to_string(),
);
let request = serde_json::json!({
"Target": self.target,
"DocumentName": PORT_FORWARD_DOCUMENT,
"Parameters": {
"portNumber": [self.remote_port.to_string()],
"localPortNumber": [local_port.to_string()],
},
})
.to_string();
let mut env = vec![
(RESPONSE_ENV.to_string(), response),
("AWS_REGION".to_string(), Zeroizing::new(self.region.clone())),
];
env.extend(self.plugin_credentials().await);
let launch = PluginLaunch {
program: self.plugin.clone(),
// <response env var> <region> StartSession <profile> <request> <endpoint>:
// what the AWS CLI passes. No profile: credentials come from env.
args: vec![
RESPONSE_ENV.to_string(),
self.region.clone(),
"StartSession".to_string(),
String::new(),
request,
ssm_endpoint(&self.region),
],
env,
session_id: session_id.clone(),
};
match spawn_plugin(launch).await {
Ok(r) => {
guard.disarm();
Ok(r)
}
Err(e) => {
// No plugin is attached to the session; don't leave it open
// until the idle timeout.
guard.terminate().await;
Err(e)
}
}
}
async fn terminate(&self, session_id: String) {
terminate_session(&self.ssm, session_id).await
}
}
async fn terminate_session(ssm: &aws_sdk_ssm::Client, session_id: String) {
if session_id.is_empty() {
return;
}
let call = ssm.terminate_session().session_id(&session_id).send();
match tokio::time::timeout(TERMINATE_TIMEOUT, call).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => log::warn!("TerminateSession {session_id}: {}", sdk_error(&e)),
Err(_) => log::warn!("TerminateSession {session_id}: timed out"),
}
}
/// Ends a started session unless disarmed: on an error path explicitly, and
/// on drop (a cancelled open) from a spawned task.
struct SessionGuard {
ssm: aws_sdk_ssm::Client,
session_id: Option<String>,
}
impl SessionGuard {
fn disarm(mut self) {
self.session_id = None;
}
async fn terminate(mut self) {
if let Some(id) = self.session_id.take() {
terminate_session(&self.ssm, id).await;
}
}
}
impl Drop for SessionGuard {
fn drop(&mut self) {
if let Some(id) = self.session_id.take() {
if let Ok(rt) = tokio::runtime::Handle::try_current() {
let ssm = self.ssm.clone();
rt.spawn(async move { terminate_session(&ssm, id).await });
}
}
}
}
/// How long before expiry cached plugin credentials are refreshed. A plugin
/// started with them may need them for a resume well after it starts.
const PLUGIN_CREDS_MIN_REMAINING: Duration = Duration::from_secs(15 * 60);
impl SsmOpener {
/// The caller's credentials as the plugin's environment, for its own
/// `ResumeSession` calls. Without them a resume fails and the
/// supervisor's full reconnect takes over, so a failure here is not fatal.
async fn plugin_credentials(&self) -> Vec<(String, Zeroizing<String>)> {
use aws_sdk_ssm::config::ProvideCredentials;
let mut cached = self.plugin_creds.lock().await;
let fresh = |c: &aws_sdk_ssm::config::Credentials| match c.expiry() {
None => true,
Some(exp) => exp
.duration_since(std::time::SystemTime::now())
.map(|left| left > PLUGIN_CREDS_MIN_REMAINING)
.unwrap_or(false),
};
if !cached.as_ref().is_some_and(fresh) {
*cached = None;
if let Some(provider) = self.sdk.credentials_provider() {
match provider.provide_credentials().await {
Ok(c) => *cached = Some(c),
Err(e) => log::warn!("No credentials for the SSM plugin's resume: {}", sdk_error(&e)),
}
}
}
let Some(c) = cached.as_ref() else {
return vec![];
};
let mut v = vec![
("AWS_ACCESS_KEY_ID".to_string(), Zeroizing::new(c.access_key_id().to_string())),
(
"AWS_SECRET_ACCESS_KEY".to_string(),
Zeroizing::new(c.secret_access_key().to_string()),
),
];
if let Some(t) = c.session_token() {
v.push(("AWS_SESSION_TOKEN".to_string(), Zeroizing::new(t.to_string())));
}
v
}
}
fn ssm_endpoint(region: &str) -> String {
let suffix = if region.starts_with("cn-") { "amazonaws.com.cn" } else { "amazonaws.com" };
format!("https://ssm.{region}.{suffix}")
}
/// A free loopback port, for the plugin to bind. The plugin binds it a moment
/// later, so another process can take it in between; the first connection
/// retries with a new port when that happens.
fn free_local_port() -> Result<u16, String> {
std::net::TcpListener::bind(("127.0.0.1", 0))
.and_then(|l| l.local_addr())
.map(|a| a.port())
.map_err(|e| format!("Could not find a free local port: {e}"))
}
// ── The port forward ─────────────────────────────────────────────────────────
/// Where the tunnel is. Watch it with [`PortForward::state`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TunnelState {
Up,
/// The session dropped and is being reopened. `last_error` is why the
/// previous one ended or the previous attempt failed.
Reconnecting { attempt: u32, last_error: String },
/// Reconnecting gave up. Nothing listens on the local port.
Failed(String),
/// [`PortForward::close`] ran, or the handle was dropped.
Closed,
}
/// One SSM port-forward to an agent, supervised: reopened on the same local
/// port when it drops, ended (`TerminateSession`) when this handle is closed
/// or dropped.
pub struct PortForward {
local_port: u16,
state: watch::Receiver<TunnelState>,
plugin_pid: watch::Receiver<Option<u32>>,
supervisor: Option<tokio::task::JoinHandle<()>>,
_cancel: DropGuard,
cancel: CancellationToken,
}
impl std::fmt::Debug for PortForward {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PortForward")
.field("local_port", &self.local_port)
.field("state", &*self.state.borrow())
.finish()
}
}
impl PortForward {
/// Open a port forward from a free loopback port to `remote_port` in
/// `task`, and return once the local listener is up. Errors (no plugin,
/// IAM denial, the task not reachable) come back here rather than as a
/// state.
pub async fn open(sdk: &SdkConfig, task: &EcsTask, remote_port: u16) -> Result<Self, String> {
let region = sdk
.region()
.ok_or("No AWS region is configured for this connection")?
.to_string();
let opener = SsmOpener {
sdk: sdk.clone(),
plugin_creds: Default::default(),
ssm: aws_sdk_ssm::Client::new(sdk),
region,
plugin: find_plugin()?,
target: task.ssm_target(),
remote_port,
};
Self::open_with(opener).await
}
async fn open_with<O: SessionOpener>(opener: O) -> Result<Self, String> {
let mut last_err = String::new();
for _ in 0..INITIAL_PORT_ATTEMPTS {
let port = free_local_port()?;
match opener.open(port).await {
Ok(running) => return Ok(Self::supervise(opener, port, running)),
Err(e) if is_port_in_use(&e) => last_err = e,
Err(e) => return Err(e),
}
}
Err(last_err)
}
fn supervise<O: SessionOpener>(opener: O, local_port: u16, running: Running) -> Self {
let (state_tx, state) = watch::channel(TunnelState::Up);
let (pid_tx, plugin_pid) = watch::channel(running.child.id());
let cancel = CancellationToken::new();
let supervisor = tokio::spawn(run_supervisor(
opener,
local_port,
running,
state_tx,
pid_tx,
cancel.clone(),
));
Self {
local_port,
state,
plugin_pid,
supervisor: Some(supervisor),
_cancel: cancel.clone().drop_guard(),
cancel,
}
}
pub fn local_port(&self) -> u16 {
self.local_port
}
/// The agent at the local end of this tunnel. Stays valid across
/// reconnects (the port does not change).
pub fn endpoint(&self, token: impl Into<String>) -> AgentEndpoint {
AgentEndpoint::loopback(self.local_port, token)
}
pub fn state(&self) -> watch::Receiver<TunnelState> {
self.state.clone()
}
/// The running plugin's process id, if one is running.
pub fn plugin_pid(&self) -> Option<u32> {
*self.plugin_pid.borrow()
}
/// Stop the plugin and end the session, and wait for both.
pub async fn close(mut self) {
self.cancel.cancel();
if let Some(s) = self.supervisor.take() {
let _ = s.await;
}
}
}
fn is_port_in_use(e: &str) -> bool {
let e = e.to_ascii_lowercase();
e.contains("address already in use") || e.contains("only one usage of each socket address")
}
async fn run_supervisor<O: SessionOpener>(
opener: O,
local_port: u16,
mut running: Running,
state: watch::Sender<TunnelState>,
pid: watch::Sender<Option<u32>>,
cancel: CancellationToken,
) {
loop {
// ── Up: wait for the plugin to exit, or for close; keep it busy ──
let mut keepalive = tokio::time::interval_at(
tokio::time::Instant::now() + KEEPALIVE_INTERVAL,
KEEPALIVE_INTERVAL,
);
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let exited = loop {
tokio::select! {
s = running.child.wait() => break s,
_ = keepalive.tick() => {
tokio::spawn(keepalive_probe(local_port));
}
_ = cancel.cancelled() => {
let _ = running.child.kill().await;
opener.terminate(running.session_id).await;
let _ = pid.send(None);
let _ = state.send(TunnelState::Closed);
return;
}
}
};
let _ = pid.send(None);
// Let the drains catch the last lines.
tokio::time::sleep(Duration::from_millis(50)).await;
// Both halves: SSM's own reason is in the output ("timed out due to
// inactivity"), but after a kill the last line is just whatever the
// plugin printed before, so the exit status has to be there too.
let mut last_error = match running.output.last() {
Some(line) => format!("the SSM session ended ({}; last output: {line})", describe_exit(exited)),
None => format!("the SSM session ended ({})", describe_exit(exited)),
};
log::warn!("SSM port forward on 127.0.0.1:{local_port}: {last_error}; reconnecting");
opener.terminate(running.session_id).await;
// ── Reconnecting ──
let mut attempt = 0;
running = loop {
attempt += 1;
if attempt > MAX_RECONNECT_ATTEMPTS {
let _ = state.send(TunnelState::Failed(last_error));
// Nothing to clean up; wait for the handle to go.
cancel.cancelled().await;
let _ = state.send(TunnelState::Closed);
return;
}
let _ = state.send(TunnelState::Reconnecting {
attempt,
last_error: last_error.clone(),
});
let delay = reconnect_delay(attempt);
tokio::select! {
_ = tokio::time::sleep(delay) => {}
_ = cancel.cancelled() => {
let _ = state.send(TunnelState::Closed);
return;
}
}
let opened = tokio::select! {
r = opener.open(local_port) => r,
_ = cancel.cancelled() => {
let _ = state.send(TunnelState::Closed);
return;
}
};
match opened {
Ok(r) => break r,
Err(e) => {
log::warn!("SSM port forward on 127.0.0.1:{local_port}: reconnect {attempt}: {e}");
last_error = e;
}
}
};
let _ = pid.send(running.child.id());
let _ = state.send(TunnelState::Up);
log::info!("SSM port forward on 127.0.0.1:{local_port}: reconnected after {attempt} attempt(s)");
}
}
/// One request through the tunnel so SSM sees data: an *unauthenticated*
/// `GET /v1/health`. The agent answers `401` before it counts anything as
/// activity, so this keeps the session alive without keeping the workspace
/// "in use" for idle auto-stop — and it needs no token here.
async fn keepalive_probe(local_port: u16) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let probe = async {
let mut s = tokio::net::TcpStream::connect(("127.0.0.1", local_port)).await?;
s.write_all(b"GET /v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
.await?;
let mut sink = [0u8; 512];
while s.read(&mut sink).await? > 0 {}
Ok::<_, std::io::Error>(())
};
match tokio::time::timeout(KEEPALIVE_TIMEOUT, probe).await {
Ok(Ok(())) => {}
Ok(Err(e)) => log::debug!("SSM keepalive on 127.0.0.1:{local_port}: {e}"),
Err(_) => log::debug!("SSM keepalive on 127.0.0.1:{local_port}: timed out"),
}
}
fn reconnect_delay(attempt: u32) -> Duration {
let d = RECONNECT_BACKOFF[(attempt as usize).saturating_sub(1).min(RECONNECT_BACKOFF.len() - 1)];
if cfg!(test) {
d / 100
} else {
d
}
}
fn describe_exit(s: std::io::Result<std::process::ExitStatus>) -> String {
match s {
Ok(s) => format!("plugin {s}"),
Err(e) => format!("plugin wait failed: {e}"),
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod live_tests;
+192
View File
@@ -0,0 +1,192 @@
//! The real thing: an SSM port forward to `triple-c-agent` in a running ECS
//! task, used through `ContainerTarget::Agent` exactly as the app will.
//!
//! Ignored; needs AWS credentials (the default chain — profile, SSO or env),
//! `session-manager-plugin`, and a task started with ECS Exec from an image
//! that has the agent:
//!
//! - `TRIPLEC_SSM_E2E_CLUSTER`, `TRIPLEC_SSM_E2E_TASK` (name/id or ARN)
//! - `TRIPLEC_SSM_E2E_TOKEN` — the agent token the task was given
//! - `TRIPLEC_SSM_E2E_CONTAINER` — default `workspace`
//! - `TRIPLEC_SSM_E2E_HOLD_MINUTES` — only for the hold test
//!
//! The SSM session's IAM gate is what these exercise against AWS; the agent's
//! Docker parity is already pinned by `host::agent::live_tests`.
use super::*;
use crate::host::{ContainerTarget, ExecChunk};
use futures_util::StreamExt;
use std::time::Instant;
use tokio::io::AsyncWriteExt;
fn var(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set (see this file's header)"))
}
async fn connect() -> (PortForward, ContainerTarget, Duration) {
let sdk = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let ecs = aws_sdk_ecs::Client::new(&sdk);
let container = std::env::var("TRIPLEC_SSM_E2E_CONTAINER").unwrap_or_else(|_| "workspace".into());
let task = EcsTask::resolve(&ecs, &var("TRIPLEC_SSM_E2E_CLUSTER"), &var("TRIPLEC_SSM_E2E_TASK"), &container)
.await
.expect("resolve the task");
let t0 = Instant::now();
let pf = PortForward::open(&sdk, &task, AGENT_PORT).await.expect("open the port forward");
let opened = t0.elapsed();
let target = ContainerTarget::Agent(pf.endpoint(var("TRIPLEC_SSM_E2E_TOKEN")));
(pf, target, opened)
}
async fn wait_up(pf: &PortForward, within: Duration) -> Duration {
let t0 = Instant::now();
let mut s = pf.state();
tokio::time::timeout(within, s.wait_for(|s| *s == TunnelState::Up))
.await
.expect("the tunnel did not come back")
.unwrap();
t0.elapsed()
}
async fn whoami(t: &ContainerTarget) -> Result<String, String> {
t.exec_oneshot(vec!["whoami".into()]).await.map(|s| s.trim().to_string())
}
#[tokio::test]
#[ignore]
async fn the_agent_works_through_an_ssm_port_forward_and_survives_a_drop() {
let (pf, t, opened) = connect().await;
eprintln!("port forward up in {opened:?} on 127.0.0.1:{}", pf.local_port());
// ── the operations the app uses ──
assert!(t.is_running().await.unwrap());
assert_eq!(whoami(&t).await.unwrap(), "claude");
let n = 20;
let t0 = Instant::now();
for _ in 0..n {
t.exec_oneshot(vec!["true".into()]).await.unwrap();
}
eprintln!("one-shot exec round trip: {:?} average over {n}", t0.elapsed() / n);
// attach with stdin
let mut exec = t
.attach(crate::host::ExecSpec::new(vec!["head".into(), "-n1".into()]))
.await
.unwrap();
exec.input.write_all(b"through ssm\n").await.unwrap();
exec.input.shutdown().await.unwrap();
let mut out = Vec::new();
while let Some(Ok(c)) = exec.output.next().await {
if let ExecChunk::StdOut(b) = c {
out.extend_from_slice(&b);
}
}
assert_eq!(String::from_utf8_lossy(&out), "through ssm\n");
// 4 MiB up and back
let data: Vec<u8> = (0..4 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect();
let t0 = Instant::now();
t.upload_bytes_with_ids("/tmp", "ssm-e2e.bin", &data, 0o644, (1000, 1000)).await.unwrap();
let up = t0.elapsed();
let t0 = Instant::now();
let mut tar = Vec::new();
let mut s = t.download_archive("/tmp/ssm-e2e.bin");
while let Some(chunk) = s.next().await {
tar.extend_from_slice(&chunk.unwrap());
}
let down = t0.elapsed();
let mut a = tar::Archive::new(&tar[..]);
let mut e = a.entries().unwrap().next().unwrap().unwrap();
let mut back = Vec::new();
std::io::Read::read_to_end(&mut e, &mut back).unwrap();
assert!(back == data, "the file came back different");
eprintln!("4 MiB: upload {up:?}, download {down:?}");
t.exec_oneshot(vec!["rm".into(), "-f".into(), "/tmp/ssm-e2e.bin".into()]).await.unwrap();
// ── a dropped session: kill the plugin, as a crash or sleep would ──
let pid = pf.plugin_pid().unwrap();
std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().unwrap();
let mut s = pf.state();
s.wait_for(|s| *s != TunnelState::Up).await.unwrap();
let back_in = wait_up(&pf, Duration::from_secs(60)).await;
eprintln!("reconnected in {back_in:?} (same port {})", pf.local_port());
assert_eq!(whoami(&t).await.unwrap(), "claude", "the same endpoint works after a reconnect");
// ── close ends it ──
let port = pf.local_port();
pf.close().await;
assert!(
std::net::TcpStream::connect(("127.0.0.1", port)).is_err(),
"something still listens on the closed tunnel's port"
);
}
/// Hold one tunnel for `TRIPLEC_SSM_E2E_HOLD_MINUTES` (default 60): a probe
/// every minute for the first and last stretch, and a silent stretch in the
/// middle longer than Session Manager's default 20-minute idle timeout. One
/// attached exec stays open the whole time. Prints a timeline of tunnel
/// states; fails only if the tunnel cannot serve a request at the end.
#[tokio::test]
#[ignore]
async fn an_ssm_port_forward_holds_for_a_long_session() {
let minutes: u64 = std::env::var("TRIPLEC_SSM_E2E_HOLD_MINUTES")
.ok()
.and_then(|m| m.parse().ok())
.unwrap_or(60);
let (pf, t, opened) = connect().await;
let start = Instant::now();
let stamp = move || format!("[{:>5.1} min]", start.elapsed().as_secs_f64() / 60.0);
eprintln!("{} up in {opened:?}", stamp());
// Log every state change as it happens.
let mut states = pf.state();
let log_states = tokio::spawn(async move {
while states.changed().await.is_ok() {
eprintln!("{} tunnel: {:?}", stamp(), *states.borrow());
}
});
// A long-lived connection: does one TCP stream survive the whole hold?
let mut held = t.attach(crate::host::ExecSpec::new(vec!["cat".into()])).await.unwrap();
let idle_from = minutes / 4;
let idle_to = idle_from + 25.min(minutes / 2);
let mut failures = 0;
for m in 0..minutes {
if m < idle_from || m >= idle_to {
let t0 = Instant::now();
match whoami(&t).await {
Ok(_) => eprintln!("{} probe ok ({:?})", stamp(), t0.elapsed()),
Err(e) => {
failures += 1;
eprintln!("{} probe FAILED: {e}", stamp())
}
}
} else if m == idle_from {
eprintln!("{} silent until minute {idle_to}", stamp());
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
// Did the held stream survive? Echo through it.
let survived = async {
held.input.write_all(b"still here\n").await.ok()?;
let mut got = Vec::new();
while let Ok(Some(Ok(c))) = tokio::time::timeout(Duration::from_secs(10), held.output.next()).await {
if let ExecChunk::StdOut(b) = c {
got.extend_from_slice(&b);
if got.ends_with(b"\n") {
break;
}
}
}
Some(got == b"still here\n")
}
.await
.unwrap_or(false);
eprintln!("{} long-lived attach survived: {survived}; probe failures: {failures}", stamp());
assert_eq!(whoami(&t).await.unwrap(), "claude", "the tunnel serves at the end");
pf.close().await;
log_states.abort();
}
+402
View File
@@ -0,0 +1,402 @@
//! The plugin handling and the supervisor, against a fake plugin (a shell
//! script) instead of AWS. The real thing is in `live_tests.rs`.
use super::*;
#[test]
fn the_ssm_target_names_cluster_task_and_container_runtime() {
let t = EcsTask {
cluster: "triple-c".into(),
task_id: "9fb3a6cf".into(),
runtime_id: "9fb3a6cf-0791508406".into(),
};
assert_eq!(t.ssm_target(), "ecs:triple-c_9fb3a6cf_9fb3a6cf-0791508406");
}
#[test]
fn arns_are_reduced_to_their_names() {
assert_eq!(
last_arn_segment("arn:aws:ecs:us-west-2:123456789012:cluster/triple-c", "cluster/"),
"triple-c"
);
assert_eq!(
last_arn_segment("arn:aws:ecs:us-west-2:123456789012:task/triple-c/9fb3a6cf", "/"),
"9fb3a6cf"
);
assert_eq!(last_arn_segment("triple-c", "cluster/"), "triple-c");
assert_eq!(last_arn_segment("9fb3a6cf", "/"), "9fb3a6cf");
}
#[test]
fn the_plugin_endpoint_follows_the_partition() {
assert_eq!(ssm_endpoint("us-west-2"), "https://ssm.us-west-2.amazonaws.com");
assert_eq!(ssm_endpoint("cn-north-1"), "https://ssm.cn-north-1.amazonaws.com.cn");
}
#[test]
fn a_port_clash_is_recognised_on_every_platform() {
assert!(is_port_in_use("listen tcp 127.0.0.1:4: bind: address already in use"));
assert!(is_port_in_use(
"bind: Only one usage of each socket address (protocol/network address/port) is normally permitted."
));
assert!(!is_port_in_use("TargetNotConnected"));
}
#[test]
fn backoff_grows_then_holds() {
assert!(reconnect_delay(1) < reconnect_delay(2));
assert_eq!(reconnect_delay(5), reconnect_delay(500));
}
#[cfg(unix)]
mod with_a_fake_plugin {
use super::*;
use std::os::unix::fs::PermissionsExt;
use std::sync::atomic::{AtomicU32, Ordering};
/// A fake `session-manager-plugin`: a shell script.
fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p
}
/// Runs the script as `/bin/sh <script>`: the script is only ever read,
/// never exec'd, so a parallel test's fork holding it open for writing
/// for a moment cannot fail the spawn with ETXTBSY ("Text file busy").
fn launch(script: PathBuf, secret: &str) -> PluginLaunch {
PluginLaunch {
program: "/bin/sh".into(),
args: vec![
script.display().to_string(),
RESPONSE_ENV.into(),
"us-west-2".into(),
"StartSession".into(),
],
env: vec![(RESPONSE_ENV.into(), Zeroizing::new(secret.into()))],
session_id: "s-1".into(),
}
}
const READY: &str = "echo 'Port 1 opened for sessionId s.'; echo 'Waiting for connections...'";
#[tokio::test]
async fn the_session_token_travels_in_the_environment_never_on_the_command_line() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("seen");
// Nothing AWS-shaped from our own environment may reach the plugin.
// (A name no other test reads, so setting it here is not a race.)
std::env::set_var("AWS_TRIPLEC_SSM_TEST_LEAK", "leaked");
let p = script(
dir.path(),
"plugin",
&format!(
"echo \"argv=$*\" > {o}; echo \"env=${r}\" >> {o}; echo \"leak=$AWS_TRIPLEC_SSM_TEST_LEAK\" >> {o}; {READY}; exec sleep 30",
o = out.display(),
r = RESPONSE_ENV
),
);
let running = spawn_plugin(launch(p, "the-secret-token")).await.expect("ready");
let seen = std::fs::read_to_string(&out).unwrap();
std::env::remove_var("AWS_TRIPLEC_SSM_TEST_LEAK");
assert!(seen.contains("env=the-secret-token"), "{seen}");
let argv = seen.lines().find(|l| l.starts_with("argv=")).unwrap();
assert!(!argv.contains("the-secret-token"), "{argv}");
assert!(argv.contains(RESPONSE_ENV), "the plugin is told which variable to read: {argv}");
assert!(seen.contains("leak=\n"), "{seen}");
drop(running); // kill_on_drop
}
#[tokio::test]
async fn a_plugin_that_exits_early_says_why() {
let dir = tempfile::tempdir().unwrap();
let p = script(
dir.path(),
"plugin",
"echo 'An error occurred (TargetNotConnected) when calling the StartSession operation' >&2; exit 255",
);
let err = spawn_plugin(launch(p, "x")).await.err().expect("must fail");
assert!(err.contains("TargetNotConnected"), "{err}");
}
#[tokio::test]
async fn a_plugin_that_never_gets_ready_is_killed_and_reported() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", "echo 'Starting session'; exec sleep 30");
let err = spawn_plugin(launch(p, "x")).await.err().expect("must time out");
assert!(err.contains("did not come up"), "{err}");
assert!(err.contains("Starting session"), "{err}");
}
/// Opens sessions by running a fake plugin; records what it was asked.
struct FakeOpener {
plugin: PathBuf,
ports: Arc<Mutex<Vec<u16>>>,
terminated: Arc<Mutex<Vec<String>>>,
opened: Arc<AtomicU32>,
/// Fail opens whose 1-based number is in here, with this message.
fail: Vec<(u32, &'static str)>,
/// Connections made to the local port (the fake plugin listens on
/// nothing, so the opener stands in for its listener).
connections: Arc<AtomicU32>,
}
impl FakeOpener {
fn new(plugin: PathBuf) -> Self {
Self {
plugin,
ports: Default::default(),
terminated: Default::default(),
opened: Default::default(),
fail: vec![],
connections: Default::default(),
}
}
}
impl SessionOpener for FakeOpener {
async fn open(&self, local_port: u16) -> Result<Running, String> {
let n = self.opened.fetch_add(1, Ordering::SeqCst) + 1;
self.ports.lock().unwrap().push(local_port);
if let Some((_, msg)) = self.fail.iter().find(|(i, _)| *i == n) {
return Err(msg.to_string());
}
if let Ok(listener) = tokio::net::TcpListener::bind(("127.0.0.1", local_port)).await {
let count = self.connections.clone();
tokio::spawn(async move {
while let Ok((sock, _)) = listener.accept().await {
count.fetch_add(1, Ordering::SeqCst);
drop(sock);
}
});
}
let mut l = launch(self.plugin.clone(), "x");
l.session_id = format!("s-{n}");
spawn_plugin(l).await
}
async fn terminate(&self, session_id: String) {
self.terminated.lock().unwrap().push(session_id);
}
}
async fn wait_for(state: &mut watch::Receiver<TunnelState>, want: impl Fn(&TunnelState) -> bool) -> TunnelState {
let s = tokio::time::timeout(Duration::from_secs(10), state.wait_for(|s| want(s)))
.await
.expect("state did not arrive")
.expect("supervisor gone")
.clone();
s
}
fn alive(pid: u32) -> bool {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn kill(pid: u32) {
std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().unwrap();
}
#[tokio::test]
async fn a_dropped_session_is_reopened_on_the_same_local_port() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let opener = FakeOpener::new(p);
let (ports, terminated) = (opener.ports.clone(), opener.terminated.clone());
let pf = PortForward::open_with(opener).await.expect("open");
let mut state = pf.state();
assert_eq!(*state.borrow(), TunnelState::Up);
let first = pf.plugin_pid().expect("a plugin runs");
kill(first);
wait_for(&mut state, |s| matches!(s, TunnelState::Reconnecting { .. })).await;
wait_for(&mut state, |s| *s == TunnelState::Up).await;
let second = pf.plugin_pid().expect("a new plugin runs");
assert_ne!(first, second);
let ports = ports.lock().unwrap().clone();
assert_eq!(ports.len(), 2, "{ports:?}");
assert_eq!(ports[0], ports[1], "the endpoint must stay valid: same port");
assert_eq!(pf.local_port(), ports[0]);
assert_eq!(*terminated.lock().unwrap(), vec!["s-1".to_string()], "the dead session is ended");
pf.close().await;
assert!(!alive(second));
}
#[tokio::test]
async fn an_up_tunnel_keeps_itself_busy() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let opener = FakeOpener::new(p);
let connections = opener.connections.clone();
let pf = PortForward::open_with(opener).await.expect("open");
tokio::time::sleep(KEEPALIVE_INTERVAL * 5 + KEEPALIVE_INTERVAL / 2).await;
let n = connections.load(Ordering::SeqCst);
assert!((3..=6).contains(&n), "{n} keepalives in 5.5 intervals");
pf.close().await;
}
#[tokio::test]
async fn close_kills_the_plugin_and_ends_the_session() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let opener = FakeOpener::new(p);
let terminated = opener.terminated.clone();
let pf = PortForward::open_with(opener).await.expect("open");
let pid = pf.plugin_pid().unwrap();
let mut state = pf.state();
pf.close().await;
assert_eq!(*state.borrow_and_update(), TunnelState::Closed);
assert!(!alive(pid), "the plugin outlived close()");
assert_eq!(*terminated.lock().unwrap(), vec!["s-1".to_string()]);
}
#[tokio::test]
async fn dropping_the_handle_also_ends_the_session() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let opener = FakeOpener::new(p);
let terminated = opener.terminated.clone();
let pf = PortForward::open_with(opener).await.expect("open");
let pid = pf.plugin_pid().unwrap();
let mut state = pf.state();
drop(pf);
wait_for(&mut state, |s| *s == TunnelState::Closed).await;
assert!(!alive(pid));
assert_eq!(*terminated.lock().unwrap(), vec!["s-1".to_string()]);
}
#[tokio::test]
async fn the_first_open_moves_off_a_port_someone_else_took() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let mut opener = FakeOpener::new(p);
opener.fail = vec![(1, "listen tcp 127.0.0.1:1: bind: address already in use")];
let opened = opener.opened.clone();
let pf = PortForward::open_with(opener).await.expect("second port works");
assert_eq!(opened.load(Ordering::SeqCst), 2);
pf.close().await;
}
#[tokio::test]
async fn other_first_open_errors_are_returned_not_retried() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let mut opener = FakeOpener::new(p);
opener.fail = vec![(1, "AccessDeniedException: not authorized to perform ssm:StartSession")];
let opened = opener.opened.clone();
let err = PortForward::open_with(opener).await.err().expect("must fail");
assert!(err.contains("AccessDenied"), "{err}");
assert_eq!(opened.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn reconnecting_gives_up_after_the_limit_and_says_why() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let mut opener = FakeOpener::new(p);
// Open 1 works; every reconnect after it fails.
opener.fail = (2..=MAX_RECONNECT_ATTEMPTS + 1)
.map(|i| (i, "TargetNotConnected: the task stopped"))
.collect();
let pf = PortForward::open_with(opener).await.expect("open");
let mut state = pf.state();
kill(pf.plugin_pid().unwrap());
let s = tokio::time::timeout(
Duration::from_secs(30),
state.wait_for(|s| matches!(s, TunnelState::Failed(_))),
)
.await
.expect("gave up")
.unwrap()
.clone();
assert_eq!(s, TunnelState::Failed("TargetNotConnected: the task stopped".into()));
assert_eq!(pf.plugin_pid(), None);
pf.close().await;
assert_eq!(*state.borrow_and_update(), TunnelState::Closed);
}
}
/// A cancelled open (its future dropped after `StartSession` succeeded) must
/// still end the session: the guard's drop sends `TerminateSession`. Checked
/// against a fake SSM endpoint that records what it is asked.
#[tokio::test]
async fn a_dropped_open_still_ends_its_session() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
tokio::spawn(async move {
loop {
let (mut sock, _) = listener.accept().await.unwrap();
let tx = seen_tx.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 64 * 1024];
let mut req = Vec::new();
// Headers and the small JSON body arrive together here.
while let Ok(n) = sock.read(&mut buf).await {
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&req);
if text.contains("SessionId") {
break;
}
}
let _ = tx.send(String::from_utf8_lossy(&req).into_owned());
let body = r#"{"SessionId":"s-dropped"}"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/x-amz-json-1.1\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
});
}
});
let conf = aws_sdk_ssm::Config::builder()
.behavior_version(aws_sdk_ssm::config::BehaviorVersion::latest())
.region(aws_sdk_ssm::config::Region::new("us-west-2"))
.credentials_provider(aws_sdk_ssm::config::Credentials::new("AKIDTEST", "secret", None, None, "test"))
.endpoint_url(format!("http://127.0.0.1:{port}"))
.build();
let guard = SessionGuard {
ssm: aws_sdk_ssm::Client::from_conf(conf),
session_id: Some("s-dropped".into()),
};
drop(guard);
let req = tokio::time::timeout(Duration::from_secs(10), seen_rx.recv())
.await
.expect("no TerminateSession arrived")
.unwrap();
assert!(req.contains("AmazonSSM.TerminateSession"), "{req}");
assert!(req.contains("s-dropped"), "{req}");
}
/// The keepalive is the unauthenticated health probe: some traffic, no token.
#[tokio::test]
async fn the_keepalive_asks_for_health_without_a_token() {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let n = sock.read(&mut buf).await.unwrap();
sock.write_all(b"HTTP/1.1 401 Unauthorized\r\ncontent-length: 0\r\n\r\n").await.unwrap();
String::from_utf8_lossy(&buf[..n]).into_owned()
});
keepalive_probe(port).await;
let req = server.await.unwrap();
assert!(req.starts_with("GET /v1/health HTTP/1.1\r\n"), "{req}");
assert!(!req.to_ascii_lowercase().contains("authorization"), "{req}");
}
+29
View File
@@ -1,4 +1,5 @@
mod auth_bridge;
mod aws;
mod browser_view;
#[cfg(test)]
mod command_census;
@@ -210,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<i32> {
aws::tofu::runner_main(std::env::args_os())
}
pub fn run() {
logging::init();
@@ -593,6 +601,27 @@ pub fn run() {
commands::settings_commands::detect_aws_config,
commands::settings_commands::inspect_ca_cert_path,
commands::settings_commands::list_aws_profiles,
// AWS host connections
commands::host_commands::add_aws_profile_connection,
commands::host_commands::add_aws_keys_connection,
commands::host_commands::update_aws_connection,
commands::host_commands::remove_host_connection,
commands::host_commands::aws_connection_identity,
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::push_aws_saved_state,
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,
+6
View File
@@ -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
+5
View File
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use super::gateway_settings::GatewaySettings;
use super::host_connection::HostConnection;
use super::marketplace::{Marketplace, MarketplaceAccount, MarketplaceInstall};
use super::project::{ClaudeCodeSettings, EnvVar};
@@ -143,6 +144,9 @@ pub struct AppSettings {
/// Marketplace git repos the user added.
#[serde(default)]
pub marketplaces: Vec<Marketplace>,
/// Remote host connections (AWS). Secrets live in the OS keychain, never here.
#[serde(default)]
pub hosts: Vec<HostConnection>,
/// Items installed for every project (projects may opt out per item).
#[serde(default)]
pub global_marketplace_installs: Vec<MarketplaceInstall>,
@@ -259,6 +263,7 @@ impl Default for AppSettings {
global_claude_code_settings: None,
marketplace_accounts: Vec::new(),
marketplaces: Vec::new(),
hosts: Vec::new(),
global_marketplace_installs: Vec::new(),
terminal_gpu_rendering: None,
}
+181
View File
@@ -0,0 +1,181 @@
//! Remote host connections (currently AWS). Only non-secret data lives here;
//! access keys are held in the OS keychain and referenced by `keyring_ref`.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostConnection {
pub id: String,
pub name: String,
pub kind: HostKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HostKind {
Aws { auth: AwsAuth, default_region: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "method", rename_all = "snake_case")]
pub enum AwsAuth {
/// A named profile from the host's AWS config.
Profile { name: String },
/// Static keys stored in the OS keychain under this reference.
Keys { keyring_ref: String },
}
/// Unicode format characters (category Cf): invisible, but able to reorder or
/// hide text (bidi overrides, zero-width spaces). `char::is_control` is Cc only.
fn is_format_char(c: char) -> bool {
matches!(c as u32,
0x00AD | 0x0600..=0x0605 | 0x061C | 0x06DD | 0x070F | 0x180E
| 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x206F
| 0xFEFF | 0xFFF9..=0xFFFB | 0xE0001 | 0xE0020..=0xE007F)
}
/// Trimmed, 1..=64 characters, no control or invisible format characters.
pub fn validate_connection_name(name: &str) -> Result<String, String> {
let name = name.trim();
let len = name.chars().count();
if len == 0 || len > 64 {
return Err("Connection name must be 1 to 64 characters".into());
}
if name.chars().any(|c| c.is_control() || is_format_char(c)) {
return Err("Connection name must not contain control or invisible formatting characters".into());
}
Ok(name.to_string())
}
/// An AWS region id such as `us-west-2` or `us-gov-west-1`.
pub fn validate_region(region: &str) -> Result<String, String> {
let b = region.as_bytes();
let ok = (|| {
// ^[a-z]{2}(-[a-z]+)+-\d+$
if b.len() < 2 || !b[..2].iter().all(u8::is_ascii_lowercase) {
return false;
}
let mut parts = region[2..].split('-');
if parts.next() != Some("") {
return false;
}
let rest: Vec<&str> = parts.collect();
let Some((last, words)) = rest.split_last() else {
return false;
};
!words.is_empty()
&& words
.iter()
.all(|w| !w.is_empty() && w.bytes().all(|c| c.is_ascii_lowercase()))
&& !last.is_empty()
&& last.bytes().all(|c| c.is_ascii_digit())
})();
if ok {
Ok(region.to_string())
} else {
Err(format!("\"{}\" is not a valid AWS region", region.escape_debug()))
}
}
/// A region Triple-C's policies and checks can work with. Every policy and check
/// template hard-codes the `aws` partition (`arn:aws:`), so China, GovCloud and
/// the isolated partitions would always check as Insufficient. `validate_region`
/// stays general (it only checks the shape); connection add/update/import use this.
pub fn validate_supported_region(region: &str) -> Result<String, String> {
let region = validate_region(region)?;
if ["cn-", "us-gov-", "us-iso-", "us-isob-", "eu-isoe-", "us-isof-"]
.iter()
.any(|p| region.starts_with(p))
{
return Err("China and GovCloud regions aren't supported yet.".into());
}
Ok(region)
}
/// Non-empty, at most 128 characters of `[A-Za-z0-9_.@+=,-]`.
pub fn validate_profile_name(name: &str) -> Result<String, String> {
if name.is_empty() || name.len() > 128 {
return Err("Profile name must be 1 to 128 characters".into());
}
if !name
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"_.@+=,-".contains(&c))
{
return Err("Profile name contains characters AWS profiles do not allow".into());
}
Ok(name.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::AppSettings;
#[test]
fn settings_without_hosts_deserialize_to_empty() {
let mut json = serde_json::to_value(AppSettings::default()).unwrap();
json.as_object_mut().unwrap().remove("hosts");
let s: AppSettings = serde_json::from_value(json).unwrap();
assert_eq!(s.hosts, vec![]);
}
#[test]
fn a_profile_connection_round_trips() {
let c = HostConnection {
id: "h1".into(),
name: "Prod".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile { name: "default".into() },
default_region: "us-west-2".into(),
},
};
let s = serde_json::to_string(&c).unwrap();
assert!(s.contains("\"type\":\"aws\""), "{s}");
assert!(s.contains("\"method\":\"profile\""), "{s}");
assert_eq!(serde_json::from_str::<HostConnection>(&s).unwrap(), c);
}
#[test]
fn regions_are_validated() {
for ok in ["us-west-2", "eu-central-1", "cn-north-1", "us-gov-west-1"] {
assert_eq!(validate_region(ok).unwrap(), ok);
}
for ok in ["us-west-2", "eu-central-1", "ap-southeast-4"] {
assert_eq!(validate_supported_region(ok).unwrap(), ok);
}
for other in ["cn-north-1", "cn-northwest-1", "us-gov-west-1", "us-gov-east-1"] {
assert_eq!(
validate_supported_region(other).unwrap_err(),
"China and GovCloud regions aren't supported yet."
);
}
assert!(validate_supported_region("nope").unwrap_err().contains("not a valid"));
for bad in ["", "US-WEST-2", "us-west", "us-west-2; rm"] {
assert!(validate_region(bad).is_err(), "{bad:?}");
}
}
#[test]
fn profile_names_are_validated() {
for ok in ["default", "340315884407_StaffAdministratorAccess"] {
assert_eq!(validate_profile_name(ok).unwrap(), ok);
}
for bad in ["", "a b", "x\n"] {
assert!(validate_profile_name(bad).is_err(), "{bad:?}");
}
assert!(validate_profile_name(&"a".repeat(129)).is_err());
assert!(validate_profile_name(&"a".repeat(128)).is_ok());
}
#[test]
fn connection_names_are_validated() {
assert_eq!(validate_connection_name(" Prod ").unwrap(), "Prod");
assert!(validate_connection_name(" ").is_err());
assert!(validate_connection_name("a\tb").is_err());
for c in ['\u{202E}', '\u{200B}', '\u{2066}', '\u{2069}', '\u{FEFF}'] {
assert!(validate_connection_name(&format!("ab{c}cd")).is_err(), "{c:?}");
}
assert!(validate_connection_name("Prod é 日本").is_ok());
assert!(validate_connection_name(&"a".repeat(65)).is_err());
}
}
+2
View File
@@ -1,6 +1,7 @@
pub mod app_settings;
pub mod container_config;
pub mod gateway_settings;
pub mod host_connection;
pub mod marketplace;
pub mod migration;
pub mod note;
@@ -11,6 +12,7 @@ pub mod update_info;
pub use app_settings::*;
pub use container_config::*;
pub use gateway_settings::*;
pub use host_connection::*;
pub use migration::*;
pub use note::*;
pub use project::*;
+219
View File
@@ -64,6 +64,11 @@ pub struct ExportedSecrets {
/// here or an imported account could never fetch.
#[serde(default)]
pub marketplace_account_tokens: BTreeMap<String, String>,
/// Access keys of `AwsAuth::Keys` host connections, keyed by connection
/// id; each value is the `AwsKeys` JSON. Like the tokens above they live
/// in the keychain, not in `AppSettings::hosts`.
#[serde(default)]
pub aws_connection_keys: BTreeMap<String, String>,
}
impl ExportedSecrets {
@@ -74,6 +79,7 @@ impl ExportedSecrets {
&& blank(&self.gateway_master_key)
&& blank(&self.web_terminal_access_token)
&& self.marketplace_account_tokens.values().all(|v| v.trim().is_empty())
&& self.aws_connection_keys.values().all(|v| v.trim().is_empty())
}
}
@@ -171,6 +177,69 @@ pub struct SettingsImportPreview {
/// Non-blank marketplace account tokens the import restores.
#[serde(default)]
pub marketplace_account_token_count: usize,
/// The AWS connections the import brings. Applied wholesale, so a crafted
/// export could otherwise add or replace one unannounced. Names and
/// profile names are cleaned; no key values, only the count below.
#[serde(default)]
pub aws_connections: Vec<AwsConnectionPreview>,
/// Non-blank AWS access-key entries the import restores to the keychain.
#[serde(default)]
pub aws_connection_key_count: usize,
/// Local connections the import drops, because it replaces the whole list.
/// Their keychain entries, if any, are left behind.
#[serde(default)]
pub removed_aws_connections: Vec<AwsConnectionSummary>,
}
/// Cleaned, non-secret description of one connection.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AwsConnectionSummary {
pub name: String,
/// `"profile"` or `"keys"`.
pub auth: String,
pub profile_name: Option<String>,
pub default_region: String,
}
impl AwsConnectionSummary {
fn of(h: &crate::models::host_connection::HostConnection) -> Self {
use crate::models::host_connection::{AwsAuth, HostKind};
let HostKind::Aws { auth, default_region } = &h.kind;
let (auth_kind, profile_name) = match auth {
AwsAuth::Profile { name } => ("profile", Some(sanitize_for_preview(name))),
AwsAuth::Keys { .. } => ("keys", None),
};
Self {
name: sanitize_for_preview(&h.name),
auth: auth_kind.to_string(),
profile_name,
default_region: sanitize_for_preview(default_region),
}
}
}
/// One imported AWS connection as the confirmation dialog shows it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AwsConnectionPreview {
pub name: String,
/// `"profile"` or `"keys"`.
pub auth: String,
/// The profile name, for a `profile` connection.
pub profile_name: Option<String>,
pub default_region: String,
/// A connection with this id exists here with different content.
pub overwrites_existing: bool,
/// The import carries keys for this id and a keychain entry for it
/// already exists here (its value is never read into the preview).
pub overwrites_keys: bool,
/// The import carries keys for this id and the keychain could not be
/// asked whether some are stored already.
#[serde(default)]
pub keys_check_failed: bool,
/// The local connection with this id, when the import replaces it, so an
/// imported name cannot hide which connection is being replaced.
#[serde(default)]
pub local: Option<AwsConnectionSummary>,
}
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
@@ -195,7 +264,55 @@ fn sanitize_for_preview(value: &str) -> String {
}
impl SettingsImportPreview {
#[cfg(test)]
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
Self::from_payload_against(payload, &[], |_| Some(false))
}
/// Builds the preview, also flagging what the import would overwrite or
/// drop: `local_hosts` are the connections held now and `local_has_keys`
/// says whether a keychain entry exists for an id (`None`: could not be
/// determined).
pub fn from_payload_against(
payload: &SettingsExportPayload,
local_hosts: &[crate::models::host_connection::HostConnection],
local_has_keys: impl Fn(&str) -> Option<bool>,
) -> Self {
let carried_keys = |id: &str| {
payload
.secrets
.aws_connection_keys
.get(id)
.is_some_and(|v| !v.trim().is_empty())
};
let aws_connections = payload
.settings
.hosts
.iter()
.map(|h| {
let summary = AwsConnectionSummary::of(h);
let local = local_hosts.iter().find(|l| l.id == h.id);
let overwrites_existing = local.is_some_and(|l| l != h);
let key_state = if carried_keys(&h.id) { local_has_keys(&h.id) } else { Some(false) };
AwsConnectionPreview {
name: summary.name,
auth: summary.auth,
profile_name: summary.profile_name,
default_region: summary.default_region,
overwrites_existing,
overwrites_keys: key_state == Some(true),
keys_check_failed: key_state.is_none(),
local: local
.filter(|_| overwrites_existing || key_state != Some(false))
.map(AwsConnectionSummary::of),
}
})
.collect();
let removed_aws_connections = local_hosts
.iter()
.filter(|l| !payload.settings.hosts.iter().any(|h| h.id == l.id))
.map(AwsConnectionSummary::of)
.collect();
let non_blank = |s: &Option<String>| s.as_deref().is_some_and(|v| !v.trim().is_empty());
let sanitized_non_blank = |s: &Option<String>| {
s.as_deref()
@@ -240,6 +357,14 @@ impl SettingsImportPreview {
.values()
.filter(|v| !v.trim().is_empty())
.count(),
aws_connections,
aws_connection_key_count: payload
.secrets
.aws_connection_keys
.values()
.filter(|v| !v.trim().is_empty())
.count(),
removed_aws_connections,
}
}
}
@@ -477,4 +602,98 @@ mod tests {
assert_eq!(preview.global_plugin_install_count, 2);
assert_eq!(preview.global_hook_install_count, 1);
}
fn aws_host(id: &str, name: &str, keys: bool) -> crate::models::host_connection::HostConnection {
use crate::models::host_connection::*;
HostConnection {
id: id.into(),
name: name.into(),
kind: HostKind::Aws {
auth: if keys {
AwsAuth::Keys { keyring_ref: id.into() }
} else {
AwsAuth::Profile { name: "prod-profile".into() }
},
default_region: "us-west-2".into(),
},
}
}
#[test]
fn import_preview_discloses_aws_connections_and_key_count() {
let mut payload = payload_with(ExportedSecrets {
aws_connection_keys: BTreeMap::from([
("k1".to_string(), "{\"secret_access_key\":\"test-secret-not-real\"}".to_string()),
("k2".to_string(), " ".to_string()),
]),
..Default::default()
});
payload.settings.hosts = vec![aws_host("k1", "Ke\nys", true), aws_host("p1", "Prof", false)];
let preview = SettingsImportPreview::from_payload(&payload);
assert_eq!(preview.aws_connection_key_count, 1);
assert_eq!(preview.aws_connections.len(), 2);
assert_eq!(preview.aws_connections[0].name, "Keys");
assert_eq!(preview.aws_connections[0].auth, "keys");
assert_eq!(preview.aws_connections[1].auth, "profile");
assert_eq!(preview.aws_connections[1].profile_name.as_deref(), Some("prod-profile"));
assert_eq!(preview.aws_connections[1].default_region, "us-west-2");
assert!(!serde_json::to_string(&preview).unwrap().contains("test-secret-not-real"));
}
#[test]
fn import_preview_flags_aws_overwrites() {
let mut payload = payload_with(ExportedSecrets {
aws_connection_keys: BTreeMap::from([("k1".to_string(), "{}".to_string())]),
..Default::default()
});
payload.settings.hosts = vec![
aws_host("k1", "Keys", true),
aws_host("same", "Same", false),
aws_host("changed", "New name", false),
aws_host("fresh", "Fresh", false),
];
let local = vec![aws_host("same", "Same", false), aws_host("changed", "Old name", false)];
let preview =
SettingsImportPreview::from_payload_against(&payload, &local, |id| Some(id == "k1"));
let flags: Vec<_> = preview
.aws_connections
.iter()
.map(|c| (c.overwrites_existing, c.overwrites_keys))
.collect();
assert_eq!(flags, vec![(false, true), (false, false), (true, false), (false, false)]);
}
#[test]
fn aws_preview_names_the_local_connection_and_discloses_removals_and_failed_checks() {
let mut payload = payload_with(ExportedSecrets {
aws_connection_keys: BTreeMap::from([
("k1".to_string(), "{}".to_string()),
("k2".to_string(), "{}".to_string()),
]),
..Default::default()
});
payload.settings.hosts = vec![
aws_host("k1", "Innocent", true),
aws_host("k2", "Other", true),
aws_host("p1", "Fresh Name", false),
];
let local = vec![
aws_host("k1", "Real Prod", true),
aws_host("p1", "Real Dev", false),
aws_host("gone", "Dropped\n", false),
];
let preview = SettingsImportPreview::from_payload_against(&payload, &local, |id| match id {
"k1" => Some(true),
_ => None,
});
let c = &preview.aws_connections;
assert!(c[0].overwrites_keys && !c[0].keys_check_failed);
assert_eq!(c[0].local.as_ref().unwrap().name, "Real Prod");
assert!(c[1].keys_check_failed && !c[1].overwrites_keys && c[1].local.is_none());
assert!(c[2].overwrites_existing);
assert_eq!(c[2].local.as_ref().unwrap().name, "Real Dev");
assert_eq!(c[2].local.as_ref().unwrap().profile_name.as_deref(), Some("prod-profile"));
assert_eq!(preview.removed_aws_connections.len(), 1);
assert_eq!(preview.removed_aws_connections[0].name, "Dropped");
}
}
+126 -5
View File
@@ -167,7 +167,12 @@ fn read_entry(service: &str, label: &str) -> Result<Option<String>, String> {
fn delete_entry(service: &str, label: &str) -> Result<(), String> {
let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
match entry.delete_credential() {
delete_outcome(entry.delete_credential(), label)
}
/// "Wasn't there" is success; any other failure names the entry, not its value.
fn delete_outcome(result: Result<(), keyring::Error>, label: &str) -> Result<(), String> {
match result {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("Failed to delete {}: {}", label, e)),
}
@@ -379,10 +384,7 @@ const MARKETPLACE_TOKEN_SERVICE_PREFIX: &str = "triple-c-marketplace-account-";
/// The service name for one account. Ids are uuids; anything else is refused
/// before a keychain entry is constructed.
fn marketplace_token_service(account_id: &str) -> Result<String, String> {
let ok = !account_id.is_empty()
&& account_id.len() <= 64
&& account_id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
if !ok {
if !is_valid_connection_id(account_id) {
return Err(format!("Invalid marketplace account id {:?}", account_id));
}
Ok(format!("{}{}", MARKETPLACE_TOKEN_SERVICE_PREFIX, account_id))
@@ -408,6 +410,90 @@ pub fn delete_marketplace_token(account_id: &str) -> Result<(), String> {
delete_entry(&marketplace_token_service(account_id)?, "the marketplace account token")
}
// ─────────────────────────────────────────────────────────────────────────────
// AWS connection access keys (global, one entry per connection)
// ─────────────────────────────────────────────────────────────────────────────
/// Keychain service prefix; the connection id completes it.
const AWS_CONNECTION_SERVICE_PREFIX: &str = "triple-c-aws-connection-";
/// Static AWS credentials for a `HostConnection` using `AwsAuth::Keys`. Held in
/// the keychain as one JSON value. `Debug` redacts the secret and the token.
#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct AwsKeys {
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: Option<String>,
}
impl std::fmt::Debug for AwsKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AwsKeys")
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &"<redacted>")
.field("session_token", &self.session_token.as_ref().map(|_| "<redacted>"))
.finish()
}
}
/// Whether `id` may be used as a connection id / keyring reference: 1..=64 of
/// `[A-Za-z0-9-]`. The single definition of the keychain-id rule: marketplace
/// account ids, AWS connection ids and `commands::marketplace_commands`'s
/// `validate_id` all go through it.
pub fn is_valid_connection_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
}
fn aws_connection_service(connection_id: &str) -> Result<String, String> {
if !is_valid_connection_id(connection_id) {
return Err(format!("Invalid AWS connection id {:?}", connection_id));
}
Ok(format!("{}{}", AWS_CONNECTION_SERVICE_PREFIX, connection_id))
}
pub fn store_aws_keys(connection_id: &str, keys: &AwsKeys) -> Result<(), String> {
let service = aws_connection_service(connection_id)?;
let json = serde_json::to_string(keys)
.map_err(|_| "Failed to encode the AWS access keys".to_string())?;
let entry = keyring::Entry::new(&service, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
entry
.set_password(&json)
.map_err(|e| format!("Failed to store the AWS access keys: {}", e))
}
pub fn get_aws_keys(connection_id: &str) -> Result<Option<AwsKeys>, String> {
let Some(json) = read_entry(&aws_connection_service(connection_id)?, "the AWS access keys")?
else {
return Ok(None);
};
serde_json::from_str(&json)
.map(Some)
.map_err(|_| "The stored AWS access keys are unreadable".to_string())
}
/// Whether an entry exists for the connection, readable or not. `None` means
/// the keychain could not be asked, so callers must not read it as "absent".
/// Never returns or logs a value.
pub fn aws_keys_entry_state(connection_id: &str) -> Option<bool> {
let svc = aws_connection_service(connection_id).ok()?;
let entry = keyring::Entry::new(&svc, KEYCHAIN_ACCOUNT).ok()?;
match entry.get_password() {
Ok(_) => Some(true),
Err(keyring::Error::NoEntry) => Some(false),
// Present but not a plain string, etc.: something is there.
Err(keyring::Error::BadEncoding(_)) => Some(true),
Err(_) => None,
}
}
/// Missing entry is success.
pub fn delete_aws_keys(connection_id: &str) -> Result<(), String> {
delete_entry(&aws_connection_service(connection_id)?, "the AWS access keys")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -496,4 +582,39 @@ mod tests {
// Pasted credentials routinely carry a trailing newline.
assert_eq!(secret_to_store(Some(" ghp_abc123\n")), Some("ghp_abc123"));
}
#[test]
fn aws_keys_debug_redacts_secrets() {
let keys = AwsKeys {
access_key_id: "AKIATESTNOTREAL".into(),
secret_access_key: "test-secret-not-real".into(),
session_token: Some("test-session-token-not-real".into()),
};
let shown = format!("{:?}", keys);
assert!(!shown.contains("test-secret-not-real"), "{shown}");
assert!(!shown.contains("test-session-token-not-real"), "{shown}");
}
#[test]
fn aws_key_service_names_reject_bad_ids() {
for bad in ["../x", "", &"a".repeat(65)] {
assert!(aws_connection_service(bad).is_err(), "{bad:?}");
assert!(get_aws_keys(bad).is_err());
assert!(delete_aws_keys(bad).is_err());
}
assert_eq!(
aws_connection_service("0b9e6a2c-1111").unwrap(),
"triple-c-aws-connection-0b9e6a2c-1111"
);
}
/// There is no mock keyring here, so the "no entry is fine" rule is tested
/// as the pure mapping `delete_entry` applies.
#[test]
fn removing_a_keys_connection_with_no_keyring_entry_succeeds() {
assert_eq!(delete_outcome(Err(keyring::Error::NoEntry), "x"), Ok(()));
assert_eq!(delete_outcome(Ok(()), "x"), Ok(()));
let err = delete_outcome(Err(keyring::Error::NoStorageAccess("denied".into())), "x");
assert!(err.is_err());
}
}
+76 -1
View File
@@ -47,6 +47,12 @@ impl SettingsStore {
})
}
/// A store over an explicit file, for tests (no data-dir lookup).
#[cfg(test)]
pub(crate) fn at_path(file_path: PathBuf) -> Self {
Self { settings: Mutex::new(AppSettings::default()), file_path }
}
fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> {
self.settings.lock().unwrap_or_else(|e| e.into_inner())
}
@@ -69,8 +75,77 @@ impl SettingsStore {
pub fn update(&self, new_settings: AppSettings) -> Result<AppSettings, String> {
let mut settings = self.lock();
// Save first: a failed save must not leave the unsaved copy in memory.
self.save(&new_settings)?;
*settings = new_settings.clone();
self.save(&settings)?;
Ok(new_settings)
}
/// Read-modify-save under the store's lock, so concurrent callers cannot
/// lose each other's updates. `f` edits a copy; if it fails, or the save
/// fails, memory and disk are unchanged. Returns the saved settings and
/// whatever `f` returned.
pub fn modify<R, F>(&self, f: F) -> Result<(AppSettings, R), String>
where
F: FnOnce(&mut AppSettings) -> Result<R, String>,
{
let mut settings = self.lock();
let mut copy = settings.clone();
let out = f(&mut copy)?;
self.save(&copy)?;
*settings = copy.clone();
Ok((copy, out))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modify_saves_and_returns_the_closure_result() {
let dir = tempfile::tempdir().unwrap();
let store = SettingsStore::at_path(dir.path().join("settings.json"));
let (saved, n) = store
.modify(|s| {
s.hosts.clear();
Ok(7)
})
.unwrap();
assert_eq!(n, 7);
assert_eq!(
serde_json::to_value(&saved).unwrap(),
serde_json::to_value(store.get()).unwrap()
);
assert!(dir.path().join("settings.json").exists());
}
#[test]
fn modify_does_not_change_memory_when_save_fails() {
let dir = tempfile::tempdir().unwrap();
let store = SettingsStore::at_path(dir.path().join("missing/settings.json"));
let before = serde_json::to_value(store.get()).unwrap();
let err = store.modify(|s| {
s.hosts.push(crate::models::HostConnection {
id: "x".into(),
name: "x".into(),
kind: crate::models::HostKind::Aws {
auth: crate::models::AwsAuth::Profile { name: "p".into() },
default_region: "us-west-2".into(),
},
});
Ok(())
});
assert!(err.is_err());
assert_eq!(serde_json::to_value(store.get()).unwrap(), before);
}
#[test]
fn modify_does_not_save_when_the_closure_fails() {
let dir = tempfile::tempdir().unwrap();
let store = SettingsStore::at_path(dir.path().join("settings.json"));
let err = store.modify(|_| -> Result<(), String> { Err("no".into()) }).unwrap_err();
assert_eq!(err, "no");
assert!(!dir.path().join("settings.json").exists());
}
}
+4 -5
View File
@@ -187,11 +187,10 @@ fn strip_appdir_entries(value: &str, appdir: &str) -> Option<String> {
/// 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<String, String>,
pristine: &BTreeMap<String, String>,
appdir: Option<&str>,
@@ -0,0 +1,375 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import HostsSettings from "./HostsSettings";
import { useAppState } from "../../store/appState";
import type { AppSettings, HostConnection, PermissionReport } from "../../lib/types";
const invoke = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (cmd: string, args?: unknown) => invoke(cmd, args),
}));
const conn: HostConnection = {
id: "h1",
name: "Prod",
kind: { type: "aws", auth: { method: "profile", name: "prod-sso" }, default_region: "eu-west-1" },
};
const keysConn: HostConnection = {
id: "h2",
name: "Keys box",
kind: { type: "aws", auth: { method: "keys", keyring_ref: "r" }, default_region: "us-east-1" },
};
const identity = { account: "123456789012", arn: "arn:aws:iam::123456789012:user/me", user_id: "U" };
function report(over: Partial<PermissionReport>): PermissionReport {
return {
identity,
region: "eu-west-1",
method: "simulated",
summary: "insufficient",
tiers: [
{ tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "denied", note: null }] },
{ tier: "environment_admin", results: [{ action: "iam:CreateRole", verdict: "denied", note: null }] },
],
...over,
};
}
function setHosts(hosts: HostConnection[]) {
useAppState.setState({ appSettings: { hosts } as unknown as AppSettings });
}
describe("HostsSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
setHosts([conn, keysConn]);
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return ["dev", "prod-sso"];
if (cmd === "get_settings") return { hosts: [conn, keysConn] };
throw new Error(`unexpected ${cmd}`);
});
});
it("shows Local Docker and existing AWS connections", async () => {
render(<HostsSettings />);
await screen.findByRole("option", { name: "dev" });
expect(screen.getByText("Local Docker")).toBeInTheDocument();
expect(screen.getByText("Prod")).toBeInTheDocument();
expect(screen.getByText("Profile: prod-sso")).toBeInTheDocument();
expect(within(screen.getByTestId("host-h2")).getByText("Access keys")).toBeInTheDocument();
expect(within(screen.getByTestId("host-h1")).getByText("eu-west-1")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^Check/ })).toHaveLength(2);
expect(screen.queryByRole("button", { name: "Remove Local Docker" })).toBeNull();
});
it("adds a profile connection and shows the signed-in identity", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return ["dev", "prod-sso"];
if (cmd === "add_aws_profile_connection") return conn;
if (cmd === "aws_connection_identity") return identity;
if (cmd === "get_settings") return { hosts: [conn] };
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
await screen.findByRole("option", { name: "dev" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
fireEvent.change(screen.getByLabelText("Default region"), { target: { value: "eu-west-1" } });
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
expect(await screen.findByText(`Signed in as ${identity.arn}`)).toBeInTheDocument();
expect(invoke).toHaveBeenCalledWith("add_aws_profile_connection", {
name: "Dev",
profile: "dev",
defaultRegion: "eu-west-1",
});
});
it("offers Sign in when the profile's SSO session expired", async () => {
let identityCalls = 0;
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return ["dev"];
if (cmd === "add_aws_profile_connection") return conn;
if (cmd === "aws_connection_identity") {
if (identityCalls++ === 0) throw { kind: "sso_expired", profile: "dev" };
return identity;
}
if (cmd === "aws_connection_sso_login") return undefined;
if (cmd === "get_settings") return { hosts: [conn] };
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
await screen.findByRole("option", { name: "dev" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
expect(await screen.findByText("Your SSO session for profile dev has expired.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByText(`Signed in as ${identity.arn}`)).toBeInTheDocument();
expect(invoke).toHaveBeenCalledWith("aws_connection_sso_login", { id: "h1" });
// The connection was added once; only the failed step was retried.
expect(invoke.mock.calls.filter((c) => c[0] === "add_aws_profile_connection")).toHaveLength(1);
});
it("access key inputs are password fields and the session token is optional", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "add_aws_keys_connection") return keysConn;
if (cmd === "aws_connection_identity") return identity;
if (cmd === "get_settings") return { hosts: [keysConn] };
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("radio", { name: "Access keys" }));
const id = screen.getByLabelText("Access key ID");
const secret = screen.getByLabelText("Secret access key");
const token = screen.getByLabelText(/Session token/);
for (const el of [id, secret, token]) {
expect(el).toHaveAttribute("type", "password");
expect(el).toHaveAttribute("autocomplete", "off");
}
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "K" } });
fireEvent.change(id, { target: { value: "AKIA" } });
fireEvent.change(secret, { target: { value: "shh" } });
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
await screen.findByText(`Signed in as ${identity.arn}`);
expect(invoke).toHaveBeenCalledWith(
"add_aws_keys_connection",
expect.objectContaining({ accessKeyId: "AKIA", secretAccessKey: "shh", sessionToken: null }),
);
expect(screen.getByLabelText("Access key ID")).toHaveValue("");
expect(screen.getByLabelText("Secret access key")).toHaveValue("");
});
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 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);
Object.assign(navigator, { clipboard: { writeText } });
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
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: "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.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 () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions")
return report({
tiers: [
{ tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "unverified", note: "throttled" }] },
{ tier: "environment_admin", results: [] },
],
});
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
const badge = await screen.findByText("Incomplete — check again");
expect(badge.className).not.toContain("--error");
expect(screen.queryByText("Insufficient")).not.toBeInTheDocument();
expect(screen.getByText(/Some checks couldn't complete/)).toBeInTheDocument();
});
it("explains denied own-principal checks as the first thing to ask an admin for", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions")
return report({
tiers: [
{ tier: "workspace_user", results: [{ action: "iam:SimulatePrincipalPolicy", verdict: "denied", note: null }] },
{ tier: "environment_admin", results: [] },
],
});
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
expect(await screen.findByText(/couldn't inspect the caller's own permissions/)).toBeInTheDocument();
});
it("shows throttling notes with a check-again hint", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions")
return report({
tiers: [
{ tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "unverified", note: "Throttling" }] },
{ tier: "environment_admin", results: [] },
],
});
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
expect(await screen.findByText(/Throttling/)).toBeInTheDocument();
expect(screen.getAllByText(/Check again/).length).toBeGreaterThan(0);
});
it("a probed check says writes are unverified", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions")
return report({
method: { probed: { reason: "iam:SimulatePrincipalPolicy denied" } },
summary: "workspace_user",
});
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
expect(await screen.findByText("Workspace user (unverified)")).toBeInTheDocument();
expect(
screen.getByText(
"Simulation not permitted (iam:SimulatePrincipalPolicy denied). Read access was probed; write actions are unverified and will be confirmed on first use.",
),
).toBeInTheDocument();
});
it("asks for confirmation before removing", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "remove_host_connection") return { hosts: [keysConn] };
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Remove Prod" }));
expect(invoke).not.toHaveBeenCalledWith("remove_host_connection", expect.anything());
fireEvent.click(screen.getByRole("button", { name: "Confirm remove Prod" }));
await waitFor(() => expect(screen.queryByText("Prod")).toBeNull());
expect(invoke).toHaveBeenCalledWith("remove_host_connection", { id: "h1" });
});
it("never renders a no-op Sign in when the add itself rejects with sso_expired", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return ["dev"];
if (cmd === "add_aws_profile_connection") throw { kind: "sso_expired", profile: "dev" };
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
await screen.findByRole("option", { name: "dev" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
expect(await screen.findByText("Your SSO session for profile dev has expired.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Sign in" })).toBeNull();
expect(screen.getByText("aws sso login --profile dev")).toBeInTheDocument();
});
it("never re-runs the add when the settings refresh fails after a successful add", async () => {
let settingsCalls = 0;
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return ["dev"];
if (cmd === "add_aws_profile_connection") return conn;
if (cmd === "get_settings") {
if (settingsCalls++ === 0) throw "boom";
return { hosts: [conn] };
}
if (cmd === "aws_connection_identity") return identity;
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
await screen.findByRole("option", { name: "dev" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
expect(await screen.findByText(/could not be refreshed/)).toBeInTheDocument();
expect(invoke.mock.calls.filter((c) => c[0] === "add_aws_profile_connection")).toHaveLength(1);
});
it("shows a message when the profile list cannot be read", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") throw "no config";
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
expect(await screen.findByText(/Couldn't read your AWS profiles: no config/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("radio", { name: "Access keys" }));
expect(screen.getByLabelText("Access key ID")).toBeInTheDocument();
});
it("shows a message when copying the policy fails", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions") return report({});
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(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
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(<HostsSettings />);
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([
[{ kind: "profile_not_found", profile: "ghost" }, "Profile ghost was not found in your AWS config, or has no credentials."],
[
{ kind: "keys_missing" },
"The access keys for this connection are missing from the keychain. Remove and re-add the connection.",
],
])("a failed Check shows its error: %j", async (err, text) => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions") throw err;
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
expect(await screen.findByText(text)).toBeInTheDocument();
expect(screen.queryByTestId("permission-report")).toBeNull();
});
it("moves focus to Confirm when removing, and back to Remove on cancel", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Remove Prod" }));
expect(screen.getByRole("button", { name: "Confirm remove Prod" })).toHaveFocus();
fireEvent.click(within(screen.getByTestId("host-h1")).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Remove Prod" })).toHaveFocus());
});
});
@@ -0,0 +1,531 @@
import { useEffect, useRef, useState } from "react";
import { useSettings } from "../../hooks/useSettings";
import {
addAwsKeysConnection,
addAwsProfileConnection,
awsConnectionIdentity,
awsConnectionSsoLogin,
checkAwsPermissions,
listAwsProfiles,
removeHostConnection,
updateAwsConnection,
} from "../../lib/tauri-commands";
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,
} from "../../lib/types";
import Button from "../ui/Button";
import SegmentedControl from "../ui/SegmentedControl";
import Field, { inputClass, selectClass } from "../ui/Field";
const OWN_PRINCIPAL_ACTIONS = ["iam:GetRole", "iam:SimulatePrincipalPolicy"];
const SUMMARY_LABEL: Record<PermissionSummary, string> = {
environment_admin: "Environment admin",
workspace_user: "Workspace user",
insufficient: "Insufficient",
};
const SUMMARY_STYLE: Record<PermissionSummary, string> = {
environment_admin: "text-[var(--success)] border-[var(--success)]/40",
workspace_user: "text-[var(--accent)] border-[var(--accent)]/40",
insufficient: "text-[var(--error)] border-[var(--error)]/40",
};
type AuthChoice = "profile" | "keys";
/** An error with the step that failed, so Sign in can retry just that step. */
interface Failure {
error: AwsError;
retry: () => Promise<void>;
}
function ErrorLine({
failure,
onSignIn,
signingIn,
}: {
failure: Failure;
/** Absent when there is no saved connection to sign in with. */
onSignIn?: () => void;
signingIn: boolean;
}) {
return (
<div role="alert" className="text-xs text-[var(--error)] leading-snug flex items-center gap-2 flex-wrap">
<span>{awsErrorMessage(failure.error)}</span>
{failure.error.kind === "sso_expired" && !onSignIn && (
<span>
Sign in with <code>aws sso login --profile {failure.error.profile}</code> and try again.
</span>
)}
{failure.error.kind === "sso_expired" && onSignIn && (
<Button size="sm" variant="secondary" onClick={onSignIn} disabled={signingIn}>
{signingIn ? "Signing in…" : "Sign in"}
</Button>
)}
</div>
);
}
function PermissionReportView({ report }: { report: PermissionReport }) {
const [copyNote, setCopyNote] = useState<string | null>(null);
const probed = typeof report.method === "object";
// Insufficient only because checks couldn't complete (no denied row in the
// workspace tier, only rows with a note): don't claim the permissions are missing.
const workspaceTier = report.tiers.find((t) => t.tier === "workspace_user");
const incomplete =
report.summary === "insufficient" &&
!!workspaceTier &&
!workspaceTier.results.some((r) => r.verdict === "denied") &&
workspaceTier.results.some((r) => r.note);
const badge = incomplete
? "Incomplete — check again"
: report.summary === "workspace_user" && probed
? "Workspace user (unverified)"
: SUMMARY_LABEL[report.summary];
const badgeStyle = incomplete
? "text-[var(--text-secondary)] border-[var(--border-color)]"
: SUMMARY_STYLE[report.summary];
const flagged = report.tiers
.map((t) => ({ ...t, results: t.results.filter((r) => r.verdict !== "allowed") }))
.filter((t) => t.results.length > 0);
const ownPrincipalDenied =
report.summary === "insufficient" &&
report.tiers.some((t) =>
t.results.some((r) => r.verdict === "denied" && OWN_PRINCIPAL_ACTIONS.includes(r.action)),
);
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))}`);
}
};
return (
<div className="space-y-2 pt-2" data-testid="permission-report">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full border ${badgeStyle}`}
>
{badge}
</span>
<span className="text-xs text-[var(--text-secondary)]">
{report.identity.arn} · {report.region}
</span>
</div>
{incomplete && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Some checks couldn't complete (for example because AWS throttled the requests), so the
permissions couldn't be fully assessed. Check again in a moment.
</p>
)}
{typeof report.method === "object" && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{`Simulation not permitted (${report.method.probed.reason}). Read access was probed; write actions are unverified and will be confirmed on first use.`}
</p>
)}
{ownPrincipalDenied && (
<p className="text-xs text-[var(--warning)] leading-snug">
Triple-C couldn't inspect the caller's own permissions. Both policies grant iam:GetRole and
iam:SimulatePrincipalPolicy on the caller's own principal, so this is the first thing to ask
an admin for.
</p>
)}
{flagged.map((t) => (
<details key={t.tier} className="text-xs">
<summary className="cursor-pointer text-[var(--text-primary)]">
{t.tier === "workspace_user" ? "Workspace user" : "Environment admin"} ({t.results.length} not
allowed)
</summary>
<ul className="mt-1 space-y-0.5 pl-4">
{t.results.map((r) => (
<li key={r.action} className="text-[var(--text-secondary)]">
<span className="font-mono">{r.action}</span> — {r.verdict}
{r.note && (
<>
{" "}
({r.note}). Check again in a moment.
</>
)}
</li>
))}
</ul>
</details>
))}
<PolicyFiles tier="workspace_user" label="Show workspace-user policy" onCopy={copy} />
<PolicyFiles tier="environment_admin" label="Show admin policies" onCopy={copy} />
{copyNote && (
<p role="status" className="text-xs text-[var(--text-secondary)]">
{copyNote}
</p>
)}
<p className="text-xs text-[var(--text-secondary)] leading-snug">
The simulation doesn't account for SCPs or session policies.
</p>
</div>
);
}
function ConnectionRow({ host }: { host: HostConnection }) {
const { loadSettings, setAppSettings } = useSettings();
const [editing, setEditing] = useState(false);
const [name, setName] = useState(host.name);
const [region, setRegion] = useState(host.kind.default_region);
const [confirmRemove, setConfirmRemove] = useState(false);
const [busy, setBusy] = useState(false);
const [signingIn, setSigningIn] = useState(false);
const [report, setReport] = useState<PermissionReport | null>(null);
const [failure, setFailure] = useState<Failure | null>(null);
const auth = host.kind.auth;
const rowRef = useRef<HTMLLIElement>(null);
const focusIn = (key: string) =>
rowRef.current?.querySelector<HTMLElement>(`[data-focus="${key}"]`)?.focus();
useEffect(() => {
if (confirmRemove) focusIn("confirm-remove");
}, [confirmRemove]);
const cancelRemove = () => {
setConfirmRemove(false);
requestAnimationFrame(() => focusIn("remove"));
};
const runCheck = async () => {
setBusy(true);
setFailure(null);
try {
setReport(await checkAwsPermissions(host.id));
} catch (e) {
setReport(null);
setFailure({ error: toAwsError(e), retry: runCheck });
} finally {
setBusy(false);
}
};
const signIn = async () => {
if (!failure) return;
const retry = failure.retry;
setSigningIn(true);
try {
await awsConnectionSsoLogin(host.id);
setFailure(null);
await retry();
} catch (e) {
setFailure({ error: toAwsError(e), retry });
} finally {
setSigningIn(false);
}
};
const save = async () => {
setBusy(true);
setFailure(null);
try {
await updateAwsConnection(host.id, name.trim(), region.trim());
await loadSettings();
setEditing(false);
} catch (e) {
setFailure({ error: toAwsError(e), retry: save });
} finally {
setBusy(false);
}
};
const remove = async () => {
setBusy(true);
try {
setAppSettings(await removeHostConnection(host.id));
} catch (e) {
setFailure({ error: toAwsError(e), retry: remove });
setConfirmRemove(false);
setBusy(false);
}
};
return (
<li
ref={rowRef}
data-testid={`host-${host.id}`}
className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-2.5 space-y-1.5"
>
{editing ? (
<div className="space-y-2">
<Field label="Name">{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
<Field label="Default region">
{(id) => (
<input id={id} className={inputClass} list="aws-regions" value={region} onChange={(e) => setRegion(e.target.value)} />
)}
</Field>
<div className="flex gap-2">
<Button size="sm" variant="primary" onClick={save} disabled={busy || !name.trim() || !region.trim()}>
Save changes
</Button>
<Button size="sm" onClick={() => { setEditing(false); setName(host.name); setRegion(host.kind.default_region); }}>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex items-start justify-between gap-2 flex-wrap">
<div className="min-w-0">
<div className="text-[13px] font-medium text-[var(--text-primary)]">{host.name}</div>
<div className="text-xs text-[var(--text-secondary)]">
<span>{auth.method === "profile" ? `Profile: ${auth.name}` : "Access keys"}</span>
{" · "}
<span>{host.kind.default_region}</span>
</div>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={runCheck} disabled={busy} aria-label={`Check ${host.name}`}>
Check
</Button>
<Button size="sm" onClick={() => setEditing(true)} aria-label={`Edit ${host.name}`}>
Edit
</Button>
{!confirmRemove && (
<Button data-focus="remove" size="sm" variant="danger" onClick={() => setConfirmRemove(true)} aria-label={`Remove ${host.name}`}>
Remove
</Button>
)}
</div>
</div>
)}
{confirmRemove && (
<div role="alertdialog" aria-label={`Remove ${host.name}?`} className="flex items-center gap-2 flex-wrap text-xs">
<span>Remove {host.name}? Its stored credentials are deleted.</span>
<Button data-focus="confirm-remove" size="sm" variant="danger" onClick={remove} disabled={busy} aria-label={`Confirm remove ${host.name}`}>
Confirm remove
</Button>
<Button size="sm" onClick={cancelRemove}>
Cancel
</Button>
</div>
)}
{busy && !editing && !confirmRemove && <p role="status" className="text-xs text-[var(--text-secondary)]">Working…</p>}
{failure && <ErrorLine failure={failure} onSignIn={signIn} signingIn={signingIn} />}
{report && <PermissionReportView report={report} />}
<EnvironmentsPanel connection={host} tier={report?.summary} />
</li>
);
}
function AddConnectionForm() {
const { loadSettings } = useSettings();
const [profiles, setProfiles] = useState<string[]>([]);
const [auth, setAuth] = useState<AuthChoice>("profile");
const [name, setName] = useState("");
const [profile, setProfile] = useState("");
const [accessKeyId, setAccessKeyId] = useState("");
const [secret, setSecret] = useState("");
const [token, setToken] = useState("");
const [region, setRegion] = useState("us-east-1");
const [busy, setBusy] = useState(false);
const [signingIn, setSigningIn] = useState(false);
const [signedInAs, setSignedInAs] = useState<string | null>(null);
const [failure, setFailure] = useState<Failure | null>(null);
const [profilesError, setProfilesError] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
listAwsProfiles()
.then((p) => {
if (!cancelled) setProfiles(p);
})
.catch((e) => {
if (!cancelled) {
setProfiles([]);
setProfilesError(awsErrorMessage(toAwsError(e)));
}
});
return () => {
cancelled = true;
};
}, []);
const ready =
name.trim() !== "" &&
region.trim() !== "" &&
(auth === "profile" ? profile !== "" : accessKeyId.trim() !== "" && secret !== "");
/** Second step: confirm the new connection can sign in. Retried alone after an SSO login. */
const verify = async (id: string) => {
try {
const who = await awsConnectionIdentity(id);
setSignedInAs(who.arn);
setFailure(null);
} catch (e) {
setFailure({ error: toAwsError(e), retry: () => verify(id) });
}
};
/** Everything after a successful add. Retrying never re-runs the add. */
const afterAdd = async (id: string) => {
const refreshed = await loadSettings();
if (!refreshed) {
setFailure({
error: { kind: "other", message: "The connection was added, but the settings list could not be refreshed." },
retry: () => afterAdd(id),
});
return;
}
await verify(id);
};
const submit = async () => {
setBusy(true);
setFailure(null);
setSignedInAs(null);
try {
setPendingId(null);
const added =
auth === "profile"
? await addAwsProfileConnection(name.trim(), profile, region.trim())
: await addAwsKeysConnection(name.trim(), accessKeyId.trim(), secret, token.trim() || null, region.trim());
if (auth === "keys") {
setAccessKeyId("");
setSecret("");
setToken("");
}
setName("");
setPendingId(added.id);
await afterAdd(added.id);
} catch (e) {
setFailure({ error: toAwsError(e), retry: submit });
} finally {
setBusy(false);
}
};
const signIn = async () => {
if (!failure || !pendingId) return;
const retry = failure.retry;
setSigningIn(true);
try {
await awsConnectionSsoLogin(pendingId);
setFailure(null);
await retry();
} catch (e) {
setFailure({ error: toAwsError(e), retry });
} finally {
setSigningIn(false);
}
};
return (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
if (ready && !busy) void submit();
}}
>
<h4 className="text-[13px] font-semibold text-[var(--text-primary)]">Add AWS connection</h4>
<Field label="Name">
{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}
</Field>
<SegmentedControl<AuthChoice>
label="Authentication"
value={auth}
onChange={setAuth}
segments={[
{ value: "profile", label: "Profile" },
{ value: "keys", label: "Access keys" },
]}
/>
{auth === "profile" ? (
<Field
label="Profile"
hint={profilesError ? `Couldn't read your AWS profiles: ${profilesError}` : undefined}
>
{(id) => (
<select id={id} className={`${selectClass} w-full`} value={profile} onChange={(e) => setProfile(e.target.value)}>
<option value="">Select a profile…</option>
{profiles.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
)}
</Field>
) : (
<>
<Field label="Access key ID">
{(id) => (
<input id={id} type="password" autoComplete="off" className={inputClass} value={accessKeyId} onChange={(e) => setAccessKeyId(e.target.value)} />
)}
</Field>
<Field label="Secret access key">
{(id) => (
<input id={id} type="password" autoComplete="off" className={inputClass} value={secret} onChange={(e) => setSecret(e.target.value)} />
)}
</Field>
<Field label="Session token (optional)" hint="Only for temporary credentials.">
{(id) => (
<input id={id} type="password" autoComplete="off" className={inputClass} value={token} onChange={(e) => setToken(e.target.value)} />
)}
</Field>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Stored in the OS keychain and never shown again.
</p>
</>
)}
<Field label="Default region">
{(id) => (
<input id={id} className={inputClass} list="aws-regions" value={region} onChange={(e) => setRegion(e.target.value)} />
)}
</Field>
<Button type="submit" size="md" variant="primary" disabled={!ready || busy}>
{busy ? "Adding…" : "Add connection"}
</Button>
{signedInAs && (
<p role="status" className="text-xs text-[var(--success)]">
Signed in as {signedInAs}
</p>
)}
{failure && (
<ErrorLine failure={failure} onSignIn={pendingId ? signIn : undefined} signingIn={signingIn} />
)}
</form>
);
}
export default function HostsSettings() {
const { appSettings } = useSettings();
const hosts = appSettings?.hosts ?? [];
return (
<div className="space-y-3">
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Where project environments run. AWS connections check what your credentials can do, and find, deploy and join environments.
</p>
<ul className="space-y-2">
<li className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-2.5">
<div className="text-[13px] font-medium text-[var(--text-primary)]">Local Docker</div>
<div className="text-xs text-[var(--text-secondary)]">Always available</div>
</li>
{hosts.map((h) => (
<ConnectionRow key={h.id} host={h} />
))}
</ul>
<div className="pt-3 border-t border-[var(--border-color)]">
<AddConnectionForm />
</div>
<datalist id="aws-regions">
{COMMON_REGIONS.map((r) => (
<option key={r} value={r} />
))}
</datalist>
</div>
);
}
@@ -36,6 +36,9 @@ const samplePreview: SettingsImportPreview = {
global_hook_install_count: 0,
global_plugin_install_count: 0,
marketplace_account_token_count: 0,
aws_connections: [],
aws_connection_key_count: 0,
removed_aws_connections: [],
};
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
@@ -80,6 +83,50 @@ describe("ImportSettingsModal", () => {
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
});
it("shows the AWS connections and key count, and flags overwrites", async () => {
previewSettingsImport.mockResolvedValue({
...samplePreview,
aws_connections: [
{
name: "Prod",
auth: "keys",
profile_name: null,
default_region: "us-west-2",
overwrites_existing: true,
overwrites_keys: true,
keys_check_failed: false,
local: { name: "Real Prod", auth: "keys", profile_name: null, default_region: "us-east-1" },
},
{
name: "Dev",
auth: "profile",
profile_name: "dev-profile",
default_region: "eu-west-1",
overwrites_existing: false,
overwrites_keys: false,
keys_check_failed: true,
local: null,
},
],
aws_connection_key_count: 1,
removed_aws_connections: [
{ name: "Old", auth: "profile", profile_name: "old-p", default_region: "ap-south-1" },
],
});
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
expect(await screen.findByText(/AWS connection "Prod" \(access keys, us-west-2\)/)).toBeInTheDocument();
expect(screen.getByText(/AWS connection "Dev" \(profile dev-profile, eu-west-1\)/)).toBeInTheDocument();
expect(screen.getByText(/1 AWS access key set$/)).toBeInTheDocument();
expect(screen.getByText(/Overwrites the AWS access keys stored for your connection "Real Prod"/)).toBeInTheDocument();
expect(screen.getByText(/Replaces your connection "Real Prod" \(access keys, us-east-1\) with "Prod"/)).toBeInTheDocument();
expect(screen.getByText(/Could not check whether keys are stored for "Dev".*may overwrite them/)).toBeInTheDocument();
expect(screen.getByText(/Removes your AWS connection "Old" \(profile old-p, ap-south-1\)/)).toBeInTheDocument();
});
it("warns about a custom Docker image every time, not just on change", async () => {
previewSettingsImport.mockResolvedValue({
...samplePreview,
@@ -21,6 +21,7 @@ import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
import MarketplaceSettings from "./MarketplaceSettings";
import HostsSettings from "./HostsSettings";
import CertificateSettings from "./CertificateSettings";
import ExportSettingsModal from "./ExportSettingsModal";
import ImportSettingsModal from "./ImportSettingsModal";
@@ -188,6 +189,10 @@ export default function SettingsPanel() {
<GatewaySettings />
</AccordionSection>
<AccordionSection id="hosts" title="Hosts" defaultOpen={false}>
<HostsSettings />
</AccordionSection>
<AccordionSection id="container" title="Container" defaultOpen={false}>
<DockerSettings />
</AccordionSection>
@@ -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(
<AdminBundleModal connectionId="h1" region="eu-west-1" kind="user_onboard" environment="dev" ownerId="AIDAEXAMPLE" onClose={vi.fn()} />,
);
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(<AdminBundleModal connectionId="h1" region="eu-west-1" kind="permissions_boundary" onClose={vi.fn()} />);
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(<AdminBundleModal connectionId="h1" region="eu-west-1" kind="environment" onClose={vi.fn()} />);
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(<AdminBundleModal connectionId="h1" region="eu-west-1" kind="permissions_boundary" onClose={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Save bundle to folder…" }));
expect(await screen.findByText("Your SSO session for profile prod-sso has expired.")).toBeInTheDocument();
});
});
@@ -0,0 +1,158 @@
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<ModuleKind, string> = {
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,
onSaved,
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;
/** Called with the folder once a bundle was actually written. */
onSaved?: (folder: string) => void;
onClose: () => void;
}) {
const askEnvironment = kind === "environment" && !environment;
const [name, setName] = useState("main");
const [draft, setDraft] = useState<NetworkDraft>(DEFAULT_NETWORK);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState<string | null>(null);
const [error, setError] = useState<AwsError | null>(null);
const [copyNote, setCopyNote] = useState<string | null>(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);
onSaved?.(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 (
<Modal
title={TITLE[kind]}
onClose={onClose}
footer={
<Button size="md" onClick={onClose}>
Close
</Button>
}
>
<div className="space-y-3 text-xs text-[var(--text-secondary)] leading-snug">
{reason && (
<p role="alert" className="text-[var(--warning)]">
Doing this from here was refused: {reason}
</p>
)}
{kind === "environment" && (
<p>
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.
</p>
)}
{kind === "user_onboard" && (
<p>
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.
</p>
)}
{kind === "permissions_boundary" && (
<p>
Every role Triple-C creates is capped by the account's permissions boundary, and creating it needs an
account administrator. Send them this folder.
</p>
)}
<p>The bundle works with OpenTofu or Terraform 1.10 and later. Its README lists every step.</p>
{askEnvironment && (
<div className="space-y-3">
<Field label="Name" hint={nameError ?? undefined}>
{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}
</Field>
<NetworkFields value={draft} onChange={setDraft} error={picked && "error" in picked ? picked.error : null} />
</div>
)}
<Button size="md" variant="primary" onClick={save} disabled={saving || !ready}>
{saving ? "Saving…" : "Save bundle to folder…"}
</Button>
{error && (
<p role="alert" className="text-[var(--error)]">
{awsErrorMessage(error)}
</p>
)}
{saved && (
<div className="space-y-1.5">
<p>
Saved to <code className="font-mono text-[var(--text-primary)] break-all">{saved}</code>
</p>
<p>From that folder, run:</p>
<pre className="font-mono text-[var(--text-primary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2 whitespace-pre-wrap">
{COMMANDS}
</pre>
<Button size="sm" onClick={copy}>
Copy commands
</Button>
{copyNote && <p role="status">{copyNote}</p>}
</div>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,183 @@
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<Handler>();
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<Parameters<typeof DeployEnvironmentModal>[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(<DeployEnvironmentModal {...props} />);
return props;
}
describe("DeployEnvironmentModal", () => {
beforeEach(() => {
vi.clearAllMocks();
handlers.clear();
});
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();
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" });
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
});
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();
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" });
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
});
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" });
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,
});
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
});
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)" }));
// 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!" } }));
expect(await screen.findByLabelText("Name")).toBeInTheDocument();
expect(props.onDetect).toHaveBeenCalled();
});
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";
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,
}),
);
expect(await within(dialog).findByText("/tmp/b")).toBeInTheDocument();
});
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();
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" });
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
});
});
@@ -0,0 +1,186 @@
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, { runKey, 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<AwsError | null>;
onDetect: () => void;
}) {
const [name, setName] = useState("main");
const [draft, setDraft] = useState<NetworkDraft>(DEFAULT_NETWORK);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<AwsError | null>(null);
const [boundaryDone, setBoundaryDone] = useState(false);
const [boundaryError, setBoundaryError] = useState<AwsError | null>(null);
const [bundle, setBundle] = useState<{ reason?: string } | null>(null);
const boundary = useDeployRuns(({ result }) => {
if (result?.ok) setBoundaryDone(true);
onDetect();
});
const boundaryRun = boundary.runs[runKey(region, BOUNDARY_STATE_KEY)];
const boundaryFailed = boundaryRun?.done && boundaryRun.result && !boundaryRun.result.ok ? boundaryRun.result : null;
if (bundle) {
return (
<AdminBundleModal
connectionId={connectionId}
region={region}
kind="permissions_boundary"
reason={bundle.reason}
onClose={() => 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 (
<Modal
title={repair ? `Repair ${repair}` : "Deploy a new environment"}
description={`In ${region}.`}
onClose={onClose}
footer={
<>
<Button size="md" onClick={onClose}>
Cancel
</Button>
{!needsBoundary && (
<Button size="md" variant="primary" onClick={submit} disabled={!ready}>
{submitting ? `${verb}ing…` : verb}
</Button>
)}
</>
}
>
{needsBoundary ? (
<div className="space-y-3 text-xs text-[var(--text-secondary)] leading-snug">
<p>
This account has no Triple-C permissions boundary yet. Every role an environment creates is capped by
it, so it comes first.
</p>
<div className="flex gap-2 flex-wrap">
<Button
size="md"
variant="primary"
onClick={createBoundary}
disabled={!!boundaryRun && !boundaryRun.done}
>
Create the permissions boundary (needs an account administrator)
</Button>
<Button size="md" onClick={() => setBundle({})}>
Save a bundle for an administrator instead
</Button>
</div>
{boundaryError && (
<p role="alert" className="text-[var(--error)]">
{awsErrorMessage(boundaryError)}
</p>
)}
{boundaryRun && <DeployProgress run={boundaryRun} connectionId={connectionId} />}
{boundaryFailed && (
<div className="space-y-1.5">
<p>
Creating the boundary failed. If it was refused, an account administrator can create it from a
bundle.
</p>
<Button
size="md"
onClick={() =>
setBundle({
reason: boundaryFailed.summary ?? `OpenTofu exited with code ${boundaryFailed.exit_code}`,
})
}
>
Get the boundary bundle for an administrator
</Button>
</div>
)}
</div>
) : (
<div className="space-y-4">
{repair ? (
<p className="text-xs text-[var(--warning)] leading-snug">
{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).
</p>
) : (
<Field label="Name" hint={nameError ?? "Lowercase letters, digits and hyphens; up to 20 characters."}>
{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}
</Field>
)}
<NetworkFields value={draft} onChange={setDraft} error={"error" in picked ? picked.error : null} />
<p className="text-xs text-[var(--text-secondary)] leading-snug">
First deploy downloads OpenTofu (~30 MB) and the AWS provider (~150 MB).
</p>
{error && (
<p role="alert" className="text-xs text-[var(--error)] leading-snug">
{awsErrorMessage(error)}
</p>
)}
</div>
)}
</Modal>
);
}
@@ -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<Handler>();
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({});
});
});
@@ -0,0 +1,288 @@
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 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 `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; region: string; result: TofuOutcome | null }) => void,
) {
const [runs, setRuns] = useState<Record<string, DeployRun>>({});
const subs = useRef(new Map<string, Subscription>());
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<string>): Promise<AwsError | null> => {
const k = runKey(region, stateKey);
subs.current.get(k)?.unlisten?.();
const sub: Subscription = { unlisten: null };
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(k) === sub) subs.current.delete(k);
};
let opId: string | null = null;
const early: ProgressPayload[] = [];
const apply = (p: ProgressPayload) => {
setRuns((prev) => {
const r = prev[k];
if (!r || r.opId !== p.op_id || r.done) return prev;
return {
...prev,
[k]: {
...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, region, result: p.result ?? null });
}
};
setRuns((prev) => ({
...prev,
[k]: { stateKey, region, label, opId: null, events: [], done: false, cancellable: false, result: null },
}));
const unlisten = await listen<ProgressPayload>(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 { [k]: _gone, ...rest } = prev;
return rest;
});
}
return toAwsError(e);
}
if (!current()) return null;
const id = opId;
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;
},
[],
);
/** `key` is a `runKey`. */
const dismiss = useCallback((key: string) => {
setRuns((prev) => {
if (!prev[key]?.done) return prev;
const { [key]: _gone, ...rest } = prev;
return rest;
});
}, []);
return { runs, start, dismiss };
}
function EventLine({ e }: { e: TofuEvent }) {
const isError = e.level === "error";
return (
<li className="text-xs leading-snug">
{(e.resource || e.action) && (
<span className="mr-1.5">
{e.resource && <span className="font-mono text-[var(--text-primary)]">{e.resource}</span>}
{e.action && <span className="ml-1 text-[var(--text-secondary)]">{e.action}</span>}
</span>
)}
{isError ? (
<>
<span className="font-semibold text-[var(--error)]">Error: </span>
<pre className="whitespace-pre-wrap break-words font-mono text-[var(--error)]">{e.message}</pre>
</>
) : (
<span className="text-[var(--text-secondary)]">{e.message}</span>
)}
</li>
);
}
/** 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<AwsError | null>(null);
const listRef = useRef<HTMLOListElement>(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 (
<section
aria-label={`${run.label} progress`}
className="space-y-1.5 border border-[var(--border-color)] rounded-[var(--radius-control)] p-2 bg-[var(--bg-primary)]"
>
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 text-xs">
<span className="font-medium text-[var(--text-primary)]">{run.label}</span>
{status && <StatusIndicator tone={status.tone} label={status.label} />}
</div>
<div className="flex gap-1.5">
{!run.done && (
<Button
size="sm"
variant="danger"
disabled={!run.cancellable}
title={run.cancellable ? undefined : "This run can't be cancelled from here"}
onClick={() => setConfirming(true)}
>
Cancel deployment
</Button>
)}
{run.done && onDismiss && (
<Button size="sm" variant="ghost" onClick={onDismiss}>
Dismiss
</Button>
)}
</div>
</div>
{run.events.length > 0 && (
<ol ref={listRef} className="max-h-64 overflow-y-auto space-y-0.5">
{run.events.map((e, i) => (
<EventLine key={i} e={e} />
))}
</ol>
)}
{run.done && run.result && (
<p role="status" className="text-xs">
{run.result.ok ? (
<StatusIndicator tone="ok" label={`Finished${run.result.summary ? `: ${run.result.summary}` : ""}`} />
) : (
<StatusIndicator
tone="error"
label={`Failed (exit ${run.result.exit_code})${run.result.summary ? `: ${run.result.summary}` : ""}`}
/>
)}
</p>
)}
{cancelError && !confirming && (
<p role="alert" className="text-xs text-[var(--error)]">
{awsErrorMessage(cancelError)}
</p>
)}
{confirming && (
<Modal
title="Cancel this deployment?"
onClose={() => setConfirming(false)}
footer={
<>
<Button size="md" onClick={() => setConfirming(false)}>
Keep running
</Button>
<Button size="md" variant="danger" onClick={cancel} disabled={cancelling}>
Cancel deployment
</Button>
</>
}
>
<div className="space-y-2 text-xs text-[var(--text-secondary)] leading-snug">
<p>
OpenTofu stops gracefully and saves its state. Anything it already created stays; Repair or Update
finishes the job later.
</p>
{cancelError && (
<p role="alert" className="text-[var(--error)]">
{awsErrorMessage(cancelError)}
</p>
)}
</div>
</Modal>
)}
</section>
);
}
@@ -0,0 +1,549 @@
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<Handler>();
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="workspace_user" />);
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(<EnvironmentsPanel connection={conn} />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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" });
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" } });
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="workspace_user" />);
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",
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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("a workspace user who closes the bundle without saving is not waiting", async () => {
backend(() => scan({ environments: [env()] }));
render(<EnvironmentsPanel connection={conn} tier="workspace_user" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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<EnvironmentScan>((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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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<EnvironmentScan>((r) => (release = () => r(found)));
});
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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" };
});
render(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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();
});
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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(<EnvironmentsPanel connection={conn} tier="environment_admin" />);
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();
});
});
@@ -0,0 +1,645 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
deployAwsEnvironment,
destroyIncompleteAwsEnvironment,
detectAwsEnvironments,
followAwsDeployment,
onboardAwsUser,
pushAwsSavedState,
repairAwsEnvironment,
unlockAwsState,
updateAwsEnvironment,
} from "../../../lib/tauri-commands";
import { awsErrorMessage, toAwsError } from "../../../lib/aws-error";
import type {
AwsError,
DetectedEnvironment,
EnvironmentScan,
HostConnection,
LockInfo,
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, { runKey, 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<AwsError | null>;
onClose: () => void;
}) {
const [typed, setTyped] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<AwsError | null>(null);
const confirm = async () => {
setBusy(true);
setError(null);
const err = await onConfirm();
setBusy(false);
if (err) setError(err);
else onClose();
};
return (
<Modal
title={title}
onClose={onClose}
footer={
<>
<Button size="md" onClick={onClose}>
Cancel
</Button>
<Button size="md" variant="danger" onClick={confirm} disabled={typed !== name || busy}>
{confirmLabel}
</Button>
</>
}
>
<div className="space-y-3 text-xs text-[var(--text-secondary)] leading-snug">
{children}
<Field label={`Type ${name} to confirm`}>
{(id) => (
<input id={id} className={inputClass} autoComplete="off" value={typed} onChange={(e) => setTyped(e.target.value)} />
)}
</Field>
{error && (
<p role="alert" className="text-[var(--error)]">
{awsErrorMessage(error)}
</p>
)}
</div>
</Modal>
);
}
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 {
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<EnvironmentScan | null>(null);
const [detecting, setDetecting] = useState(false);
const [detectError, setDetectError] = useState<AwsError | null>(null);
const [rowErrors, setRowErrors] = useState<Record<string, AwsError>>({});
const [pending, setPending] = useState<Record<string, boolean>>({});
const [waiting, setWaiting] = useState<Set<string>>(new Set());
const [deployOpen, setDeployOpen] = useState(false);
const [repairOf, setRepairOf] = useState<string | null>(null);
const [destroyOf, setDestroyOf] = useState<{ name: string; 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<Bundle | null>(null);
const [copyNote, setCopyNote] = useState<string | null>(null);
const request = useRef(0);
const followed = useRef(new Set<string>());
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]);
/** Start a run in `runRegion` (always the region of the scan the action came from). */
const launch = useCallback(
async (runRegion: string, envName: string, stateKey: string, label: string, run: () => Promise<string>) => {
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],
);
// Reattach to a deploy (or the caller's onboarding) running on this machine;
// a re-follow replays its whole log.
useEffect(() => {
if (!scan) return;
const reattach = (name: string, key: string, label: string) => {
const seen = runKey(scan.region, key);
if (!runs[seen] && !followed.current.has(seen)) {
followed.current.add(seen);
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]);
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))}`);
}
};
// 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 = (runRegion: string, name: string, network?: NetworkChoice) =>
launch(runRegion, name, environmentStateKey(name), `Repair of ${name}`, () =>
repairAwsEnvironment(connection.id, runRegion, name, network),
);
const join = (found: EnvironmentScan, e: DetectedEnvironment) => {
if (isAdmin) {
void launch(found.region, e.name, onboardingStateKey(e.name, found.owner_key), `Joining ${e.name}`, () =>
onboardAwsUser(connection.id, found.region, e.name),
);
} else {
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 === 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 !== current?.region || !shown.has(runEnvironment(r.stateKey) ?? ""),
);
const progress = (r: DeployRun) => (
<DeployProgress
key={runKey(r.region, r.stateKey)}
run={r}
connectionId={connection.id}
onDismiss={() => dismiss(runKey(r.region, r.stateKey))}
/>
);
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 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));
return (
<li
key={e.name}
data-testid={`env-${e.name}`}
className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-2 space-y-1.5"
>
<div className="flex items-start justify-between gap-2 flex-wrap">
<div className="min-w-0 space-y-0.5 text-xs">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-[var(--text-primary)]">{e.name}</span>
<StatusIndicator tone={status.tone} label={status.label} />
{onboarding === "joined" && <StatusIndicator tone="ok" label="Joined" />}
{onboarding === "in_progress" && <StatusIndicator tone="busy" label="Onboarding in progress" />}
{onboarding === "incomplete" && <StatusIndicator tone="error" label="Onboarding incomplete" />}
</div>
<div className="text-[var(--text-secondary)]">
{e.template_version !== null ? `Module version ${e.template_version}` : "Module version unknown"}
{e.info && ` · ${e.info.network_mode} network`}
</div>
{lock && (
<div className="text-[var(--text-secondary)]">
{lockLine(lock)}
{lock.local_process && " · running on this machine"}
</div>
)}
{e.state.state === "unreadable_record" && (
<div className="text-[var(--error)]">The environment's record can't be used: {e.state.reason}</div>
)}
{e.state.state === "externally_managed" && (
<div className="text-[var(--text-secondary)]">
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.
</div>
)}
{e.state.state === "incomplete" && (
<div className="text-[var(--text-secondary)]">
{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."}
</div>
)}
{has("push_state") && (
<div className="text-[var(--error)]">
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.
</div>
)}
{onboarding === "incomplete" && (
<div className="text-[var(--text-secondary)]">
{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."}
</div>
)}
{!isAdmin && isWaiting && onboarding !== "joined" && (
<div className="text-[var(--text-secondary)]">Waiting for an admin to onboard you</div>
)}
</div>
<div className="flex gap-1.5 flex-wrap">
{has("join") && onboarding !== "joined" && onboarding !== "in_progress" && (
<Button
size="sm"
variant="primary"
disabled={busy || joinBlocked}
aria-label={`Join ${e.name}`}
onClick={() => join(found, e)}
>
{!isAdmin && isWaiting ? "Show onboarding bundle" : "Join"}
</Button>
)}
{mine("follow") && (
<Button
size="sm"
disabled={busy}
aria-label={`Follow my onboarding to ${e.name}`}
onClick={() =>
void launch(at, e.name, myKey, `Joining ${e.name}`, () => followAwsDeployment(connection.id, at, myKey))
}
>
Follow
</Button>
)}
{mine("push_state") && (
<Button
size="sm"
disabled={busy}
aria-label={`Push saved state of my onboarding to ${e.name}`}
onClick={() =>
void launch(at, e.name, myKey, `Push of the saved state of joining ${e.name}`, () =>
pushAwsSavedState(connection.id, at, myKey),
)
}
>
Push saved state
</Button>
)}
{mine("unlock") && (
<Button
size="sm"
variant="danger"
disabled={busy}
aria-label={`Unlock my onboarding to ${e.name}`}
onClick={() => setUnlockOf({ name: e.name, region: at, stateKey: myKey, lock: myLock, onboarding: true })}
>
Unlock onboarding
</Button>
)}
{has("push_state") && (
<Button
size="sm"
disabled={busy}
aria-label={`Push saved state of ${e.name}`}
onClick={() => {
const key = environmentStateKey(e.name);
void launch(at, e.name, key, `Push of the saved state of ${e.name}`, () =>
pushAwsSavedState(connection.id, at, key),
);
}}
>
Push saved state
</Button>
)}
{has("follow") && (
<Button
size="sm"
disabled={busy}
aria-label={`Follow ${e.name}`}
onClick={() => {
const key = environmentStateKey(e.name);
void launch(at, e.name, key, `Deploy of ${e.name}`, () => followAwsDeployment(connection.id, at, key));
}}
>
Follow
</Button>
)}
{has("update") && (
<Button
size="sm"
disabled={busy}
aria-label={`Update ${e.name}`}
onClick={() =>
void launch(at, e.name, environmentStateKey(e.name), `Update of ${e.name}`, () =>
updateAwsEnvironment(connection.id, at, e.name),
)
}
>
Update
</Button>
)}
{has("repair") && (
<Button
size="sm"
disabled={busy || lockHeld}
aria-label={`Repair ${e.name}`}
onClick={() => (e.info ? void repair(at, e.name) : setRepairOf(e.name))}
>
Repair
</Button>
)}
{has("unlock") && (
<Button
size="sm"
variant="danger"
disabled={busy}
aria-label={`Unlock ${e.name}`}
onClick={() =>
setUnlockOf({ name: e.name, region: at, stateKey: environmentStateKey(e.name), lock, onboarding: false })
}
>
Unlock
</Button>
)}
{has("destroy") && (
<Button
size="sm"
variant="danger"
disabled={busy || lockHeld}
aria-label={`Destroy ${e.name}`}
onClick={() => setDestroyOf({ name: e.name, region: at })}
>
Destroy
</Button>
)}
{lockHeld && (has("repair") || has("destroy")) && (
<span className="self-center text-xs text-[var(--text-secondary)]">Unlock first</span>
)}
</div>
</div>
{rowError && (
<p role="alert" className="text-xs text-[var(--error)] leading-snug">
{awsErrorMessage(rowError)}
</p>
)}
{rowRuns(e.name).map(progress)}
</li>
);
};
const nothingFound = !!current && current.environments.length === 0;
return (
<div className="pt-1.5 space-y-2">
<Button size="sm" variant="ghost" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
Environments
</Button>
{open && (
<div className="space-y-2 pl-1">
<div className="flex items-end gap-2 flex-wrap">
<Field label="Region">
{(id) => (
<select id={id} className={selectClass} value={region} onChange={(e) => setRegion(e.target.value)}>
{regions.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
)}
</Field>
<Button size="md" onClick={() => void detect()} disabled={detecting}>
Detect again
</Button>
</div>
{detecting && (
<p role="status" className="text-xs text-[var(--text-secondary)]">
Detecting…
</p>
)}
{detectError && (
<p role="alert" className="text-xs text-[var(--error)] leading-snug">
{awsErrorMessage(detectError)}
</p>
)}
{current && isAdmin && current.boundary === "no" && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
This account has no Triple-C permissions boundary yet; deploying creates it first.
</p>
)}
{current && current.environments.length > 0 && (
<ul className="space-y-1.5">{current.environments.map((e) => envRow(current, e))}</ul>
)}
{nothingFound && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
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."}
</p>
)}
{current && isAdmin && (
<Button
size="md"
variant={nothingFound ? "primary" : "secondary"}
disabled={stale}
onClick={() => setDeployOpen(true)}
>
Deploy a new environment
</Button>
)}
{nothingFound && !isAdmin && (
<div className="space-y-2">
<PolicyFiles tier="environment_admin" label="Show the environment-admin policies" onCopy={copy} />
<Button size="md" disabled={stale} onClick={() => setBundle({ kind: "environment", region: current.region })}>
Request an environment from an admin…
</Button>
{copyNote && (
<p role="status" className="text-xs text-[var(--text-secondary)]">
{copyNote}
</p>
)}
</div>
)}
{looseRuns.map(progress)}
</div>
)}
{current && (deployOpen || repairOf) && (
<DeployEnvironmentModal
connectionId={connection.id}
region={current.region}
scan={current}
repair={repairOf ?? undefined}
onClose={() => {
setDeployOpen(false);
setRepairOf(null);
}}
onDeploy={(name, network) =>
repairOf ? repair(current.region, name, network) : deploy(current.region, name, network)
}
onDetect={() => void detect()}
/>
)}
{destroyOf && (
<TypedConfirmModal
title={`Destroy ${destroyOf.name}`}
name={destroyOf.name}
confirmLabel="Destroy"
onClose={() => setDestroyOf(null)}
onConfirm={() =>
launch(destroyOf.region, destroyOf.name, environmentStateKey(destroyOf.name), `Destroy of ${destroyOf.name}`, () =>
destroyIncompleteAwsEnvironment(connection.id, destroyOf.region, destroyOf.name),
)
}
>
<p>
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.
</p>
</TypedConfirmModal>
)}
{unlockOf && (
<TypedConfirmModal
title={unlockOf.onboarding ? `Unlock your onboarding to ${unlockOf.name}` : `Unlock ${unlockOf.name}`}
name={unlockOf.name}
confirmLabel="Unlock"
onClose={() => setUnlockOf(null)}
onConfirm={async () => {
try {
await unlockAwsState(connection.id, unlockOf.region, unlockOf.stateKey);
} catch (e) {
return toAwsError(e);
}
void detect();
return null;
}}
>
{(() => {
const lock = unlockOf.lock;
return lock ? (
<p>
Held by <span className="font-mono text-[var(--text-primary)]">{lock.who || "unknown"}</span> since{" "}
{formatAge(lock.age_secs)} ago ({lock.created}
{lock.operation && `, ${lock.operation}`}).
</p>
) : null;
})()}
<p className="text-[var(--warning)]">
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.
</p>
</TypedConfirmModal>
)}
{bundle && (
<AdminBundleModal
connectionId={connection.id}
region={bundle.region}
kind={bundle.kind}
environment={bundle.environment}
ownerId={bundle.ownerId}
onSaved={() => {
if (bundle.kind === "user_onboard" && bundle.environment) {
const rk = rowKey(bundle.region, bundle.environment);
setWaiting((w) => new Set(w).add(rk));
}
}}
onClose={() => setBundle(null)}
/>
)}
</div>
);
}
@@ -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<NetworkChoice["mode"], string> = {
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<NetworkDraft>) => onChange({ ...value, ...patch });
return (
<div className="space-y-2">
<div className="text-[13px] font-medium text-[var(--text-primary)]">Network</div>
<SegmentedControl<NetworkChoice["mode"]>
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" },
]}
/>
<p className="text-xs text-[var(--text-secondary)] leading-snug">{COST[value.mode]}</p>
{value.mode === "existing" && (
<div className="space-y-2">
<Field label="VPC ID">
{(id) => (
<input id={id} className={monoInputClass} placeholder="vpc-0123456789abcdef0" value={value.vpcId} onChange={(e) => set({ vpcId: e.target.value })} />
)}
</Field>
<Field label="Subnet ID 1">
{(id) => (
<input id={id} className={monoInputClass} placeholder="subnet-…" value={value.subnet1} onChange={(e) => set({ subnet1: e.target.value })} />
)}
</Field>
<Field label="Subnet ID 2">
{(id) => (
<input id={id} className={monoInputClass} placeholder="subnet-…" value={value.subnet2} onChange={(e) => set({ subnet2: e.target.value })} />
)}
</Field>
<SwitchRow
label="Assign a public IP"
hint="Needed when the subnets have no NAT route to the internet. Tasks still get no inbound access."
control={<Toggle label="Assign a public IP" checked={value.assignPublicIp} onChange={(v) => set({ assignPublicIp: v })} />}
/>
{error && <p className="text-xs text-[var(--warning)] leading-snug">{error}</p>}
</div>
)}
</div>
);
}
@@ -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<PolicyFile[] | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-1">
<Button size="sm" onClick={load}>
{label}
</Button>
{error && (
<p role="alert" className="text-xs text-[var(--error)]">
{error}
</p>
)}
</div>
);
}
return (
<div className="space-y-1 text-xs">
<p className="text-[var(--text-secondary)]">
{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.`}
</p>
{files.map((f) => (
<div key={f.name} className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-mono text-[var(--text-primary)]">{f.name}</span>
<Button size="sm" aria-label={`Copy ${f.name}`} onClick={() => onCopy(f)}>
Copy
</Button>
</div>
<details>
<summary className="cursor-pointer text-[var(--text-secondary)]">Show JSON</summary>
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-all text-[var(--text-secondary)]">
{f.json}
</pre>
</details>
</div>
))}
</div>
);
}
@@ -0,0 +1,99 @@
// 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 <who> · <age> ago (<operation>)". */
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":
// 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" };
}
}
export function stateLock(s: EnvironmentState): LockInfo | null {
return s.state === "deploying" || s.state === "incomplete" ? s.lock : null;
}
@@ -0,0 +1,66 @@
// 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> = {}): 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> = {}): DetectedEnvironment {
return {
name: "dev",
template_version: 1,
state: { state: "ready" },
actions: ["join"],
info: info(over.name ?? "dev"),
onboarding: { state: "not_joined" },
onboarding_actions: [],
onboarded_users: 0,
...over,
};
}
export function tofuEvent(message: string, over: Partial<TofuEvent> = {}): 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>): DeployProgress {
return { op_id: "op1", state_key: "environments/dev/terraform.tfstate", events: [], done: false, cancellable: true, ...over };
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest";
import { toAwsError } from "./aws-error";
describe("toAwsError", () => {
it("passes a tagged AwsError through", () => {
const e = { kind: "sso_expired", profile: "dev" };
expect(toAwsError(e)).toBe(e);
});
it("wraps strings and Errors as other", () => {
expect(toAwsError("boom")).toEqual({ kind: "other", message: "boom" });
expect(toAwsError(new Error("bad"))).toEqual({ kind: "other", message: "bad" });
});
it("rejects unknown kinds, inherited names and null", () => {
expect(toAwsError({ kind: "nope" }).kind).toBe("other");
expect(toAwsError({ kind: "toString" }).kind).toBe("other");
expect(toAwsError(null).kind).toBe("other");
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { AwsError } from "./types";
// A `Record` over every kind: adding or removing a kind in `AwsError` fails to compile here.
const KINDS: Record<AwsError["kind"], true> = {
sso_expired: true,
profile_not_found: true,
keys_missing: true,
invalid_credentials: true,
access_denied: true,
state_bucket_taken: true,
other: true,
};
/** Narrow what an AWS command rejected with. Anything that is not a tagged `AwsError` becomes `other`. */
export function toAwsError(e: unknown): AwsError {
if (e && typeof e === "object" && "kind" in e) {
const kind = (e as { kind: unknown }).kind;
if (typeof kind === "string" && Object.prototype.hasOwnProperty.call(KINDS, kind)) {
return e as 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;
}
}
@@ -24,6 +24,9 @@ function preview(overrides: Partial<SettingsImportPreview> = {}): SettingsImport
global_hook_install_count: 0,
global_plugin_install_count: 0,
marketplace_account_token_count: 0,
aws_connections: [],
aws_connection_key_count: 0,
removed_aws_connections: [],
...overrides,
};
}
+37 -1
View File
@@ -1,4 +1,4 @@
import type { SettingsImportPreview } from "./types";
import type { AwsConnectionSummary, SettingsImportPreview } from "./types";
/** Named things a `SettingsImportPreview` says an import will change, for
* `ImportSettingsModal`'s confirmation list. Does not include anything
@@ -35,6 +35,14 @@ export function describeImport(preview: SettingsImportPreview): string[] {
const n = preview.marketplace_account_token_count;
items.push(`${n} marketplace account token${n === 1 ? "" : "s"}`);
}
for (const c of preview.aws_connections) {
const auth = c.auth === "keys" ? "access keys" : `profile ${c.profile_name ?? ""}`.trim();
items.push(`AWS connection "${c.name}" (${auth}, ${c.default_region})`);
}
if (preview.aws_connection_key_count > 0) {
const n = preview.aws_connection_key_count;
items.push(`${n} AWS access key set${n === 1 ? "" : "s"}`);
}
return items;
}
@@ -82,6 +90,29 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[]
`Installs ${n} marketplace plugin${n === 1 ? "" : "s"} for all projects. Plugins can bring their own hooks, MCP servers and commands into every project container, and these skip the confirmation that lists what a plugin brings before a Marketplace tab install.`,
);
}
for (const c of preview.aws_connections) {
const localDesc = c.local ? describeAws(c.local) : null;
if (c.overwrites_existing && c.local) {
warnings.push(`Replaces your connection ${localDesc} with "${c.name}" (${c.auth === "keys" ? "access keys" : "profile " + (c.profile_name ?? "")}, ${c.default_region}).`);
}
if (c.overwrites_keys) {
warnings.push(
c.local
? `Overwrites the AWS access keys stored for your connection ${localDesc} (imported as "${c.name}").`
: `Overwrites AWS access keys already stored for the imported connection "${c.name}".`,
);
}
if (c.keys_check_failed) {
warnings.push(
`Could not check whether keys are stored for "${c.name}"${c.local ? ` (your connection ${localDesc})` : ""}; importing may overwrite them.`,
);
}
}
for (const r of preview.removed_aws_connections) {
warnings.push(
`Removes your AWS connection ${describeAws(r)}, which is not in the file. Any access keys stored for it stay in your keychain.`,
);
}
if (preview.image_source === "custom") {
warnings.push(
`Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`,
@@ -89,3 +120,8 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[]
}
return warnings;
}
function describeAws(c: AwsConnectionSummary): string {
const auth = c.auth === "keys" ? "access keys" : `profile ${c.profile_name ?? ""}`.trim();
return `"${c.name}" (${auth}, ${c.default_region})`;
}
+82 -1
View File
@@ -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 } 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<boolean>("check_docker");
@@ -44,6 +44,87 @@ export const detectAwsConfig = () =>
invoke<string | null>("detect_aws_config");
export const listAwsProfiles = () =>
invoke<string[]>("list_aws_profiles");
// AWS host connections. The commands returning `Promise<…>` below reject with an `AwsError`
// object (not a string); narrow a caught value with `toAwsError` from `./aws-error`.
export const addAwsProfileConnection = (name: string, profile: string, defaultRegion: string) =>
invoke<HostConnection>("add_aws_profile_connection", { name, profile, defaultRegion });
export const addAwsKeysConnection = (
name: string,
accessKeyId: string,
secretAccessKey: string,
sessionToken: string | null,
defaultRegion: string,
) =>
invoke<HostConnection>("add_aws_keys_connection", {
name,
accessKeyId,
secretAccessKey,
sessionToken,
defaultRegion,
});
export const updateAwsConnection = (id: string, name: string, defaultRegion: string) =>
invoke<HostConnection>("update_aws_connection", { id, name, defaultRegion });
/** Rejects with a string. Returns the settings after removal. */
export const removeHostConnection = (id: string) =>
invoke<AppSettings>("remove_host_connection", { id });
export const awsConnectionIdentity = (id: string) =>
invoke<CallerIdentity>("aws_connection_identity", { id });
/** Profile connections only; rejects with a string. */
export const awsConnectionSsoLogin = (id: string) =>
invoke<void>("aws_connection_sso_login", { id });
/** `region` omitted: the connection's default region. */
export const checkAwsPermissions = (id: string, region?: string) =>
invoke<PermissionReport>("check_aws_permissions", { id, region: region ?? null });
/** Every policy file `tier` needs, in order: attach all of them. */
export const awsPolicyDocument = (tier: Tier) =>
invoke<PolicyFile[]>("aws_policy_document", { tier });
// 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<EnvironmentScan>("detect_aws_environments", { connectionId, region });
export const deployAwsEnvironment = (connectionId: string, region: string, name: string, network: NetworkChoice) =>
invoke<string>("deploy_aws_environment", { connectionId, region, name, network });
export const updateAwsEnvironment = (connectionId: string, region: string, name: string) =>
invoke<string>("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<string>("repair_aws_environment", { connectionId, region, name, network: network ?? null });
export const destroyIncompleteAwsEnvironment = (connectionId: string, region: string, name: string) =>
invoke<string>("destroy_incomplete_aws_environment", { connectionId, region, name });
export const unlockAwsState = (connectionId: string, region: string, stateKey: string) =>
invoke<void>("unlock_aws_state", { connectionId, region, stateKey });
export const followAwsDeployment = (connectionId: string, region: string, stateKey: string) =>
invoke<string>("follow_aws_deployment", { connectionId, region, stateKey });
export const cancelAwsDeployment = (connectionId: string, region: string, stateKey: string) =>
invoke<void>("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<string>("push_aws_saved_state", { connectionId, region, stateKey });
export const onboardAwsUser = (connectionId: string, region: string, environment: string) =>
invoke<string>("onboard_aws_user", { connectionId, region, environment });
export const deployAwsPermissionsBoundary = (connectionId: string, region: string) =>
invoke<string>("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<string | null>("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) =>
+202
View File
@@ -300,6 +300,7 @@ export interface AppSettings {
terminal_gpu_rendering: boolean | null;
marketplace_accounts: MarketplaceAccount[];
marketplaces: Marketplace[];
hosts: HostConnection[];
global_marketplace_installs: MarketplaceInstall[];
}
@@ -424,6 +425,34 @@ export interface SettingsImportPreview {
global_plugin_install_count: number;
/** Marketplace account tokens the import restores to the keychain. */
marketplace_account_token_count: number;
/** AWS connections the import brings (names cleaned; no key values). */
aws_connections: AwsConnectionPreview[];
/** AWS access-key entries the import restores to the keychain. */
aws_connection_key_count: number;
/** Local AWS connections the import drops (it replaces the whole list). */
removed_aws_connections: AwsConnectionSummary[];
}
export interface AwsConnectionSummary {
name: string;
auth: "profile" | "keys";
profile_name: string | null;
default_region: string;
}
export interface AwsConnectionPreview {
name: string;
auth: "profile" | "keys";
profile_name: string | null;
default_region: string;
/** A connection with this id exists here with different content. */
overwrites_existing: boolean;
/** The import carries keys for an id that already has keys stored here. */
overwrites_keys: boolean;
/** The keychain could not be asked whether keys are already stored. */
keys_check_failed: boolean;
/** The local connection with this id, when the import replaces it. */
local: AwsConnectionSummary | null;
}
/** What `apply_settings_import` returns: the settings that were actually
@@ -1099,3 +1128,176 @@ export interface ViewerPoll {
hash: string | null;
size: number | null;
}
export type AwsAuth =
| { method: "profile"; name: string }
| { method: "keys"; keyring_ref: string };
export interface HostConnection {
id: string;
name: string;
kind: { type: "aws"; auth: AwsAuth; default_region: string };
}
export type AwsErrorKind = AwsError["kind"];
/** What a failed AWS command rejects with (`aws::AwsError`, tagged on `kind`). */
export type AwsError =
| { kind: "sso_expired"; profile: string }
| { kind: "profile_not_found"; profile: string }
| { 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 {
account: string;
arn: string;
user_id: string;
}
export type Tier = "environment_admin" | "workspace_user";
/** One policy file of a tier (`aws/policies/<name>`); a tier needs all of its files. */
export interface PolicyFile {
name: string;
json: string;
}
export type Verdict = "allowed" | "denied" | "unverified";
export interface ActionResult {
action: string;
verdict: Verdict;
/** Set when the row is unverified because a call failed; blocks the tier's claim. */
note: string | null;
}
export interface TierResult {
tier: Tier;
results: ActionResult[];
}
export type PermissionMethod = "simulated" | { probed: { reason: string } };
export type PermissionSummary = "environment_admin" | "workspace_user" | "insufficient";
export interface PermissionReport {
identity: CallerIdentity;
region: string;
method: PermissionMethod;
summary: PermissionSummary;
/** `[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 };
/** `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"; lock: LockInfo | null }
| { state: "incomplete"; lock: LockInfo | null };
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;
/** 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/<env>/…` states); null when the bucket can't be listed. */
onboarded_users: number | 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. 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/<env>/terraform.tfstate`, `users/<env>/<ownerkey>/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;
}
+499
View File
@@ -0,0 +1,499 @@
# Triple-C AWS policies
These are the IAM policies for running Triple-C workspaces on AWS (ECS). Attach them to the IAM
users, groups, roles or SSO permission sets of the people who use Triple-C. The app reports which
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, part 1: apply and destroy the environment and onboarding modules (`terraform/`) with OpenTofu, and keep their state |
| `policies/environment-admin-network.json` | The **environment admin** tier, part 2: the network those modules create (VPC, subnets, gateways, routes, security groups) |
| `policies/permissions-boundary.json` | The **permissions boundary** every Triple-C role must carry. It is never attached to a person |
The admin tier is **two** files, because one managed policy holds only 6,144 characters; both are
required, and the app's **Copy** lists every file of a tier. An environment admin attaches all three
tier files (the workspace user's and both admin files): the admin files add environment management
on top of everything a workspace user can do. Explicit Deny statements in any file apply to anyone
who has it attached.
## Workspace user
Every per-user resource is tagged `triple-c:owner = ${aws:userid}`. That value is the `UserId`
printed by `aws sts get-caller-identity`. The policy uses this tag so that a user can only do the
following:
- **Run tasks.** The task definition must be a `triple-c-*` one tagged with the user's own owner
ID, and the cluster must be a `triple-c-*` one. The new task is tagged with the user's owner ID.
The user can pass only `triple-c-*` roles tagged with their owner ID, and only to ECS tasks.
- **Work with their own tasks.** They can stop them, `ecs execute-command` into them, and open an
SSM session to them. They can end or resume only sessions they started, and open data channels
(`ssmmessages:OpenDataChannel`) only on those.
- `ecs:ExecuteCommand` is authorized against the **cluster** as well as the task, so the policy
also allows it on `triple-c-*` clusters with no condition. The owner rule is on the task, and
both must pass.
- **Manage their own secrets.** They can create, update and delete `triple-c/*` secrets tagged with
their owner ID.
- A secret must be encrypted with an **environment KMS key**, meaning one tagged
`triple-c:component`. `CreateSecret` must name the key by its full ARN in `KmsKeyId` (a key
ID, an alias or no key at all is refused), and the user can use KMS
(`Encrypt`/`GenerateDataKey`/`Decrypt`) only through Secrets Manager and only with environment
keys. Secrets Manager needs `kms:Decrypt` as well as `GenerateDataKey` to create or update a
secret on a customer key.
- A secret on any other key is refused by an **explicit Deny**: `kms:Encrypt` and
`kms:GenerateDataKey*` through Secrets Manager on a key without `triple-c:component`. The Deny
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 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,
even when it only adds `triple-c:project`.
- 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 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
workspace user can never set `triple-c:component`, which marks environment resources and which
the Denies and phase-4 detection trust.
- **Inspect their own principal, as far as IAM can say so.** `iam:GetRole` and
`iam:SimulatePrincipalPolicy`, which the app's permission check uses on the caller's own
principal. An IAM user may inspect only itself (`user/${aws:username}`, with or without a path).
"My own role" cannot be written: IAM refuses `${aws:PrincipalArn}` as a Resource (verified live,
"must be in ARN format or \"*\"") and has no role-name variable. So the `InspectOwnPrincipal`
statement names the roles people actually sign in with:
- `role/aws-reserved/sso.amazonaws.com/*`, the IAM Identity Center permission-set roles;
- `role/triple-c-*`.
A caller on any other role cannot read or simulate its role, so the permission check falls back
to probes and reports **Insufficient**, naming `iam:GetRole`. If your users sign in through other
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, 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 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:
- the per-account state bucket `triple-c-tfstate-<account>-<region>` (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 (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.
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
`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. 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: 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` (`*`) |
| `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: 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`, `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), `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`, 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
(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.
Every state-bucket statement also requires `s3:ResourceAccount = ${aws:PrincipalAccount}`: the
bucket name is predictable, so another account could create `triple-c-tfstate-<your account>-…`
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
the role carries the `triple-c-permissions-boundary` managed policy as its permissions boundary.
- The boundary is the most a Triple-C role can ever hold:
- ECR image pulls;
- writing `/triple-c/*` logs;
- ECS Exec channels;
- reading `triple-c/*` secrets;
- decrypting only through Secrets Manager, and only with environment (component-tagged) keys;
- 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.
- Explicit Deny statements stop the admin from:
- removing the boundary from a `triple-c-*` role;
- setting any other boundary on one;
- editing or deleting the boundary policy itself.
Without these fences an admin could create a role with any permissions and become an account
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. 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
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:
- `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.
- `elasticfilesystem:CreateFileSystem` on `*` (no resource type), still only with a
`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. 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). **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
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). 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/<env>`, the network (`network_mode`: `public`, `private-nat`, `existing`) and, last, the record `/triple-c/environments/<env>` | `environments/<env>/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/<env>/<owner key>/terraform.tfstate` |
The **owner key** is the first 12 hex characters of `sha256("<region>:<aws:userid>")`; it names
the user's roles, `triple-c-<env>-<owner key>-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
reads the environment only from its record, never from its state.
**The state bucket** is `triple-c-tfstate-<account>-<region>`, 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`; 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 `<state key>.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, 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
(`<app data>/triple-c/aws/work/<bucket>/<state key>/`) 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 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. 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), 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); 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 <bundle folder>
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,
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)
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. **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.
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`.
## One-time setup: the boundary policy
An **account administrator** creates the boundary policy once, before anyone deploys an
environment. The environment-admin tier deliberately cannot do this.
```sh
aws iam create-policy --policy-name triple-c-permissions-boundary \
--policy-document file://policies/permissions-boundary.json
```
Create the policy **without a path**. The admin policy expects exactly
`arn:aws:iam::<account>: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 every tier file (about 14,500), and a user's share 2,048. A managed policy
may hold 6,144 characters, not counting whitespace. Without whitespace, `workspace-user.json` is
about 6,040 (roughly 100 characters of headroom), `environment-admin.json` about 5,800 (340)
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.
With the CLI:
```sh
aws iam create-policy --policy-name triple-c-workspace-user \
--policy-document file://policies/workspace-user.json
aws iam attach-user-policy --user-name <user> \
--policy-arn arn:aws:iam::<account>:policy/triple-c-workspace-user
```
## Known gaps
**Phase 4**
- **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 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
IAM fences.
- **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.
**Phase 5**
- **No secrets in task definitions or `RunTask` overrides.** Nothing sensitive (the agent token,
git credentials) may travel in task-definition environment variables or in `RunTask`
`containerOverrides`: every workspace user can read them through `ecs:Describe*` on `*`. Use
Secrets Manager (`triple-c/*`) references instead.
**Phase 6**
- **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)
Every point the unit tests' small evaluator could not settle was checked against real IAM in a
disposable account (us-west-2): with the policy simulator, with calls made as throwaway roles that
hold the policies, and with deny probes (a call made as a role with no permissions names the
resource it was authorized against, and creates nothing). The repeatable part is
`app/src-tauri/src/aws/live_tests.rs`:
```sh
cd app/src-tauri
TRIPLEC_AWS_E2E_PROFILE=<admin profile of a disposable account> TRIPLEC_AWS_E2E_REGION=us-west-2 \
cargo test aws::live_tests -- --ignored --test-threads=1
```
It creates `triple-c-e2e-<id>-*` roles and policies, `triple-c/triple-c-e2e-<id>/*` secrets and
`triple-c-e2e-<id>` KMS keys, all tagged `triple-c-test=phase3`, and deletes them when each test
ends (keys are scheduled for deletion, 7 days). One test creates the real
`triple-c-permissions-boundary` policy if the account has none, and deletes it again.
✓ verified as written · ✗→fixed: the policy changed · — could not verify, and why.
| Point | Result |
|---|---|
| `${aws:PrincipalArn}` as a Resource | ✗→fixed. IAM refuses it ("must be in ARN format or \"*\""), so neither tier policy could be attached at all. Now SSO permission-set roles, `triple-c-*` roles and `user/${aws:username}`, `user/*/${aws:username}` (see the workspace user's own principal) |
| SSO role path | ✓ `iam:GetRole` returns `role/aws-reserved/sso.amazonaws.com/<region>/AWSReservedSSO_…`, and the check simulates that ARN. The SSO admin session checks as Environment admin, Simulated |
| `${aws:PrincipalAccount}` in `iam:PermissionsBoundary` | ✓ `CreateRole` with `triple-c-permissions-boundary` succeeds as the admin tier; without a boundary, or with another one, it is denied |
| Boundary fences | ✓ removing, replacing or editing the boundary is an explicit deny |
| Simulator and context | ✓ the simulator substitutes `${aws:userid}` and `${aws:username}` from context entries. It **does** apply the simulated principal's own permissions boundary (verified); it does not apply session policies. SCPs: not verifiable here |
| Simulator rate limit | ✗→fixed (app). `SimulatePrincipalPolicy` throttles ("Rate exceeded") within a full check; the check now uses adaptive retries and two requests at a time |
| KMS `CreateKey` with tags | ✓ allowed: tag-on-create passes the tag rule on `kms:TagResource`. Untagged `CreateKey`, and `CreateAlias`/`TagResource`/`ScheduleKeyDeletion` on an untagged key, are denied |
| ECS Exec on the cluster | ✗→fixed. `ecs:ExecuteCommand` is authorized against the cluster, then the task. Added `ExecOnTripleCClusters`; with only the cluster allowed, the call is denied on the task, so the owner rule still decides |
| ECS tag keys | ✓ (service reference) `aws:ResourceTag` is supported on tasks, clusters and task definitions. Live: — needs phase 4/5 ECS resources |
| SSM `StartSession` to a task | ✓ authorized against `arn:aws:ecs:<region>:<account>:task/<cluster>/<task-id>`, which `task/triple-c-*/*` matches. The tag condition on a real task: — needs phase 5 |
| SSO session ownership | ✓ (AWS docs) the `aws:ssmmessages:session-id` tag is the starter's `aws:userid` for users and role sessions alike. Live: — needs a session. Added `ssmmessages:OpenDataChannel` on own sessions, which AWS's current examples require |
| EC2 tag-on-create | ✓ `CreateSecurityGroup` (DryRun) is allowed with a `triple-c:component` tag and denied without one or with only other tags. `security-group-rule` resources on `AuthorizeSecurityGroup*`: — needs a security group |
| EFS | ✗→fixed: `CreateFileSystem` has no resource type, so `Resource` is now `*`. ✓ a tagged `CreateFileSystem`/`CreateAccessPoint` authorizes `elasticfilesystem:TagResource` first. `elasticfilesystem:CreateAction`, `CreateAccessPoint` against the file system's tags, `ClientMount`/`ClientWrite`: — need phase 6 file systems |
| Secrets tag-on-create | ✓ `CreateSecret` with tags authorizes `secretsmanager:TagResource` separately (refused without `TagOwnSecrets`), and that statement's `StringEqualsIfExists`/`Null` conditions pass at create |
| `secretsmanager:KmsKeyId` | ✓ carries the request's value: a key ARN passes, a bare key ID, an alias or no key is refused |
| Secrets Manager's KMS calls | ✓ made as the caller with `kms:ViaService`. ✗→fixed: `CreateSecret`/`PutSecretValue` on a customer key also need `kms:Decrypt`, which the policy lacked ("Access to KMS is not allowed"). `PutSecretValue` on an own secret now passes |
| `aws/secretsmanager` key | ✓ denied, and the Deny is what denies it: the same policy without `NeverEncryptSecretsWithOtherKeys` creates the secret |
| 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` | ✓ (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. 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=<admin profile of a disposable account> 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-<id>-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-<account>-<region>-<suffix>` 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 (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.
| 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 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 |
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).
+216
View File
@@ -0,0 +1,216 @@
{
"Version": "2012-10-17",
"Statement": [
{
"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:CreateInternetGateway",
"ec2:CreateRouteTable",
"ec2:CreateNatGateway",
"ec2:CreateSecurityGroup"
],
"Resource": [
"arn:aws:ec2:*:*:internet-gateway/*",
"arn:aws:ec2:*:*:route-table/*",
"arn:aws:ec2:*:*:natgateway/*",
"arn:aws:ec2:*:*:security-group/*"
],
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
}
}
},
{
"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/*"
]
},
{
"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: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": {
"StringEquals": {
"aws:CalledViaLast": "elasticfilesystem.amazonaws.com"
}
}
},
{
"Sid": "DisassociateGoneAddresses",
"Effect": "Allow",
"Action": [
"ec2:DisassociateAddress"
],
"Resource": [
"arn:aws:ec2:*:*:${*}/${*}"
]
},
{
"Sid": "DescribeEc2",
"Effect": "Allow",
"Action": [
"ec2:Describe*"
],
"Resource": [
"*"
]
}
]
}
+359
View File
@@ -0,0 +1,359 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ManageBoundedTripleCRoles",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:PutRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:DeleteRolePolicy"
],
"Resource": [
"arn:aws:iam::*:role/triple-c-*"
],
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::${aws:PrincipalAccount}:policy/triple-c-permissions-boundary"
}
}
},
{
"Sid": "ManageAndReadTripleCRoles",
"Effect": "Allow",
"Action": [
"iam:DeleteRole",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfilesForRole"
],
"Resource": [
"arn:aws:iam::*:role/triple-c-*"
]
},
{
"Sid": "PassTripleCRoles",
"Effect": "Allow",
"Action": [
"iam:PassRole"
],
"Resource": [
"arn:aws:iam::*:role/triple-c-*"
],
"Condition": {
"StringEquals": {
"iam:PassedToService": "ecs-tasks.amazonaws.com"
}
}
},
{
"Sid": "KeepTheBoundaryOnTripleCRoles",
"Effect": "Deny",
"Action": [
"iam:DeleteRolePermissionsBoundary"
],
"Resource": [
"arn:aws:iam::*:role/triple-c-*"
]
},
{
"Sid": "KeepTheBoundaryPolicyUnchanged",
"Effect": "Deny",
"Action": [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion"
],
"Resource": [
"arn:aws:iam::*:policy/triple-c-permissions-boundary"
]
},
{
"Sid": "OnlyTheTripleCBoundaryOnTripleCRoles",
"Effect": "Deny",
"Action": [
"iam:PutRolePermissionsBoundary"
],
"Resource": [
"arn:aws:iam::*:role/triple-c-*"
],
"Condition": {
"StringNotEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::${aws:PrincipalAccount}:policy/triple-c-permissions-boundary"
}
}
},
{
"Sid": "InspectOwnPrincipal",
"Effect": "Allow",
"Action": [
"iam:GetRole",
"iam:SimulatePrincipalPolicy"
],
"Resource": [
"arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*",
"arn:aws:iam::*:role/triple-c-*",
"arn:aws:iam::*:user/${aws:username}",
"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:UpdateCluster",
"ecs:PutClusterCapacityProviders",
"ecs:DescribeClusters",
"ecs:TagResource",
"ecs:UntagResource",
"ecs:ListTagsForResource"
],
"Resource": [
"arn:aws:ecs:*:*:cluster/triple-c-*"
]
},
{
"Sid": "ManageTaskDefinitions",
"Effect": "Allow",
"Action": [
"ecs:RegisterTaskDefinition",
"ecs:DeregisterTaskDefinition"
],
"Resource": [
"*"
]
},
{
"Sid": "CreateTaggedFileSystems",
"Effect": "Allow",
"Action": [
"elasticfilesystem:CreateFileSystem"
],
"Resource": [
"*"
],
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
}
}
},
{
"Sid": "TagFileSystemsAtCreate",
"Effect": "Allow",
"Action": [
"elasticfilesystem:TagResource"
],
"Resource": [
"arn:aws:elasticfilesystem:*:*:file-system/*"
],
"Condition": {
"StringEquals": {
"elasticfilesystem:CreateAction": "CreateFileSystem"
}
}
},
{
"Sid": "ManageTripleCFileSystems",
"Effect": "Allow",
"Action": [
"elasticfilesystem:DeleteFileSystem",
"elasticfilesystem:UpdateFileSystem",
"elasticfilesystem:PutLifecycleConfiguration",
"elasticfilesystem:CreateMountTarget",
"elasticfilesystem:DeleteMountTarget",
"elasticfilesystem:ModifyMountTargetSecurityGroups",
"elasticfilesystem:PutFileSystemPolicy",
"elasticfilesystem:DeleteFileSystemPolicy",
"elasticfilesystem:TagResource",
"elasticfilesystem:UntagResource"
],
"Resource": [
"arn:aws:elasticfilesystem:*:*:file-system/*"
],
"Condition": {
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "CreateTaggedKeys",
"Effect": "Allow",
"Action": [
"kms:CreateKey"
],
"Resource": [
"*"
],
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
}
}
},
{
"Sid": "ManageTripleCKeys",
"Effect": "Allow",
"Action": [
"kms:ScheduleKeyDeletion",
"kms:PutKeyPolicy",
"kms:EnableKeyRotation",
"kms:UpdateKeyDescription",
"kms:TagResource",
"kms:UntagResource",
"kms:CreateAlias",
"kms:UpdateAlias",
"kms:DeleteAlias"
],
"Resource": [
"arn:aws:kms:*:*:key/*"
],
"Condition": {
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "ManageTripleCKeyAliases",
"Effect": "Allow",
"Action": [
"kms:CreateAlias",
"kms:UpdateAlias",
"kms:DeleteAlias"
],
"Resource": [
"arn:aws:kms:*:*:alias/triple-c-*"
]
},
{
"Sid": "ManageLogGroups",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:DeleteLogGroup",
"logs:PutRetentionPolicy",
"logs:DeleteRetentionPolicy",
"logs:TagResource",
"logs:UntagResource",
"logs:ListTagsForResource"
],
"Resource": [
"arn:aws:logs:*:*:log-group:/triple-c/*"
]
},
{
"Sid": "ManageEnvironmentParameters",
"Effect": "Allow",
"Action": [
"ssm:PutParameter",
"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",
"s3:TagResource"
],
"Resource": [
"arn:aws:s3:::triple-c-tfstate-*"
],
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
},
"StringEquals": {
"s3:ResourceAccount": "${aws:PrincipalAccount}"
}
}
},
{
"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-*"
],
"Condition": {
"StringEquals": {
"s3:ResourceAccount": "${aws:PrincipalAccount}"
}
}
},
{
"Sid": "ReadAndWriteState",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::triple-c-tfstate-*/*"
],
"Condition": {
"StringEquals": {
"s3:ResourceAccount": "${aws:PrincipalAccount}"
}
}
}
]
}
+121
View File
@@ -0,0 +1,121 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EcrAuthorization",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken"
],
"Resource": [
"*"
]
},
{
"Sid": "PullTripleCImages",
"Effect": "Allow",
"Action": [
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchCheckLayerAvailability",
"ecr:BatchImportUpstreamImage",
"ecr:CreateRepository"
],
"Resource": [
"arn:aws:ecr:*:*:repository/triple-c-*",
"arn:aws:ecr:*:*:repository/triple-c/*"
]
},
{
"Sid": "WriteTripleCLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:*:*:log-group:/triple-c/*"
]
},
{
"Sid": "EcsExecChannels",
"Effect": "Allow",
"Action": [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
],
"Resource": [
"*"
]
},
{
"Sid": "ReadTripleCSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
]
},
{
"Sid": "DecryptThroughSecretsManagerOnly",
"Effect": "Allow",
"Action": [
"kms:Decrypt"
],
"Resource": [
"arn:aws:kms:*:*:key/*"
],
"Condition": {
"StringLike": {
"kms:ViaService": "secretsmanager.*.amazonaws.com"
},
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "MountEnvironmentFileSystems",
"Effect": "Allow",
"Action": [
"elasticfilesystem:ClientMount",
"elasticfilesystem:ClientWrite"
],
"Resource": [
"arn:aws:elasticfilesystem:*:*:file-system/*"
],
"Condition": {
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "WhoAmI",
"Effect": "Allow",
"Action": [
"sts:GetCallerIdentity"
],
"Resource": [
"*"
]
},
{
"Sid": "InvokeBedrockModels",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": [
"arn:aws:bedrock:*::foundation-model/*",
"arn:aws:bedrock:*:*:inference-profile/*",
"arn:aws:bedrock:*:*:application-inference-profile/*"
]
}
]
}
+404
View File
@@ -0,0 +1,404 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RunOwnTasks",
"Effect": "Allow",
"Action": [
"ecs:RunTask"
],
"Resource": [
"arn:aws:ecs:*:*:task-definition/triple-c-*"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/triple-c:owner": "${aws:userid}",
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
},
"ArnLike": {
"ecs:cluster": "arn:aws:ecs:*:*:cluster/triple-c-*"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
{
"Sid": "TagOwnTasksAtRunTask",
"Effect": "Allow",
"Action": [
"ecs:TagResource"
],
"Resource": [
"arn:aws:ecs:*:*:task/triple-c-*/*"
],
"Condition": {
"StringEquals": {
"ecs:CreateAction": "RunTask",
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
{
"Sid": "PassOwnTaskRoles",
"Effect": "Allow",
"Action": [
"iam:PassRole"
],
"Resource": [
"arn:aws:iam::*:role/triple-c-*"
],
"Condition": {
"StringEquals": {
"iam:ResourceTag/triple-c:owner": "${aws:userid}",
"iam:PassedToService": "ecs-tasks.amazonaws.com"
}
}
},
{
"Sid": "StopAndExecOwnTasks",
"Effect": "Allow",
"Action": [
"ecs:StopTask",
"ecs:ExecuteCommand"
],
"Resource": [
"arn:aws:ecs:*:*:task/triple-c-*/*"
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
}
}
},
{
"Sid": "ExecOnTripleCClusters",
"Effect": "Allow",
"Action": [
"ecs:ExecuteCommand"
],
"Resource": [
"arn:aws:ecs:*:*:cluster/triple-c-*"
]
},
{
"Sid": "StartSessionsToOwnTasks",
"Effect": "Allow",
"Action": [
"ssm:StartSession"
],
"Resource": [
"arn:aws:ecs:*:*:task/triple-c-*/*"
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
}
}
},
{
"Sid": "UsePortForwardingDocument",
"Effect": "Allow",
"Action": [
"ssm:StartSession"
],
"Resource": [
"arn:aws:ssm:*::document/AWS-StartPortForwardingSession"
]
},
{
"Sid": "UseEndOrResumeOwnSessions",
"Effect": "Allow",
"Action": [
"ssm:TerminateSession",
"ssm:ResumeSession",
"ssmmessages:OpenDataChannel"
],
"Resource": [
"arn:aws:ssm:*:*:session/*"
],
"Condition": {
"StringLike": {
"ssm:resourceTag/aws:ssmmessages:session-id": "${aws:userid}"
}
}
},
{
"Sid": "CreateOwnSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:CreateSecret"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
},
"ArnLike": {
"secretsmanager:KmsKeyId": "arn:aws:kms:*:*:key/*"
}
}
},
{
"Sid": "TagOwnSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:TagResource"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"StringEqualsIfExists": {
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
},
"Null": {
"aws:ResourceTag/triple-c:component": "true"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
{
"Sid": "ManageOwnSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:PutSecretValue",
"secretsmanager:DeleteSecret"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
}
}
},
{
"Sid": "NeverTouchEnvironmentSecrets",
"Effect": "Deny",
"Action": [
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"secretsmanager:PutSecretValue",
"secretsmanager:DeleteSecret"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
],
"Condition": {
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "CreateOwnAccessPoints",
"Effect": "Allow",
"Action": [
"elasticfilesystem:CreateAccessPoint"
],
"Resource": [
"arn:aws:elasticfilesystem:*:*:file-system/*"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/triple-c:owner": "${aws:userid}",
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
},
"Null": {
"aws:ResourceTag/triple-c:component": "false"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
{
"Sid": "TagOwnAccessPointsAtCreate",
"Effect": "Allow",
"Action": [
"elasticfilesystem:TagResource"
],
"Resource": [
"arn:aws:elasticfilesystem:*:*:access-point/*"
],
"Condition": {
"StringEquals": {
"elasticfilesystem:CreateAction": "CreateAccessPoint",
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
{
"Sid": "DeleteOwnAccessPoints",
"Effect": "Allow",
"Action": [
"elasticfilesystem:DeleteAccessPoint"
],
"Resource": [
"arn:aws:elasticfilesystem:*:*:access-point/*"
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
}
}
},
{
"Sid": "EncryptThroughSecretsManagerWithEnvironmentKeysOnly",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:GenerateDataKey",
"kms:Decrypt"
],
"Resource": [
"arn:aws:kms:*:*:key/*"
],
"Condition": {
"StringLike": {
"kms:ViaService": "secretsmanager.*.amazonaws.com"
},
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "NeverEncryptSecretsWithOtherKeys",
"Effect": "Deny",
"Action": [
"kms:Encrypt",
"kms:GenerateDataKey*"
],
"Resource": [
"arn:aws:kms:*:*:key/*"
],
"Condition": {
"StringLike": {
"kms:ViaService": "secretsmanager.*.amazonaws.com"
},
"Null": {
"aws:ResourceTag/triple-c:component": "true"
}
}
},
{
"Sid": "ReadEcs",
"Effect": "Allow",
"Action": [
"ecs:Describe*",
"ecs:List*"
],
"Resource": [
"*"
]
},
{
"Sid": "ReadEnvironmentParameters",
"Effect": "Allow",
"Action": [
"ssm:GetParameter*"
],
"Resource": [
"arn:aws:ssm:*:*:parameter/triple-c/*"
]
},
{
"Sid": "ListSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:ListSecrets"
],
"Resource": [
"*"
]
},
{
"Sid": "ReadFileSystems",
"Effect": "Allow",
"Action": [
"elasticfilesystem:Describe*"
],
"Resource": [
"*"
]
},
{
"Sid": "ReadLogs",
"Effect": "Allow",
"Action": [
"logs:GetLogEvents",
"logs:FilterLogEvents"
],
"Resource": [
"arn:aws:logs:*:*:log-group:/triple-c/*"
]
},
{
"Sid": "InspectOwnPrincipal",
"Effect": "Allow",
"Action": [
"iam:GetRole",
"iam:SimulatePrincipalPolicy"
],
"Resource": [
"arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*",
"arn:aws:iam::*:role/triple-c-*",
"arn:aws:iam::*:user/${aws:username}",
"arn:aws:iam::*:user/*/${aws:username}"
]
},
{
"Sid": "WhoAmI",
"Effect": "Allow",
"Action": [
"sts:GetCallerIdentity"
],
"Resource": [
"*"
]
}
]
}
+39
View File
@@ -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",
]
}
+134
View File
@@ -0,0 +1,134 @@
# 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"]
}
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" {
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,
]
}
+145
View File
@@ -0,0 +1,145 @@
# 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"
)
}
# 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
# 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
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}" }
lifecycle {
ignore_changes = [availability_zone]
}
}
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}" }
lifecycle {
ignore_changes = [availability_zone]
}
}
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
}
+32
View File
@@ -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
}
+96
View File
@@ -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, ...)."
}
}
+15
View File
@@ -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" {}
}
+39
View File
@@ -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",
]
}
@@ -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
}
@@ -0,0 +1,3 @@
output "policy_arn" {
value = aws_iam_policy.boundary.arn
}
@@ -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)."
}
}
@@ -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" {}
}
+39
View File
@@ -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",
]
}
+107
View File
@@ -0,0 +1,107 @@
# 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.
# 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 the file system of ${var.owner_key}"
vpc_id = data.aws_subnet.task[0].vpc_id
ingress {
description = "NFS from the environment task group"
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" } }
},
{
# 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"
Principal = { AWS = aws_iam_role.task.arn }
Action = ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite"]
Resource = aws_efs_file_system.this.arn
},
]
})
}
+125
View File
@@ -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
},
]
})
}
+73
View File
@@ -0,0 +1,73 @@
# 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
# 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(
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."
}
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/<environment>), security_group_id or subnet id."
}
}
}
+38
View File
@@ -0,0 +1,38 @@
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
}
# 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,
]
}
+56
View File
@@ -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(\"<region>:<owner_id>\"); 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.region}:${var.owner_id}"), 0, 12)
error_message = "owner_key must be the first 12 hex characters of sha256(\"<region>:<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
}
+15
View File
@@ -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" {}
}
+9 -2
View File
@@ -99,8 +99,15 @@ remap_uid_gid() {
remap_uid_gid
# Fix ownership of home directory after UID/GID change
chown -R claude:claude /home/claude
# Fix ownership of home directory after UID/GID change — only where it is
# wrong. A blanket `chown -R` rewrites every inode, and where /home/claude is
# the image's own copy rather than a volume (an ECS/Fargate task) that copies
# the whole directory up into the writable layer: measured at ~5 minutes on
# 1 vCPU before the agent could start. `find` only stats. `-h` and find's
# default no-follow match `chown -R`'s physical walk (it changes a symlink
# itself, never its target), and like `chown -R` this crosses into the nested
# ~/.claude volume.
find /home/claude \( ! -user claude -o ! -group claude \) -exec chown -h claude:claude {} +
# ── Corporate CA certificates ───────────────────────────────────────────────
# The host's CA material is bind-mounted read-only at /tmp/.host-ca. Triple-C
@@ -0,0 +1,370 @@
# AWS Connection + Permission Check (Phase 3) 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:** A user can add an AWS connection in Settings → Hosts (an AWS profile, including SSO, or access keys, plus a default region). They see "Signed in as `<arn>`", and can run a permission check that reports Environment admin, Workspace user or Insufficient. For anything missing, it names the exact actions and offers the policy JSON to send to an admin.
**Architecture:**
- A new `aws/` Rust module builds SDK configs from a `HostConnection`: an explicit profile provider, or static keys read from the keyring.
- It calls `sts:GetCallerIdentity` and runs a table-driven permission check. That check uses `iam:SimulatePrincipalPolicy`, falling back to read-only probes when simulation is denied.
- The two tier policies ship as JSON in `aws/policies/` and are compiled in with `include_str!`. A test keeps the check table and the policy files identical action-for-action.
- Tauri commands in `commands/host_commands.rs`. The React UI is `components/settings/HostsSettings.tsx`.
**Tech Stack:**
- Rust: Tauri v2. Already present: `aws-config` 1.13 (`sso`, `credentials-process`), `aws-sdk-ecs`, `aws-sdk-ssm`. Added: `aws-sdk-sts`, `aws-sdk-iam`, `aws-sdk-cloudformation`, `aws-sdk-secretsmanager`, all with `default-features = false` and features `default-https-client`, `rt-tokio`, `behavior-version-latest`, the same as the existing ones.
- `keyring` through `storage/secure.rs`.
- React + TypeScript + vitest.
**Spec:** `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`. Relevant sections: "User flow" §1 and §3, "Multi-user accounts", "Data model", "Shipped AWS artefacts", "Connection model".
## Global Constraints
- Connection model **C**: a connection is credentials plus a **default** region. Region and environment belong to a project later (phase 5), not to the connection.
- Auth options are the same as the AWS CLI's: `AwsAuth::Profile { name }` (any profile in `~/.aws/config`, SSO included) or `AwsAuth::Keys { keyring_ref }`.
- Access keys are stored in the OS keyring through `storage/secure.rs`, **never in `settings.json`**.
- `AppSettings.hosts: Vec<HostConnection>`. Local is implicit and is not stored. Existing `settings.json` files must deserialize unchanged (`#[serde(default)]`).
- The owner ID is `UserId` from `GetCallerIdentity`, i.e. `aws:userid`. Every per-user resource is tagged `triple-c:owner = ${aws:userid}`.
- Tags: `triple-c:owner`, `triple-c:project`, `triple-c:environment`, `triple-c:component`.
- Resource naming fixed by this phase, which phase 4's templates must use:
- clusters `triple-c-*`;
- task definition families `triple-c-*`;
- secrets `triple-c/*`;
- SSM parameters `/triple-c/*`;
- stacks `triple-c-*`;
- IAM roles `triple-c-*`.
- The simulator does not apply SCPs, permission boundaries or session policies. The UI must say so, and write actions that were only probed are shown as **"unverified — will be confirmed on first use"**, never as OK.
- AWS calls must not inherit the app process's `AWS_*` environment for a **profile** connection. Use `ProfileFileCredentialsProvider` with the explicit profile name, not the default chain.
- Every new Tauri command is registered in `lib.rs` and granted in `capabilities/default.json`. `src/test/capabilities.test.ts` must pass.
- Deferred on purpose:
- **EFS.** The spec's `elasticfilesystem:DescribeAccessPoints` probe waits for the EFS crate, which arrives with EFS mode in phase 6. Until then, EFS read checks are `Unverified` in probe mode.
- **Role policies.** `task-execution-role.json` and `task-role-standard.json` ship with the phase 4 templates that use them.
- **Per-environment caching.** Results are cached per `(connection, region, environment)` and re-run on a denial. Environments arrive in phase 4, so for now the check runs on demand and the UI keeps the last result.
- No AWS spending. The permission check uses only IAM simulation, STS and read-only list/describe calls.
## Review Focus
1. **SSO profile with an expired token.** Expect a typed `AwsError::SsoExpired` and a "Sign in" button that runs `aws sso login --profile <name>`, not a raw SDK error. Test: `an_expired_sso_token_is_reported_as_sso_expired` (Task 3).
2. **An SSO or assumed-role caller.** `GetCallerIdentity` returns `arn:aws:sts::<acct>:assumed-role/<Role>/<session>`, but simulation needs the role ARN *with its path*. SSO roles live under `/aws-reserved/sso.amazonaws.com/<region>/`. Look the path up with `iam:GetRole`. If that is denied, fall back to probes rather than simulating a wrong ARN. Tests: `assumed_role_arn_maps_to_role_arn_with_path` and `a_denied_get_role_falls_back_to_probes` (Tasks 3 and 5).
3. **The caller can't run `iam:SimulatePrincipalPolicy`** (common for locked-down SSO roles). Expect the probe fallback: probed reads are marked allowed or denied, writes unverified, and the tier labelled "unverified". Test: `a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified` (Task 5).
4. **Removing a connection whose keyring entry is already gone** (deleted by hand, or another OS user). Expect removal to still succeed, because a missing entry is not an error. Test: `removing_a_keys_connection_with_no_keyring_entry_succeeds` (Task 2).
5. **Settings import on another machine.** A `Keys` connection must arrive with its keys (exported like marketplace tokens), and a `Profile` connection arrives as-is even if that profile doesn't exist there. Checking it then says "profile not found", not a panic. Tests: `aws_connection_keys_round_trip_through_export` (Task 2) and `a_missing_profile_is_reported_by_name` (Task 3).
---
### Task 1: Data model — `HostConnection` in settings (Rust + TS)
**Files:**
- Create: `app/src-tauri/src/models/host_connection.rs`
- Modify: `app/src-tauri/src/models/mod.rs` (add `pub mod host_connection;` and re-export)
- Modify: `app/src-tauri/src/models/app_settings.rs` (add `#[serde(default)] pub hosts: Vec<HostConnection>` after `marketplaces`, plus `Default`)
- Modify: `app/src/lib/types.ts` (add `HostConnection`, `AwsAuth`; add `hosts: HostConnection[]` to `AppSettings`)
**Interfaces:**
- Produces:
```rust
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostConnection { pub id: String, pub name: String, pub kind: HostKind }
#[derive(..)] #[serde(tag = "type", rename_all = "snake_case")]
pub enum HostKind { Aws { auth: AwsAuth, default_region: String } }
#[derive(..)] #[serde(tag = "method", rename_all = "snake_case")]
pub enum AwsAuth { Profile { name: String }, Keys { keyring_ref: String } }
pub fn validate_connection_name(name: &str) -> Result<String, String> // trimmed, 1..=64 chars, no control chars
pub fn validate_region(region: &str) -> Result<String, String> // ^[a-z]{2}(-[a-z]+)+-\d+$
pub fn validate_profile_name(name: &str) -> Result<String, String> // non-empty, ≤128, [A-Za-z0-9_.@+=,-]
```
TS: `type AwsAuth = { method: "profile"; name: string } | { method: "keys"; keyring_ref: string }`; `interface HostConnection { id: string; name: string; kind: { type: "aws"; auth: AwsAuth; default_region: string } }`.
- [ ] **Step 1: Write failing tests** in `host_connection.rs` `mod tests`:
- `settings_without_hosts_deserialize_to_empty`: a minimal `AppSettings` JSON with no `hosts` key gives `hosts == vec![]`.
- `a_profile_connection_round_trips`: serialize then deserialize gives an equal value, and the JSON contains `"type":"aws"` and `"method":"profile"`.
- `regions_are_validated`: accepts `us-west-2`, `eu-central-1`, `cn-north-1`, `us-gov-west-1`; rejects `""`, `US-WEST-2`, `us-west`, `us-west-2; rm`.
- `profile_names_are_validated`: accepts `default` and `340315884407_StaffAdministratorAccess`; rejects `""`, `a b`, `x\n`, and 129 chars.
- [ ] **Step 2: Run them:** `cd app/src-tauri && cargo test host_connection`. Expected: FAIL (module missing).
- [ ] **Step 3: Implement the types and validators.** Add `hosts` to `AppSettings` and its `Default`.
- [ ] **Step 4: Run them:** `cargo test host_connection` passes. Run `cargo test` (whole crate) to catch any other `AppSettings { .. }` literal that needs the field.
- [ ] **Step 5: Add the TS types.** `cd app && npx tsc --noEmit` passes. Update any test fixture that builds an `AppSettings` with `hosts: []`.
- [ ] **Step 6: Commit** `feat(aws): HostConnection model in app settings`.
### Task 2: Keyring storage for access keys, and settings export
**Files:**
- Modify: `app/src-tauri/src/storage/secure.rs` (new section modelled on the marketplace-token one)
- Modify: `app/src-tauri/src/models/settings_export.rs` (`ExportedSecrets.aws_connection_keys: BTreeMap<String, String>`, `#[serde(default)]`)
- Modify: `app/src-tauri/src/commands/settings_export_commands.rs` (export and restore, mirroring `exported_marketplace_tokens` / `restore_marketplace_tokens`)
**Interfaces:**
- Produces:
```rust
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] // Debug impl redacts the secret and token
pub struct AwsKeys { pub access_key_id: String, pub secret_access_key: String, pub session_token: Option<String> }
pub fn store_aws_keys(connection_id: &str, keys: &AwsKeys) -> Result<(), String> // service "triple-c-aws-connection-<id>", JSON value
pub fn get_aws_keys(connection_id: &str) -> Result<Option<AwsKeys>, String>
pub fn delete_aws_keys(connection_id: &str) -> Result<(), String> // missing entry = Ok
```
`keyring_ref` in `AwsAuth::Keys` is the connection id. Ids are validated like `marketplace_token_service`.
- [ ] **Step 1: Write failing tests:**
- `aws_keys_debug_redacts_secrets`: `format!("{:?}", keys)` contains neither the secret nor the token.
- `aws_key_service_names_reject_bad_ids`: rejects `"../x"`, `""`, and a 65-char id.
- `removing_a_keys_connection_with_no_keyring_entry_succeeds`: run against the existing mock-keyring test pattern in `secure.rs` if there is one; otherwise as a unit test on `delete_entry`'s `NoEntry` → `Ok` mapping.
- `aws_connection_keys_round_trip_through_export`, in `settings_export_commands.rs` tests: a `Keys` connection's stored keys appear in `ExportedSecrets.aws_connection_keys` (as JSON), a `Profile` connection contributes nothing, and restore calls the store fn with the same id and keys.
- [ ] **Step 2: Run** `cargo test aws_key` and `cargo test aws_connection_keys`. Expected: FAIL.
- [ ] **Step 3: Implement.** On restore, a value that fails to parse as `AwsKeys` gives a warning naming the connection id, never the value.
- [ ] **Step 4: Run** those tests, then all of `cargo test settings_export`. All pass.
- [ ] **Step 5: Commit** `feat(aws): keyring storage for AWS access keys, carried by settings export`.
### Task 3: SDK config and identity (`aws::connection`, `aws::identity`)
**Files:**
- Modify: `app/src-tauri/Cargo.toml` (add `aws-sdk-sts`, `aws-sdk-iam`, `aws-sdk-cloudformation`, `aws-sdk-secretsmanager` at the current versions matching `aws-config 1.13.x`, with the feature set above)
- Create: `app/src-tauri/src/aws/mod.rs`, `app/src-tauri/src/aws/connection.rs`, `app/src-tauri/src/aws/identity.rs`, `app/src-tauri/src/aws/error.rs`
- Modify: `app/src-tauri/src/lib.rs` (`mod aws;`)
**Interfaces:**
- Consumes: `HostConnection`, `AwsAuth` (Task 1); `secure::get_aws_keys` (Task 2).
- Produces:
```rust
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum AwsError {
SsoExpired { profile: String }, // the UI offers "Sign in"
ProfileNotFound { profile: String },
KeysMissing, // keyring entry gone
InvalidCredentials { message: String },
AccessDenied { action: String, message: String },
Other { message: String },
}
impl std::fmt::Display for AwsError
pub async fn sdk_config(conn: &HostConnection, region: &str) -> Result<aws_config::SdkConfig, AwsError>
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CallerIdentity { pub account: String, pub arn: String, pub user_id: String /* owner id */ }
pub async fn caller_identity(cfg: &aws_config::SdkConfig) -> Result<CallerIdentity, AwsError>
pub enum PrincipalArn { Exact(String), AssumedRole { account: String, role_name: String } }
pub fn principal_for_simulation(caller_arn: &str) -> Result<PrincipalArn, AwsError>
pub async fn resolve_principal(iam: &aws_sdk_iam::Client, p: PrincipalArn) -> Result<String, AwsError> // GetRole for the path; denied → AccessDenied{action:"iam:GetRole"}
pub fn classify_sdk_error(action: &str, err: &(dyn std::error::Error + 'static)) -> AwsError
```
`sdk_config`:
- `Profile` uses `ProfileFileCredentialsProvider::builder().profile_name(name)`, plus `.region(Region::new(region))`. It does not use the default chain.
- `Keys` uses `Credentials::new(akid, secret, token, None, "triple-c-connection")`.
- The config is built lazily, so profile problems surface on the first call and are classified there.
- [ ] **Step 1: Write failing tests** (pure, no network):
- `iam_user_arn_is_used_as_is`: `arn:aws:iam::111122223333:user/alice` gives `Exact`.
- `assumed_role_arn_maps_to_role_name`: `arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Admin_abc/alice@x.com` gives `AssumedRole{account:"111122223333", role_name:"AWSReservedSSO_Admin_abc"}`.
- `root_and_federated_arns_are_rejected_for_simulation`: `arn:aws:iam::1:root` and `arn:aws:sts::1:federated-user/bob` give `Err(AwsError::Other{..})`, with a message saying simulation isn't possible for this principal (the caller then falls back to probes).
- `assumed_role_arn_maps_to_role_arn_with_path`: `resolve_principal` against a fake IAM endpoint. Use an `aws_smithy_runtime` `StaticReplayClient`, or a local `tokio` HTTP listener as in `host/ssm/tests.rs` `a_dropped_open_still_ends_its_session`. The fake returns GetRole XML with `<Path>/aws-reserved/sso.amazonaws.com/us-west-2/</Path>` and `<Arn>`; expect that `Arn`.
- `an_expired_sso_token_is_reported_as_sso_expired`: `classify_sdk_error` on an error whose display chain contains "The SSO session associated with this profile has expired" or "Token has expired" gives `SsoExpired`.
- `a_missing_profile_is_reported_by_name`: `sdk_config` + `caller_identity` with `AWS_CONFIG_FILE` and `AWS_SHARED_CREDENTIALS_FILE` pointing at an empty temp file gives `ProfileNotFound{profile}`. Use the provider's `ProfileFileCredentialsProvider` profile-files builder to point at the temp files, not process env, so tests don't race.
- [ ] **Step 2: Run** `cargo test aws::`. Expected: FAIL.
- [ ] **Step 3: Implement.** Keep `classify_sdk_error` substring-based on `DisplayErrorContext` text, plus the SDK error code (`AccessDenied`, `AccessDeniedException`, `ExpiredToken`, `InvalidClientTokenId`, `SignatureDoesNotMatch`).
- [ ] **Step 4: Run** `cargo test aws::`. Passes. Run `cargo tree -i rustls@0.21` and confirm it is still absent: no legacy TLS stack.
- [ ] **Step 5: Commit** `feat(aws): SDK config from a connection, caller identity, principal resolution`.
### Task 4: Shipped tier policies and the check table
**Files:**
- Create: `aws/policies/workspace-user.json`, `aws/policies/environment-admin.json` (repo root)
- Create: `aws/README.md` (short: what each file grants and how to attach it)
- Create: `app/src-tauri/src/aws/policies.rs`
**Interfaces:**
- Produces:
```rust
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")]
pub enum Tier { EnvironmentAdmin, WorkspaceUser }
pub fn policy_json(tier: Tier) -> &'static str // include_str!("../../../../aws/policies/<file>")
pub struct Check {
pub action: &'static str, // "ecs:RunTask"
pub resource: &'static str, // template; {region} {account} substituted
pub context: &'static [(&'static str, ContextValue)],// e.g. ("aws:RequestTag/triple-c:owner", OwnerId)
pub write: bool,
}
pub enum ContextValue { OwnerId, Literal(&'static str) }
pub fn checks(tier: Tier) -> &'static [Check]
```
**Workspace-user policy.** These statements follow spec "Multi-user accounts":
- `ecs:RunTask` on `arn:aws:ecs:*:*:task-definition/triple-c-*`, with `aws:RequestTag/triple-c:owner = ${aws:userid}`;
- `ecs:TagResource` on create;
- `iam:PassRole` on `arn:aws:iam::*:role/triple-c-*`, with `iam:ResourceTag/triple-c:owner = ${aws:userid}` and `iam:PassedToService = ecs-tasks.amazonaws.com`;
- `ecs:StopTask` and `ecs:ExecuteCommand` on `arn:aws:ecs:*:*:task/triple-c-*/*`, with `aws:ResourceTag/triple-c:owner = ${aws:userid}`;
- `ssm:StartSession` on `arn:aws:ecs:*:*:task/triple-c-*/*` (with the same tag) and on `arn:aws:ssm:*::document/AWS-StartPortForwardingSession`;
- `ssm:TerminateSession` and `ssm:ResumeSession` on `arn:aws:ssm:*:*:session/*` with `ssm:resourceTag/aws:ssmmessages:session-id = ${aws:userid}` (the AWS-documented owner condition; session IDs are prefixed by the role session name, not `aws:userid`, so an ARN pattern can't scope them);
- `secretsmanager:CreateSecret`, `TagResource`, `PutSecretValue` and `DeleteSecret` on `arn:aws:secretsmanager:*:*:secret:triple-c/*`, with the request tag (create) or resource tag (others);
- `kms:Encrypt` and `kms:GenerateDataKey` with `kms:ViaService = secretsmanager.*.amazonaws.com`;
- read-only: `ecs:Describe*`, `ecs:List*`, `ssm:GetParameter*` on `arn:aws:ssm:*:*:parameter/triple-c/*`, `cloudformation:DescribeStacks`, `cloudformation:ListStacks`, `secretsmanager:ListSecrets`, `elasticfilesystem:Describe*`, `logs:GetLogEvents` and `logs:FilterLogEvents` on `log-group:/triple-c/*`, and `sts:GetCallerIdentity`.
**Environment-admin policy:**
- `cloudformation:*` on `arn:aws:cloudformation:*:*:stack/triple-c-*/*`;
- `iam:CreateRole`, `DeleteRole`, `PutRolePolicy`, `DeleteRolePolicy`, `AttachRolePolicy`, `DetachRolePolicy`, `TagRole`, `GetRole` and `PassRole` on `role/triple-c-*`;
- `ecs:CreateCluster`, `DeleteCluster`, `RegisterTaskDefinition`, `DeregisterTaskDefinition`, `PutClusterCapacityProviders`, `CreateCapacityProvider` and `DeleteCapacityProvider`;
- `ec2:CreateSecurityGroup`, `DeleteSecurityGroup`, `AuthorizeSecurityGroup*`, `RevokeSecurityGroup*`, `CreateTags` and `Describe*`;
- `elasticfilesystem:CreateFileSystem`, `DeleteFileSystem`, `CreateMountTarget`, `DeleteMountTarget`, `PutFileSystemPolicy` and `TagResource`;
- `kms:CreateKey`, `ScheduleKeyDeletion`, `CreateAlias`, `DeleteAlias`, `PutKeyPolicy` and `TagResource`;
- `logs:CreateLogGroup`, `DeleteLogGroup` and `PutRetentionPolicy` on `/triple-c/*`;
- `ecr:CreateRepository`, `DeleteRepository` and `CreatePullThroughCacheRule`;
- `ssm:PutParameter` and `DeleteParameter` on `parameter/triple-c/*`;
- `iam:SimulatePrincipalPolicy` on `*`.
The check table holds one representative `Check` per action. Each resource template is a concrete name inside the pattern, for example `arn:aws:ecs:{region}:{account}:task-definition/triple-c-probe:1`. Wildcard actions like `ecs:Describe*` are checked as one concrete action, `ecs:DescribeClusters`.
- [ ] **Step 1: Write failing tests** in `policies.rs`:
- `policies_are_valid_iam_json`: each file parses. `Version == "2012-10-17"`, and every statement has `Sid`, `Effect: "Allow"`, `Action` and `Resource`.
- `every_policy_action_has_a_check_and_vice_versa`: the set of actions in the JSON (wildcard actions matched by glob against the check's action) equals the set covered by `checks(tier)`. This is the drift guard.
- `every_check_resource_matches_a_statement_resource`: after substituting `{region}`=`us-west-2` and `{account}`=`111122223333`, each check's resource glob-matches one of the `Resource` patterns of a statement that contains its action.
- `owner_scoped_actions_carry_the_owner_context`: `ecs:RunTask`, `ecs:StopTask`, `ecs:ExecuteCommand`, `ssm:StartSession` (task resource), `iam:PassRole` and `secretsmanager:CreateSecret` each have an `OwnerId` context entry with the key their statement's condition uses.
- [ ] **Step 2: Run** `cargo test aws::policies`. Expected: FAIL.
- [ ] **Step 3: Write the two JSON files and the table.** Write `aws/README.md`, and state there that statements are verified against a real account by Task 7's live test.
- [ ] **Step 4: Run** `cargo test aws::policies`. Passes.
- [ ] **Step 5: Commit** `feat(aws): shipped tier policies and the permission check table`.
### Task 5: The permission check
**Files:**
- Create: `app/src-tauri/src/aws/permissions.rs`, `app/src-tauri/src/aws/permissions/tests.rs`
**Interfaces:**
- Consumes: `Tier`, `checks`, `Check` (Task 4); `CallerIdentity`, `PrincipalArn`, `resolve_principal`, `AwsError` (Task 3).
- Produces:
```rust
#[async_trait::async_trait] // or native async fn in trait if the crate already avoids async_trait
pub trait PermissionApi: Send + Sync {
async fn resolve_principal(&self, caller_arn: &str) -> Result<String, AwsError>;
async fn simulate(&self, principal: &str, action: &str, resource: &str, context: &[(String, String)]) -> Result<bool, AwsError>;
async fn probe(&self, probe: Probe) -> Result<(), AwsError>;
}
pub enum Probe { EcsListClusters, SsmGetParametersByPath, CfnDescribeStacks, SecretsListSecrets }
pub struct SdkPermissionApi { /* iam, ecs, ssm, cfn, secrets clients */ }
impl SdkPermissionApi { pub fn new(cfg: &aws_config::SdkConfig) -> Self }
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")]
pub enum Verdict { Allowed, Denied, Unverified }
#[derive(Debug, Clone, Serialize)]
pub struct ActionResult { pub action: String, pub verdict: Verdict }
#[derive(Debug, Clone, Serialize)]
pub struct TierResult { pub tier: Tier, pub results: Vec<ActionResult> }
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")]
pub enum Method { Simulated, Probed { reason: String } }
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")]
pub enum Summary { EnvironmentAdmin, WorkspaceUser, Insufficient }
#[derive(Debug, Clone, Serialize)]
pub struct PermissionReport {
pub identity: CallerIdentity, pub region: String, pub method: Method,
pub summary: Summary, pub tiers: Vec<TierResult>, // [WorkspaceUser, EnvironmentAdmin]
}
pub async fn check_permissions(api: &dyn PermissionApi, identity: &CallerIdentity, region: &str) -> PermissionReport
```
Rules:
- **Simulated:** one `simulate` per check. Context values: `OwnerId` → `identity.user_id`, plus `aws:userid` → `identity.user_id` on every call. `EvalDecision == "allowed"` → Allowed, anything else → Denied.
- **Fallback to Probed:** if `resolve_principal` or the *first* `simulate` returns `AccessDenied`, or `principal_for_simulation` errors, run all four probes.
- A read check whose service has a probe gets that probe's result.
- A read check with no probe, and every `write: true` check, is `Unverified`.
- **Summary:** WorkspaceUser needs no `Denied` in the workspace tier. EnvironmentAdmin additionally needs no `Denied` in the admin tier. `Unverified` doesn't block a summary; the UI shows the method.
- Simulations run with bounded concurrency (4).
- [ ] **Step 1: Write failing tests** against a `FakeApi { allow: HashSet<&str>, simulate_denied: bool, get_role_denied: bool, probes_ok: HashSet<Probe> }`:
- `a_full_workspace_policy_reports_workspace_user`.
- `admin_and_workspace_policies_report_environment_admin`.
- `a_missing_action_reports_insufficient_and_names_it`: drop `iam:PassRole`. The summary is `Insufficient`, and the workspace tier lists `iam:PassRole` as `Denied`.
- `owner_context_is_sent_with_owner_scoped_checks`: the fake records contexts. `ecs:RunTask` was simulated with `aws:RequestTag/triple-c:owner` = identity.user_id.
- `a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified`.
- `a_denied_get_role_falls_back_to_probes`: the method is `Probed`, and its reason mentions `iam:GetRole`.
- `a_failed_probe_marks_its_reads_denied`.
- [ ] **Step 2: Run** `cargo test aws::permissions`. Expected: FAIL.
- [ ] **Step 3: Implement** `check_permissions` and `SdkPermissionApi`.
- `SimulatePrincipalPolicy`: set `policy_source_arn`, `action_names` = [action], `resource_arns` = [resource], and `context_entries` with `ContextKeyTypeEnum::String`. Read `evaluation_results[0].eval_decision`.
- Probes:
- `ecs list_clusters` with `max_results(1)`;
- `ssm get_parameters_by_path("/triple-c/environments")` with `max_results(1)`;
- `cfn describe_stacks` with no name, first page only;
- `secretsmanager list_secrets` with `max_results(1)`.
- [ ] **Step 4: Run** `cargo test aws::`. All pass.
- [ ] **Step 5: Commit** `feat(aws): tiered permission check with simulation and probe fallback`.
### Task 6: Tauri commands
**Files:**
- Create: `app/src-tauri/src/commands/host_commands.rs`
- Modify: `app/src-tauri/src/commands/mod.rs`, `app/src-tauri/src/lib.rs` (`generate_handler!`), `app/src-tauri/capabilities/default.json` (or the census location CLAUDE.md names), and `app/src/lib/tauri-commands.ts`, plus the `PermissionReport`, `CallerIdentity` and `AwsError` TS types in `types.ts`
**Interfaces:**
- Consumes: Tasks 1–5.
- Produces these commands. TS wrappers are camelCase with the same arguments.
```rust
add_aws_profile_connection(name: String, profile: String, default_region: String) -> Result<HostConnection, AwsError>
add_aws_keys_connection(name: String, access_key_id: String, secret_access_key: String, session_token: Option<String>, default_region: String) -> Result<HostConnection, AwsError>
update_aws_connection(id: String, name: String, default_region: String) -> Result<HostConnection, AwsError>
remove_host_connection(id: String) -> Result<AppSettings, String>
aws_connection_identity(id: String) -> Result<CallerIdentity, AwsError>
aws_connection_sso_login(id: String) -> Result<(), String> // Profile only; wraps aws_commands::run_sso_login
check_aws_permissions(id: String, region: Option<String>) -> Result<PermissionReport, AwsError> // None → default_region
aws_policy_document(tier: Tier) -> String
```
- **Add (both kinds):** validate → call `GetCallerIdentity` with the new credentials → only on success store the keys and save settings. Return `AwsError`, and save nothing, if the identity call fails.
- Exception: `SsoExpired` for a profile still saves. The UI then shows "Sign in".
- **Keys:** if saving settings fails after storing the keys, delete the keyring entry.
- **Remove:** delete the keyring entry first; a missing entry is fine. Then drop the connection from settings.
- [ ] **Step 1: Write failing tests** for the pure parts, in `host_commands.rs` tests:
- `adding_saves_nothing_when_identity_fails`: inject an identity fn returning `InvalidCredentials`. Afterwards there are no settings changes and no store call. Structure the commands as thin wrappers over `fn add_connection_with(…, identity: impl AsyncFn, store: impl Fn, settings: &SettingsStore)` so this is testable without AWS.
- `an_sso_expired_profile_is_still_saved`.
- `keys_are_removed_if_saving_settings_fails`.
- `capabilities.test.ts` (existing) still passes with the new commands granted.
- [ ] **Step 2: Run them.** Expected: FAIL.
- [ ] **Step 3: Implement and register the commands.** Add the TS wrappers and types.
- [ ] **Step 4: Run** `cargo test host_commands`, `cd app && npx vitest run src/test/capabilities.test.ts`, and `npx tsc --noEmit`. All pass.
- [ ] **Step 5: Commit** `feat(aws): Tauri commands for AWS connections and permission checks`.
### Task 7: Settings → Hosts UI
**Files:**
- Create: `app/src/components/settings/HostsSettings.tsx`, `app/src/components/settings/HostsSettings.test.tsx`
- Modify: `app/src/components/settings/SettingsPanel.tsx` (new `<AccordionSection id="hosts" title="Hosts" defaultOpen={false}>` after "Backends")
**Interfaces:**
- Consumes: the Task 6 TS wrappers and types; `listAwsProfiles` (existing); `useSettings().setAppSettings`.
Behaviour:
- **List.** "Local Docker" is always first and can't be removed. Each AWS connection shows its name, `Profile: <name>` or `Access keys`, and its default region, with **Check**, **Edit** and **Remove** buttons.
- **Add AWS connection form:**
- name;
- auth (segmented: Profile | Access keys). Profile is a dropdown from `listAwsProfiles()`; Access keys are three password-type inputs, the session token optional;
- default region (a text input with a datalist of common regions).
- Saving shows "Signed in as `<arn>`" on success, or the error. `sso_expired` shows a **Sign in** button that calls `awsConnectionSsoLogin` and then retries.
- **Check.** Runs `checkAwsPermissions`, then shows:
- the summary as a badge (Environment admin / Workspace user / Insufficient);
- when the method is `probed`, a note: "Simulation not permitted (<reason>). Read access was probed; write actions are unverified and will be confirmed on first use.";
- a collapsible per-tier list of the denied and unverified actions;
- **Copy workspace-user policy** and **Copy admin policy** buttons (`awsPolicyDocument` → clipboard);
- a one-line caveat that the simulation doesn't account for SCPs, permission boundaries or session policies.
- **Remove** asks for confirmation first.
- [ ] **Step 1: Write failing tests** in `HostsSettings.test.tsx`, mocking `@tauri-apps/api/core` `invoke` as the existing `MarketplaceSettings.test.tsx` does:
- `shows Local Docker and existing AWS connections`.
- `adds a profile connection and shows the signed-in identity`.
- `offers Sign in when the profile's SSO session expired`.
- `access key inputs are password fields and the session token is optional`.
- `check shows Insufficient with the denied actions and the copy-policy buttons`.
- `a probed check says writes are unverified`.
- [ ] **Step 2: Run** `cd app && npx vitest run src/components/settings/HostsSettings.test.tsx`. Expected: FAIL.
- [ ] **Step 3: Implement**, following `MarketplaceSettings.tsx` for structure and styling.
- [ ] **Step 4: Run** `npx vitest run` (the whole suite) and `npx tsc --noEmit`. Both pass.
- [ ] **Step 5: Commit** `feat(aws): Settings → Hosts with AWS connections and permission check`.
### Task 8: Live verification against a real account, and docs
**Files:**
- Create: `app/src-tauri/src/aws/live_tests.rs` (`#[ignore]`, env-gated like `host/ssm/live_tests.rs`)
- Modify: `CLAUDE.md` (a new `aws/` module paragraph next to `host/`), `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md` (mark phase 3 done and record the verification results)
The live tests need `TRIPLEC_AWS_E2E_PROFILE` (admin creds for a disposable account) and `TRIPLEC_AWS_E2E_REGION`. Each test creates IAM roles named `triple-c-e2e-<uuid>`, each assumable by the caller's account, with the policy under test inline, and deletes them in a guard on exit. IAM is free.
- [ ] **Step 1: Write the ignored tests:**
- `a_role_with_the_workspace_policy_checks_as_workspace_user`: create the role with `workspace-user.json`, tag it `triple-c:owner=<simulated userid>`, and run `check_permissions` with an `SdkPermissionApi` whose principal is that role ARN. Expect `WorkspaceUser`, with no `Denied` in the workspace tier.
- `a_role_with_both_policies_checks_as_environment_admin`.
- `a_role_with_no_policy_checks_as_insufficient_and_lists_every_action`.
- `a_role_without_simulate_permission_falls_back_to_probes`: assume the role (STS `AssumeRole`) and run the full `check_permissions` *as* it.
- `the_owner_condition_denies_another_owners_task`: simulate `ecs:StopTask` with `aws:ResourceTag/triple-c:owner` = a different id, and expect Denied. This is the spec's "most important rule".
- [ ] **Step 2: Run them** with fresh credentials from the user: `cargo test aws::live_tests -- --ignored --test-threads=1`. All pass. Fix the policy JSON if any statement doesn't evaluate as the spec intends, and re-run Task 4's tests.
- [ ] **Step 3: Confirm no `triple-c-e2e-*` roles remain:** `aws iam list-roles --query "Roles[?starts_with(RoleName,'triple-c-e2e-')]"` returns `[]`.
- [ ] **Step 4: Update the docs** and commit `test(aws): live IAM verification of the tier policies; docs`.
@@ -0,0 +1,492 @@
# 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 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:**
- **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-<account>-<region>`. 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-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";
- "Multi-user accounts" (strict tenancy);
- "Shipped AWS artefacts";
- "Phase 3: policy verification and rulings".
**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 (user, 2026-11-14)
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.
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-*`.
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/<env>/terraform.tfstate`;
- `users/<env>/<ownerkey>/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 `<workdir>/apply.log` and its PID in `<workdir>/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/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/<env>`.
- **Owner key:** the first 12 hex characters of `sha256(aws:userid)`. A `UserId` contains `:`, which IAM role names reject.
- **Per-user roles:** `triple-c-<env>-<ownerkey>-task` and `triple-c-<env>-<ownerkey>-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-<account>-<region>`. 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=<env>`, `triple-c:template-version=<n>`;
- onboarding: `triple-c:component=user-onboarding`, `triple-c:environment`, `triple-c:owner=<owner_id>`, `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:<partition>:iam::<account>: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::<account>: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=<name>`.
- 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. **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 module and `aws/tofu_modules.rs`
**Files:**
- 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:
```rust
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<String, String>;
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<String>, assign_public_ip: bool } }
pub fn environment_vars(env: &str, region: &str, network: &NetworkChoice) -> Result<serde_json::Value, String>; // tfvars JSON; validates vpc-/subnet- ids, exactly 2 subnets
```
- 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-<env>`, with `aws_ecs_cluster_capacity_providers` for FARGATE and FARGATE_SPOT;
- the security group `triple-c-<env>-tasks`, with egress set to all and no ingress;
- the KMS key and the alias `alias/triple-c-<env>`;
- the log group `/triple-c/<env>`;
- 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-<env>-<key>-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 when `bedrock_on_task_role` is set.
- **The execution role** `triple-c-<env>-<key>-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.<region>.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-<env>-<key>-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`.
- `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 modules; CloudFormation leaves the policies
**Files:**
- 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<PolicyFile>`), `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`'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)]; // replaces policy_json
#[tauri::command] pub fn aws_policy_document(tier: Tier) -> Vec<PolicyFile>; // PolicyFile { name: String, json: String }
```
- `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_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: 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: The OpenTofu runner (`aws/tofu.rs`)
**Files:**
- 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
pub const TOFU_VERSION: &str = "<same as Task 1>";
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<PathBuf, AwsError>; // <root>/tofu/<ver>/tofu[.exe]; downloads from github.com/opentofu/opentofu/releases/download/v<ver>/<file>, 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<PathBuf, AwsError>; // 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<Workdir, AwsError>; // <root>/work/<bucket>/<state_key without .tfstate>/; 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<u32 /*pid*/, AwsError>; // 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<String>, pub action: Option<String>, pub message: String }
pub fn parse_log_line(line: &str) -> Option<TofuEvent>; // 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<TofuOutcome, AwsError>; // from byte 0, each line once, until apply.exit exists
pub struct TofuOutcome { pub ok: bool, pub exit_code: i32, pub summary: Option<String> }
```
- Detaching: the runner is the app binary itself, re-executed with a hidden `--tofu-runner <workdir> <op>` 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:**
- `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: 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: `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<String>, 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<EnvironmentInfo, String>;
#[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 { lock: Option<LockInfo> }, UpdateAvailable, Incomplete { lock: Option<LockInfo> }, 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, Incomplete }
#[derive(Serialize, Clone, Debug, PartialEq)] pub struct DetectedEnvironment { name, template_version: Option<u32>, state: EnvironmentState, actions: Vec<EnvironmentAction>, info: Option<EnvironmentInfo>, onboarding: Option<OnboardingState> }
#[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<DetectedEnvironment> }
pub struct StateObjects { pub states: Vec<String> /* keys */, pub locks: Vec<(String, String /*lock json*/, u64 /*age*/)> }
pub fn merge(records: Vec<(String, Result<EnvironmentInfo, String>)>, objects: Option<&StateObjects>, running_locally: &dyn Fn(&str) -> bool, current_version: u32) -> Vec<DetectedEnvironment>;
#[async_trait] pub trait DetectApi: Send + Sync {
async fn environment_records(&self) -> Result<Vec<(String, String)>, AwsError>;
async fn state_objects(&self, bucket: &str) -> Result<Option<StateObjects>, AwsError>; // None = bucket missing or denied
async fn bucket_exists(&self, bucket: &str) -> Result<Knowable, AwsError>;
async fn boundary_exists(&self, account: &str) -> Result<Knowable, AwsError>;
async fn role_exists(&self, name: &str) -> Result<Knowable, AwsError>;
}
pub async fn scan<D: DetectApi>(d: &D, who: &CallerIdentity, region: &str, running_locally: &dyn Fn(&str) -> bool) -> Result<EnvironmentScan, AwsError>;
pub async fn ensure_state_bucket(cfg: &SdkConfig, account: &str, region: &str) -> Result<String, AwsError>;
```
- `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:
- 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 reason names the field and never echoes the value.
- [ ] **Step 1: Write the failing tests:**
- `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**, 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): state bucket, and detection of environments, locks and onboarding`.
### Task 6: Commands, progress events and admin bundles
**Files:**
- Create: `app/src-tauri/src/commands/environment_commands.rs`
- 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). The work root is `dirs::data_dir()/triple-c/aws/`. An `op_id` is a UUID.
```rust
detect_aws_environments(connection_id, region) -> Result<EnvironmentScan, AwsError>
deploy_aws_environment(connection_id, region, name, network: NetworkChoice, app) -> Result<String, AwsError> // 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<String, AwsError> // 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<NetworkChoice>, app) -> Result<String, AwsError> // re-apply an Incomplete one; network required if no record
destroy_incomplete_aws_environment(connection_id, region, name, app) -> Result<String, AwsError> // 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<String, AwsError>
cancel_aws_deployment(connection_id, region, state_key) -> Result<(), AwsError> // tofu::interrupt
onboard_aws_user(connection_id, region, environment, app) -> Result<String, AwsError> // self-onboarding with the caller's own owner id
deploy_aws_permissions_boundary(connection_id, region, app) -> Result<String, AwsError>
save_aws_admin_bundle(connection_id, region, kind: ModuleKind, environment: Option<String>, owner_id: Option<String>, window) -> Result<Option<String>, AwsError> // Rust-side folder dialog; writes D5 bundle; returns the folder path
```
- 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 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, 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 OpenTofu progress`.
### Task 7: Environments UI
**Files:**
- Create: `app/src/components/settings/hosts/{EnvironmentsPanel,DeployEnvironmentModal,AdminBundleModal,DeployProgress}.tsx`, plus tests for the first three
- Modify: `HostsSettings.tsx`, which renders `<EnvironmentsPanel connection={c} tier={lastReport?.summary} />`
**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: 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 the failing tests:**
- `shows deploy as primary when nothing is found and the user is an admin`.
- `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`.
- `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 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, repair, join`.
### Task 8: Live verification, docs and spec
**Files:**
- 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 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<rand>` 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 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`.
@@ -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,55 @@ 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), 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
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-<account>-<region>`: `environments/<env>/terraform.tfstate`,
`users/<env>/<ownerkey>/terraform.tfstate`, `boundary/terraform.tfstate`. The owner key is the
first 12 hex characters of `sha256("<region>:<aws:userid>")` and also names the user's roles
(`triple-c-<env>-<ownerkey>-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 session, output to
`<workdir>/apply.log`, PID in `<workdir>/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)
- Kubernetes/EKS, other clouds, or remote Docker hosts. The host abstraction below allows them
@@ -83,40 +132,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** `<name>` (default) or **Deploy a new one** (for example a separate region or cost boundary) |
| One or more | No | **Join** `<name>` |
**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 +181,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 |
@@ -137,8 +194,11 @@ How the check works:
probes (`ecs:DescribeClusters`, `ssm:GetParameter`, `secretsmanager:ListSecrets` filtered by
tag, `elasticfilesystem:DescribeAccessPoints`). Write actions are then marked **"unverified —
will be confirmed on first use"**, not "OK".
3. Be honest about the gaps: the simulation does not apply SCPs, permission boundaries or session
policies. The first real create is the final check, and its error must name the denied action.
3. Be honest about the gaps: the simulation does not apply SCPs or session policies (it does
apply the principal's own permissions boundary — verified 2026-11-14). The first real create is
the final check, and its error must name the denied action.
4. A probed run never claims more than **Workspace user**: probes see reads, and the admin tier is
nearly all writes.
The exact policies for each tier ship as files with the app (`aws/policies/*.json`). Settings
links to them so an admin can grant them without reading this doc.
@@ -267,7 +327,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.
@@ -280,9 +340,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/<owner>/<project-id>`, 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
@@ -387,7 +448,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. 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
@@ -395,8 +456,8 @@ user, and the account is already safe to share.
Strict tenancy's per-user `iam:PassRole` is scoped by a tag on the role
(`iam:ResourceTag/triple-c:owner = ${aws:userid}`), not by role name, because `aws:userid`
contains a `:`, which role names don't allow. **Verify** that `iam:PassRole` evaluates
`iam:ResourceTag` as expected before release.
contains a `:`, which role names don't allow. That `iam:PassRole` evaluates `iam:ResourceTag` this
way is verified in the simulator (phase 3); confirm it on the first real `RunTask` (phase 5).
Why EC2 privileged mode needs dedicated capacity: a privileged container, or one with the Docker
socket, has root on its EC2 host, and so access to every other tenant's task on that host
@@ -419,7 +480,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 |
@@ -442,6 +503,29 @@ way.
entrypoint. It doesn't depend on the app being open. Because a running scheduler job counts as
activity, scheduled tasks keep a workspace alive. The admin's maximum lifetime is a hard cap
either way.
- **Health:** the task definition carries an ECS container health check against the agent, run
inside the container with the token from its tmpfs file, so the token never leaves the task:
`curl -sf --max-time 3 -H "Authorization: Bearer $(cat /dev/shm/triple-c/agent-token)"
http://127.0.0.1:47900/v1/health` (interval 30 s, retries 3, `startPeriod` 120 s). ECS reports it
as the container's and the task's `healthStatus` in `DescribeTasks`, so the same 15 s poll
shows *Starting* (`UNKNOWN`), *Ready* (`HEALTHY`) or *Unhealthy* (`UNHEALTHY`) with no
connection open. Measured 2026-11-14: `HEALTHY` 105 s after `RunTask`. The app still checks the
agent through its own tunnel before it calls a project connected.
- **A workspace outlives the app.** Nothing about a task depends on the app or its tunnel:
- Quitting or crashing the app ends only the SSM session. Idle auto-stop and the admin's
lifetime cap are what stop the task.
- The next app (the same machine or another) resolves the task and opens a new tunnel.
- Measured 2026-11-14: a background job started through one tunnel kept writing every 5 s,
with no gap, after that tunnel's processes were killed with `-9`. A fresh tunnel found it
still running, and the agent still reported its exec as running.
- What does **not** survive is an attached exec. As with `docker exec`, the agent sends
SIGHUP to a tty's process group when its client disconnects. So a terminal tab's `claude`
ends with the app, and comes back through `claude --resume`, whose transcripts live on the
workspace's storage. Keeping live terminals across an app restart (a detached pty the agent
holds for re-attach) is a possible later feature, not v1.
- If the app dies without closing its tunnels, the plugin child can outlive it. Its session
then carries no keepalive (that lives in the app) and SSM ends it within its 20-minute idle
timeout. The host still closes every tunnel on a normal exit.
- Logs: `awslogs` driver to the environment's log group, stream `<owner>/<project>/<task>`. The
app shows start logs during startup and when a start fails.
@@ -534,18 +618,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
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-<account>-<region>` (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
@@ -553,14 +642,17 @@ aws/
2. **Agent.** `triple-c-agent` in the image. Optionally use it locally too behind a flag, to
harden it before AWS depends on it.
3. **AWS connection + permission check.** Settings UI, SDK wiring, `GetCallerIdentity`, the tiered
simulation and fallback probes, policy files.
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**.
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 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.
@@ -571,12 +663,95 @@ aws/
- ABAC condition keys for each action: ECS `RunTask` tag-on-create, `ExecuteCommand`, and
`ssm:StartSession` against ECS targets; EFS `CreateAccessPoint` with request tags; Secrets
Manager name-prefix plus tag conditions with `${aws:userid}` for SSO sessions.
- That `session-manager-plugin` port forwarding to an ECS target is stable for hours-long
sessions, and how quickly it reconnects.
- Fargate image-pull time for the sandbox image, and whether SOCI indexes help.
- ~~That `session-manager-plugin` port forwarding to an ECS target is stable for hours-long
sessions, and how quickly it reconnects.~~ **Measured 2026-11-14** (phase 2b, us-west-2,
plugin 1.2.896, Fargate 1 vCPU / 2 GB, `host::ssm::live_tests`):
- Opening the port forward takes 0.6-0.7 s. A one-shot exec through it averages 35 ms, against
about 1 s for a fresh `execute-command`. A file moves at about 0.75 MB/s each way (4 MiB in
5.5 s), which is SSM's own throughput.
- When the plugin is killed, the supervisor reopens the session on the same local port in
about 0.55 s.
- **Session Manager's idle timeout applies to port forwards.** A tunnel with no traffic was cut
at 19.7 minutes ("timed out due to inactivity"), and every connection inside it went with it,
including a held-open attach (the test's stand-in for an idle terminal tab). The supervisor
reconnected at once and requests resumed, but open streams do not survive a reconnect. So an
Up tunnel sends an unauthenticated `GET /v1/health` every 5 minutes. The agent answers `401`
before it counts activity, so this doesn't defeat idle auto-stop. With it, a 44-minute hold
with 22 silent minutes had no reconnect at all, and the held-open attach survived to the end
(without it, the same attach died at the 20-minute cut).
- Over 60 minutes, no drop happened other than the idle timeout.
- Fargate image-pull time for the sandbox image, and whether SOCI indexes help. **Measured
2026-11-14:** the 1.1 GB (compressed) amd64 image pulled from public GHCR in 55 s, and the task
reached RUNNING about 75 s after `RunTask`. **The entrypoint's `chown -R /home/claude` then took
about 5.5 minutes.** On ECS, `/home/claude` is the image's own copy (no volume), so it copied the
whole tree up, also costing about 2 GB of ephemeral storage. It now chowns only what isn't
already `claude`'s, and the agent answers 83 s after `RunTask`, 4 s after the container starts.
SOCI was not tried.
- That GHCR pull-through cache works for the public image.
- That the `SIGTERM` deadline on Fargate retirement and Spot leaves enough time for a WIP push.
### Phase 3: policy verification and rulings (2026-11-14)
Rulings made while writing the tier policies, which phase 4 must build on:
- **Environment admins are fenced by a permissions boundary.** They create or change `triple-c-*`
roles only with the `triple-c-permissions-boundary` managed policy as the boundary, and cannot
remove, replace or edit it. An account administrator creates the boundary policy once.
- **Destructive admin actions are tag-scoped.** Security groups, EFS file systems and KMS keys are
created only with `triple-c:component`, and changed or deleted only if they carry it.
- **A probed permission check caps at Workspace user** (above).
- **Strict tenancy needs per-user execution roles**, not only task roles: a workspace user can pass
only roles tagged with their own owner ID.
- **Environment-level secrets carry `triple-c:component`**; workspace users can never write,
delete or retag those, and an untagged `triple-c/*` secret could be claimed by any of them.
- **User secrets use the environment KMS key, named by ARN** in `KmsKeyId`; any other key,
including `aws/secretsmanager`, is explicitly denied.
`aws/README.md` ("Live verification") has the per-statement results, and
`app/src-tauri/src/aws/live_tests.rs` repeats them. What reality changed:
- IAM refuses `${aws:PrincipalArn}` as a Resource, so self-inspection is `user/${aws:username}`
for IAM users and, for role sessions (no role-name variable exists), SSO permission-set roles
and `triple-c-*` roles only (ruling R18). Callers on other roles are probed and reported
Insufficient; an admin can add their roles to `InspectOwnPrincipal`.
- `ecs:ExecuteCommand` is also authorized against the cluster; the owner rule stays on the task.
- Secrets Manager needs `kms:Decrypt` as well as `GenerateDataKey` on a customer key.
- `elasticfilesystem:CreateFileSystem` has no resource type (`Resource: "*"`).
- The session client needs `ssmmessages:OpenDataChannel` on its own session (AWS docs).
- The tier policies must be managed policies (both together exceed a role's inline budget); the
workspace policy is close to the 6,144-character limit (about 6,110), and a unit test guards it.
- `SimulatePrincipalPolicy` throttles within one check; the app retries adaptively.
- The `aws/secretsmanager` Deny is load-bearing, but not for the reason first given: a bounded task
role *can* decrypt such a secret, because that key's policy grants the caller directly.
Left for the phases with real ECS/EFS/EC2 resources: `RunTask`'s `aws:TagKeys` (and any
`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
**Decided: C** (user, 2026-11-13). The comparison below is kept for the record.
@@ -596,7 +771,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 |