diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90ae2f16..0f39bc09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,6 +220,8 @@ jobs: # checks (see scripts/ci/install-smoke.sh and docs/uat-ci-coverage.md). name: Install smoke (fresh phx.new + sigra.install) runs-on: ubuntu-latest + permissions: + contents: write services: postgres: image: postgres:15 @@ -253,7 +255,7 @@ jobs: mix local.hex --force mix local.rebar --force - name: Install phx_new archive - run: mix archive.install --force hex phx_new + run: mix archive.install --force hex phx_new 1.8.5 - name: Fetch Sigra library deps run: mix deps.get - name: Run install smoke harness @@ -264,7 +266,73 @@ jobs: GITHUB_WORKSPACE: ${{ github.workspace }} # Fresh tmp_app includes Cloak.Vault; compile/boot needs a dummy key (same as admin-acceptance-smoke.sh). CLOAK_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= - run: scripts/ci/install-smoke.sh + run: | + mkdir -p .planning/uat-evidence/v1.20/oauth-gen + scripts/ci/install-smoke.sh 2>&1 | tee .planning/uat-evidence/v1.20/oauth-gen/transcript.log + - name: Generate install-smoke evidence reports + env: + MIX_ENV: test + SIGRA_CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + SIGRA_CI_WORKFLOW: .github/workflows/ci.yml / install_smoke + SIGRA_GIT_TAG: ${{ github.ref_name }} + run: | + MIX_ENV=test mix sigra.uat.report --phase=oauth-gen + MIX_ENV=test mix sigra.uat.report --phase=getting-started + - name: Upload oauth-gen bundle (main, 14d retention) + if: always() && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: oauth-gen-bundle + path: .planning/uat-evidence/v1.20/oauth-gen/ + retention-days: 14 + - name: Upload oauth-gen bundle (PR/push, 7d retention) + if: always() && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: oauth-gen-bundle + path: .planning/uat-evidence/v1.20/oauth-gen/ + retention-days: 7 + - name: Create oauth-gen release asset archive (v* tag only) + if: startsWith(github.ref, 'refs/tags/v') + run: | + cd /tmp && tar -czf "sigra-oauth-gen-${{ github.ref_name }}.tar.gz" \ + -C "${{ github.workspace }}" .planning/uat-evidence/v1.20/oauth-gen/ + - name: Promote oauth-gen bundle to ${{ github.ref_name }} release asset + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ github.ref_name }}" "/tmp/sigra-oauth-gen-${{ github.ref_name }}.tar.gz" \ + --clobber \ + --repo "${{ github.repository }}" + - name: Upload oauth-gen bundle (tag, 90d retention) + if: startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: oauth-gen-bundle + path: .planning/uat-evidence/v1.20/oauth-gen/ + retention-days: 90 + - name: Upload getting-started bundle (main, 14d retention) + if: always() && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: getting-started-generated-host-bundle + path: .planning/uat-evidence/v1.20/getting-started-clean-machine/ + retention-days: 14 + - name: Upload getting-started bundle (PR/push, 7d retention) + if: always() && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: getting-started-generated-host-bundle + path: .planning/uat-evidence/v1.20/getting-started-clean-machine/ + retention-days: 7 + - name: Upload getting-started bundle (tag, 90d retention) + if: startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: getting-started-generated-host-bundle + path: .planning/uat-evidence/v1.20/getting-started-clean-machine/ + retention-days: 90 passkeys_manual_fallback_smoke: name: Passkeys manual fallback smoke @@ -788,6 +856,292 @@ jobs: path: test/example/priv/playwright/test-results/ retention-days: 7 + oauth_e2e_playwright: + name: OAuth E2E Playwright (mock issuer) + runs-on: ubuntu-latest + permissions: + contents: write + env: + EXAMPLE_DB_PROBE_ENABLED: "1" + EXAMPLE_OAUTH_ISSUER_CTL_ENABLED: "1" + CLOAK_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 + with: + version-file: .tool-versions + version-type: strict + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'test/example/priv/playwright/package-lock.json' + - name: Cache example deps + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + test/example/deps + test/example/_build + key: ${{ runner.os }}-example-dev-${{ hashFiles('test/example/mix.lock', 'test/example/config/**', 'test/example/lib/**/*.ex', 'lib/**/*.ex', 'mix.exs') }} + - name: Fetch example deps + working-directory: test/example + env: + MIX_ENV: dev + run: mix deps.get + - name: Compile example + working-directory: test/example + env: + MIX_ENV: dev + run: mix compile --warnings-as-errors + - name: Setup example dev DB + working-directory: test/example + env: + MIX_ENV: dev + PGUSER: postgres + PGPASSWORD: postgres + PGHOST: localhost + run: mix ecto.create && mix ecto.migrate + - name: Install Playwright deps + working-directory: test/example/priv/playwright + run: npm ci + - name: Install Playwright browsers + working-directory: test/example/priv/playwright + run: npx playwright install --with-deps chromium + - name: Boot example app in background + working-directory: test/example + env: + MIX_ENV: dev + PGUSER: postgres + PGPASSWORD: postgres + PGHOST: localhost + PHX_SERVER: "true" + run: mix phx.server > /tmp/example-oauth-playwright-server.log 2>&1 & + - name: Wait for app and warm OAuth routes + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:4000/ > /dev/null; then + echo "App responding after ${i}s" + break + fi + sleep 1 + done + for path in /users/log_in /users/settings /test/db_probe /test/oauth_issuer/reset; do + curl -s -o /dev/null -w "%{http_code} ${path}\n" "http://localhost:4000${path}" || true + done + - name: Run OAuth Playwright specs + working-directory: test/example/priv/playwright + env: + CI: "true" + SIGRA_EXAMPLE_URL: "http://localhost:4000" + run: | + npx playwright test \ + tests/oauth-register.spec.ts \ + tests/oauth-link.spec.ts \ + tests/oauth-email-match.spec.ts \ + --project=chromium \ + --reporter=line + - name: Generate OAuth evidence reports + env: + MIX_ENV: test + SIGRA_CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + SIGRA_CI_WORKFLOW: .github/workflows/ci.yml / oauth_e2e_playwright + SIGRA_GIT_TAG: ${{ github.ref_name }} + run: | + MIX_ENV=test mix sigra.uat.report --phase=oauth-google + MIX_ENV=test mix sigra.uat.report --phase=oauth-link + MIX_ENV=test mix sigra.uat.report --phase=oauth-email-match + - name: Dump example app log (on failure) + if: failure() + run: | + echo "--- /tmp/example-oauth-playwright-server.log ---" + cat /tmp/example-oauth-playwright-server.log || echo "(no log file)" + - name: Assemble oauth e2e bundle + if: always() + run: | + mkdir -p /tmp/oauth-e2e-playwright-bundle + cp -r .planning/uat-evidence/v1.20/oauth-google /tmp/oauth-e2e-playwright-bundle/oauth-google 2>/dev/null || true + cp -r .planning/uat-evidence/v1.20/oauth-link /tmp/oauth-e2e-playwright-bundle/oauth-link 2>/dev/null || true + cp -r .planning/uat-evidence/v1.20/oauth-email-match /tmp/oauth-e2e-playwright-bundle/oauth-email-match 2>/dev/null || true + cp -r test/example/priv/playwright/playwright-report /tmp/oauth-e2e-playwright-bundle/playwright-report 2>/dev/null || true + cp -r test/example/priv/playwright/__snapshots__/oauth-link.spec.ts /tmp/oauth-e2e-playwright-bundle/oauth-link-hero 2>/dev/null || true + echo "Bundle contents:" + find /tmp/oauth-e2e-playwright-bundle -type f | sort || true + - name: Upload oauth e2e playwright bundle (main, 14d retention) + if: always() && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: oauth-e2e-playwright-bundle + path: /tmp/oauth-e2e-playwright-bundle/ + retention-days: 14 + - name: Upload oauth e2e playwright bundle (PR/push, 7d retention) + if: always() && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: oauth-e2e-playwright-bundle + path: /tmp/oauth-e2e-playwright-bundle/ + retention-days: 7 + - name: Upload oauth e2e playwright failure diagnostics (main, 14d retention) + if: failure() && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: oauth-e2e-playwright-failure-diagnostics + path: test/example/priv/playwright/test-results/ + retention-days: 14 + - name: Upload oauth e2e playwright failure diagnostics (PR/push, 7d retention) + if: failure() && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: oauth-e2e-playwright-failure-diagnostics + path: test/example/priv/playwright/test-results/ + retention-days: 7 + - name: Create oauth e2e playwright release asset archive (v* tag only) + if: startsWith(github.ref, 'refs/tags/v') + run: | + cd /tmp && tar -czf "sigra-oauth-e2e-playwright-${{ github.ref_name }}.tar.gz" oauth-e2e-playwright-bundle/ + echo "Release asset: sigra-oauth-e2e-playwright-${{ github.ref_name }}.tar.gz" + ls -lh "/tmp/sigra-oauth-e2e-playwright-${{ github.ref_name }}.tar.gz" + - name: Promote oauth e2e playwright bundle to ${{ github.ref_name }} release asset + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ github.ref_name }}" "/tmp/sigra-oauth-e2e-playwright-${{ github.ref_name }}.tar.gz" \ + --clobber \ + --repo "${{ github.repository }}" + - name: Upload oauth e2e playwright bundle (tag, 90d retention) + if: startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: oauth-e2e-playwright-bundle + path: /tmp/oauth-e2e-playwright-bundle/ + retention-days: 90 + + mfa_e2e_playwright: + name: MFA backup-code rotation E2E + runs-on: ubuntu-latest + permissions: + contents: write + env: + EXAMPLE_DB_PROBE_ENABLED: "1" + CLOAK_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 + with: + version-file: .tool-versions + version-type: strict + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'test/example/priv/playwright/package-lock.json' + - name: Cache example deps + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + test/example/deps + test/example/_build + key: ${{ runner.os }}-example-dev-${{ hashFiles('test/example/mix.lock', 'test/example/config/**', 'test/example/lib/**/*.ex', 'lib/**/*.ex', 'mix.exs') }} + - name: Fetch example deps + working-directory: test/example + env: + MIX_ENV: dev + run: mix deps.get + - name: Compile example + working-directory: test/example + env: + MIX_ENV: dev + run: mix compile --warnings-as-errors + - name: Setup example dev DB + working-directory: test/example + env: + MIX_ENV: dev + PGUSER: postgres + PGPASSWORD: postgres + PGHOST: localhost + run: mix ecto.create && mix ecto.migrate + - name: Install Playwright deps + working-directory: test/example/priv/playwright + run: npm ci + - name: Install Playwright browsers + working-directory: test/example/priv/playwright + run: npx playwright install --with-deps chromium + - name: Boot example app in background + working-directory: test/example + env: + MIX_ENV: dev + PGUSER: postgres + PGPASSWORD: postgres + PGHOST: localhost + PHX_SERVER: "true" + run: mix phx.server > /tmp/example-mfa-playwright-server.log 2>&1 & + - name: Wait for app + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:4000/ > /dev/null; then + echo "App responding after ${i}s" + break + fi + sleep 1 + done + - name: Run MFA Playwright spec + working-directory: test/example/priv/playwright + env: + CI: "true" + SIGRA_EXAMPLE_URL: "http://localhost:4000" + run: npx playwright test tests/mfa-backup-rotation.spec.ts --project=chromium --reporter=line + - name: Generate MFA evidence report + env: + MIX_ENV: test + SIGRA_CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + SIGRA_CI_WORKFLOW: .github/workflows/ci.yml / mfa_e2e_playwright + SIGRA_GIT_TAG: ${{ github.ref_name }} + run: MIX_ENV=test mix sigra.uat.report --phase=mfa-backup-rotation + - name: Dump example app log (on failure) + if: failure() + run: | + echo "--- /tmp/example-mfa-playwright-server.log ---" + cat /tmp/example-mfa-playwright-server.log || echo "(no log file)" + - name: Upload MFA bundle (main, 14d retention) + if: always() && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mfa-backup-rotation-bundle + path: .planning/uat-evidence/v1.20/mfa-backup-rotation/ + retention-days: 14 + - name: Upload MFA bundle (PR/push, 7d retention) + if: always() && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mfa-backup-rotation-bundle + path: .planning/uat-evidence/v1.20/mfa-backup-rotation/ + retention-days: 7 + - name: Upload MFA bundle (tag, 90d retention) + if: startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mfa-backup-rotation-bundle + path: .planning/uat-evidence/v1.20/mfa-backup-rotation/ + retention-days: 90 + generated_admin_playwright_smoke: name: Generated admin Playwright smoke runs-on: ubuntu-latest @@ -924,3 +1278,160 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run Phase 34 mechanical UAT contracts run: scripts/ci/phase34-uat-contracts.sh + + # Phase 86: GAUAT-01 / GAUAT-02 — email visual regression harness + # Runs the snapshot prerender, evidence report generation, and narrow + # Playwright email-visual spec. Uploads the full raw bundle on every run. + # On any refs/tags/v* tag promotes that exact bundle to the matching GitHub release + # asset without rebuilding from different inputs (D-86-01, D-86-06, D-86-11). + # + # SEED-1/SEED-2 residual columns in docs/uat-ci-coverage.md point at this job. + # snapshot count = 36, contrast min ratio = 4.5, byte budget max = 100000 + email_visual_regression: + name: Email visual regression (GAUAT-01/02) + runs-on: ubuntu-latest + permissions: + contents: write # required for gh release upload on tag runs + env: + CLOAK_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 10s + --health-timeout 5s --health-retries 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 + with: + version-file: .tool-versions + version-type: strict + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'test/example/priv/playwright/package-lock.json' + - name: Cache library deps + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-test-${{ hashFiles('mix.lock', 'mix.exs') }} + - name: Cache example deps + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + test/example/deps + test/example/_build + key: ${{ runner.os }}-example-test-${{ hashFiles('test/example/mix.lock', 'test/example/mix.exs', 'mix.exs') }} + - name: Fetch library deps + run: mix deps.get + - name: Fetch example deps + working-directory: test/example + env: + MIX_ENV: test + run: mix deps.get + - name: Compile library (test env) + env: + MIX_ENV: test + run: mix compile --warnings-as-errors + - name: Compile example (test env) + working-directory: test/example + env: + MIX_ENV: test + run: mix compile --warnings-as-errors + - name: Setup test DB + working-directory: test/example + env: + MIX_ENV: test + PGUSER: postgres + PGPASSWORD: postgres + PGHOST: localhost + run: mix ecto.create && mix ecto.migrate + # L1: snapshot prerender — deterministic email HTML to disk via frozen fixtures + - name: Prerender email HTML snapshots (L1 snapshot lane) + env: + MIX_ENV: test + PGUSER: postgres + PGPASSWORD: postgres + PGHOST: localhost + run: MIX_ENV=test mix sigra.email.snapshot --check + # L2: Playwright email-visual spec — 36-cell matrix (9 templates × 2 engines × 2 themes) + - name: Install Playwright deps + working-directory: test/example/priv/playwright + run: npm ci + - name: Install Playwright browsers (chromium + webkit) + working-directory: test/example/priv/playwright + run: npx playwright install --with-deps chromium webkit + - name: Run email visual regression spec (L2 Playwright lane) + working-directory: test/example/priv/playwright + env: + CI: "true" + run: npx playwright test tests/email-visual.spec.ts --project=email-chromium-light --project=email-chromium-dark --project=email-webkit-light --project=email-webkit-dark + # L3: evidence report generation — manifest + README + contrast/byte-budget reports + - name: Generate Phase 04 evidence report (L3 report lane) + env: + MIX_ENV: test + SIGRA_CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + SIGRA_CI_WORKFLOW: .github/workflows/ci.yml / email_visual_regression + SIGRA_GIT_TAG: ${{ github.ref_name }} + run: MIX_ENV=test mix sigra.uat.report --phase=04 + - name: Generate Phase 08 evidence report (L3 report lane) + env: + MIX_ENV: test + SIGRA_CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + SIGRA_CI_WORKFLOW: .github/workflows/ci.yml / email_visual_regression + SIGRA_GIT_TAG: ${{ github.ref_name }} + run: MIX_ENV=test mix sigra.uat.report --phase=08 + # Bundle the full evidence payload: committed baselines + generated reports + - name: Assemble email evidence bundle + run: | + mkdir -p /tmp/email-visual-bundle + cp -r .planning/uat-evidence/v1.20/ /tmp/email-visual-bundle/uat-evidence/ + cp -r test/example/priv/playwright/__snapshots__/email-visual.spec.ts/ /tmp/email-visual-bundle/baselines/ + cp -r test/example/priv/email_snapshots/ /tmp/email-visual-bundle/html-snapshots/ 2>/dev/null || true + echo "Bundle contents:" + find /tmp/email-visual-bundle -type f | sort + # Upload the raw bundle artifact on every run (branch/PR and tags) + - name: Upload email visual regression bundle (main, 14d retention) + if: always() && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: email-visual-regression-bundle + path: /tmp/email-visual-bundle/ + retention-days: 14 + - name: Upload email visual regression bundle (PR/push, 7d retention) + if: always() && github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: email-visual-regression-bundle + path: /tmp/email-visual-bundle/ + retention-days: 7 + # On any v* tag (v1.20.0, v1.20.1, v1.21.0, ...) promote the exact same + # bundle to the matching GitHub release asset without rebuilding from + # different inputs (D-86-01, D-86-06). + - name: Create release asset archive (v* tag only) + if: startsWith(github.ref, 'refs/tags/v') + run: | + cd /tmp && tar -czf "sigra-email-visual-regression-${{ github.ref_name }}.tar.gz" email-visual-bundle/ + echo "Release asset: sigra-email-visual-regression-${{ github.ref_name }}.tar.gz" + ls -lh "/tmp/sigra-email-visual-regression-${{ github.ref_name }}.tar.gz" + - name: Promote bundle to ${{ github.ref_name }} release asset + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ github.ref_name }}" "/tmp/sigra-email-visual-regression-${{ github.ref_name }}.tar.gz" \ + --clobber \ + --repo "${{ github.repository }}" + - name: Upload email visual bundle (tag, 90d retention) + if: startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: email-visual-regression-bundle + path: /tmp/email-visual-bundle/ + retention-days: 90 diff --git a/.github/workflows/hex-publish.yml b/.github/workflows/hex-publish.yml index 8c2c4cf0..f2aae85d 100644 --- a/.github/workflows/hex-publish.yml +++ b/.github/workflows/hex-publish.yml @@ -19,7 +19,7 @@ on: type: string permissions: - contents: read + contents: write jobs: publish: @@ -66,6 +66,16 @@ jobs: - name: Verify release version in mix.exs run: grep -n "@version \"${{ inputs.release_version }}\"" mix.exs + - name: Sync changelog summary into GitHub release body + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + chmod +x scripts/release/sync_release_summary.sh + scripts/release/sync_release_summary.sh \ + "${{ inputs.release_version }}" \ + "${{ inputs.tag }}" + - name: Fetch library deps run: mix deps.get diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 809b9b12..4f56b868 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -47,9 +47,31 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json + sync-release-summary: + name: Sync GitHub release summary + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + + - name: Sync changelog summary into GitHub release body + env: + GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + chmod +x scripts/release/sync_release_summary.sh + scripts/release/sync_release_summary.sh \ + "${{ needs.release-please.outputs.version }}" \ + "${{ needs.release-please.outputs.tag_name }}" + publish-hex: name: Publish to Hex.pm - needs: release-please + needs: [release-please, sync-release-summary] if: ${{ needs.release-please.outputs.release_created == 'true' }} runs-on: ubuntu-latest permissions: diff --git a/.planning/AUDIT-ATOMICITY-DEFAULTS.md b/.planning/AUDIT-ATOMICITY-DEFAULTS.md new file mode 100644 index 00000000..b2b09bae --- /dev/null +++ b/.planning/AUDIT-ATOMICITY-DEFAULTS.md @@ -0,0 +1,77 @@ +# Sigra — audit atomicity defaults (GSD / planning) + +**Purpose:** Capture **default engineering choices** for bounded **SEED-002**–style phases and `/gsd-discuss-phase` so planners rarely re-open settled tradeoffs. **Override** in phase CONTEXT when a requirement truly demands an exception (call that out explicitly). + +**Status:** Established **2026-04-24** (Phase 80 research synthesis); extended **D-AUD-05..07** **2026-04-24** (Phase 81 JWT audit-only slice + discuss shift-left); **D-AUD-12** + discuss delegation prefs **2026-04-24** (Phase **83**). + +## Defaults + +### D-AUD-01 — Orchestration layer + +- **`Sigra.Account`** (and analogous top-level orchestrators) **own** `Repo.transaction/1`, `Ecto.Multi`, and `Sigra.Audit.log_multi_safe/3` when audit must share fate with domain writes. +- **Domain modules** (`Sigra.Account.PasswordChange`, `EmailChange`, `Deletion`, …) stay **audit-agnostic**: no `Sigra.Audit` imports; they expose changesets, `Repo` operations, or small `Multi.run`-friendly steps. +- **Rationale:** Matches Phoenix **context** orchestration, avoids ActiveRecord/Django-signal-style hidden coupling, keeps **one transaction owner**, reduces nested-transaction footguns, improves generator DX (single extension seam). + +### D-AUD-02 — Deprecation of superseded audit-only helpers + +- When a standalone `log_safe/3` helper is replaced by an atomic **`Multi` + `log_multi_safe`** path: **`@deprecated`** with a **compile-time** warning for **at least one minor**, then **remove** in the following minor (pre-1.0). Document the **single supported** call sequence in CHANGELOG + upgrade guide. +- **Avoid:** immediate patch removal of public APIs; long-lived “smart no-op” shims without idempotency keys; default **runtime raise** for “double audit” in production (prefer CI / docs / generator alignment). + +### D-AUD-03 — Options keyword shape + +- Public **`Sigra.Account.*`** arities keep **`(repo, …, opts)`** with the **same physical `opts` keyword** hosts/generators already pass for `change_password`, email flows, etc. +- Implementations **`Keyword.take`** / NimbleOptions **composed schemas** so each operation uses the **audit context slice** consistently; operation-specific keys are validated per function without inventing a second public opts bag. + +### D-AUD-04 — Testing split + +- **MockRepo / unit:** API contracts, branches, error tuples, composition (no second Postgres copy of every branch). +- **Postgres + fault injection (`CHECK` / similar):** **one** happy path + **one** rollback proof per atomic story in `*_audit_atomicity_test.exs` (or equivalent), mirroring existing `change_password` patterns. + +### D-AUD-05 — Audit-only `Multi` (no domain step) + +- Helpers that **only** persist an audit row (e.g. **`api.jwt_refresh`**, **`api.jwt_refresh_reuse`**) still use **`Repo.transaction/1` + `Ecto.Multi` + `Sigra.Audit.log_multi_safe/3`** when `:audit_schema` is set — same **durability class** as token verify failure audits — with a **single private `commit_*`** owning the transaction shell and **thin `def` wrappers** for action-specific opts. +- **Do not** imply **shared fate** with unrelated domain tables unless a future phase explicitly joins them in the same `Multi` (**AUD-08**-class work stays out of bounded SEED-002 slices unless scoped). + +### D-AUD-06 — Caller contract when audit insert fails (audit-only paths) + +- Public functions that today return **`:ok`** and use **`log_safe`/`log_multi_safe`** for **side-channel audit** keep **`:ok`** on audit subsystem failure **when the audit row is not co-fated with a durable partner write**. This covers three legitimate sub-classes: **detection-only** (the audit row is the forensic record), **pre-domain** (the event fires before a persistence target exists), and **audit-only helpers**. Emit **`[:sigra, :audit, :log_safe_error]`** (or the same telemetry contract as `emit_log_safe_error`) so operators can alert; **raise** only on programmer-wiring errors. **`@doc`** must state **`:ok` does not guarantee** the audit row exists. + +### D-AUD-07 — ExUnit layout for audit fault injection + +- **Separate named tests** per action × fault story (no parametrized loops for fault paths). Reuse **only** small private helpers for `ALTER … CHECK` + `try/after` + telemetry attach; **unique** handler IDs per test; **`async: false`**; action-scoped SQL counts. + +### D-AUD-08 — Persistence + audit co-fate (JWT refresh class; **AUD-19**) + +- When requirements mandate **one commit** for **domain `user_tokens` effects** and **`api.jwt_refresh*`** audit rows (**:audit_schema** set): + - **Orchestrator** (**`Sigra.JWT.refresh/3`**, optionally delegated to an internal **`@moduledoc false`** module) owns **exactly one** **`Repo.transaction/1`** (or **`Repo.transact/2`** if the project adopts it here). + - **`Sigra.APIToken`** (or equivalent) must expose audit as **`Ecto.Multi` steps** composable into that transaction — **do not** call **`Repo.transaction`** inside helpers used from that **`Multi`** (no nested txn / savepoint surprise). + - **Public contract:** **`{:ok, tokens}`** iff the **full bundle** commits; **any** step failure → rollback and **`{:error, _}`** — **explicit exception** to **D-AUD-06** for this class only. **`@doc`** must contrast with **`audit_jwt_refresh/2`** / **`audit_jwt_refresh_reuse/2`** standalone semantics (**`:ok`** + telemetry on audit-only failure). +- **Rationale:** **D-AUD-06** exists because audit-only paths cannot roll back already-committed host work; co-fate paths **can** and **must** roll back persistence when audit fails — returning **`:ok`** with tokens would violate least surprise and audit integrity. + +### D-AUD-09 — Security telemetry after commit (reuse / co-fate) + +- **`Telemetry.event/3`** (or similar) that implies **persisted** security outcomes (**reuse detected**, family revoked) must run **after** the transaction **commits** (success branch), not interleaved between persistence and audit where a later audit failure would roll back DB state but leave misleading signals. + +### D-AUD-10 — ExUnit split: audit-only vs persistence co-fate + +- **Audit-only** stories (helpers that do not join **`user_tokens`** writes in the same txn) stay in **`api_token_audit_atomic_test.exs`** (or the established audit-atomicity module for that surface). +- **Persistence + audit co-fate** proofs live in a **dedicated** file (e.g. **`jwt_refresh_audit_cofate_test.exs`**) with **`@moduledoc`** cross-linking the audit-only module — preserves CI failure labels and avoids conflating **D-AUD-06** contracts with **D-AUD-08** contracts in one module. + +### D-AUD-11 — Planning matrix updates when **T1** semantics strengthen + +- Prefer **surgical cell edits** + **one dated supersession footnote** (phase id + what narrowed, e.g. **AUD-08** closed) across **44** / **45** / **09-VERIFICATION** / **09-03-SUMMARY** in lockstep; **`CHANGELOG` [Unreleased]** carries the user-visible behavior story; phase **`NN-VERIFICATION.md`** is the merge gate spine. Avoid wholesale matrix rewrites unless the row taxonomy is wrong. + +### D-AUD-12 — MFA invalid pre-DB enrollment attempt (**AUD-04-022**) + +- When **`:audit_schema`** is set, **`Sigra.MFA.confirm_enrollment/5`** wrong-TOTP path (**before** enrollment `Multi`) persists **`mfa.enroll.failure`** via **`Repo.transaction/1` + `Ecto.Multi` + `Sigra.Audit.log_multi_safe/3`**, using the **same audit-only shell** as **`commit_ad_hoc_mfa_audit/5`** (success → **`emit_telemetry_from_changes`**; failure → **`[:sigra, :audit, :log_safe_error]`** per existing rescue/changeset paths). +- **Public return:** **`{:error, :invalid_code}`** whenever the TOTP check fails — **independent** of audit insert outcome (**not** **D-AUD-08**; not a new failure atom for audit DB issues). +- **Explicit waiver** (retain **`log_safe`**) remains valid only if a phase **CONTEXT** records an intentional **exception to D-AUD-05** with updated **EX-44-02** rationale (**AUD-20-01** second branch). + +## Discuss-phase preferences (this project) + +- When the user **delegates** (“all”, “synthesize”, “don’t make me think”), default orchestrator behavior: **parallel research** (subagents or equivalent) on listed gray areas → **one coherent CONTEXT** aligned with **PROJECT.md** / **REQUIREMENTS.md** / these defaults. +- **Still require explicit user choices** for topics in **`.planning/config.json` → `workflow.discuss_always_surface_for_user`** (semver/public API, security model vs published OWASP stance, generator/host output contracts). + +## When to still run discuss-phase + +Use `/gsd-discuss-phase` when any of: new **external** contract (HTTP, host generator output), **semver exception**, **cross-cutting** audit API change (e.g. new `log_multi_safe` step names), **persistence + audit co-fate** scope not already covered by **D-AUD-08**, or **explicit** stakeholder preference. Otherwise planners may treat **D-AUD-01..11** as locked unless phase SPEC says otherwise. diff --git a/.planning/MILESTONES.md b/.planning/MILESTONES.md index be58c62c..ecf70ea1 100644 --- a/.planning/MILESTONES.md +++ b/.planning/MILESTONES.md @@ -45,9 +45,9 @@ - `SEED-001` — 8 human-only UAT items to run before GA public announcement (email visual × 4, OAuth real-credential × 4) - `SEED-002` — Phase 9 `log_safe/3` hybrid to atomic `Ecto.Multi` conversion (C-1 caveat followup) -**Backlog** (999.x parking lot): -- `Phase 999.1` — Retroactive Nyquist validation pass for 6 draft + 1 missing VALIDATION.md files -- `Phase 999.2` — Dependabot major-version bumps (setup-node 4→6, upload-artifact 4→7, checkout 4→6) requiring per-bump CI verification +**Backlog** (999.x parking lot; archaeology only): +- `Phase 999.1` — Retroactive Nyquist validation pass for 6 draft + 1 missing VALIDATION.md files; shipped in v1.3 and now retained as a tombstone/pointer only +- `Phase 999.2` — Dependabot major-version bumps (setup-node 4→6, upload-artifact 4→7, checkout 4→6) requiring per-bump CI verification; historical parking-lot label only until promoted into a newly numbered phase **Archive:** - [v1.0 Roadmap](milestones/v1.0-ROADMAP.md) — full phase details @@ -81,7 +81,7 @@ ### Tech Debt Carried Forward - **`gsd-tools audit-open --json` is deprecated** for Sigra maintainers; the **supported path** is [`MAINTAINING.md`](../MAINTAINING.md) section **Planning hygiene (without gsd-tools JSON)** plus optional [`scripts/maintainers/planning-audit-hygiene.sh`](../scripts/maintainers/planning-audit-hygiene.sh). -- `Phase 999.1` Nyquist backfill remains parked. +- `Phase 999.1` Nyquist backfill remains archaeology-only; Phase 84 owns the routing-honesty cleanup so active workflows stop pointing at the tombstone. - `Phase 999.2` Dependabot major-version cleanup remains parked. **Archive:** @@ -114,7 +114,7 @@ ### Tech Debt Carried Forward -- `SEED-001` human-only GA UAT items; `SEED-002` audit atomicity hybrid; backlog **999.1** / **999.2** unchanged from prior milestones. +- `SEED-001` human-only GA UAT items; `SEED-002` audit atomicity hybrid; backlog **999.1** / **999.2** remain historical parking-lot labels only. - Residual subjective reviewer items called out in phase VERIFICATION/HUMAN-UAT docs where automation cannot fully substitute judgment. **Archive:** @@ -489,3 +489,99 @@ - [v1.14 Requirements](milestones/v1.14-REQUIREMENTS.md) --- + +## v1.17 Forced password change audit atomicity (Shipped: 2026-04-24) + +**Scope:** 1 phase (**80**), **`Sigra.Account.clear_password_change_requirement/3`** + planning truth (**AUD-17-01**..**AUD-17-04**). + +**What shipped:** **`clear_password_change_requirement/3`** co-fates **`must_change_password: false`** with **`account.password_change`** (`metadata: %{forced: true}`) via **`Repo.transaction/1`** + **`Ecto.Multi`** + **`Sigra.Audit.log_multi_safe/3`** when `:audit_schema` is set; **`audit_forced_password_change/2`** **`@deprecated`**; **`test/sigra/account_audit_atomicity_test.exs`** forced-clear + CHECK rollback; **44** inventory + **09-VERIFICATION** C-1 **043** **T1** + **09-03-SUMMARY** + **`CHANGELOG` [Unreleased]**; **EX-44-05** closed. + +### Key accomplishments + +1. **AUD-17-01** — Forced-clear path matches atomic audit pattern used elsewhere on **Account**. +2. **AUD-17-02** — Standalone post-commit **`log_safe`** for that completion path retired (**deprecation**). +3. **AUD-17-03 / AUD-17-04** — Postgres-backed atomicity tests + planning truth aligned to **AUD-04-043**. + +### Stats + +- **Requirements:** 4/4 **Validated** in archived [`milestones/v1.17-REQUIREMENTS.md`](milestones/v1.17-REQUIREMENTS.md). +- **Timeline:** **2026-04-24**; **`/gsd-complete-milestone`** — live **`REQUIREMENTS.md`** removed. +- **Milestone audit:** not filed (optional); pre-close **`audit-open`**: all artifact types clear (2026-04-24). +- **`gsd-sdk query milestone.complete`:** failed (`version required for phases archive`); manual **`milestones/v1.17-*`** archival (same pattern as **v1.12**–**v1.16**). +- **Git (since `v1.16` tag):** 9 commits; **24** files (**1288** insertions / **50** deletions in `git diff --shortstat v1.16..HEAD` at close). + +### Tech debt carried forward + +- **SEED-002** — remaining **`log_safe/3`** clusters (**048–049**, OAuth phase **45**, etc.). +- **AUD-04-022** — **`log_safe`** invalid enrollment path unchanged (**EX-44-02**). + +**Archive:** + +- [v1.17 Roadmap](milestones/v1.17-ROADMAP.md) +- [v1.17 Requirements](milestones/v1.17-REQUIREMENTS.md) + +--- + +## v1.16 API verify failure audit atomicity (Shipped: 2026-04-24) + +**Scope:** 1 phase (**79**), **`Sigra.APIToken.verify/2`** failure audits + planning truth (**AUD-16-01**..**AUD-16-04**). + +**What shipped:** **`api.token_verify.failure`** for invalid / revoked / expired branches uses **`Repo.transaction/1`** + **`Ecto.Multi`** + **`Sigra.Audit.log_multi_safe/3`** when `:audit_schema` is set; **`log_safe_error`** telemetry on audit insert failure while callers still receive **`{:error, reason}`**; **44** + **09** + **09-03-SUMMARY** + **`CHANGELOG` [Unreleased]**; **`test/sigra/api_token_audit_atomic_test.exs`** coverage + fault injection; **EX-44-01** verify slice retired (appendix row retained). + +### Key accomplishments + +1. **AUD-16-01 / AUD-16-02** — **`verify/2`** failure branches match atomic audit pattern without **D-27** success-path noise. +2. **AUD-16-03** — **AUD-04-044..046** **T1** in **44** inventory + **09-VERIFICATION** C-1 matrix. +3. **AUD-16-04** — Success path remains telemetry-only. + +### Stats + +- **Requirements:** 4/4 **Validated** in archived [`milestones/v1.16-REQUIREMENTS.md`](milestones/v1.16-REQUIREMENTS.md). +- **Timeline:** **2026-04-24**; **`/gsd-complete-milestone`** same day — live **`REQUIREMENTS.md`** removed. +- **Milestone audit:** not filed (optional); pre-close **`audit-open`**: all artifact types clear (2026-04-24). +- **`gsd-sdk query milestone.complete`:** not relied on; manual **`milestones/v1.16-*`** archival (same pattern as **v1.12**–**v1.15**). +- **Git (since `v1.15` tag):** 1 commit; **13** files (**487** insertions / **80** deletions in `git diff --shortstat 'v1.15^{}'..HEAD` at close). + +### Tech debt carried forward + +- **SEED-002** — remaining **`log_safe/3`** clusters (e.g. **043**, **048–049**, OAuth phase **45**). +- **AUD-04-022** — **`log_safe`** invalid enrollment path unchanged (**EX-44-02**). + +**Archive:** + +- [v1.16 Roadmap](milestones/v1.16-ROADMAP.md) +- [v1.16 Requirements](milestones/v1.16-REQUIREMENTS.md) + +--- + +## v1.15 Account + API C-1 planning truth (Shipped: 2026-04-24) + +**Scope:** 1 phase (**78**), library tests + planning truth (**AUD-14**..**AUD-14-05**). + +**What shipped:** **`44-AUD-04-INVENTORY.md`** rows **035–042** and **047** aligned to **`Multi` + `log_multi_safe`** in **`lib/sigra/account.ex`** and **`lib/sigra/api_token.ex`**; **`09-VERIFICATION.md`** C-1 **T1**/**T2** honesty for those rows; **`09-03-SUMMARY.md`** bounded-batch note for **phase 78** / **AUD-14**; **`CHANGELOG.md` [Unreleased]** trace bullet; **`test/sigra/account_audit_atomicity_test.exs`** **`change_password`** success + CHECK-guard rollback. + +### Key accomplishments + +1. **AUD-14-01 / AUD-14-02** — Inventory rows match code for **Account** paths and **`APIToken.revoke/2`**, preserving **EX-44-05** and **EX-44-01** for **044–046** at **v1.15** close (**044–046** advanced in **v1.16** / **phase 79**). +2. **AUD-14-03** — **09-VERIFICATION** Phase **44** table carries defensible **T1**/**T2** labels for **035–042**, **043**, **044–046**, **047**, **048–049**. +3. **AUD-14-04 / AUD-14-05** — Summary + changelog trace; Postgres-backed atomicity tests for **`change_password`**. + +### Stats + +- **Requirements:** 5/5 **Validated** in archived [`milestones/v1.15-REQUIREMENTS.md`](milestones/v1.15-REQUIREMENTS.md). +- **Timeline:** **2026-04-24**; **`/gsd-complete-milestone`** same day — live **`REQUIREMENTS.md`** removed. +- **Milestone audit:** not filed (optional); pre-close **`audit-open`**: all artifact types clear (2026-04-24). +- **`gsd-sdk query milestone.complete`:** failed (`version required for phases archive`); archival manual (same pattern as **v1.12**–**v1.14**). +- **Git (since `v1.14` tag):** ~4 commits; **13** files (**282** insertions / **72** deletions in `git diff --shortstat v1.14..HEAD` at close). + +### Tech debt carried forward + +- **SEED-002** — remaining **`log_safe/3`** clusters per **44** / phase **45** inventory; backlog-triggered. +- **AUD-04-022** — **`log_safe`** invalid enrollment path unchanged (**EX-44-02**). + +**Archive:** + +- [v1.15 Roadmap](milestones/v1.15-ROADMAP.md) +- [v1.15 Requirements](milestones/v1.15-REQUIREMENTS.md) + +--- diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 0cf95f91..d6802056 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -18,15 +18,39 @@ Milestone scoping for GSD (`/gsd-new-milestone`, `/gsd-plan-phase`) should prefe **GSD use:** When a phase or milestone proposal does not clearly move one of the bullets above, treat it as lower priority unless it closes a documented adoption gap or security/audit risk. -## Current milestone +## Current Milestone: v1.20 GA Launch — SEED closure + public release -_No coordinated GSD milestone open._ Use **`/gsd-new-milestone`** when **`MAINTAINING.md`** *Resume `/gsd-new-milestone`* triggers fire; otherwise ship small fixes via **CHANGELOG + Hex** without a new planning milestone. +**Goal:** Close the last two trust-surface gates (**SEED-001** GA UAT closure, **SEED-002** OAuth audit atomicity) and execute Sigra's first public release per the v1.5 **`MAINT-01`** checklist — turning Sigra from "evidence-capable, on-disk only" into "publicly available, used in production." -**Last closed:** **v1.14 — Bounded audit trust closure (SEED-002 slice)** (**Phase 77**, **AUD-13**..**AUD-13-04**, **2026-04-24**). **`audit_backup_codes_regenerate/3`** and **`audit_trust_browser/2`** use **`commit_ad_hoc_mfa_audit/5`**; **`mfa_audit_atomicity_test.exs`**; planning truth on **09** / **44** / **CHANGELOG**. Archives: [`.planning/milestones/v1.14-ROADMAP.md`](milestones/v1.14-ROADMAP.md), [`.planning/milestones/v1.14-REQUIREMENTS.md`](milestones/v1.14-REQUIREMENTS.md). Verification: **`.planning/phases/77-mfa-adhoc-audit-multi/77-VERIFICATION.md`**. +**Target features (3 legs):** + +- **SEED-002 — OAuth audit atomicity closure** — Convert remaining `log_safe/3` OAuth/ops clusters in **Phase 45 T2** (**052–056**, **058**, **063**) to atomic **`Repo.transaction/1` + `Ecto.Multi` + `log_multi_safe`**. Refresh **`45-AUD-04-INVENTORY.md`**, **`09-VERIFICATION.md`** C-1 matrix, **`09-03-SUMMARY.md`**. Downgrade Phase 9 **C-1** from **PASS-WITH-CAVEATS → PASS**. Audit-aware tests; `CHANGELOG [Unreleased]`. +- **SEED-001 — GA UAT closure** — Close all 8 GA-risk items with merge-blocking machine evidence: email visual regression, generated-host OAuth install proof, issuer-backed OAuth browser flows, MFA backup-code rotation E2E, and generated-host getting-started install/runtime proof. File **`.planning/v1.20-GA-UAT-RESULTS.md`** against the exact release SHA/tag; archive evidence under **`.planning/uat-evidence/v1.20/`**. +- **Public launch execution** — Tag **v1.20**; **`mix hex.publish`**; promote README from "production readiness available" to "use this in production" with v1.20 evidence/closure pointers; write + publish announcement post (positioning vs Pow / phx.gen.auth, why hybrid lib+generator, getting-started link); HN submission with outcome captured; Elixir community soft-launch (Discord/forum); add **`MAINTAINING.md`** post-launch monitoring lane (24h / 7d / 30d cadence: issues, Hex downloads, GitHub stars, triage SLA). + +**Selected seeds:** **SEED-001** (GA UAT closure) + **SEED-002** (OAuth audit atomicity remainder) — both close in this milestone. + +**Explicit non-goals:** **`sigra_lockspire`** / **ADR 001** glue package (still awaiting companion-app trigger); **999.x** Nyquist archaeology; responding to week-one launch feedback (deferred to a follow-up milestone if signal warrants). + +Live **`.planning/REQUIREMENTS.md`** + **`.planning/ROADMAP.md`**. + +### Previously closed milestones + +**v1.19 — JWT refresh persistence + audit co-fate & MFA enrollment failure (SEED-002)** — **Phases 82–83** (shipped **2026-04-24**). Closed the **v1.18** footnote deferral: **JWT `user_tokens` rotation** (`Sigra.JWT.RefreshToken` / **`Sigra.JWT.refresh/3`**) shares a **single transactional boundary** with **`api.jwt_refresh`** / **`api.jwt_refresh_reuse`** audit rows when `:audit_schema` is set. Second tranche: **`AUD-04-022`** / **`EX-44-02`** — invalid pre-DB TOTP on **`Sigra.MFA.confirm_enrollment/5`** promoted to the same **`Multi` + `log_multi_safe`** discipline where semantics allow. Plus **Phase 84** routing-honesty reconciliation (**2026-04-25**). + +**Previously closed:** **v1.18 — JWT refresh / reuse audit atomicity (SEED-002 / AUD-04-048..049 / AUD-18)** (**Phase 81**, **AUD-18-01**..**AUD-18-04**, **2026-04-24**). **`Sigra.APIToken.audit_jwt_refresh/2`** / **`audit_jwt_refresh_reuse/2`** use **`Repo.transaction/1`** + audit-only **`Multi` + `log_multi_safe`** when `:audit_schema` is set; **`api_token_audit_atomic_test.exs`**; **44** / **45** / **09** / **`CHANGELOG` [Unreleased]**; **JWT persistence co-fate** explicitly deferred to **v1.19**. Verification: **`.planning/phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md`**. + +**Previously closed:** **v1.17 — Forced password change audit atomicity (SEED-002 / AUD-04-043)** (**Phase 80**, **AUD-17-01**..**AUD-17-04**, **2026-04-24**). **`Sigra.Account.clear_password_change_requirement/3`** **`Multi` + `log_multi_safe`**; **`audit_forced_password_change/2`** **`@deprecated`**; **`account_audit_atomicity_test.exs`**; **44** / **09** / **09-03-SUMMARY** / **`CHANGELOG` [Unreleased]**; **EX-44-05** closed. Archives: [`.planning/milestones/v1.17-ROADMAP.md`](milestones/v1.17-ROADMAP.md), [`.planning/milestones/v1.17-REQUIREMENTS.md`](milestones/v1.17-REQUIREMENTS.md). Verification: **`.planning/phases/80-forced-password-change-audit/80-VERIFICATION.md`**. + +**Previously closed:** **v1.16 — API verify failure audit atomicity (SEED-002 slice)** (**Phase 79**, **AUD-16-01**..**AUD-16-04**, **2026-04-24**). **`Sigra.APIToken.verify/2`** failure **`api.token_verify.failure`** via **`Repo.transaction/1`** + **`Multi` + `log_multi_safe`**; **`api_token_audit_atomic_test.exs`**; **44** / **09** / **09-03-SUMMARY** / **`CHANGELOG` [Unreleased]**; **D-27** preserved. Archives: [`.planning/milestones/v1.16-ROADMAP.md`](milestones/v1.16-ROADMAP.md), [`.planning/milestones/v1.16-REQUIREMENTS.md`](milestones/v1.16-REQUIREMENTS.md). Verification: **`.planning/phases/79-api-token-verify-failure-audit/79-VERIFICATION.md`**. + +**Previously closed:** **v1.15 — Account + API C-1 planning truth (SEED-002 slice)** (**Phase 78**, **AUD-14**..**AUD-14-05**, **2026-04-24**). **44** + **09** C-1 planning truth for **035–042**, **047**; **`09-03-SUMMARY`** + **`CHANGELOG` [Unreleased]**; **`account_audit_atomicity_test.exs`** **`change_password`**. Archives: [`.planning/milestones/v1.15-ROADMAP.md`](milestones/v1.15-ROADMAP.md), [`.planning/milestones/v1.15-REQUIREMENTS.md`](milestones/v1.15-REQUIREMENTS.md). Verification: **`.planning/phases/78-account-api-c1-planning-truth/78-VERIFICATION.md`**. + +**Previously closed:** **v1.14 — Bounded audit trust closure (SEED-002 slice)** (**Phase 77**, **AUD-13**..**AUD-13-04**, **2026-04-24**). **`audit_backup_codes_regenerate/3`** and **`audit_trust_browser/2`** use **`commit_ad_hoc_mfa_audit/5`**; **`mfa_audit_atomicity_test.exs`**; planning truth on **09** / **44** / **CHANGELOG**. Archives: [`.planning/milestones/v1.14-ROADMAP.md`](milestones/v1.14-ROADMAP.md), [`.planning/milestones/v1.14-REQUIREMENTS.md`](milestones/v1.14-REQUIREMENTS.md). Verification: **`.planning/phases/77-mfa-adhoc-audit-multi/77-VERIFICATION.md`**. **Previously closed:** **v1.13 — Post–v1.12 operational cadence** (planning **2026-04-24**, **Phase 76**, **CAD-01**..**CAD-03**). Archives: [`.planning/milestones/v1.13-ROADMAP.md`](milestones/v1.13-ROADMAP.md), [`.planning/milestones/v1.13-REQUIREMENTS.md`](milestones/v1.13-REQUIREMENTS.md). Attestation: **`.planning/phases/76-post-v1-12-cadence-lock-in/76-VERIFICATION.md`**. -**Last shipped code milestone:** **v1.14 — Bounded audit trust closure** (**Phase 77**, **2026-04-24**; **`AUD-13`**). Archives: [`.planning/milestones/v1.14-ROADMAP.md`](milestones/v1.14-ROADMAP.md), [`v1.14-REQUIREMENTS.md`](milestones/v1.14-REQUIREMENTS.md). _(Prior multi-phase doc ship: **v1.12** — phases **73–75**.)_ +**Last shipped code milestone:** **v1.18 — JWT refresh / reuse audit atomicity** (**Phase 81**, **2026-04-24**; **`AUD-18`**). Verification: **`.planning/phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md`**. _(Prior: **v1.17** — **Phase 80** / **`AUD-17`**.)_ **Previously closed:** **v1.11 Adoption stabilization** — shipped **2026-04-23** (**phases 71–72**; **`STAB-01`**..**`STAB-04`**). Archives: [`.planning/milestones/v1.11-ROADMAP.md`](milestones/v1.11-ROADMAP.md), [`v1.11-REQUIREMENTS.md`](milestones/v1.11-REQUIREMENTS.md); triage [`.planning/v1.11-TRIAGE.md`](v1.11-TRIAGE.md). @@ -40,7 +64,19 @@ _No coordinated GSD milestone open._ Use **`/gsd-new-milestone`** when **`MAINTA ## Current State -**v1.14 (archived 2026-04-24):** Phase **77** — **AUD-13**..**AUD-13-04** MFA ad-hoc audit **`Multi`** closure. Archives: **`milestones/v1.14-ROADMAP.md`**, **`milestones/v1.14-REQUIREMENTS.md`**; verification **`.planning/phases/77-mfa-adhoc-audit-multi/77-VERIFICATION.md`**. Live **`.planning/REQUIREMENTS.md`** removed at milestone close — recreate via **`/gsd-new-milestone`**. +**v1.20 (started 2026-04-25):** Defining requirements — three legs (**SEED-002** OAuth audit atomicity closure, **SEED-001** GA UAT closure, public launch per v1.5 **`MAINT-01`** checklist). Phase numbering continues from **Phase 84** (last completed); **`--reset-phase-numbers`** not used. Live **`.planning/REQUIREMENTS.md`** + **`.planning/ROADMAP.md`** drafting. + +**v1.19 (shipped 2026-04-24):** Phases **82–83** — **AUD-19** (JWT **`user_tokens`** persistence + **`api.jwt_refresh*`** co-fate) + **AUD-20** (**`AUD-04-022`** invalid-code → **`commit_ad_hoc_mfa_audit/5`**). **`83-VERIFICATION.md`** / **`82-VERIFICATION.md`** merge gates. **Phase 84** routing-honesty reconciliation closed **2026-04-25** (`84-VERIFICATION.md`). + +**v1.18 (shipped 2026-04-24):** Phase **81** — **AUD-18-01**..**AUD-18-04** — **`audit_jwt_refresh/2`** / **`audit_jwt_refresh_reuse/2`** transactional **`log_multi_safe`** (audit-only txn); **`api_token_audit_atomic_test.exs`**; **44** / **45** / **09** / **`CHANGELOG` [Unreleased]**; **persistence co-fate** → **v1.19**. Verification: **`.planning/phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md`**. + +**v1.17 (archived 2026-04-24):** Phase **80** — **AUD-17-01**..**AUD-17-04** — **`clear_password_change_requirement/3`** + **`account_audit_atomicity_test.exs`**; **44** / **09** / **09-03-SUMMARY** / **`CHANGELOG` [Unreleased]**; **EX-44-05** closed. Archives **`milestones/v1.17-ROADMAP.md`**, **`milestones/v1.17-REQUIREMENTS.md`**; verification **`.planning/phases/80-forced-password-change-audit/80-VERIFICATION.md`**. + +**v1.16 (archived 2026-04-24):** Phase **79** — **AUD-16-01**..**AUD-16-04** — **`Sigra.APIToken.verify/2`** **`api.token_verify.failure`** transactional **`log_multi_safe`** (**AUD-04-044..046**); **`api_token_audit_atomic_test.exs`**; archives **`milestones/v1.16-ROADMAP.md`**, **`milestones/v1.16-REQUIREMENTS.md`**; verification **`.planning/phases/79-api-token-verify-failure-audit/79-VERIFICATION.md`**. + +**v1.15 (archived 2026-04-24):** Phase **78** — **AUD-14**..**AUD-14-05** — **SEED-002** planning truth for **Account** + **`APIToken.revoke`** C-1 rows (**AUD-04-035..042**, **047**) aligned to **`lib/`**; **`account_audit_atomicity_test.exs`** **`change_password`**. Archives: **`milestones/v1.15-ROADMAP.md`**, **`milestones/v1.15-REQUIREMENTS.md`**; verification **`.planning/phases/78-account-api-c1-planning-truth/78-VERIFICATION.md`**. + +**v1.14 (archived 2026-04-24):** Phase **77** — **AUD-13**..**AUD-13-04** MFA ad-hoc audit **`Multi`** closure. Archives: **`milestones/v1.14-ROADMAP.md`**, **`milestones/v1.14-REQUIREMENTS.md`**; verification **`.planning/phases/77-mfa-adhoc-audit-multi/77-VERIFICATION.md`**. **v1.13 (planning shipped 2026-04-24):** Phase **76** — **CAD-01**..**CAD-03** cadence lock-in. Archives: **`milestones/v1.13-ROADMAP.md`**, **`milestones/v1.13-REQUIREMENTS.md`**; verification **`.planning/phases/76-post-v1-12-cadence-lock-in/76-VERIFICATION.md`**. @@ -70,11 +106,14 @@ Sigra is a Phoenix 1.8+ authentication platform spanning the v1.0 auth stack, v1 ## Next milestone goals -**When to open v1.15+ (or patch-only):** Prefer **CHANGELOG + Hex** for small fixes. Open a **new coordinated milestone** when an event matches **`MAINTAINING.md`** *Resume `/gsd-new-milestone`* list (loud launch + **SEED-001**, compliance / incident + further **SEED-002**, documented adoption gap, **ADR 001** glue). +**v1.20 is active** (started **2026-04-25**) and bundles all three remaining "before megaphone launch" gates: **SEED-001** GA UAT closure, **SEED-002** Phase **45 T2** OAuth audit atomicity closure (**052–056**, **058**, **063**), and the public launch sequence (Hex push, README promotion, announcement post, HN, Elixir community soft-launch, post-launch monitoring lane). -**Backlog / hygiene:** Phase **999.1** and **999.x** — optional archaeology; see **`.planning/ROADMAP.md`**. **`STATE.md`** is session handoff only. **Planning precedence:** **`ROADMAP.md`** + phase **`*-VERIFICATION.md`** over conflicting **`STATE.md`** notes. +**Later candidates (post–v1.20):** +- **`sigra_lockspire`** glue package per **ADR 001** — only after a real companion-app trigger fires. +- Week-one launch-feedback follow-ups — sized as a **v1.21** patch milestone if signal warrants; not pre-scoped. +- Any newly identified validation / assurance work uses newly numbered phases (no **999.x** reuse). -**Later candidates:** bounded **SEED-002** batches, **SEED-001** human matrix before megaphone launch, **999.x** only if promoted, **`sigra_lockspire`** per ADR **001** triggers. +**Backlog / hygiene:** **`999.1`** / **999.x** remain archaeology only; see **`.planning/ROADMAP.md`** and **`999.1-*`** tombstone files. **`STATE.md`** is session handoff only. **Planning precedence:** **`ROADMAP.md`** + phase **`*-VERIFICATION.md`** / **`*-VALIDATION.md`** over conflicting **`STATE.md`** notes.
Archived v1.2 milestone framing (Admin Dashboard) @@ -104,6 +143,53 @@ Sigra is a Phoenix 1.8+ authentication platform spanning the v1.0 auth stack, v1 ## Requirements +### Active — v1.20 GA Launch (in progress) + +_See **`.planning/REQUIREMENTS.md`** for the full v1.20 REQ-ID list (LAUNCH-*, AUD-21-*, GAUAT-*). Three legs: SEED-002 OAuth audit atomicity closure, SEED-001 GA UAT closure, public launch execution._ + +- ✓ **AUD-21** — OAuth audit atomicity closure (Phase 45 T2 cluster: 052–056, 058, 063 → atomic) — **Phase 85** (2026-04-25) +- ✓ **GAUAT-01** — Phase 04 lockout + suspicious-login email visual regression: 8 baselines, evidence under `.planning/uat-evidence/v1.20/email-phase-04/`, 0-human-MUA — **Phase 86** (2026-04-26) +- ✓ **GAUAT-02** — Phase 08 lifecycle email visual regression: 28 baselines, evidence under `.planning/uat-evidence/v1.20/email-phase-08/`, same residual policy as GAUAT-01 — **Phase 86** (2026-04-26) + +### Validated — v1.19 JWT persistence + audit co-fate & MFA invalid-code audit (shipped in-repo 2026-04-24) + +- ✓ **AUD-19-01** — **`Sigra.JWT` / `RefreshToken.rotate`** success path co-fates **`user_tokens`** + **`api.jwt_refresh`** when `:audit_schema` is set — **Phase 82** +- ✓ **AUD-19-02** — Reuse-detected path co-fates family revocation + **`api.jwt_refresh_reuse`** — **Phase 82** +- ✓ **AUD-19-03** — **`jwt_refresh_audit_cofate_test.exs`** (+ related) proves co-fate, audit-off, fault injection — **Phase 82** +- ✓ **AUD-19-04** — **09-VERIFICATION** / **44** / **45** / **09-03-SUMMARY** / **`CHANGELOG` [Unreleased]** + **`82-VERIFICATION.md`** — **Phase 82** +- ✓ **AUD-20-01** — **`Sigra.MFA.confirm_enrollment/5`** invalid-TOTP path → **`commit_ad_hoc_mfa_audit/5`** — **Phase 83** +- ✓ **AUD-20-02** — **`mfa_audit_atomicity_test.exs`** invalid-code matrix — **Phase 83** +- ✓ **AUD-20-03** — **44** inventory **022** + **EX-44-02**, **09** C-1 **022**, **09-03-SUMMARY**, **`CHANGELOG` [Unreleased]**, **`83-VERIFICATION.md`** — **Phase 83** + +### Validated — v1.18 JWT refresh / reuse audit atomicity (shipped in-repo 2026-04-24) + +- ✓ **AUD-18-01** — **`Sigra.APIToken.audit_jwt_refresh/2`** — **`Repo.transaction/1`** + audit-only **`Multi` + `log_multi_safe`** when `:audit_schema` set — **Phase 81** +- ✓ **AUD-18-02** — **`Sigra.APIToken.audit_jwt_refresh_reuse/2`** — same pattern — **Phase 81** +- ✓ **AUD-18-03** — **`api_token_audit_atomic_test.exs`** — happy path + audit insert **`CHECK`** rollback / audit-off — **Phase 81** +- ✓ **AUD-18-04** — **44** + **45** inventories, **09-VERIFICATION** C-1 **048–049**, **09-03-SUMMARY**, **`CHANGELOG` [Unreleased]** — **Phase 81** + +### Validated — v1.17 Forced password change audit atomicity (shipped in-repo 2026-04-24) + +- ✓ **AUD-17-01** — **`Sigra.Account.clear_password_change_requirement/3`** — **`Repo.transaction/1`** + **`Ecto.Multi`** + **`log_multi_safe`** when `:audit_schema` is set — **Phase 80** +- ✓ **AUD-17-02** — **`audit_forced_password_change/2`** **`@deprecated`** for that completion path — **Phase 80** +- ✓ **AUD-17-03** — **`account_audit_atomicity_test.exs`** forced-clear + **`CHECK`** rollback — **Phase 80** +- ✓ **AUD-17-04** — **44** / **09** / **09-03-SUMMARY** / **`CHANGELOG` [Unreleased]** — **Phase 80** + +### Validated — v1.16 API verify failure audit atomicity (shipped in-repo 2026-04-24) + +- ✓ **AUD-16-01** — **`verify/2`** invalid-token **`api.token_verify.failure`** — **`Repo.transaction/1`** + **`Multi` + `log_multi_safe`** — **Phase 79** +- ✓ **AUD-16-02** — **`verify/2`** revoked + expired failure branches — same pattern — **Phase 79** +- ✓ **AUD-16-03** — **44** + **09** + **09-03-SUMMARY** + **`CHANGELOG` [Unreleased]** — **044–046** **T1** — **Phase 79** +- ✓ **AUD-16-04** — **D-27** — no success-path **`api.token_verify`** audit — **Phase 79** + +### Validated — v1.15 Account + API C-1 planning truth (shipped 2026-04-24) + +- ✓ **AUD-14-01** — **44-AUD-04-INVENTORY** — **AUD-04-035..042** **`Multi` + `log_multi_safe`** + **Phase 78**; **043** was **`log_safe`** (**EX-44-05** open) at **v1.15** close — superseded by **v1.17** / **phase 80** (**AUD-17**) — **Phase 78** +- ✓ **AUD-14-02** — Same file — **AUD-04-047** **`Multi` + `log_multi_safe`**; **044–046** were **`log_safe`** (**EX-44-01**) at **v1.15** close — superseded by **v1.16** / **phase 79** (**AUD-16**) — **Phase 78** +- ✓ **AUD-14-03** — **`09-VERIFICATION.md`** C-1 rows **035–042**, **047** → **T1**; **043** / **044–046** / **048–049** honest **T2** / deferral — **Phase 78** +- ✓ **AUD-14-04** — **`09-03-SUMMARY.md`** phase **78** bounded-batch note + document status — **Phase 78** +- ✓ **AUD-14-05** — **`CHANGELOG.md` [Unreleased]** + **`account_audit_atomicity_test.exs`** **`change_password`** CHECK rollback — **Phase 78** + ### Validated — v1.14 Bounded audit trust closure (shipped 2026-04-24) - ✓ **AUD-13-01** — **`audit_backup_codes_regenerate/3`** **`Multi` + `log_multi_safe`** — **Phase 77** @@ -354,7 +440,7 @@ _SEED-001 and SEED-002 were promoted and **closed in v1.4** (see `.planning/mile | Hybrid user/identity table pattern | `users` + `user_identities` — clean multi-provider support, natural Ecto idiom, matches Better Auth/Django Allauth/PowAssent. | ✓ Validated v1.0 — pattern held through registration/login/linking/unlink | | Argon2id default with bcrypt migration path | OWASP gold standard; memory-hard; transparent upgrade on login keeps migration invisible to users. | ✓ Validated v1.0 — `verify_with_upgrade/3` pattern works cleanly | | Database-backed session tokens (no JWT for browser) | Revocation requires server-side state; JWT-only for browser is an anti-pattern for session auth. | ✓ Validated v1.0 — JWT remains opt-in for stateless API paths only | -| D-01 universal atomic `Ecto.Multi` for audit writes | Audit rows must be as durable as the business op that produced them; no dropped rows on partial failure. | ✓ Advanced in v1.4 — prioritized Auth/MFA/Account/OAuth batches + merge-gated verification; remaining deferrals are explicitly listed under post–v1.4 **C-1** matrices | +| D-01 universal atomic `Ecto.Multi` for audit writes | Audit rows must be as durable as the business op that produced them; no dropped rows on partial failure. | ✓ Advanced through **v1.16** — **`APIToken.verify/2`** failure audits (**AUD-04-044..046**) use transactional **`log_multi_safe`**; **Account** + **`APIToken.revoke`** (**v1.15**) + prior batches; remaining deferrals explicit in **44** / **09** matrices | | D-10 installer default PK type = `binary_id` (uuid) | UUIDs are idiomatic for modern Phoenix; avoids enumeration of integer IDs; matches phx.gen.auth 1.8 convention. | ✓ Validated v1.0 (flipped in phase 10.1.1) — no integer-PK regressions downstream | | IN-03 SHA-pin all GitHub Actions | Supply-chain security: tag-based references allow the tag to be moved post-publish; SHA pins lock the exact code. | ✓ Validated v1.0 (phase 10.1 + 10.1.1) — Dependabot `github-actions` ecosystem handles upgrade churn | | D-15 no `continue-on-error` on any required CI check | Flakes must be fixed at root cause; masking them defeats the gate's purpose. | ✓ Validated v1.0 — all 5 CI jobs are strict-pass; no `continue-on-error` anywhere in `.github/workflows/ci.yml` | @@ -387,8 +473,11 @@ This document evolves at phase transitions and milestone boundaries. ---
-Archived milestone “Last updated” footers (v1.0–v1.13 execution log) +Archived milestone “Last updated” footers (v1.0–v1.17 execution log) +- **2026-04-24** — **`/gsd-complete-milestone` v1.17**: **`MILESTONES.md`** + **`RETROSPECTIVE.md`**; live **`REQUIREMENTS.md`** removed; tag **`v1.17`**; **`ROADMAP`** link → **`milestones/v1.17-REQUIREMENTS.md`**. +- **2026-04-24** — **`/gsd-complete-milestone` v1.16**: archived **`v1.16-REQUIREMENTS.md`**, **`v1.16-ROADMAP.md`**; live **`REQUIREMENTS.md`** removed; tag **`v1.16`**. +- **2026-04-24** — **`/gsd-complete-milestone` v1.15**: archived **`v1.15-REQUIREMENTS.md`**, **`v1.15-ROADMAP.md`**; live **`REQUIREMENTS.md`** removed; tag **`v1.15`**. - **2026-04-24** — **`/gsd-complete-milestone` v1.14**: archived **`v1.14-REQUIREMENTS.md`**, **`v1.14-ROADMAP.md`**; live **`REQUIREMENTS.md`** removed; tag **`v1.14`**. - **2026-04-24** — **`/gsd-complete-milestone` v1.13** (planning-only): archived **`v1.13-REQUIREMENTS.md`**, **`v1.13-ROADMAP.md`**; live **`REQUIREMENTS.md`** removed; no Hex tag (no library version bump). - **2026-04-24** — **`/gsd-complete-milestone` v1.12**: archived **`v1.12-REQUIREMENTS.md`**, **`v1.12-ROADMAP.md`**; live **`REQUIREMENTS.md`** removed; tag **`v1.12`**. @@ -409,4 +498,4 @@ This document evolves at phase transitions and milestone boundaries.
-*Last updated: 2026-04-24 — **`/gsd-complete-milestone` v1.14**: archived **`v1.14-ROADMAP.md`** + **`v1.14-REQUIREMENTS.md`**; live **`REQUIREMENTS.md`** removed; git tag **`v1.14`**; **`77-01-SUMMARY.md`** added.* +*Last updated: 2026-04-28 — **Phase 88** complete: GAUAT closing cluster + SEED-001 closure. Captured GAUAT-07 and GAUAT-08 evidence bundles via Playwright lane and generated-host smoke tests, updating SEED-001 honestly.* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 00000000..c9d5a0d2 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,95 @@ +# Requirements: Sigra — v1.20 GA Launch (SEED closure + public release) + +**Defined:** 2026-04-25 +**Milestone:** v1.20 — GA Launch — SEED closure + public release +**Selected seeds:** SEED-001 (human GA UAT), SEED-002 (OAuth audit atomicity remainder) + +## v1.20 Requirements + +### Leg 1 — SEED-002 OAuth audit atomicity closure (AUD-21) + +Closes the C-1 caveat that has hung over Phase 9 since v1.0. After this leg, every `log_safe/3` integration site in `lib/sigra/oauth/*` and the OAuth/ops Phase 45 T2 inventory uses atomic `Repo.transaction/1` + `Ecto.Multi` + `Sigra.Audit.log_multi_safe/3` when `:audit_schema` is set, matching the discipline already shipped in `Sigra.MFA`, `Sigra.Account`, and `Sigra.APIToken`. + +- [x] **AUD-21-01 +** — Convert OAuth/ops `log_safe/3` clusters at **AUD-04 rows 052–056, 058, 063** (per `.planning/phases/45-oauth-ops-c1-signoff/45-AUD-04-INVENTORY.md`) to atomic `Repo.transaction/1` + `Ecto.Multi` + `log_multi_safe`. On audit-insert failure: callers see `{:error, _}` and business-op rolls back; on `:audit_schema` unset: behavior preserved (telemetry-on-commit only). +- [x] **AUD-21-02 +** — Audit-aware test coverage at `test/sigra/oauth_audit_atomic_test.exs` (or extension of existing OAuth ceremony tests) proves: happy-path co-fate, audit-off parity, fault-injection rollback (CHECK guard) for each new atomic site. +- [x] **AUD-21-03 +** — Planning truth refresh: `45-AUD-04-INVENTORY.md` rows 052–056/058/063 marked T1 with phase reference; `09-VERIFICATION.md` C-1 matrix updated; `09-03-SUMMARY.md` post-batch narrative added; `CHANGELOG.md` `[Unreleased]` trace bullet. +- [x] **AUD-21-04 +** — Phase 9 **C-1 caveat downgraded from PASS-WITH-CAVEATS to PASS** in `09-VERIFICATION.md` frontmatter (`caveats: []` or removal) and `09-03-SUMMARY.md` summary block, with explicit reference to AUD-21 closure. SEED-002 status flipped to `validated` in `.planning/seeds/SEED-002-phase-9-log-safe-atomicity-followup.md` frontmatter. +- [x] **AUD-21-05 +** — Per-phase merge gate (`*-VERIFICATION.md`) in the implementing phase directory; `mix ci.audit_45` still green; library test suite + 5 CI gates remain green on `main`. + +### Leg 2 — SEED-001 GA UAT closure (GAUAT) + +Closes the 8 GA-risk UAT items listed in `.planning/seeds/SEED-001-v1.0-ga-human-uat-gate.md`. Each requirement maps to machine-authoritative evidence with recorded outcome and supporting artifacts. No GAUAT row requires a human witness to ship. + +- [x] **GAUAT-01** — **Phase 04 lockout + suspicious-login email visual regression (automated)** — Phase 86 ships the `email_visual_regression` CI job rendering both templates (`lockout_notification_email`, `suspicious_login_email`) across {Chromium, WebKit} × {light, dark} via Premailex-inlined HTML + Playwright `toHaveScreenshot`, plus extended ExUnit asserts (computed WCAG contrast, byte budget vs Gmail 102 KB clip, multipart parity, recipient correctness, XSS fuzz, Outlook-Word-engine deny-list, image tripwire) and caniemail.com CSS-feature lint. Eight baselines committed under `test/example/priv/playwright/__snapshots__/email-visual.spec.ts/`. Evidence (README, manifest.json, hero PNGs, contrast-summary.json, byte-budget.csv) under `.planning/uat-evidence/v1.20/email-phase-04/`. Phase-86 CONTEXT.md D-86-09 records the documented residual (legacy Outlook desktop Word engine — EOL Oct 2026; subjective copy tone — handled in PR review; spam-folder placement — adopter deliverability surface). 0 human MUA passes required. +- [x] **GAUAT-02** — **Phase 08 lifecycle email visual regression (automated)** — Same harness covers the 7 lifecycle templates (`email_change_confirmation_email`, `email_change_notification_email`, `email_changed_email`, `password_changed_email`, `deletion_scheduled_email`, `deletion_cancelled_email`, `deletion_finalized_email`); 28 baselines committed; evidence under `.planning/uat-evidence/v1.20/email-phase-08/`. Same residual policy as GAUAT-01. 0 human MUA passes required. +- [ ] **GAUAT-03** — **`mix sigra.gen.oauth` fresh-host smoke (automated)** — Extended `scripts/ci/install-smoke.sh` runs on every PR: `mix phx.new` + `sigra.install` + `sigra.gen.oauth --providers google,github` + `mix compile --warnings-as-errors` + `MIX_ENV=test mix test`, emitting `oauth-gen: 12/12 expected artifacts present, mix test green`. Transcript tee'd to `.planning/uat-evidence/v1.20/oauth-gen/transcript.log` (CI artifact + GitHub release asset on `v*` tags). Reshaped from human terminal-transcript capture per Phase 87 D-87-04 (precedent: Phase 86 D-86-08). +- [ ] **GAUAT-04** — **End-to-end OAuth register/login cycle (automated)** — Playwright spec `oauth-register.spec.ts` drives Sigra's example app against the in-process `Sigra.Testing.OAuthIssuer` (TestServer-backed, RS256 ID tokens, real PKCE — mirrors Assent's own `OIDCTestCase` precedent). Cells: provider button → 302 to /authorize with state nonce → mock auto-consent → callback → user + identity row + session cookie → logout → re-login (same user, no new identity row). Evidence: pass/fail manifest + Playwright trace under `.planning/uat-evidence/v1.20/oauth-google/`. Adopter-side real-credential check ships separately as `mix sigra.oauth.smoketest --provider=google` per Phase 87 D-87-03. Reshaped from human screen-recording capture per Phase 87 D-87-01 (0 human UAT — matches Auth.js / Spring Security / Assent / pow_assent / Devise+omniauth ecosystem convention). +- [ ] **GAUAT-05** — **Provider linking + last-method unlink prevention (automated)** — Playwright spec `oauth-link.spec.ts` covers four visual states: (1) linked-with-password (unlink enabled), (2) only-oauth-no-password (unlink disabled, tooltip matches verbatim source from `oauth_settings_live.ex:92`), (3) after-set-password (button re-enabled), (4) post-unlink (`user_identities` row absent, password login still works). Evidence: 4-row manifest + one hero PNG of the disabled-tooltip state under `.planning/uat-evidence/v1.20/oauth-link/`. Reshaped from human four-state screenshot capture per Phase 87 D-87-05. +- [ ] **GAUAT-06** — **Email-match confirmation flash + redirect (automated)** — Playwright spec `oauth-email-match.spec.ts` covers: pre-seeded user with password → mock issuer returns matching email + novel sub → flash text matches verbatim source from `oauth_controller.ex:96` → redirect to login → password login → identity row created → `provider_linked_email` arrival in `/dev/mailbox/json`. Evidence: 4-row manifest + flash-text + DB-probe + mailbox JSON under `.planning/uat-evidence/v1.20/oauth-email-match/`. Reshaped from human screenshot capture per Phase 87 D-87-05. +- [x] **GAUAT-07 +** — **Backup-code regeneration E2E proof (automated)** — `mfa-backup-rotation.spec.ts` drives the real MFA settings flow in the example app (register/confirm/login → sudo → enroll MFA → capture pre-rotation backup code → regenerate via fresh TOTP) and proves both user-visible and persisted outcomes: new backup codes shown once, old plaintext no longer matches any current code, and `mfa.backup_codes_regenerate` audit persistence. Evidence under `.planning/uat-evidence/v1.20/mfa-backup-rotation/`; CI gate is `.github/workflows/ci.yml / mfa_e2e_playwright`. 0 human UAT required. +- [x] **GAUAT-08 +** — **Generated-host getting-started proof (automated)** — `scripts/ci/install-smoke.sh` runs the real getting-started path on a disposable Phoenix 1.8 host (`mix phx.new` → Sigra install → compile/migrate → generated-host auth lifecycle test → boot the app and hit the documented routes) and emits machine-readable environment, transcript, and lifecycle evidence under `.planning/uat-evidence/v1.20/getting-started-clean-machine/`. Subjective first-read timing/friction is explicitly non-gating. 0 human UAT required. +- [x] **GAUAT-09** — **Results filing + seed closure** — `.planning/v1.20-GA-UAT-RESULTS.md` is written with one explicit row per GAUAT-01..08, links to the machine evidence directories under `.planning/uat-evidence/v1.20/`, and a final go/no-go disposition for the launch leg. SEED-001 moves to `validated` when those rows have release-authoritative evidence on the launch SHA/tag; no human-only exception path remains for GAUAT-07 + or GAUAT-08. + +### Leg 3 — Public launch execution (LAUNCH) + +Executes the v1.5 `MAINT-01` First Public Launch checklist for the first time. Sequenced *after* legs 1 and 2 close so the launch is defensible. Failures here roll back narrowly (delete announcement, mark Hex release as broken) without invalidating legs 1 and 2. + +- [ ] **LAUNCH-01** — **Hex.pm publish v1.20** — Bump `mix.exs` version to `1.20.0`; tag `v1.20` annotated; `mix hex.publish` (with reviewable diff against the prior published version, if any); verify package shows on hex.pm with correct description, links, optional-deps, and ExDoc. Record release URL. (If this is Sigra's first-ever Hex publish, also covers `mix hex.user auth` setup if not already configured.) +- [ ] **LAUNCH-02** — **README "use this in production" promotion** — Update README from "production readiness available" framing to an explicit "Use this in production" section with: link to v1.20 GA evidence, link to Phase 9 C-1 PASS attestation (post-AUD-21), getting-started link, version-pin guidance. ExDoc landing path mirrors the change. +- [ ] **LAUNCH-03** — **Announcement post drafted + published** — Long-form post covering: (a) what Sigra is and why it exists, (b) positioning vs Pow / phx.gen.auth (without disparaging Pow — credit the prior art), (c) hybrid lib+generator architecture rationale, (d) what shipped v1.0–v1.20, (e) getting-started call-to-action, (f) where to file issues / contribute. Self-hosted blog or dev.to / Medium acceptable. Record canonical URL. +- [ ] **LAUNCH-04** — **Hacker News submission** — Submit announcement post to HN with title that reads honestly (no clickbait, no false-claim "production-ready" if anything material is open). Stay reachable in the comments for 4–8 hours after submission. Record: submission URL, peak score, top 3 comments + responses, and any issues filed against Sigra as a result. +- [ ] **LAUNCH-05** — **Elixir community soft-launch** — Post to: elixir-lang Discord (or active Elixir community Discord), elixirforum.com, and one of {Twitter/X, Bluesky, Mastodon}. Brief, link-driven posts pointing at the announcement. Record post URLs. +- [ ] **LAUNCH-06** — **MAINTAINING.md post-launch monitoring lane** — Add a `Post-launch monitoring (v1.20)` section to `MAINTAINING.md` with concrete checkpoints at 24h / 7d / 30d. Each checkpoint enumerates: open issues count, Hex downloads, GitHub star delta, time-to-first-response on issues, and a triage SLA (e.g. acknowledge within 24h, resolve sev-1 within 72h). Initial 24h checkpoint filled in as part of this requirement; 7d and 30d remain pending with documented owner. +- [ ] **LAUNCH-07** — **CHANGELOG + ExDoc final alignment** — `CHANGELOG.md` v1.20.0 section finalized: covers AUD-21 (audit completeness PASS), GAUAT closure pointer, launch metadata, upgrade notes (none expected — pure additive). ExDoc extras include `upgrading-to-v1.20.md` (or "no upgrade required" stub if changeset is purely additive); `mix docs --warnings-as-errors` clean. + +## Future requirements + +- **Week-one launch-feedback follow-ups** — sized as a v1.21 patch milestone if signal warrants. Not pre-scoped. +- **Phase 45 T2 stragglers** beyond 052–056/058/063, if any surface during AUD-21 inventory walk — captured as `EX-45-*` with reopen triggers, deferred to a later milestone. +- **`sigra_lockspire` glue package per ADR 001** — still awaiting companion-app trigger; explicitly out of scope for v1.20. +- **30d post-launch retrospective** — formal retrospective on launch-week outcomes, distinct from the LAUNCH-06 monitoring checkpoints. Triggered automatically at the 30d mark. + +## Out of scope + +- **Reopening 999.x archaeology** — assurance work uses newly numbered phases. +- **Re-auditing Phase 45 merge gate (`mix ci.audit_45`) beyond regression needed for AUD-21 edits.** +- **Responding to launch feedback during the v1.20 milestone window** — captured in LAUNCH-06 monitoring lane and routed to a follow-up milestone. +- **`sigra_lockspire` / ADR 001** — deferred until a real companion-app trigger fires. +- **Marketing site / standalone landing page** — README + announcement post cover positioning. A dedicated marketing site is a later concern. +- **Paid promotion / sponsorships** — organic only for first launch. + +## Traceability + +| REQ-ID | Phase | +|-----------|-------| +| AUD-21-01 | 85 | +| AUD-21-02 | 85 | +| AUD-21-03 | 85 | +| AUD-21-04 | 85 | +| AUD-21-05 | 85 | +| GAUAT-01 | 86 | +| GAUAT-02 | 86 | +| GAUAT-03 | 87 | +| GAUAT-04 | 87 | +| GAUAT-05 | 87 | +| GAUAT-06 | 87 | +| GAUAT-07 | 88 | +| GAUAT-08 | 88 | +| GAUAT-09 | 88 | +| LAUNCH-01 | 89 | +| LAUNCH-02 | 89 | +| LAUNCH-03 | 90 | +| LAUNCH-04 | 90 | +| LAUNCH-05 | 90 | +| LAUNCH-06 | 90 | +| LAUNCH-07 | 89 | + +_(Phase column populated by gsd-roadmapper, 2026-04-25. 21/21 requirements mapped to exactly one phase across Phases 85–90.)_ diff --git a/.planning/RETROSPECTIVE.md b/.planning/RETROSPECTIVE.md index 86a15029..63d64b04 100644 --- a/.planning/RETROSPECTIVE.md +++ b/.planning/RETROSPECTIVE.md @@ -1,6 +1,121 @@ # Project Retrospective -*Living document updated at milestone boundaries. v1.14 section added at milestone archive (2026-04-24).* +*Living document updated at milestone boundaries. v1.17 section added at milestone close (2026-04-24).* + +## Milestone: v1.17 — Forced password change audit atomicity + +**Shipped:** 2026-04-24 +**Phases:** 1 (80) | **Plans (on-disk):** 2 (`80-01`, `80-02`) | **Sessions:** n/a (not instrumented) + +### What was built + +- **AUD-17-01** — **`clear_password_change_requirement/3`** wraps **`PasswordChange.clear_force_change/2`** semantics in **`Repo.transaction/1`** with **`Multi` + `log_multi_safe`** when audit is enabled. +- **AUD-17-02** — **`audit_forced_password_change/2`** **`@deprecated`** for the path now covered by the **`Multi`**. +- **AUD-17-03** — **`account_audit_atomicity_test.exs`** forced-clear happy path + audit **`CHECK`** rollback. +- **AUD-17-04** — **44** / **09** / **09-03-SUMMARY** / **`CHANGELOG` [Unreleased]**; **EX-44-05** closed. + +### What worked + +- **Verification gate** — **`80-VERIFICATION.md`** mapped 1:1 to code, tests, and planning surfaces. +- **`audit-open` all clear** — no deferred artifact debt at close. + +### What was inefficient + +- **`gsd-sdk query milestone.complete`** still fails (`version required for phases archive`); manual **`milestones/v1.17-*`** + **`/gsd-complete-milestone`** finish (same as **v1.12**–**v1.16**). + +### Patterns established + +- **Account forced-clear** follows the same transactional audit pattern as **`change_password`** in **`account_audit_atomicity_test.exs`**. + +### Key lessons + +1. Close **EX-44-05** in the same milestone as **043** **T1** to avoid dangling inventory appendix rows. +2. Deprecation on the old helper keeps semver-safe migration for any host callers. + +### Cost observations + +- Model mix: n/a +- Sessions: n/a +- Notable: Tight scope (one public API + tests + planning); highest leverage was **`account.ex`** + atomic tests. + +--- + +## Milestone: v1.16 — API verify failure audit atomicity + +**Shipped:** 2026-04-24 +**Phases:** 1 (79) | **Plans (on-disk):** 0 | **Sessions:** n/a (not instrumented) + +### What was built + +- **AUD-16-01 / AUD-16-02** — **`verify/2`** invalid / revoked / expired paths emit **`api.token_verify.failure`** inside **`Repo.transaction/1`** with **`Ecto.Multi`** + **`log_multi_safe`** when `:audit_schema` is set. +- **AUD-16-03** — **44** inventory + **09-VERIFICATION** C-1 **044–046** → **T1**; **09-03-SUMMARY** + **`CHANGELOG` [Unreleased]** trace **v1.16** / **79** / **AUD-16**. +- **AUD-16-04** — **D-27** preserved: no success-path **`api.token_verify`** audit rows. +- **`api_token_audit_atomic_test.exs`** — Postgres-backed failure + fault-injection coverage alongside **`api_token_test.exs`**. + +### What worked + +- **Verification-first single phase** — **`79-VERIFICATION.md`** checklist mapped cleanly to code + docs. +- **`audit-open` all clear** — no deferred artifact debt at close. + +### What was inefficient + +- **`gsd-sdk query milestone.complete`** not used; manual **`milestones/v1.16-*`** writes (same as **v1.12**–**v1.15**). +- **No on-disk `79-SUMMARY.md`** — closure relied on **VERIFICATION** + requirements traceability (same shape as **v1.15**). + +### Patterns established + +- **Failure-only API token verify audits** co-fated with the **`{:error, reason}`** return path without widening **D-27** success noise. + +### Key lessons + +1. Retire **EX-44-01** appendix honesty when code moves **`044–046`** to **T1** — keep appendix row for archaeology. +2. Keep **`log_safe_error`** telemetry explicit when audit insert fails but the caller still gets the domain error. + +### Cost observations + +- Model mix: n/a +- Sessions: n/a +- Notable: Single commit since **`v1.15`** tag; highest leverage was **`api_token.ex`** + one atomic test module + planning matrix rows. + +--- + +## Milestone: v1.15 — Account + API C-1 planning truth + +**Shipped:** 2026-04-24 +**Phases:** 1 (78) | **Plans (on-disk):** 0 | **Sessions:** n/a (not instrumented) + +### What was built + +- **AUD-14-01 / AUD-14-02** — **44-AUD-04-INVENTORY** rows **035–042** and **047** aligned to **`lib/sigra/account.ex`** / **`lib/sigra/api_token.ex`**; hybrid **044–046** and **043** deferrals preserved (**EX-44-01**, **EX-44-05**). +- **AUD-14-03** — **09-VERIFICATION** C-1 table honesty for the same row IDs. +- **AUD-14-04 / AUD-14-05** — **09-03-SUMMARY** bounded-batch note; **CHANGELOG [Unreleased]** trace; **`account_audit_atomicity_test.exs`** **`change_password`** success + CHECK-guard rollback. + +### What worked + +- **Verification-first single phase** — **`78-VERIFICATION.md`** gave a tight checklist against inventory + matrix + tests. +- **`audit-open` all clear** — no deferred artifact debt at close. + +### What was inefficient + +- **`gsd-sdk query milestone.complete`** still failed (`version required for phases archive`); manual **`milestones/v1.15-*`** writes repeated the established pattern. +- **No on-disk `78-SUMMARY.md`** — closure relied on **VERIFICATION** + requirements traceability (same shape as other micro-ships). + +### Patterns established + +- **Planning truth before the next SEED-002 batch** — when code already matched **Multi**, the milestone was mostly honest **C-1** labeling + test evidence. + +### Key lessons + +1. Keep **EX-44-01** / **EX-44-05** explicit whenever **Account**/**API** rows mix **`log_safe`** and **`log_multi_safe`**. +2. **`change_password`** audit atomicity belongs in **`account_audit_atomicity_test.exs`** alongside **`set_password`** for symmetric confidence. + +### Cost observations + +- Model mix: n/a +- Sessions: n/a +- Notable: Small diff since **`v1.14`** tag; highest leverage was matrix + inventory alignment + one focused test file. + +--- ## Milestone: v1.14 — Bounded audit trust closure @@ -399,6 +514,7 @@ | Milestone | Sessions | Phases | Key change | |-----------|----------|--------|--------------| +| v1.16 | n/a | 1 | **`APIToken.verify/2`** failure **`api.token_verify.failure`** → **`Multi` + `log_multi_safe`** + **`api_token_audit_atomic_test.exs`** (**044–046** **T1**) | | v1.14 | n/a | 1 | MFA ad-hoc **`log_safe`** closure (**033**/**034**) → **`Multi` + `log_multi_safe`** + **`mfa_audit_atomicity_test.exs`** | | v1.6 | n/a | 3 | Nyquist 41–44 posture matrix + OA-01 OAuth ceremony audit tests + OA-02 docs alignment | | v1.5 | n/a | 4 | Public narrative + Hex/changelog/README/MAINTAINING alignment with v1.4 GA evidence | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 904647af..023ba043 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -18,115 +18,215 @@ - ✅ **v1.12 Trust, evidence, and adoption polish** — Phases **73–75** (shipped **2026-04-24**). See [v1.12 archive](milestones/v1.12-ROADMAP.md), [v1.12 requirements](milestones/v1.12-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). Bounded **SEED-002** batch + **SEED-001** evidence index + triage-driven doc polish. - ✅ **v1.13 Post–v1.12 operational cadence** — Phase **76** (shipped **2026-04-24**). See [v1.13 archive](milestones/v1.13-ROADMAP.md), [v1.13 requirements](milestones/v1.13-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). Planning-only cadence lock-in (**CAD-01**..**CAD-03**). - ✅ **v1.14 Bounded audit trust closure** — Phase **77** (shipped **2026-04-24**). See [v1.14 archive](milestones/v1.14-ROADMAP.md), [v1.14 requirements](milestones/v1.14-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). **SEED-002** slice — **AUD-04-033** / **034** (**AUD-13**). +- ✅ **v1.15 Account + API C-1 planning truth** — Phase **78** (shipped **2026-04-24**). See [v1.15 archive](milestones/v1.15-ROADMAP.md), [v1.15 requirements](milestones/v1.15-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). **AUD-14**..**AUD-14-05**. +- ✅ **v1.16 API verify failure audit atomicity** — Phase **79** (shipped **2026-04-24**). See [v1.16 archive](milestones/v1.16-ROADMAP.md), [v1.16 requirements](milestones/v1.16-REQUIREMENTS.md), and [MILESTONES.md](MILESTONES.md). +- ✅ **v1.17 Forced password change audit atomicity (SEED-002 / AUD-04-043)** — Phase **80** (shipped **2026-04-24**). See [v1.17 requirements](milestones/v1.17-REQUIREMENTS.md), [milestone archive](milestones/v1.17-ROADMAP.md), and [MILESTONES.md](MILESTONES.md). +- ✅ **v1.18 JWT refresh / reuse audit atomicity (SEED-002 / AUD-04-048..049)** — Phase **81** (shipped **2026-04-24**). [MILESTONES.md](MILESTONES.md); verification [`.planning/phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md`](phases/81-jwt-refresh-audit-atomicity/81-VERIFICATION.md). +- ✅ **v1.19 JWT refresh persistence + audit co-fate & MFA enrollment failure** — Phases **82–83** (shipped **2026-04-24**). [MILESTONES.md](MILESTONES.md). +- ✅ **Post-v1.19 routing honesty follow-up** — Phase **84** (completed **2026-04-25**). +- 🟡 **v1.20 GA Launch — SEED closure + public release** — Phases **85–90** (opened **2026-04-25**). Live [REQUIREMENTS.md](REQUIREMENTS.md); phases below. ## Phases -
-✅ v1.14 Bounded audit trust closure (Phase 77) — SHIPPED 2026-04-24 +### v1.20 — active (Phases **85–90**) -Full phase table, goals, and success criteria are archived in [`milestones/v1.14-ROADMAP.md`](milestones/v1.14-ROADMAP.md). +**Coverage:** 21 requirements → 6 phases. Numbering continues from **v1.19/post-v1.19** (last phase **84**). -**At a glance:** **77** — **`audit_backup_codes_regenerate/3`** / **`audit_trust_browser/2`** → **`commit_ad_hoc_mfa_audit/5`** (**`Multi` + `log_multi_safe`**); **`mfa_audit_atomicity_test.exs`**; **09** / **44** / **CHANGELOG** truth (**AUD-13-01**..**AUD-13-04**). Verification: [`.planning/phases/77-mfa-adhoc-audit-multi/77-VERIFICATION.md`](phases/77-mfa-adhoc-audit-multi/77-VERIFICATION.md). +**Dependency shape:** two parallel legs (Leg 1 — AUD-21 OAuth audit closure; Leg 2 — SEED-001 GAUAT closure) feed a sequential launch leg (Leg 3 — pre-launch then launch+monitoring). Legs 1 and 2 are independent and can run in either order. Leg 3 cannot start until **both** legs 1 and 2 close — the launch's defensibility depends on AUD-21 downgrading the Phase 9 C-1 caveat to PASS and GAUAT-09 filing v1.20 GA UAT results with a go-decision. -
+**Phase summary:** -
-✅ v1.13 Post–v1.12 operational cadence (Phase 76) — SHIPPED 2026-04-24 +- [x] **Phase 85: OAuth audit atomicity closure (AUD-21)** — Convert remaining `log_safe/3` OAuth/ops clusters in Phase 45 T2 (AUD-04 rows 052–056, 058, 063) to atomic `Repo.transaction/1` + `Ecto.Multi` + `Sigra.Audit.log_multi_safe/3`; refresh planning truth; downgrade Phase 9 C-1 caveat from PASS-WITH-CAVEATS to PASS. +- [x] **Phase 86: GAUAT email visual regression harness (Phase 04 + Phase 08 templates)** — Ship automated visual regression harness (Premailex CSS-inline + Playwright `toHaveScreenshot` Chromium+WebKit × light+dark + caniemail CSS lint + extended WCAG/byte/multipart/XSS ExUnit asserts) producing CI-reproducible evidence per template. 0 human MUA passes required for v1.20 launch. (completed 2026-04-26) +- [ ] **Phase 87: GAUAT OAuth automated end-to-end harness** — Ship `Sigra.Testing.OAuthIssuer` (TestServer-backed in-process OIDC issuer mirroring Assent's `OIDCTestCase`) + 3 Playwright specs (oauth-register / oauth-link / oauth-email-match) covering GAUAT-04/05/06 against Sigra's example app, plus extended `install-smoke.sh` covering GAUAT-03 (`mix phx.new` + `sigra.install` + `sigra.gen.oauth` + `--warnings-as-errors` + `mix test`); ship `mix sigra.oauth.smoketest --provider=google` + `docs/oauth-google-setup.md` for adopter-side real-credential check at install time. 0 human UAT. +- [x] **Phase 88: GAUAT MFA + getting-started + results filing** — Automated backup-code regeneration E2E proof; generated-host getting-started install/runtime proof; file `.planning/v1.20-GA-UAT-RESULTS.md`; flip SEED-001 status to `validated` when release-SHA evidence is complete. (completed 2026-04-28) +- [ ] **Phase 89: Pre-launch — Hex publish + README promotion + CHANGELOG/ExDoc alignment** — Bump `mix.exs` to 1.20.0; tag `v1.20`; `mix hex.publish`; promote README from "production readiness available" to "use this in production"; finalize CHANGELOG v1.20.0 section + `upgrading-to-v1.20.md` (or no-upgrade-required stub) so `mix docs --warnings-as-errors` is clean. +- [ ] **Phase 90: Launch + monitoring lane** — Publish announcement post; submit to Hacker News; soft-launch to Elixir Discord / forum / one social channel; install `MAINTAINING.md` "Post-launch monitoring (v1.20)" lane with 24h / 7d / 30d checkpoints and triage SLA; complete the 24h checkpoint as part of this phase. -Full phase table, goals, and success criteria are archived in [`milestones/v1.13-ROADMAP.md`](milestones/v1.13-ROADMAP.md). +## Phase Details -**At a glance:** **76** — **PROJECT** / **STATE** / **ROADMAP** / **REQUIREMENTS** + **76-VERIFICATION.md** record default Hex patch cadence and trust-signal event lanes (**CAD-01**..**CAD-03**). +### Phase 85: OAuth audit atomicity closure (AUD-21) -
+**Goal:** Close the last live SEED-002 audit-atomicity gap so every OAuth/ops integration site that emits a security-relevant audit row co-fates that row with its business-op transaction. After this phase, the Phase 9 C-1 caveat is downgraded from PASS-WITH-CAVEATS to PASS, and SEED-002 is `validated`. -
-✅ v1.12 Trust, evidence, and adoption polish (Phases 73–75) — SHIPPED 2026-04-24 +**Depends on:** Nothing (parallel-ready with Phases 86–88). -Full phase table, goals, and success criteria are archived in [`milestones/v1.12-ROADMAP.md`](milestones/v1.12-ROADMAP.md). +**Requirements:** AUD-21-01, AUD-21-02, AUD-21-03, AUD-21-04, AUD-21-05. -**At a glance:** **73** bounded **C-1** **`Multi`** + **`log_multi_safe`** + **`mfa_audit_atomicity_test.exs`** (**AUD-11**); **74** **09-03-SUMMARY** + **v1.12-UAT-EVIDENCE** + **`docs/uat-ci-coverage.md`** (**AUD-12**, **UAT-01**, **UAT-02**); **75** **`upgrading-to-v1.12.md`** + trust-bundle surfacing + **`v1.11-TRIAGE.md`** reconciliation (**TRN-01**..**TRN-03**). +**Success criteria** (what must be TRUE): -
+1. A maintainer running `rg "log_safe\\(" lib/sigra/oauth/ lib/sigra/oauth.ex` finds zero hits at the AUD-04 row 052–056/058/063 boundaries that v1.20 targeted; remaining `log_safe` calls are paired with explicit `EX-*` rows in `45-AUD-04-INVENTORY.md` (or have been retired from that inventory). +2. A maintainer querying `09-VERIFICATION.md` C-1 matrix sees PASS, not PASS-WITH-CAVEATS, with the AUD-21 phase reference embedded; `caveats:` in frontmatter is `[]` (or the field is removed). +3. Running `mix test test/sigra/oauth_audit_atomic_test.exs` (or the equivalent OAuth-ceremony audit test extension) on Postgres exercises happy-path co-fate, audit-off parity, and CHECK-guarded fault-injection rollback for each newly atomic site, and exits green. +4. `.planning/seeds/SEED-002-phase-9-log-safe-atomicity-followup.md` frontmatter `status:` reads `validated`; `45-AUD-04-INVENTORY.md` rows 052–056/058/063 carry T1 verdicts with phase 85 reference; `09-03-SUMMARY.md` has a phase-85 / AUD-21 narrative bullet; `CHANGELOG.md` `[Unreleased]` carries the AUD-21 trace bullet. +5. `mix ci.audit_45` and the library test suite (plus the 5 existing CI gates) remain green on `main` after the phase merges; `85-VERIFICATION.md` records the merge gate outcome. -
-✅ v1.11 Adoption stabilization (Phases 71–72) — SHIPPED 2026-04-23 +**Plans:** 4 plans. -| Phase | Name | Goal | Requirements | Success criteria (observable) | -|-------|------|------|----------------|----------------------------| -| **71** | Triage + maintainer pause guidance | Record adoption signals; document when to pause GSD milestones. | STAB-01, STAB-03 | (1) **`.planning/v1.11-TRIAGE.md`** is complete and linked from **`upgrading-to-v1.11.md`**. (2) **`MAINTAINING.md`** contains **Milestone cadence and pause** with pause/resume criteria. | -| **72** | Upgrade stub + intro cross-links | Planning **v1.11** vs Hex is legible; intro docs surface upgrade pages. | STAB-02, STAB-04 | (1) **`guides/introduction/upgrading-to-v1.11.md`** ships and appears in **`mix.exs`** ExDoc extras after **v1.10** upgrade page. (2) **getting-started** faster path lists **v1.10** and **v1.11** upgrade links; **upgrading-to-v1.10** See also links **v1.11**. | +Plans: +- [x] 85-01-PLAN.md — Atomicize impersonation session/audit orchestration and sharpen AUD-04 truth. +- [x] 85-02-PLAN.md — Close the C-1 narrative, seed status, and verification trail for AUD-21. -**Coverage:** 4 requirements → 2 phases. Phase numbering continues from **v1.10** (last phase **70**). +### Phase 86: GAUAT email visual regression harness (Phase 04 + Phase 08 templates) -
+**Goal:** Ship an automated email visual regression harness that produces CI-reproducible evidence for the 9 transactional email templates (2 Phase 04 security + 7 Phase 08 lifecycle) without any human MUA pass. The launch claim is downgraded from "real-mail-client tested" to **"render-tested across Chromium + WebKit engines × light + dark mode, with caniemail-validated CSS for Gmail web / new Outlook web / Apple Mail; legacy Outlook Word-engine desktop documented as out-of-scope (Microsoft EOL Oct 2026)"** — accurate, defensible, and reproducible from any SHA. 0 human UAT for v1.20 launch. -
-✅ v1.10 Adopter confidence for solo production (Phases 68–70) — SHIPPED 2026-04-23 +**Depends on:** Nothing (parallel-ready with Phase 85 and with Phases 87–88). -Full phase table, goals, and success criteria are archived in [`milestones/v1.10-ROADMAP.md`](milestones/v1.10-ROADMAP.md). +**Requirements:** GAUAT-01, GAUAT-02. -**At a glance:** **68** deployment + mail confidence hub (**`068-VERIFICATION.md`**, **ACF-01** / **ACF-04**); **69** intermediate path + **`generator-options`** index (**`069-VERIFICATION.md`**, **ACF-02** / **ACF-03**); **70** **`upgrading-to-v1.10.md`** + non-goal attestation (**`070-VERIFICATION.md`**, **ACF-05** / **ACF-06**). +**Success criteria** (what must be TRUE): -
+1. An external reviewer running `mix test` and `mix ci.email_visual` (or the equivalent CI workflow) on the phase-close SHA produces 36 baseline-matching snapshots (9 templates × 2 engines × 2 themes) byte-equal to the committed baselines under `test/example/priv/playwright/__snapshots__/email-visual.spec.ts/`. Pixel-diff > `maxDiffPixels` fails the build. +2. An external reviewer opening `.planning/uat-evidence/v1.20/email-phase-04/README.md` and `email-phase-08/README.md` finds: YAML frontmatter (`hex_version`, `git_sha`, `git_tag`, `ci_run_url`, `disposition`); `manifest.json` with one row per (template, engine, theme); `reports/contrast-summary.json` and `byte-budget.csv`; hero PNGs under `snapshots/` named `{template}__{engine}__{theme}__sha-{short-sha}.png` (~1-2 MB total per directory). +3. The full snapshot bundle (raw `.eml`, all engine PNGs at full res, axe-core JSONs) is uploaded as a GitHub Actions artifact at every CI run AND promoted to the `v1.20.0` GitHub release asset at tag time (release assets do not expire, vs. Actions artifacts capped at 400d — matters for SOC 2 Type II audit windows). +4. The extended ExUnit suite (`Sigra.A11y.Contrast` module + `Example.EmailAssertions` helper) asserts WCAG 2.2 AA computed contrast (CTA bumped to `#1d4ed8` for 5.17:1 normal-text), byte budget < 100 KB vs Gmail clip, multipart parity (text mirrors HTML URLs), recipient correctness, XSS fuzz on user-controlled fields, Outlook Word-engine deny-list (no ` + + +

Hello

+ + + """ + + describe "lint/1" do + test "returns :ok for HTML using only allow-listed CSS properties" do + assert :ok = CssLint.lint(@safe_html) + end + + test "returns error for display:flex (not supported by Gmail/Outlook/Apple Mail)" do + assert {:error, violations} = CssLint.lint(@unsafe_html_flex) + assert is_list(violations) + assert length(violations) > 0 + assert Enum.any?(violations, &String.contains?(&1, "flex")) + end + + test "returns error for display:grid (not supported by Gmail/Outlook/Apple Mail)" do + assert {:error, violations} = CssLint.lint(@unsafe_html_grid) + assert is_list(violations) + assert Enum.any?(violations, &String.contains?(&1, "grid")) + end + + test "returns error for position: (ignored by Outlook Word-engine)" do + assert {:error, violations} = CssLint.lint(@unsafe_html_position) + assert is_list(violations) + assert Enum.any?(violations, &String.contains?(&1, "position")) + end + + test "returns error for background-image: (stripped by Outlook Word-engine)" do + assert {:error, violations} = CssLint.lint(@unsafe_html_bg_image) + assert is_list(violations) + assert Enum.any?(violations, &String.contains?(&1, "background-image")) + end + + test "returns error for + +
Hello
+ + + """ + + assert {:error, violations} = CssLint.lint(combined) + assert length(violations) >= 2 + end + end + + describe "allowlist/0" do + test "returns a map with clients, allow_css, and deny_css keys" do + policy = CssLint.allowlist() + assert is_map(policy) + assert Map.has_key?(policy, "clients") + assert Map.has_key?(policy, "allow_css") + assert Map.has_key?(policy, "deny_css") + end + + test "clients list includes gmail-web, outlook-web-new, and apple-mail-macos" do + %{"clients" => clients} = CssLint.allowlist() + assert "gmail-web" in clients + assert "outlook-web-new" in clients + assert "apple-mail-macos" in clients + end + + test "allow_css includes common safe properties" do + %{"allow_css" => allowed} = CssLint.allowlist() + assert "background-color" in allowed + assert "color" in allowed + assert "font-size" in allowed + assert "padding" in allowed + end + + test "deny_css includes the four Word-engine landmine constructs" do + %{"deny_css" => denied} = CssLint.allowlist() + assert "display:flex" in denied or Enum.any?(denied, &String.contains?(&1, "flex")) + assert "display:grid" in denied or Enum.any?(denied, &String.contains?(&1, "grid")) + assert "position" in denied or Enum.any?(denied, &String.contains?(&1, "position")) + assert "background-image" in denied or Enum.any?(denied, &String.contains?(&1, "background-image")) + end + end +end diff --git a/test/sigra/impersonation_audit_atomicity_test.exs b/test/sigra/impersonation_audit_atomicity_test.exs new file mode 100644 index 00000000..8ccb6753 --- /dev/null +++ b/test/sigra/impersonation_audit_atomicity_test.exs @@ -0,0 +1,408 @@ +defmodule Sigra.ImpersonationAuditAtomicityTest do + @moduledoc """ + Postgres integration coverage for impersonation session/audit co-fate. + + For the non-atomic fallback path that still uses `session.create` / `session.delete` + plus `log_safe`, see `Sigra.ImpersonationTest`. + """ + + use ExUnit.Case, async: false + + alias Sigra.Admin.Scope, as: AdminScope + alias Sigra.Config + alias Sigra.Impersonation + alias Sigra.Session + alias Sigra.Test.AuditEvent, as: AuditTestEvent + alias Sigra.Test.PostgresRepo + + defmodule TestUser do + defstruct [:id, :email, :organization_ids] + end + + defmodule TestScope do + defstruct [:user, :active_organization, :membership, :impersonating_from] + end + + defmodule ImpersonationSessionRecord do + @moduledoc false + use Ecto.Schema + + @primary_key {:id, :binary_id, autogenerate: true} + schema "impersonation_audit_sessions" do + field(:user_id, :binary_id) + field(:hashed_token, :binary) + field(:token, :binary, virtual: true) + field(:type, :string, default: "standard") + field(:ip, :string) + field(:user_agent, :string) + field(:geo_city, :string) + field(:geo_country_code, :string) + field(:active_organization_id, :binary_id) + field(:last_active_at, :utc_datetime_usec) + field(:sudo_at, :utc_datetime_usec) + timestamps(type: :utc_datetime_usec) + end + end + + defmodule LegacySessionStore do + @moduledoc false + + def create(user_id, metadata, _opts) do + send(self(), {:legacy_create, user_id, metadata}) + + {:ok, + %Sigra.Session{ + id: 4242, + user_id: user_id, + token: "impersonation-raw", + hashed_token: "impersonation-hash", + type: :standard, + inserted_at: DateTime.utc_now(), + last_active_at: DateTime.utc_now() + }} + end + + def delete(hashed_token, _opts) do + send(self(), {:legacy_delete, hashed_token}) + :ok + end + end + + setup do + start_supervised!({PostgresRepo, PostgresRepo.default_config()}) + repo = PostgresRepo + + Ecto.Adapters.SQL.query!(repo, "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"", []) + + for table <- ["impersonation_audit_sessions", "audit_events"] do + Ecto.Adapters.SQL.query!(repo, "DROP TABLE IF EXISTS #{table} CASCADE", []) + end + + Ecto.Adapters.SQL.query!( + repo, + """ + CREATE TABLE impersonation_audit_sessions ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id uuid NOT NULL, + hashed_token bytea NOT NULL, + type varchar(32) NOT NULL DEFAULT 'standard', + ip varchar(64), + user_agent varchar(512), + geo_city varchar(255), + geo_country_code varchar(8), + active_organization_id uuid, + last_active_at timestamp, + sudo_at timestamp, + inserted_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now() + ) + """, + [] + ) + + Ecto.Adapters.SQL.query!( + repo, + """ + CREATE TABLE audit_events ( + id uuid PRIMARY KEY, + occurred_at timestamp NOT NULL DEFAULT now(), + action varchar(255) NOT NULL, + outcome varchar(32) NOT NULL DEFAULT 'success', + actor_id uuid, + actor_type varchar(64) NOT NULL DEFAULT 'user', + target_id uuid, + target_type varchar(64), + ip_address varchar(64), + user_agent varchar(512), + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + organization_id uuid, + effective_user_id uuid, + inserted_at timestamp NOT NULL DEFAULT now() + ) + """, + [] + ) + + Ecto.Adapters.SQL.query!(repo, "TRUNCATE TABLE impersonation_audit_sessions CASCADE", []) + Ecto.Adapters.SQL.query!(repo, "TRUNCATE TABLE audit_events CASCADE", []) + + %{repo: repo} + end + + defp admin_scope(mode, admin_user, organization_id \\ nil) do + organization = + case organization_id do + nil -> nil + id -> %{id: id, slug: "org-#{id}", name: "Org #{id}"} + end + + %AdminScope{ + mode: mode, + scope: %TestScope{ + user: admin_user, + active_organization: nil, + membership: nil, + impersonating_from: nil + }, + organization: organization, + organization_id: organization_id, + organization_slug: organization && organization.slug, + platform_admin?: mode == :global, + admin_org_ids: if(organization_id, do: [organization_id], else: []) + } + end + + defp session(user_id, attrs) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + struct( + %Session{ + id: attrs[:id] || 1, + user_id: user_id, + token: attrs[:token], + hashed_token: attrs[:hashed_token] || "hashed-session-token", + type: attrs[:type] || :standard, + last_active_at: Map.get(attrs, :last_active_at, now), + inserted_at: Map.get(attrs, :inserted_at, now), + active_organization_id: Map.get(attrs, :active_organization_id), + sudo_at: Map.get(attrs, :sudo_at) + }, + Map.drop(attrs, [ + :id, + :token, + :hashed_token, + :type, + :last_active_at, + :inserted_at, + :active_organization_id, + :sudo_at + ]) + ) + end + + defp base_config(repo, store, audit_schema \\ AuditTestEvent) do + Config.new!( + repo: repo, + user_schema: TestUser, + scope_module: TestScope, + otp_app: :impersonation_audit_atomicity_test, + secret_key_base: String.duplicate("k", 64), + audit: [audit_schema: audit_schema], + session: [ + store: store, + session_schema: ImpersonationSessionRecord + ] + ) + end + + defp audit_count(repo, action) do + %{rows: [[count]]} = + Ecto.Adapters.SQL.query!(repo, "SELECT count(*)::bigint FROM audit_events WHERE action = $1", [ + action + ]) + + count + end + + defp session_count(repo) do + %{rows: [[count]]} = + Ecto.Adapters.SQL.query!(repo, "SELECT count(*)::bigint FROM impersonation_audit_sessions", []) + + count + end + + defp get_session_row(repo, hashed_token) do + %{rows: rows} = + Ecto.Adapters.SQL.query!( + repo, + "SELECT user_id, hashed_token, type FROM impersonation_audit_sessions WHERE hashed_token = $1", + [hashed_token] + ) + + rows + end + + test "default Ecto store co-fates impersonation start with its audit row", %{repo: repo} do + cfg = base_config(repo, Sigra.SessionStores.Ecto) + admin = %TestUser{id: Ecto.UUID.generate(), email: "admin@example.com"} + target = %TestUser{id: Ecto.UUID.generate(), email: "user@example.com"} + admin_session = session(admin.id, %{id: 11, hashed_token: "admin-hash"}) + + assert {:ok, %{session: result, restore: {:admin_session, "admin-token"}, mode: :impersonating}} = + Impersonation.start( + cfg, + admin_scope(:global, admin), + admin_session, + target, + admin_token: "admin-token" + ) + + assert result.user_id == target.id + assert is_binary(result.token) + assert session_count(repo) == 1 + assert audit_count(repo, "admin.impersonation.start") == 1 + assert audit_count(repo, "session.create") == 0 + assert length(get_session_row(repo, result.hashed_token)) == 1 + end + + test "audit-off parity still persists sessions without audit rows", %{repo: repo} do + cfg = base_config(repo, Sigra.SessionStores.Ecto, nil) + admin = %TestUser{id: Ecto.UUID.generate(), email: "admin@example.com"} + target = %TestUser{id: Ecto.UUID.generate(), email: "user@example.com"} + admin_session = session(admin.id, %{id: 12, hashed_token: "admin-hash"}) + + assert {:ok, %{session: session, mode: :impersonating}} = + Impersonation.start( + cfg, + admin_scope(:global, admin), + admin_session, + target, + admin_token: "admin-token" + ) + + assert session_count(repo) == 1 + assert audit_count(repo, "admin.impersonation.start") == 0 + + assert {:ok, %{restore: {:admin_session, "admin-token"}, session_deleted?: true}} = + Impersonation.stop( + cfg, + %TestScope{user: target, active_organization: nil, membership: nil, impersonating_from: admin}, + session, + admin_token: "admin-token" + ) + + assert session_count(repo) == 0 + assert audit_count(repo, "admin.impersonation.stop") == 0 + end + + test "default Ecto store co-fates impersonation stop with its audit row", %{repo: repo} do + cfg = base_config(repo, Sigra.SessionStores.Ecto) + admin = %TestUser{id: Ecto.UUID.generate(), email: "admin@example.com"} + target = %TestUser{id: Ecto.UUID.generate(), email: "user@example.com"} + + {:ok, _} = + repo.insert(%ImpersonationSessionRecord{ + user_id: target.id, + hashed_token: "impersonation-hash", + type: "standard" + }) + + impersonation_session = + session(target.id, %{ + id: 22, + hashed_token: "impersonation-hash", + impersonator_user_id: admin.id + }) + + assert {:ok, %{restore: {:admin_session, "admin-token"}, session_deleted?: true}} = + Impersonation.stop( + cfg, + %TestScope{user: target, active_organization: nil, membership: nil, impersonating_from: admin}, + impersonation_session, + admin_token: "admin-token" + ) + + assert session_count(repo) == 0 + assert audit_count(repo, "admin.impersonation.stop") == 1 + assert audit_count(repo, "session.delete") == 0 + end + + test "audit insert failure rolls back both start and stop on the Ecto path", %{repo: repo} do + cfg = base_config(repo, Sigra.SessionStores.Ecto) + admin = %TestUser{id: Ecto.UUID.generate(), email: "admin@example.com"} + target = %TestUser{id: Ecto.UUID.generate(), email: "user@example.com"} + admin_session = session(admin.id, %{id: 31, hashed_token: "admin-hash"}) + + Ecto.Adapters.SQL.query!( + repo, + """ + ALTER TABLE audit_events + ADD CONSTRAINT impersonation_audit_guard CHECK (action NOT IN ('admin.impersonation.start', 'admin.impersonation.stop')) + """, + [] + ) + + try do + assert {:error, :impersonation_aborted} = + Impersonation.start( + cfg, + admin_scope(:global, admin), + admin_session, + target, + admin_token: "admin-token" + ) + + assert session_count(repo) == 0 + assert audit_count(repo, "admin.impersonation.start") == 0 + + {:ok, _} = + repo.insert(%ImpersonationSessionRecord{ + user_id: target.id, + hashed_token: "impersonation-hash", + type: "standard" + }) + + impersonation_session = + session(target.id, %{ + id: 32, + hashed_token: "impersonation-hash", + impersonator_user_id: admin.id + }) + + assert {:error, :impersonation_aborted} = + Impersonation.stop( + cfg, + %TestScope{user: target, active_organization: nil, membership: nil, impersonating_from: admin}, + impersonation_session, + admin_token: "admin-token" + ) + + assert session_count(repo) == 1 + assert audit_count(repo, "admin.impersonation.stop") == 0 + after + Ecto.Adapters.SQL.query!( + repo, + "ALTER TABLE audit_events DROP CONSTRAINT IF EXISTS impersonation_audit_guard", + [] + ) + end + end + + test "fallback store keeps the legacy create/delete plus log_safe path", %{repo: repo} do + cfg = base_config(repo, LegacySessionStore) + admin = %TestUser{id: Ecto.UUID.generate(), email: "admin@example.com"} + target = %TestUser{id: Ecto.UUID.generate(), email: "user@example.com"} + target_id = target.id + admin_session = session(admin.id, %{id: 41, hashed_token: "admin-hash"}) + + assert {:ok, %{session: impersonation_session, mode: :impersonating}} = + Impersonation.start( + cfg, + admin_scope(:global, admin), + admin_session, + target, + admin_token: "admin-token" + ) + + assert_received {:legacy_create, ^target_id, metadata} + assert metadata.impersonator_user_id == admin.id + assert metadata.impersonator_session_id == admin_session.id + assert impersonation_session.user_id == target.id + assert impersonation_session.hashed_token == "impersonation-hash" + + assert {:ok, %{session_deleted?: true}} = + Impersonation.stop( + cfg, + %TestScope{user: target, active_organization: nil, membership: nil, impersonating_from: admin}, + impersonation_session, + admin_token: "admin-token" + ) + + assert_received {:legacy_delete, "impersonation-hash"} + + assert audit_count(repo, "session.create") == 1 + assert audit_count(repo, "admin.impersonation.start") == 1 + assert audit_count(repo, "session.delete") == 1 + assert audit_count(repo, "admin.impersonation.stop") == 1 + end +end diff --git a/test/sigra/install/generator_email_test.exs b/test/sigra/install/generator_email_test.exs index 22e91514..d9745d80 100644 --- a/test/sigra/install/generator_email_test.exs +++ b/test/sigra/install/generator_email_test.exs @@ -79,7 +79,7 @@ defmodule Sigra.Install.GeneratorEmailTest do test "includes CTA button color from UI-SPEC" do content = render_template("emails.ex") - assert content =~ "#2563eb" + assert content =~ "#1d4ed8" end test "includes background color from UI-SPEC" do diff --git a/test/sigra/install/oauth_smoketest_task_test.exs b/test/sigra/install/oauth_smoketest_task_test.exs new file mode 100644 index 00000000..7d697954 --- /dev/null +++ b/test/sigra/install/oauth_smoketest_task_test.exs @@ -0,0 +1,160 @@ +defmodule Sigra.Install.OAuthSmoketestTaskTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + + defmodule TaskImplStub do + def run(opts) do + send(self(), {:task_impl_opts, opts}) + + case Process.get(:task_impl_result, :ok) do + fun when is_function(fun, 1) -> fun.(opts) + other -> other + end + end + end + + describe "mix sigra.oauth.smoketest" do + setup do + original = Application.get_env(:sigra, :oauth_smoketest_impl) + Application.put_env(:sigra, :oauth_smoketest_impl, TaskImplStub) + + on_exit(fn -> + if original do + Application.put_env(:sigra, :oauth_smoketest_impl, original) + else + Application.delete_env(:sigra, :oauth_smoketest_impl) + end + + Process.delete(:task_impl_result) + end) + + :ok + end + + test "passes validated options to the runtime" do + capture_io(fn -> + Mix.Tasks.Sigra.Oauth.Smoketest.run([ + "--provider=google", + "--port=4100", + "--config=Example.Accounts.sigra_config/0" + ]) + end) + + assert_received {:task_impl_opts, + [provider: "google", port: 4100, config: "Example.Accounts.sigra_config/0"]} + end + + test "prints success when the runtime returns :ok" do + output = + capture_io(fn -> + Mix.Tasks.Sigra.Oauth.Smoketest.run(["--provider=google"]) + end) + + assert output =~ "OK — round-trip succeeded." + end + + test "exits with the runtime error code" do + Process.put(:task_impl_result, {:error, 2, "missing config"}) + + assert catch_exit( + capture_io(:stderr, fn -> + Mix.Tasks.Sigra.Oauth.Smoketest.run(["--provider=google"]) + end) + ) == {:shutdown, 2} + end + + test "requires --provider" do + assert_raise NimbleOptions.ValidationError, fn -> + Mix.Tasks.Sigra.Oauth.Smoketest.run([]) + end + end + end + + describe "Sigra.OAuth.Smoketest.run/1" do + test "returns success after a simulated callback and exchanges on loopback" do + parent = self() + + result = + capture_io(fn -> + send( + parent, + {:runtime_result, + Sigra.OAuth.Smoketest.run( + provider: "google", + port: 4101, + load_config_fun: fn _opts -> + {:ok, + %{ + secret_key_base: String.duplicate("a", 64), + oauth: [ + providers: [ + google: [client_id: "cid", client_secret: "secret"] + ] + ] + }} + end, + start_server_fun: fn server_opts -> + send(parent, {:server_opts, server_opts}) + {:ok, self()} + end, + stop_server_fun: fn _server -> :ok end, + authorize_url_fun: fn _provider_config -> + {:ok, + %{ + url: "https://accounts.example.test/auth?state=placeholder", + session_params: %{code_verifier: "verifier"} + }} + end, + receive_callback_fun: fn _timeout_ms -> + receive do + {:authorize_state, state} -> {:ok, %{"state" => state, "code" => "auth-code"}} + end + end, + callback_fun: fn _provider_config, _params, _session -> + {:ok, %{"sub" => "google-sub", "email" => "jon@example.test"}, + %{ + "id_token" => + "eyJhbGciOiJub25lIn0." <> + Base.url_encode64( + Jason.encode!(%{ + "sub" => "google-sub", + "email" => "jon@example.test" + }), + padding: false + ) <> ".sig" + }} + end, + print_fun: fn line -> + if String.contains?(line, "https://accounts.example.test/auth") do + [_, state] = Regex.run(~r/state=([^&]+)/, line) + send(parent, {:authorize_state, state}) + end + + send(parent, {:print, line}) + end + )} + ) + end) + + assert is_binary(result) + assert_received {:runtime_result, :ok} + + assert_received {:server_opts, + [ip: {127, 0, 0, 1}, port: 4101, owner: _, callback_path: "/callback"]} + + assert_received {:print, + "OK — got back valid id_token with sub=google-sub and email=jon@example.test"} + end + + test "returns config error when the provider is missing" do + assert {:error, 2, "provider google is not configured under :sigra oauth.providers"} = + Sigra.OAuth.Smoketest.run( + provider: "google", + load_config_fun: fn _opts -> + {:ok, %{secret_key_base: String.duplicate("a", 64), oauth: []}} + end + ) + end + end +end diff --git a/test/sigra/jwt_refresh_audit_cofate_test.exs b/test/sigra/jwt_refresh_audit_cofate_test.exs new file mode 100644 index 00000000..63d02d7f --- /dev/null +++ b/test/sigra/jwt_refresh_audit_cofate_test.exs @@ -0,0 +1,330 @@ +defmodule Sigra.JWTRefreshAuditCofateTest do + @moduledoc """ + Postgres integration tests for JWT refresh **persistence + audit co-fate**. + + For **audit-only** `Sigra.APIToken.audit_jwt_refresh/2` and + `audit_jwt_refresh_reuse/2` (Phase **81**), see + `test/sigra/api_token_audit_atomic_test.exs`. + """ + use ExUnit.Case, async: false + + @moduletag :capture_log + + alias Sigra.JWT + alias Sigra.JWT.RefreshToken + alias Sigra.Test.AuditEvent, as: AuditTestEvent + alias Sigra.Test.PostgresRepo + + defmodule VerifyFailureTelemetryHandler do + @moduledoc false + def handle_event(event, measurements, metadata, parent) do + send(parent, {:telemetry, event, measurements, metadata}) + end + end + + defmodule CofateUser do + @moduledoc false + use Ecto.Schema + + @primary_key {:id, :binary_id, autogenerate: true} + schema "jwt_refresh_cofate_users" do + field(:email, :string) + field(:token_epoch, :integer, default: 0) + timestamps(type: :utc_datetime_usec) + end + end + + defmodule CofateUserToken do + @moduledoc false + use Ecto.Schema + + @primary_key {:id, :binary_id, autogenerate: true} + schema "jwt_refresh_cofate_user_tokens" do + field(:token, :binary) + field(:context, :string) + field(:sent_to, :string) + field(:user_id, :binary_id) + timestamps(type: :utc_datetime_usec) + end + end + + setup do + start_supervised!({PostgresRepo, PostgresRepo.default_config()}) + repo = PostgresRepo + + Ecto.Adapters.SQL.query!(repo, "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"", []) + + for t <- ["jwt_refresh_cofate_user_tokens", "jwt_refresh_cofate_users"] do + Ecto.Adapters.SQL.query!(repo, "DROP TABLE IF EXISTS #{t} CASCADE", []) + end + + Ecto.Adapters.SQL.query!( + repo, + """ + CREATE TABLE jwt_refresh_cofate_users ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + email text, + token_epoch integer NOT NULL DEFAULT 0, + inserted_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now() + ) + """, + [] + ) + + Ecto.Adapters.SQL.query!( + repo, + """ + CREATE TABLE jwt_refresh_cofate_user_tokens ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + token bytea NOT NULL, + context varchar(255) NOT NULL, + sent_to text NOT NULL, + user_id uuid NOT NULL, + inserted_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now() + ) + """, + [] + ) + + Ecto.Adapters.SQL.query!( + repo, + """ + CREATE TABLE IF NOT EXISTS audit_events ( + id uuid PRIMARY KEY, + occurred_at timestamp NOT NULL DEFAULT now(), + action varchar(255) NOT NULL, + outcome varchar(32) NOT NULL DEFAULT 'success', + actor_id uuid, + actor_type varchar(64) NOT NULL DEFAULT 'user', + target_id uuid, + target_type varchar(64), + ip_address varchar(64), + user_agent varchar(512), + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + organization_id uuid, + effective_user_id uuid, + inserted_at timestamp NOT NULL DEFAULT now() + ) + """, + [] + ) + + Ecto.Adapters.SQL.query!( + repo, + "TRUNCATE TABLE jwt_refresh_cofate_user_tokens RESTART IDENTITY CASCADE", + [] + ) + + Ecto.Adapters.SQL.query!( + repo, + "TRUNCATE TABLE jwt_refresh_cofate_users RESTART IDENTITY CASCADE", + [] + ) + + Ecto.Adapters.SQL.query!(repo, "TRUNCATE TABLE audit_events RESTART IDENTITY CASCADE", []) + + %{repo: repo} + end + + defp token_opts do + [user_token_schema: CofateUserToken] + end + + defp sigra_config(repo) do + Sigra.Config.new!( + repo: repo, + user_schema: CofateUser, + otp_app: :jwt_cofate_test, + secret_key_base: String.duplicate("k", 64), + audit: [audit_schema: AuditTestEvent], + jwt: [ + enabled: true, + algorithm: "HS256", + issuer: "jwt_cofate", + access_ttl: 900, + refresh_ttl: 86_400, + refresh: true, + verify_epoch: false + ] + ) + end + + defp sigra_config_no_audit(repo) do + Sigra.Config.new!( + repo: repo, + user_schema: CofateUser, + otp_app: :jwt_cofate_test, + secret_key_base: String.duplicate("k", 64), + audit: [], + jwt: [ + enabled: true, + algorithm: "HS256", + issuer: "jwt_cofate", + access_ttl: 900, + refresh_ttl: 86_400, + refresh: true, + verify_epoch: false + ] + ) + end + + defp count(repo, table) do + %{rows: [[n]]} = Ecto.Adapters.SQL.query!(repo, "SELECT count(*)::bigint FROM #{table}", []) + n + end + + defp count_where(repo, table, where) do + %{rows: [[n]]} = + Ecto.Adapters.SQL.query!(repo, "SELECT count(*)::bigint FROM #{table} WHERE #{where}", []) + + n + end + + defp insert_user!(repo) do + {:ok, u} = repo.insert(%CofateUser{email: "jwt-cofate@example.com"}) + u + end + + test "happy path: audit on persists rotation and one api.jwt_refresh row", %{repo: repo} do + user = insert_user!(repo) + cfg = sigra_config(repo) + opts = token_opts() + {raw_refresh, _} = RefreshToken.create(cfg, user, ["profile:read"], opts) + + assert count(repo, "jwt_refresh_cofate_user_tokens") == 1 + + assert {:ok, new_tokens} = JWT.refresh(cfg, raw_refresh, opts) + assert is_binary(new_tokens.access_token) + assert new_tokens.refresh_token != raw_refresh + + assert count(repo, "jwt_refresh_cofate_user_tokens") == 2 + assert count_where(repo, "audit_events", "action = 'api.jwt_refresh'") == 1 + end + + test "audit off: refresh succeeds with zero api.jwt_refresh rows", %{repo: repo} do + user = insert_user!(repo) + cfg = sigra_config_no_audit(repo) + opts = token_opts() + {raw_refresh, _} = RefreshToken.create(cfg, user, ["profile:read"], opts) + + assert {:ok, _} = JWT.refresh(cfg, raw_refresh, opts) + assert count_where(repo, "audit_events", "action = 'api.jwt_refresh'") == 0 + end + + test "happy path fault injection: audit CHECK rejects api.jwt_refresh → jwt_refresh_aborted, no partial rotation", + %{repo: repo} do + Ecto.Adapters.SQL.query!( + repo, + """ + ALTER TABLE audit_events + ADD CONSTRAINT jwt_refresh_cofate_happy_guard CHECK (action <> 'api.jwt_refresh') + """, + [] + ) + + try do + user = insert_user!(repo) + cfg = sigra_config(repo) + opts = token_opts() + {raw_refresh, _} = RefreshToken.create(cfg, user, ["profile:read"], opts) + + before_tokens = count(repo, "jwt_refresh_cofate_user_tokens") + + ref = + :telemetry.attach( + {__MODULE__, :jwt_cofate_happy_guard}, + [:sigra, :audit, :log_safe_error], + &VerifyFailureTelemetryHandler.handle_event/4, + self() + ) + + try do + assert {:error, :jwt_refresh_aborted} = JWT.refresh(cfg, raw_refresh, opts) + + assert_receive {:telemetry, [:sigra, :audit, :log_safe_error], %{count: 1}, + %{action: "api.jwt_refresh", reason: :constraint_violation}} + after + :telemetry.detach(ref) + end + + assert count(repo, "jwt_refresh_cofate_user_tokens") == before_tokens + assert count_where(repo, "audit_events", "action = 'api.jwt_refresh'") == 0 + after + Ecto.Adapters.SQL.query!( + repo, + "ALTER TABLE audit_events DROP CONSTRAINT IF EXISTS jwt_refresh_cofate_happy_guard", + [] + ) + end + end + + test "reuse + audit on: api.jwt_refresh_reuse row and {:error, :reuse_detected} after commit", + %{ + repo: repo + } do + user = insert_user!(repo) + cfg = sigra_config(repo) + opts = token_opts() + {raw1, _} = RefreshToken.create(cfg, user, ["profile:read"], opts) + + assert {:ok, %{refresh_token: raw2}} = JWT.refresh(cfg, raw1, opts) + assert {:error, :reuse_detected} = JWT.refresh(cfg, raw1, opts) + + assert count_where(repo, "audit_events", "action = 'api.jwt_refresh_reuse'") == 1 + assert raw2 != raw1 + end + + test "reuse audit fault injection: reject api.jwt_refresh_reuse → jwt_refresh_aborted (revoke rolled back)", + %{repo: repo} do + Ecto.Adapters.SQL.query!( + repo, + """ + ALTER TABLE audit_events + ADD CONSTRAINT jwt_refresh_cofate_reuse_guard CHECK (action <> 'api.jwt_refresh_reuse') + """, + [] + ) + + try do + user = insert_user!(repo) + cfg = sigra_config(repo) + opts = token_opts() + {raw1, _} = RefreshToken.create(cfg, user, ["profile:read"], opts) + assert {:ok, %{refresh_token: raw2}} = JWT.refresh(cfg, raw1, opts) + + before_reuse_audits = + count_where(repo, "audit_events", "action = 'api.jwt_refresh_reuse'") + + ref = + :telemetry.attach( + {__MODULE__, :jwt_cofate_reuse_guard}, + [:sigra, :audit, :log_safe_error], + &VerifyFailureTelemetryHandler.handle_event/4, + self() + ) + + try do + assert {:error, :jwt_refresh_aborted} = JWT.refresh(cfg, raw1, opts) + + assert_receive {:telemetry, [:sigra, :audit, :log_safe_error], %{count: 1}, + %{action: "api.jwt_refresh_reuse", reason: :constraint_violation}} + after + :telemetry.detach(ref) + end + + assert before_reuse_audits == + count_where(repo, "audit_events", "action = 'api.jwt_refresh_reuse'") + + # Legitimate rotated token still usable when reuse audit could not commit + assert {:ok, _} = JWT.refresh(cfg, raw2, opts) + after + Ecto.Adapters.SQL.query!( + repo, + "ALTER TABLE audit_events DROP CONSTRAINT IF EXISTS jwt_refresh_cofate_reuse_guard", + [] + ) + end + end +end diff --git a/test/sigra/mfa_audit_atomicity_test.exs b/test/sigra/mfa_audit_atomicity_test.exs index ce994642..790e8088 100644 --- a/test/sigra/mfa_audit_atomicity_test.exs +++ b/test/sigra/mfa_audit_atomicity_test.exs @@ -1,6 +1,13 @@ defmodule Sigra.MFAAuditAtomicityTest do use ExUnit.Case, async: false + defmodule EnrollInvalidCodeTelemetryHandler do + @moduledoc false + def handle_event(event, measurements, metadata, parent) do + send(parent, {:enroll_invalid_code_telemetry, event, measurements, metadata}) + end + end + import Ecto.Query alias Sigra.{Config, MFA} @@ -841,6 +848,99 @@ defmodule Sigra.MFAAuditAtomicityTest do end end + test "confirm_enrollment invalid TOTP writes mfa.enroll.failure when audit enabled", %{ + repo: repo + } do + user = %{id: Ecto.UUID.generate()} + config = cfg(repo) + {:ok, %{raw_secret: raw}} = MFA.enroll(config, account: "u@example.com") + + assert {:error, :invalid_code} = + MFA.confirm_enrollment(config, user, raw, "000000", + mfa_credential_schema: MfaCredential, + backup_code_schema: BackupCode + ) + + assert count(repo, "user_mfa_credentials") == 0 + assert count(repo, "user_mfa_backup_codes") == 0 + assert count_where(repo, "audit_events", "action = 'mfa.enroll.failure'") == 1 + + %{rows: [[meta]]} = + Ecto.Adapters.SQL.query!( + repo, + "SELECT metadata::text FROM audit_events WHERE action = 'mfa.enroll.failure' ORDER BY inserted_at DESC LIMIT 1", + [] + ) + + assert meta =~ "invalid_code" + assert meta =~ "totp" + end + + test "confirm_enrollment invalid TOTP skips audit when audit disabled", %{repo: repo} do + user = %{id: Ecto.UUID.generate()} + config = cfg(repo, false) + {:ok, %{raw_secret: raw}} = MFA.enroll(config, account: "u@example.com") + + assert {:error, :invalid_code} = + MFA.confirm_enrollment(config, user, raw, "000000", + mfa_credential_schema: MfaCredential, + backup_code_schema: BackupCode + ) + + assert count_where(repo, "audit_events", "action = 'mfa.enroll.failure'") == 0 + end + + test "confirm_enrollment invalid TOTP emits log_safe_error when failure audit blocked", %{ + repo: repo + } do + Ecto.Adapters.SQL.query!( + repo, + """ + ALTER TABLE audit_events + ADD CONSTRAINT mfa_enroll_invalid_code_failure_guard CHECK (action <> 'mfa.enroll.failure') + """, + [] + ) + + try do + user = %{id: Ecto.UUID.generate()} + config = cfg(repo) + {:ok, %{raw_secret: raw}} = MFA.enroll(config, account: "u@example.com") + + ref = + :telemetry.attach( + {__MODULE__, :enroll_invalid_code_guard}, + [:sigra, :audit, :log_safe_error], + &EnrollInvalidCodeTelemetryHandler.handle_event/4, + self() + ) + + try do + assert {:error, :invalid_code} = + MFA.confirm_enrollment(config, user, raw, "000000", + mfa_credential_schema: MfaCredential, + backup_code_schema: BackupCode + ) + + assert count_where(repo, "audit_events", "action = 'mfa.enroll.failure'") == 0 + + assert_receive {:enroll_invalid_code_telemetry, [:sigra, :audit, :log_safe_error], + %{count: 1}, + %{action: "mfa.enroll.failure", reason: reason}} + + assert reason in [:constraint_violation, :invalid_changeset] + after + :telemetry.detach(ref) + end + after + Ecto.Adapters.SQL.query!( + repo, + "ALTER TABLE audit_events DROP CONSTRAINT IF EXISTS mfa_enroll_invalid_code_failure_guard", + [] + ) + end + end + test "audit_backup_codes_regenerate inserts mfa.backup_codes_regenerate audit row", %{ repo: repo } do diff --git a/test/sigra/testing/oauth_issuer_test.exs b/test/sigra/testing/oauth_issuer_test.exs new file mode 100644 index 00000000..9e0a54af --- /dev/null +++ b/test/sigra/testing/oauth_issuer_test.exs @@ -0,0 +1,288 @@ +defmodule Sigra.Testing.OAuthIssuerTest do + use ExUnit.Case, async: true + + alias Sigra.Testing.OAuthIssuer + + describe "start_link/1 - provider :google" do + test "returns an issuer handle with request-time state" do + with_issuer([provider: :google], fn issuer -> + assert is_binary(OAuthIssuer.url(issuer)) + assert String.starts_with?(OAuthIssuer.url(issuer), "http://127.0.0.1:") + assert is_pid(issuer.state) + end) + end + end + + describe "/.well-known/openid-configuration" do + test "returns the discovery document" do + with_issuer([], fn issuer -> + response = get!(OAuthIssuer.url(issuer) <> "/.well-known/openid-configuration") + assert response.status == 200 + + assert response.body == %{ + "issuer" => OAuthIssuer.url(issuer), + "authorization_endpoint" => OAuthIssuer.url(issuer) <> "/oauth2/v2/auth", + "token_endpoint" => OAuthIssuer.url(issuer) <> "/token", + "userinfo_endpoint" => OAuthIssuer.url(issuer) <> "/userinfo", + "jwks_uri" => OAuthIssuer.url(issuer) <> "/jwks", + "token_endpoint_auth_methods_supported" => [ + "none", + "client_secret_post", + "client_secret_basic" + ] + } + end) + end + end + + describe "/oauth2/v2/auth -> 302 redirect" do + test "redirects back with code and state" do + with_issuer([], fn issuer -> + response = + get!( + OAuthIssuer.url(issuer) <> + "/oauth2/v2/auth?" <> + URI.encode_query(%{ + "client_id" => "sigra-client", + "redirect_uri" => "http://example.test/callback", + "state" => "state-123", + "code_challenge" => pkce_challenge("verifier-123"), + "code_challenge_method" => "S256", + "nonce" => "nonce-123" + }), + autoredirect: false + ) + + assert response.status == 302 + location = header!(response, "location") + query = URI.parse(location).query |> URI.decode_query() + + assert URI.parse(location).path == "/callback" + assert query["state"] == "state-123" + assert is_binary(query["code"]) + end) + end + end + + describe "/token RS256 sign+verify roundtrip" do + test "returns an RS256 id_token" do + with_issuer([], fn issuer -> + %{code: code} = authorize!(issuer) + response = exchange_code!(issuer, code, "verifier-123") + + assert response.status == 200 + assert response.body["token_type"] == "Bearer" + assert response.body["expires_in"] == 3600 + assert is_binary(response.body["access_token"]) + assert is_binary(response.body["refresh_token"]) + + config = [ + client_id: "sigra-client", + openid_configuration: OAuthIssuer.openid_config(issuer), + session_params: %{nonce: "nonce-123"} + ] + + assert {:ok, jwt} = + Assent.Strategy.OIDC.validate_id_token(config, response.body["id_token"]) + + assert jwt.header["alg"] == "RS256" + assert jwt.header["kid"] == "kid1" + assert jwt.claims["iss"] == OAuthIssuer.url(issuer) + assert jwt.claims["aud"] == "sigra-client" + assert jwt.claims["nonce"] == "nonce-123" + assert jwt.claims["email_verified"] == true + end) + end + end + + describe "/token with bad code_verifier" do + test "returns invalid_grant" do + with_issuer([], fn issuer -> + %{code: code} = authorize!(issuer) + response = exchange_code!(issuer, code, "wrong-verifier") + + assert response.status == 400 + assert response.body["error"] == "invalid_grant" + assert response.body["error_description"] == "invalid code_verifier" + end) + end + end + + describe "/jwks" do + test "exposes the configured key count" do + with_issuer([kid_count: 2], fn issuer -> + response = get!(OAuthIssuer.url(issuer) <> "/jwks") + assert response.status == 200 + assert Enum.map(response.body["keys"], & &1["kid"]) == ["kid1", "kid2"] + end) + end + end + + describe "configurable exp" do + test "respects the requested expiration offset" do + with_issuer([exp: 60], fn issuer -> + %{code: code} = authorize!(issuer) + response = exchange_code!(issuer, code, "verifier-123") + claims = jwt_claims(response.body["id_token"]) + + assert claims["exp"] - claims["iat"] == 60 + end) + end + end + + describe "refresh-token rotation toggle" do + test "keeps refresh tokens stable when disabled" do + with_issuer([refresh_rotation: false], fn issuer -> + %{code: code} = authorize!(issuer) + first = exchange_code!(issuer, code, "verifier-123") + + second = + post_form!(OAuthIssuer.url(issuer) <> "/token", %{ + "grant_type" => "refresh_token", + "refresh_token" => first.body["refresh_token"], + "client_id" => "sigra-client" + }) + + assert second.status == 200 + assert second.body["refresh_token"] == first.body["refresh_token"] + end) + end + end + + describe "email_verified boolean shape" do + test "returns email_verified as a JSON boolean" do + with_issuer([], fn issuer -> + %{code: code} = authorize!(issuer) + token_response = exchange_code!(issuer, code, "verifier-123") + + userinfo = + get!(OAuthIssuer.url(issuer) <> "/userinfo", + headers: [{"authorization", "Bearer " <> token_response.body["access_token"]}] + ) + + assert userinfo.status == 200 + assert userinfo.body["email_verified"] === true + assert is_boolean(userinfo.body["email_verified"]) + end) + end + end + + defp authorize!(issuer) do + response = + get!( + OAuthIssuer.url(issuer) <> + "/oauth2/v2/auth?" <> + URI.encode_query(%{ + "client_id" => "sigra-client", + "redirect_uri" => "http://example.test/callback", + "state" => "state-123", + "code_challenge" => pkce_challenge("verifier-123"), + "code_challenge_method" => "S256", + "nonce" => "nonce-123" + }), + autoredirect: false + ) + + query = + response + |> header!("location") + |> URI.parse() + |> Map.fetch!(:query) + |> URI.decode_query() + + %{code: query["code"], state: query["state"]} + end + + defp exchange_code!(issuer, code, verifier) do + post_form!(OAuthIssuer.url(issuer) <> "/token", %{ + "grant_type" => "authorization_code", + "code" => code, + "redirect_uri" => "http://example.test/callback", + "client_id" => "sigra-client", + "code_verifier" => verifier + }) + end + + defp jwt_claims(token) do + %JOSE.JWT{fields: claims} = JOSE.JWT.peek_payload(token) + claims + end + + defp pkce_challenge(verifier) do + verifier + |> then(&:crypto.hash(:sha256, &1)) + |> Base.url_encode64(padding: false) + end + + defp get!(url, opts \\ []) do + request!(:get, url, opts) + end + + defp post_form!(url, form, opts \\ []) do + request!(:post, url, Keyword.put(opts, :body, URI.encode_query(form))) + end + + defp request!(method, url, opts) do + headers = + opts + |> Keyword.get(:headers, []) + |> Enum.map(fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end) + |> maybe_put_form_header(method, opts) + + request = + case {method, Keyword.get(opts, :body)} do + {:post, body} -> + {String.to_charlist(url), headers, ~c"application/x-www-form-urlencoded", body} + + _other -> + {String.to_charlist(url), headers} + end + + http_opts = + [] + |> maybe_put(:autoredirect, Keyword.get(opts, :autoredirect)) + + assert {:ok, {{_, status, _}, raw_headers, raw_body}} = + :httpc.request(method, request, http_opts, body_format: :binary) + + %{ + status: status, + headers: Enum.map(raw_headers, fn {key, value} -> {to_string(key), to_string(value)} end), + body: decode_body(raw_body) + } + end + + defp maybe_put_form_header(headers, :post, _opts) do + [{~c"content-type", ~c"application/x-www-form-urlencoded"} | headers] + end + + defp maybe_put_form_header(headers, _method, _opts), do: headers + + defp decode_body(""), do: "" + + defp decode_body(body) do + case Jason.decode(body) do + {:ok, json} -> json + {:error, _reason} -> body + end + end + + defp header!(response, name) do + response.headers + |> Enum.find_value(fn {header, value} -> if header == name, do: value end) + |> Kernel.||(flunk("missing header #{name} in #{inspect(response.headers)}")) + end + + defp maybe_put(list, _key, nil), do: list + defp maybe_put(list, key, value), do: Keyword.put(list, key, value) + + defp with_issuer(opts, fun) do + {:ok, issuer} = OAuthIssuer.start_link(opts) + + try do + fun.(issuer) + after + OAuthIssuer.stop(issuer) + end + end +end diff --git a/test/support/sigra/testing/fixtures/README.md b/test/support/sigra/testing/fixtures/README.md new file mode 100644 index 00000000..90abae7e --- /dev/null +++ b/test/support/sigra/testing/fixtures/README.md @@ -0,0 +1,26 @@ +# Sigra.Testing.OAuthIssuer Fixtures + +These PEM files are test fixtures for `Sigra.Testing.OAuthIssuer`. + +- `oauth_issuer_rsa_kid1_private.pem` / `oauth_issuer_rsa_kid1_public.pem` + Primary signing keypair for `kid=1`. +- `oauth_issuer_rsa_kid2_private.pem` / `oauth_issuer_rsa_kid2_public.pem` + Secondary signing keypair used for multi-kid JWKS rotation coverage when + `kid_count: 2`. + +Regenerate them with: + +```bash +mkdir -p test/support/sigra/testing/fixtures +for kid in 1 2; do + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + -out test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid${kid}_private.pem + openssl rsa -in test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid${kid}_private.pem \ + -pubout -out test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid${kid}_public.pem +done +``` + +Threat-model note: these keys are TEST FIXTURES ONLY and must never be reused +for production signing. This follows D-87-02 and mitigation T-87-03. Sigra's +Hex package file list in `mix.exs` excludes `test/`, so these fixtures do not +ship to adopters. diff --git a/test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid1_private.pem b/test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid1_private.pem new file mode 100644 index 00000000..940269d9 --- /dev/null +++ b/test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid1_private.pem @@ -0,0 +1,29 @@ +# TEST FIXTURE — Sigra.Testing.OAuthIssuer; never use for production signing +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDlBe2UlRRePwu/ +nozmBCYF/QZaHqT0ibJfZvINgbN8bKVQWMuRcIZsjUsbBIimhvGsRQ6bu4lA2RGq +ov3SoZZi2MLhUyp0aA5QMxKoH1AbONqfbL+KlaikrIkRgff6Mz85xQjvz3Bcj1Bk +LI2RbcPnaG1igumcve3gOCTtaGNpf7e38RkcBxQ+pQ2KFVqdStob6s2Of31BsAFJ +GyxP4ieVTrxLgvP4FIbfRHx7dETPp062mjZcF9oPB4CprpfoTXKCdPtenxNdf/ON +M6T+CG3Q9Vt/VoJ8QNIcFlP219nhthmNrpKS/yEuH2DNJmE+sX5WX5OFm01MOvPN +8xPRWZivAgMBAAECggEAP117BNuUdZkC8qL2/+MQ9CI0IjYNVL1OVVgBy5vhoaDb +wlW3CQf1oU4chB6mglCeyBeZOZxTFtaYLTqIeMENf07S6I3elrN9llHzLQHw439A ++dAYVMsgjGNSTz5C8n5AVYb++H7P60QZrYWoK58Pj1SUwydOZHgmOx29ldQGgVb3 +aiZbLyFAY4822WNR846fpcjkoHTcM8RgaIqIuYQ75R2JKdTmOHTxp4TNlW/ZwuwU +7DG+HgkJ+LqR55gPuJMM9KWYPXznTIgJJm0s5x1wnHGqEceWqE+CFNsmCad59xQI +e5ZYMxsbbNSLU5OSFLbNcYGzokfEFOl8mWe2ZLexkQKBgQD02ell2nMJ2ar6pEZt +cGhseYrWknScrgMksmlDabFYtnEAXNbaJ4GiXK7/bfN2L+2TsHK7r4seIeTTTpNk +yuVqB5NziHxCO4fl1CNrzq1mCZFKq+XPTWbrlECe3kzCaB16pWUk2zhVFZEUa/Ia +Wo6eCYpUL+MZmg43ZNbRnRvL8QKBgQDvc4SQlPKeac+X2SYb9UHhERcH6hzIuOIF +ijjl6ZYxXKdyAPRfJQjAJiGBV2DItilk9HubEITplpGL2ALj/KmHCJtWBqT5/CJe +4ej6F+ELjNMFbuptw8hhmDrd0qhWCEoxVzPdq2swtCsLZgbtPjZtg5a4+KVl9/7t +036RMJrOnwKBgGkpvukULhyo9Jq6O9V9VhxhB5SpSpSQ2KDGUBe4KYektFwng9Am +77LAhBkJLGwyoaOxQVYDS4khnZp0QTIlQuuLXXVdxaDc2L2Jo70GA8uziEe+FPI4 +mF/OSQLzD5zgAulOaGawET3aCXnv8wgGpQKTrmoCN1Qjqr93/BwDkpDBAoGAEThE +e0VK4VuIo0npdK9Bipb5CgerBEBPeMiE6PvQYkJghFFPQZxfMbpMRIntGuIGvgza +6r7YYBgE5YKmSpD7/AsBaMFXkeaw7hPe9kVLWNJKxqRAVZ5zxZj1+sfQdUdpVn0H +7NQMBFeglNREgUEtFtkUuL6g3mFkQuQnwPc22s8CgYAF+MI1IAStwcsqA14AyyYV +hDbvvHOhRsEVCQjPjbs4gw/zQ1qoraAuTeaAqkZPB4C+2fLZDt15gyPq/t86lyrf ++7D9JKMRw0rT5qY5w0vH2EaXq30sNip1kKpt8doD+zdwKvK0OAiVH0oGTcKqeMAr +DJl7ss6TnxFa05nN9A8U1Q== +-----END PRIVATE KEY----- diff --git a/test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid1_public.pem b/test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid1_public.pem new file mode 100644 index 00000000..5d48fc4e --- /dev/null +++ b/test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid1_public.pem @@ -0,0 +1,10 @@ +# TEST FIXTURE — Sigra.Testing.OAuthIssuer; never use for production signing +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5QXtlJUUXj8Lv56M5gQm +Bf0GWh6k9ImyX2byDYGzfGylUFjLkXCGbI1LGwSIpobxrEUOm7uJQNkRqqL90qGW +YtjC4VMqdGgOUDMSqB9QGzjan2y/ipWopKyJEYH3+jM/OcUI789wXI9QZCyNkW3D +52htYoLpnL3t4Dgk7WhjaX+3t/EZHAcUPqUNihVanUraG+rNjn99QbABSRssT+In +lU68S4Lz+BSG30R8e3REz6dOtpo2XBfaDweAqa6X6E1ygnT7Xp8TXX/zjTOk/ght +0PVbf1aCfEDSHBZT9tfZ4bYZja6Skv8hLh9gzSZhPrF+Vl+ThZtNTDrzzfMT0VmY +rwIDAQAB +-----END PUBLIC KEY----- diff --git a/test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid2_private.pem b/test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid2_private.pem new file mode 100644 index 00000000..cb2c52ed --- /dev/null +++ b/test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid2_private.pem @@ -0,0 +1,29 @@ +# TEST FIXTURE — Sigra.Testing.OAuthIssuer; never use for production signing +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDVrF6CM60ZAS1G +QI71k8Ixj7QnJcfvTDUI9PoYRvp//iHhFAw/HzKwesVYqLzA1vhu4OifYW7YFHkv +8absFEwklHPVCaiG2/qlcuQOeu4RcSMW9UTjv3c27NPCuJA7KqcsOY4qRgOgkON1 +xAXOle5u9dR6bUTRtjTKI4uCBIn7Ba1FIrvu//mjheFv3YtUDiXuJ8/xZS30Jt01 ++fCsOE8XotsI+OMRPKYHapaQEfDP3Gy3Vnr5ZyMjFh5pqxI0yGh6QFVJvQKsUdH+ +dLBgoViM0WW2xOcLhk37RiGaGd5FrMc5iPonxeybGc9CeA5+IIiIG9V1zOVrW7pd +iObdvbfPAgMBAAECggEARHbJhYCXWya0Ygk3hVqF46l++PgzGurZJ3iPVg4QH8jH +BD6POf5+GGwOJb1TVZrL2YM5JjBq+tN8jS8p5AUQ7Lugbcd9d1Cu/CpXBoi/FVmh ++641F6B2y2OQ6piGpl6hWBtNASCT8vPZ3hckITCLSIR+Q4gVf/iY65f+EHfx2js+ +QlmGjWpfwvBXYVfNPFnCG5JI+yRH5uoJK9mlwdiy4PA7+QeK3Z1O84qsAWF1tGYV +jQ9rgTGunUdQ9srjHXKfzZYWBWx27OmXlyRGx/g9S6TLtr9K/xEnyMg2Lpv9tBm4 +HxDan3T3DcwhcnPB3dFFvs27ptLfTNLD2W/kzsjcWQKBgQD4woze/TDbJdQ2JfvB +XJG7wrxILSIqz+hfN0EMQyCkjfVhp2BWy00ShoEt0QhPjoX2WUq3oONXG0W4uwv7 +/AQ5Kuq2Hz9Q+TBfW6a9elHhd9J3mI3YICeC7F8ztVXeqnkO8eh11xRlQqLTQPHw +/MHKrET9V8493vU6zlWpQDya1QKBgQDb5GWZkLhqzZ4JoHkMaxgHKXoUaN/ZhNjH ++MNVIpxCaxz/v3LUgNdFw1VON8WtCJQpc8VQ9jdQCSfp8QpFjjd122L86LO+ujUU +2nhxEvdL1F6y4AQmzBxOE4XEWULhEQkfIEyJVYnQ8iSbQCi3NyMVfLTi9d4aVLkY +v+5SWA5SEwKBgEQrzL8vU7w62bUdI6kR3T4/V6nP9JUW9O9jDQh3PPLblGt2mwgu +Hqj9A1my9zwWKtAgGEHKbYLpjmnZmKctoVqpUDkoxwlBwOfhDgjPBLFtTNhJjlW0 +Oh++9zgMccPbo+Fcmf/xOT2mzUhne+Y23kTUgPOMpJCAEWRUN1VyrSkhAoGBAIZF +Fs0Ik7OTzpauSHwOwONOrl7cEyQtfHnPKudHdQcRhOmdq66a5diRh/t1Dt2zyVTu +fmQLlIbosFineNA0ISV0SyOHrIogBd2v8a+KFztUeGbdZ2uRYw9B2IKmxrHLxzgc +bt/FPZw636N1L+eAYYnzVjjoTTDi3wt/1zSs1EHFAoGAFDZjiEoroZsiePbTdL1Z +Yi/dlMXPCUXBjc3jgCpJ/0nyI4mihj0mbHEiaqY7Oc6n2h4DHMjcvpRGyUbiw7sl +5e9hMS3FL5lkR800631ESV+mFMMYsyqfRXy4DjrUfM+6gY4kJRsjupx/l7vwfVTX +7Qu2/ysDi/Pfv7iCUBzwQUE= +-----END PRIVATE KEY----- diff --git a/test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid2_public.pem b/test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid2_public.pem new file mode 100644 index 00000000..ae36d858 --- /dev/null +++ b/test/support/sigra/testing/fixtures/oauth_issuer_rsa_kid2_public.pem @@ -0,0 +1,10 @@ +# TEST FIXTURE — Sigra.Testing.OAuthIssuer; never use for production signing +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1axegjOtGQEtRkCO9ZPC +MY+0JyXH70w1CPT6GEb6f/4h4RQMPx8ysHrFWKi8wNb4buDon2Fu2BR5L/Gm7BRM +JJRz1Qmohtv6pXLkDnruEXEjFvVE4793NuzTwriQOyqnLDmOKkYDoJDjdcQFzpXu +bvXUem1E0bY0yiOLggSJ+wWtRSK77v/5o4Xhb92LVA4l7ifP8WUt9CbdNfnwrDhP +F6LbCPjjETymB2qWkBHwz9xst1Z6+WcjIxYeaasSNMhoekBVSb0CrFHR/nSwYKFY +jNFltsTnC4ZN+0YhmhneRazHOYj6J8XsmxnPQngOfiCIiBvVdczla1u6XYjm3b23 +zwIDAQAB +-----END PUBLIC KEY----- diff --git a/test/support/sigra/testing/oauth_issuer.ex b/test/support/sigra/testing/oauth_issuer.ex new file mode 100644 index 00000000..552dcb73 --- /dev/null +++ b/test/support/sigra/testing/oauth_issuer.ex @@ -0,0 +1,504 @@ +defmodule Sigra.Testing.OAuthIssuer do + @moduledoc """ + In-process OIDC issuer for testing Sigra's OAuth ceremony end-to-end. + + Mirrors Assent's own OIDC test-server precedent with RS256 ID tokens, + JWKS exposure, real PKCE verification, `email_verified` boolean shape, + configurable expiration, and kid rotation. + + This module lives under test/support and is not exported as adopter + public API in v0.x. It complements `Sigra.Testing.mock_oauth_callback/1` + rather than replacing it. + """ + + @typedoc "Issuer handle returned by start_link/1" + @type t :: %__MODULE__{ + base_url: String.t(), + state: pid(), + server: pid() + } + + @fixture_dir Path.expand("fixtures", __DIR__) + @kid1_private_path Path.join(@fixture_dir, "oauth_issuer_rsa_kid1_private.pem") + @kid1_public_path Path.join(@fixture_dir, "oauth_issuer_rsa_kid1_public.pem") + @kid2_private_path Path.join(@fixture_dir, "oauth_issuer_rsa_kid2_private.pem") + @kid2_public_path Path.join(@fixture_dir, "oauth_issuer_rsa_kid2_public.pem") + + @external_resource @kid1_private_path + @external_resource @kid1_public_path + @external_resource @kid2_private_path + @external_resource @kid2_public_path + + @default_user %{ + sub: "provider_123", + email: "oauth@example.com", + email_verified: true, + name: "OAuth User", + picture: "https://example.com/avatar.jpg" + } + + @discovery_path "/.well-known/openid-configuration" + @authorize_path "/oauth2/v2/auth" + @token_path "/token" + @userinfo_path "/userinfo" + @jwks_path "/jwks" + + defstruct [:base_url, :state, :server] + + defmodule HTTPPlug do + @moduledoc false + + def init(opts), do: opts + + def call(conn, opts) do + Sigra.Testing.OAuthIssuer.dispatch(conn, Keyword.fetch!(opts, :state)) + end + end + + @spec start_link(keyword()) :: {:ok, t()} | {:error, term()} + def start_link(opts \\ []) do + provider = Keyword.get(opts, :provider, :google) + user_claims = normalize_user_claims(Keyword.get(opts, :user, @default_user)) + kid_count = Keyword.get(opts, :kid_count, 1) + exp_offset = normalize_exp(Keyword.get(opts, :exp, 3600)) + refresh_rotation = Keyword.get(opts, :refresh_rotation, true) + pkce_required = Keyword.get(opts, :pkce_required, true) + + with :ok <- validate_provider(provider), + :ok <- validate_kid_count(kid_count), + {:ok, state} <- + Agent.start_link(fn -> + %{ + base_url: nil, + provider: provider, + user_claims: user_claims, + kid_count: kid_count, + exp_offset: exp_offset, + refresh_rotation?: refresh_rotation, + pkce_required?: pkce_required, + codes: %{}, + access_tokens: %{}, + refresh_tokens: %{} + } + end), + {:ok, server, base_url} <- start_http_server(state) do + Agent.update(state, &Map.put(&1, :base_url, base_url)) + {:ok, %__MODULE__{base_url: base_url, state: state, server: server}} + end + end + + @spec set_user(t(), map()) :: :ok + def set_user(%__MODULE__{state: state}, user_claims) do + Agent.update(state, &Map.put(&1, :user_claims, normalize_user_claims(user_claims))) + end + + @spec set_kid_count(t(), 1 | 2) :: :ok + def set_kid_count(%__MODULE__{state: state}, kid_count) when kid_count in [1, 2] do + Agent.update(state, &Map.put(&1, :kid_count, kid_count)) + end + + @spec url(t()) :: String.t() + def url(%__MODULE__{base_url: base_url}), do: base_url + + @spec openid_config(t()) :: map() + def openid_config(%__MODULE__{base_url: base_url}) do + %{ + "issuer" => base_url, + "authorization_endpoint" => base_url <> @authorize_path, + "token_endpoint" => base_url <> @token_path, + "userinfo_endpoint" => base_url <> @userinfo_path, + "jwks_uri" => base_url <> @jwks_path, + "token_endpoint_auth_methods_supported" => [ + "none", + "client_secret_post", + "client_secret_basic" + ] + } + end + + @spec stop(t()) :: :ok + def stop(%__MODULE__{state: state, server: server}) do + if Process.alive?(state) do + if is_pid(server) and Process.alive?(server) do + GenServer.stop(server) + end + + Agent.stop(state) + end + + :ok + end + + def dispatch(conn, state) do + case {conn.method, conn.request_path} do + {"GET", @discovery_path} -> handle_discovery(conn, state) + {"GET", @authorize_path} -> handle_authorize(conn, state) + {"POST", @token_path} -> handle_token(conn, state) + {"GET", @userinfo_path} -> handle_userinfo(conn, state) + {"GET", @jwks_path} -> handle_jwks(conn, state) + _other -> Plug.Conn.send_resp(conn, 404, "") + end + end + + defp start_http_server(state) do + unless Code.ensure_loaded?(Bandit) do + {:error, {:missing_dependency, :bandit}} + else + bandit_opts = [ + scheme: :http, + ip: {127, 0, 0, 1}, + port: 0, + plug: {HTTPPlug, state: state} + ] + + with {:ok, server} <- apply(Bandit, :start_link, [bandit_opts]), + {:ok, {{127, 0, 0, 1}, port}} <- ThousandIsland.listener_info(server) do + {:ok, server, "http://127.0.0.1:#{port}"} + end + end + end + + defp handle_discovery(conn, state) do + conn + |> json(200, state |> agent_issuer() |> openid_config()) + end + + defp handle_authorize(conn, state) do + params = Plug.Conn.fetch_query_params(conn).params + + with {:ok, redirect_uri} <- fetch_required(params, "redirect_uri"), + {:ok, oauth_state} <- fetch_required(params, "state"), + :ok <- validate_pkce_request(params, state) do + code = random_token("code") + + stored_code = %{ + code_challenge: Map.get(params, "code_challenge"), + code_challenge_method: Map.get(params, "code_challenge_method"), + client_id: Map.get(params, "client_id", "sigra-client"), + nonce: Map.get(params, "nonce"), + redirect_uri: redirect_uri + } + + Agent.update(state, &put_in(&1, [:codes, code], stored_code)) + + conn + |> Plug.Conn.put_resp_header( + "location", + redirect_with_code(redirect_uri, code, oauth_state) + ) + |> Plug.Conn.send_resp(302, "") + else + {:error, message} -> json(conn, 400, %{error: message}) + end + end + + defp handle_token(conn, state) do + params = read_form_body(conn) + + case params["grant_type"] || "authorization_code" do + "authorization_code" -> exchange_code(conn, state, params) + "refresh_token" -> exchange_refresh_token(conn, state, params) + other -> json(conn, 400, %{error: "unsupported_grant_type", grant_type: other}) + end + end + + defp exchange_code(conn, state, params) do + with {:ok, code} <- fetch_required(params, "code"), + {:ok, code_data} <- fetch_code(state, code), + :ok <- validate_redirect_uri(code_data, params), + :ok <- validate_code_verifier(state, code_data, params) do + token_payload = issue_tokens(state, code_data) + Agent.update(state, &update_in(&1.codes, fn codes -> Map.delete(codes, code) end)) + json(conn, 200, token_payload) + else + {:error, reason} -> + json(conn, 400, %{error: "invalid_grant", error_description: reason}) + end + end + + defp exchange_refresh_token(conn, state, params) do + with {:ok, refresh_token} <- fetch_required(params, "refresh_token"), + {:ok, refresh_data} <- fetch_refresh_token(state, refresh_token) do + {refresh_token, state_update} = + if refresh_data.refresh_rotation? do + new_token = random_token("refresh") + + {new_token, + fn current_state -> + current_state + |> update_in([:refresh_tokens], fn tokens -> + tokens + |> Map.delete(refresh_token) + |> Map.put(new_token, %{refresh_data | refresh_token: new_token}) + end) + end} + else + {refresh_token, fn current_state -> current_state end} + end + + Agent.update(state, state_update) + token_payload = issue_tokens_from_refresh(state, refresh_data, refresh_token) + json(conn, 200, token_payload) + else + {:error, reason} -> + json(conn, 400, %{error: "invalid_grant", error_description: reason}) + end + end + + defp handle_userinfo(conn, state) do + with {:ok, token} <- bearer_token(conn), + {:ok, user_claims} <- fetch_access_token(state, token) do + json(conn, 200, stringify_claims(user_claims)) + else + {:error, _reason} -> + json(conn, 401, %{error: "invalid_token"}) + end + end + + defp handle_jwks(conn, state) do + keys = + state + |> Agent.get(& &1.kid_count) + |> public_jwks() + + json(conn, 200, %{"keys" => keys}) + end + + defp validate_provider(:google), do: :ok + defp validate_provider(provider), do: {:error, {:unsupported_provider, provider}} + + defp validate_kid_count(kid_count) when kid_count in [1, 2], do: :ok + defp validate_kid_count(kid_count), do: {:error, {:invalid_kid_count, kid_count}} + + defp validate_pkce_request(params, state) do + if Agent.get(state, & &1.pkce_required?) do + with {:ok, _challenge} <- fetch_required(params, "code_challenge"), + {:ok, "S256"} <- fetch_required(params, "code_challenge_method") do + :ok + else + {:error, _reason} -> {:error, "missing_pkce"} + end + else + :ok + end + end + + defp validate_redirect_uri(%{redirect_uri: redirect_uri}, %{"redirect_uri" => redirect_uri}), + do: :ok + + defp validate_redirect_uri(%{redirect_uri: _redirect_uri}, _params), + do: {:error, "redirect_uri mismatch"} + + defp validate_code_verifier(state, code_data, params) do + if Agent.get(state, & &1.pkce_required?) do + with {:ok, verifier} <- fetch_required(params, "code_verifier"), + true <- code_data.code_challenge == pkce_challenge(verifier) do + :ok + else + _any -> {:error, "invalid code_verifier"} + end + else + :ok + end + end + + defp fetch_code(state, code) do + case Agent.get(state, &get_in(&1, [:codes, code])) do + nil -> {:error, "unknown code"} + code_data -> {:ok, code_data} + end + end + + defp fetch_access_token(state, token) do + case Agent.get(state, &get_in(&1, [:access_tokens, token])) do + nil -> {:error, :invalid_token} + claims -> {:ok, claims} + end + end + + defp fetch_refresh_token(state, refresh_token) do + case Agent.get(state, &get_in(&1, [:refresh_tokens, refresh_token])) do + nil -> {:error, "unknown refresh_token"} + data -> {:ok, data} + end + end + + defp issue_tokens(state, code_data) do + base_state = Agent.get(state, & &1) + refresh_token = random_token("refresh") + + claims = build_id_token_claims(base_state, code_data.client_id, code_data.nonce) + id_token = sign_id_token(claims, current_kid(base_state)) + access_token = random_token("access") + + Agent.update(state, fn current_state -> + current_state + |> put_in([:access_tokens, access_token], current_state.user_claims) + |> put_in([:refresh_tokens, refresh_token], %{ + client_id: code_data.client_id, + nonce: code_data.nonce, + refresh_rotation?: current_state.refresh_rotation? + }) + end) + + %{ + "access_token" => access_token, + "refresh_token" => refresh_token, + "id_token" => id_token, + "token_type" => "Bearer", + "expires_in" => base_state.exp_offset + } + end + + defp issue_tokens_from_refresh(state, refresh_data, refresh_token) do + base_state = Agent.get(state, & &1) + claims = build_id_token_claims(base_state, refresh_data.client_id, refresh_data.nonce) + id_token = sign_id_token(claims, current_kid(base_state)) + access_token = random_token("access") + + Agent.update(state, &put_in(&1, [:access_tokens, access_token], &1.user_claims)) + + %{ + "access_token" => access_token, + "refresh_token" => refresh_token, + "id_token" => id_token, + "token_type" => "Bearer", + "expires_in" => base_state.exp_offset + } + end + + defp build_id_token_claims(base_state, client_id, nonce) do + now = DateTime.utc_now() |> DateTime.to_unix() + exp = now + base_state.exp_offset + + base_state.user_claims + |> stringify_claims() + |> Map.merge(%{ + "iss" => base_state.base_url, + "aud" => client_id, + "iat" => now, + "exp" => exp + }) + |> maybe_put("nonce", nonce) + end + + defp sign_id_token(claims, kid) do + private_jwk = + kid + |> private_key_path() + |> File.read!() + |> JOSE.JWK.from_pem() + + {_, token} = + private_jwk + |> JOSE.JWT.sign(%{"alg" => "RS256", "kid" => kid}, claims) + |> JOSE.JWS.compact() + + token + end + + defp public_jwks(kid_count) do + 1..kid_count + |> Enum.map(fn index -> + kid = "kid#{index}" + + public_key_path(kid) + |> File.read!() + |> JOSE.JWK.from_pem() + |> JOSE.JWK.to_public() + |> JOSE.JWK.to_map() + |> elem(1) + |> Map.merge(%{"kid" => kid, "alg" => "RS256", "use" => "sig"}) + end) + end + + defp current_kid(%{kid_count: 1}), do: "kid1" + defp current_kid(%{kid_count: 2}), do: "kid2" + + defp agent_issuer(state) do + base_url = Agent.get(state, & &1.base_url) + %__MODULE__{base_url: base_url, state: state} + end + + defp read_form_body(conn) do + {:ok, body, conn} = Plug.Conn.read_body(conn) + _ = conn + URI.decode_query(body) + end + + defp bearer_token(conn) do + case Plug.Conn.get_req_header(conn, "authorization") do + ["Bearer " <> token] -> {:ok, token} + _other -> {:error, :missing_bearer} + end + end + + defp redirect_with_code(redirect_uri, code, oauth_state) do + uri = URI.parse(redirect_uri) + + query = + URI.decode_query(uri.query || "") |> Map.merge(%{"code" => code, "state" => oauth_state}) + + %{uri | query: URI.encode_query(query)} |> URI.to_string() + end + + defp fetch_required(params, key) do + case Map.fetch(params, key) do + {:ok, value} when value not in [nil, ""] -> {:ok, value} + _other -> {:error, "#{key} missing"} + end + end + + defp pkce_challenge(verifier) do + verifier + |> then(&:crypto.hash(:sha256, &1)) + |> Base.url_encode64(padding: false) + end + + defp json(conn, status, payload) do + body = Jason.encode!(payload) + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(status, body) + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp stringify_claims(user_claims) do + Map.new(user_claims, fn {key, value} -> {to_string(key), value} end) + end + + defp normalize_user_claims(user_claims) when is_map(user_claims) do + user_claims + |> Enum.reduce(%{}, fn + {key, value}, acc when is_atom(key) -> Map.put(acc, key, value) + {key, value}, acc when is_binary(key) -> Map.put(acc, String.to_existing_atom(key), value) + end) + |> then(&Map.merge(@default_user, &1)) + |> Map.update!(:email_verified, &(&1 == true)) + rescue + ArgumentError -> + @default_user + end + + defp normalize_exp(%DateTime{} = exp) do + diff = DateTime.diff(exp, DateTime.utc_now(), :second) + if diff > 0, do: diff, else: 0 + end + + defp normalize_exp(exp) when is_integer(exp) and exp >= 0, do: exp + defp normalize_exp(_exp), do: 3600 + + defp private_key_path("kid1"), do: @kid1_private_path + defp private_key_path("kid2"), do: @kid2_private_path + defp public_key_path("kid1"), do: @kid1_public_path + defp public_key_path("kid2"), do: @kid2_public_path + + defp random_token(prefix) do + encoded = :crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false) + prefix <> "_" <> encoded + end + +end