Compare commits
33
Commits
No files matched your search
@@ -66,7 +66,13 @@ env:
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
#
|
||||
# SUSPEND skips needrestart entirely. Mode `a` (restart automatically) is not
|
||||
# safe: on a host-mode runner it restarted act-runner.service mid-job and
|
||||
# killed the job. Mode `l` (list only) is a fallback for a needrestart that
|
||||
# ignores SUSPEND; it never prompts and never restarts anything.
|
||||
NEEDRESTART_SUSPEND: "1"
|
||||
NEEDRESTART_MODE: l
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
@@ -272,7 +278,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -283,8 +289,8 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
@@ -368,7 +374,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -391,8 +397,8 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
|
||||
@@ -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
|
||||
@@ -22,7 +24,13 @@ env:
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
#
|
||||
# SUSPEND skips needrestart entirely. Mode `a` (restart automatically) is not
|
||||
# safe: on a host-mode runner it restarted act-runner.service mid-job and
|
||||
# killed the job. Mode `l` (list only) is a fallback for a needrestart that
|
||||
# ignores SUSPEND; it never prompts and never restarts anything.
|
||||
NEEDRESTART_SUSPEND: "1"
|
||||
NEEDRESTART_MODE: l
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
@@ -125,7 +133,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
echo "Node.js at: $(which node)"
|
||||
@@ -147,8 +155,8 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
@@ -256,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: |
|
||||
@@ -282,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
|
||||
;;
|
||||
*)
|
||||
@@ -334,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 }}
|
||||
@@ -423,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: |
|
||||
@@ -449,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
|
||||
;;
|
||||
*)
|
||||
@@ -711,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 }}
|
||||
@@ -735,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 `
|
||||
@@ -772,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
|
||||
@@ -782,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,27 @@ 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 two tier policies and the permissions boundary)
|
||||
and the table of checks the simulator is asked; `permissions.rs` runs it
|
||||
(`SimulatePrincipalPolicy`, adaptive retries — it throttles — or read-only probes when the
|
||||
caller can't simulate itself; a probed run never claims the admin tier). `policies.rs`'s tests
|
||||
are the drift guard: every allowed action has a check, every check is allowed by a small IAM
|
||||
evaluator, owner-scoped checks are denied for someone else, admin destruction is tag-scoped,
|
||||
the boundary fences hold, every Resource is an ARN and every policy fits a 6,144-character
|
||||
managed policy. **Change a policy JSON, its checks and `aws/README.md` together.** The evaluator
|
||||
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").
|
||||
- **`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 +814,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 +939,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 +1003,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 +1037,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:
|
||||
|
||||
|
||||
Generated
+675
-25
@@ -14,7 +14,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -275,6 +275,49 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-config"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16ec023cac0d0c0432e606a2d14f04eb70d09c71a739a406141e7be6b923ab95"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-sdk-sso",
|
||||
"aws-sdk-ssooidc",
|
||||
"aws-sdk-sts",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"hex",
|
||||
"http",
|
||||
"sha1",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-credential-types"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "576e8da35751dc85409ec1cd15999f692c277a1fbe9336780fe6f15e49dd637d"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.1"
|
||||
@@ -298,6 +341,471 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-runtime"
|
||||
version = "1.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de8930c11353c72ae54aa24dc94d692df74d59814839cbaf8069d63dbcb4c519"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"bytes-utils",
|
||||
"fastrand",
|
||||
"http",
|
||||
"http-body",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-cloudformation"
|
||||
version = "1.139.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b77def03a5fb2442348afdd6ecd0eafbfa117243865cc78e899beb26edf7fb6d"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-query",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-smithy-xml",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"http",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-ecs"
|
||||
version = "1.159.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecd8f39cf3e41848314347fc3b5242cbf14291258c9b8f4f61156735627726bf"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-iam"
|
||||
version = "1.136.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f53b6d3c4f3f04b734a5ae6eea34491e5c356be99bc677fa7fd84eba0cc671b"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-query",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-smithy-xml",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"http",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-kms"
|
||||
version = "1.131.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6309b510ca62e64bafb147a08413d81665fdcaa2b607423d348c576b5d4a89"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-secretsmanager"
|
||||
version = "1.128.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d77ef7832d9e7c60fa7b8201bb2038ccc9821b6cf88746caab6dc1c8767882b5"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-ssm"
|
||||
version = "1.137.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db5dffb9ec07fac5dbbf475396fbe1a4a319ee46528b7054662c518f18d00421"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sso"
|
||||
version = "1.121.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8d414ba8f00c2b4b506893ded67827e63be88b5e6cce71b7c0ac8f96ce09bc5"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-ssooidc"
|
||||
version = "1.123.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db26187b124bcc637641f7963d5200c5ec2409ea5b7730dd7e014ef4e0ab6f19"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sts"
|
||||
version = "1.126.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c667da7839295125cfe23280f798c07ba694a9fed191af804edb4bda09ac7f10"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-query",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-smithy-xml",
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"http",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sigv4"
|
||||
version = "1.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57a4393e6addc3380632887b0998d06ad5f099ab5c478be0a3eeedecf8604161"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"hex",
|
||||
"hmac",
|
||||
"http",
|
||||
"percent-encoding",
|
||||
"sha2 0.11.1",
|
||||
"time",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-async"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http"
|
||||
version = "0.64.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
"bytes-utils",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-client"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "859a81364f8185349c584fb41cd8765a400cf982f447adada590cf008a9f3b85"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"h2",
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-json"
|
||||
version = "0.65.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b45f9fc77ffe92c92c341165a01a565bd30c39ecf5aa8c0719b41719009604f9"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-observability"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a416257d7b1b0f32a517a470a8c1dd354d8c4aa697ccab81a11a1e6cfb7418e3"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-query"
|
||||
version = "0.64.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "165f6265342a313ad6f7a1ac17a0cdba30c522039d3f41d8d6871ce8a2b34ecd"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"aws-smithy-xml",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime"
|
||||
version = "1.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bc688673e282f42d3152dcd9cacc1a9a75734d7cd39d02e58989276fc750f3c"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
"aws-smithy-http-client",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api"
|
||||
version = "1.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0467a2414e84b1b01e37452896fb3562b93926e81c619eba6ecf27ce9e682a0e"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api-macros",
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
"http",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api-macros"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce237f890a2283c9b328955c3b0918d2d5bf66a1cb6ceccd36a0047246fbc6e6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-schema"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7ef30d39e131651359b60a1c560de9a9502a4c3754172ab2d6e8734fe21bcba"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "1.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aff4bf014c512840b2bba80a20417b723cbd6a0a65c02e2d81f50b4268aedfc7"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
"bytes-utils",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"ryu",
|
||||
"serde",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-xml"
|
||||
version = "0.64.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "590fdc1a70546b2f7f2061c42eaf173fb6f26d935330d069edc04dbd8f01d924"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"xmlparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-types"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba0d970d3947bb064d2ae1d823efa186c1dbde88f88afa8f6549946e5d8deee"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
"aws-smithy-types",
|
||||
"rustc_version",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
@@ -371,6 +879,16 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64-simd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195"
|
||||
dependencies = [
|
||||
"outref",
|
||||
"vsimd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
@@ -413,7 +931,7 @@ version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -425,6 +943,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
@@ -556,6 +1083,16 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytes-utils"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.18.5"
|
||||
@@ -705,7 +1242,7 @@ version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"inout",
|
||||
]
|
||||
|
||||
@@ -727,6 +1264,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -746,6 +1289,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be95d4a884358a21a199689323c29e9256b4a1b315e22f183d155422041d3a9a"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -871,6 +1420,15 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.29.6"
|
||||
@@ -936,6 +1494,15 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
@@ -1158,11 +1725,23 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
@@ -1387,7 +1966,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2887,6 +3466,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@@ -2954,6 +3542,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@@ -3002,6 +3599,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
@@ -4158,6 +4756,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -4742,7 +5346,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4965,6 +5569,12 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
@@ -5118,7 +5728,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5176,7 +5786,7 @@ dependencies = [
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5369,9 +5979,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
@@ -5391,22 +6001,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5565,7 +6175,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5582,7 +6192,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6013,7 +6634,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"syn 2.0.117",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
@@ -6227,10 +6848,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6615,6 +7236,17 @@ version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"aws-config",
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-sdk-cloudformation",
|
||||
"aws-sdk-ecs",
|
||||
"aws-sdk-iam",
|
||||
"aws-sdk-kms",
|
||||
"aws-sdk-secretsmanager",
|
||||
"aws-sdk-ssm",
|
||||
"aws-sdk-sts",
|
||||
"aws-smithy-async",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bollard",
|
||||
@@ -6633,7 +7265,7 @@ dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"similar",
|
||||
"tar",
|
||||
"tauri",
|
||||
@@ -6783,7 +7415,7 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -6806,6 +7438,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
@@ -6854,6 +7492,12 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vsimd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
@@ -7181,7 +7825,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7745,7 +8389,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"raw-window-handle",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"soup3",
|
||||
"tao-macros",
|
||||
"thiserror 2.0.18",
|
||||
@@ -7790,6 +8434,12 @@ dependencies = [
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmlparser"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
|
||||
@@ -55,6 +55,25 @@ 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-cloudformation = { version = "1.139.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
aws-sdk-secretsmanager = { version = "1.128.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
|
||||
# Named by src/aws/connection.rs: the credentials-provider trait (its profile
|
||||
# wrapper) and the profile-file set (aws-config's alias for it is deprecated).
|
||||
# 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"] }
|
||||
|
||||
# Linux only: WebKitGTK refuses getUserMedia until the embedder enables media
|
||||
# streams and answers the permission request (see src/linux_media.rs). Same
|
||||
@@ -67,6 +86,9 @@ 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"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -57,6 +57,14 @@
|
||||
"allow-detect-aws-config",
|
||||
"allow-inspect-ca-cert-path",
|
||||
"allow-list-aws-profiles",
|
||||
"allow-add-aws-profile-connection",
|
||||
"allow-add-aws-keys-connection",
|
||||
"allow-update-aws-connection",
|
||||
"allow-remove-host-connection",
|
||||
"allow-aws-connection-identity",
|
||||
"allow-aws-connection-sso-login",
|
||||
"allow-check-aws-permissions",
|
||||
"allow-aws-policy-document",
|
||||
"allow-detect-host-timezone",
|
||||
"allow-export-settings",
|
||||
"allow-preview-settings-import",
|
||||
|
||||
@@ -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-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","descriptionLine truncated
|
||||
@@ -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",
|
||||
@@ -428,6 +458,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",
|
||||
@@ -866,6 +902,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",
|
||||
@@ -1082,6 +1124,12 @@
|
||||
"const": "allow-uninstall-marketplace-item",
|
||||
"markdownDescription": "Enables the uninstall_marketplace_item 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_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1160,6 +1208,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 +1262,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",
|
||||
@@ -1238,6 +1316,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",
|
||||
@@ -1676,6 +1760,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",
|
||||
@@ -1892,6 +1982,12 @@
|
||||
"const": "deny-uninstall-marketplace-item",
|
||||
"markdownDescription": "Denies the uninstall_marketplace_item 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_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -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",
|
||||
@@ -428,6 +458,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",
|
||||
@@ -866,6 +902,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",
|
||||
@@ -1082,6 +1124,12 @@
|
||||
"const": "allow-uninstall-marketplace-item",
|
||||
"markdownDescription": "Enables the uninstall_marketplace_item 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_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1160,6 +1208,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 +1262,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",
|
||||
@@ -1238,6 +1316,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",
|
||||
@@ -1676,6 +1760,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",
|
||||
@@ -1892,6 +1982,12 @@
|
||||
"const": "deny-uninstall-marketplace-item",
|
||||
"markdownDescription": "Denies the uninstall_marketplace_item 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_marketplace command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! 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,
|
||||
},
|
||||
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::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 }
|
||||
}
|
||||
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
//! The tier policies against real IAM: `check_permissions` run against
|
||||
//! throwaway roles that hold the shipped policies, and the few statements
|
||||
//! whose behaviour only a real request shows (Secrets Manager's KMS calls,
|
||||
//! the permissions boundary, the admin tier's role and key fences).
|
||||
//!
|
||||
//! Ignored; needs credentials for a **disposable** account that may create
|
||||
//! and delete IAM roles and policies, KMS keys and Secrets Manager secrets:
|
||||
//!
|
||||
//! - `TRIPLEC_AWS_E2E_PROFILE` — a profile in the AWS config/credentials
|
||||
//! files (read the way a saved connection reads it, so `AWS_*` key
|
||||
//! variables are not used; `AWS_SHARED_CREDENTIALS_FILE` and
|
||||
//! `AWS_CONFIG_FILE` still choose the files);
|
||||
//! - `TRIPLEC_AWS_E2E_REGION` — e.g. `us-west-2`.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cd app/src-tauri
|
||||
//! TRIPLEC_AWS_E2E_PROFILE=… TRIPLEC_AWS_E2E_REGION=us-west-2 \
|
||||
//! cargo test aws::live_tests -- --ignored --test-threads=1
|
||||
//! ```
|
||||
//!
|
||||
//! Every resource is named `triple-c-e2e-<id>…` (secrets
|
||||
//! `triple-c/triple-c-e2e-<id>/…`, because the policies only cover
|
||||
//! `triple-c/*`), tagged `triple-c-test=phase3`, and deleted when the test
|
||||
//! ends, pass or fail. KMS keys can only be scheduled for deletion (7 days).
|
||||
//! IAM is free; a run costs a few cents at most (secrets for minutes, keys
|
||||
//! pending deletion).
|
||||
|
||||
use std::future::Future;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use aws_config::SdkConfig;
|
||||
use futures_util::FutureExt;
|
||||
|
||||
use super::error::{classify_sdk_error, AwsError};
|
||||
use super::permissions::{
|
||||
check_permissions, simulation_request, Method, PermissionApi, PermissionReport,
|
||||
SdkPermissionApi, Summary, Verdict,
|
||||
};
|
||||
use super::policies::{
|
||||
checks, permissions_boundary_json, policy_json, Placeholders, Tier, PERMISSIONS_BOUNDARY_NAME,
|
||||
};
|
||||
use super::{caller_identity, sdk_config, sdk_config_for_keys, CallerIdentity};
|
||||
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
|
||||
use crate::storage::secure::AwsKeys;
|
||||
|
||||
const TEST_TAG: (&str, &str) = ("triple-c-test", "phase3");
|
||||
const PATH: &str = "/triple-c-e2e/";
|
||||
const SESSION: &str = "triple-c-e2e";
|
||||
|
||||
fn var(name: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set (see this file's header)"))
|
||||
}
|
||||
|
||||
/// What one test created, for the guard to delete.
|
||||
#[derive(Default)]
|
||||
struct Created {
|
||||
roles: Vec<String>,
|
||||
policies: Vec<String>,
|
||||
secrets: Vec<String>,
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
struct E2e {
|
||||
cfg: SdkConfig,
|
||||
iam: aws_sdk_iam::Client,
|
||||
sts: aws_sdk_sts::Client,
|
||||
secrets: aws_sdk_secretsmanager::Client,
|
||||
kms: aws_sdk_kms::Client,
|
||||
region: String,
|
||||
account: String,
|
||||
/// `triple-c-e2e-<random>`: the prefix of everything this test creates.
|
||||
prefix: String,
|
||||
created: Mutex<Created>,
|
||||
}
|
||||
|
||||
/// A role this test created.
|
||||
struct Role {
|
||||
name: String,
|
||||
arn: String,
|
||||
/// `RoleId`: an assumed session's `aws:userid` is `<id>:<session>`.
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
fn session_user_id(&self) -> String {
|
||||
format!("{}:{SESSION}", self.id)
|
||||
}
|
||||
|
||||
/// The identity `check_permissions` sees for the role itself (not a
|
||||
/// session of it): an IAM role ARN, simulated as-is.
|
||||
fn identity(&self, account: &str) -> CallerIdentity {
|
||||
CallerIdentity {
|
||||
account: account.to_string(),
|
||||
arn: self.arn.clone(),
|
||||
user_id: self.session_user_id(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `body` with a fresh fixture and deletes what it created afterwards,
|
||||
/// whether or not `body` panicked.
|
||||
async fn e2e<F, Fut>(body: F)
|
||||
where
|
||||
F: FnOnce(Arc<E2e>) -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
let region = var("TRIPLEC_AWS_E2E_REGION");
|
||||
let conn = HostConnection {
|
||||
id: "e2e".into(),
|
||||
name: "e2e".into(),
|
||||
kind: HostKind::Aws {
|
||||
auth: AwsAuth::Profile {
|
||||
name: var("TRIPLEC_AWS_E2E_PROFILE"),
|
||||
},
|
||||
default_region: region.clone(),
|
||||
},
|
||||
};
|
||||
let cfg = sdk_config(&conn, ®ion).await.expect("sdk config");
|
||||
let me = caller_identity(&cfg)
|
||||
.await
|
||||
.expect("the e2e profile's identity");
|
||||
let e = Arc::new(E2e {
|
||||
iam: aws_sdk_iam::Client::new(&cfg),
|
||||
sts: aws_sdk_sts::Client::new(&cfg),
|
||||
secrets: aws_sdk_secretsmanager::Client::new(&cfg),
|
||||
kms: aws_sdk_kms::Client::new(&cfg),
|
||||
cfg,
|
||||
region,
|
||||
account: me.account,
|
||||
prefix: format!(
|
||||
"triple-c-e2e-{}",
|
||||
&uuid::Uuid::new_v4().simple().to_string()[..12]
|
||||
),
|
||||
created: Mutex::default(),
|
||||
});
|
||||
let result = AssertUnwindSafe(body(e.clone())).catch_unwind().await;
|
||||
e.cleanup().await;
|
||||
if let Err(panic) = result {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
|
||||
fn iam_tag(k: &str, v: &str) -> aws_sdk_iam::types::Tag {
|
||||
aws_sdk_iam::types::Tag::builder()
|
||||
.key(k)
|
||||
.value(v)
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn sm_tag(k: &str, v: &str) -> aws_sdk_secretsmanager::types::Tag {
|
||||
aws_sdk_secretsmanager::types::Tag::builder()
|
||||
.key(k)
|
||||
.value(v)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// `Err(AccessDenied)` for a denied call; panics on anything else.
|
||||
fn denied<T: std::fmt::Debug, E: std::error::Error + 'static>(what: &str, r: Result<T, E>) {
|
||||
match r {
|
||||
Err(e) => match classify_sdk_error(what, &e) {
|
||||
AwsError::AccessDenied { .. } => {}
|
||||
other => panic!("{what}: expected AccessDenied, got {other}"),
|
||||
},
|
||||
Ok(v) => panic!("{what}: expected AccessDenied, but it succeeded: {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok<T, E: std::error::Error + 'static>(what: &str, r: Result<T, E>) -> T {
|
||||
r.unwrap_or_else(|e| panic!("{what}: {}", classify_sdk_error(what, &e)))
|
||||
}
|
||||
|
||||
impl E2e {
|
||||
fn name(&self, suffix: &str) -> String {
|
||||
format!("{}-{suffix}", self.prefix)
|
||||
}
|
||||
|
||||
fn secret_name(&self, suffix: &str) -> String {
|
||||
format!("triple-c/{}/{suffix}", self.prefix)
|
||||
}
|
||||
|
||||
/// A role assumable by this account, under `/triple-c-e2e/` (so its ARN
|
||||
/// has a path, as an SSO role's does), optionally bounded.
|
||||
async fn role(&self, suffix: &str, boundary: Option<&str>, tags: &[(&str, &str)]) -> Role {
|
||||
let name = self.name(suffix);
|
||||
let trust = format!(
|
||||
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{}:root"}},"Action":"sts:AssumeRole"}}]}}"#,
|
||||
self.account
|
||||
);
|
||||
let mut req = self
|
||||
.iam
|
||||
.create_role()
|
||||
.role_name(&name)
|
||||
.path(PATH)
|
||||
.assume_role_policy_document(trust)
|
||||
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.set_permissions_boundary(boundary.map(str::to_string));
|
||||
for (k, v) in tags {
|
||||
req = req.tags(iam_tag(k, v));
|
||||
}
|
||||
let out = ok("iam:CreateRole", req.send().await);
|
||||
self.created.lock().unwrap().roles.push(name.clone());
|
||||
let r = out.role().unwrap();
|
||||
Role {
|
||||
name,
|
||||
arn: r.arn().to_string(),
|
||||
id: r.role_id().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn tag_role(&self, role: &Role, k: &str, v: &str) {
|
||||
ok(
|
||||
"iam:TagRole",
|
||||
self.iam
|
||||
.tag_role()
|
||||
.role_name(&role.name)
|
||||
.tags(iam_tag(k, v))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
/// A customer managed policy (the tier policies are too big to hold
|
||||
/// both inline on one role).
|
||||
async fn managed_policy(&self, suffix: &str, document: &str) -> String {
|
||||
let out = ok(
|
||||
"iam:CreatePolicy",
|
||||
self.iam
|
||||
.create_policy()
|
||||
.policy_name(self.name(suffix))
|
||||
.policy_document(document)
|
||||
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
let arn = out.policy().unwrap().arn().unwrap().to_string();
|
||||
self.created.lock().unwrap().policies.push(arn.clone());
|
||||
arn
|
||||
}
|
||||
|
||||
async fn attach(&self, role: &Role, policy_arn: &str) {
|
||||
ok(
|
||||
"iam:AttachRolePolicy",
|
||||
self.iam
|
||||
.attach_role_policy()
|
||||
.role_name(&role.name)
|
||||
.policy_arn(policy_arn)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
async fn put_inline(&self, role: &Role, document: &str) {
|
||||
ok(
|
||||
"iam:PutRolePolicy",
|
||||
self.iam
|
||||
.put_role_policy()
|
||||
.role_name(&role.name)
|
||||
.policy_name("under-test")
|
||||
.policy_document(document)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
/// A role holding the given tier policies, tagged as owned by its own
|
||||
/// sessions (the strict-tenancy task role shape `iam:PassRole` checks).
|
||||
async fn tier_role(&self, suffix: &str, tiers: &[Tier]) -> Role {
|
||||
let role = self.role(suffix, None, &[]).await;
|
||||
self.tag_role(&role, "triple-c:owner", &role.session_user_id())
|
||||
.await;
|
||||
for &tier in tiers {
|
||||
let name = match tier {
|
||||
Tier::WorkspaceUser => "workspace",
|
||||
Tier::EnvironmentAdmin => "admin",
|
||||
};
|
||||
let arn = self
|
||||
.managed_policy(&format!("{suffix}-{name}"), policy_json(tier))
|
||||
.await;
|
||||
self.attach(&role, &arn).await;
|
||||
}
|
||||
role
|
||||
}
|
||||
|
||||
/// An SDK config for a session of `role`, once IAM lets it be assumed
|
||||
/// and its policies have propagated.
|
||||
async fn assume(&self, role: &Role) -> SdkConfig {
|
||||
let mut last = None;
|
||||
for _ in 0..20 {
|
||||
match self
|
||||
.sts
|
||||
.assume_role()
|
||||
.role_arn(&role.arn)
|
||||
.role_session_name(SESSION)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(out) => {
|
||||
let c = out.credentials().unwrap();
|
||||
let keys = AwsKeys {
|
||||
access_key_id: c.access_key_id().to_string(),
|
||||
secret_access_key: c.secret_access_key().to_string(),
|
||||
session_token: Some(c.session_token().to_string()),
|
||||
};
|
||||
// New sessions see a just-attached policy after a few
|
||||
// seconds; denials in that window would be noise.
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
return sdk_config_for_keys(&keys, &self.region);
|
||||
}
|
||||
Err(e) => {
|
||||
last = Some(classify_sdk_error("sts:AssumeRole", &e).to_string());
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("could not assume {}: {last:?}", role.arn);
|
||||
}
|
||||
|
||||
/// `check_permissions` as the e2e profile, simulating `identity`'s
|
||||
/// principal. Retried once if a check failed (IAM is eventually
|
||||
/// consistent for a role created a moment ago).
|
||||
async fn check_as(&self, identity: &CallerIdentity) -> PermissionReport {
|
||||
let api = SdkPermissionApi::new(&self.cfg);
|
||||
for attempt in 0..3 {
|
||||
let report = check_permissions(&api, identity, &self.region)
|
||||
.await
|
||||
.expect("check_permissions");
|
||||
let failed = report
|
||||
.tiers
|
||||
.iter()
|
||||
.flat_map(|t| &t.results)
|
||||
.any(|r| r.note.is_some());
|
||||
if !failed || attempt == 2 {
|
||||
return report;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn environment_key(&self, kms: &aws_sdk_kms::Client, suffix: &str) -> String {
|
||||
let tag = |k: &str, v: &str| {
|
||||
aws_sdk_kms::types::Tag::builder()
|
||||
.tag_key(k)
|
||||
.tag_value(v)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let r = kms
|
||||
.create_key()
|
||||
.description(self.name(suffix))
|
||||
.tags(tag("triple-c:component", "environment"))
|
||||
.tags(tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.send()
|
||||
.await;
|
||||
let arn = ok("kms:CreateKey", r)
|
||||
.key_metadata()
|
||||
.unwrap()
|
||||
.arn()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
self.created.lock().unwrap().keys.push(arn.clone());
|
||||
arn
|
||||
}
|
||||
|
||||
fn record_secret(&self, arn: &str) {
|
||||
self.created.lock().unwrap().secrets.push(arn.to_string());
|
||||
}
|
||||
|
||||
/// An admin-created secret encrypted with `key`.
|
||||
async fn admin_secret(&self, suffix: &str, key: &str, tag: Option<(&str, &str)>) -> String {
|
||||
let mut req = self
|
||||
.secrets
|
||||
.create_secret()
|
||||
.name(self.secret_name(suffix))
|
||||
.kms_key_id(key)
|
||||
.secret_string("e2e")
|
||||
.tags(sm_tag(TEST_TAG.0, TEST_TAG.1));
|
||||
if let Some((k, v)) = tag {
|
||||
req = req.tags(sm_tag(k, v));
|
||||
}
|
||||
let arn = ok("secretsmanager:CreateSecret", req.send().await)
|
||||
.arn()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
self.record_secret(&arn);
|
||||
arn
|
||||
}
|
||||
|
||||
/// Deletes everything recorded, logging (not failing on) errors so one
|
||||
/// failure doesn't strand the rest.
|
||||
async fn cleanup(&self) {
|
||||
let created = std::mem::take(&mut *self.created.lock().unwrap());
|
||||
let warn = |what: &str, e: &dyn std::fmt::Display| eprintln!("cleanup: {what}: {e}");
|
||||
for arn in &created.secrets {
|
||||
if let Err(e) = self
|
||||
.secrets
|
||||
.delete_secret()
|
||||
.secret_id(arn)
|
||||
.force_delete_without_recovery(true)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let e = classify_sdk_error("secretsmanager:DeleteSecret", &e);
|
||||
if !e.to_string().contains("ResourceNotFound") {
|
||||
warn(arn, &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
for arn in &created.keys {
|
||||
if let Err(e) = self
|
||||
.kms
|
||||
.schedule_key_deletion()
|
||||
.key_id(arn)
|
||||
.pending_window_in_days(7)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let e = classify_sdk_error("kms:ScheduleKeyDeletion", &e);
|
||||
if !e.to_string().contains("pending") {
|
||||
warn(arn, &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in &created.roles {
|
||||
if let Ok(out) = self.iam.list_role_policies().role_name(name).send().await {
|
||||
for p in out.policy_names() {
|
||||
let _ = self
|
||||
.iam
|
||||
.delete_role_policy()
|
||||
.role_name(name)
|
||||
.policy_name(p)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Ok(out) = self
|
||||
.iam
|
||||
.list_attached_role_policies()
|
||||
.role_name(name)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
for p in out.attached_policies() {
|
||||
let _ = self
|
||||
.iam
|
||||
.detach_role_policy()
|
||||
.role_name(name)
|
||||
.policy_arn(p.policy_arn().unwrap_or_default())
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.iam.delete_role().role_name(name).send().await {
|
||||
warn(name, &classify_sdk_error("iam:DeleteRole", &e));
|
||||
}
|
||||
}
|
||||
for arn in &created.policies {
|
||||
if let Err(e) = self.iam.delete_policy().policy_arn(arn).send().await {
|
||||
warn(arn, &classify_sdk_error("iam:DeletePolicy", &e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verdicts(report: &PermissionReport, tier: Tier) -> Vec<(String, Verdict)> {
|
||||
report
|
||||
.tiers
|
||||
.iter()
|
||||
.find(|t| t.tier == tier)
|
||||
.unwrap()
|
||||
.results
|
||||
.iter()
|
||||
.map(|r| (r.action.clone(), r.verdict.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn not_allowed(report: &PermissionReport, tier: Tier) -> Vec<(String, Verdict)> {
|
||||
verdicts(report, tier)
|
||||
.into_iter()
|
||||
.filter(|(_, v)| *v != Verdict::Allowed)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn a_role_with_the_workspace_policy_checks_as_workspace_user() {
|
||||
e2e(|e| async move {
|
||||
let role = e.tier_role("ws", &[Tier::WorkspaceUser]).await;
|
||||
let report = e.check_as(&role.identity(&e.account)).await;
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(
|
||||
not_allowed(&report, Tier::WorkspaceUser),
|
||||
[],
|
||||
"every workspace check must be allowed"
|
||||
);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
assert!(
|
||||
verdicts(&report, Tier::EnvironmentAdmin)
|
||||
.iter()
|
||||
.any(|(_, v)| *v == Verdict::Denied),
|
||||
"the workspace policy alone grants admin actions"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn a_role_with_both_policies_checks_as_environment_admin() {
|
||||
e2e(|e| async move {
|
||||
let role = e
|
||||
.tier_role("admin", &[Tier::WorkspaceUser, Tier::EnvironmentAdmin])
|
||||
.await;
|
||||
let report = e.check_as(&role.identity(&e.account)).await;
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(not_allowed(&report, Tier::WorkspaceUser), []);
|
||||
assert_eq!(not_allowed(&report, Tier::EnvironmentAdmin), []);
|
||||
assert_eq!(report.summary, Summary::EnvironmentAdmin);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn a_role_with_no_policy_checks_as_insufficient_and_lists_every_action() {
|
||||
e2e(|e| async move {
|
||||
let role = e.role("none", None, &[]).await;
|
||||
let report = e.check_as(&role.identity(&e.account)).await;
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
for tier in [Tier::WorkspaceUser, Tier::EnvironmentAdmin] {
|
||||
let rows = verdicts(&report, tier);
|
||||
for c in checks(tier) {
|
||||
assert!(
|
||||
rows.iter().any(|(a, _)| a == c.action),
|
||||
"{tier:?}: no row for {}",
|
||||
c.action
|
||||
);
|
||||
}
|
||||
for (action, v) in rows {
|
||||
assert_eq!(v, Verdict::Denied, "{tier:?} {action}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The workspace policy without `InspectOwnPrincipal`, assumed and checked
|
||||
/// *as* itself: `iam:GetRole` is denied, so the check probes.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn a_role_without_simulate_permission_falls_back_to_probes() {
|
||||
e2e(|e| async move {
|
||||
let mut doc: serde_json::Value =
|
||||
serde_json::from_str(policy_json(Tier::WorkspaceUser)).unwrap();
|
||||
doc["Statement"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.retain(|s| s["Sid"] != "InspectOwnPrincipal");
|
||||
let role = e.role("nosim", None, &[]).await;
|
||||
let arn = e.managed_policy("nosim", &doc.to_string()).await;
|
||||
e.attach(&role, &arn).await;
|
||||
let cfg = e.assume(&role).await;
|
||||
let me = caller_identity(&cfg).await.expect("identity");
|
||||
assert_eq!(me.user_id, role.session_user_id());
|
||||
let report = check_permissions(&SdkPermissionApi::new(&cfg), &me, &e.region)
|
||||
.await
|
||||
.expect("check_permissions");
|
||||
let Method::Probed { reason } = &report.method else {
|
||||
panic!("expected probes, got {:?}", report.method);
|
||||
};
|
||||
assert!(reason.contains("iam:GetRole"), "{reason}");
|
||||
let rows = verdicts(&report, Tier::WorkspaceUser);
|
||||
let verdict = |a: &str| rows.iter().find(|(x, _)| x == a).unwrap().1.clone();
|
||||
for probed in [
|
||||
"ecs:ListClusters",
|
||||
"ssm:GetParametersByPath",
|
||||
"cloudformation:DescribeStacks",
|
||||
"secretsmanager:ListSecrets",
|
||||
] {
|
||||
assert_eq!(verdict(probed), Verdict::Allowed, "{probed}");
|
||||
}
|
||||
assert_eq!(verdict("iam:GetRole"), Verdict::Denied);
|
||||
assert_eq!(verdict("ecs:RunTask"), Verdict::Unverified);
|
||||
// A probed run never claims the admin tier, and the denied
|
||||
// own-principal check keeps it from claiming the workspace tier.
|
||||
assert_eq!(report.summary, Summary::Insufficient);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The whole workspace policy, assumed: the real `iam:GetRole` on its own
|
||||
/// role (whose ARN has a path) and `SimulatePrincipalPolicy` of itself.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn an_assumed_workspace_role_checks_itself_by_simulation() {
|
||||
e2e(|e| async move {
|
||||
let role = e.tier_role("self", &[Tier::WorkspaceUser]).await;
|
||||
let cfg = e.assume(&role).await;
|
||||
let me = caller_identity(&cfg).await.expect("identity");
|
||||
assert!(me.arn.contains(":assumed-role/"), "{}", me.arn);
|
||||
let report = check_permissions(&SdkPermissionApi::new(&cfg), &me, &e.region)
|
||||
.await
|
||||
.expect("check_permissions");
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
assert_eq!(not_allowed(&report, Tier::WorkspaceUser), []);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The e2e profile itself (an account administrator): every check allowed.
|
||||
/// For an SSO profile this resolves the `/aws-reserved/sso.amazonaws.com/`
|
||||
/// role path through `iam:GetRole`.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_e2e_profile_checks_as_environment_admin() {
|
||||
e2e(|e| async move {
|
||||
let me = caller_identity(&e.cfg).await.expect("identity");
|
||||
let report = e.check_as(&me).await;
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
let failed: Vec<_> = report
|
||||
.tiers
|
||||
.iter()
|
||||
.flat_map(|t| &t.results)
|
||||
.filter(|r| r.verdict != Verdict::Allowed)
|
||||
.collect();
|
||||
assert!(failed.is_empty(), "{failed:#?}");
|
||||
assert_eq!(report.summary, Summary::EnvironmentAdmin);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The spec's most important rule: no shell, stop or session in someone
|
||||
/// else's task.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_owner_condition_denies_another_owners_task() {
|
||||
e2e(|e| async move {
|
||||
let role = e.tier_role("owner", &[Tier::WorkspaceUser]).await;
|
||||
let me = role.session_user_id();
|
||||
let api = SdkPermissionApi::new(&e.cfg);
|
||||
let p = Placeholders {
|
||||
region: &e.region,
|
||||
account: &e.account,
|
||||
principal: &role.arn,
|
||||
};
|
||||
let task_checks = checks(Tier::WorkspaceUser).iter().filter(|c| {
|
||||
["ecs:StopTask", "ecs:ExecuteCommand", "ssm:StartSession"].contains(&c.action)
|
||||
&& c.resource.contains(":task/")
|
||||
});
|
||||
let mut n = 0;
|
||||
for c in task_checks {
|
||||
n += 1;
|
||||
let mine = simulation_request(c, &p, &me);
|
||||
assert!(
|
||||
api.simulate(&mine).await.unwrap(),
|
||||
"{} on my own task",
|
||||
c.action
|
||||
);
|
||||
let mut theirs = mine.clone();
|
||||
for entry in &mut theirs.context {
|
||||
if entry.key == "aws:ResourceTag/triple-c:owner" {
|
||||
entry.values = vec!["AROAEXAMPLEOTHER:bob@example.com".into()];
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!api.simulate(&theirs).await.unwrap(),
|
||||
"{} allowed on another owner's task",
|
||||
c.action
|
||||
);
|
||||
theirs
|
||||
.context
|
||||
.retain(|x| x.key != "aws:ResourceTag/triple-c:owner");
|
||||
assert!(
|
||||
!api.simulate(&theirs).await.unwrap(),
|
||||
"{} allowed on an untagged task",
|
||||
c.action
|
||||
);
|
||||
}
|
||||
assert_eq!(n, 3);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// SimulatePrincipalPolicy *does* apply the principal's own permissions
|
||||
/// boundary (it does not apply SCPs or session policies).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_simulator_applies_the_principals_permissions_boundary() {
|
||||
e2e(|e| async move {
|
||||
let boundary = e.managed_policy("boundary", permissions_boundary_json()).await;
|
||||
let role = e.role("bounded", Some(&boundary), &[]).await;
|
||||
e.put_inline(
|
||||
&role,
|
||||
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"secretsmanager:GetSecretValue","Resource":"*"}]}"#,
|
||||
)
|
||||
.await;
|
||||
let sim = |name: &str| {
|
||||
e.iam
|
||||
.simulate_principal_policy()
|
||||
.policy_source_arn(&role.arn)
|
||||
.action_names("secretsmanager:GetSecretValue")
|
||||
.resource_arns(format!(
|
||||
"arn:aws:secretsmanager:{}:{}:secret:{name}-AbCdEf",
|
||||
e.region, e.account
|
||||
))
|
||||
.send()
|
||||
};
|
||||
let decision = |out: aws_sdk_iam::operation::simulate_principal_policy::SimulatePrincipalPolicyOutput| {
|
||||
out.evaluation_results()[0].eval_decision().as_str().to_string()
|
||||
};
|
||||
assert_eq!(decision(ok("simulate", sim("triple-c/x").await)), "allowed");
|
||||
assert_eq!(decision(ok("simulate", sim("other/x").await)), "implicitDeny");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The secret statements and the KMS rules behind them, with real calls:
|
||||
/// tag-on-create, the environment key by ARN (and `kms:Decrypt` with it),
|
||||
/// the explicit Deny on the AWS-managed key, owner and component fences,
|
||||
/// and the permissions boundary reading `aws:ResourceTag` of the key.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn secrets_use_the_environment_key_and_only_their_owner_can_change_them() {
|
||||
e2e(|e| async move {
|
||||
let key = e.environment_key(&e.kms, "env-key").await;
|
||||
let aws_managed = ok(
|
||||
"kms:DescribeKey",
|
||||
e.kms.describe_key().key_id("alias/aws/secretsmanager").send().await,
|
||||
)
|
||||
.key_metadata()
|
||||
.unwrap()
|
||||
.arn()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let user = e.tier_role("secrets-user", &[Tier::WorkspaceUser]).await;
|
||||
let boundary = e.managed_policy("secrets-boundary", permissions_boundary_json()).await;
|
||||
let task = e.role("secrets-task", Some(&boundary), &[]).await;
|
||||
e.put_inline(
|
||||
&task,
|
||||
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["secretsmanager:GetSecretValue","kms:Decrypt"],"Resource":"*"}]}"#,
|
||||
)
|
||||
.await;
|
||||
let environment = e
|
||||
.admin_secret("environment", &key, Some(("triple-c:component", "environment")))
|
||||
.await;
|
||||
let someone_elses = e
|
||||
.admin_secret("someone-else", &key, Some(("triple-c:owner", "AROAEXAMPLEOTHER:bob")))
|
||||
.await;
|
||||
|
||||
let as_user = aws_sdk_secretsmanager::Client::new(&e.assume(&user).await);
|
||||
let me = user.session_user_id();
|
||||
let create = |suffix: &str, key: Option<&str>| {
|
||||
as_user
|
||||
.create_secret()
|
||||
.name(e.secret_name(suffix))
|
||||
.set_kms_key_id(key.map(str::to_string))
|
||||
.secret_string("v1")
|
||||
.tags(sm_tag("triple-c:owner", &me))
|
||||
.send()
|
||||
};
|
||||
|
||||
// Own secret on the environment key, named by ARN.
|
||||
let own = ok("create own secret", create("own", Some(&key)).await)
|
||||
.arn()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
e.record_secret(&own);
|
||||
ok(
|
||||
"tag for cleanup",
|
||||
e.secrets
|
||||
.tag_resource()
|
||||
.secret_id(&own)
|
||||
.tags(sm_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
ok(
|
||||
"put own",
|
||||
as_user.put_secret_value().secret_id(&own).secret_string("v2").send().await,
|
||||
);
|
||||
|
||||
// Any other key is refused.
|
||||
for (i, (what, k)) in [("no KmsKeyId", None), ("aws/secretsmanager", Some(aws_managed.as_str()))]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let r = create(&format!("refused-{i}"), k).await;
|
||||
if let Ok(out) = &r {
|
||||
e.record_secret(out.arn().unwrap());
|
||||
}
|
||||
denied(what, r);
|
||||
}
|
||||
// Only the ordinary tag keys, and never claiming or changing others'.
|
||||
denied(
|
||||
"tag own with triple-c:component",
|
||||
as_user
|
||||
.tag_resource()
|
||||
.secret_id(&own)
|
||||
.tags(sm_tag("triple-c:owner", &me))
|
||||
.tags(sm_tag("triple-c:component", "x"))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
for (what, arn) in [("environment", &environment), ("someone else's", &someone_elses)] {
|
||||
denied(
|
||||
&format!("put {what}"),
|
||||
as_user.put_secret_value().secret_id(arn).secret_string("x").send().await,
|
||||
);
|
||||
denied(
|
||||
&format!("delete {what}"),
|
||||
as_user.delete_secret().secret_id(arn).send().await,
|
||||
);
|
||||
denied(
|
||||
&format!("claim {what}"),
|
||||
as_user
|
||||
.tag_resource()
|
||||
.secret_id(arn)
|
||||
.tags(sm_tag("triple-c:owner", &me))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
// A task role under the boundary decrypts through Secrets Manager
|
||||
// only with the component-tagged key.
|
||||
let as_task = aws_sdk_secretsmanager::Client::new(&e.assume(&task).await);
|
||||
let value = ok(
|
||||
"task reads own",
|
||||
as_task.get_secret_value().secret_id(&own).send().await,
|
||||
);
|
||||
assert_eq!(value.secret_string(), Some("v2"));
|
||||
|
||||
ok(
|
||||
"delete own",
|
||||
as_user
|
||||
.delete_secret()
|
||||
.secret_id(&own)
|
||||
.force_delete_without_recovery(true)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The admin tier's fences, for real: roles only with the Triple-C
|
||||
/// boundary, the boundary never removed or swapped or edited, and keys
|
||||
/// created only with `triple-c:component` (tag-on-create passes the
|
||||
/// `kms:TagResource` rule) and destroyed only if they carry it.
|
||||
///
|
||||
/// Needs the exact `triple-c-permissions-boundary` ARN: the test creates
|
||||
/// that policy and deletes it again, unless the account already has one, in
|
||||
/// which case it uses it and leaves it alone.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn the_admin_tier_creates_only_bounded_roles_and_tagged_keys() {
|
||||
e2e(|e| async move {
|
||||
let boundary = format!("arn:aws:iam::{}:policy/{PERMISSIONS_BOUNDARY_NAME}", e.account);
|
||||
if e.iam.get_policy().policy_arn(&boundary).send().await.is_err() {
|
||||
ok(
|
||||
"create the boundary",
|
||||
e.iam
|
||||
.create_policy()
|
||||
.policy_name(PERMISSIONS_BOUNDARY_NAME)
|
||||
.policy_document(permissions_boundary_json())
|
||||
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
e.created.lock().unwrap().policies.push(boundary.clone());
|
||||
}
|
||||
let other_boundary = e.managed_policy("other-boundary", permissions_boundary_json()).await;
|
||||
let admin = e
|
||||
.tier_role("fence", &[Tier::WorkspaceUser, Tier::EnvironmentAdmin])
|
||||
.await;
|
||||
let cfg = e.assume(&admin).await;
|
||||
let iam = aws_sdk_iam::Client::new(&cfg);
|
||||
let kms = aws_sdk_kms::Client::new(&cfg);
|
||||
let trust = format!(
|
||||
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{}:root"}},"Action":"sts:AssumeRole"}}]}}"#,
|
||||
e.account
|
||||
);
|
||||
let create = |suffix: &str, b: Option<&str>| {
|
||||
iam.create_role()
|
||||
.role_name(e.name(suffix))
|
||||
.assume_role_policy_document(&trust)
|
||||
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
|
||||
.set_permissions_boundary(b.map(str::to_string))
|
||||
.send()
|
||||
};
|
||||
for (suffix, b) in [("unbounded", None), ("other-bounded", Some(other_boundary.as_str()))] {
|
||||
let r = create(suffix, b).await;
|
||||
if r.is_ok() {
|
||||
e.created.lock().unwrap().roles.push(e.name(suffix));
|
||||
}
|
||||
denied(&format!("create {suffix} role"), r);
|
||||
}
|
||||
let child = e.name("bounded");
|
||||
ok("create bounded role", create("bounded", Some(&boundary)).await);
|
||||
e.created.lock().unwrap().roles.push(child.clone());
|
||||
ok(
|
||||
"put a policy on the bounded role",
|
||||
iam.put_role_policy()
|
||||
.role_name(&child)
|
||||
.policy_name("p")
|
||||
.policy_document(r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:GetCallerIdentity","Resource":"*"}]}"#)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
denied(
|
||||
"remove the boundary",
|
||||
iam.delete_role_permissions_boundary().role_name(&child).send().await,
|
||||
);
|
||||
denied(
|
||||
"swap the boundary",
|
||||
iam.put_role_permissions_boundary()
|
||||
.role_name(&child)
|
||||
.permissions_boundary(&other_boundary)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
denied(
|
||||
"edit the boundary",
|
||||
iam.create_policy_version()
|
||||
.policy_arn(&boundary)
|
||||
.policy_document(permissions_boundary_json())
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
|
||||
// Keys: tagged create passes (tag-on-create authorizes
|
||||
// kms:TagResource against the new key's requested tags).
|
||||
let untagged = kms.create_key().description(e.name("untagged")).send().await;
|
||||
if let Ok(out) = &untagged {
|
||||
e.created
|
||||
.lock()
|
||||
.unwrap()
|
||||
.keys
|
||||
.push(out.key_metadata().unwrap().arn().unwrap().to_string());
|
||||
}
|
||||
denied("create an untagged key", untagged);
|
||||
let key = e.environment_key(&kms, "admin-key").await;
|
||||
ok(
|
||||
"schedule the environment key's deletion",
|
||||
kms.schedule_key_deletion()
|
||||
.key_id(&key)
|
||||
.pending_window_in_days(7)
|
||||
.send()
|
||||
.await,
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! AWS connection plumbing: an SDK config built from a saved
|
||||
//! `HostConnection`, the caller's identity, the tier policies and the
|
||||
//! permission check.
|
||||
//!
|
||||
|
||||
mod connection;
|
||||
mod error;
|
||||
mod identity;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
pub mod permissions;
|
||||
pub mod policies;
|
||||
|
||||
pub use connection::{sdk_config, sdk_config_for_keys};
|
||||
pub use error::AwsError;
|
||||
pub use identity::{caller_identity, CallerIdentity};
|
||||
@@ -0,0 +1,557 @@
|
||||
//! 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,
|
||||
CfnDescribeStacks,
|
||||
SecretsListSecrets,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
const ALL: [Probe; 4] = [
|
||||
Probe::EcsListClusters,
|
||||
Probe::SsmGetParametersByPath,
|
||||
Probe::CfnDescribeStacks,
|
||||
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),
|
||||
"cloudformation" => Some(Probe::CfnDescribeStacks),
|
||||
"secretsmanager" => Some(Probe::SecretsListSecrets),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn action(self) -> &'static str {
|
||||
match self {
|
||||
Probe::EcsListClusters => "ecs:ListClusters",
|
||||
Probe::SsmGetParametersByPath => "ssm:GetParametersByPath",
|
||||
Probe::CfnDescribeStacks => "cloudformation:DescribeStacks",
|
||||
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,
|
||||
cfn: aws_sdk_cloudformation::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),
|
||||
cfn: aws_sdk_cloudformation::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::CfnDescribeStacks => self
|
||||
.cfn
|
||||
.describe_stacks()
|
||||
.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 four 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;
|
||||
@@ -0,0 +1,632 @@
|
||||
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; 4] = [
|
||||
Probe::EcsListClusters,
|
||||
Probe::SsmGetParametersByPath,
|
||||
Probe::CfnDescribeStacks,
|
||||
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("cloudformation:CreateStack");
|
||||
let report = run(&api);
|
||||
assert_eq!(report.summary, Summary::WorkspaceUser);
|
||||
assert_eq!(
|
||||
verdict(
|
||||
&report,
|
||||
Tier::EnvironmentAdmin,
|
||||
"cloudformation:CreateStack"
|
||||
),
|
||||
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, "cloudformation:DescribeStacks"),
|
||||
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(), 4);
|
||||
// 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![("cloudformation:CreateStack", throttled())];
|
||||
let report = run(&api);
|
||||
assert_eq!(report.method, Method::Simulated);
|
||||
let r = row(
|
||||
&report,
|
||||
Tier::EnvironmentAdmin,
|
||||
"cloudformation:CreateStack",
|
||||
);
|
||||
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::CfnDescribeStacks);
|
||||
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>;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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))?;
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
//! 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_json, 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, ®ion),
|
||||
};
|
||||
caller_identity(&cfg).await
|
||||
}
|
||||
|
||||
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, ®ion).await?;
|
||||
let identity = caller_identity(&cfg).await?;
|
||||
let api = SdkPermissionApi::new(&cfg);
|
||||
check_permissions(&api, &identity, ®ion).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn aws_policy_document(tier: Tier) -> String {
|
||||
policy_json(tier).to_string()
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -6,6 +6,7 @@ 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]);
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod auth_bridge;
|
||||
mod aws;
|
||||
mod browser_view;
|
||||
#[cfg(test)]
|
||||
mod command_census;
|
||||
@@ -593,6 +594,15 @@ 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::settings_commands::detect_host_timezone,
|
||||
// Settings export/import
|
||||
commands::settings_export_commands::export_settings,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(©)?;
|
||||
*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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
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 the copy-policy buttons", 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 '{"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: "Copy workspace-user policy" }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('{"Version":"2012-10-17"}'));
|
||||
expect(invoke).toHaveBeenCalledWith("aws_policy_document", { tier: "workspace_user" });
|
||||
expect(screen.getByRole("button", { name: "Copy admin policy" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 "{}";
|
||||
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: "Copy admin policy" }));
|
||||
expect(await screen.findByText(/Could not copy the policy: denied/)).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,554 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import {
|
||||
addAwsKeysConnection,
|
||||
addAwsProfileConnection,
|
||||
awsConnectionIdentity,
|
||||
awsConnectionSsoLogin,
|
||||
awsPolicyDocument,
|
||||
checkAwsPermissions,
|
||||
listAwsProfiles,
|
||||
removeHostConnection,
|
||||
updateAwsConnection,
|
||||
} from "../../lib/tauri-commands";
|
||||
import { toAwsError } from "../../lib/aws-error";
|
||||
import type {
|
||||
AwsError,
|
||||
HostConnection,
|
||||
PermissionReport,
|
||||
PermissionSummary,
|
||||
Tier,
|
||||
} from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import SegmentedControl from "../ui/SegmentedControl";
|
||||
import Field, { inputClass, selectClass } from "../ui/Field";
|
||||
|
||||
const COMMON_REGIONS = [
|
||||
"us-east-1", "us-east-2", "us-west-1", "us-west-2", "ca-central-1",
|
||||
"eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1",
|
||||
"ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ap-northeast-2", "ap-south-1",
|
||||
"sa-east-1",
|
||||
];
|
||||
|
||||
const OWN_PRINCIPAL_ACTIONS = ["iam:GetRole", "iam:SimulatePrincipalPolicy"];
|
||||
|
||||
/** Human-readable copy for a failed AWS command; never raw JSON. */
|
||||
export function awsErrorMessage(e: AwsError): string {
|
||||
switch (e.kind) {
|
||||
case "sso_expired":
|
||||
return `Your SSO session for profile ${e.profile} has expired.`;
|
||||
case "profile_not_found":
|
||||
return `Profile ${e.profile} was not found in your AWS config, or has no credentials.`;
|
||||
case "keys_missing":
|
||||
return "The access keys for this connection are missing from the keychain. Remove and re-add the connection.";
|
||||
default:
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
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 (tier: Tier) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(await awsPolicyDocument(tier));
|
||||
setCopyNote("Policy copied to clipboard.");
|
||||
} catch (e) {
|
||||
setCopyNote(`Could not copy the policy: ${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>
|
||||
))}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" onClick={() => copy("workspace_user")}>
|
||||
Copy workspace-user policy
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => copy("environment_admin")}>
|
||||
Copy admin policy
|
||||
</Button>
|
||||
</div>
|
||||
{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} />}
|
||||
</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 are used to check what your credentials can do.
|
||||
</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,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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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,
|
||||
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" };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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, Tier } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -44,6 +44,40 @@ 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 });
|
||||
export const awsPolicyDocument = (tier: Tier) =>
|
||||
invoke<string>("aws_policy_document", { tier });
|
||||
/** Check a corporate CA path and report what would be installed. Never
|
||||
* rejects for a bad path — the reason comes back in `error`. */
|
||||
export const inspectCaCertPath = (path: string) =>
|
||||
|
||||
@@ -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,59 @@ 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: "other"; message: string };
|
||||
|
||||
export interface CallerIdentity {
|
||||
account: string;
|
||||
arn: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export type Tier = "environment_admin" | "workspace_user";
|
||||
|
||||
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[];
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
# 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: deploy, update and delete the environment stack, and onboard users |
|
||||
| `policies/permissions-boundary.json` | The **permissions boundary** every Triple-C role must carry. It is never attached to a person |
|
||||
|
||||
An environment admin needs **both** tier files. The admin policy adds environment management on
|
||||
top of everything a workspace user can do. Explicit Deny statements in either file apply to anyone
|
||||
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 stack 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 only on the environment's file system, which is the one tagged
|
||||
`triple-c:component`.
|
||||
- **Set only the ordinary tag keys.** Every request that tags something may set only
|
||||
`triple-c:owner`, `triple-c:project` and `triple-c:environment` (`aws:TagKeys`). This applies to
|
||||
`RunTask`, the ECS and EFS tag-on-create calls, `CreateSecret`, and secret `TagResource`. A
|
||||
workspace user can never set `triple-c:component`, which marks environment resources and which
|
||||
the Denies and phase-4 detection trust.
|
||||
- **Inspect their own principal, 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, CloudFormation stacks, the `/triple-c/*` SSM
|
||||
parameters, the secret list, EFS, and the `/triple-c/*` log groups. Listing is not a secret; the
|
||||
app filters by owner for display.
|
||||
|
||||
## Environment admin
|
||||
|
||||
The admin tier manages `triple-c-*` CloudFormation stacks and the resources they create:
|
||||
|
||||
- `triple-c-*` IAM roles;
|
||||
- ECS clusters, capacity providers and task definitions;
|
||||
- security groups, EFS file systems and KMS keys;
|
||||
- `/triple-c/*` log groups and `/triple-c/*` SSM parameters;
|
||||
- ECR repositories and pull-through cache rules.
|
||||
|
||||
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 environment (component-tagged) file systems; whether that excludes
|
||||
root access depends on access points fixing the POSIX user and root directory, which is only
|
||||
true once phase 6 enforces them (see "Enforced access-point roots" under Phase 6 gaps);
|
||||
- `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.
|
||||
|
||||
**No CloudFormation service roles.** The admin cannot create or update a `triple-c-*` stack, or
|
||||
create a change set for one, with a service role (`RoleARN`).
|
||||
|
||||
**Destruction is tag-scoped.** Security groups, EFS file systems and KMS keys can be created only
|
||||
with a `triple-c:component` tag. They can be changed or deleted only if they carry that tag.
|
||||
Tagging is allowed only as part of the creating call, or on a resource that is already
|
||||
Triple-C's.
|
||||
|
||||
**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.
|
||||
- `ecr:CreatePullThroughCacheRule` on `*`, which also has no resource type. The admin can add
|
||||
pull-through cache rules for any prefix. This cannot delete or overwrite anything.
|
||||
- `elasticfilesystem:CreateFileSystem` on `*` (no resource type), still only with a
|
||||
`triple-c:component` request tag.
|
||||
- `ec2:CreateSecurityGroup` in any VPC (the new group itself must be tagged), and `ec2:Describe*`.
|
||||
- `cloudformation:*` on `triple-c-*` stacks. Whatever a template creates is limited by the actions
|
||||
above, because CloudFormation runs with the caller's own permissions (see the invariants below).
|
||||
|
||||
## Invariants the templates must keep (phase 4)
|
||||
|
||||
The policies rely on the following. Phase 4's templates must establish them, and phase 4's
|
||||
detection should flag any environment that breaks them.
|
||||
|
||||
1. **Every `triple-c-*` role carries `triple-c-permissions-boundary`.** The admin tier cannot
|
||||
create one without it. A role created some other way, such as by an account administrator or by
|
||||
hand, must still have the boundary. Detection should flag a `triple-c-*` role that has no
|
||||
boundary.
|
||||
2. **`triple-c-*` stacks are never deployed with a CloudFormation service role.** `UpdateStack`
|
||||
and change sets on a stack that already has a service role reuse it without `iam:PassRole`,
|
||||
and that role is not under the boundary. The admin tier denies setting `RoleARN`. Detection
|
||||
should flag a `triple-c-*` stack whose `RoleARN` is set.
|
||||
3. **Every environment-level `triple-c/*` secret carries `triple-c:component`.** A secret with
|
||||
neither an owner tag nor a component tag could be claimed by any workspace user.
|
||||
4. **Every environment KMS key and EFS file system carries `triple-c:component`.** The boundary
|
||||
and the admin tier both rely on this tag.
|
||||
5. **User secrets use the environment key.** The app must pass the environment KMS key's ARN as
|
||||
`KmsKeyId` when it creates a secret. The 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`. Phase 4 decides how the app offers
|
||||
this step; one option is to list it with the environment stack's prerequisites.
|
||||
|
||||
## Attaching the tier policies
|
||||
|
||||
Create each file as a **customer managed policy** and attach it. In the console, go to
|
||||
**IAM → Policies → Create policy → JSON**, paste the file, and attach the policy to a user, group
|
||||
or role. Inline policies do not fit: a role's inline policies share 10,240 characters and an
|
||||
environment admin needs both tiers (about 11,500), and a user's share 2,048. A managed policy may
|
||||
hold 6,144 characters, not counting whitespace; the workspace policy is about 6,110 (roughly 30
|
||||
characters of headroom, so phase 4 must split `workspace-user.json` into two policies before
|
||||
adding anything to it), and a unit test (`every_policy_fits_a_managed_policy`) fails before it
|
||||
outgrows that. For IAM Identity Center, add the files as inline policies on the permission set
|
||||
(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. Each user's onboarding stack must therefore create their execution role as well as their
|
||||
task role. The alternative is a separate statement that allows the environment's shared
|
||||
execution role.
|
||||
- **The admin's full deploy permissions.** Deploying needs the following, added with the same
|
||||
boundary and tag scoping once the templates exist:
|
||||
- tag-on-create permissions for ECS, CloudWatch Logs, SSM and ECR;
|
||||
- autoscaling and instance-profile actions for EC2 capacity;
|
||||
- `cloudformation:ValidateTemplate`;
|
||||
- whatever else the templates create.
|
||||
- **Admin access to user data.** The admin can retag `triple-c-*` roles (`iam:TagRole`). The
|
||||
boundary also allows every `triple-c/*` secret and every environment file system. As a result,
|
||||
isolating users *from the admin* depends on the onboarding template's per-user policies, not on
|
||||
IAM fences.
|
||||
- **The workspace tier's secret Deny also applies to admins.** If the environment stack 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**
|
||||
|
||||
- **Enforced access-point roots.** A workspace user creates their own access points. The
|
||||
`RootDirectory` and `PosixUser` must be enforced, so that an access point rooted at `/` cannot
|
||||
read other users' data. Two ways to do this: an EFS file-system policy that pins each access
|
||||
point to its owner's task role, or access points created by the onboarding stack rather than by
|
||||
the user.
|
||||
|
||||
## 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` | ✓ present when `RoleARN` is given; the Deny fires. The admin tier cannot pass any role to CloudFormation anyway (`PassRole` is only to ECS tasks and EC2) |
|
||||
| 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.
|
||||
@@ -0,0 +1,374 @@
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ManageEnvironmentStacks",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudformation:*"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:cloudformation:*:*:stack/triple-c-*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "TagReadAndDeleteTripleCRoles",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"iam:DeleteRole",
|
||||
"iam:TagRole",
|
||||
"iam:GetRole"
|
||||
],
|
||||
"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",
|
||||
"ec2.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": "NoServiceRoleForTripleCStacks",
|
||||
"Effect": "Deny",
|
||||
"Action": [
|
||||
"cloudformation:CreateStack",
|
||||
"cloudformation:UpdateStack",
|
||||
"cloudformation:CreateChangeSet"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:cloudformation:*:*:stack/triple-c-*/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"cloudformation:RoleArn": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "InspectOwnPrincipal",
|
||||
"Effect": "Allow",
|
||||
"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": "ManageClusters",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:CreateCluster",
|
||||
"ecs:DeleteCluster",
|
||||
"ecs:PutClusterCapacityProviders"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecs:*:*:cluster/triple-c-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageCapacityProviders",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:CreateCapacityProvider",
|
||||
"ecs:DeleteCapacityProvider"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecs:*:*:capacity-provider/triple-c-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageTaskDefinitions",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecs:RegisterTaskDefinition",
|
||||
"ecs:DeregisterTaskDefinition"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "CreateTaggedSecurityGroups",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateSecurityGroup"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:security-group/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:RequestTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "CreateSecurityGroupsInVpcs",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateSecurityGroup"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:vpc/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "TagSecurityGroupsAtCreate",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:CreateTags"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:security-group/*"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"ec2:CreateAction": "CreateSecurityGroup"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ManageTripleCSecurityGroups",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:DeleteSecurityGroup",
|
||||
"ec2:AuthorizeSecurityGroup*",
|
||||
"ec2:RevokeSecurityGroup*",
|
||||
"ec2:CreateTags"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ec2:*:*:security-group/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "DescribeEc2",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:Describe*"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "CreateTaggedFileSystems",
|
||||
"Effect": "Allow",
|
||||
"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:CreateMountTarget",
|
||||
"elasticfilesystem:DeleteMountTarget",
|
||||
"elasticfilesystem:PutFileSystemPolicy",
|
||||
"elasticfilesystem:TagResource"
|
||||
],
|
||||
"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:TagResource",
|
||||
"kms:CreateAlias",
|
||||
"kms:DeleteAlias"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:kms:*:*:key/*"
|
||||
],
|
||||
"Condition": {
|
||||
"Null": {
|
||||
"aws:ResourceTag/triple-c:component": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ManageTripleCKeyAliases",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:CreateAlias",
|
||||
"kms:DeleteAlias"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:kms:*:*:alias/triple-c-*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageLogGroups",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:CreateLogGroup",
|
||||
"logs:DeleteLogGroup",
|
||||
"logs:PutRetentionPolicy"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:logs:*:*:log-group:/triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageRepositories",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecr:CreateRepository",
|
||||
"ecr:DeleteRepository"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ecr:*:*:repository/triple-c-*",
|
||||
"arn:aws:ecr:*:*:repository/triple-c/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "CreatePullThroughCacheRules",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ecr:CreatePullThroughCacheRule"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ManageEnvironmentParameters",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ssm:PutParameter",
|
||||
"ssm:DeleteParameter"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:ssm:*:*:parameter/triple-c/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
{
|
||||
"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}"
|
||||
},
|
||||
"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": "ReadStacks",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudformation:DescribeStacks",
|
||||
"cloudformation:ListStacks"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -137,8 +137,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.
|
||||
@@ -395,8 +398,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
|
||||
@@ -442,6 +445,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.
|
||||
|
||||
@@ -542,6 +568,7 @@ aws/
|
||||
policies/
|
||||
environment-admin.json
|
||||
workspace-user.json
|
||||
permissions-boundary.json # every triple-c-* role carries it (phase 3)
|
||||
task-execution-role.json
|
||||
task-role-standard.json
|
||||
```
|
||||
@@ -553,7 +580,8 @@ 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.
|
||||
simulation and fallback probes, policy files. **Done 2026-11-14**; the policies were verified
|
||||
against real IAM (see "Phase 3: policy verification and rulings").
|
||||
4. **Environment + onboarding stacks.** CloudFormation templates for both, detection (SSM
|
||||
parameters + tagged stacks, including half-deployed and outdated ones), join or deploy,
|
||||
self-onboarding or an admin request, **strict tenancy**.
|
||||
@@ -571,12 +599,72 @@ 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.
|
||||
|
||||
## Connection model
|
||||
|
||||
**Decided: C** (user, 2026-11-13). The comparison below is kept for the record.
|
||||
|
||||
Reference in new issue
Block a user