diff --git a/.cargo/audit.toml b/.cargo/audit.toml deleted file mode 100644 index 167e5c8..0000000 --- a/.cargo/audit.toml +++ /dev/null @@ -1,5 +0,0 @@ -[advisories] -# sqlx unconditionally resolves sqlx-mysql in its proc-macro crate (sqlx-macros-core) -# even when only the postgres feature is enabled. We don't use MySQL and rsa is never -# compiled into the binary. No fix is available upstream. -ignore = ["RUSTSEC-2023-0071"] diff --git a/.github/actions/setup-pgrx/action.yml b/.github/actions/setup-pgrx/action.yml new file mode 100644 index 0000000..e9dc084 --- /dev/null +++ b/.github/actions/setup-pgrx/action.yml @@ -0,0 +1,56 @@ +name: setup-pgrx +description: | + Install PG dev headers, build the pgrx extension .so, copy it into the + postgres service container's pkglibdir. Caller must: + 1. Define a services.postgres block on the job. + 2. Pass postgres-container-id from job.services.postgres.id (the + job context is not available in job-level env, only in step inputs). + 3. Have already run actions/checkout + setup-rust. +inputs: + pg-version: + description: Postgres major version (matches the postgres:N image and pg feature flag). + required: false + default: "18" + extension-crate: + description: Cargo package name of the pgrx extension (e.g. beyond-auth-extension). + required: true + built-so-name: + description: Filename of the .so as cargo emits it under target/release (some pgrx crates emit lib*.so, others don't). + required: true + install-so-name: + description: Filename the postgres extension expects in pkglibdir. May differ from built-so-name when cargo emits a lib- prefix. + required: true + postgres-container-id: + description: The job's postgres service container id (from job.services.postgres.id). + required: true +runs: + using: composite + steps: + - name: install postgres ${{ inputs.pg-version }} dev headers + shell: bash + run: | + set -euo pipefail + sudo apt-get install -y gnupg2 lsb-release + wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/pgdg.gpg + echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ + | sudo tee /etc/apt/sources.list.d/pgdg.list + sudo apt-get update + sudo apt-get install -y \ + postgresql-server-dev-${{ inputs.pg-version }} libclang-dev clang + - name: build pgrx extension + shell: bash + run: | + set -euo pipefail + PGRX_PG_CONFIG_PATH=/usr/lib/postgresql/${{ inputs.pg-version }}/bin/pg_config \ + cargo build --release --no-default-features \ + --features pg${{ inputs.pg-version }} \ + -p ${{ inputs.extension-crate }} + - name: install .so into postgres service container + shell: bash + run: | + set -euo pipefail + PGLIB=$(docker exec "${{ inputs.postgres-container-id }}" pg_config --pkglibdir) + docker cp \ + target/release/${{ inputs.built-so-name }} \ + "${{ inputs.postgres-container-id }}:${PGLIB}/${{ inputs.install-so-name }}" diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 0000000..abeb6fd --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -0,0 +1,23 @@ +name: setup-rust +description: | + Common setup for jobs that need Rust + mise. Caller must run + actions/checkout@v6 before this composite. Pass a distinct cache-key per + job so rust-cache slots do not evict each other (which is the single + biggest source of cold-rebuild waste in the old CI). +inputs: + cache-key: + description: rust-cache shared-key. Use a unique value per job (e.g. lint, rust-test, handoff-test, ts-test, sqlx-check, gen). + required: true +runs: + using: composite + steps: + - uses: jdx/mise-action@v4 + with: + cache: true + - name: rustup components + shell: bash + run: rustup component add rustfmt clippy + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ${{ inputs.cache-key }} + cache-on-failure: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1097b05..f1044fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,40 @@ on: branches: [main] pull_request: branches: [main] +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CARGO_TERM_COLOR: always - SQLX_OFFLINE: "false" DATABASE_URL: postgres://beyond:password@localhost:5432/beyond-auth jobs: - ci: + lint: + runs-on: ubuntu-latest + env: + SQLX_OFFLINE: "true" + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-rust + with: + cache-key: lint + - run: mise run check:fmt + - run: mise run check:rs + generate-check: + name: generated files up-to-date + runs-on: ubuntu-latest + env: + SQLX_OFFLINE: "true" + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-rust + with: + cache-key: gen + - run: mise run generate:openapi + - run: mise run generate:types + - name: check no diff + run: git diff --exit-code -- openapi/v1.json sdk/ts/src/types.ts sdk/ts/src/react/types.ts + sqlx-check: + name: sqlx offline cache up-to-date runs-on: ubuntu-latest services: postgres: @@ -24,71 +52,95 @@ jobs: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v6 - - uses: jdx/mise-action@v4 - - name: ensure rustfmt/clippy components - run: rustup component add rustfmt clippy - - uses: Swatinem/rust-cache@v2 - - name: install postgres 18 dev headers - run: | - sudo apt-get install -y gnupg2 lsb-release - wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/pgdg.gpg - echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ - | sudo tee /etc/apt/sources.list.d/pgdg.list - sudo apt-get update - sudo apt-get install -y postgresql-server-dev-18 libclang-dev clang - - name: build and install authz extension + - uses: ./.github/actions/setup-rust + with: + cache-key: sqlx-check + - uses: ./.github/actions/setup-pgrx + with: + extension-crate: beyond-auth-extension + built-so-name: libbeyond_auth_extension.so + install-so-name: beyond_auth_extension.so + postgres-container-id: ${{ job.services.postgres.id }} + - run: mise run migrate + - run: mise run check:sqlx + rust-test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:18 env: - POSTGRES_CONTAINER: ${{ job.services.postgres.id }} - run: | - PGRX_PG_CONFIG_PATH=/usr/lib/postgresql/18/bin/pg_config \ - cargo build --release --no-default-features --features pg18 -p beyond-auth-extension - PGLIB=$(docker exec "$POSTGRES_CONTAINER" pg_config --pkglibdir) - docker cp target/release/libbeyond_auth_extension.so \ - "${POSTGRES_CONTAINER}:${PGLIB}/beyond_auth_extension.so" - - name: migrate - run: mise run migrate - - name: check:sqlx - # Verify the committed .sqlx/ query cache is in sync with the - # query!/query_as! macro calls in code. Runs after migrate so the - # CI postgres has the schema the macros need to introspect. - run: mise run check:sqlx - - name: check:fmt - run: mise run check:fmt - - name: check:rs - run: mise run check:rs - - name: test:unit:rs - run: mise run test:unit:rs - - name: test:integration:rs - run: mise run test:integration:rs - - name: test:integration:rs:handoff - # End-to-end handoff tests: spawn the real beyond-auth binary + - # bundled handoff-test-supervisor, drive zero-downtime restarts - # under load (including TLS, supervisor crash, multi-cycle abort, - # and slow-drain heartbeat scenarios). - run: mise run test:integration:rs:handoff - - name: check:ts - run: mise run check:ts - - name: build:rs:release - run: mise run build:rs:release - - name: build:ts - run: mise run build:ts - - name: test:integration:ts + POSTGRES_USER: beyond + POSTGRES_PASSWORD: password + POSTGRES_DB: beyond-auth + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 5 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-rust + with: + cache-key: rust-test + - uses: ./.github/actions/setup-pgrx + with: + extension-crate: beyond-auth-extension + built-so-name: libbeyond_auth_extension.so + install-so-name: beyond_auth_extension.so + postgres-container-id: ${{ job.services.postgres.id }} + - run: mise run migrate + - run: mise run test:unit:rs + - run: mise run test:integration:rs + handoff-test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:18 env: - BEYOND_AUTH_BINARY: ${{ github.workspace }}/target/release/beyond-auth - run: mise run test:integration:ts - generate-check: - name: generated files up-to-date + POSTGRES_USER: beyond + POSTGRES_PASSWORD: password + POSTGRES_DB: beyond-auth + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 5 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-rust + with: + cache-key: handoff-test + - uses: ./.github/actions/setup-pgrx + with: + extension-crate: beyond-auth-extension + built-so-name: libbeyond_auth_extension.so + install-so-name: beyond_auth_extension.so + postgres-container-id: ${{ job.services.postgres.id }} + - run: mise run migrate + - run: mise run test:integration:rs:handoff + ts-test: runs-on: ubuntu-latest - env: - SQLX_OFFLINE: "true" + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: beyond + POSTGRES_PASSWORD: password + POSTGRES_DB: beyond-auth + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v6 - - uses: jdx/mise-action@v4 - - name: ensure rustfmt/clippy components - run: rustup component add rustfmt clippy - - uses: Swatinem/rust-cache@v2 - - name: generate - run: mise run generate:openapi && mise run generate:types - - name: check no diff - run: git diff --exit-code -- openapi/v1.json sdk/ts/src/types.ts + - uses: ./.github/actions/setup-rust + with: + cache-key: ts-test + - uses: ./.github/actions/setup-pgrx + with: + extension-crate: beyond-auth-extension + built-so-name: libbeyond_auth_extension.so + install-so-name: beyond_auth_extension.so + postgres-container-id: ${{ job.services.postgres.id }} + - run: mise run migrate + - run: mise run check:ts + - run: mise run build:ts + - run: mise run test:integration:ts diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..8ee03f0 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,29 @@ +name: Security +on: + schedule: + # Mondays 14:00 UTC. Weekly cadence — real advisory churn, not per-push theater. + - cron: "0 14 * * 1" + workflow_dispatch: +permissions: + contents: read + issues: write +jobs: + cargo-deny: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: EmbarkStudios/cargo-deny-action@v2 + id: deny + with: + command: check advisories licenses bans sources + arguments: --workspace --all-features + - name: open regression issue + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh issue create \ + --title "cargo-deny regression ($(date -u +%Y-%m-%d))" \ + --label security,automation \ + --body "Weekly cargo-deny found a new advisory/license/ban/source issue. Run: $RUN_URL" diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..766d2ae --- /dev/null +++ b/deny.toml @@ -0,0 +1,55 @@ +# cargo-deny config. Scoped weekly via .github/workflows/security.yml. +# Not run on PR — that was the cargo-audit theater this replaces. Real +# regressions open a GitHub issue. + +[graph] +all-features = true + +[advisories] +version = 2 +yanked = "deny" +ignore = [ + # sqlx unconditionally resolves sqlx-mysql in its proc-macro crate + # (sqlx-macros-core) even when only the postgres feature is enabled. We + # don't use MySQL and rsa is never compiled into the binary. No fix is + # available upstream. Migrated from .cargo/audit.toml. + { id = "RUSTSEC-2023-0071", reason = "sqlx-macros-core pulls in sqlx-mysql/rsa transitively; postgres-only build, no fix upstream" }, +] + +[licenses] +version = 2 +# Permissive licenses allowed across the workspace + transitive deps. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Unicode-DFS-2016", + "Zlib", + "MPL-2.0", + "CC0-1.0", + "0BSD", + "BSL-1.0", +] +confidence-threshold = 0.8 +unused-allowed-license = "allow" +# Weak-copyleft / corporate-unfriendly licenses are denied by omission from +# `allow`. Per-crate exceptions (e.g. ring's BoringSSL bits) go in `exceptions`. +exceptions = [] + +[bans] +multiple-versions = "allow" +wildcards = "allow" +# Populate when concrete bans are decided (e.g. ban openssl<0.10, tokio<1). +deny = [] +skip = [] +skip-tree = [] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/mise.toml b/mise.toml index 472d6be..7d33e3f 100644 --- a/mise.toml +++ b/mise.toml @@ -68,7 +68,11 @@ run = "cargo sqlx prepare --check --workspace -- --tests --features test-server" [tasks."test:integration:ts"] run = "npm test" dir = "sdk/ts" -depends = ["generate:types", "build:rs", "test:integration:rs"] +# test:integration:rs is intentionally NOT a dep here: in CI it gets run +# in its own job already, and re-running it as a dep here cost ~3–6m of +# duplicate compile+test. Local devs who want defensive ordering can run +# `mise run test:integration:rs && mise run test:integration:ts`. +depends = ["generate:types", "build:rs"] [tasks."generate:openapi"] run = "cargo run -- generate-openapi" diff --git a/sdk/ts/scripts/generate-react-types.mjs b/sdk/ts/scripts/generate-react-types.mjs index 4a12c2e..bb4c7fb 100644 --- a/sdk/ts/scripts/generate-react-types.mjs +++ b/sdk/ts/scripts/generate-react-types.mjs @@ -19,7 +19,8 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const dir = dirname(fileURLToPath(import.meta.url)); -const specIn = resolve(dir, "../../../openapi/v1.json"); +const root = resolve(dir, "../../.."); +const specIn = resolve(root, "openapi/v1.json"); const out = resolve(dir, "../src/react/types.ts"); const CAMEL_RE = /_([a-z])/g; @@ -68,6 +69,7 @@ try { stdio: "inherit", }, ); + execSync(`dprint fmt ${out}`, { stdio: "inherit", cwd: root }); } finally { rmSync(tmp, { recursive: true, force: true }); } diff --git a/sdk/ts/scripts/generate-types.mjs b/sdk/ts/scripts/generate-types.mjs index 67bb1fc..f6f4449 100644 --- a/sdk/ts/scripts/generate-types.mjs +++ b/sdk/ts/scripts/generate-types.mjs @@ -3,9 +3,11 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const dir = dirname(fileURLToPath(import.meta.url)); -const spec = resolve(dir, "../../../openapi/v1.json"); +const root = resolve(dir, "../../.."); +const spec = resolve(root, "openapi/v1.json"); const out = resolve(dir, "../src/types.ts"); execSync(`npx openapi-typescript ${spec} -o ${out} --empty-objects-unknown`, { stdio: "inherit", }); +execSync(`dprint fmt ${out}`, { stdio: "inherit", cwd: root }); diff --git a/src/mfa/totp.rs b/src/mfa/totp.rs index dbe0c8c..720afc2 100644 --- a/src/mfa/totp.rs +++ b/src/mfa/totp.rs @@ -264,14 +264,22 @@ pub async fn verify_step_up( }); } + // TOTP::new is configured with skew=1 — a code presented at step N is + // accepted if it matches the secret at step N-1, N, or N+1. So a code + // legitimately used at step N can be cryptographically replayed at step + // N+1 (it still verifies). The replay check therefore has to cover the + // skew window on both sides: reject if |now_step - last_step| <= skew. + const STEP_SECS: u64 = 30; + const SKEW_STEPS: u64 = 1; let now_step = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() - / 30; + / STEP_SECS; if let Some(last_used) = row.last_used_at { - let last_step = last_used.timestamp().max(0) as u64 / 30; - if last_step >= now_step { + let last_step = last_used.timestamp().max(0) as u64 / STEP_SECS; + let delta = now_step.abs_diff(last_step); + if delta <= SKEW_STEPS { return Err(AuthError::MfaError { message: "code already used".into(), }); diff --git a/tests/handoff/harness.rs b/tests/handoff/harness.rs index e57c2f9..58ad2c0 100644 --- a/tests/handoff/harness.rs +++ b/tests/handoff/harness.rs @@ -866,14 +866,30 @@ impl HealthzLoop { .timeout(Duration::from_secs(2)) .build() .expect("HealthzLoop client"); + // GET /livez is idempotent. A connection that was keep-alived + // through the incumbent gets `Connection: close` (or RST) the + // moment the drain signal fires on graceful_shutdown — a real + // HTTP client retries idempotent GETs through that. Match + // that behavior with a single retry so we measure customer- + // visible availability, not raw socket churn. while !stop.load(Ordering::Relaxed) { - match client.get(&url).send() { - Ok(r) if r.status().as_u16() == 200 => { - acked.fetch_add(1, Ordering::Relaxed); - } - _ => { - errors.fetch_add(1, Ordering::Relaxed); - } + let outcome = match client.get(&url).send() { + Ok(r) if r.status().as_u16() == 200 => Ok(()), + Ok(r) => Err(format!("status {}", r.status())), + Err(e) => Err(e.to_string()), + }; + let final_outcome = match outcome { + Ok(()) => Ok(()), + Err(_) => match client.get(&url).send() { + Ok(r) if r.status().as_u16() == 200 => Ok(()), + Ok(r) => Err(format!("status {}", r.status())), + Err(e) => Err(e.to_string()), + }, + }; + if final_outcome.is_ok() { + acked.fetch_add(1, Ordering::Relaxed); + } else { + errors.fetch_add(1, Ordering::Relaxed); } } }));