From 75f5c0a095b5d840f4c0738beef5a73df07c684f Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Wed, 29 Jul 2026 23:29:57 -0400 Subject: [PATCH] Harden ACL publication authority and DNS policy proof --- .github/actionlint.yaml | 3 + .github/workflows/cd.yml | 53 +- .github/workflows/ci.yml | 147 +--- .github/workflows/publish-acl.yml | 245 ++++++ constants.dhall | 1 + docs/acl-publication-authority.md | 106 +++ docs/rke2-egress-authority.md | 71 ++ flake.nix | 1 + fragments/core.dhall | 7 + grants.json | 4 + justfile | 13 +- scripts/push.py | 480 +++++++++++- tests/test_policy_contract.py | 100 +++ tests/test_push_digest_contract.py | 907 ++++++++++++++++++++++ tests/test_rke2_egress_source_contract.py | 169 ++++ tests/test_workflow_authority.py | 148 ++++ 16 files changed, 2255 insertions(+), 200 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 .github/workflows/publish-acl.yml create mode 100644 docs/acl-publication-authority.md create mode 100644 docs/rke2-egress-authority.md create mode 100644 tests/test_push_digest_contract.py create mode 100644 tests/test_rke2_egress_source_contract.py create mode 100644 tests/test_workflow_authority.py diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..552713f --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - tinyland-nix diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index d32d03c..266743e 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,51 +1,14 @@ -name: CD +name: ACL CD (retired tombstone) on: - push: - branches: [main] + workflow_dispatch: -concurrency: - group: acl-deploy - cancel-in-progress: false - -permissions: - contents: read +permissions: {} jobs: - deploy: - name: Build, validate, push ACL - runs-on: ubuntu-latest - environment: production + retired: + if: ${{ github.repository == 'invalid/retired-tailnet-acl-cd' }} + runs-on: tinyland-nix steps: - - uses: actions/checkout@v4 - - - uses: DeterminateSystems/nix-installer-action@main - - uses: DeterminateSystems/magic-nix-cache-action@main - - - name: Build policy JSON - run: nix develop --command just build - env: - XDG_CACHE_HOME: ${{ runner.temp }}/.cache - - - name: Validate against live ACL - continue-on-error: true - run: nix develop --command python3 scripts/validate.py - env: - TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_API_KEY }} - TS_OAUTH_CLIENT_ID: ${{ vars.TS_OAUTH_CLIENT_ID }} - XDG_CACHE_HOME: ${{ runner.temp }}/.cache - - - name: Show diff - run: | - nix develop --command python3 scripts/push.py --dry-run || true - env: - TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_API_KEY }} - TS_OAUTH_CLIENT_ID: ${{ vars.TS_OAUTH_CLIENT_ID }} - XDG_CACHE_HOME: ${{ runner.temp }}/.cache - - - name: Push ACL to Tailscale - run: nix develop --command python3 scripts/push.py --confirm - env: - TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_API_KEY }} - TS_OAUTH_CLIENT_ID: ${{ vars.TS_OAUTH_CLIENT_ID }} - XDG_CACHE_HOME: ${{ runner.temp }}/.cache + - name: Refuse retired ACL publication workflow + run: exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91c7885..f1116d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,29 +8,41 @@ on: permissions: contents: read - pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: secrets: name: Secret detection - runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: tinyland-nix steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - - uses: gitleaks/gitleaks-action@v2 + - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} check: name: Dhall type-check + build - runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: tinyland-nix steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - - uses: DeterminateSystems/nix-installer-action@main - - uses: DeterminateSystems/magic-nix-cache-action@main + - name: Validate workflow syntax + run: nix develop --command actionlint -color=false + env: + XDG_CACHE_HOME: ${{ runner.temp }}/.cache - name: Dhall type-check run: nix develop --command dhall type --file policy.dhall > /dev/null @@ -77,121 +89,14 @@ jobs: print(f' {len(p[\"tagOwners\"])} tag owners') " - - name: Policy contract tests - run: python3 -m unittest discover -s tests -p 'test_*.py' + - name: Policy and workflow authority contract tests + run: nix develop --command python3 -m unittest discover -s tests -p 'test_*.py' + env: + PYTHONDONTWRITEBYTECODE: "1" - name: Upload policy artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: policy-json path: generated/policy.json retention-days: 30 - - validate: - name: Validate against live ACL - runs-on: ubuntu-latest - needs: [check, secrets] - if: github.event_name == 'pull_request' - steps: - - uses: actions/checkout@v4 - - - uses: DeterminateSystems/nix-installer-action@main - - uses: DeterminateSystems/magic-nix-cache-action@main - - - name: Build policy - run: nix develop --command just build - env: - XDG_CACHE_HOME: ${{ runner.temp }}/.cache - - - name: Validate against live - id: validate - run: | - set +e - OUTPUT=$(nix develop --command python3 scripts/validate.py 2>&1) - EXIT_CODE=$? - echo "$OUTPUT" - # Save for PR comment - { - echo 'validation_output<> "$GITHUB_OUTPUT" - echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT" - if [ "$EXIT_CODE" -eq 0 ]; then - exit 0 - fi - - if printf '%s\n' "$OUTPUT" | grep -q 'Validation FAILED: local policy differs from live.'; then - echo "::warning::Local policy differs from live; this PR check is advisory. The CD workflow applies the policy after merge to main." - exit 0 - fi - - exit "$EXIT_CODE" - env: - TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_API_KEY }} - XDG_CACHE_HOME: ${{ runner.temp }}/.cache - - - name: Diff summary - if: always() - id: diff - run: | - set +e - OUTPUT=$(nix develop --command python3 scripts/push.py --dry-run 2>&1) - { - echo 'diff_output<> "$GITHUB_OUTPUT" - env: - TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_API_KEY }} - XDG_CACHE_HOME: ${{ runner.temp }}/.cache - - - name: Comment on PR - if: always() - uses: actions/github-script@v7 - with: - script: | - const validate = `${{ steps.validate.outputs.validation_output }}`; - const diff = `${{ steps.diff.outputs.diff_output }}`; - const exitCode = '${{ steps.validate.outputs.exit_code }}'; - const icon = exitCode === '0' ? ':white_check_mark:' : ':warning:'; - - const body = [ - `## ${icon} ACL Validation`, - '', - '### Validation', - '```', - validate, - '```', - '', - diff ? '### Diff against live' : '', - diff ? '```' : '', - diff || '', - diff ? '```' : '', - ].filter(Boolean).join('\n'); - - // Find existing comment - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - const existing = comments.find(c => - c.user.type === 'Bot' && c.body.includes('ACL Validation') - ); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); - } diff --git a/.github/workflows/publish-acl.yml b/.github/workflows/publish-acl.yml new file mode 100644 index 0000000..c52cc03 --- /dev/null +++ b/.github/workflows/publish-acl.yml @@ -0,0 +1,245 @@ +name: Publish Tailnet ACL (attended) + +on: + workflow_dispatch: + inputs: + action: + description: "Plan against live policy or apply an accepted exact plan." + required: true + default: plan + type: choice + options: + - plan + - apply + expected_source_sha: + description: "Exact current main commit to build and bind." + required: true + type: string + expected_live_policy_sha256: + description: "Apply only: accepted live-policy digest from the plan receipt." + required: false + default: "" + type: string + expected_policy_sha256: + description: "Apply only: accepted generated-policy digest from the plan receipt." + required: false + default: "" + type: string + confirmation: + description: "Enter plan-tailnet-acl- or apply-tailnet-acl-." + required: true + type: string + +permissions: + contents: read + +concurrency: + group: tailnet-acl-publish + cancel-in-progress: false + +jobs: + validate-request: + name: Validate exact attended request without secrets + runs-on: tinyland-nix + timeout-minutes: 5 + steps: + - name: Fail closed before protected environment access + env: + EVENT_NAME: ${{ github.event_name }} + REPOSITORY: ${{ github.repository }} + REF: ${{ github.ref }} + RUN_SHA: ${{ github.sha }} + RUN_ATTEMPT: ${{ github.run_attempt }} + ACTION: ${{ inputs.action }} + EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }} + EXPECTED_LIVE_POLICY_SHA256: ${{ inputs.expected_live_policy_sha256 }} + EXPECTED_POLICY_SHA256: ${{ inputs.expected_policy_sha256 }} + CONFIRMATION: ${{ inputs.confirmation }} + run: | + set -euo pipefail + if [ "${EVENT_NAME}" != "workflow_dispatch" ] || + [ "${REPOSITORY}" != "Jesssullivan/tailnet-acl" ] || + [ "${REF}" != "refs/heads/main" ] || + [ "${RUN_ATTEMPT}" != "1" ]; then + echo "::error::ACL publication requires a fresh workflow_dispatch from current main" + exit 1 + fi + if ! [[ "${EXPECTED_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]] || + [[ "${EXPECTED_SOURCE_SHA}" =~ ^0+$ ]] || + [ "${RUN_SHA}" != "${EXPECTED_SOURCE_SHA}" ]; then + echo "::error::expected_source_sha must equal the exact dispatched main SHA" + exit 1 + fi + + case "${ACTION}" in + plan) + if [ -n "${EXPECTED_LIVE_POLICY_SHA256}" ] || + [ -n "${EXPECTED_POLICY_SHA256}" ] || + [ "${CONFIRMATION}" != "plan-tailnet-acl-${EXPECTED_SOURCE_SHA}" ]; then + echo "::error::plan forbids accepted digests and requires an exact source-bound confirmation" + exit 1 + fi + ;; + apply) + if ! [[ "${EXPECTED_LIVE_POLICY_SHA256}" =~ ^[0-9a-f]{64}$ ]] || + [[ "${EXPECTED_LIVE_POLICY_SHA256}" =~ ^0+$ ]] || + ! [[ "${EXPECTED_POLICY_SHA256}" =~ ^[0-9a-f]{64}$ ]] || + [[ "${EXPECTED_POLICY_SHA256}" =~ ^0+$ ]] || + [ "${CONFIRMATION}" != "apply-tailnet-acl-${EXPECTED_SOURCE_SHA}" ]; then + echo "::error::apply requires exact accepted digests and source-bound confirmation" + exit 1 + fi + ;; + *) + echo "::error::action must be plan or apply" + exit 1 + ;; + esac + + plan: + name: Produce exact live-policy plan + if: github.event_name == 'workflow_dispatch' && inputs.action == 'plan' + needs: validate-request + environment: tailnet-acl-production + runs-on: tinyland-nix + timeout-minutes: 15 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + ref: main + + - name: Build exact policy + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${EXPECTED_SOURCE_SHA}" + nix develop --command just build + env: + EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }} + XDG_CACHE_HOME: ${{ runner.temp }}/.cache + + - name: Validate least-authority credential shape + env: + TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_ACL_READ_OAUTH_CLIENT_SECRET }} + TS_OAUTH_CLIENT_ID: ${{ vars.TAILSCALE_ACL_READ_OAUTH_CLIENT_ID }} + run: | + set -euo pipefail + case "${TAILSCALE_API_KEY:-}" in + tskey-client-*) ;; + *) + echo "::error::protected environment must provide the read-only policy OAuth client secret" + exit 1 + ;; + esac + test -n "${TS_OAUTH_CLIENT_ID:-}" + + - name: Plan and record exact policy digests + env: + EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }} + TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_ACL_READ_OAUTH_CLIENT_SECRET }} + TS_OAUTH_CLIENT_ID: ${{ vars.TAILSCALE_ACL_READ_OAUTH_CLIENT_ID }} + XDG_CACHE_HOME: ${{ runner.temp }}/.cache + run: | + set -euo pipefail + nix develop --command python3 scripts/push.py \ + --dry-run \ + --source-sha "${EXPECTED_SOURCE_SHA}" \ + --receipt "${RUNNER_TEMP}/tailnet-acl-plan.json" + + - name: Record non-secret plan receipt + if: always() + env: + EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }} + run: | + set -euo pipefail + { + echo "### Tailnet ACL plan" + echo + printf -- "- source SHA: \`%s\`\n" "${EXPECTED_SOURCE_SHA}" + if [ -s "${RUNNER_TEMP}/tailnet-acl-plan.json" ]; then + python3 - "${RUNNER_TEMP}/tailnet-acl-plan.json" <<'PY' + import json + import sys + + with open(sys.argv[1], encoding="utf-8") as handle: + receipt = json.load(handle) + print(f"- live policy SHA-256: `{receipt['pre_policy_sha256']}`") + print(f"- generated policy SHA-256: `{receipt['local_policy_sha256']}`") + print(f"- live policy ETag: `{receipt['pre_write_etag']}`") + print(f"- outcome: `{receipt['outcome']}`") + print(f"- changed: `{str(receipt['changed']).lower()}`") + PY + fi + } >>"${GITHUB_STEP_SUMMARY}" + + - name: Upload non-secret plan receipt + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: tailnet-acl-plan-${{ github.run_id }} + path: ${{ runner.temp }}/tailnet-acl-plan.json + if-no-files-found: error + retention-days: 30 + + apply: + name: Apply accepted exact policy and prove zero diff + if: github.event_name == 'workflow_dispatch' && inputs.action == 'apply' + needs: validate-request + environment: tailnet-acl-production + runs-on: tinyland-nix + timeout-minutes: 15 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + ref: main + + - name: Build exact policy + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${EXPECTED_SOURCE_SHA}" + nix develop --command just build + env: + EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }} + XDG_CACHE_HOME: ${{ runner.temp }}/.cache + + - name: Validate least-authority credential shape + env: + TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_ACL_WRITE_OAUTH_CLIENT_SECRET }} + TS_OAUTH_CLIENT_ID: ${{ vars.TAILSCALE_ACL_WRITE_OAUTH_CLIENT_ID }} + run: | + set -euo pipefail + case "${TAILSCALE_API_KEY:-}" in + tskey-client-*) ;; + *) + echo "::error::protected environment must provide the policy-write OAuth client secret" + exit 1 + ;; + esac + test -n "${TS_OAUTH_CLIENT_ID:-}" + + - name: Apply only the accepted exact plan + env: + EXPECTED_LIVE_POLICY_SHA256: ${{ inputs.expected_live_policy_sha256 }} + EXPECTED_POLICY_SHA256: ${{ inputs.expected_policy_sha256 }} + EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }} + TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_ACL_WRITE_OAUTH_CLIENT_SECRET }} + TS_OAUTH_CLIENT_ID: ${{ vars.TAILSCALE_ACL_WRITE_OAUTH_CLIENT_ID }} + XDG_CACHE_HOME: ${{ runner.temp }}/.cache + run: | + set -euo pipefail + nix develop --command python3 scripts/push.py \ + --confirm \ + --expect-live-sha256 "${EXPECTED_LIVE_POLICY_SHA256}" \ + --expect-policy-sha256 "${EXPECTED_POLICY_SHA256}" \ + --source-sha "${EXPECTED_SOURCE_SHA}" \ + --receipt "${RUNNER_TEMP}/tailnet-acl-apply.json" + + - name: Upload non-secret apply receipt + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: tailnet-acl-apply-${{ github.run_id }} + path: ${{ runner.temp }}/tailnet-acl-apply.json + if-no-files-found: error + retention-days: 30 diff --git a/constants.dhall b/constants.dhall index da63c61..e606b17 100644 --- a/constants.dhall +++ b/constants.dhall @@ -26,6 +26,7 @@ let tag = , services = "tag:services" , k8s = "tag:k8s" , k8s_operator = "tag:k8s-operator" + , rke2_egress = "tag:rke2-egress" , mcp_proxy = "tag:mcp-proxy" , tsidp = "tag:tsidp" , dev = "tag:dev" diff --git a/docs/acl-publication-authority.md b/docs/acl-publication-authority.md new file mode 100644 index 0000000..fb2250c --- /dev/null +++ b/docs/acl-publication-authority.md @@ -0,0 +1,106 @@ +# ACL publication authority + +Normal pull-request and main-push workflows build and validate source only. +They run exclusively on sanctioned `tinyland-nix` capacity and receive no +Tailscale credential or protected environment. + +Repository workflow publication is confined to +`.github/workflows/publish-acl.yml`. It is manual, exact-head, digest-bound, +and protected by the +`tailnet-acl-production` environment. + +## Pre-landing control-plane prerequisites + +Before landing this workflow transition: + +1. Permanently disable historical `.github/workflows/cd.yml` workflow ID + `238207465`. Its source is now a tombstone, but source changes, branch + protection, and branch deletion do not revoke the old workflow identity or + old credentialed runs. +2. Configure `tailnet-acl-production` to allow only `main`, require an attended + reviewer, and prevent self-review where GitHub supports it. +3. Create a read-only plan OAuth client with exactly + `policy_file:read`, `devices:posture_attributes:read`, and + `devices:core:read`. Store its secret as + `TAILSCALE_ACL_READ_OAUTH_CLIENT_SECRET` and its non-secret client ID as + `TAILSCALE_ACL_READ_OAUTH_CLIENT_ID`. +4. Create a separate apply OAuth client with exactly `policy_file`, + `devices:posture_attributes`, and `devices:core:read`. Store its secret as + `TAILSCALE_ACL_WRITE_OAUTH_CLIENT_SECRET` and its non-secret client ID as + `TAILSCALE_ACL_WRITE_OAUTH_CLIENT_ID`. Do not use a broad admin API key. +5. Under a separately attended denial-proof authorization, prove that ID + `238207465` cannot dispatch from `main` or any retained historical branch + or tag, and that historical credentialed run `30311167574` cannot be rerun + in whole or by failed-job replay. Require non-success API responses and no + workflow-run-count change. Source tombstoning alone is not this proof. + +## Post-landing, pre-dispatch barrier + +The new `.github/workflows/publish-acl.yml` identity does not exist on the +default branch until this source lands. After landing and before any plan: + +1. Record the new workflow's exact numeric ID and confirm it is `active`. +2. Reconfirm retired workflow ID `238207465` remains `disabled_manually`; + landing its tombstone must not be allowed to re-enable that identity. +3. Reconfirm the protected environment, exact OAuth clients, and denial-proof + evidence above are unchanged. + +## Attended plan + +Dispatch `Publish Tailnet ACL (attended)` from the exact current `main` commit: + +- `action`: `plan` +- `expected_source_sha`: exact 40-character `main` commit +- accepted digest inputs: empty +- `confirmation`: `plan-tailnet-acl-` + +The protected job rebuilds the policy and requests an access token carrying +exactly the three read scopes above. Missing or additional returned scopes are +rejected. Before credential exchange, the publisher independently requires Git +HEAD to equal `expected_source_sha` and rejects every tracked or untracked +source change; only build output `generated/policy.json` is exempt. The job +reads the live ACL and retains the API's opaque `ETag`, then emits a non-secret +receipt containing the verified source SHA, pre-write ETag, canonical live and +generated SHA-256 digests, write-attempted flag, and outcome. Review the source +diff, ETag, and both receipt digests before authorizing publication. + +## Attended apply + +Dispatch the same exact `main` commit again: + +- `action`: `apply` +- `expected_source_sha`: the plan's exact source commit +- `expected_live_policy_sha256`: the accepted live digest +- `expected_policy_sha256`: the accepted generated digest +- `confirmation`: `apply-tailnet-acl-` + +The publisher requests a token carrying exactly the three apply scopes above, +re-reads both policies, and refuses mutation if either digest has changed. It +retains the new GET `ETag` and sends that exact opaque value as `If-Match` on +the policy POST. HTTP 412 is classified as a confirmed no-write concurrent +change, never a successful apply. + +After every non-cancelled write attempt—including HTTP 412 and other write +errors—the publisher re-reads the live policy. Its durable receipt records the +source SHA, pre/post ETags, pre/local/post canonical digests, whether a write +was attempted, and the reconciled outcome. A successful apply requires the +post-write digest to equal the accepted generated-policy digest. + +The outcome keeps confirmed HTTP rejection separate from an unconfirmed 5xx or +transport response. For an ambiguous response, reconciliation reports whether +the live policy remained at the pre-write digest, equals the intended local +digest, or moved to a third digest; equality never infers which actor wrote it. + +The apply command requires a receipt path. It atomically persists a +`write_attempt_pending_reconciliation` receipt before sending the POST, then +atomically replaces that receipt only after reconciliation. Failure to write +the intent receipt prevents the POST; failure to replace it cannot erase the +durable pending-attempt evidence. Each receipt write flushes and `fsync`s its +0600 temporary file before replacement, then `fsync`s the parent directory. +This is local-filesystem durability; the `always()` artifact upload remains the +separate durable off-runner copy after the step returns. + +The workflow rejects every `run_attempt` after the first. Never rerun an old +publication run to apply a newer intent. Any source, live policy, credential, +environment, or workflow-identity change requires a fresh plan and a new +attended apply. diff --git a/docs/rke2-egress-authority.md b/docs/rke2-egress-authority.md new file mode 100644 index 0000000..d5732f3 --- /dev/null +++ b/docs/rke2-egress-authority.md @@ -0,0 +1,71 @@ +# RKE2 egress authority + +`tag:rke2-egress` is the dedicated data-plane identity for the Tailscale +Kubernetes operator's RKE2 control-plane egress ProxyGroup. It must not be +replaced with `tag:k8s`, `tag:k8s-operator`, or another broadly authorized tag. + +The authority is intentionally narrow: + +- `tag:k8s-operator` may assign `tag:rke2-egress`; administrators retain + break-glass ownership. +- A device carrying `tag:rke2-egress` may reach only the `tinyland-honey` host + alias. +- The only permitted transports are TCP 6443 (Kubernetes API) and TCP 9345 + (RKE2 supervisor). +- The tag has no legacy ACL rule, wildcard destination, wildcard port, subnet, + SSH, application capability, or tag-management authority. +- No additional direct grant or legacy ACL may target Honey's 6443/9345 + endpoints through the `tinyland-honey` alias, its current IP, a containing + CIDR, or a wildcard. The source-literal census and compiled-policy guard + cover all policy fragments, not only rules mentioning `tag:rke2-egress`. + +## Existing selector overlap and proof boundary + +This contract narrows the new ProxyGroup identity. It does **not** prove +Honey-wide exclusivity or remove authority that reaches the same device through +mutable tag membership. + +The 2026-07-30Z read-only inventory found Honey carrying `tag:dollhouse`, +`tag:subnet-router`, and `tag:switch`. Existing rules for those selectors +include broad paths that can overlap Honey's 6443/9345 endpoints. Those tags +and rules predate this source slice; no live tag or policy migration is +authorized here. + +Before claiming that `tag:rke2-egress` is the sole Honey control-plane path, +run a separately attended live preflight that: + +1. inventories Honey's current tags and addresses; +2. resolves every grant and legacy ACL destination selector, port wildcard, + and port range against that membership; +3. records every source that can reach 6443 or 9345; and +4. either accepts that explicit overlap or lands a separately reviewed tag and + policy migration. + +The source-only guards intentionally cover direct alias/IP/CIDR/wildcard +recurrence. They cannot prove facts about mutable live device-tag membership. + +This policy is a prerequisite for the TIN-620 Blahaj ProxyGroup source. Apply +and verify the reviewed tailnet policy before creating ProxyGroup devices, +because a ProxyGroup's device tags cannot be changed in place. The Blahaj +resource must request exactly `tag:rke2-egress`. + +This source contract does not authorize an ACL push, ProxyGroup creation, +DNSConfig or CoreDNS rollout, cluster mutation, or continuity/failure proof. +Those remain separately attended operations. + +ACL planning and publication must follow the +[ACL publication authority](acl-publication-authority.md) sequence. + +The generated-policy tests verify the effective compiled contract. The +source-contract test independently guards the same boundary without requiring +Dhall or Nix: + +```console +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest \ + tests.test_rke2_egress_source_contract +``` + +See the Tailscale operator documentation for +[high-availability ProxyGroups](https://tailscale.com/docs/kubernetes-operator/manage-and-configure/high-availability) +and +[tailnet egress Services](https://tailscale.com/docs/kubernetes-operator/egress/access-tailnet-service). diff --git a/flake.nix b/flake.nix index 4450e39..820199b 100644 --- a/flake.nix +++ b/flake.nix @@ -14,6 +14,7 @@ { devShells.default = pkgs.mkShell { packages = [ + pkgs.actionlint pkgs.dhall pkgs.dhall-json pkgs.just diff --git a/fragments/core.dhall b/fragments/core.dhall index 7c4ee06..a2a0451 100644 --- a/fragments/core.dhall +++ b/fragments/core.dhall @@ -43,6 +43,13 @@ let tagOwners , C.group.dollhouse_admins ] } + , { mapKey = C.tag.rke2_egress + , mapValue = + [ C.tag.k8s_operator + , C.autogroup.admin + , C.group.dollhouse_admins + ] + } , { mapKey = C.tag.mcp_proxy , mapValue = [ C.tag.k8s_operator, C.autogroup.admin, C.group.dollhouse_admins ] diff --git a/grants.json b/grants.json index 1ae674c..97f3b0d 100644 --- a/grants.json +++ b/grants.json @@ -35,6 +35,10 @@ "src": ["tinyland-honey"], "dst": ["tag:mcp-proxy"], "ip": ["tcp:8080"] +}, { + "src": ["tag:rke2-egress"], + "dst": ["tinyland-honey"], + "ip": ["tcp:6443", "tcp:9345"] }, { "src": ["tag:dollhouse"], "dst": ["tag:dollhouse"], diff --git a/justfile b/justfile index c120f33..ba4107f 100644 --- a/justfile +++ b/justfile @@ -5,7 +5,9 @@ # just build # compile Dhall + merge grants # just validate # compare to live ACL # just diff # show what would change -# just push # push to live (requires --confirm) +# SOURCE_SHA=... RECEIPT=... EXPECTED_LIVE_POLICY_SHA256=... \ +# EXPECTED_POLICY_SHA256=... just push +# # attended push bound to source, receipt, and plan digests # just fmt # format all Dhall files set shell := ["bash", "-euo", "pipefail", "-c"] @@ -27,9 +29,14 @@ validate: build diff: build @python3 {{repo_root}}/scripts/push.py --dry-run -# Push generated policy to live Tailscale ACL +# Push the generated policy only when both accepted plan digests still match. push: build - @python3 {{repo_root}}/scripts/push.py --confirm + @python3 {{repo_root}}/scripts/push.py \ + --confirm \ + --expect-live-sha256 "${EXPECTED_LIVE_POLICY_SHA256:?required}" \ + --expect-policy-sha256 "${EXPECTED_POLICY_SHA256:?required}" \ + --source-sha "${SOURCE_SHA:?required}" \ + --receipt "${RECEIPT:?required}" # Format all Dhall files fmt: diff --git a/scripts/push.py b/scripts/push.py index ac85bc1..314b96c 100755 --- a/scripts/push.py +++ b/scripts/push.py @@ -4,32 +4,160 @@ Compares the generated policy with the live ACL, shows the diff, and pushes only if --confirm is passed. -Requires: TAILSCALE_API_KEY environment variable +Requires: an exact-scope OAuth client secret in TAILSCALE_API_KEY +and its client ID in TS_OAUTH_CLIENT_ID Tailnet: taila4c78d.ts.net """ import argparse +import hashlib import json import os +import re +import subprocess import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass from pathlib import Path -from ts_auth import resolve_bearer - -try: - import urllib.request - import urllib.error -except ImportError: - pass - REPO_ROOT = Path(__file__).resolve().parent.parent GENERATED_POLICY = REPO_ROOT / "generated" / "policy.json" TAILNET = "taila4c78d.ts.net" API_BASE = f"https://api.tailscale.com/api/v2/tailnet/{TAILNET}" +OAUTH_TOKEN_URL = "https://api.tailscale.com/api/v2/oauth/token" +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +SOURCE_SHA_RE = re.compile(r"^[0-9a-f]{40}$") + +PLAN_SCOPES = frozenset( + { + "devices:core:read", + "devices:posture_attributes:read", + "policy_file:read", + } +) +APPLY_SCOPES = frozenset( + { + "devices:core:read", + "devices:posture_attributes", + "policy_file", + } +) + + +@dataclass(frozen=True) +class LiveAcl: + """One concurrency-bound observation of the live policy.""" + + policy: dict + etag: str -def fetch_live_acl(api_key: str) -> dict: - """Fetch the current ACL from the Tailscale API.""" +@dataclass(frozen=True) +class PushAttempt: + """The server's disposition of one conditional write request.""" + + outcome: str + http_status: int | None = None + + +def verify_source_state(expected_source_sha: str) -> None: + """Bind receipts to the exact clean Git source used for the build.""" + try: + head = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + status = subprocess.run( + [ + "git", + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + ], + cwd=REPO_ROOT, + check=True, + capture_output=True, + ).stdout + except (OSError, subprocess.CalledProcessError) as e: + raise RuntimeError("failed to inspect Git source state") from e + + if head != expected_source_sha: + raise RuntimeError( + f"source SHA mismatch: expected {expected_source_sha}, observed {head}" + ) + + # `just build` intentionally creates this untracked artifact before plan + # or apply. Every tracked change and every other untracked path is source + # drift and invalidates the receipt binding. + allowed_generated_path = b"generated/policy.json" + unexpected = [] + for entry in status.split(b"\0"): + if not entry: + continue + path = entry[3:] if len(entry) >= 4 else entry + if path == allowed_generated_path: + continue + unexpected.append(entry.decode("utf-8", errors="backslashreplace")) + if unexpected: + raise RuntimeError( + "source worktree is not clean: " + ", ".join(unexpected) + ) + + +def resolve_scoped_bearer(secret: str, expected_scopes: frozenset[str]) -> str: + """Exchange an OAuth client secret for one exact-scope access token.""" + if not secret.startswith("tskey-client-"): + raise RuntimeError( + "ACL publication requires a scoped OAuth client secret " + "(tskey-client-...), not a broad API key" + ) + + client_id = os.environ.get("TS_OAUTH_CLIENT_ID", "") + if not client_id: + raise RuntimeError("TS_OAUTH_CLIENT_ID is required for OAuth token exchange") + + requested_scope = " ".join(sorted(expected_scopes)) + data = urllib.parse.urlencode( + { + "client_id": client_id, + "client_secret": secret, + "grant_type": "client_credentials", + "scope": requested_scope, + } + ).encode() + req = urllib.request.Request(OAUTH_TOKEN_URL, data=data) + req.add_header("Content-Type", "application/x-www-form-urlencoded") + + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode()) + + token = payload.get("access_token") + returned_scope = payload.get("scope") + if not token: + raise RuntimeError("OAuth token exchange returned no access_token") + if not isinstance(returned_scope, str): + raise RuntimeError("OAuth token exchange returned no scope") + + returned_scopes = returned_scope.split() + if ( + len(returned_scopes) != len(set(returned_scopes)) + or set(returned_scopes) != expected_scopes + ): + raise RuntimeError( + "OAuth token scope mismatch: " + f"requested {requested_scope!r}, returned {returned_scope!r}" + ) + return token + + +def fetch_live_acl(api_key: str) -> LiveAcl: + """Fetch the current ACL together with its opaque concurrency ETag.""" url = f"{API_BASE}/acl" req = urllib.request.Request(url) req.add_header("Authorization", f"Bearer {api_key}") @@ -37,29 +165,53 @@ def fetch_live_acl(api_key: str) -> dict: try: with urllib.request.urlopen(req) as resp: - return json.loads(resp.read().decode()) + etag = resp.headers.get("ETag", "") + if not etag: + raise RuntimeError("Tailscale ACL GET returned no ETag") + return LiveAcl( + policy=json.loads(resp.read().decode()), + etag=etag, + ) except urllib.error.HTTPError as e: - print(f"API error {e.code}: {e.read().decode()}", file=sys.stderr) - sys.exit(1) + raise RuntimeError(f"ACL GET failed with HTTP {e.code}") from e -def push_acl(api_key: str, policy: dict) -> bool: - """Push an ACL policy to the Tailscale API.""" +def push_acl(api_key: str, policy: dict, *, etag: str) -> PushAttempt: + """Conditionally push an ACL policy against the exact observed ETag.""" url = f"{API_BASE}/acl" data = json.dumps(policy).encode("utf-8") req = urllib.request.Request(url, data=data, method="POST") req.add_header("Authorization", f"Bearer {api_key}") req.add_header("Content-Type", "application/json") + req.add_header("If-Match", etag) try: - with urllib.request.urlopen(req) as resp: - result = json.loads(resp.read().decode()) - return True + with urllib.request.urlopen(req): + return PushAttempt("accepted") except urllib.error.HTTPError as e: - body = e.read().decode() - print(f"Push failed (HTTP {e.code}):\n{body}", file=sys.stderr) - return False + status = e.code + e.close() + if status == 412: + return PushAttempt("precondition_failed", http_status=status) + print(f"Push failed (HTTP {status}).", file=sys.stderr) + if 400 <= status < 500: + return PushAttempt("rejected", http_status=status) + return PushAttempt("response_ambiguous", http_status=status) + except Exception as e: + print(f"Push failed before a response was confirmed: {e}", file=sys.stderr) + return PushAttempt("response_ambiguous") + + +def policy_sha256(policy: dict) -> str: + """Return a deterministic digest for the semantic JSON policy.""" + canonical = json.dumps( + policy, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() def summarize_diff(live: dict, local: dict) -> list[str]: @@ -92,6 +244,94 @@ def summarize_diff(live: dict, local: dict) -> list[str]: return lines +def write_receipt( + path: Path, + *, + source_sha: str, + pre_write_etag: str, + pre_policy_sha256: str, + local_sha256: str, + post_write_etag: str | None, + post_policy_sha256: str | None, + write_attempted: bool, + outcome: str, + changes: list[str], +) -> None: + """Atomically write a non-secret plan/apply reconciliation receipt.""" + receipt = { + "tailnet": TAILNET, + "source_sha": source_sha, + "pre_write_etag": pre_write_etag, + "pre_policy_sha256": pre_policy_sha256, + "local_policy_sha256": local_sha256, + "post_write_etag": post_write_etag, + "post_policy_sha256": post_policy_sha256, + "write_attempted": write_attempted, + "outcome": outcome, + "changed": ( + post_policy_sha256 + if post_policy_sha256 is not None + else pre_policy_sha256 + ) + != local_sha256, + "changes": changes, + } + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(temporary, flags, 0o600) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = -1 + handle.write(json.dumps(receipt, indent=2, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + finally: + if descriptor >= 0: + os.close(descriptor) + + os.replace(temporary, path) + directory_flags = os.O_RDONLY + if hasattr(os, "O_DIRECTORY"): + directory_flags |= os.O_DIRECTORY + directory = os.open(path.parent, directory_flags) + try: + os.fsync(directory) + finally: + os.close(directory) + + +def validate_expected_digest(name: str, expected: str, actual: str) -> bool: + """Fail closed when an accepted digest is absent, malformed, or stale.""" + if not SHA256_RE.fullmatch(expected): + print(f"ERROR: {name} must be exactly 64 lowercase hex characters.", file=sys.stderr) + return False + if expected != actual: + print( + f"ERROR: {name} mismatch: expected {expected}, observed {actual}.", + file=sys.stderr, + ) + return False + return True + + +def reconciled_state( + *, + pre_policy_sha256: str, + local_policy_sha256: str, + post_policy_sha256: str, +) -> str: + """Classify the reconciled live state without inferring write authorship.""" + if post_policy_sha256 == local_policy_sha256: + return "local_state" + if post_policy_sha256 == pre_policy_sha256: + return "pre_state" + return "third_state" + + def main() -> int: parser = argparse.ArgumentParser(description="Push Tailscale ACL policy") parser.add_argument( @@ -104,14 +344,59 @@ def main() -> int: action="store_true", help="Show what would change without pushing", ) + parser.add_argument( + "--expect-live-sha256", + default="", + help="Required with --confirm: accepted digest of the live policy", + ) + parser.add_argument( + "--expect-policy-sha256", + default="", + help="Required with --confirm: accepted digest of the generated policy", + ) + parser.add_argument( + "--receipt", + type=Path, + help="Write a non-secret JSON receipt containing concurrency evidence", + ) + parser.add_argument( + "--source-sha", + default="", + help="Exact source commit recorded in --receipt", + ) args = parser.parse_args() - api_key = os.environ.get("TAILSCALE_API_KEY") - if not api_key: + if args.confirm and args.dry_run: + parser.error("--confirm and --dry-run are mutually exclusive") + if args.confirm and ( + not SHA256_RE.fullmatch(args.expect_live_sha256) + or not SHA256_RE.fullmatch(args.expect_policy_sha256) + ): + parser.error( + "--confirm requires both --expect-live-sha256 and " + "--expect-policy-sha256 as 64 lowercase hex characters" + ) + if args.confirm and not args.receipt: + parser.error("--confirm requires --receipt for durable write evidence") + if args.receipt and not SOURCE_SHA_RE.fullmatch(args.source_sha): + parser.error("--receipt requires --source-sha as exactly 40 lowercase hex") + + if args.receipt: + try: + verify_source_state(args.source_sha) + except Exception as e: + print(f"ERROR: source binding failed: {e}", file=sys.stderr) + return 1 + + oauth_secret = os.environ.get("TAILSCALE_API_KEY") + if not oauth_secret: print("ERROR: TAILSCALE_API_KEY environment variable is required.", file=sys.stderr) return 1 try: - api_key = resolve_bearer(api_key) + api_key = resolve_scoped_bearer( + oauth_secret, + APPLY_SCOPES if args.confirm else PLAN_SCOPES, + ) except Exception as e: print(f"ERROR: failed to resolve Tailscale token: {e}", file=sys.stderr) return 1 @@ -124,19 +409,73 @@ def main() -> int: local = json.load(f) print("Fetching live ACL ...", file=sys.stderr) - live = fetch_live_acl(api_key) + try: + pre_write = fetch_live_acl(api_key) + except Exception as e: + print(f"ERROR: failed to fetch live ACL: {e}", file=sys.stderr) + return 1 + + live = pre_write.policy + live_sha256 = policy_sha256(pre_write.policy) + local_sha256 = policy_sha256(local) + diff_lines = summarize_diff(live, local) + + print(f"Live policy SHA-256: {live_sha256}", file=sys.stderr) + print(f"Generated policy SHA-256: {local_sha256}", file=sys.stderr) + + def record( + *, + outcome: str, + write_attempted: bool = False, + post_write: LiveAcl | None = None, + ) -> None: + if not args.receipt: + return + write_receipt( + args.receipt, + source_sha=args.source_sha, + pre_write_etag=pre_write.etag, + pre_policy_sha256=live_sha256, + local_sha256=local_sha256, + post_write_etag=post_write.etag if post_write else None, + post_policy_sha256=( + policy_sha256(post_write.policy) if post_write else None + ), + write_attempted=write_attempted, + outcome=outcome, + changes=diff_lines, + ) + + if args.confirm: + expectations_match = validate_expected_digest( + "expected live policy SHA-256", + args.expect_live_sha256, + live_sha256, + ) + expectations_match = ( + validate_expected_digest( + "expected generated policy SHA-256", + args.expect_policy_sha256, + local_sha256, + ) + and expectations_match + ) + if not expectations_match: + record(outcome="rejected_stale_plan") + return 3 if live == local: print("No changes: local policy matches live ACL.", file=sys.stderr) + record(outcome="already_converged") return 0 print("\nChanges to apply:", file=sys.stderr) - diff_lines = summarize_diff(live, local) for line in diff_lines: print(f" {line}", file=sys.stderr) if args.dry_run: print("\n(dry run, no changes made)", file=sys.stderr) + record(outcome="plan_changes") return 0 if not args.confirm: @@ -144,15 +483,94 @@ def main() -> int: "\nTo apply these changes, run again with --confirm.", file=sys.stderr, ) + record(outcome="plan_changes_unconfirmed") return 2 print("\nPushing policy to Tailscale API ...", file=sys.stderr) - if push_acl(api_key, local): - print("Push successful.", file=sys.stderr) - return 0 - else: + # Persist intent before issuing the POST. A failed atomic final receipt + # replacement leaves this evidence intact rather than erasing the attempt. + record( + outcome="write_attempt_pending_reconciliation", + write_attempted=True, + ) + attempt = push_acl(api_key, local, etag=pre_write.etag) + + # Every non-cancelled write attempt is reconciled, even when the write + # response is a confirmed precondition failure or another error. + try: + post_write = fetch_live_acl(api_key) + except Exception as e: + print(f"ERROR: post-attempt reconciliation failed: {e}", file=sys.stderr) + record( + outcome=f"{attempt.outcome}_reconciliation_failed", + write_attempted=True, + ) return 1 + observed_sha256 = policy_sha256(post_write.policy) + post_state = reconciled_state( + pre_policy_sha256=live_sha256, + local_policy_sha256=local_sha256, + post_policy_sha256=observed_sha256, + ) + if attempt.outcome == "precondition_failed": + record( + outcome="precondition_failed_confirmed_no_write", + write_attempted=True, + post_write=post_write, + ) + print( + "ERROR: conditional write was rejected with HTTP 412; " + "the request made no write and live policy was reconciled.", + file=sys.stderr, + ) + return 4 + + if attempt.outcome == "rejected": + record( + outcome=f"write_rejected_reconciled_{post_state}", + write_attempted=True, + post_write=post_write, + ) + return 1 + + if attempt.outcome == "response_ambiguous": + record( + outcome=f"write_response_ambiguous_reconciled_{post_state}", + write_attempted=True, + post_write=post_write, + ) + return 1 + + if attempt.outcome != "accepted": + record( + outcome=f"unknown_write_result_reconciled_{post_state}", + write_attempted=True, + post_write=post_write, + ) + return 1 + + if post_state != "local_state": + record( + outcome=f"write_accepted_reconciliation_{post_state}", + write_attempted=True, + post_write=post_write, + ) + print( + "ERROR: post-push zero-diff proof failed: " + f"expected {local_sha256}, observed {observed_sha256}.", + file=sys.stderr, + ) + return 1 + + record( + outcome="write_accepted_reconciled", + write_attempted=True, + post_write=post_write, + ) + print(f"Push successful; zero-diff SHA-256: {observed_sha256}", file=sys.stderr) + return 0 + if __name__ == "__main__": sys.exit(main()) diff --git a/tests/test_policy_contract.py b/tests/test_policy_contract.py index a96d273..468c9d2 100644 --- a/tests/test_policy_contract.py +++ b/tests/test_policy_contract.py @@ -1,3 +1,4 @@ +import ipaddress import json import unittest from pathlib import Path @@ -31,6 +32,105 @@ def test_honey_does_not_receive_broad_kubernetes_acl_access(self) -> None: } self.assertNotIn(broad_rule, self.policy["acls"]) + def test_kubernetes_operator_owns_rke2_egress_tag(self) -> None: + owners = self.policy["tagOwners"]["tag:rke2-egress"] + self.assertEqual( + owners, + [ + "tag:k8s-operator", + "autogroup:admin", + "group:dollhouse-admins", + ], + ) + + def test_rke2_egress_authority_is_exact(self) -> None: + expected = { + "src": ["tag:rke2-egress"], + "dst": ["tinyland-honey"], + "ip": ["tcp:6443", "tcp:9345"], + } + related_grants = [ + grant + for grant in self.policy["grants"] + if "tag:rke2-egress" in grant.get("src", []) + or "tag:rke2-egress" in grant.get("dst", []) + ] + self.assertEqual(related_grants, [expected]) + + related_acls = [ + rule + for rule in self.policy["acls"] + if any("tag:rke2-egress" in value for value in rule["src"] + rule["dst"]) + ] + self.assertEqual(related_acls, []) + for section in ("ssh", "nodeAttrs", "autoApprovers"): + self.assertNotIn( + "tag:rke2-egress", + json.dumps(self.policy[section], sort_keys=True), + ) + + self.assertEqual(self.policy["hosts"]["tinyland-honey"], "100.113.89.12") + + def test_no_parallel_direct_rule_reaches_honey_control_plane(self) -> None: + expected = { + "src": ["tag:rke2-egress"], + "dst": ["tinyland-honey"], + "ip": ["tcp:6443", "tcp:9345"], + } + honey_ip = ipaddress.ip_address(self.policy["hosts"]["tinyland-honey"]) + + def destination_includes_honey(destination: str) -> bool: + if destination in {"tinyland-honey", str(honey_ip), "*"}: + return True + try: + return honey_ip in ipaddress.ip_network(destination, strict=False) + except ValueError: + return False + + self.assertTrue(destination_includes_honey("*")) + self.assertTrue(destination_includes_honey("100.64.0.0/10")) + direct_grants = [ + grant + for grant in self.policy["grants"] + if any( + destination_includes_honey(dst) + for dst in grant.get("dst", []) + ) + ] + self.assertEqual(direct_grants, [expected]) + + def includes_control_plane_port(port_specification: str) -> bool: + for item in port_specification.split(","): + if item == "*": + return True + if "-" in item: + start, end = item.split("-", 1) + if start.isdigit() and end.isdigit(): + if any( + int(start) <= port <= int(end) + for port in (6443, 9345) + ): + return True + elif item.isdigit() and int(item) in {6443, 9345}: + return True + return False + + self.assertTrue(includes_control_plane_port("*")) + self.assertTrue(includes_control_plane_port("6000-10000")) + self.assertTrue(includes_control_plane_port("22,6443")) + direct_legacy_rules = [] + for rule in self.policy["acls"]: + for destination in rule["dst"]: + host, separator, port = destination.rpartition(":") + if ( + separator + and destination_includes_honey(host) + and includes_control_plane_port(port) + ): + direct_legacy_rules.append(rule) + break + self.assertEqual(direct_legacy_rules, []) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_push_digest_contract.py b/tests/test_push_digest_contract.py new file mode 100644 index 0000000..502d93c --- /dev/null +++ b/tests/test_push_digest_contract.py @@ -0,0 +1,907 @@ +import contextlib +import importlib.util +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock +from urllib.parse import parse_qs + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +SPEC = importlib.util.spec_from_file_location("tailnet_acl_push", SCRIPTS / "push.py") +assert SPEC is not None and SPEC.loader is not None +PUSH = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PUSH) + + +class Response: + def __init__( + self, + payload: dict, + *, + headers: dict[str, str] | None = None, + ) -> None: + self.payload = payload + self.headers = headers or {} + + def __enter__(self) -> "Response": + return self + + def __exit__(self, *args: object) -> None: + return None + + def read(self) -> bytes: + return json.dumps(self.payload).encode() + + +class PushDigestContractTest(unittest.TestCase): + def test_digest_is_stable_across_mapping_order(self) -> None: + left = {"tagOwners": {"tag:b": ["b"], "tag:a": ["a"]}, "acls": []} + right = {"acls": [], "tagOwners": {"tag:a": ["a"], "tag:b": ["b"]}} + self.assertEqual(PUSH.policy_sha256(left), PUSH.policy_sha256(right)) + + def test_digest_preserves_authoritative_list_order(self) -> None: + left = {"acls": [{"src": ["a", "b"], "dst": ["c"], "action": "accept"}]} + right = {"acls": [{"src": ["b", "a"], "dst": ["c"], "action": "accept"}]} + self.assertNotEqual(PUSH.policy_sha256(left), PUSH.policy_sha256(right)) + + def test_receipt_contains_only_non_secret_plan_material(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "receipt.json" + PUSH.write_receipt( + path, + source_sha="c" * 40, + pre_write_etag='"pre"', + pre_policy_sha256="a" * 64, + local_sha256="b" * 64, + post_write_etag=None, + post_policy_sha256=None, + write_attempted=False, + outcome="plan_changes", + changes=["~ grants: 9 -> 10 (+1)"], + ) + receipt = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual( + receipt, + { + "changed": True, + "changes": ["~ grants: 9 -> 10 (+1)"], + "local_policy_sha256": "b" * 64, + "outcome": "plan_changes", + "post_policy_sha256": None, + "post_write_etag": None, + "pre_policy_sha256": "a" * 64, + "pre_write_etag": '"pre"', + "source_sha": "c" * 40, + "tailnet": "taila4c78d.ts.net", + "write_attempted": False, + }, + ) + + def test_failed_atomic_receipt_replace_preserves_prior_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "receipt.json" + prior = '{"outcome":"write_attempt_pending_reconciliation"}\n' + path.write_text(prior, encoding="utf-8") + with mock.patch.object( + PUSH.os, + "replace", + side_effect=OSError("simulated replace failure"), + ): + with self.assertRaisesRegex(OSError, "replace failure"): + PUSH.write_receipt( + path, + source_sha="c" * 40, + pre_write_etag='"pre"', + pre_policy_sha256="a" * 64, + local_sha256="b" * 64, + post_write_etag='"post"', + post_policy_sha256="b" * 64, + write_attempted=True, + outcome="write_accepted_reconciled", + changes=[], + ) + self.assertEqual(path.read_text(encoding="utf-8"), prior) + + def test_receipt_fsyncs_file_then_replace_then_parent_directory(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "receipt.json" + temporary = path.with_name(f".{path.name}.tmp") + temporary.write_text("stale temporary", encoding="utf-8") + temporary.chmod(0o644) + events = [] + real_fsync = PUSH.os.fsync + real_replace = PUSH.os.replace + + def fsync(descriptor: int) -> None: + events.append("fsync") + real_fsync(descriptor) + + def replace(source: object, destination: object) -> None: + events.append("replace") + real_replace(source, destination) + + with ( + mock.patch.object(PUSH.os, "fsync", side_effect=fsync), + mock.patch.object(PUSH.os, "replace", side_effect=replace), + ): + PUSH.write_receipt( + path, + source_sha="c" * 40, + pre_write_etag='"pre"', + pre_policy_sha256="a" * 64, + local_sha256="b" * 64, + post_write_etag=None, + post_policy_sha256=None, + write_attempted=True, + outcome="write_attempt_pending_reconciliation", + changes=[], + ) + + self.assertEqual(events, ["fsync", "replace", "fsync"]) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + def test_failed_pre_replace_fsync_preserves_prior_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "receipt.json" + prior = '{"outcome":"write_attempt_pending_reconciliation"}\n' + path.write_text(prior, encoding="utf-8") + with mock.patch.object( + PUSH.os, + "fsync", + side_effect=OSError("simulated file fsync failure"), + ): + with self.assertRaisesRegex(OSError, "fsync failure"): + PUSH.write_receipt( + path, + source_sha="c" * 40, + pre_write_etag='"pre"', + pre_policy_sha256="a" * 64, + local_sha256="b" * 64, + post_write_etag='"post"', + post_policy_sha256="b" * 64, + write_attempted=True, + outcome="write_accepted_reconciled", + changes=[], + ) + self.assertEqual(path.read_text(encoding="utf-8"), prior) + + def test_failed_directory_fsync_leaves_complete_replacement_evidence( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "receipt.json" + calls = 0 + real_fsync = PUSH.os.fsync + + def fail_directory_fsync(descriptor: int) -> None: + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("simulated directory fsync failure") + real_fsync(descriptor) + + with mock.patch.object( + PUSH.os, + "fsync", + side_effect=fail_directory_fsync, + ): + with self.assertRaisesRegex(OSError, "directory fsync failure"): + PUSH.write_receipt( + path, + source_sha="c" * 40, + pre_write_etag='"pre"', + pre_policy_sha256="a" * 64, + local_sha256="b" * 64, + post_write_etag='"post"', + post_policy_sha256="b" * 64, + write_attempted=True, + outcome="write_accepted_reconciled", + changes=[], + ) + + replacement = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual( + replacement["outcome"], + "write_accepted_reconciled", + ) + + def test_source_binding_allows_only_exact_head_and_generated_policy( + self, + ) -> None: + expected = "c" * 40 + clean_results = [ + subprocess.CompletedProcess( + args=["git", "rev-parse", "HEAD"], + returncode=0, + stdout=expected + "\n", + stderr="", + ), + subprocess.CompletedProcess( + args=["git", "status"], + returncode=0, + stdout=b"?? generated/policy.json\0", + stderr=b"", + ), + ] + with mock.patch.object( + PUSH.subprocess, + "run", + side_effect=clean_results, + ): + PUSH.verify_source_state(expected) + + dirty_results = [ + clean_results[0], + subprocess.CompletedProcess( + args=["git", "status"], + returncode=0, + stdout=( + b"?? generated/policy.json\0" + b" M scripts/push.py\0" + b"?? unexpected.dhall\0" + ), + stderr=b"", + ), + ] + with mock.patch.object( + PUSH.subprocess, + "run", + side_effect=dirty_results, + ): + with self.assertRaisesRegex(RuntimeError, "not clean"): + PUSH.verify_source_state(expected) + + def test_source_binding_rejects_wrong_head(self) -> None: + results = [ + subprocess.CompletedProcess( + args=["git", "rev-parse", "HEAD"], + returncode=0, + stdout="d" * 40 + "\n", + stderr="", + ), + subprocess.CompletedProcess( + args=["git", "status"], + returncode=0, + stdout=b"", + stderr=b"", + ), + ] + with mock.patch.object(PUSH.subprocess, "run", side_effect=results): + with self.assertRaisesRegex(RuntimeError, "source SHA mismatch"): + PUSH.verify_source_state("c" * 40) + + def test_plan_oauth_exchange_requests_and_requires_exact_read_scopes(self) -> None: + expected = PUSH.PLAN_SCOPES + response = Response( + { + "access_token": "read-token", + "scope": " ".join(reversed(sorted(expected))), + } + ) + with ( + mock.patch.object(PUSH.urllib.request, "urlopen", return_value=response) + as urlopen, + mock.patch.dict( + os.environ, + {"TS_OAUTH_CLIENT_ID": "read-client"}, + clear=False, + ), + ): + token = PUSH.resolve_scoped_bearer("tskey-client-read", expected) + + self.assertEqual(token, "read-token") + request = urlopen.call_args.args[0] + form = parse_qs(request.data.decode()) + self.assertEqual(form["client_id"], ["read-client"]) + self.assertEqual(form["scope"], [" ".join(sorted(expected))]) + + def test_apply_oauth_exchange_rejects_missing_or_extra_scopes(self) -> None: + expected = PUSH.APPLY_SCOPES + cases = { + "missing": expected - {"policy_file"}, + "extra": expected | {"all:read"}, + } + for name, returned_scopes in cases.items(): + with self.subTest(name=name): + response = Response( + { + "access_token": "wrong-token", + "scope": " ".join(sorted(returned_scopes)), + } + ) + with ( + mock.patch.object( + PUSH.urllib.request, + "urlopen", + return_value=response, + ), + mock.patch.dict( + os.environ, + {"TS_OAUTH_CLIENT_ID": "write-client"}, + clear=False, + ), + ): + with self.assertRaisesRegex(RuntimeError, "scope mismatch"): + PUSH.resolve_scoped_bearer("tskey-client-write", expected) + + def test_scoped_exchange_rejects_broad_api_key(self) -> None: + with self.assertRaisesRegex(RuntimeError, "scoped OAuth client"): + PUSH.resolve_scoped_bearer("tskey-api-broad", PUSH.PLAN_SCOPES) + + def test_fetch_requires_and_retains_etag(self) -> None: + response = Response({"acls": []}, headers={"ETag": 'W/"opaque"'}) + with mock.patch.object( + PUSH.urllib.request, + "urlopen", + return_value=response, + ): + observed = PUSH.fetch_live_acl("token") + self.assertEqual(observed, PUSH.LiveAcl({"acls": []}, 'W/"opaque"')) + + with mock.patch.object( + PUSH.urllib.request, + "urlopen", + return_value=Response({"acls": []}), + ): + with self.assertRaisesRegex(RuntimeError, "no ETag"): + PUSH.fetch_live_acl("token") + + def test_push_uses_exact_if_match_etag(self) -> None: + captured = {} + + def open_request(request: object) -> Response: + captured["request"] = request + return Response({}) + + with mock.patch.object(PUSH.urllib.request, "urlopen", side_effect=open_request): + result = PUSH.push_acl( + "token", + {"acls": []}, + etag='W/"exact-opaque-value"', + ) + + self.assertEqual(result, PUSH.PushAttempt("accepted")) + request = captured["request"] + self.assertEqual(request.get_header("If-match"), 'W/"exact-opaque-value"') + + def test_push_classifies_http_412_as_precondition_failure(self) -> None: + failure = PUSH.urllib.error.HTTPError( + PUSH.API_BASE + "/acl", + 412, + "Precondition Failed", + {}, + None, + ) + with mock.patch.object( + PUSH.urllib.request, + "urlopen", + side_effect=failure, + ): + result = PUSH.push_acl( + "token", + {"acls": []}, + etag='"stale"', + ) + self.assertEqual( + result, + PUSH.PushAttempt("precondition_failed", http_status=412), + ) + + def test_push_distinguishes_http_rejection_from_ambiguous_response( + self, + ) -> None: + cases = [ + ( + PUSH.urllib.error.HTTPError( + PUSH.API_BASE + "/acl", + 403, + "Forbidden", + {}, + None, + ), + PUSH.PushAttempt("rejected", http_status=403), + ), + ( + PUSH.urllib.error.HTTPError( + PUSH.API_BASE + "/acl", + 503, + "Unavailable", + {}, + None, + ), + PUSH.PushAttempt("response_ambiguous", http_status=503), + ), + ( + OSError("connection reset after send"), + PUSH.PushAttempt("response_ambiguous"), + ), + ] + for failure, expected in cases: + with ( + self.subTest(failure=repr(failure)), + mock.patch.object( + PUSH.urllib.request, + "urlopen", + side_effect=failure, + ), + contextlib.redirect_stderr(io.StringIO()), + ): + self.assertEqual( + PUSH.push_acl("token", {"acls": []}, etag='"exact"'), + expected, + ) + + def test_reconciled_state_distinguishes_pre_local_and_third_state( + self, + ) -> None: + arguments = { + "pre_policy_sha256": "a" * 64, + "local_policy_sha256": "b" * 64, + } + self.assertEqual( + PUSH.reconciled_state( + **arguments, + post_policy_sha256="a" * 64, + ), + "pre_state", + ) + self.assertEqual( + PUSH.reconciled_state( + **arguments, + post_policy_sha256="b" * 64, + ), + "local_state", + ) + self.assertEqual( + PUSH.reconciled_state( + **arguments, + post_policy_sha256="c" * 64, + ), + "third_state", + ) + + def test_expected_digest_fails_closed(self) -> None: + with contextlib.redirect_stderr(io.StringIO()): + self.assertFalse(PUSH.validate_expected_digest("live", "", "a" * 64)) + self.assertFalse( + PUSH.validate_expected_digest("live", "b" * 64, "a" * 64) + ) + self.assertTrue(PUSH.validate_expected_digest("live", "a" * 64, "a" * 64)) + + def test_confirm_without_accepted_digests_fails_before_credentials(self) -> None: + environment = os.environ.copy() + environment.pop("TAILSCALE_API_KEY", None) + result = subprocess.run( + [sys.executable, str(SCRIPTS / "push.py"), "--confirm"], + capture_output=True, + check=False, + env=environment, + text=True, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("--confirm requires both", result.stderr) + self.assertNotIn("TAILSCALE_API_KEY environment variable is required", result.stderr) + + def test_confirm_without_receipt_fails_before_credentials(self) -> None: + environment = os.environ.copy() + environment.pop("TAILSCALE_API_KEY", None) + result = subprocess.run( + [ + sys.executable, + str(SCRIPTS / "push.py"), + "--confirm", + "--expect-live-sha256", + "a" * 64, + "--expect-policy-sha256", + "b" * 64, + ], + capture_output=True, + check=False, + env=environment, + text=True, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("--confirm requires --receipt", result.stderr) + self.assertNotIn("TAILSCALE_API_KEY environment variable is required", result.stderr) + + def test_wrong_source_binding_fails_before_credentials(self) -> None: + environment = os.environ.copy() + environment.pop("TAILSCALE_API_KEY", None) + with tempfile.TemporaryDirectory() as directory: + result = subprocess.run( + [ + sys.executable, + str(SCRIPTS / "push.py"), + "--dry-run", + "--source-sha", + "c" * 40, + "--receipt", + str(Path(directory) / "receipt.json"), + ], + capture_output=True, + check=False, + env=environment, + text=True, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("source binding failed", result.stderr) + self.assertNotIn("TAILSCALE_API_KEY environment variable is required", result.stderr) + + def test_stale_live_digest_never_reaches_push(self) -> None: + live = {"acls": [{"action": "accept", "src": ["old"], "dst": ["dst"]}]} + local = {"acls": [{"action": "accept", "src": ["new"], "dst": ["dst"]}]} + with tempfile.TemporaryDirectory() as directory: + policy = Path(directory) / "policy.json" + policy.write_text(json.dumps(local), encoding="utf-8") + receipt = Path(directory) / "receipt.json" + fetch = mock.Mock(return_value=PUSH.LiveAcl(live, '"before"')) + push = mock.Mock(return_value=PUSH.PushAttempt("accepted")) + argv = [ + "push.py", + "--confirm", + "--expect-live-sha256", + "c" * 64, + "--expect-policy-sha256", + PUSH.policy_sha256(local), + "--source-sha", + "c" * 40, + "--receipt", + str(receipt), + ] + with ( + mock.patch.object(PUSH, "GENERATED_POLICY", policy), + mock.patch.object( + PUSH, + "verify_source_state", + return_value=None, + ), + mock.patch.object(PUSH, "fetch_live_acl", fetch), + mock.patch.object(PUSH, "push_acl", push), + mock.patch.object( + PUSH, + "resolve_scoped_bearer", + return_value="scoped-token", + ), + mock.patch.object(sys, "argv", argv), + mock.patch.dict( + os.environ, + {"TAILSCALE_API_KEY": "tskey-client-test"}, + clear=False, + ), + contextlib.redirect_stderr(io.StringIO()), + ): + result = PUSH.main() + self.assertEqual(result, 3) + push.assert_not_called() + + def test_apply_rechecks_zero_diff_after_push(self) -> None: + live = {"acls": [{"action": "accept", "src": ["old"], "dst": ["dst"]}]} + local = {"acls": [{"action": "accept", "src": ["new"], "dst": ["dst"]}]} + with tempfile.TemporaryDirectory() as directory: + policy = Path(directory) / "policy.json" + policy.write_text(json.dumps(local), encoding="utf-8") + receipt = Path(directory) / "receipt.json" + events = [] + observations = iter( + [ + PUSH.LiveAcl(live, '"before"'), + PUSH.LiveAcl(local, '"after"'), + ] + ) + + def fetch_acl(*args: object, **kwargs: object) -> object: + events.append("fetch") + return next(observations) + + fetch = mock.Mock(side_effect=fetch_acl) + + def push_acl(*args: object, **kwargs: object) -> object: + events.append("push") + return PUSH.PushAttempt("accepted") + + push = mock.Mock(side_effect=push_acl) + real_write_receipt = PUSH.write_receipt + + def write_receipt(*args: object, **kwargs: object) -> None: + events.append(f"receipt:{kwargs['outcome']}") + real_write_receipt(*args, **kwargs) + + receipt_writer = mock.Mock(side_effect=write_receipt) + argv = [ + "push.py", + "--confirm", + "--expect-live-sha256", + PUSH.policy_sha256(live), + "--expect-policy-sha256", + PUSH.policy_sha256(local), + "--source-sha", + "d" * 40, + "--receipt", + str(receipt), + ] + with ( + mock.patch.object(PUSH, "GENERATED_POLICY", policy), + mock.patch.object( + PUSH, + "verify_source_state", + return_value=None, + ), + mock.patch.object(PUSH, "fetch_live_acl", fetch), + mock.patch.object(PUSH, "push_acl", push), + mock.patch.object(PUSH, "write_receipt", receipt_writer), + mock.patch.object( + PUSH, + "resolve_scoped_bearer", + return_value="scoped-token", + ), + mock.patch.object(sys, "argv", argv), + mock.patch.dict( + os.environ, + {"TAILSCALE_API_KEY": "tskey-client-test"}, + clear=False, + ), + contextlib.redirect_stderr(io.StringIO()), + ): + result = PUSH.main() + recorded = json.loads(receipt.read_text(encoding="utf-8")) + self.assertEqual(result, 0) + self.assertEqual(fetch.call_count, 2) + push.assert_called_once_with("scoped-token", local, etag='"before"') + self.assertEqual(recorded["source_sha"], "d" * 40) + self.assertEqual(recorded["pre_write_etag"], '"before"') + self.assertEqual(recorded["post_write_etag"], '"after"') + self.assertEqual(recorded["pre_policy_sha256"], PUSH.policy_sha256(live)) + self.assertEqual(recorded["post_policy_sha256"], PUSH.policy_sha256(local)) + self.assertTrue(recorded["write_attempted"]) + self.assertEqual(recorded["outcome"], "write_accepted_reconciled") + self.assertEqual( + events, + [ + "fetch", + "receipt:write_attempt_pending_reconciliation", + "push", + "fetch", + "receipt:write_accepted_reconciled", + ], + ) + + def test_ambiguous_local_state_reconciles_before_terminal_receipt( + self, + ) -> None: + live = {"acls": [{"action": "accept", "src": ["old"], "dst": ["dst"]}]} + local = {"acls": [{"action": "accept", "src": ["new"], "dst": ["dst"]}]} + with tempfile.TemporaryDirectory() as directory: + policy = Path(directory) / "policy.json" + policy.write_text(json.dumps(local), encoding="utf-8") + receipt = Path(directory) / "receipt.json" + events = [] + observations = iter( + [ + PUSH.LiveAcl(live, '"before"'), + PUSH.LiveAcl(local, '"after-ambiguous"'), + ] + ) + + def fetch_acl(*args: object, **kwargs: object) -> object: + events.append("fetch") + return next(observations) + + def push_acl(*args: object, **kwargs: object) -> object: + events.append("push") + return PUSH.PushAttempt("response_ambiguous") + + real_write_receipt = PUSH.write_receipt + + def write_receipt(*args: object, **kwargs: object) -> None: + events.append(f"receipt:{kwargs['outcome']}") + real_write_receipt(*args, **kwargs) + + argv = [ + "push.py", + "--confirm", + "--expect-live-sha256", + PUSH.policy_sha256(live), + "--expect-policy-sha256", + PUSH.policy_sha256(local), + "--source-sha", + "e" * 40, + "--receipt", + str(receipt), + ] + with ( + mock.patch.object(PUSH, "GENERATED_POLICY", policy), + mock.patch.object( + PUSH, + "verify_source_state", + return_value=None, + ), + mock.patch.object(PUSH, "fetch_live_acl", side_effect=fetch_acl), + mock.patch.object(PUSH, "push_acl", side_effect=push_acl), + mock.patch.object( + PUSH, + "write_receipt", + side_effect=write_receipt, + ), + mock.patch.object( + PUSH, + "resolve_scoped_bearer", + return_value="scoped-token", + ), + mock.patch.object(sys, "argv", argv), + mock.patch.dict( + os.environ, + {"TAILSCALE_API_KEY": "tskey-client-test"}, + clear=False, + ), + contextlib.redirect_stderr(io.StringIO()), + ): + result = PUSH.main() + recorded = json.loads(receipt.read_text(encoding="utf-8")) + + self.assertEqual(result, 1) + self.assertEqual( + recorded["outcome"], + "write_response_ambiguous_reconciled_local_state", + ) + self.assertEqual( + events, + [ + "fetch", + "receipt:write_attempt_pending_reconciliation", + "push", + "fetch", + "receipt:write_response_ambiguous_reconciled_local_state", + ], + ) + + def test_concurrent_change_returns_412_and_records_confirmed_no_write( + self, + ) -> None: + live = {"acls": [{"action": "accept", "src": ["old"], "dst": ["dst"]}]} + local = {"acls": [{"action": "accept", "src": ["new"], "dst": ["dst"]}]} + concurrent = { + "acls": [{"action": "accept", "src": ["other"], "dst": ["dst"]}] + } + with tempfile.TemporaryDirectory() as directory: + policy = Path(directory) / "policy.json" + policy.write_text(json.dumps(local), encoding="utf-8") + receipt = Path(directory) / "receipt.json" + fetch = mock.Mock( + side_effect=[ + PUSH.LiveAcl(live, '"accepted-plan-etag"'), + PUSH.LiveAcl(concurrent, '"concurrent-etag"'), + ] + ) + push = mock.Mock( + return_value=PUSH.PushAttempt("precondition_failed") + ) + argv = [ + "push.py", + "--confirm", + "--expect-live-sha256", + PUSH.policy_sha256(live), + "--expect-policy-sha256", + PUSH.policy_sha256(local), + "--source-sha", + "e" * 40, + "--receipt", + str(receipt), + ] + with ( + mock.patch.object(PUSH, "GENERATED_POLICY", policy), + mock.patch.object( + PUSH, + "verify_source_state", + return_value=None, + ), + mock.patch.object(PUSH, "fetch_live_acl", fetch), + mock.patch.object(PUSH, "push_acl", push), + mock.patch.object( + PUSH, + "resolve_scoped_bearer", + return_value="scoped-token", + ), + mock.patch.object(sys, "argv", argv), + mock.patch.dict( + os.environ, + {"TAILSCALE_API_KEY": "tskey-client-test"}, + clear=False, + ), + contextlib.redirect_stderr(io.StringIO()), + ): + result = PUSH.main() + recorded = json.loads(receipt.read_text(encoding="utf-8")) + + self.assertEqual(result, 4) + self.assertEqual(fetch.call_count, 2) + push.assert_called_once_with( + "scoped-token", + local, + etag='"accepted-plan-etag"', + ) + self.assertEqual( + recorded["outcome"], + "precondition_failed_confirmed_no_write", + ) + self.assertTrue(recorded["write_attempted"]) + self.assertEqual(recorded["pre_write_etag"], '"accepted-plan-etag"') + self.assertEqual(recorded["post_write_etag"], '"concurrent-etag"') + self.assertEqual( + recorded["post_policy_sha256"], + PUSH.policy_sha256(concurrent), + ) + + def test_ambiguous_write_is_always_reconciled_and_classified(self) -> None: + live = {"acls": [{"action": "accept", "src": ["old"], "dst": ["dst"]}]} + local = {"acls": [{"action": "accept", "src": ["new"], "dst": ["dst"]}]} + with tempfile.TemporaryDirectory() as directory: + policy = Path(directory) / "policy.json" + policy.write_text(json.dumps(local), encoding="utf-8") + receipt = Path(directory) / "receipt.json" + fetch = mock.Mock( + side_effect=[ + PUSH.LiveAcl(live, '"before"'), + PUSH.LiveAcl(live, '"after-failure"'), + ] + ) + push = mock.Mock( + return_value=PUSH.PushAttempt("response_ambiguous") + ) + argv = [ + "push.py", + "--confirm", + "--expect-live-sha256", + PUSH.policy_sha256(live), + "--expect-policy-sha256", + PUSH.policy_sha256(local), + "--source-sha", + "f" * 40, + "--receipt", + str(receipt), + ] + with ( + mock.patch.object(PUSH, "GENERATED_POLICY", policy), + mock.patch.object( + PUSH, + "verify_source_state", + return_value=None, + ), + mock.patch.object(PUSH, "fetch_live_acl", fetch), + mock.patch.object(PUSH, "push_acl", push), + mock.patch.object( + PUSH, + "resolve_scoped_bearer", + return_value="scoped-token", + ), + mock.patch.object(sys, "argv", argv), + mock.patch.dict( + os.environ, + {"TAILSCALE_API_KEY": "tskey-client-test"}, + clear=False, + ), + contextlib.redirect_stderr(io.StringIO()), + ): + result = PUSH.main() + recorded = json.loads(receipt.read_text(encoding="utf-8")) + + self.assertEqual(result, 1) + self.assertEqual(fetch.call_count, 2) + self.assertEqual( + recorded["outcome"], + "write_response_ambiguous_reconciled_pre_state", + ) + self.assertTrue(recorded["write_attempted"]) + self.assertEqual(recorded["post_write_etag"], '"after-failure"') + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rke2_egress_source_contract.py b/tests/test_rke2_egress_source_contract.py new file mode 100644 index 0000000..5f627e0 --- /dev/null +++ b/tests/test_rke2_egress_source_contract.py @@ -0,0 +1,169 @@ +import ipaddress +import json +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CONSTANTS = ROOT / "constants.dhall" +CORE = ROOT / "fragments" / "core.dhall" +GRANTS = ROOT / "grants.json" +FRAGMENTS = ROOT / "fragments" +AUTHORITY_DOC = ROOT / "docs" / "rke2-egress-authority.md" + +TAG = "tag:rke2-egress" +HONEY = "tinyland-honey" +HONEY_IP = "100.113.89.12" +EXPECTED_GRANT = { + "src": [TAG], + "dst": [HONEY], + "ip": ["tcp:6443", "tcp:9345"], +} + + +class Rke2EgressSourceContractTest(unittest.TestCase): + def test_tag_constant_is_defined_once(self) -> None: + constants = CONSTANTS.read_text(encoding="utf-8") + self.assertEqual(constants.count('rke2_egress = "tag:rke2-egress"'), 1) + literal_uses = [ + path.relative_to(ROOT).as_posix() + for path in sorted(ROOT.rglob("*.dhall")) + for _ in range(path.read_text(encoding="utf-8").count(TAG)) + ] + self.assertEqual(literal_uses, ["constants.dhall"]) + + def test_tag_owners_are_exact(self) -> None: + core = CORE.read_text(encoding="utf-8") + self.assertEqual(core.count("mapKey = C.tag.rke2_egress"), 1) + owner_block = re.compile( + r""" + \{\s*mapKey\s*=\s*C\.tag\.rke2_egress + \s*,\s*mapValue\s*= + \s*\[\s*C\.tag\.k8s_operator + \s*,\s*C\.autogroup\.admin + \s*,\s*C\.group\.dollhouse_admins + \s*\]\s*\} + """, + re.VERBOSE, + ) + self.assertEqual(len(owner_block.findall(core)), 1) + + def test_tag_has_no_legacy_acl_authority(self) -> None: + uses = [] + for path in sorted(ROOT.rglob("*.dhall")): + count = path.read_text(encoding="utf-8").count("C.tag.rke2_egress") + uses.extend([path.relative_to(ROOT).as_posix()] * count) + self.assertEqual(uses, ["fragments/core.dhall"]) + + def test_transport_grant_is_exact_and_unique(self) -> None: + grants = json.loads(GRANTS.read_text(encoding="utf-8")) + related = [ + grant + for grant in grants + if TAG in grant.get("src", []) or TAG in grant.get("dst", []) + ] + self.assertEqual(related, [EXPECTED_GRANT]) + + def test_no_new_direct_rule_can_target_honey(self) -> None: + grants = json.loads(GRANTS.read_text(encoding="utf-8")) + + def destination_includes_honey(destination: str) -> bool: + if destination in {HONEY, HONEY_IP, "*"}: + return True + try: + return ipaddress.ip_address(HONEY_IP) in ipaddress.ip_network( + destination, + strict=False, + ) + except ValueError: + return False + + self.assertTrue(destination_includes_honey("*")) + self.assertTrue(destination_includes_honey("100.64.0.0/10")) + direct_honey_grants = [ + grant + for grant in grants + if any( + destination_includes_honey(dst) + for dst in grant.get("dst", []) + ) + ] + self.assertEqual(direct_honey_grants, [EXPECTED_GRANT]) + + legacy_sources = [ + ROOT / "policy.dhall", + *sorted(FRAGMENTS.rglob("*.dhall")), + ] + direct_control_plane_destination = re.compile( + rf"(?:{re.escape(HONEY)}|{re.escape(HONEY_IP)}):(?:\*|6443|9345)" + ) + for forbidden in ( + f"{HONEY}:*", + f"{HONEY}:6443", + f"{HONEY_IP}:9345", + ): + self.assertIsNotNone( + direct_control_plane_destination.fullmatch(forbidden) + ) + matches = { + path.relative_to(ROOT).as_posix(): direct_control_plane_destination.findall( + path.read_text(encoding="utf-8") + ) + for path in legacy_sources + } + self.assertEqual( + {path: values for path, values in matches.items() if values}, + {}, + ) + + def test_docs_preserve_mutable_tag_membership_proof_boundary(self) -> None: + source = AUTHORITY_DOC.read_text(encoding="utf-8") + self.assertIn("does **not** prove\nHoney-wide exclusivity", source) + for tag in ("tag:dollhouse", "tag:subnet-router", "tag:switch"): + self.assertIn(f"`{tag}`", source) + self.assertIn("separately attended live preflight", source) + self.assertIn( + "cannot prove facts about mutable live device-tag membership", + source, + ) + + def test_sensitive_source_literal_census_is_closed(self) -> None: + policy_sources = [ + ROOT / "constants.dhall", + GRANTS, + *sorted(FRAGMENTS.rglob("*.dhall")), + ] + occurrences = { + needle: [ + path.relative_to(ROOT).as_posix() + for path in policy_sources + for _ in range(path.read_text(encoding="utf-8").count(needle)) + ] + for needle in (HONEY, HONEY_IP, "6443", "9345") + } + self.assertEqual( + occurrences, + { + HONEY: [ + "grants.json", + "grants.json", + "fragments/kubernetes.dhall", + "fragments/kubernetes.dhall", + "fragments/kubernetes.dhall", + ], + HONEY_IP: ["constants.dhall"], + "6443": [ + "grants.json", + "fragments/kubernetes.dhall", + ], + "9345": [ + "grants.json", + "fragments/kubernetes.dhall", + ], + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow_authority.py b/tests/test_workflow_authority.py new file mode 100644 index 0000000..a016efb --- /dev/null +++ b/tests/test_workflow_authority.py @@ -0,0 +1,148 @@ +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_DIR = ROOT / ".github" / "workflows" +CI = WORKFLOW_DIR / "ci.yml" +CD = WORKFLOW_DIR / "cd.yml" +PUBLISH = WORKFLOW_DIR / "publish-acl.yml" +JUSTFILE = ROOT / "justfile" +PUBLICATION_DOC = ROOT / "docs" / "acl-publication-authority.md" + +HOSTED_LABEL = re.compile(r"\b(?:ubuntu|macos|windows)-", re.IGNORECASE) +RUNNER = re.compile(r"(?m)^\s*runs-on:\s*(\S+)\s*$") +ACTION_REF = re.compile(r"(?m)^\s*-\s+uses:\s+[^@\s]+@([^\s#]+)") + + +def workflow_paths() -> list[Path]: + return sorted( + set(WORKFLOW_DIR.glob("*.yml")) | set(WORKFLOW_DIR.glob("*.yaml")) + ) + + +class WorkflowAuthorityTest(unittest.TestCase): + def test_all_workflows_use_only_sanctioned_runners(self) -> None: + for path in workflow_paths(): + source = path.read_text(encoding="utf-8") + self.assertIsNone(HOSTED_LABEL.search(source), path.name) + runners = RUNNER.findall(source) + self.assertTrue(runners, path.name) + self.assertTrue( + all(label.startswith("tinyland-") for label in runners), + f"{path.name}: {runners}", + ) + + def test_source_ci_is_credential_free_and_non_mutating(self) -> None: + source = CI.read_text(encoding="utf-8") + self.assertRegex(source, r"(?m)^ pull_request:") + self.assertRegex(source, r"(?m)^ push:") + self.assertNotIn("workflow_dispatch:", source) + self.assertNotIn("secrets.", source) + self.assertNotIn("environment:", source) + self.assertNotIn("scripts/push.py", source) + self.assertNotIn("scripts/validate.py", source) + self.assertNotRegex(source, r"(?m)^\s+\w[\w-]*:\s+write\s*$") + + def test_old_cd_identity_is_a_non_mutating_tombstone(self) -> None: + source = CD.read_text(encoding="utf-8") + self.assertRegex(source, r"(?m)^ workflow_dispatch:") + self.assertNotRegex( + source, + r"(?m)^ (?:push|pull_request|pull_request_target|schedule):", + ) + self.assertIn("permissions: {}", source) + self.assertNotIn("uses:", source) + self.assertNotIn("secrets.", source) + self.assertNotIn("environment:", source) + self.assertNotIn("scripts/push.py", source) + self.assertNotIn("scripts/validate.py", source) + + def test_publish_is_exact_dispatch_only_authority(self) -> None: + source = PUBLISH.read_text(encoding="utf-8") + self.assertRegex(source, r"(?m)^ workflow_dispatch:") + self.assertNotRegex( + source, + r"(?m)^ (?:push|pull_request|pull_request_target|schedule):", + ) + self.assertEqual(source.count("environment: tailnet-acl-production"), 2) + self.assertIn('github.event_name == "workflow_dispatch"', source.replace("'", '"')) + self.assertIn('"refs/heads/main"', source.replace("'", '"')) + self.assertIn("${{ github.sha }}", source) + self.assertIn("${{ github.run_attempt }}", source) + self.assertIn('[ "${RUN_ATTEMPT}" != "1" ]', source) + self.assertIn("expected_source_sha:", source) + self.assertIn("expected_live_policy_sha256:", source) + self.assertIn("expected_policy_sha256:", source) + self.assertIn("plan-tailnet-acl-${EXPECTED_SOURCE_SHA}", source) + self.assertIn("apply-tailnet-acl-${EXPECTED_SOURCE_SHA}", source) + self.assertEqual(source.count("ref: main"), 2) + self.assertEqual( + source.count('test "$(git rev-parse HEAD)" = "${EXPECTED_SOURCE_SHA}"'), + 2, + ) + + validator = source.split("\n plan:", 1)[0] + self.assertNotIn("secrets.", validator) + self.assertNotIn("environment:", validator) + + plan = source.split("\n plan:", 1)[1].split("\n apply:", 1)[0] + self.assertIn("--dry-run", plan) + self.assertNotIn("--confirm", plan) + self.assertIn( + "secrets.TAILSCALE_ACL_READ_OAUTH_CLIENT_SECRET", + plan, + ) + self.assertIn("vars.TAILSCALE_ACL_READ_OAUTH_CLIENT_ID", plan) + self.assertNotIn("TAILSCALE_ACL_WRITE_", plan) + self.assertIn('--source-sha "${EXPECTED_SOURCE_SHA}"', plan) + + apply = source.split("\n apply:", 1)[1] + self.assertIn("--confirm", apply) + self.assertIn("--expect-live-sha256", apply) + self.assertIn("--expect-policy-sha256", apply) + self.assertIn( + "secrets.TAILSCALE_ACL_WRITE_OAUTH_CLIENT_SECRET", + apply, + ) + self.assertIn("vars.TAILSCALE_ACL_WRITE_OAUTH_CLIENT_ID", apply) + self.assertNotIn("TAILSCALE_ACL_READ_", apply) + self.assertIn('--source-sha "${EXPECTED_SOURCE_SHA}"', apply) + + self.assertNotIn("TAILSCALE_ACL_OAUTH_CLIENT_SECRET", source) + self.assertNotIn("TAILSCALE_ACL_OAUTH_CLIENT_ID", source) + self.assertNotIn("secrets.TAILSCALE_API_KEY", source) + self.assertNotIn("secrets.GITHUB_TOKEN", source) + + def test_external_actions_are_immutable(self) -> None: + for path in workflow_paths(): + refs = ACTION_REF.findall(path.read_text(encoding="utf-8")) + self.assertTrue( + all(re.fullmatch(r"[0-9a-f]{40}", ref) for ref in refs), + f"{path.name}: {refs}", + ) + + def test_operator_push_recipe_requires_durable_source_bound_receipt( + self, + ) -> None: + source = JUSTFILE.read_text(encoding="utf-8") + push = source.split("\npush: build\n", 1)[1].split("\n\n", 1)[0] + self.assertIn("--confirm", push) + self.assertIn('--source-sha "${SOURCE_SHA:?required}"', push) + self.assertIn('--receipt "${RECEIPT:?required}"', push) + + def test_new_workflow_identity_check_is_post_landing(self) -> None: + source = PUBLICATION_DOC.read_text(encoding="utf-8") + pre_landing, post_landing = source.split( + "## Post-landing, pre-dispatch barrier", + 1, + ) + self.assertIn("Permanently disable historical", pre_landing) + self.assertNotIn("new workflow's exact numeric ID", pre_landing) + self.assertIn("new workflow's exact numeric ID", post_landing) + self.assertIn("238207465", post_landing) + + +if __name__ == "__main__": + unittest.main()