From 85505e636e5e5e9239ad6020cff4e5e5593d2169 Mon Sep 17 00:00:00 2001 From: a Date: Mon, 17 Aug 2026 08:31:40 +0000 Subject: [PATCH 1/3] feat(pggomtm): slim to validator-only with minimal database-token v1 (gomtm#310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 executor 产品全部:executor/ 源码/测试/Dockerfile、根 Cargo workspace 成员、 executor-v* release 入口与 executor CI 步骤、docs/executor-runtime.md、 共享 PG18 harness 的 run-executor 矩阵,及 README/MAINTAINERS/AGENTS 中 executor 引用 - database-token contract 收敛为最小 v1:claims 仅 iss/aud/sub/iat/exp/jti/scope/profile 并 deny unknown;删除 delegation_id/auth_method/authority_version/client_id/credential_id/db_role, profile 即数据库角色(startup role 必须与 profile 精确同名) - system_user 身份编码改为 oauth::v1;u=;p=, 彻底移除 oauth:pggomtm:v2;u=...;actor=...;d=...;m=...;a=...;p=... 编码及前缀常量 - validator Cargo.toml 升 0.3.0;离线 JWKS 快照、/etc/pggomtm 双文件契约、fail-closed、 reason-code 脱敏闭集、ES256/P-256、aud!=iss、TTL 30-300s、拒绝网络/SQL/SPI 保持不变 - 更新 Rust 领域测试、PG18 harness fixture 与 final-image smoke;ABI 测试不动 - openspec 新增 slim-validator-only-database-token-v1 change, 并标注 publish-rust-sql-executor / release-host-artifacts / standardize-profile-role-contract-v2 被硬切取代 --- .dockerignore | 12 - .github/workflows/ci.yml | 377 +-------- .github/workflows/release.yml | 65 +- AGENTS.md | 20 +- Cargo.toml | 4 +- Dockerfile | 3 - MAINTAINERS.md | 8 +- README.md | 16 +- docs/authentication-failures.md | 4 +- docs/executor-runtime.md | 65 -- docs/release-and-compatibility.md | 29 +- docs/runtime-configuration.md | 2 +- executor/Cargo.toml | 35 - executor/Dockerfile | 92 -- executor/build.rs | 110 --- executor/src/auth.rs | 123 --- executor/src/issuer.rs | 138 --- executor/src/lib.rs | 8 - executor/src/libpq.rs | 798 ------------------ executor/src/main.rs | 7 - executor/src/protocol.rs | 207 ----- executor/src/service.rs | 340 -------- executor/src/token_registry.rs | 131 --- executor/tests/hmac_envelope.rs | 206 ----- executor/tests/image-readiness.sh | 175 ---- executor/tests/issuer.rs | 194 ----- executor/tests/libpq_abi.rs | 19 - executor/tests/libpq_layout_probe.c | 72 -- executor/tests/postgres_setup.sql | 38 - executor/tests/protocol.rs | 282 ------- executor/tests/stage-integration.sh | 109 --- executor/tests/support/executor_fixture.rs | 102 --- executor/tests/support/pg18_driver.rs | 505 ----------- executor/tests/token_registry.rs | 115 --- .../publish-rust-sql-executor/tasks.md | 2 + .../changes/release-host-artifacts/tasks.md | 2 + .../.openspec.yaml | 2 + .../design.md | 60 ++ .../proposal.md | 32 + .../pggomtm-release-supply-chain/spec.md | 25 + .../specs/pggomtm-validator-module/spec.md | 38 + .../tasks.md | 41 + .../tasks.md | 2 + src/database_auth.rs | 153 +--- src/runtime_config.rs | 12 +- tests/jwt_identity.rs | 368 +++----- tests/oauth_smoke_client.c | 4 +- tests/postgres_integration.sh | 31 - tests/postgres_integration_container.sh | 242 +----- tests/support/oauth_fixture.rs | 45 +- 50 files changed, 436 insertions(+), 5034 deletions(-) delete mode 100644 docs/executor-runtime.md delete mode 100644 executor/Cargo.toml delete mode 100644 executor/Dockerfile delete mode 100644 executor/build.rs delete mode 100644 executor/src/auth.rs delete mode 100644 executor/src/issuer.rs delete mode 100644 executor/src/lib.rs delete mode 100644 executor/src/libpq.rs delete mode 100644 executor/src/main.rs delete mode 100644 executor/src/protocol.rs delete mode 100644 executor/src/service.rs delete mode 100644 executor/src/token_registry.rs delete mode 100644 executor/tests/hmac_envelope.rs delete mode 100755 executor/tests/image-readiness.sh delete mode 100644 executor/tests/issuer.rs delete mode 100644 executor/tests/libpq_abi.rs delete mode 100644 executor/tests/libpq_layout_probe.c delete mode 100644 executor/tests/postgres_setup.sql delete mode 100644 executor/tests/protocol.rs delete mode 100755 executor/tests/stage-integration.sh delete mode 100644 executor/tests/support/executor_fixture.rs delete mode 100644 executor/tests/support/pg18_driver.rs delete mode 100644 executor/tests/token_registry.rs create mode 100644 openspec/changes/slim-validator-only-database-token-v1/.openspec.yaml create mode 100644 openspec/changes/slim-validator-only-database-token-v1/design.md create mode 100644 openspec/changes/slim-validator-only-database-token-v1/proposal.md create mode 100644 openspec/changes/slim-validator-only-database-token-v1/specs/pggomtm-release-supply-chain/spec.md create mode 100644 openspec/changes/slim-validator-only-database-token-v1/specs/pggomtm-validator-module/spec.md create mode 100644 openspec/changes/slim-validator-only-database-token-v1/tasks.md diff --git a/.dockerignore b/.dockerignore index 678294e..48966c7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,15 +12,3 @@ !tests/ !tests/support/ !tests/support/oauth_fixture.rs -!executor/ -!executor/Cargo.toml -!executor/Dockerfile -!executor/build.rs -!executor/src/ -!executor/src/** -!executor/tests/ -!executor/tests/postgres_setup.sql -!executor/tests/stage-integration.sh -!executor/tests/support/ -!executor/tests/support/executor_fixture.rs -!executor/tests/support/pg18_driver.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e65844..ae9d012 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,6 @@ jobs: release_artifact: ${{ steps.release-artifact.outputs.name }} source: ${{ github.sha }} validator_version: ${{ steps.resolve.outputs.validator_version }} - executor_version: ${{ steps.resolve.outputs.executor_version }} steps: - name: Checkout exact source @@ -127,10 +126,8 @@ jobs: docker pull rust:bookworm >/dev/null docker pull postgres:18-bookworm >/dev/null - docker pull debian:bookworm-slim >/dev/null rust_image="$(docker image inspect --format '{{ index .RepoDigests 0 }}' rust:bookworm)" postgres_image="$(docker image inspect --format '{{ index .RepoDigests 0 }}' postgres:18-bookworm)" - executor_runtime_image="$(docker image inspect --format '{{ index .RepoDigests 0 }}' debian:bookworm-slim)" host_rust="$(rustc --version)" image_rust="$(docker run --rm --entrypoint rustc "$rust_image" --version)" test "$host_rust" = "$image_rust" @@ -147,20 +144,11 @@ jobs: | jq --exit-status --raw-output \ '[.packages[] | select(.name == "pggomtm") | .version] | if length == 1 then .[0] else error("expected exactly one pggomtm package") end' )" - executor_version="$( - cargo metadata --locked --no-deps --format-version 1 \ - | jq --exit-status --raw-output \ - '[.packages[] | select(.name == "mtmpg-executor") | .version] | if length == 1 then .[0] else error("expected exactly one executor package") end' - )" resolved_version="$validator_version" if test "$UPLOAD_RELEASE_ARTIFACTS" = true; then test -n "$REQUESTED_RELEASE_VERSION" case "$REQUESTED_RELEASE_PRODUCT" in validator) test "$REQUESTED_RELEASE_VERSION" = "$validator_version" ;; - executor) - test "$REQUESTED_RELEASE_VERSION" = "$executor_version" - resolved_version="$executor_version" - ;; *) exit 1 ;; esac else @@ -181,7 +169,6 @@ jobs: --arg version "$resolved_version" \ --arg release_product "$REQUESTED_RELEASE_PRODUCT" \ --arg validator_version "$validator_version" \ - --arg executor_version "$executor_version" \ --arg rust "$host_rust" \ --arg cargo "$(cargo --version)" \ --arg pgrx "$pgrx_version" \ @@ -189,7 +176,6 @@ jobs: --arg pg_config "$host_postgres" \ --arg rust_image "$rust_image" \ --arg postgres_image "$postgres_image" \ - --arg executor_runtime_image "$executor_runtime_image" \ --arg lock_sha256 "$lock_sha256" \ --arg header_sha256 "$header_sha256" \ --arg client_header_sha256 "$client_header_sha256" \ @@ -200,8 +186,7 @@ jobs: version: $version, release_product: (if $release_product == "" then null else $release_product end), products: { - validator: {version: $validator_version}, - executor: {version: $executor_version, runtime: $executor_runtime_image} + validator: {version: $validator_version} }, rust: {compiler: $rust, cargo: $cargo, builder: $rust_image}, pgrx: $pgrx, @@ -220,12 +205,9 @@ jobs: echo "POSTGRES_IMAGE=$postgres_image" echo "POSTGRES_MINOR=$host_minor" echo "MTMPG_VERSION=$validator_version" - echo "EXECUTOR_RUNTIME_IMAGE=$executor_runtime_image" - echo "EXECUTOR_VERSION=$executor_version" } >>"$GITHUB_ENV" { echo "validator_version=$validator_version" - echo "executor_version=$executor_version" } >>"$GITHUB_OUTPUT" - name: Name shared input artifact @@ -264,13 +246,6 @@ jobs: -o "$RUNNER_TEMP/pggomtm-oauth-layout-probe" "$RUNNER_TEMP/pggomtm-oauth-layout-probe" - client_include_dir="$("$PGRX_PG_CONFIG_PATH" --includedir)" - cc -std=c11 -Wall -Wextra -Werror \ - -I"$client_include_dir" \ - executor/tests/libpq_layout_probe.c \ - -o "$RUNNER_TEMP/mtmpg-executor-libpq-layout-probe" - "$RUNNER_TEMP/mtmpg-executor-libpq-layout-probe" - - name: Run Rust quality and domain tests shell: bash run: | @@ -380,345 +355,10 @@ jobs: retention-days: 1 compression-level: 0 - executor_domain: - name: Executor Rust domain - needs: validator - runs-on: ubuntu-24.04 - timeout-minutes: 60 - env: - CARGO_TARGET_DIR: ${{ github.workspace }}/target - PGRX_PG_CONFIG_PATH: /usr/lib/postgresql/18/bin/pg_config - - steps: - - name: Checkout exact source - uses: actions/checkout@v7 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Download shared resolved inputs - uses: actions/download-artifact@v8 - with: - name: ${{ needs.validator.outputs.inputs_artifact }} - path: ${{ runner.temp }}/resolved-inputs - - - name: Install resolved toolchain inputs - shell: bash - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install --yes --no-install-recommends \ - clang \ - curl \ - gnupg \ - jq \ - libclang-dev \ - libssl-dev \ - pkg-config - curl --fail --location --proto '=https' --tlsv1.2 \ - https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | sudo gpg --dearmor --batch --yes \ - --output /usr/share/keyrings/postgresql.gpg - . /etc/os-release - echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" \ - | sudo tee /etc/apt/sources.list.d/pgdg.list >/dev/null - sudo apt-get update - sudo apt-get install --yes --no-install-recommends \ - postgresql-client-18 \ - postgresql-server-dev-18 - - install -m 0644 \ - "$RUNNER_TEMP/resolved-inputs/Cargo.lock" \ - Cargo.lock - install -m 0644 \ - "$RUNNER_TEMP/resolved-inputs/resolved-inputs.json" \ - resolved-inputs.json - test "$(sha256sum Cargo.lock | cut -d' ' -f1)" = \ - "$(jq --raw-output '.cargo_lock_sha256' resolved-inputs.json)" - test "$("$PGRX_PG_CONFIG_PATH" --version | grep --only-matching --extended-regexp '18\.[0-9]+' | head -n 1)" = \ - "$(jq --raw-output '.postgresql.minor' resolved-inputs.json)" - - rustup set profile minimal - rustup update stable --no-self-update - rustup default stable - rustup component add clippy rustfmt - test "$(rustc --version)" = "$(jq --raw-output '.rust.compiler' resolved-inputs.json)" - test "$(cargo --version)" = "$(jq --raw-output '.rust.cargo' resolved-inputs.json)" - - - name: Run executor domain gates - shell: bash - run: | - set -euo pipefail - cargo fmt --all -- --check - cargo clippy --locked --package mtmpg-executor --all-targets -- -D warnings - cargo test --locked --package mtmpg-executor - - executor_pg18: - name: Executor real PG18 - needs: validator - runs-on: ubuntu-24.04 - timeout-minutes: 90 - env: - CARGO_TARGET_DIR: ${{ github.workspace }}/target - PGRX_PG_CONFIG_PATH: /usr/lib/postgresql/18/bin/pg_config - - steps: - - name: Checkout exact source - uses: actions/checkout@v7 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Download shared resolved inputs - uses: actions/download-artifact@v8 - with: - name: ${{ needs.validator.outputs.inputs_artifact }} - path: ${{ runner.temp }}/resolved-inputs - - - name: Install resolved integration inputs - shell: bash - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install --yes --no-install-recommends \ - clang \ - curl \ - gnupg \ - jq \ - libclang-dev \ - libkrb5-dev \ - libssl-dev \ - pkg-config \ - shellcheck - curl --fail --location --proto '=https' --tlsv1.2 \ - https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | sudo gpg --dearmor --batch --yes \ - --output /usr/share/keyrings/postgresql.gpg - . /etc/os-release - echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" \ - | sudo tee /etc/apt/sources.list.d/pgdg.list >/dev/null - sudo apt-get update - sudo apt-get install --yes --no-install-recommends \ - postgresql-client-18 \ - postgresql-server-dev-18 - - install -m 0644 \ - "$RUNNER_TEMP/resolved-inputs/Cargo.lock" \ - Cargo.lock - install -m 0644 \ - "$RUNNER_TEMP/resolved-inputs/resolved-inputs.json" \ - resolved-inputs.json - test "$(sha256sum Cargo.lock | cut -d' ' -f1)" = \ - "$(jq --raw-output '.cargo_lock_sha256' resolved-inputs.json)" - test "$("$PGRX_PG_CONFIG_PATH" --version | grep --only-matching --extended-regexp '18\.[0-9]+' | head -n 1)" = \ - "$(jq --raw-output '.postgresql.minor' resolved-inputs.json)" - - rustup set profile minimal - rustup update stable --no-self-update - rustup default stable - test "$(rustc --version)" = "$(jq --raw-output '.rust.compiler' resolved-inputs.json)" - test "$(cargo --version)" = "$(jq --raw-output '.rust.cargo' resolved-inputs.json)" - - { - echo "RUST_IMAGE=$(jq --raw-output '.rust.builder' resolved-inputs.json)" - echo "POSTGRES_MINOR=$(jq --raw-output '.postgresql.minor' resolved-inputs.json)" - } >>"$GITHUB_ENV" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Verify executor integration harness - shell: bash - run: | - set -euo pipefail - shellcheck tests/*.sh executor/tests/*.sh - - - name: Stage executor integration artifacts - uses: docker/build-push-action@v7 - with: - context: . - file: ./executor/Dockerfile - target: integration-artifacts - platforms: linux/amd64 - pull: true - push: false - provenance: false - sbom: false - outputs: type=local,dest=${{ runner.temp }}/executor-integration - build-args: | - RUST_IMAGE=${{ env.RUST_IMAGE }} - POSTGRES_MINOR=${{ env.POSTGRES_MINOR }} - cache-from: type=gha,scope=mtmpg-executor-ci - cache-to: type=gha,mode=max,scope=mtmpg-executor-ci - - - name: Run executor real PG18 matrix - shell: bash - run: | - set -euo pipefail - postgres_image="$(jq --raw-output '.postgresql.runtime' resolved-inputs.json)" - test -n "$postgres_image" - PGGOMTM_POSTGRES_IMAGE="$postgres_image" \ - tests/postgres_integration.sh \ - run-executor \ - "$RUNNER_TEMP/executor-integration/artifacts" - - executor_image: - name: Executor final image - needs: - - validator - - executor_domain - - executor_pg18 - runs-on: ubuntu-24.04 - timeout-minutes: 120 - outputs: - release_artifact: ${{ steps.release-artifact.outputs.name }} - - steps: - - name: Checkout exact source - uses: actions/checkout@v7 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Download shared resolved inputs - uses: actions/download-artifact@v8 - with: - name: ${{ needs.validator.outputs.inputs_artifact }} - path: ${{ runner.temp }}/resolved-inputs - - - name: Install image verification inputs - shell: bash - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install --yes --no-install-recommends ca-certificates curl jq skopeo - install -m 0644 \ - "$RUNNER_TEMP/resolved-inputs/Cargo.lock" \ - Cargo.lock - install -m 0644 \ - "$RUNNER_TEMP/resolved-inputs/resolved-inputs.json" \ - resolved-inputs.json - test "$(git rev-parse HEAD)" = "$GITHUB_SHA" - test "$(jq --raw-output '.source' resolved-inputs.json)" = "$GITHUB_SHA" - test "$(sha256sum Cargo.lock | cut -d' ' -f1)" = \ - "$(jq --raw-output '.cargo_lock_sha256' resolved-inputs.json)" - - { - echo "RUST_IMAGE=$(jq --raw-output '.rust.builder' resolved-inputs.json)" - echo "POSTGRES_IMAGE=$(jq --raw-output '.postgresql.runtime' resolved-inputs.json)" - echo "POSTGRES_MINOR=$(jq --raw-output '.postgresql.minor' resolved-inputs.json)" - echo "EXECUTOR_RUNTIME_IMAGE=$(jq --raw-output '.products.executor.runtime' resolved-inputs.json)" - echo "EXECUTOR_VERSION=$(jq --raw-output '.products.executor.version' resolved-inputs.json)" - } >>"$GITHUB_ENV" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Stage final-image integration artifacts - uses: docker/build-push-action@v7 - with: - context: . - file: ./executor/Dockerfile - target: integration-artifacts - platforms: linux/amd64 - pull: true - push: false - provenance: false - sbom: false - outputs: type=local,dest=${{ runner.temp }}/executor-final-integration - build-args: | - RUST_IMAGE=${{ env.RUST_IMAGE }} - POSTGRES_MINOR=${{ env.POSTGRES_MINOR }} - cache-from: type=gha,scope=mtmpg-executor-ci - cache-to: type=gha,mode=max,scope=mtmpg-executor-ci - - - name: Build executor production OCI archive once - uses: docker/build-push-action@v7 - with: - context: . - file: ./executor/Dockerfile - platforms: linux/amd64 - pull: true - push: false - provenance: false - sbom: false - outputs: type=oci,dest=${{ runner.temp }}/mtmpg-executor.oci.tar - build-args: | - RUST_IMAGE=${{ env.RUST_IMAGE }} - RUNTIME_IMAGE=${{ env.EXECUTOR_RUNTIME_IMAGE }} - POSTGRES_MINOR=${{ env.POSTGRES_MINOR }} - SOURCE_REVISION=${{ github.sha }} - VERSION=${{ env.EXECUTOR_VERSION }} - cache-from: type=gha,scope=mtmpg-executor-ci - cache-to: type=gha,mode=max,scope=mtmpg-executor-ci - - - name: Verify the exact executor production image - shell: bash - run: | - set -euo pipefail - image="mtmpg-executor-ci:$GITHUB_SHA" - archive="$RUNNER_TEMP/mtmpg-executor.oci.tar" - integration_root="$RUNNER_TEMP/executor-final-integration/artifacts" - skopeo copy \ - "oci-archive:$archive" \ - "docker-daemon:$image" - PGGOMTM_POSTGRES_IMAGE="$POSTGRES_IMAGE" \ - executor/tests/image-readiness.sh \ - "$image" \ - "$GITHUB_SHA" \ - "$EXECUTOR_VERSION" \ - "$integration_root" - - jq --null-input \ - --arg source "$GITHUB_SHA" \ - --arg version "$EXECUTOR_VERSION" \ - --arg resolved_inputs_sha256 "$(sha256sum resolved-inputs.json | cut -d' ' -f1)" \ - --arg cargo_lock_sha256 "$(sha256sum Cargo.lock | cut -d' ' -f1)" \ - --arg binary_sha256 "$(sha256sum "$integration_root/mtmpg-executor" | cut -d' ' -f1)" \ - --arg oci_archive_sha256 "$(sha256sum "$archive" | cut -d' ' -f1)" \ - --arg oci_manifest_digest "$(skopeo inspect --format '{{.Digest}}' "oci-archive:$archive")" \ - '{ - schema: "mtmpg-executor-verified-image/v1", - product: "executor", - source: $source, - version: $version, - resolved_inputs_sha256: $resolved_inputs_sha256, - cargo_lock_sha256: $cargo_lock_sha256, - binary_sha256: $binary_sha256, - oci_archive_sha256: $oci_archive_sha256, - oci_manifest_digest: $oci_manifest_digest - }' >verified-image.json - - material_root="$RUNNER_TEMP/executor-release-materials" - install -d -m 0755 "$material_root" - install -m 0644 \ - Cargo.lock \ - resolved-inputs.json \ - verified-image.json \ - "$material_root" - mv "$archive" "$material_root/mtmpg-executor.oci.tar" - chmod 0644 "$material_root/mtmpg-executor.oci.tar" - - - name: Name executor release artifact - id: release-artifact - shell: bash - run: echo "name=mtmpg-executor-release-${GITHUB_SHA}-${GITHUB_RUN_ATTEMPT}" >>"$GITHUB_OUTPUT" - - - name: Upload verified executor release materials - if: ${{ inputs.upload_release_artifacts == true && inputs.release_product == 'executor' }} - uses: actions/upload-artifact@v7 - with: - name: ${{ steps.release-artifact.outputs.name }} - path: ${{ runner.temp }}/executor-release-materials/ - if-no-files-found: error - retention-days: 1 - compression-level: 0 - release_selection: name: Select release product needs: - validator - - executor_image runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: @@ -731,8 +371,6 @@ jobs: - name: Select verified product materials id: select env: - EXECUTOR_ARTIFACT: ${{ needs.executor_image.outputs.release_artifact }} - EXECUTOR_VERSION: ${{ needs.validator.outputs.executor_version }} RELEASE_PRODUCT: ${{ inputs.release_product || '' }} VALIDATOR_ARTIFACT: ${{ needs.validator.outputs.release_artifact }} VALIDATOR_VERSION: ${{ needs.validator.outputs.validator_version }} @@ -740,10 +378,6 @@ jobs: run: | set -euo pipefail case "$RELEASE_PRODUCT" in - executor) - release_artifact="$EXECUTOR_ARTIFACT" - version="$EXECUTOR_VERSION" - ;; validator) release_artifact="$VALIDATOR_ARTIFACT" version="$VALIDATOR_VERSION" @@ -766,9 +400,6 @@ jobs: if: ${{ always() }} needs: - validator - - executor_domain - - executor_pg18 - - executor_image - release_selection runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -776,16 +407,10 @@ jobs: steps: - name: Require every product gate env: - EXECUTOR_DOMAIN_RESULT: ${{ needs.executor_domain.result }} - EXECUTOR_IMAGE_RESULT: ${{ needs.executor_image.result }} - EXECUTOR_PG18_RESULT: ${{ needs.executor_pg18.result }} RELEASE_SELECTION_RESULT: ${{ needs.release_selection.result }} VALIDATOR_RESULT: ${{ needs.validator.result }} shell: bash run: | set -euo pipefail test "$VALIDATOR_RESULT" = success - test "$EXECUTOR_DOMAIN_RESULT" = success - test "$EXECUTOR_PG18_RESULT" = success - test "$EXECUTOR_IMAGE_RESULT" = success test "$RELEASE_SELECTION_RESULT" = success diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dca91f8..acc5e59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,6 @@ on: push: tags: - "v[0-9]*.[0-9]*.[0-9]*" - - "executor-v[0-9]*.[0-9]*.[0-9]*" permissions: contents: read @@ -33,11 +32,6 @@ jobs: test "$GITHUB_REF_TYPE" = "tag" tag="$GITHUB_REF_NAME" case "$tag" in - executor-v*) - product=executor - version="${tag#executor-v}" - expected_tag="executor-v$version" - ;; v*) product=validator version="${tag#v}" @@ -88,12 +82,12 @@ jobs: artifact-metadata: write env: GH_TOKEN: ${{ github.token }} - ARCHIVE_NAME: ${{ needs.metadata.outputs.product == 'executor' && 'mtmpg-executor.oci.tar' || 'mtmpg.oci.tar' }} - ARTIFACT_HASH_FIELD: ${{ needs.metadata.outputs.product == 'executor' && 'binary_sha256' || 'module_sha256' }} - ARTIFACT_PATH: ${{ needs.metadata.outputs.product == 'executor' && '/usr/local/bin/mtmpg-executor' || '/usr/lib/postgresql/18/lib/pggomtm.so' }} - ARTIFACT_NAME: ${{ needs.metadata.outputs.product == 'executor' && 'mtmpg-executor' || 'pggomtm.so' }} - EXPECTED_VERIFIED_SCHEMA: ${{ needs.metadata.outputs.product == 'executor' && 'mtmpg-executor-verified-image/v1' || 'mtmpg-verified-image/v2' }} - IMAGE_REPOSITORY: ${{ needs.metadata.outputs.product == 'executor' && 'ghcr.io/codeh007/mtmpg-executor' || 'ghcr.io/codeh007/mtmpg' }} + ARCHIVE_NAME: mtmpg.oci.tar + ARTIFACT_HASH_FIELD: module_sha256 + ARTIFACT_PATH: /usr/lib/postgresql/18/lib/pggomtm.so + ARTIFACT_NAME: pggomtm.so + EXPECTED_VERIFIED_SCHEMA: mtmpg-verified-image/v2 + IMAGE_REPOSITORY: ghcr.io/codeh007/mtmpg PRERELEASE: ${{ needs.metadata.outputs.prerelease }} PRODUCT: ${{ needs.metadata.outputs.product }} TAG: ${{ github.ref_name }} @@ -194,22 +188,6 @@ jobs: fi latest_release_before="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" - validator_release_before="" - validator_source_before="" - validator_version_digest_before="" - validator_latest_digest_before="" - if test "$PRODUCT" = executor; then - validator_source_before="$(gh api "repos/$GITHUB_REPOSITORY/commits/v0.2.0" --jq .sha)" - validator_release_before="$( - gh api "repos/$GITHUB_REPOSITORY/releases/tags/v0.2.0" \ - --jq '{id, tag_name, target_commitish, published_at, updated_at, assets: [.assets[] | {id, name, size, digest}]}' \ - | sha256sum \ - | cut -d' ' -f1 - )" - validator_version_digest_before="$(skopeo inspect --authfile "$auth_file" --format '{{.Digest}}' "docker://ghcr.io/codeh007/mtmpg:0.2.0")" - validator_latest_digest_before="$(skopeo inspect --authfile "$auth_file" --format '{{.Digest}}' "docker://ghcr.io/codeh007/mtmpg:latest")" - fi - install -d -m 0755 "$asset_root" { echo "ARCHIVE=$archive" @@ -219,10 +197,6 @@ jobs: echo "LATEST_RELEASE_BEFORE=$latest_release_before" echo "MATERIAL_ROOT=$material_root" echo "TARGET_REFERENCE=$target_reference" - echo "VALIDATOR_LATEST_DIGEST_BEFORE=$validator_latest_digest_before" - echo "VALIDATOR_RELEASE_BEFORE=$validator_release_before" - echo "VALIDATOR_SOURCE_BEFORE=$validator_source_before" - echo "VALIDATOR_VERSION_DIGEST_BEFORE=$validator_version_digest_before" } >>"$GITHUB_ENV" - name: Push the verified OCI archive once @@ -459,13 +433,8 @@ jobs: shell: bash run: | set -euo pipefail - if test "$PRODUCT" = executor; then - release_title="mtmpg executor $VERSION" - release_description="PostgreSQL 18 OAuth SQL executor image" - else - release_title="mtmpg $VERSION" - release_description="PostgreSQL 18 OAuth validator image" - fi + release_title="mtmpg $VERSION" + release_description="PostgreSQL 18 OAuth validator image" release_notes="$release_description: $IMAGE_REPOSITORY:$VERSION Source: $GITHUB_SHA @@ -523,7 +492,7 @@ jobs: [[ "$release_id" =~ ^[0-9]+$ ]] make_latest=false - if test "$PRODUCT" = validator && test "$PRERELEASE" = false; then + if test "$PRERELEASE" = false; then make_latest=true fi gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$release_id" \ @@ -591,7 +560,7 @@ jobs: "$verify_root/release-manifest.json" >/dev/null auth_file="$HOME/.docker/config.json" - if test "$PRERELEASE" = true || test "$PRODUCT" = executor; then + if test "$PRERELEASE" = true; then if test "$LATEST_BEFORE" = absent; then ! skopeo inspect --authfile "$auth_file" "docker://$IMAGE_REPOSITORY:latest" >/dev/null 2>&1 else @@ -607,21 +576,9 @@ jobs: fi latest_release_after="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" - if test "$PRODUCT" = validator && test "$PRERELEASE" = false; then + if test "$PRERELEASE" = false; then test "$latest_release_after" = "$TAG" else test "$latest_release_after" = "$LATEST_RELEASE_BEFORE" fi - if test "$PRODUCT" = executor; then - test "$(gh api "repos/$GITHUB_REPOSITORY/commits/v0.2.0" --jq .sha)" = "$VALIDATOR_SOURCE_BEFORE" - validator_release_after="$( - gh api "repos/$GITHUB_REPOSITORY/releases/tags/v0.2.0" \ - --jq '{id, tag_name, target_commitish, published_at, updated_at, assets: [.assets[] | {id, name, size, digest}]}' \ - | sha256sum \ - | cut -d' ' -f1 - )" - test "$validator_release_after" = "$VALIDATOR_RELEASE_BEFORE" - test "$(skopeo inspect --authfile "$auth_file" --format '{{.Digest}}' "docker://ghcr.io/codeh007/mtmpg:0.2.0")" = "$VALIDATOR_VERSION_DIGEST_BEFORE" - test "$(skopeo inspect --authfile "$auth_file" --format '{{.Digest}}' "docker://ghcr.io/codeh007/mtmpg:latest")" = "$VALIDATOR_LATEST_DIGEST_BEFORE" - fi diff --git a/AGENTS.md b/AGENTS.md index 68931b9..0ed1fd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,20 +4,20 @@ ## 权威与边界 -- `src/` 是唯一validator production实现;`executor/` 是唯一Rust/libpq companion executor实现。消费仓库不得保留源码副本或第二条构建链。 -- 根`Cargo.toml`只组织`pggomtm`与`executor/`两个产品package,声明兼容依赖范围;`rust-toolchain.toml` 使用 stable;release用共享`Cargo.lock`只由远端CI解析并保存为发布证据。 -- `tests/` 承载validator Rust领域、官方C layout、真实PG18和final-image行为测试;executor测试归属`executor/tests/`,跨产品PG18 harness只保留一个真实行为权威。 -- 根`Dockerfile`只构建production validator image;`executor/Dockerfile`只构建最小非root executor image。 -- `.github/workflows/` 是PR/main只读CI、validator `v*` release与executor `executor-v*` release入口。 +- `src/` 是唯一validator production实现。消费仓库不得保留源码副本或第二条构建链。 +- 根`Cargo.toml`只组织`pggomtm`一个产品package,声明兼容依赖范围;`rust-toolchain.toml` 使用 stable;release用共享`Cargo.lock`只由远端CI解析并保存为发布证据。 +- `tests/` 承载validator Rust领域、官方C layout、真实PG18和final-image行为测试,跨产品PG18 harness只保留一个真实行为权威。 +- 根`Dockerfile`只构建production validator image。 +- `.github/workflows/` 是PR/main只读CI与validator `v*` release入口。 - `openspec/` 是需求、设计和task状态权威;Git、Actions、Release和attestation保存历史证据。 -除上述两个产品package和两个image定义外,不要引入第三crate、第二executor、第二validator、额外Dockerfile或本地image fallback。Executor只提供规格定义的私网HTTPS/HMAC SQL入口,不得扩成通用HTTP API。 +除上述唯一product package和唯一image定义外,不要引入第二crate、第二validator、额外Dockerfile或本地image fallback。 ## 修改前 1. 使用`gh`读取关联Issue,并读取active OpenSpec proposal、design、spec和tasks。 -2. 阅读根与目标product的`Cargo.toml`、`rust-toolchain.toml`、Dockerfile及相关源码和测试。 -3. 修改OAuth边界时同时追踪validator的`_PG_oauth_validator_module_init`/startup/validate/shutdown,以及executor的issuer/`PGconn*` auth hook/cleanup调用链。 +2. 阅读根`Cargo.toml`、`rust-toolchain.toml`、Dockerfile及相关源码和测试。 +3. 修改OAuth边界时同时追踪validator的`_PG_oauth_validator_module_init`/startup/validate/shutdown调用链。 4. 区分当前行为与计划目标,只有实现和远端验证完成后才能勾选task。 ## PostgreSQL与认证 @@ -27,7 +27,7 @@ - Module由`oauth_validator_libraries`加载,不得增加control、versioned SQL、`CREATE EXTENSION`或`cargo pgrx install/package`交付路径。 - 认证必须fail closed。不得增加备用issuer、旧verifier、network fetch、SQL/SPI、宽松claims或其他fallback。 - Runtime只读取固定只读config/public JWKS;不得读取private key、API key、连接串或生产数据。 -- Executor private key只在运行进程内签发30秒database JWT并交给当前`PGconn*`;不得进入validator、HTTP响应、connection string、文件或日志。 +- Validator只校验真实issuer签发的最小database-token v1,`profile`即数据库角色;token签发与SQL relay职责属于gomtmui/gomtm,不得进入validator。 ## Latest-compatible输入 @@ -39,7 +39,7 @@ ## 验证与本地限制 - 本地只允许源码/规划编辑、Git/OpenSpec操作、只读调查和精确清理已知对象。 -- 本地不得运行Cargo、原生编译、Docker build/run、临时PostgreSQL、validator image或executor image检查。 +- 本地不得运行Cargo、原生编译、Docker build/run、临时PostgreSQL或validator image检查。 - 实现提交到`main`后只使用精确SHA的GitHub Actions结果完成任务;失败历史保留并向前修复。 - 测试验证领域和真实系统行为,不测试Dockerfile/workflow字面量、精确版本/hash、layer/config相等或配置文件不存在。 - 不通过删除必要行为测试、弱化断言、降低lint或扩大权限获得通过。 diff --git a/Cargo.toml b/Cargo.toml index 9fdba9d..bdcc819 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,11 @@ [package] name = "pggomtm" -version = "0.2.1" +version = "0.3.0" edition = "2024" license = "MIT" publish = false [workspace] -members = ["executor"] resolver = "3" [lib] @@ -35,7 +34,6 @@ url = "2" [dev-dependencies] base64ct = "1" -sha2 = "0.11" [build-dependencies] bindgen = { version = "0.72", default-features = false, features = ["runtime"] } diff --git a/Dockerfile b/Dockerfile index b017595..efcc705 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,10 +32,7 @@ RUN apt-get update \ WORKDIR /src COPY Cargo.toml Cargo.lock build.rs rust-toolchain.toml LICENSE ./ -COPY executor/Cargo.toml ./executor/Cargo.toml COPY src ./src -COPY executor/src ./executor/src -COPY executor/tests/support ./executor/tests/support RUN cargo build --locked --release --lib --no-default-features --features pg18 diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 497c43b..d35c0eb 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -4,7 +4,7 @@ ## 源码与构建权威 -- 本仓库维护根validator与唯一`executor/`两个产品package、各自测试/image及共享CI;不得增加第三crate或第二实现。 +- 本仓库维护唯一根validator package、测试/image及共享CI;不得增加第二crate或第二实现。 - 消费者只使用mtmpg发布的版本化image,不复制源码或现场编译。 - 所有Cargo、PostgreSQL和Docker重计算只在GitHub Actions执行。 - 维护者和Agent可以直接非force推进`main`;失败commit保留并由后续commit修复。 @@ -19,14 +19,14 @@ Rust stable、PG18 minor、兼容Cargo依赖、Actions major内版本和标准 - PostgreSQL major、pgrx不兼容major或OAuth ABI变化 - Database token、profile-role、identity或reason-code contract变化 -- validator/executor SemVer、release manifest schema和release权限变化 +- validator SemVer、release manifest schema和release权限变化 - 新平台、架构、libc或runtime发行版 ## SemVer release -只有合法validator `v`或executor `executor-v` annotated tag可以进入对应release workflow。Tag version必须与目标Cargo package version一致并指向`main` ancestry;publish job只能checkout tag作identity验证并推送同一run只读CI已验证的OCI archive,不得运行Cargo、重新解析依赖或执行Docker build。 +只有合法validator `v` annotated tag可以进入release workflow。Tag version必须与目标Cargo package version一致并指向`main` ancestry;publish job只能checkout tag作identity验证并推送同一run只读CI已验证的OCI archive,不得运行Cargo、重新解析依赖或执行Docker build。 -Prerelease与stable分别执行完整门禁并保存自己的Cargo.lock、resolved inputs、目标artifact、OCI digest、SBOM、provenance和attestation。Prerelease不得更新`latest`;validator stable成功后更新validator `latest`,executor始终只发布明确SemVer。任何既有version、tag、asset或Release都不得覆盖。 +Prerelease与stable分别执行完整门禁并保存自己的Cargo.lock、resolved inputs、目标artifact、OCI digest、SBOM、provenance和attestation。Prerelease不得更新`latest`;validator stable成功后更新validator `latest`。任何既有version、tag、asset或Release都不得覆盖。 ## 禁止操作 diff --git a/README.md b/README.md index fb8c20b..3069b3c 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,18 @@ # pggomtm -`mtmpg`维护两个隔离Rust产品:PostgreSQL 18 OAuth validator `pggomtm`,以及把受信delegation转换为短期database JWT与libpq OAuth连接的私网executor。本仓库是两者源码、测试、image和release的唯一权威;公开`main`是唯一CI/CD源码线,但不代表stable。 +`mtmpg`维护唯一Rust产品:PostgreSQL 18 OAuth validator `pggomtm`。本仓库是该validator源码、测试、image和release的唯一权威;公开`main`是唯一CI/CD源码线,但不代表stable。 ## 产品边界 - PostgreSQL 通过 `oauth_validator_libraries` 加载 `pggomtm.so`。 - Module 导出 `PG_MODULE_MAGIC` 与 `_PG_oauth_validator_module_init`,不是 SQL extension,不需要 control、versioned SQL 或 `CREATE EXTENSION`。 - 每个新 OAuth backend 从 `/etc/pggomtm/validator.json` 和 `/etc/pggomtm/jwks.json` 建立只读离线 snapshot,不执行 HTTP、DNS、SQL、SPI 或在线 introspection。 -- Validator 只接受 ES256 database JWT,校验唯一 issuer/audience、`database` scope、30 至 300 秒 TTL、closed profile-role 和版本化 identity。V0.2.x只允许`ordinary`、`business_admin`和`database_developer`,且`db_profile`、`db_role`与startup role必须精确同名。 +- Validator 只接受 ES256 database JWT,校验唯一 issuer/audience、`database` scope、30 至 300 秒 TTL 和最小 claims(`iss`/`aud`/`sub`/`iat`/`exp`/`jti`/`scope`/`profile`)。`profile`只允许`ordinary`、`business_admin`和`database_developer`,且与 startup role 必须精确同名。 - 认证失败保持 fail closed,服务端只记录稳定 reason-code,不记录 token、JWKS、配置或身份原文。 -- `executor/`只接受versioned HTTPS/HMAC单statement请求,以per-`PGconn` auth hook隔离一次性JWT,并使用extended protocol、service-owned transaction、预算和取消;它不提供公开token endpoint或认证fallback。 -- Validator与executor共享唯一database-token contract,但保持独立package、image和版本:validator使用`v`,executor使用`executor-v`。 详细运行契约见: - [Runtime 配置](docs/runtime-configuration.md) -- [SQL executor 运行契约](docs/executor-runtime.md) - [认证失败可见性](docs/authentication-failures.md) - [发布与兼容](docs/release-and-compatibility.md) @@ -27,7 +24,7 @@ ## GitHub Actions -`.github/workflows/ci.yml`是PR、`main` push与两个release入口复用的验证权威,负责一次依赖解析、Rustfmt、Clippy、Cargo tests、C/Rust ABI、真实PG18 OAuth、executor并发隔离与各自final image。PR与`main`只读运行且不上传release材料;只有明确product的tag workflow调用时才短暂传递同一run已验证的OCI archive。两个Dockerfile只构建各自production image,不承载测试或扫描器。 +`.github/workflows/ci.yml`是PR、`main` push与release入口复用的验证权威,负责一次依赖解析、Rustfmt、Clippy、Cargo tests、C/Rust ABI、真实PG18 OAuth与final image。PR与`main`只读运行且不上传release材料;只有明确validator tag workflow调用时才短暂传递同一run已验证的OCI archive。根Dockerfile只构建production image,不承载测试或扫描器。 维护者和 Agent 可以把范围明确的 commit 直接非 force 推送到 `main`。失败 commit 保留并通过后续 commit 修复;没有显式 SemVer tag 的 run 不得发布 image、GitHub Release 或 attestation。 @@ -45,12 +42,11 @@ gh run view --repo codeh007/mtmpg --log-failed mtmpg 使用 SemVer 作为用户可见身份: - Validator prerelease/stable:`ghcr.io/codeh007/mtmpg:`,由`v` tag发布。 -- Executor prerelease/stable:`ghcr.io/codeh007/mtmpg-executor:`,由`executor-v` tag发布。 -- Validator stable成功后更新自己的`latest`;executor不发布可消费的`latest`,消费者始终使用明确SemVer。 +- Validator stable成功后更新自己的`latest`;消费者始终使用明确SemVer。 -`.github/workflows/release.yml`严格分派validator `v`与executor `executor-v` annotated tag。它调用同一 `ci.yml`,下载该run已验证的目标OCI archive后推送一次,不在publish job中运行Cargo或Docker build。Prerelease与stable分别从自己的不可变tag完整解析、测试、构建和发布;executor release不修改validator tag、image、Release或`latest`。 +`.github/workflows/release.yml`严格分派validator `v` annotated tag。它调用同一 `ci.yml`,下载该run已验证的目标OCI archive后推送一次,不在publish job中运行Cargo或Docker build。Prerelease与stable分别从自己的不可变tag完整解析、测试、构建和发布。 -每个release的长期权威是版本化公开GHCR image、GitHub Release、Cargo.lock、`resolved-inputs.json`、release manifest、checksums、SPDX SBOM、provenance与GitHub attestation。Actions artifact只用于同一次run内传递。当前stable见 [v0.2.1](https://github.com/codeh007/mtmpg/releases/tag/v0.2.1);[v0.1.0](https://github.com/codeh007/mtmpg/releases/tag/v0.1.0)与首个prerelease [v0.1.0-rc.1](https://github.com/codeh007/mtmpg/releases/tag/v0.1.0-rc.1)作为不可变历史保留。发布进度由 [mtmpg #1](https://github.com/codeh007/mtmpg/issues/1) 和 active OpenSpec change 跟踪。 +每个release的长期权威是版本化公开GHCR image、GitHub Release、`pggomtm.so`宿主产物、Cargo.lock、`resolved-inputs.json`、release manifest、checksums、SPDX SBOM、provenance与GitHub attestation。Actions artifact只用于同一次run内传递。当前stable见 [v0.2.1](https://github.com/codeh007/mtmpg/releases/tag/v0.2.1);[v0.1.0](https://github.com/codeh007/mtmpg/releases/tag/v0.1.0)与首个prerelease [v0.1.0-rc.1](https://github.com/codeh007/mtmpg/releases/tag/v0.1.0-rc.1)作为不可变历史保留。发布进度由 [mtmpg #1](https://github.com/codeh007/mtmpg/issues/1) 和 active OpenSpec change 跟踪。 ## 维护入口 diff --git a/docs/authentication-failures.md b/docs/authentication-failures.md index 95bfbc1..570cc14 100644 --- a/docs/authentication-failures.md +++ b/docs/authentication-failures.md @@ -27,8 +27,8 @@ | `token-header-invalid` | JOSE header无效 | | `token-kid-unknown` | `kid`不在当前snapshot | | `token-signature-invalid` | ES256签名无效 | -| `token-claims-invalid` | Claims、资源、时间或actor组合无效 | -| `token-role-mismatch` | Requested role与signed role不一致 | +| `token-claims-invalid` | Claims、资源、时间或profile组合无效 | +| `token-role-mismatch` | Requested role与token profile不一致 | | `identity-invalid` | Identity字段或规范编码无效 | | `callback-input-invalid` | Callback收到无效指针或文本输入 | | `callback-state-invalid` | Callback state未初始化或不一致 | diff --git a/docs/executor-runtime.md b/docs/executor-runtime.md deleted file mode 100644 index 96a0178..0000000 --- a/docs/executor-runtime.md +++ /dev/null @@ -1,65 +0,0 @@ -# SQL executor 运行契约 - -`mtmpg-executor`是只供受控私网调用的PostgreSQL 18 OAuth companion service。它把已经由调用方认证并授权的`DelegatedPrincipal`转换为30秒database JWT,并只通过同一进程的libpq auth-data hook把token交给当前`PGconn`。它不是公开SQL API、通用token service或直接数据库登录入口。 - -## 固定入口与HMAC - -Service只监听配置的TLS socket,并提供两个路径: - -- `GET /ready`:进程和TLS readiness。 -- `POST /v1/sql/execute`:唯一SQL执行入口。 - -执行请求必须包含`x-executor-version`、`x-executor-timestamp`、`x-executor-nonce`和`x-executor-signature`。Version固定为`v1`;timestamp窗口为30秒;nonce是16字节的小写hex;signature是HMAC-SHA256的小写hex。Canonical input固定为: - -```text -v1\nPOST\n/v1/sql/execute\n\n\n -``` - -HMAC secret必须是32个原始字节,只从只读文件加载。Service在JSON解析前验证body上限和HMAC,并使用constant-time比较。Nonce保存在单进程有界TTL store中;当前release只允许运行一个replica。水平扩容前必须先引入共享原子replay authority,不能用多个本地store绕过重放边界。 - -## Strict request - -Request body只允许以下字段: - -- `principal`:`user_id`、恰好一个`client_id|credential_id`、`delegation_id`、`auth_method`、`authority_version`、固定`database_scope`、`profile`和必需的`credential_expires_at`字段。API key允许该字段显式为`null`,OAuth必须是正整数时间戳;字段缺失和时间戳哨兵值均非法。 -- `statement`:一个非空PostgreSQL顶层statement。 -- `binds`:`null|text|int64|boolean|json`结构化参数数组。 -- `intent`:`read|change`。 -- `change_confirmed`:`change`必须为`true`,`read`必须为`false`。 -- `correlation_id`:受限的调用方关联ID。 - -所有对象deny unknown fields。请求不能提交Bearer、API key、password、database JWT、connection string、issuer、audience、role、claims或`statements[]`。Profile和startup role只允许完全同名的`ordinary`、`business_admin`、`database_developer`;不存在alias、映射或阶段前缀。 - -## Runtime mount - -Image以固定`10001:10001`身份运行。以下路径由部署平台通过只读mount提供,不能写入image、environment value或argv: - -| 环境变量 | 文件或值 | -| --- | --- | -| `MTMPG_EXECUTOR_HMAC_SECRET_PATH` | 32字节HMAC secret文件 | -| `MTMPG_EXECUTOR_SIGNING_KEY_PATH` | ES256 PKCS#8 private key文件 | -| `MTMPG_EXECUTOR_POSTGRES_CA_PATH` | PostgreSQL TLS CA文件 | -| `MTMPG_EXECUTOR_TLS_CERT_PATH` | Executor HTTPS certificate文件 | -| `MTMPG_EXECUTOR_TLS_KEY_PATH` | Executor HTTPS private key文件 | -| `MTMPG_EXECUTOR_ISSUER` | 唯一database-token issuer | -| `MTMPG_EXECUTOR_AUDIENCE` | 唯一database-token audience | -| `MTMPG_EXECUTOR_KEY_ID` | active signing `kid` | -| `MTMPG_EXECUTOR_LISTEN` | 私网listen address | - -Signer private key只进入executor。PostgreSQL validator只接收对应public config/JWKS投影。Database JWT不进入HTTP响应、connection string、文件或日志;连接成功、失败、取消和关闭都会清理registry与token内存。 - -## PostgreSQL连接与SQL - -每个合法请求新建一个连接,固定使用host `postgres`、database `gomtm`、profile同名user、`sslmode=verify-full`、`require_auth=oauth`、唯一issuer和通用client ID `sql-executor`。请求不能覆盖这些参数。Service不提供password、SCRAM、备用host/database、`SET ROLE`、Hyperdrive、pool或connection reuse。 - -用户statement始终通过`PQsendQueryParams` extended protocol提交一次;bind不插值,executor不按分号切割或自行解析SQL。PostgreSQL负责拒绝多个顶层statement,并最终裁决ACL、RLS、routine和constraint。 - -`read`使用service-owned read-only transaction;`change`只接受本次明确确认。结果完全缓冲并通过预算后才commit。Parse、bind、授权、约束、预算、timeout、cancel或commit任一失败都会rollback或关闭backend,且不返回部分结果。 - -## 预算、取消与失败 - -固定上限包括256 KiB request body、64 KiB statement、64个bind、单bind 64 KiB、1000 rows、1 MiB serialized result和256 KiB single value。连接、lock、statement、transaction和总请求均有deadline;HTTP future被丢弃时只向connection owner发送cancel flag,由owner使用PG18 libpq cancel API终止query并在总deadline内结束,不保留后台task。 - -成功响应只含columns、rows、command tag、affected rows、duration和correlation ID。失败响应只含稳定category、允许的SQLSTATE class、固定消息和correlation ID。日志可以记录闭集阶段,但不能记录HMAC、credential、database JWT、private key、connection string、完整SQL、bind、结果、panic文本或stack。 - -认证、TLS、OAuth、授权或执行失败全部fail closed。任何部署回滚都应停止注册上层SQL tool并切回另一个已发布executor SemVer;不得改用本地build、旧issuer、SCRAM或第二executor实现。 diff --git a/docs/release-and-compatibility.md b/docs/release-and-compatibility.md index cb41448..ce8ec40 100644 --- a/docs/release-and-compatibility.md +++ b/docs/release-and-compatibility.md @@ -4,24 +4,23 @@ ## 版本域 -四个版本域相互独立: +三个版本域相互独立: | 版本 | 当前形式 | 作用 | | --- | --- | --- | | mtmpg SemVer | `MAJOR.MINOR.PATCH`或prerelease | 用户选择的module/image release | -| Executor SemVer | `MAJOR.MINOR.PATCH`或prerelease | 用户选择的私网executor image release | -| Database token contract | integer `2` | JWT字段和验证语义 | -| Authn ID contract | `pggomtm:v2` | `authn_id`编码与解析 | +| Database token contract | v1(最小) | JWT字段和验证语义 | +| Authn ID contract | `oauth::v1` | `system_user`编码与解析 | -Database token contract 2固定ES256、唯一issuer/audience、`database` scope、30至300秒TTL、deny-unknown claims、actor二选一以及closed profile-role。三个名称同时用于`db_profile`、`db_role`和startup requested role: +Database-token contract v1固定ES256、唯一issuer/audience、`database` scope、30至300秒TTL、deny-unknown claims,claims 只允许 `iss`、`aud`、`sub`(`^[A-Za-z0-9_-]{1,64}$`)、`iat`、`exp`、`jti`、`scope=database`、`profile`。`profile` 即数据库角色,与 startup requested role 精确同名: -| `db_profile` | `db_role`与PostgreSQL role | +| `profile` | PostgreSQL role | | --- | --- | | `ordinary` | `ordinary` | | `business_admin` | `business_admin` | | `database_developer` | `database_developer` | -V0.1.x只实现database-token contract 1与`pggomtm:v1` identity,其连字符profile和带项目/阶段前缀的role属于不可变历史。V0.2.x只实现contract 2与`pggomtm:v2`,必须拒绝v1 token、identity和role;不得提供alias、role membership、兼容decoder或fallback。改变token字段、算法、profile-role或identity编码时必须再次提升对应contract,不得原地改变已发布版本语义。 +V0.2.x及更早的 database-token contract 2 与 `pggomtm:v2` identity(含 executor 铸币链字段 `delegation_id`/`auth_method`/`authority_version`/`client_id`/`credential_id`/`db_role`/`db_profile`)已被 gomtm issue #310 硬切取代。V0.3.x只实现最小 contract v1 与 `oauth::v1` identity,必须拒绝旧 token、identity 和 role;不得提供 alias、role membership、兼容 decoder 或 fallback。改变 token 字段、算法、profile-role 或 identity 编码时必须再次提升对应 contract,不得原地改变已发布版本语义。 ## 平台兼容 @@ -33,14 +32,14 @@ PostgreSQL major、架构、libc或runtime发行版变化需要显式源码变 ## CI与发布 -`.github/workflows/ci.yml`同时服务Pull Request、`main` push和validator/executor release调用。PR与main只运行只读门禁: +`.github/workflows/ci.yml`同时服务Pull Request、`main` push和validator release调用。PR与main只运行只读门禁: 1. 生成一次Cargo.lock并解析Rust、PG18和builder/runtime digest。 -2. 运行validator Rust/C ABI/真实PG18门禁与executor Rust/libpq ABI/并发OAuth/SQL门禁。 -3. 分别构建并验证一次validator与executor production OCI archive;release调用只上传明确选择的product。 +2. 运行validator Rust/C ABI/真实PG18门禁。 +3. 构建并验证一次validator production OCI archive;release调用只上传validator材料。 4. 没有SemVer tag时不写GHCR、Release或attestation。 -`.github/workflows/release.yml`以validator `v`或executor `executor-v` annotated tag进入两个隔离分支。去除各自前缀后的version必须与目标Cargo package version相等,tag必须指向`main` ancestry。Release调用同一CI并上传本次run已验证的目标archive;最小写权限publish job只校验tag/source、下载和推送该archive,不运行Cargo、不重新解析依赖,也不执行第二次Docker build。 +`.github/workflows/release.yml`以validator `v` annotated tag进入唯一发布分支。去除前缀`v`后的version必须与Cargo package version相等,tag必须指向`main` ancestry。Release调用同一CI并上传本次run已验证的目标archive;最小写权限publish job只校验tag/source、下载和推送该archive,不运行Cargo、不重新解析依赖,也不执行第二次Docker build。 目标version或GitHub Release已存在、tag/source/version不一致、任一门禁失败时,publish必须fail closed。失败tag不得移动、删除或复用;修复后提升SemVer并创建新tag。 @@ -49,8 +48,7 @@ PostgreSQL major、架构、libc或runtime发行版变化需要显式源码变 Prerelease和stable是两个独立release: - Prerelease例如 `v0.1.0-rc.1`,发布 `ghcr.io/codeh007/mtmpg:0.1.0-rc.1`和GitHub prerelease,不更新 `latest`。 -- Stable例如 `v0.2.0`,从自己的tag重新解析、完整测试、构建和发布;全部成功后才把 `latest`更新为该stable digest。 -- Executor stable使用`executor-v`和`ghcr.io/codeh007/mtmpg-executor:`,不更新validator Release或任一validator image引用,也不提供executor `latest`消费入口。 +- Stable例如 `v0.3.0`,从自己的tag重新解析、完整测试、构建和发布;全部成功后才把 `latest`更新为该stable digest。 Stable从自己的tag运行完整release,不复用prerelease制品,也不依赖gomtmui跨仓证据。不同tag即使指向相近源码,也分别保存自己的lockfile、resolved inputs、module和OCI身份。 @@ -65,14 +63,15 @@ Stable从自己的tag运行完整release,不复用prerelease制品,也不依 - `checksums.txt` - SPDX JSON SBOM - provenance与SBOM attestation bundle +- 裸 `pggomtm.so` 宿主产物(其sha256与image内module一致) -Release manifest绑定product、SemVer、tag、source SHA、Cargo.lock、实际toolchain/PG18/libpq输入、目标module或binary SHA-256、OCI archive hash和registry digest。对应image digest同时具有GitHub attestation与OCI registry referrer。 +Release manifest绑定product、SemVer、tag、source SHA、Cargo.lock、实际toolchain/PG18输入、目标module SHA-256、OCI archive hash和registry digest。对应image digest同时具有GitHub attestation与OCI registry referrer。 Actions artifact只在同一次workflow run内传递已验证archive和manifest输入,并使用短保留期。长期消费不得依赖Actions artifact,也不得发布自定义 `.evidence` OCI tag。 ## 消费与rollback -消费者使用明确的`ghcr.io/codeh007/mtmpg:`与按需使用`ghcr.io/codeh007/mtmpg-executor:`,不得现场编译、复制native测试矩阵或增加本地image fallback。Gomtmui的TLS、sub2api、pgAdmin、ACL/RLS、OAuth issuer和SQL tool activation由gomtmui自身领域change验证,不阻塞mtmpg release。 +消费者使用明确的`ghcr.io/codeh007/mtmpg:`(或对应 versioned GitHub Release 的 `pggomtm.so` 宿主产物),不得现场编译、复制native测试矩阵或增加本地image fallback。Gomtm 的 Go sql-relay、gomtmui 的签名上移与 TLS/ACL/RLS 集成由各自领域 change 验证,不阻塞 mtmpg release。 每次切换以完整PostgreSQL image为单位: diff --git a/docs/runtime-configuration.md b/docs/runtime-configuration.md index 1025d15..a45cdf5 100644 --- a/docs/runtime-configuration.md +++ b/docs/runtime-configuration.md @@ -61,7 +61,7 @@ Config只选择唯一issuer、唯一audience和固定public JWKS文件。以下 - Signing private key、API key、OAuth bearer、database JSON Web Token(JWT)、service credential或连接串 - Signal、reload、共享cache或既有backend重新认证选项 -V0.2.x的database-token contract v2固定ES256、P-256、`use=sig`、`key_ops=["verify"]`、`database` scope、30s至300s TTL,并只允许`ordinary`、`business_admin`和`database_developer`三个profile-role同名值。Runtime config schema仍是`pggomtm-validator-config/v1`,因为配置字段和解析语义没有变化。改变任一固定策略都需要新的module和consumer contract版本,不能通过修改JSON扩权。 +V0.3.x的database-token contract v1固定ES256、P-256、`use=sig`、`key_ops=["verify"]`、`database` scope、30s至300s TTL,claims 只允许`iss`/`aud`/`sub`/`iat`/`exp`/`jti`/`scope`/`profile`,并只允许`ordinary`、`business_admin`和`database_developer`三个`profile`同名值作为数据库角色。Runtime config schema仍是`pggomtm-validator-config/v1`,因为配置字段和解析语义没有变化。改变任一固定策略都需要新的module和consumer contract版本,不能通过修改JSON扩权。 ## 原子发布public材料 diff --git a/executor/Cargo.toml b/executor/Cargo.toml deleted file mode 100644 index 763c37c..0000000 --- a/executor/Cargo.toml +++ /dev/null @@ -1,35 +0,0 @@ -[package] -name = "mtmpg-executor" -version = "0.1.5" -edition = "2024" -license = "MIT" -publish = false - -[dependencies] -axum = "0.8" -axum-server = { version = "0.8", features = ["tls-rustls"] } -hmac = "0.13" -jaws = { version = "1", default-features = false, features = ["p256"] } -p256 = { version = "0.13", features = ["ecdsa"] } -pggomtm = { path = "..", default-features = false, features = ["database-token-contract"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.11" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } -uuid = { version = "1", features = ["v4"] } -zeroize = "1" - -[build-dependencies] -bindgen = { version = "0.72", default-features = false, features = ["runtime"] } -pkg-config = "0.3" - -[dev-dependencies] -reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls", "webpki-roots"] } - -[[example]] -name = "mtmpg_executor_fixture" -path = "tests/support/executor_fixture.rs" - -[[example]] -name = "mtmpg_executor_pg18_driver" -path = "tests/support/pg18_driver.rs" diff --git a/executor/Dockerfile b/executor/Dockerfile deleted file mode 100644 index 83d14b8..0000000 --- a/executor/Dockerfile +++ /dev/null @@ -1,92 +0,0 @@ -ARG RUST_IMAGE=rust:bookworm -ARG RUNTIME_IMAGE=debian:bookworm-slim - -FROM ${RUST_IMAGE} AS build - -ARG POSTGRES_MINOR - -ENV CARGO_TARGET_DIR=/src/target -ENV DEBIAN_FRONTEND=noninteractive -ENV PGRX_PG_CONFIG_PATH=/usr/lib/postgresql/18/bin/pg_config - -RUN apt-get update \ - && apt-get install --yes --no-install-recommends \ - build-essential \ - ca-certificates \ - clang \ - curl \ - gnupg \ - libclang-dev \ - libkrb5-dev \ - libssl-dev \ - openssl \ - pkg-config \ - && curl --fail --location --proto '=https' --tlsv1.2 \ - https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor --output /usr/share/keyrings/postgresql.gpg \ - && echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install --yes --no-install-recommends postgresql-server-dev-18 \ - && test -n "${POSTGRES_MINOR}" \ - && test "$("${PGRX_PG_CONFIG_PATH}" --version | grep --only-matching --extended-regexp '18\.[0-9]+' | head -n 1)" = "${POSTGRES_MINOR}" \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /src -COPY Cargo.toml Cargo.lock build.rs rust-toolchain.toml LICENSE ./ -COPY src ./src -COPY tests/support/oauth_fixture.rs ./tests/support/oauth_fixture.rs -COPY executor/Cargo.toml executor/build.rs ./executor/ -COPY executor/src ./executor/src -COPY executor/tests/postgres_setup.sql ./executor/tests/postgres_setup.sql -COPY executor/tests/stage-integration.sh ./executor/tests/stage-integration.sh -COPY executor/tests/support ./executor/tests/support - -RUN cargo build --locked --release --lib --no-default-features --features pg18 \ - && install -d -m 0755 /tmp/executor-validator \ - && install -m 0644 \ - "${CARGO_TARGET_DIR}/release/libpggomtm.so" \ - /tmp/executor-validator/pggomtm.so \ - && GITHUB_ACTIONS=true executor/tests/stage-integration.sh \ - /tmp/executor-validator \ - "${CARGO_TARGET_DIR}/executor-integration" - -FROM scratch AS integration-artifacts -COPY --from=build /src/target/executor-integration /artifacts - -FROM ${RUNTIME_IMAGE} - -ARG POSTGRES_MINOR -ARG SOURCE_REVISION=unknown -ARG VERSION=0.0.0-dev - -LABEL org.opencontainers.image.source="https://github.com/codeh007/mtmpg" \ - org.opencontainers.image.revision="${SOURCE_REVISION}" \ - org.opencontainers.image.version="${VERSION}" \ - org.opencontainers.image.licenses="MIT" - -COPY --from=build /usr/share/keyrings/postgresql.gpg /usr/share/keyrings/postgresql.gpg -RUN test -n "${POSTGRES_MINOR}" \ - && apt-get update \ - && apt-get install --yes --no-install-recommends ca-certificates \ - && echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install --yes --no-install-recommends libpq5 \ - && case "$(dpkg-query --show --showformat='${Version}' libpq5)" in \ - "${POSTGRES_MINOR}"-*) ;; \ - *) exit 1 ;; \ - esac \ - && rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/pgdg.list /usr/share/keyrings/postgresql.gpg - -COPY --from=build --chown=10001:10001 --chmod=0555 \ - /src/target/release/mtmpg-executor \ - /usr/local/bin/mtmpg-executor -COPY --from=build --chown=10001:10001 --chmod=0444 \ - /src/LICENSE \ - /usr/share/doc/mtmpg-executor/LICENSE - -USER 10001:10001 -EXPOSE 8443 -STOPSIGNAL SIGTERM -ENTRYPOINT ["/usr/local/bin/mtmpg-executor"] diff --git a/executor/build.rs b/executor/build.rs deleted file mode 100644 index 6c65eef..0000000 --- a/executor/build.rs +++ /dev/null @@ -1,110 +0,0 @@ -use std::env; -use std::error::Error; -use std::path::{Path, PathBuf}; -use std::process::Command; - -const POSTGRES_MAJOR: u32 = 18; -const BINDINGS_FILE: &str = "mtmpg_executor_libpq_bindings.rs"; -const LIBPQ_FUNCTIONS: &str = concat!( - "^(PQcancelBlocking|PQcancelCreate|PQcancelFinish|PQclear|PQcmdStatus|PQcmdTuples|", - "PQconnectPoll|PQconnectStartParams|PQconsumeInput|PQerrorMessage|PQfinish|PQflush|", - "PQfname|PQftype|PQgetCurrentTimeUSec|PQgetResult|PQgetisnull|PQgetlength|PQgetvalue|", - "PQisBusy|PQlibVersion|PQnfields|PQntuples|PQresultErrorField|PQresultStatus|", - "PQsendQueryParams|PQsetAuthDataHook|PQsetErrorContextVisibility|PQsetErrorVerbosity|", - "PQsetnonblocking|PQsetNoticeProcessor|PQsocket|PQsocketPoll|PQtransactionStatus)$" -); -const LIBPQ_TYPES: &str = concat!( - "^(ConnStatusType|ExecStatusType|Oid|PGauthData|PGcancelConn|PGconn|PGContextVisibility|", - "PGoauthBearerRequest|PGresult|PGTransactionStatusType|PGVerbosity|PostgresPollingStatusType|", - "PQauthDataHook_type|PQnoticeProcessor|pg_cancel_conn|pg_conn|pg_result|pg_usec_time_t)$" -); - -type BuildResult = Result>; - -fn main() { - println!("cargo:rerun-if-env-changed=PGRX_PG_CONFIG_PATH"); - if let Err(error) = generate_bindings() { - panic!("failed to generate PostgreSQL libpq bindings: {error}"); - } -} - -fn generate_bindings() -> BuildResult<()> { - let pg_config = required_path("PGRX_PG_CONFIG_PATH")?; - let version = pg_config_line(&pg_config, "--version")?; - let major = version - .strip_prefix("PostgreSQL ") - .and_then(|value| value.split('.').next()) - .and_then(|value| value.parse::().ok()) - .ok_or("pg_config returned an unsupported version string")?; - if major != POSTGRES_MAJOR { - return Err(format!("PostgreSQL major {major} is unsupported").into()); - } - - let include_dir = PathBuf::from(pg_config_line(&pg_config, "--includedir")?); - if !include_dir.is_absolute() || !include_dir.is_dir() { - return Err("pg_config client include directory is unavailable".into()); - } - let header = required_file(&include_dir.join("libpq-fe.h"))?; - let include_dir = utf8_path(&include_dir)?; - let header = utf8_path(&header)?; - println!("cargo:rerun-if-changed={header}"); - - pkg_config::Config::new() - .atleast_version("18") - .probe("libpq")?; - let bindings = bindgen::Builder::default() - .header(header) - .detect_include_paths(false) - .clang_arg(format!("-I{include_dir}")) - .allowlist_function(LIBPQ_FUNCTIONS) - .allowlist_type(LIBPQ_TYPES) - .allowlist_var(concat!( - "^(CONNECTION_|PGRES_|PG_DIAG_SQLSTATE$|PQAUTHDATA_|PQERRORS_|", - "PQSHOW_CONTEXT_|PQTRANS_).*$" - )) - .allowlist_recursively(false) - .generate_comments(false) - .layout_tests(false) - .formatter(bindgen::Formatter::None) - .generate()?; - - let output = required_path("OUT_DIR")?.join(BINDINGS_FILE); - bindings.write_to_file(output)?; - Ok(()) -} - -fn required_path(name: &str) -> BuildResult { - let path = PathBuf::from(env::var_os(name).ok_or_else(|| format!("{name} is required"))?); - if !path.is_absolute() { - return Err(format!("{name} must be absolute").into()); - } - Ok(path) -} - -fn required_file(path: &Path) -> BuildResult { - if !path.is_file() { - return Err(format!( - "required PostgreSQL header is unavailable: {}", - path.display() - ) - .into()); - } - Ok(path.to_path_buf()) -} - -fn utf8_path(path: &Path) -> BuildResult<&str> { - path.to_str() - .ok_or_else(|| format!("path must be UTF-8: {}", path.display()).into()) -} - -fn pg_config_line(pg_config: &Path, argument: &str) -> BuildResult { - let output = Command::new(pg_config).arg(argument).output()?; - if !output.status.success() { - return Err(format!("pg_config {argument} failed").into()); - } - let value = String::from_utf8(output.stdout)?.trim().to_owned(); - if value.is_empty() { - return Err(format!("pg_config {argument} returned no value").into()); - } - Ok(value) -} diff --git a/executor/src/auth.rs b/executor/src/auth.rs deleted file mode 100644 index 0ca24c7..0000000 --- a/executor/src/auth.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::collections::BTreeMap; -use std::sync::Mutex; - -use hmac::{Hmac, KeyInit, Mac}; -use sha2::{Digest, Sha256}; -use zeroize::Zeroizing; - -pub const AUTH_WINDOW_SECONDS: i64 = 30; -pub const EXECUTE_PATH: &str = "/v1/sql/execute"; -pub const WIRE_VERSION: &str = "v1"; - -type HmacSha256 = Hmac; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthenticationError { - InvalidConfiguration, - Unauthorized, -} - -#[derive(Debug, Clone, Copy)] -pub struct SignedRequest<'a> { - pub method: &'a str, - pub path: &'a str, - pub version: &'a str, - pub timestamp: i64, - pub nonce: &'a str, - pub body: &'a [u8], - pub signature: &'a str, -} - -pub struct HmacAuthenticator { - secret: Zeroizing>, - replay_capacity: usize, - replay: Mutex>, -} - -impl HmacAuthenticator { - pub fn new(secret: Vec, replay_capacity: usize) -> Result { - if secret.len() != 32 || replay_capacity == 0 { - return Err(AuthenticationError::InvalidConfiguration); - } - Ok(Self { - secret: Zeroizing::new(secret), - replay_capacity, - replay: Mutex::new(BTreeMap::new()), - }) - } - - pub fn verify(&self, request: &SignedRequest<'_>, now: i64) -> Result<(), AuthenticationError> { - if request.method != "POST" - || request.path != EXECUTE_PATH - || request.version != WIRE_VERSION - || now.abs_diff(request.timestamp) > AUTH_WINDOW_SECONDS.unsigned_abs() - || !is_lower_hex(request.nonce, 16) - || !is_lower_hex(request.signature, 32) - { - return Err(AuthenticationError::Unauthorized); - } - - let signature = - decode_signature(request.signature).ok_or(AuthenticationError::Unauthorized)?; - let body_digest = Sha256::digest(request.body); - let body_digest_hex = body_digest - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - let canonical = format!( - "{}\n{}\n{}\n{}\n{}\n{}", - request.version, - request.method, - request.path, - request.timestamp, - request.nonce, - body_digest_hex, - ); - let mut mac = HmacSha256::new_from_slice(&self.secret) - .map_err(|_| AuthenticationError::Unauthorized)?; - mac.update(canonical.as_bytes()); - mac.verify_slice(&signature) - .map_err(|_| AuthenticationError::Unauthorized)?; - - let mut replay = self - .replay - .lock() - .map_err(|_| AuthenticationError::Unauthorized)?; - replay.retain(|_, expires_at| *expires_at >= now); - if replay.contains_key(request.nonce) || replay.len() >= self.replay_capacity { - return Err(AuthenticationError::Unauthorized); - } - replay.insert( - request.nonce.to_owned(), - request.timestamp.saturating_add(AUTH_WINDOW_SECONDS), - ); - Ok(()) - } -} - -fn is_lower_hex(value: &str, expected_bytes: usize) -> bool { - value.len() == expected_bytes * 2 - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) -} - -fn decode_signature(value: &str) -> Option<[u8; 32]> { - if !is_lower_hex(value, 32) { - return None; - } - - let mut decoded = [0_u8; 32]; - for (output, pair) in decoded.iter_mut().zip(value.as_bytes().chunks_exact(2)) { - *output = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?; - } - Some(decoded) -} - -fn hex_nibble(value: u8) -> Option { - match value { - b'0'..=b'9' => Some(value - b'0'), - b'a'..=b'f' => Some(value - b'a' + 10), - _ => None, - } -} diff --git a/executor/src/issuer.rs b/executor/src/issuer.rs deleted file mode 100644 index 089bcdc..0000000 --- a/executor/src/issuer.rs +++ /dev/null @@ -1,138 +0,0 @@ -use std::fmt; - -use jaws::Token; -use p256::ecdsa::{Signature, SigningKey}; -use pggomtm::database_auth::{DatabaseTokenClaims, DatabaseTokenPolicy}; -use uuid::Uuid; -use zeroize::Zeroizing; - -use crate::protocol::DelegatedPrincipal; - -pub const DATABASE_TOKEN_TTL_SECONDS: i64 = 30; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum IssuerError { - InvalidConfiguration, - InvalidPrincipal, - CredentialExpiresTooSoon, - SigningFailed, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IssuerConfig { - issuer: String, - audience: String, - key_id: String, -} - -impl IssuerConfig { - pub fn new( - issuer: impl Into, - audience: impl Into, - key_id: impl Into, - ) -> Result { - let issuer = issuer.into(); - let audience = audience.into(); - let key_id = key_id.into(); - if DatabaseTokenPolicy::new(issuer.clone(), audience.clone()).is_err() - || !is_valid_key_id(&key_id) - { - return Err(IssuerError::InvalidConfiguration); - } - Ok(Self { - issuer, - audience, - key_id, - }) - } -} - -pub struct DatabaseTokenIssuer { - config: IssuerConfig, - signing_key: SigningKey, -} - -impl DatabaseTokenIssuer { - #[must_use] - pub fn new(config: IssuerConfig, signing_key: SigningKey) -> Self { - Self { - config, - signing_key, - } - } - - pub fn issue( - &self, - principal: &DelegatedPrincipal, - now: i64, - ) -> Result { - if now < 0 || !principal.is_valid() { - return Err(IssuerError::InvalidPrincipal); - } - let expires_at = now - .checked_add(DATABASE_TOKEN_TTL_SECONDS) - .ok_or(IssuerError::InvalidPrincipal)?; - if let Some(credential_expires_at) = principal.credential_expires_at - && credential_expires_at < expires_at - { - return Err(IssuerError::CredentialExpiresTooSoon); - } - - let (client_id, credential_id) = match principal.auth_method { - pggomtm::database_auth::AuthMethod::OAuth => (principal.client_id.clone(), None), - pggomtm::database_auth::AuthMethod::ApiKey => (None, principal.credential_id.clone()), - }; - let claims = DatabaseTokenClaims { - issuer: self.config.issuer.clone(), - audience: self.config.audience.clone(), - subject: principal.user_id.clone(), - issued_at: now, - expires_at, - token_id: Uuid::new_v4().simple().to_string(), - scope: "database".into(), - delegation_id: principal.delegation_id.clone(), - auth_method: principal.auth_method, - authority_version: principal.authority_version, - db_profile: principal.profile, - db_role: principal.profile.database_role().into(), - client_id, - credential_id, - }; - let mut token = Token::compact((), claims); - *token.header_mut().key_id() = Some(self.config.key_id.clone()); - let encoded = token - .sign::<_, Signature>(&self.signing_key) - .map_err(|_| IssuerError::SigningFailed)? - .rendered() - .map_err(|_| IssuerError::SigningFailed)?; - Ok(IssuedDatabaseToken(Zeroizing::new(encoded))) - } -} - -#[derive(PartialEq, Eq)] -pub struct IssuedDatabaseToken(Zeroizing); - -impl IssuedDatabaseToken { - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - pub(crate) fn into_secret(self) -> Zeroizing { - self.0 - } -} - -impl fmt::Debug for IssuedDatabaseToken { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("IssuedDatabaseToken([REDACTED])") - } -} - -fn is_valid_key_id(value: &str) -> bool { - !value.is_empty() - && value.len() <= 128 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) -} diff --git a/executor/src/lib.rs b/executor/src/lib.rs deleted file mode 100644 index 5b8b8c8..0000000 --- a/executor/src/lib.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Private PostgreSQL OAuth executor companion. - -pub mod auth; -pub mod issuer; -pub mod libpq; -pub mod protocol; -pub mod service; -pub mod token_registry; diff --git a/executor/src/libpq.rs b/executor/src/libpq.rs deleted file mode 100644 index aa0067a..0000000 --- a/executor/src/libpq.rs +++ /dev/null @@ -1,798 +0,0 @@ -use std::ffi::{CStr, CString, c_char, c_int, c_void}; -use std::ptr; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, OnceLock}; -use std::time::{Duration, Instant}; - -use serde::Serialize; -use serde_json::Value; -use zeroize::Zeroizing; - -use crate::protocol::{BindValue, ExecuteRequest, ExecutionIntent}; -use crate::token_registry::{ConnectionId, ConnectionTokenRegistry}; - -pub const MAX_RESULT_ROWS: usize = 1_000; -pub const MAX_RESULT_BYTES: usize = 1024 * 1024; -pub const MAX_RESULT_VALUE_BYTES: usize = 256 * 1024; -pub const TOTAL_DEADLINE: Duration = Duration::from_secs(3); - -const POLL_SLICE_MICROSECONDS: i64 = 50_000; -const OAUTH_CLIENT_ID: &str = "sql-executor"; -const JSON_OID: u32 = 114; -const JSONB_OID: u32 = 3_802; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ClientAbiError { - Unavailable, - UnsupportedPostgresMajor, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ClientAbi { - pub postgresql_major: u32, - pub oauth_bearer_auth_data_hook: bool, - pub async_cancel: bool, - pub extended_query: bool, - pub socket_poll: bool, -} - -pub fn current_client_abi() -> Result { - // SAFETY: PQlibVersion takes no arguments and does not retain Rust memory. - let version = unsafe { ffi::PQlibVersion() }; - if version <= 0 { - return Err(ClientAbiError::Unavailable); - } - let postgresql_major = - u32::try_from(version / 10_000).map_err(|_| ClientAbiError::Unavailable)?; - if postgresql_major != 18 { - return Err(ClientAbiError::UnsupportedPostgresMajor); - } - Ok(ClientAbi { - postgresql_major, - oauth_bearer_auth_data_hook: true, - async_cancel: true, - extended_query: true, - socket_poll: true, - }) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DatabaseConfig { - pub host: String, - pub database: String, - pub ca_path: String, - pub oauth_issuer: String, -} - -impl DatabaseConfig { - #[must_use] - pub fn canonical(ca_path: impl Into, oauth_issuer: impl Into) -> Self { - Self { - host: "postgres".into(), - database: "gomtm".into(), - ca_path: ca_path.into(), - oauth_issuer: oauth_issuer.into(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DatabaseErrorKind { - InvalidRequest, - Unavailable, - Rejected, - BudgetExceeded, - DeadlineExceeded, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DatabaseStage { - Client, - Connect, - Begin, - LockBudget, - StatementBudget, - TransactionBudget, - Statement, - Result, - Commit, -} - -impl DatabaseStage { - #[must_use] - pub const fn code(self) -> &'static str { - match self { - Self::Client => "client", - Self::Connect => "connect", - Self::Begin => "begin", - Self::LockBudget => "lock_budget", - Self::StatementBudget => "statement_budget", - Self::TransactionBudget => "transaction_budget", - Self::Statement => "statement", - Self::Result => "result", - Self::Commit => "commit", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DatabaseError { - pub kind: DatabaseErrorKind, - pub sqlstate_class: Option, - pub stage: DatabaseStage, -} - -impl DatabaseError { - fn new(kind: DatabaseErrorKind) -> Self { - Self { - kind, - sqlstate_class: None, - stage: DatabaseStage::Client, - } - } - - fn rejected(sqlstate: Option) -> Self { - if sqlstate.as_deref() == Some("57") { - return Self::new(DatabaseErrorKind::DeadlineExceeded); - } - Self { - kind: DatabaseErrorKind::Rejected, - sqlstate_class: sqlstate, - stage: DatabaseStage::Client, - } - } - - fn at_stage(mut self, stage: DatabaseStage) -> Self { - self.stage = stage; - self - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ResultColumn { - pub name: String, - pub type_oid: u32, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct ExecutionResult { - pub columns: Vec, - pub rows: Vec>, - pub command_tag: String, - pub affected_rows: u64, - pub duration_ms: u64, - pub correlation_id: String, -} - -#[derive(Debug, Clone)] -pub struct Cancellation { - cancelled: Arc, -} - -impl Cancellation { - #[must_use] - pub fn new() -> Self { - Self { - cancelled: Arc::new(AtomicBool::new(false)), - } - } - - pub fn cancel(&self) { - self.cancelled.store(true, Ordering::Release); - } - - fn is_cancelled(&self) -> bool { - self.cancelled.load(Ordering::Acquire) - } -} - -impl Default for Cancellation { - fn default() -> Self { - Self::new() - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthHookError { - AlreadyInstalled, - InvalidClientAbi, -} - -static AUTH_TOKENS: OnceLock> = OnceLock::new(); - -pub fn install_auth_data_hook(registry: Arc) -> Result<(), AuthHookError> { - current_client_abi().map_err(|_| AuthHookError::InvalidClientAbi)?; - AUTH_TOKENS - .set(registry) - .map_err(|_| AuthHookError::AlreadyInstalled)?; - // SAFETY: the callback is process-global, has C ABI, never unwinds, and the registry is static. - unsafe { ffi::PQsetAuthDataHook(Some(auth_data_hook)) }; - Ok(()) -} - -struct HookToken { - bytes: Zeroizing, -} - -unsafe extern "C" fn auth_data_hook( - auth_type: ffi::PGauthData, - connection: *mut ffi::PGconn, - data: *mut c_void, -) -> c_int { - std::panic::catch_unwind(|| { - if auth_type != ffi::PGauthData_PQAUTHDATA_OAUTH_BEARER_TOKEN - || connection.is_null() - || data.is_null() - { - return -1; - } - let Some(registry) = AUTH_TOKENS.get() else { - return -1; - }; - let Some(connection_id) = ConnectionId::new(connection.addr()) else { - return -1; - }; - let Ok(token) = registry.claim(connection_id) else { - return -1; - }; - - let mut bytes = token.into_secret(); - bytes.push('\0'); - let mut token = Box::new(HookToken { bytes }); - let token_pointer = token.bytes.as_mut_ptr().cast::(); - let user_pointer = Box::into_raw(token).cast::(); - let request = data.cast::(); - // SAFETY: libpq supplied data for this auth type as a writable PGoauthBearerRequest. - unsafe { - (*request).async_ = None; - (*request).cleanup = Some(cleanup_auth_data); - (*request).token = token_pointer; - (*request).user = user_pointer; - } - 1 - }) - .unwrap_or(-1) -} - -unsafe extern "C" fn cleanup_auth_data( - _connection: *mut ffi::PGconn, - request: *mut ffi::PGoauthBearerRequest, -) { - let _ = std::panic::catch_unwind(|| { - if request.is_null() { - return; - } - // SAFETY: user was created by auth_data_hook and libpq calls cleanup at most once. - unsafe { - if !(*request).user.is_null() { - drop(Box::from_raw((*request).user.cast::())); - (*request).user = ptr::null_mut(); - } - (*request).token = ptr::null_mut(); - (*request).cleanup = None; - } - }); -} - -pub fn execute( - config: &DatabaseConfig, - registry: Arc, - request: &ExecuteRequest, - token: Zeroizing, - cancellation: &Cancellation, -) -> Result { - let deadline = Instant::now() + TOTAL_DEADLINE; - let mut connection = PgConnection::connect( - config, - Arc::clone(®istry), - request.principal.profile.database_role(), - token, - deadline, - cancellation, - ) - .map_err(|error| error.at_stage(DatabaseStage::Connect))?; - let started = Instant::now(); - - let begin = match request.intent { - ExecutionIntent::Read => "BEGIN READ ONLY", - ExecutionIntent::Change => "BEGIN", - }; - connection - .control(begin, deadline, cancellation) - .map_err(|error| error.at_stage(DatabaseStage::Begin))?; - connection - .control("SET LOCAL lock_timeout = '250ms'", deadline, cancellation) - .map_err(|error| error.at_stage(DatabaseStage::LockBudget))?; - connection - .control( - "SET LOCAL statement_timeout = '750ms'", - deadline, - cancellation, - ) - .map_err(|error| error.at_stage(DatabaseStage::StatementBudget))?; - connection - .control( - "SET LOCAL idle_in_transaction_session_timeout = '1500ms'", - deadline, - cancellation, - ) - .map_err(|error| error.at_stage(DatabaseStage::TransactionBudget))?; - - let outcome = match connection.query(&request.statement, &request.binds, deadline, cancellation) - { - Ok(outcome) => outcome, - Err(error) => { - connection.rollback_best_effort(); - return Err(error.at_stage(DatabaseStage::Statement)); - } - }; - // SAFETY: the connection is live; a successful user statement must leave our transaction open. - if unsafe { ffi::PQtransactionStatus(connection.raw) } - != ffi::PGTransactionStatusType_PQTRANS_INTRANS - { - return Err( - DatabaseError::new(DatabaseErrorKind::Rejected).at_stage(DatabaseStage::Statement) - ); - } - let result = ExecutionResult { - columns: outcome.columns, - rows: outcome.rows, - command_tag: outcome.command_tag, - affected_rows: outcome.affected_rows, - duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), - correlation_id: request.correlation_id.clone(), - }; - let encoded = serde_json::to_vec(&result).map_err(|_| { - DatabaseError::new(DatabaseErrorKind::Unavailable).at_stage(DatabaseStage::Result) - })?; - if encoded.len() > MAX_RESULT_BYTES { - connection.rollback_best_effort(); - return Err( - DatabaseError::new(DatabaseErrorKind::BudgetExceeded).at_stage(DatabaseStage::Result) - ); - } - if let Err(error) = connection.control("COMMIT", deadline, cancellation) { - connection.rollback_best_effort(); - return Err(error.at_stage(DatabaseStage::Commit)); - } - Ok(result) -} - -struct QueryOutcome { - columns: Vec, - rows: Vec>, - command_tag: String, - affected_rows: u64, -} - -struct PgConnection { - raw: *mut ffi::PGconn, - registry: Arc, - connection_id: ConnectionId, -} - -impl PgConnection { - fn connect( - config: &DatabaseConfig, - registry: Arc, - user: &str, - token: Zeroizing, - deadline: Instant, - cancellation: &Cancellation, - ) -> Result { - let keys = [ - c_string("host")?, - c_string("dbname")?, - c_string("user")?, - c_string("sslmode")?, - c_string("sslrootcert")?, - c_string("oauth_issuer")?, - c_string("oauth_client_id")?, - c_string("require_auth")?, - ]; - let values = [ - c_string(&config.host)?, - c_string(&config.database)?, - c_string(user)?, - c_string("verify-full")?, - c_string(&config.ca_path)?, - c_string(&config.oauth_issuer)?, - c_string(OAUTH_CLIENT_ID)?, - c_string("oauth")?, - ]; - let mut key_pointers: Vec<*const c_char> = - keys.iter().map(|value| value.as_ptr()).collect(); - let mut value_pointers: Vec<*const c_char> = - values.iter().map(|value| value.as_ptr()).collect(); - key_pointers.push(ptr::null()); - value_pointers.push(ptr::null()); - - // SAFETY: both arrays are NULL-terminated and their CStrings outlive the call. - let raw = - unsafe { ffi::PQconnectStartParams(key_pointers.as_ptr(), value_pointers.as_ptr(), 0) }; - if raw.is_null() { - return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)); - } - // SAFETY: the callback has C ABI and intentionally ignores all notice content. - unsafe { ffi::PQsetNoticeProcessor(raw, Some(discard_notice), ptr::null_mut()) }; - let Some(connection_id) = ConnectionId::new(raw.addr()) else { - // SAFETY: raw is a live libpq connection returned above. - unsafe { ffi::PQfinish(raw) }; - return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)); - }; - if registry.register_secret(connection_id, token).is_err() { - // SAFETY: raw is a live libpq connection returned above. - unsafe { ffi::PQfinish(raw) }; - return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)); - } - let connection = Self { - raw, - registry, - connection_id, - }; - - // SAFETY: the live connection is exclusively owned by this thread. - if unsafe { ffi::PQsetnonblocking(connection.raw, 1) } != 0 { - return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)); - } - loop { - operation_allowed(deadline, cancellation)?; - // SAFETY: raw remains live and exclusively owned. - let status = unsafe { ffi::PQconnectPoll(connection.raw) }; - match status { - ffi::PostgresPollingStatusType_PGRES_POLLING_OK => return Ok(connection), - ffi::PostgresPollingStatusType_PGRES_POLLING_READING => { - connection.poll(true, false, deadline, cancellation)?; - } - ffi::PostgresPollingStatusType_PGRES_POLLING_WRITING => { - connection.poll(false, true, deadline, cancellation)?; - } - ffi::PostgresPollingStatusType_PGRES_POLLING_ACTIVE => {} - _ => return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)), - } - } - } - - fn control( - &mut self, - statement: &str, - deadline: Instant, - cancellation: &Cancellation, - ) -> Result<(), DatabaseError> { - let outcome = self.query(statement, &[], deadline, cancellation)?; - if outcome.rows.is_empty() { - Ok(()) - } else { - Err(DatabaseError::new(DatabaseErrorKind::Unavailable)) - } - } - - fn query( - &mut self, - statement: &str, - binds: &[BindValue], - deadline: Instant, - cancellation: &Cancellation, - ) -> Result { - operation_allowed(deadline, cancellation)?; - let statement = c_string(statement)?; - let encoded_binds = EncodedBinds::new(binds)?; - let bind_count = c_int::try_from(encoded_binds.values.len()) - .map_err(|_| DatabaseError::new(DatabaseErrorKind::InvalidRequest))?; - // SAFETY: all pointers refer to live owned buffers for the duration of the call. - let sent = unsafe { - ffi::PQsendQueryParams( - self.raw, - statement.as_ptr(), - bind_count, - ptr::null(), - encoded_binds.values.as_ptr(), - encoded_binds.lengths.as_ptr(), - encoded_binds.formats.as_ptr(), - 0, - ) - }; - if sent != 1 { - return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)); - } - self.flush(deadline, cancellation)?; - self.wait_for_result(deadline, cancellation)?; - - // SAFETY: libpq owns the returned PGresult until PQclear below. - let raw_result = unsafe { ffi::PQgetResult(self.raw) }; - if raw_result.is_null() { - return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)); - } - let result = ResultOwner(raw_result); - let outcome = result.decode()?; - // A single extended-protocol statement must produce exactly one result. - // SAFETY: the connection remains live and no other query is active. - let unexpected = unsafe { ffi::PQgetResult(self.raw) }; - if !unexpected.is_null() { - // SAFETY: libpq transferred ownership of this unexpected result to the caller. - unsafe { ffi::PQclear(unexpected) }; - return Err(DatabaseError::new(DatabaseErrorKind::Rejected)); - } - Ok(outcome) - } - - fn flush(&self, deadline: Instant, cancellation: &Cancellation) -> Result<(), DatabaseError> { - loop { - operation_allowed(deadline, cancellation).inspect_err(|_| self.cancel_query())?; - // SAFETY: the connection is live and exclusively owned. - match unsafe { ffi::PQflush(self.raw) } { - 0 => return Ok(()), - 1 => self.poll(false, true, deadline, cancellation)?, - _ => return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)), - } - } - } - - fn wait_for_result( - &self, - deadline: Instant, - cancellation: &Cancellation, - ) -> Result<(), DatabaseError> { - loop { - operation_allowed(deadline, cancellation).inspect_err(|_| self.cancel_query())?; - // SAFETY: the connection is live and exclusively owned. - if unsafe { ffi::PQconsumeInput(self.raw) } != 1 { - return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)); - } - // SAFETY: the connection is live and exclusively owned. - if unsafe { ffi::PQisBusy(self.raw) } == 0 { - return Ok(()); - } - self.poll(true, false, deadline, cancellation)?; - } - } - - fn poll( - &self, - read: bool, - write: bool, - deadline: Instant, - cancellation: &Cancellation, - ) -> Result<(), DatabaseError> { - operation_allowed(deadline, cancellation)?; - // SAFETY: the connection is live and exclusively owned. - let socket = unsafe { ffi::PQsocket(self.raw) }; - if socket < 0 { - return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)); - } - // SAFETY: this asks libpq for its monotonic-compatible current time. - let now = unsafe { ffi::PQgetCurrentTimeUSec() }; - let poll_deadline = now.saturating_add(POLL_SLICE_MICROSECONDS); - // SAFETY: socket belongs to the live connection and the deadline uses libpq's clock. - let result = unsafe { - ffi::PQsocketPoll(socket, c_int::from(read), c_int::from(write), poll_deadline) - }; - if result < 0 { - return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)); - } - Ok(()) - } - - fn cancel_query(&self) { - // SAFETY: the live connection is exclusively owned; cancelConn is always finalized. - unsafe { - let cancel = ffi::PQcancelCreate(self.raw); - if !cancel.is_null() { - let _ = ffi::PQcancelBlocking(cancel); - ffi::PQcancelFinish(cancel); - } - } - } - - fn rollback_best_effort(&mut self) { - let cancellation = Cancellation::new(); - let _ = self.control( - "ROLLBACK", - Instant::now() + Duration::from_millis(500), - &cancellation, - ); - } -} - -unsafe extern "C" fn discard_notice(_argument: *mut c_void, _message: *const c_char) {} - -impl Drop for PgConnection { - fn drop(&mut self) { - self.registry.cleanup(self.connection_id); - // SAFETY: this object is the sole owner and calls PQfinish exactly once. - unsafe { ffi::PQfinish(self.raw) }; - } -} - -struct EncodedBinds { - _storage: Vec>, - values: Vec<*const c_char>, - lengths: Vec, - formats: Vec, -} - -impl EncodedBinds { - fn new(binds: &[BindValue]) -> Result { - let mut storage = Vec::with_capacity(binds.len()); - for bind in binds { - let encoded = match bind { - BindValue::Null => None, - BindValue::Text(value) => Some(c_string(value)?), - BindValue::Int64(value) => Some(c_string(&value.to_string())?), - BindValue::Boolean(value) => Some(c_string(if *value { "true" } else { "false" })?), - BindValue::Json(value) => { - Some(c_string(&serde_json::to_string(value).map_err(|_| { - DatabaseError::new(DatabaseErrorKind::InvalidRequest) - })?)?) - } - }; - storage.push(encoded); - } - let values = storage - .iter() - .map(|value| value.as_ref().map_or(ptr::null(), |value| value.as_ptr())) - .collect(); - Ok(Self { - lengths: vec![0; binds.len()], - formats: vec![0; binds.len()], - _storage: storage, - values, - }) - } -} - -struct ResultOwner(*mut ffi::PGresult); - -impl ResultOwner { - fn decode(&self) -> Result { - // SAFETY: self owns a live PGresult for the duration of decoding. - let status = unsafe { ffi::PQresultStatus(self.0) }; - match status { - ffi::ExecStatusType_PGRES_COMMAND_OK | ffi::ExecStatusType_PGRES_TUPLES_OK => {} - _ => return Err(DatabaseError::rejected(self.sqlstate_class())), - } - - // SAFETY: all accessors only inspect this live PGresult. - let row_count = usize::try_from(unsafe { ffi::PQntuples(self.0) }) - .map_err(|_| DatabaseError::new(DatabaseErrorKind::Unavailable))?; - let column_count = usize::try_from(unsafe { ffi::PQnfields(self.0) }) - .map_err(|_| DatabaseError::new(DatabaseErrorKind::Unavailable))?; - if row_count > MAX_RESULT_ROWS { - return Err(DatabaseError::new(DatabaseErrorKind::BudgetExceeded)); - } - - let mut columns = Vec::with_capacity(column_count); - for column in 0..column_count { - let column_index = c_int::try_from(column) - .map_err(|_| DatabaseError::new(DatabaseErrorKind::Unavailable))?; - // SAFETY: column_index is within PQnfields. - let name = unsafe { ffi::PQfname(self.0, column_index) }; - if name.is_null() { - return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)); - } - // SAFETY: libpq returns a NUL-terminated name owned by PGresult. - let name = unsafe { CStr::from_ptr(name) } - .to_str() - .map_err(|_| DatabaseError::new(DatabaseErrorKind::Unavailable))? - .to_owned(); - // SAFETY: column_index is within PQnfields. - let type_oid = unsafe { ffi::PQftype(self.0, column_index) }; - columns.push(ResultColumn { name, type_oid }); - } - - let mut rows = Vec::with_capacity(row_count); - let mut value_bytes = 0_usize; - for row in 0..row_count { - let row_index = c_int::try_from(row) - .map_err(|_| DatabaseError::new(DatabaseErrorKind::Unavailable))?; - let mut values = Vec::with_capacity(column_count); - for (column, column_schema) in columns.iter().enumerate() { - let column_index = c_int::try_from(column) - .map_err(|_| DatabaseError::new(DatabaseErrorKind::Unavailable))?; - // SAFETY: both indexes are inside the result bounds. - if unsafe { ffi::PQgetisnull(self.0, row_index, column_index) } == 1 { - values.push(Value::Null); - continue; - } - // SAFETY: both indexes are inside the result bounds. - let length = - usize::try_from(unsafe { ffi::PQgetlength(self.0, row_index, column_index) }) - .map_err(|_| DatabaseError::new(DatabaseErrorKind::Unavailable))?; - if length > MAX_RESULT_VALUE_BYTES { - return Err(DatabaseError::new(DatabaseErrorKind::BudgetExceeded)); - } - value_bytes = value_bytes.saturating_add(length); - if value_bytes > MAX_RESULT_BYTES { - return Err(DatabaseError::new(DatabaseErrorKind::BudgetExceeded)); - } - // SAFETY: libpq returns at least length readable bytes for this cell. - let pointer = unsafe { ffi::PQgetvalue(self.0, row_index, column_index) }; - if pointer.is_null() { - return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)); - } - // SAFETY: length came from PQgetlength for this pointer. - let bytes = unsafe { std::slice::from_raw_parts(pointer.cast::(), length) }; - let text = std::str::from_utf8(bytes) - .map_err(|_| DatabaseError::new(DatabaseErrorKind::Unavailable))?; - let value = match column_schema.type_oid { - JSON_OID | JSONB_OID => serde_json::from_str(text) - .map_err(|_| DatabaseError::new(DatabaseErrorKind::Unavailable))?, - _ => Value::String(text.to_owned()), - }; - values.push(value); - } - rows.push(values); - } - - // SAFETY: command status and tuple count are NUL-terminated strings owned by PGresult. - let command_tag = unsafe { c_string_from_libpq(ffi::PQcmdStatus(self.0)) }?; - // SAFETY: see above; empty is valid for statements without affected rows. - let affected = unsafe { c_string_from_libpq(ffi::PQcmdTuples(self.0)) }?; - let affected_rows = if affected.is_empty() { - 0 - } else { - affected - .parse() - .map_err(|_| DatabaseError::new(DatabaseErrorKind::Unavailable))? - }; - Ok(QueryOutcome { - columns, - rows, - command_tag, - affected_rows, - }) - } - - fn sqlstate_class(&self) -> Option { - // SAFETY: self owns a live result; NULL indicates no SQLSTATE. - let field = unsafe { ffi::PQresultErrorField(self.0, c_int::from(ffi::PG_DIAG_SQLSTATE)) }; - if field.is_null() { - return None; - } - // SAFETY: libpq returns a NUL-terminated field owned by PGresult. - let state = unsafe { CStr::from_ptr(field) }.to_str().ok()?; - (state.len() == 5).then(|| state[..2].to_owned()) - } -} - -impl Drop for ResultOwner { - fn drop(&mut self) { - // SAFETY: this object owns the PGresult and clears it exactly once. - unsafe { ffi::PQclear(self.0) }; - } -} - -fn c_string(value: &str) -> Result { - CString::new(value).map_err(|_| DatabaseError::new(DatabaseErrorKind::InvalidRequest)) -} - -unsafe fn c_string_from_libpq(pointer: *const c_char) -> Result { - if pointer.is_null() { - return Err(DatabaseError::new(DatabaseErrorKind::Unavailable)); - } - // SAFETY: callers only pass libpq-owned NUL-terminated strings. - unsafe { CStr::from_ptr(pointer) } - .to_str() - .map(str::to_owned) - .map_err(|_| DatabaseError::new(DatabaseErrorKind::Unavailable)) -} - -fn operation_allowed(deadline: Instant, cancellation: &Cancellation) -> Result<(), DatabaseError> { - if cancellation.is_cancelled() || Instant::now() >= deadline { - return Err(DatabaseError::new(DatabaseErrorKind::DeadlineExceeded)); - } - Ok(()) -} - -#[allow( - dead_code, - non_camel_case_types, - non_snake_case, - non_upper_case_globals -)] -mod ffi { - include!(concat!( - env!("OUT_DIR"), - "/mtmpg_executor_libpq_bindings.rs" - )); -} diff --git a/executor/src/main.rs b/executor/src/main.rs deleted file mode 100644 index dcae933..0000000 --- a/executor/src/main.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[tokio::main] -async fn main() { - if mtmpg_executor::service::run().await.is_err() { - eprintln!("executor failed to start or stopped unexpectedly"); - std::process::exit(78); - } -} diff --git a/executor/src/protocol.rs b/executor/src/protocol.rs deleted file mode 100644 index 6f58daa..0000000 --- a/executor/src/protocol.rs +++ /dev/null @@ -1,207 +0,0 @@ -use pggomtm::database_auth::{AuthMethod, DatabaseProfile}; -use serde::{Deserialize, Deserializer, Serialize, de}; -use serde_json::Value; - -pub const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024; -pub const MAX_STATEMENT_BYTES: usize = 64 * 1024; -pub const MAX_BIND_COUNT: usize = 64; -pub const MAX_BIND_VALUE_BYTES: usize = 64 * 1024; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProtocolError { - InvalidRequest, - LimitExceeded, - ConfirmationRequired, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct DelegatedPrincipal { - pub user_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub client_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub credential_id: Option, - pub delegation_id: String, - pub auth_method: AuthMethod, - pub authority_version: u64, - pub database_scope: String, - pub profile: DatabaseProfile, - pub credential_expires_at: Option, -} - -impl<'de> Deserialize<'de> for DelegatedPrincipal { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct WireDelegatedPrincipal { - user_id: String, - #[serde(default)] - client_id: Option, - #[serde(default)] - credential_id: Option, - delegation_id: String, - auth_method: AuthMethod, - authority_version: u64, - database_scope: String, - profile: DatabaseProfile, - #[serde(default, deserialize_with = "deserialize_credential_expiry")] - credential_expires_at: RequiredCredentialExpiry, - } - - let wire = WireDelegatedPrincipal::deserialize(deserializer)?; - let credential_expires_at = match wire.credential_expires_at { - RequiredCredentialExpiry::Missing => { - return Err(de::Error::missing_field("credential_expires_at")); - } - RequiredCredentialExpiry::Present(value) => value, - }; - Ok(Self { - user_id: wire.user_id, - client_id: wire.client_id, - credential_id: wire.credential_id, - delegation_id: wire.delegation_id, - auth_method: wire.auth_method, - authority_version: wire.authority_version, - database_scope: wire.database_scope, - profile: wire.profile, - credential_expires_at, - }) - } -} - -#[derive(Default)] -enum RequiredCredentialExpiry { - #[default] - Missing, - Present(Option), -} - -fn deserialize_credential_expiry<'de, D>( - deserializer: D, -) -> Result -where - D: Deserializer<'de>, -{ - Option::::deserialize(deserializer).map(RequiredCredentialExpiry::Present) -} - -impl DelegatedPrincipal { - #[must_use] - pub fn actor_id(&self) -> &str { - self.client_id - .as_deref() - .or(self.credential_id.as_deref()) - .unwrap_or("") - } - - pub(crate) fn is_valid(&self) -> bool { - let actor_matches = matches!( - (self.auth_method, &self.client_id, &self.credential_id), - (AuthMethod::OAuth, Some(_), None) | (AuthMethod::ApiKey, None, Some(_)) - ); - let credential_expiry_matches = match (self.auth_method, self.credential_expires_at) { - (AuthMethod::OAuth, Some(expires_at)) | (AuthMethod::ApiKey, Some(expires_at)) => { - expires_at > 0 - } - (AuthMethod::ApiKey, None) => true, - (AuthMethod::OAuth, None) => false, - }; - actor_matches - && is_internal_id(&self.user_id) - && is_internal_id(self.actor_id()) - && is_internal_id(&self.delegation_id) - && self.authority_version > 0 - && self.database_scope == "database" - && credential_expiry_matches - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde( - tag = "type", - content = "value", - rename_all = "snake_case", - deny_unknown_fields -)] -pub enum BindValue { - Null, - Text(String), - Int64(i64), - Boolean(bool), - Json(Value), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExecutionIntent { - Read, - Change, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ExecuteRequest { - pub principal: DelegatedPrincipal, - pub statement: String, - pub binds: Vec, - pub intent: ExecutionIntent, - pub change_confirmed: bool, - pub correlation_id: String, -} - -pub fn parse_execute_request(body: &[u8]) -> Result { - if body.len() > MAX_REQUEST_BODY_BYTES { - return Err(ProtocolError::LimitExceeded); - } - - let request: ExecuteRequest = - serde_json::from_slice(body).map_err(|_| ProtocolError::InvalidRequest)?; - if !request.principal.is_valid() - || request.statement.trim().is_empty() - || !is_correlation_id(&request.correlation_id) - { - return Err(ProtocolError::InvalidRequest); - } - if request.statement.len() > MAX_STATEMENT_BYTES - || request.binds.len() > MAX_BIND_COUNT - || request.binds.iter().any(bind_exceeds_limit) - { - return Err(ProtocolError::LimitExceeded); - } - - match (request.intent, request.change_confirmed) { - (ExecutionIntent::Read, false) | (ExecutionIntent::Change, true) => Ok(request), - (ExecutionIntent::Change, false) => Err(ProtocolError::ConfirmationRequired), - (ExecutionIntent::Read, true) => Err(ProtocolError::InvalidRequest), - } -} - -fn bind_exceeds_limit(bind: &BindValue) -> bool { - match bind { - BindValue::Null | BindValue::Int64(_) | BindValue::Boolean(_) => false, - BindValue::Text(value) => value.len() > MAX_BIND_VALUE_BYTES, - BindValue::Json(value) => match serde_json::to_vec(value) { - Ok(encoded) => encoded.len() > MAX_BIND_VALUE_BYTES, - Err(_) => true, - }, - } -} - -fn is_internal_id(value: &str) -> bool { - !value.is_empty() - && value.len() <= 64 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) -} - -fn is_correlation_id(value: &str) -> bool { - !value.is_empty() - && value.len() <= 128 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) -} diff --git a/executor/src/service.rs b/executor/src/service.rs deleted file mode 100644 index 1e1f50a..0000000 --- a/executor/src/service.rs +++ /dev/null @@ -1,340 +0,0 @@ -use std::env; -use std::error::Error; -use std::fs; -use std::io::{Error as IoError, ErrorKind}; -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; - -use axum::body::Bytes; -use axum::extract::{DefaultBodyLimit, State}; -use axum::http::{HeaderMap, StatusCode}; -use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use axum_server::tls_rustls::RustlsConfig; -use p256::ecdsa::SigningKey; -use p256::pkcs8::DecodePrivateKey; -use serde::Serialize; -use tokio::sync::Semaphore; -use zeroize::Zeroizing; - -use crate::auth::{HmacAuthenticator, SignedRequest}; -use crate::issuer::{DatabaseTokenIssuer, IssuerConfig, IssuerError}; -use crate::libpq::{ - Cancellation, DatabaseConfig, DatabaseError, DatabaseErrorKind, ExecutionResult, - install_auth_data_hook, -}; -use crate::protocol::{MAX_REQUEST_BODY_BYTES, ProtocolError, parse_execute_request}; -use crate::token_registry::ConnectionTokenRegistry; - -const MAX_CONCURRENCY: usize = 32; -const REPLAY_CAPACITY: usize = 4_096; - -#[derive(Clone)] -struct AppState { - authenticator: Arc, - issuer: Arc, - database: DatabaseConfig, - registry: Arc, - concurrency: Arc, -} - -struct RuntimeConfig { - state: AppState, - listen: SocketAddr, - tls_cert_path: String, - tls_key_path: String, -} - -impl RuntimeConfig { - fn from_environment() -> Result> { - let hmac_path = startup_stage( - required_environment("MTMPG_EXECUTOR_HMAC_SECRET_PATH"), - "hmac", - )?; - let hmac_secret = Zeroizing::new(startup_stage(fs::read(hmac_path), "hmac")?); - let authenticator = startup_stage( - HmacAuthenticator::new(hmac_secret.to_vec(), REPLAY_CAPACITY), - "hmac", - )?; - - let signing_key_path = startup_stage( - required_environment("MTMPG_EXECUTOR_SIGNING_KEY_PATH"), - "signing_key", - )?; - let signing_pem = Zeroizing::new(startup_stage( - fs::read_to_string(signing_key_path), - "signing_key", - )?); - let signing_key = startup_stage(SigningKey::from_pkcs8_pem(&signing_pem), "signing_key")?; - let issuer = startup_stage(required_environment("MTMPG_EXECUTOR_ISSUER"), "issuer")?; - let audience = startup_stage(required_environment("MTMPG_EXECUTOR_AUDIENCE"), "issuer")?; - let key_id = startup_stage(required_environment("MTMPG_EXECUTOR_KEY_ID"), "issuer")?; - let database_issuer = issuer.clone(); - let issuer_config = startup_stage(IssuerConfig::new(issuer, audience, key_id), "issuer")?; - - let registry = Arc::new(startup_stage( - ConnectionTokenRegistry::with_capacity(MAX_CONCURRENCY), - "token_registry", - )?); - startup_stage(install_auth_data_hook(Arc::clone(®istry)), "libpq")?; - - let ca_path = startup_stage( - required_environment("MTMPG_EXECUTOR_POSTGRES_CA_PATH"), - "database_tls", - )?; - let listen = startup_stage( - startup_stage(required_environment("MTMPG_EXECUTOR_LISTEN"), "listen")?.parse(), - "listen", - )?; - let tls_cert_path = startup_stage( - required_environment("MTMPG_EXECUTOR_TLS_CERT_PATH"), - "https_tls", - )?; - let tls_key_path = startup_stage( - required_environment("MTMPG_EXECUTOR_TLS_KEY_PATH"), - "https_tls", - )?; - - Ok(Self { - state: AppState { - authenticator: Arc::new(authenticator), - issuer: Arc::new(DatabaseTokenIssuer::new(issuer_config, signing_key)), - database: DatabaseConfig::canonical(ca_path, database_issuer), - registry, - concurrency: Arc::new(Semaphore::new(MAX_CONCURRENCY)), - }, - listen, - tls_cert_path, - tls_key_path, - }) - } -} - -pub async fn run() -> Result<(), Box> { - let config = RuntimeConfig::from_environment()?; - let tls = startup_stage( - RustlsConfig::from_pem_file(&config.tls_cert_path, &config.tls_key_path).await, - "https_tls", - )?; - let application = Router::new() - .route("/ready", get(ready)) - .route(crate::auth::EXECUTE_PATH, post(execute)) - .layer(DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES)) - .with_state(config.state); - startup_stage( - axum_server::bind_rustls(config.listen, tls) - .serve(application.into_make_service()) - .await, - "https_server", - )?; - Ok(()) -} - -async fn ready() -> StatusCode { - StatusCode::OK -} - -async fn execute(State(state): State, headers: HeaderMap, body: Bytes) -> Response { - let envelope = match parse_envelope(&headers, &body) { - Ok(envelope) => envelope, - Err(()) => return error_response(StatusCode::UNAUTHORIZED, "unauthorized", None), - }; - let now = match unix_time() { - Ok(now) => now, - Err(()) => return error_response(StatusCode::SERVICE_UNAVAILABLE, "unavailable", None), - }; - if state.authenticator.verify(&envelope, now) != Ok(()) { - return error_response(StatusCode::UNAUTHORIZED, "unauthorized", None); - } - let request = match parse_execute_request(&body) { - Ok(request) => request, - Err(error) => return protocol_error(error), - }; - let correlation_id = request.correlation_id.clone(); - let token = match state.issuer.issue(&request.principal, now) { - Ok(token) => token, - Err(error) => return issuer_error(error, &correlation_id), - }; - let permit = match Arc::clone(&state.concurrency).try_acquire_owned() { - Ok(permit) => permit, - Err(_) => { - return error_response( - StatusCode::SERVICE_UNAVAILABLE, - "busy", - Some(&correlation_id), - ); - } - }; - - let database = state.database.clone(); - let registry = Arc::clone(&state.registry); - let cancellation = Cancellation::new(); - let worker_cancellation = cancellation.clone(); - let mut cancel_on_drop = CancelOnDrop(Some(cancellation)); - let worker = tokio::task::spawn_blocking(move || { - let _permit = permit; - crate::libpq::execute( - &database, - registry, - &request, - token.into_secret(), - &worker_cancellation, - ) - }); - let result = worker.await; - cancel_on_drop.0 = None; - match result { - Ok(Ok(result)) => success_response(result), - Ok(Err(error)) => database_error(error, &correlation_id), - Err(_) => error_response( - StatusCode::SERVICE_UNAVAILABLE, - "unavailable", - Some(&correlation_id), - ), - } -} - -fn parse_envelope<'a>(headers: &'a HeaderMap, body: &'a [u8]) -> Result, ()> { - let version = exactly_one_header(headers, "x-executor-version")?; - let timestamp = exactly_one_header(headers, "x-executor-timestamp")? - .parse() - .map_err(|_| ())?; - let nonce = exactly_one_header(headers, "x-executor-nonce")?; - let signature = exactly_one_header(headers, "x-executor-signature")?; - Ok(SignedRequest { - method: "POST", - path: crate::auth::EXECUTE_PATH, - version, - timestamp, - nonce, - body, - signature, - }) -} - -fn exactly_one_header<'a>(headers: &'a HeaderMap, name: &'static str) -> Result<&'a str, ()> { - let mut values = headers.get_all(name).iter(); - let value = values.next().ok_or(())?; - if values.next().is_some() { - return Err(()); - } - value.to_str().map_err(|_| ()) -} - -fn protocol_error(error: ProtocolError) -> Response { - match error { - ProtocolError::LimitExceeded => { - error_response(StatusCode::PAYLOAD_TOO_LARGE, "budget_exceeded", None) - } - ProtocolError::InvalidRequest | ProtocolError::ConfirmationRequired => { - error_response(StatusCode::BAD_REQUEST, "invalid_request", None) - } - } -} - -fn issuer_error(error: IssuerError, correlation_id: &str) -> Response { - let (status, category) = match error { - IssuerError::CredentialExpiresTooSoon => (StatusCode::UNAUTHORIZED, "unauthorized"), - IssuerError::InvalidPrincipal => (StatusCode::BAD_REQUEST, "invalid_request"), - IssuerError::InvalidConfiguration | IssuerError::SigningFailed => { - (StatusCode::SERVICE_UNAVAILABLE, "unavailable") - } - }; - error_response(status, category, Some(correlation_id)) -} - -fn database_error(error: DatabaseError, correlation_id: &str) -> Response { - eprintln!("executor request failed: {}", error.stage.code()); - let (status, category) = match error.kind { - DatabaseErrorKind::InvalidRequest => (StatusCode::BAD_REQUEST, "invalid_request"), - DatabaseErrorKind::Unavailable => (StatusCode::SERVICE_UNAVAILABLE, "unavailable"), - DatabaseErrorKind::Rejected => (StatusCode::UNPROCESSABLE_ENTITY, "database_rejected"), - DatabaseErrorKind::BudgetExceeded => (StatusCode::PAYLOAD_TOO_LARGE, "budget_exceeded"), - DatabaseErrorKind::DeadlineExceeded => (StatusCode::GATEWAY_TIMEOUT, "deadline_exceeded"), - }; - let error = ErrorBody { - category, - message: "request could not be completed", - correlation_id: Some(correlation_id), - sqlstate_class: error.sqlstate_class.as_deref(), - }; - (status, Json(ErrorEnvelope { error })).into_response() -} - -fn success_response(result: ExecutionResult) -> Response { - (StatusCode::OK, Json(SuccessEnvelope { result })).into_response() -} - -fn error_response( - status: StatusCode, - category: &'static str, - correlation_id: Option<&str>, -) -> Response { - let error = ErrorBody { - category, - message: "request could not be completed", - correlation_id, - sqlstate_class: None, - }; - (status, Json(ErrorEnvelope { error })).into_response() -} - -#[derive(Serialize)] -struct SuccessEnvelope { - result: ExecutionResult, -} - -#[derive(Serialize)] -struct ErrorEnvelope<'a> { - error: ErrorBody<'a>, -} - -#[derive(Serialize)] -struct ErrorBody<'a> { - category: &'a str, - message: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - correlation_id: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - sqlstate_class: Option<&'a str>, -} - -struct CancelOnDrop(Option); - -impl Drop for CancelOnDrop { - fn drop(&mut self) { - if let Some(cancellation) = &self.0 { - cancellation.cancel(); - } - } -} - -fn required_environment(name: &str) -> Result { - env::var(name).map_err(|_| { - IoError::new( - ErrorKind::InvalidInput, - format!("required configuration is unavailable: {name}"), - ) - }) -} - -fn unix_time() -> Result { - let seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| ())? - .as_secs(); - i64::try_from(seconds).map_err(|_| ()) -} - -fn invalid_input(message: &'static str) -> IoError { - IoError::new(ErrorKind::InvalidInput, message) -} - -fn startup_stage(result: Result, stage: &'static str) -> Result> { - result.map_err(|_| { - eprintln!("executor startup failed: {stage}"); - Box::new(invalid_input("executor startup failed")) as Box - }) -} diff --git a/executor/src/token_registry.rs b/executor/src/token_registry.rs deleted file mode 100644 index 7f5cca0..0000000 --- a/executor/src/token_registry.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::collections::HashMap; -use std::fmt; -use std::num::NonZeroUsize; -use std::sync::Mutex; - -use zeroize::Zeroizing; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ConnectionId(NonZeroUsize); - -impl ConnectionId { - #[must_use] - pub const fn new(value: usize) -> Option { - match NonZeroUsize::new(value) { - Some(value) => Some(Self(value)), - None => None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TokenRegistryError { - InvalidCapacity, - DuplicateConnection, - UnknownConnection, - CapacityExceeded, - InvalidToken, - Unavailable, -} - -pub struct ConnectionTokenRegistry { - capacity: usize, - tokens: Mutex>>, -} - -impl ConnectionTokenRegistry { - pub fn with_capacity(capacity: usize) -> Result { - if capacity == 0 { - return Err(TokenRegistryError::InvalidCapacity); - } - Ok(Self { - capacity, - tokens: Mutex::new(HashMap::with_capacity(capacity)), - }) - } - - pub fn register( - &self, - connection: ConnectionId, - token: String, - ) -> Result<(), TokenRegistryError> { - self.register_secret(connection, Zeroizing::new(token)) - } - - pub(crate) fn register_secret( - &self, - connection: ConnectionId, - token: Zeroizing, - ) -> Result<(), TokenRegistryError> { - if token.is_empty() || token.as_bytes().contains(&0) { - return Err(TokenRegistryError::InvalidToken); - } - let mut tokens = self - .tokens - .lock() - .map_err(|_| TokenRegistryError::Unavailable)?; - if tokens.contains_key(&connection) { - return Err(TokenRegistryError::DuplicateConnection); - } - if tokens.len() >= self.capacity { - return Err(TokenRegistryError::CapacityExceeded); - } - tokens.insert(connection, token); - Ok(()) - } - - pub fn claim(&self, connection: ConnectionId) -> Result { - let mut tokens = self - .tokens - .lock() - .map_err(|_| TokenRegistryError::Unavailable)?; - tokens - .remove(&connection) - .map(ClaimedToken) - .ok_or(TokenRegistryError::UnknownConnection) - } - - pub fn cleanup(&self, connection: ConnectionId) -> bool { - self.tokens - .lock() - .is_ok_and(|mut tokens| tokens.remove(&connection).is_some()) - } - - #[must_use] - pub fn len(&self) -> usize { - self.tokens.lock().map_or(usize::MAX, |tokens| tokens.len()) - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -impl fmt::Debug for ConnectionTokenRegistry { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("ConnectionTokenRegistry") - .finish_non_exhaustive() - } -} - -#[derive(PartialEq, Eq)] -pub struct ClaimedToken(Zeroizing); - -impl ClaimedToken { - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - pub(crate) fn into_secret(self) -> Zeroizing { - self.0 - } -} - -impl fmt::Debug for ClaimedToken { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("ClaimedToken([REDACTED])") - } -} diff --git a/executor/tests/hmac_envelope.rs b/executor/tests/hmac_envelope.rs deleted file mode 100644 index 463ba69..0000000 --- a/executor/tests/hmac_envelope.rs +++ /dev/null @@ -1,206 +0,0 @@ -use mtmpg_executor::auth::{ - AUTH_WINDOW_SECONDS, AuthenticationError, EXECUTE_PATH, HmacAuthenticator, SignedRequest, - WIRE_VERSION, -}; - -const NOW: i64 = 1_800_000_000; -const NONCE: &str = "00112233445566778899aabbccddeeff"; -const BODY: &[u8] = br#"{"principal":{"user_id":"usr_01"}}"#; -const SIGNATURE: &str = "51ea06b86989ea11d634b6a4c25f00da8e1ddd50d21e4b6da9a77721b0da8d25"; - -fn authenticator() -> HmacAuthenticator { - HmacAuthenticator::new(vec![0x0b; 32], 16).expect("valid test HMAC configuration") -} - -fn request<'a>( - method: &'a str, - path: &'a str, - version: &'a str, - timestamp: i64, - nonce: &'a str, - body: &'a [u8], - signature: &'a str, -) -> SignedRequest<'a> { - SignedRequest { - method, - path, - version, - timestamp, - nonce, - body, - signature, - } -} - -#[test] -fn accepts_the_fixed_canonical_hmac_vector_once() { - let authenticator = authenticator(); - let signed = request( - "POST", - EXECUTE_PATH, - WIRE_VERSION, - NOW, - NONCE, - BODY, - SIGNATURE, - ); - - assert_eq!(authenticator.verify(&signed, NOW), Ok(())); - assert_eq!( - authenticator.verify(&signed, NOW), - Err(AuthenticationError::Unauthorized) - ); -} - -#[test] -fn timestamp_window_is_inclusive_and_fails_closed_outside_it() { - let at_past_boundary = authenticator(); - let signed = request( - "POST", - EXECUTE_PATH, - WIRE_VERSION, - NOW, - NONCE, - BODY, - SIGNATURE, - ); - assert_eq!( - at_past_boundary.verify(&signed, NOW + AUTH_WINDOW_SECONDS), - Ok(()) - ); - - let outside_past_boundary = authenticator(); - assert_eq!( - outside_past_boundary.verify(&signed, NOW + AUTH_WINDOW_SECONDS + 1), - Err(AuthenticationError::Unauthorized) - ); - - let at_future_boundary = authenticator(); - assert_eq!( - at_future_boundary.verify(&signed, NOW - AUTH_WINDOW_SECONDS), - Ok(()) - ); - - let outside_future_boundary = authenticator(); - assert_eq!( - outside_future_boundary.verify(&signed, NOW - AUTH_WINDOW_SECONDS - 1), - Err(AuthenticationError::Unauthorized) - ); -} - -#[test] -fn every_authenticated_component_is_covered_by_the_signature() { - let mutations = [ - request( - "GET", - EXECUTE_PATH, - WIRE_VERSION, - NOW, - NONCE, - BODY, - SIGNATURE, - ), - request( - "POST", - "/v1/other", - WIRE_VERSION, - NOW, - NONCE, - BODY, - SIGNATURE, - ), - request("POST", EXECUTE_PATH, "v2", NOW, NONCE, BODY, SIGNATURE), - request( - "POST", - EXECUTE_PATH, - WIRE_VERSION, - NOW + 1, - NONCE, - BODY, - SIGNATURE, - ), - request( - "POST", - EXECUTE_PATH, - WIRE_VERSION, - NOW, - "ffeeddccbbaa99887766554433221100", - BODY, - SIGNATURE, - ), - request( - "POST", - EXECUTE_PATH, - WIRE_VERSION, - NOW, - NONCE, - br#"{"principal":{"user_id":"usr_02"}}"#, - SIGNATURE, - ), - ]; - - for mutation in mutations { - assert_eq!( - authenticator().verify(&mutation, NOW), - Err(AuthenticationError::Unauthorized) - ); - } -} - -#[test] -fn malformed_nonce_and_signature_share_the_unauthorized_result() { - for (nonce, signature) in [ - ("001122", SIGNATURE), - ("00112233445566778899AABBCCDDEEFF", SIGNATURE), - (NONCE, "not-hex"), - (NONCE, "00"), - ] { - let signed = request( - "POST", - EXECUTE_PATH, - WIRE_VERSION, - NOW, - nonce, - BODY, - signature, - ); - assert_eq!( - authenticator().verify(&signed, NOW), - Err(AuthenticationError::Unauthorized) - ); - } -} - -#[test] -fn a_full_replay_store_fails_closed_without_evicting_live_nonces() { - let authenticator = - HmacAuthenticator::new(vec![0x0b; 32], 1).expect("valid bounded replay configuration"); - let first = request( - "POST", - EXECUTE_PATH, - WIRE_VERSION, - NOW, - NONCE, - BODY, - SIGNATURE, - ); - assert_eq!(authenticator.verify(&first, NOW), Ok(())); - - let second = request( - "POST", - EXECUTE_PATH, - WIRE_VERSION, - NOW, - "ffeeddccbbaa99887766554433221100", - BODY, - "5e906b0b3a901b21f03f9e62d326e08a6f3114e70452dc465cd1bdd559a150b8", - ); - assert_eq!( - authenticator.verify(&second, NOW), - Err(AuthenticationError::Unauthorized) - ); - assert_eq!( - authenticator.verify(&first, NOW), - Err(AuthenticationError::Unauthorized) - ); -} diff --git a/executor/tests/image-readiness.sh b/executor/tests/image-readiness.sh deleted file mode 100755 index 7b96f72..0000000 --- a/executor/tests/image-readiness.sh +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -umask 077 -export LC_ALL=C - -fail() { - printf 'executor image readiness: %s\n' "$1" >&2 - exit 2 -} - -test "${GITHUB_ACTIONS:-}" = "true" || fail "run this gate through GitHub Actions" -test "$#" -eq 4 || fail "expected image, source, version, and integration artifacts" -command -v curl >/dev/null || fail "curl is unavailable" -command -v docker >/dev/null || fail "Docker is unavailable" - -readonly IMAGE="$1" -readonly SOURCE="$2" -readonly VERSION="$3" -ARTIFACT_ROOT="$(realpath -- "$4")" || fail "integration artifacts cannot be resolved" -readonly ARTIFACT_ROOT -test -d "${ARTIFACT_ROOT}" || fail "integration artifacts are unavailable" -test -n "${PGGOMTM_POSTGRES_IMAGE:-}" || fail "resolved PostgreSQL runtime is unavailable" - -readonly RUNNER_TEMP_ROOT="${RUNNER_TEMP:?RUNNER_TEMP is unavailable}" -RUNTIME_ROOT="$(mktemp --directory "${RUNNER_TEMP_ROOT}/executor-image-runtime.XXXXXX")" -INSPECTION_CONTAINER="" -SERVICE_CONTAINER="" - -cleanup() { - if test -n "${SERVICE_CONTAINER}"; then - docker rm --force "${SERVICE_CONTAINER}" >/dev/null 2>&1 || true - fi - if test -n "${INSPECTION_CONTAINER}"; then - docker rm --force "${INSPECTION_CONTAINER}" >/dev/null 2>&1 || true - fi - if test -d "${RUNTIME_ROOT}"; then - sudo rm -rf -- "${RUNTIME_ROOT}" - fi -} -trap cleanup EXIT - -test "$(docker image inspect --format '{{.Config.User}}' "${IMAGE}")" = "10001:10001" || \ - fail "image does not use the fixed non-root identity" -test "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "${IMAGE}")" = "${SOURCE}" || \ - fail "image source label does not match" -test "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "${IMAGE}")" = "${VERSION}" || \ - fail "image version label does not match" - -if docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "${IMAGE}" \ - | grep --extended-regexp --quiet 'MTMPG_EXECUTOR|SECRET|TOKEN|PRIVATE_KEY|DATABASE_URL'; then - fail "image config contains runtime credential material" -fi - -docker run --rm --user 0:0 --entrypoint /bin/sh "${IMAGE}" -ec ' - test -x /usr/local/bin/mtmpg-executor - test -f /usr/share/doc/mtmpg-executor/LICENSE - test ! -e /usr/local/cargo - test ! -e /src - test ! -e /tests - test ! -e /var/lib/postgresql - test ! -e /usr/lib/postgresql/18/lib/pggomtm.so - ! command -v cargo >/dev/null 2>&1 - ! command -v rustc >/dev/null 2>&1 - ! command -v cc >/dev/null 2>&1 - ! command -v clang >/dev/null 2>&1 - ! command -v pg_config >/dev/null 2>&1 - ! command -v postgres >/dev/null 2>&1 - ! command -v initdb >/dev/null 2>&1 - ! command -v pg_ctl >/dev/null 2>&1 - test ! -e /run/executor/hmac.secret - test ! -e /run/executor/signing-key.pem - test ! -e /run/executor/jwks.json -' || fail "image contains build, source, test, server, validator, or secret material" - -linkage="$(docker run --rm --entrypoint /usr/bin/ldd "${IMAGE}" /usr/local/bin/mtmpg-executor 2>&1)" || \ - fail "executor dynamic linkage cannot be inspected" -if grep --quiet 'not found' <<<"${linkage}"; then - fail "executor has unresolved dynamic dependencies" -fi -if ! grep --quiet 'libpq[.]so[.]5' <<<"${linkage}"; then - fail "executor is not linked to the required libpq runtime" -fi - -install -d -m 0700 "${RUNTIME_ROOT}/mount" -install -m 0400 \ - "${ARTIFACT_ROOT}/runtime/hmac.secret" \ - "${ARTIFACT_ROOT}/runtime/signing-key.pem" \ - "${ARTIFACT_ROOT}/runtime/executor.key" \ - "${RUNTIME_ROOT}/mount" -install -m 0444 \ - "${ARTIFACT_ROOT}/runtime/ca.crt" \ - "${ARTIFACT_ROOT}/runtime/executor.crt" \ - "${RUNTIME_ROOT}/mount" -sudo chown -R 10001:10001 "${RUNTIME_ROOT}/mount" -sudo chmod 0500 "${RUNTIME_ROOT}/mount" - -SERVICE_CONTAINER="$(docker run --detach \ - --read-only \ - --cap-drop ALL \ - --security-opt no-new-privileges \ - --pids-limit 64 \ - --mount "type=bind,source=${RUNTIME_ROOT}/mount,target=/run/executor,readonly" \ - --publish 127.0.0.1::8443 \ - --env MTMPG_EXECUTOR_AUDIENCE=https://postgres.example.test/database/main \ - --env MTMPG_EXECUTOR_HMAC_SECRET_PATH=/run/executor/hmac.secret \ - --env MTMPG_EXECUTOR_ISSUER=https://auth.example.test/database \ - --env MTMPG_EXECUTOR_KEY_ID=executor-es256-test \ - --env MTMPG_EXECUTOR_LISTEN=0.0.0.0:8443 \ - --env MTMPG_EXECUTOR_POSTGRES_CA_PATH=/run/executor/ca.crt \ - --env MTMPG_EXECUTOR_SIGNING_KEY_PATH=/run/executor/signing-key.pem \ - --env MTMPG_EXECUTOR_TLS_CERT_PATH=/run/executor/executor.crt \ - --env MTMPG_EXECUTOR_TLS_KEY_PATH=/run/executor/executor.key \ - "${IMAGE}")" || fail "image did not start" - -host_port="$(docker inspect --format '{{(index (index .NetworkSettings.Ports "8443/tcp") 0).HostPort}}' "${SERVICE_CONTAINER}")" -test -n "${host_port}" || fail "HTTPS port was not published" -ready=0 -curl_status=0 -for _ in $(seq 1 80); do - if curl --fail --silent --show-error \ - --noproxy '*' \ - --cacert "${ARTIFACT_ROOT}/runtime/ca.crt" \ - --resolve "executor:${host_port}:127.0.0.1" \ - "https://executor:${host_port}/ready" >/dev/null 2>&1; then - ready=1 - break - else - curl_status=$? - fi - if ! docker inspect --format '{{.State.Running}}' "${SERVICE_CONTAINER}" | grep --quiet '^true$'; then - break - fi - sleep 0.25 -done -docker logs "${SERVICE_CONTAINER}" >"${RUNTIME_ROOT}/service.log" 2>&1 -if test "${ready}" -ne 1; then - for startup_stage in \ - hmac \ - signing_key \ - issuer \ - token_registry \ - libpq \ - database_tls \ - listen \ - https_tls \ - https_server; do - if grep --quiet "^executor startup failed: ${startup_stage}$" "${RUNTIME_ROOT}/service.log"; then - fail "image exited during ${startup_stage} startup" - fi - done - fail "image HTTPS readiness failed with curl status ${curl_status}" -fi - -hmac_secret="$(tr -d '\n' <"${ARTIFACT_ROOT}/runtime/hmac.secret")" -if test -n "${hmac_secret}" && grep --fixed-strings --quiet "${hmac_secret}" "${RUNTIME_ROOT}/service.log"; then - fail "service log disclosed the HMAC secret" -fi -if grep --quiet 'BEGIN .*PRIVATE KEY' "${RUNTIME_ROOT}/service.log"; then - fail "service log disclosed the signing key" -fi -docker rm --force "${SERVICE_CONTAINER}" >/dev/null -SERVICE_CONTAINER="" - -INSPECTION_CONTAINER="$(docker create "${IMAGE}")" -docker cp \ - "${INSPECTION_CONTAINER}:/usr/local/bin/mtmpg-executor" \ - "${ARTIFACT_ROOT}/mtmpg-executor" -chmod 0755 "${ARTIFACT_ROOT}/mtmpg-executor" - -GITHUB_ACTIONS=true \ -PGGOMTM_POSTGRES_IMAGE="${PGGOMTM_POSTGRES_IMAGE}" \ - tests/postgres_integration.sh run-executor "${ARTIFACT_ROOT}" - -printf 'Executor final image readiness and PG18 matrix passed\n' diff --git a/executor/tests/issuer.rs b/executor/tests/issuer.rs deleted file mode 100644 index b01acf3..0000000 --- a/executor/tests/issuer.rs +++ /dev/null @@ -1,194 +0,0 @@ -use jaws::key::JsonWebKey; -use p256::ecdsa::SigningKey; -use pggomtm::database_auth::{ - AuthMethod, AuthenticatedActor, DatabaseProfile, DatabaseTokenPolicy, DatabaseTokenVerifier, -}; -use serde_json::{Value, json}; - -use mtmpg_executor::issuer::{ - DATABASE_TOKEN_TTL_SECONDS, DatabaseTokenIssuer, IssuerConfig, IssuerError, -}; -use mtmpg_executor::protocol::DelegatedPrincipal; - -const NOW: i64 = 1_800_000_000; -const ISSUER: &str = "https://auth.example.test/database"; -const AUDIENCE: &str = "https://postgres.example.test/database/main"; -const KID: &str = "executor-es256-test"; - -fn signing_key() -> SigningKey { - SigningKey::from_slice(&[9_u8; 32]).expect("fixed synthetic signing key") -} - -fn verifier(key: &SigningKey) -> DatabaseTokenVerifier { - let mut jwk = - serde_json::to_value(JsonWebKey::build(key.verifying_key())).expect("serialize public JWK"); - let object = jwk.as_object_mut().expect("JWK object"); - object.insert("alg".into(), json!("ES256")); - object.insert("key_ops".into(), json!(["verify"])); - object.insert("kid".into(), json!(KID)); - object.insert("use".into(), json!("sig")); - let jwks = serde_json::to_string(&json!({"keys": [jwk]})).expect("serialize JWKS"); - let policy = DatabaseTokenPolicy::new(ISSUER, AUDIENCE).expect("valid policy"); - DatabaseTokenVerifier::from_jwks(&jwks, policy).expect("valid verifier") -} - -fn issuer() -> DatabaseTokenIssuer { - let config = IssuerConfig::new(ISSUER, AUDIENCE, KID).expect("valid issuer config"); - DatabaseTokenIssuer::new(config, signing_key()) -} - -fn principal(method: AuthMethod, profile: DatabaseProfile) -> DelegatedPrincipal { - let (client_id, credential_id) = match method { - AuthMethod::OAuth => (Some("cli_01".into()), None), - AuthMethod::ApiKey => (None, Some("crd_01".into())), - }; - DelegatedPrincipal { - user_id: "usr_01".into(), - client_id, - credential_id, - delegation_id: "dlg_01".into(), - auth_method: method, - authority_version: 7, - database_scope: "database".into(), - profile, - credential_expires_at: Some(NOW + 300), - } -} - -#[test] -fn issues_exact_thirty_second_tokens_for_every_actor_and_generic_profile() { - let key = signing_key(); - let verifier = verifier(&key); - let issuer = { - let config = IssuerConfig::new(ISSUER, AUDIENCE, KID).expect("valid issuer config"); - DatabaseTokenIssuer::new(config, key) - }; - - for method in [AuthMethod::OAuth, AuthMethod::ApiKey] { - for profile in [ - DatabaseProfile::Ordinary, - DatabaseProfile::BusinessAdmin, - DatabaseProfile::DatabaseDeveloper, - ] { - let principal = principal(method, profile); - let token = issuer.issue(&principal, NOW).expect("database token"); - let verified = verifier - .verify(token.as_str(), profile.database_role(), NOW) - .expect("validator accepts executor token"); - - assert_eq!(verified.claims.issuer, ISSUER); - assert_eq!(verified.claims.audience, AUDIENCE); - assert_eq!(verified.claims.subject, principal.user_id); - assert_eq!(verified.claims.issued_at, NOW); - assert_eq!(verified.claims.expires_at, NOW + DATABASE_TOKEN_TTL_SECONDS); - assert_eq!(verified.claims.scope, "database"); - assert_eq!(verified.claims.delegation_id, principal.delegation_id); - assert_eq!(verified.claims.auth_method, method); - assert_eq!( - verified.claims.authority_version, - principal.authority_version - ); - assert_eq!(verified.claims.db_profile, profile); - assert_eq!(verified.claims.db_role, profile.database_role()); - assert_eq!( - verified.identity.actor, - match method { - AuthMethod::OAuth => AuthenticatedActor::OAuthClient("cli_01".into()), - AuthMethod::ApiKey => { - AuthenticatedActor::ApiKeyCredential("crd_01".into()) - } - } - ); - assert_eq!(verified.claims.token_id.len(), 32); - assert!( - verified - .claims - .token_id - .bytes() - .all(|byte| byte.is_ascii_hexdigit()) - ); - } - } -} - -#[test] -fn credential_must_cover_the_complete_token_lifetime() { - let issuer = issuer(); - let mut too_short = principal(AuthMethod::OAuth, DatabaseProfile::Ordinary); - too_short.credential_expires_at = Some(NOW + DATABASE_TOKEN_TTL_SECONDS - 1); - assert_eq!( - issuer.issue(&too_short, NOW), - Err(IssuerError::CredentialExpiresTooSoon) - ); - - let mut exact = too_short; - exact.credential_expires_at = Some(NOW + DATABASE_TOKEN_TTL_SECONDS); - assert!(issuer.issue(&exact, NOW).is_ok()); -} - -#[test] -fn non_expiring_api_key_can_issue_but_oauth_cannot_omit_expiry() { - let issuer = issuer(); - let mut api_key = principal(AuthMethod::ApiKey, DatabaseProfile::Ordinary); - api_key.credential_expires_at = None; - assert!(issuer.issue(&api_key, NOW).is_ok()); - - let mut oauth = principal(AuthMethod::OAuth, DatabaseProfile::Ordinary); - oauth.credential_expires_at = None; - assert_eq!( - issuer.issue(&oauth, NOW), - Err(IssuerError::InvalidPrincipal) - ); -} - -#[test] -fn invalid_actor_or_caller_claim_shape_never_reaches_signing() { - let issuer = issuer(); - let mut both = principal(AuthMethod::OAuth, DatabaseProfile::Ordinary); - both.credential_id = Some("crd_01".into()); - assert_eq!(issuer.issue(&both, NOW), Err(IssuerError::InvalidPrincipal)); - - let mut wrong_scope = principal(AuthMethod::OAuth, DatabaseProfile::Ordinary); - wrong_scope.database_scope = "administrator".into(); - assert_eq!( - issuer.issue(&wrong_scope, NOW), - Err(IssuerError::InvalidPrincipal) - ); -} - -#[test] -fn issuer_configuration_is_strict_and_does_not_accept_ambiguous_resources() { - for (issuer, audience, kid) in [ - ("http://auth.example.test/database", AUDIENCE, KID), - (ISSUER, ISSUER, KID), - (ISSUER, AUDIENCE, ""), - (ISSUER, AUDIENCE, "kid with spaces"), - ] { - assert_eq!( - IssuerConfig::new(issuer, audience, kid), - Err(IssuerError::InvalidConfiguration) - ); - } -} - -#[test] -fn issued_token_debug_output_is_redacted() { - let token = issuer() - .issue( - &principal(AuthMethod::OAuth, DatabaseProfile::Ordinary), - NOW, - ) - .expect("database token"); - let rendered = format!("{token:?}"); - assert!(!rendered.contains(token.as_str())); - assert!(!rendered.contains("eyJ")); -} - -#[test] -fn public_jwk_fixture_contains_no_private_material() { - let value = serde_json::to_value(JsonWebKey::build(signing_key().verifying_key())) - .expect("serialize public key"); - let object = value.as_object().expect("public JWK object"); - assert_eq!(object.get("kty"), Some(&Value::String("EC".into()))); - assert!(!object.contains_key("d")); -} diff --git a/executor/tests/libpq_abi.rs b/executor/tests/libpq_abi.rs deleted file mode 100644 index f5649cd..0000000 --- a/executor/tests/libpq_abi.rs +++ /dev/null @@ -1,19 +0,0 @@ -use mtmpg_executor::libpq::{ClientAbiError, current_client_abi}; - -#[test] -fn generated_client_binding_matches_the_current_postgresql_18_header() { - let abi = current_client_abi().expect("generated PostgreSQL 18 client ABI"); - assert_eq!(abi.postgresql_major, 18); - assert!(abi.oauth_bearer_auth_data_hook); - assert!(abi.async_cancel); - assert!(abi.extended_query); - assert!(abi.socket_poll); -} - -#[test] -fn unsupported_or_missing_client_abi_fails_closed() { - assert_ne!( - current_client_abi(), - Err(ClientAbiError::UnsupportedPostgresMajor) - ); -} diff --git a/executor/tests/libpq_layout_probe.c b/executor/tests/libpq_layout_probe.c deleted file mode 100644 index 64c4372..0000000 --- a/executor/tests/libpq_layout_probe.c +++ /dev/null @@ -1,72 +0,0 @@ -#include - -#include -#include - -#define TYPE_MATCHES(expression, type) \ - _Generic((expression), type: 1, default: 0) - -typedef PostgresPollingStatusType (*ExpectedOAuthAsync)( - PGconn *, PGoauthBearerRequest *, int *); -typedef void (*ExpectedOAuthCleanup)(PGconn *, PGoauthBearerRequest *); - -_Static_assert(sizeof(PGauthData) == sizeof(int), - "unexpected PGauthData representation"); -_Static_assert(PQAUTHDATA_OAUTH_BEARER_TOKEN != PQAUTHDATA_PROMPT_OAUTH_DEVICE, - "OAuth Bearer auth data must have a distinct type"); - -_Static_assert(sizeof(PGoauthBearerRequest) == 48, - "unexpected PGoauthBearerRequest size"); -_Static_assert(offsetof(PGoauthBearerRequest, openid_configuration) == 0, - "unexpected openid_configuration offset"); -_Static_assert(offsetof(PGoauthBearerRequest, scope) == 8, - "unexpected scope offset"); -_Static_assert(offsetof(PGoauthBearerRequest, async) == 16, - "unexpected async offset"); -_Static_assert(offsetof(PGoauthBearerRequest, cleanup) == 24, - "unexpected cleanup offset"); -_Static_assert(offsetof(PGoauthBearerRequest, token) == 32, - "unexpected token offset"); -_Static_assert(offsetof(PGoauthBearerRequest, user) == 40, - "unexpected user offset"); -_Static_assert(TYPE_MATCHES(((PGoauthBearerRequest *) 0)->async, - ExpectedOAuthAsync), - "unexpected OAuth async callback signature"); -_Static_assert(TYPE_MATCHES(((PGoauthBearerRequest *) 0)->cleanup, - ExpectedOAuthCleanup), - "unexpected OAuth cleanup callback signature"); - -_Static_assert(TYPE_MATCHES(&PQsetAuthDataHook, - void (*)(PQauthDataHook_type)), - "unexpected PQsetAuthDataHook signature"); -_Static_assert(TYPE_MATCHES(&PQgetAuthDataHook, - PQauthDataHook_type (*)(void)), - "unexpected PQgetAuthDataHook signature"); -_Static_assert(TYPE_MATCHES(&PQconnectStartParams, - PGconn *(*)(const char *const *, - const char *const *, int)), - "unexpected PQconnectStartParams signature"); -_Static_assert(TYPE_MATCHES(&PQconnectPoll, - PostgresPollingStatusType (*)(PGconn *)), - "unexpected PQconnectPoll signature"); -_Static_assert(TYPE_MATCHES(&PQsendQueryParams, - int (*)(PGconn *, const char *, int, const Oid *, - const char *const *, const int *, const int *, - int)), - "unexpected PQsendQueryParams signature"); -_Static_assert(TYPE_MATCHES(&PQcancelCreate, - PGcancelConn *(*)(PGconn *)), - "unexpected PQcancelCreate signature"); -_Static_assert(TYPE_MATCHES(&PQcancelBlocking, int (*)(PGcancelConn *)), - "unexpected PQcancelBlocking signature"); -_Static_assert(TYPE_MATCHES(&PQcancelFinish, void (*)(PGcancelConn *)), - "unexpected PQcancelFinish signature"); -_Static_assert(TYPE_MATCHES(&PQsocketPoll, - int (*)(int, int, int, pg_usec_time_t)), - "unexpected PQsocketPoll signature"); - -int -main(void) -{ - return 0; -} diff --git a/executor/tests/postgres_setup.sql b/executor/tests/postgres_setup.sql deleted file mode 100644 index 9c92f2f..0000000 --- a/executor/tests/postgres_setup.sql +++ /dev/null @@ -1,38 +0,0 @@ -\set ON_ERROR_STOP on - -CREATE ROLE ordinary LOGIN; -CREATE ROLE business_admin LOGIN; -CREATE ROLE database_developer LOGIN; -CREATE DATABASE gomtm; - -\connect gomtm - -CREATE SCHEMA app; -CREATE TABLE app.executor_probe ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - value text NOT NULL UNIQUE -); - -CREATE PROCEDURE app.record_probe(input text) -LANGUAGE sql -AS $$ - INSERT INTO app.executor_probe(value) VALUES (input); -$$; - -CREATE PROCEDURE app.fail_after_insert() -LANGUAGE plpgsql -AS $$ -BEGIN - INSERT INTO app.executor_probe(value) VALUES ('failed'); - RAISE EXCEPTION 'synthetic integration failure'; -END; -$$; - -GRANT CONNECT ON DATABASE gomtm TO ordinary, business_admin, database_developer; -GRANT USAGE ON SCHEMA app TO ordinary, business_admin, database_developer; -GRANT SELECT ON app.executor_probe TO ordinary; -GRANT SELECT, INSERT ON app.executor_probe TO business_admin; -GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA app TO database_developer; -GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO business_admin, database_developer; -GRANT EXECUTE ON PROCEDURE app.record_probe(text) TO business_admin; -GRANT EXECUTE ON PROCEDURE app.fail_after_insert() TO business_admin; diff --git a/executor/tests/protocol.rs b/executor/tests/protocol.rs deleted file mode 100644 index 699652c..0000000 --- a/executor/tests/protocol.rs +++ /dev/null @@ -1,282 +0,0 @@ -use mtmpg_executor::protocol::{ - BindValue, ExecutionIntent, MAX_BIND_COUNT, MAX_BIND_VALUE_BYTES, MAX_REQUEST_BODY_BYTES, - MAX_STATEMENT_BYTES, ProtocolError, parse_execute_request, -}; -use pggomtm::database_auth::{AuthMethod, DatabaseProfile}; -use serde_json::{Map, Value, json}; - -const EXPIRY: i64 = 1_800_000_300; - -fn principal(method: &str, profile: &str) -> Value { - let mut value = json!({ - "user_id": "usr_01", - "delegation_id": "dlg_01", - "auth_method": method, - "authority_version": 7, - "database_scope": "database", - "profile": profile, - "credential_expires_at": EXPIRY, - }); - let object = value.as_object_mut().expect("principal object"); - match method { - "oauth" => { - object.insert("client_id".into(), json!("cli_01")); - } - "api_key" => { - object.insert("credential_id".into(), json!("crd_01")); - } - _ => {} - } - value -} - -fn request_value(principal: Value) -> Value { - json!({ - "principal": principal, - "statement": "SELECT $1::text, $2::bigint, $3::boolean, $4::jsonb, $5::text", - "binds": [ - {"type": "text", "value": "alpha"}, - {"type": "int64", "value": 42}, - {"type": "boolean", "value": true}, - {"type": "json", "value": {"key": "value"}}, - {"type": "null"} - ], - "intent": "read", - "change_confirmed": false, - "correlation_id": "req_01" - }) -} - -fn parse_value(value: &Value) -> Result { - parse_execute_request(&serde_json::to_vec(value).expect("serialize request")) -} - -#[test] -fn oauth_and_api_key_principals_use_one_strict_request_shape() { - for (method, actor, profile) in [ - ("oauth", "cli_01", "ordinary"), - ("api_key", "crd_01", "business_admin"), - ("oauth", "cli_01", "database_developer"), - ] { - let parsed = parse_value(&request_value(principal(method, profile))) - .expect("valid delegated request"); - assert_eq!( - parsed.principal.auth_method, - if method == "oauth" { - AuthMethod::OAuth - } else { - AuthMethod::ApiKey - } - ); - assert_eq!(parsed.principal.actor_id(), actor); - assert_eq!(parsed.principal.profile.database_role(), profile); - assert_eq!(parsed.principal.database_scope, "database"); - assert_eq!(parsed.intent, ExecutionIntent::Read); - assert!(!parsed.change_confirmed); - assert_eq!( - parsed.binds, - vec![ - BindValue::Text("alpha".into()), - BindValue::Int64(42), - BindValue::Boolean(true), - BindValue::Json(json!({"key": "value"})), - BindValue::Null, - ] - ); - } -} - -#[test] -fn non_expiring_api_key_is_valid_but_oauth_requires_credential_expiry() { - let mut api_key = principal("api_key", "ordinary"); - api_key["credential_expires_at"] = Value::Null; - assert!(parse_value(&request_value(api_key)).is_ok()); - - let mut oauth = principal("oauth", "ordinary"); - oauth["credential_expires_at"] = Value::Null; - assert_eq!( - parse_value(&request_value(oauth)), - Err(ProtocolError::InvalidRequest) - ); - - for method in ["api_key", "oauth"] { - let mut missing_expiry = principal(method, "ordinary"); - missing_expiry - .as_object_mut() - .expect("principal object") - .remove("credential_expires_at"); - assert_eq!( - parse_value(&request_value(missing_expiry)), - Err(ProtocolError::InvalidRequest) - ); - } -} - -#[test] -fn unknown_fields_and_all_caller_supplied_credentials_or_claims_are_rejected() { - let base = request_value(principal("oauth", "ordinary")); - let forbidden_top_level = ["statements", "database_jwt", "connection_string"]; - for field in forbidden_top_level { - let mut request = base.clone(); - request - .as_object_mut() - .expect("request object") - .insert(field.into(), json!("forbidden")); - assert_eq!(parse_value(&request), Err(ProtocolError::InvalidRequest)); - } - - let forbidden_principal = [ - "bearer_token", - "api_key", - "password", - "db_role", - "issuer", - "audience", - "claims", - "unknown", - ]; - for field in forbidden_principal { - let mut request = base.clone(); - request["principal"] - .as_object_mut() - .expect("principal object") - .insert(field.into(), json!("forbidden")); - assert_eq!(parse_value(&request), Err(ProtocolError::InvalidRequest)); - } -} - -#[test] -fn principal_actor_method_scope_and_profile_must_be_canonical() { - let mut invalid_principals = Vec::new(); - - let mut both = principal("oauth", "ordinary"); - both.as_object_mut() - .expect("principal object") - .insert("credential_id".into(), json!("crd_01")); - invalid_principals.push(both); - - let mut no_actor = principal("oauth", "ordinary"); - no_actor - .as_object_mut() - .expect("principal object") - .remove("client_id"); - invalid_principals.push(no_actor); - - let mut wrong_actor = principal("oauth", "ordinary"); - let object = wrong_actor.as_object_mut().expect("principal object"); - object.remove("client_id"); - object.insert("credential_id".into(), json!("crd_01")); - invalid_principals.push(wrong_actor); - - let mut wrong_scope = principal("oauth", "ordinary"); - wrong_scope["database_scope"] = json!("admin"); - invalid_principals.push(wrong_scope); - - invalid_principals.push(principal("oauth", "admin")); - invalid_principals.push(principal("interactive", "ordinary")); - - for invalid in invalid_principals { - assert_eq!( - parse_value(&request_value(invalid)), - Err(ProtocolError::InvalidRequest) - ); - } -} - -#[test] -fn statement_bind_and_body_limits_are_enforced_before_execution() { - let mut empty = request_value(principal("oauth", "ordinary")); - empty["statement"] = json!(" \n\t"); - assert_eq!(parse_value(&empty), Err(ProtocolError::InvalidRequest)); - - let mut statement_limit = request_value(principal("oauth", "ordinary")); - statement_limit["statement"] = json!("x".repeat(MAX_STATEMENT_BYTES)); - statement_limit["binds"] = json!([]); - assert!(parse_value(&statement_limit).is_ok()); - statement_limit["statement"] = json!("x".repeat(MAX_STATEMENT_BYTES + 1)); - assert_eq!( - parse_value(&statement_limit), - Err(ProtocolError::LimitExceeded) - ); - - let mut bind_count = request_value(principal("oauth", "ordinary")); - bind_count["statement"] = json!("SELECT 1"); - bind_count["binds"] = Value::Array(vec![json!({"type": "null"}); MAX_BIND_COUNT]); - assert!(parse_value(&bind_count).is_ok()); - bind_count["binds"] = Value::Array(vec![json!({"type": "null"}); MAX_BIND_COUNT + 1]); - assert_eq!(parse_value(&bind_count), Err(ProtocolError::LimitExceeded)); - - let mut bind_value = request_value(principal("oauth", "ordinary")); - bind_value["statement"] = json!("SELECT $1::text"); - bind_value["binds"] = json!([ - {"type": "text", "value": "x".repeat(MAX_BIND_VALUE_BYTES + 1)} - ]); - assert_eq!(parse_value(&bind_value), Err(ProtocolError::LimitExceeded)); - - assert_eq!( - parse_execute_request(&vec![b' '; MAX_REQUEST_BODY_BYTES + 1]), - Err(ProtocolError::LimitExceeded) - ); -} - -#[test] -fn change_requires_current_confirmation_and_read_rejects_change_confirmation() { - let mut change = request_value(principal("api_key", "business_admin")); - change["intent"] = json!("change"); - assert_eq!( - parse_value(&change), - Err(ProtocolError::ConfirmationRequired) - ); - change["change_confirmed"] = json!(true); - assert!(parse_value(&change).is_ok()); - - let mut read = request_value(principal("oauth", "ordinary")); - read["change_confirmed"] = json!(true); - assert_eq!(parse_value(&read), Err(ProtocolError::InvalidRequest)); -} - -#[test] -fn malformed_bind_and_correlation_shapes_are_rejected() { - let mut invalid_bind = request_value(principal("oauth", "ordinary")); - invalid_bind["binds"] = json!([{"type": "text", "value": "ok", "extra": true}]); - assert_eq!( - parse_value(&invalid_bind), - Err(ProtocolError::InvalidRequest) - ); - - let too_long = "x".repeat(129); - for correlation_id in ["", "contains space", "x/y", too_long.as_str()] { - let mut request = request_value(principal("oauth", "ordinary")); - request["correlation_id"] = json!(correlation_id); - assert_eq!(parse_value(&request), Err(ProtocolError::InvalidRequest)); - } - - let mut duplicate_shape = request_value(principal("oauth", "ordinary")); - let object: &mut Map = duplicate_shape.as_object_mut().expect("request object"); - object.insert("statement".into(), Value::Array(vec![json!("SELECT 1")])); - assert_eq!( - parse_value(&duplicate_shape), - Err(ProtocolError::InvalidRequest) - ); -} - -#[test] -fn shared_contract_types_keep_the_three_generic_database_profiles() { - assert_eq!( - serde_json::to_value(AuthMethod::OAuth).expect("serialize OAuth method"), - json!("oauth") - ); - assert_eq!( - serde_json::to_value(AuthMethod::ApiKey).expect("serialize API key method"), - json!("api_key") - ); - assert_eq!(DatabaseProfile::Ordinary.database_role(), "ordinary"); - assert_eq!( - DatabaseProfile::BusinessAdmin.database_role(), - "business_admin" - ); - assert_eq!( - DatabaseProfile::DatabaseDeveloper.database_role(), - "database_developer" - ); -} diff --git a/executor/tests/stage-integration.sh b/executor/tests/stage-integration.sh deleted file mode 100755 index 020f157..0000000 --- a/executor/tests/stage-integration.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -umask 077 -export LC_ALL=C - -REPOSITORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -readonly REPOSITORY_ROOT - -fail() { - printf 'executor stage-integration: %s\n' "$1" >&2 - exit 2 -} - -test "${GITHUB_ACTIONS:-}" = "true" || fail "run this harness through GitHub Actions" -test -x "${PGRX_PG_CONFIG_PATH:-}" || fail "PGRX_PG_CONFIG_PATH is unavailable" -[[ "$("${PGRX_PG_CONFIG_PATH}" --version)" =~ ^PostgreSQL\ 18\. ]] || \ - fail "PGRX_PG_CONFIG_PATH must identify PostgreSQL 18" -command -v openssl >/dev/null || fail "OpenSSL is unavailable" - -test "$#" -eq 2 || fail "expected validator artifact and output directories" -VALIDATOR_ROOT="$(realpath -- "$1")" || fail "validator artifacts cannot be resolved" -test -f "${VALIDATOR_ROOT}/pggomtm.so" || fail "production validator module is unavailable" - -TARGET_ROOT="${CARGO_TARGET_DIR:-${REPOSITORY_ROOT}/target}" -if [[ "${TARGET_ROOT}" != /* ]]; then - TARGET_ROOT="${REPOSITORY_ROOT}/${TARGET_ROOT}" -fi -OUTPUT_ROOT="$2" -if [[ "${OUTPUT_ROOT}" != /* ]]; then - OUTPUT_ROOT="${REPOSITORY_ROOT}/${OUTPUT_ROOT}" -fi -case "${OUTPUT_ROOT}" in - "${TARGET_ROOT}"/*) ;; - *) fail "output directory must be inside CARGO_TARGET_DIR" ;; -esac - -install -d -m 0755 "${TARGET_ROOT}" -STAGING_ROOT="$(mktemp --directory "${TARGET_ROOT}/.executor-integration.XXXXXX")" -cleanup() { - if test -n "${STAGING_ROOT:-}" && test -d "${STAGING_ROOT}"; then - rm -rf -- "${STAGING_ROOT}" - fi -} -trap cleanup EXIT - -cd "${REPOSITORY_ROOT}" -cargo build --locked --release --package mtmpg-executor -cargo build --locked --release --package mtmpg-executor --examples - -install -m 0755 \ - "${TARGET_ROOT}/release/mtmpg-executor" \ - "${STAGING_ROOT}/mtmpg-executor" -install -m 0755 \ - "${TARGET_ROOT}/release/examples/mtmpg_executor_fixture" \ - "${STAGING_ROOT}/mtmpg_executor_fixture" -install -m 0755 \ - "${TARGET_ROOT}/release/examples/mtmpg_executor_pg18_driver" \ - "${STAGING_ROOT}/mtmpg_executor_pg18_driver" -install -m 0644 "${VALIDATOR_ROOT}/pggomtm.so" "${STAGING_ROOT}/pggomtm.so" -install -m 0644 \ - executor/tests/postgres_setup.sql \ - "${STAGING_ROOT}/executor_postgres_setup.sql" - -install -d -m 0700 "${STAGING_ROOT}/runtime" -"${STAGING_ROOT}/mtmpg_executor_fixture" generate "${STAGING_ROOT}/runtime" - -openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 1 \ - -subj '/CN=Executor integration CA' \ - -keyout "${STAGING_ROOT}/runtime/ca.key" \ - -out "${STAGING_ROOT}/runtime/ca.crt" >/dev/null 2>&1 - -generate_leaf() { - local name="$1" - openssl req -newkey rsa:2048 -sha256 -nodes \ - -subj "/CN=${name}" \ - -addext "subjectAltName=DNS:${name}" \ - -keyout "${STAGING_ROOT}/runtime/${name}.key" \ - -out "${STAGING_ROOT}/runtime/${name}.csr" >/dev/null 2>&1 - openssl x509 -req -sha256 -days 1 \ - -in "${STAGING_ROOT}/runtime/${name}.csr" \ - -CA "${STAGING_ROOT}/runtime/ca.crt" \ - -CAkey "${STAGING_ROOT}/runtime/ca.key" \ - -CAcreateserial \ - -copy_extensions copy \ - -out "${STAGING_ROOT}/runtime/${name}.crt" >/dev/null 2>&1 - rm -f -- "${STAGING_ROOT}/runtime/${name}.csr" -} - -generate_leaf postgres -generate_leaf executor -rm -f -- "${STAGING_ROOT}/runtime/ca.key" "${STAGING_ROOT}/runtime/ca.srl" -chmod 0400 \ - "${STAGING_ROOT}/runtime/hmac.secret" \ - "${STAGING_ROOT}/runtime/signing-key.pem" \ - "${STAGING_ROOT}/runtime/postgres.key" \ - "${STAGING_ROOT}/runtime/executor.key" -chmod 0444 \ - "${STAGING_ROOT}/runtime/ca.crt" \ - "${STAGING_ROOT}/runtime/postgres.crt" \ - "${STAGING_ROOT}/runtime/executor.crt" \ - "${STAGING_ROOT}/runtime/jwks.json" \ - "${STAGING_ROOT}/runtime/validator.json" - -chmod 0755 "${STAGING_ROOT}" -rm -rf -- "${OUTPUT_ROOT}" -mv -- "${STAGING_ROOT}" "${OUTPUT_ROOT}" -STAGING_ROOT="" -printf '%s\n' "${OUTPUT_ROOT}" diff --git a/executor/tests/support/executor_fixture.rs b/executor/tests/support/executor_fixture.rs deleted file mode 100644 index 98628d0..0000000 --- a/executor/tests/support/executor_fixture.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::env; -use std::error::Error; -use std::fs::OpenOptions; -use std::io::{Error as IoError, ErrorKind, Write}; -use std::os::unix::fs::OpenOptionsExt; -use std::path::{Path, PathBuf}; - -use jaws::key::JsonWebKey; -use p256::SecretKey; -use p256::ecdsa::SigningKey; -use p256::elliptic_curve::pkcs8::EncodePrivateKey; -use serde_json::json; - -const ISSUER: &str = "https://auth.example.test/database"; -const AUDIENCE: &str = "https://postgres.example.test/database/main"; -const KEY_ID: &str = "executor-es256-test"; -const HMAC_SECRET: &[u8; 32] = &[0x42; 32]; - -fn main() -> Result<(), Box> { - let mut arguments = env::args_os().skip(1); - let command = arguments - .next() - .ok_or_else(|| invalid_input("expected fixture command"))?; - let command = command - .to_str() - .ok_or_else(|| invalid_input("fixture command must be UTF-8"))?; - if command != "generate" { - return Err(invalid_input("unknown fixture command").into()); - } - let output = PathBuf::from( - arguments - .next() - .ok_or_else(|| invalid_input("expected output directory"))?, - ); - if arguments.next().is_some() { - return Err(invalid_input("unexpected fixture argument").into()); - } - generate(&output) -} - -fn generate(output: &Path) -> Result<(), Box> { - if !output.is_dir() { - return Err(invalid_input("fixture output directory is unavailable").into()); - } - - let secret_key = SecretKey::from_slice(&[9_u8; 32])?; - let signing_key = SigningKey::from_slice(&[9_u8; 32])?; - let private_pem = secret_key.to_pkcs8_pem(Default::default())?; - write_private( - &output.join("signing-key.pem"), - private_pem.as_str().as_bytes(), - )?; - write_private(&output.join("hmac.secret"), HMAC_SECRET)?; - - let mut jwk = serde_json::to_value(JsonWebKey::build(signing_key.verifying_key()))?; - let jwk_object = jwk - .as_object_mut() - .ok_or_else(|| invalid_input("public JWK must be an object"))?; - jwk_object.insert("alg".into(), json!("ES256")); - jwk_object.insert("key_ops".into(), json!(["verify"])); - jwk_object.insert("kid".into(), json!(KEY_ID)); - jwk_object.insert("use".into(), json!("sig")); - write_public_json(&output.join("jwks.json"), &json!({"keys": [jwk]}))?; - write_public_json( - &output.join("validator.json"), - &json!({ - "schema": "pggomtm-validator-config/v1", - "issuer": ISSUER, - "audience": AUDIENCE, - "jwks_path": "/etc/pggomtm/jwks.json" - }), - )?; - - println!("generated ephemeral executor integration fixtures"); - Ok(()) -} - -fn write_private(path: &Path, value: &[u8]) -> Result<(), IoError> { - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o400) - .open(path)?; - file.write_all(value)?; - file.sync_all() -} - -fn write_public_json(path: &Path, value: &serde_json::Value) -> Result<(), Box> { - let encoded = serde_json::to_vec(value)?; - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o444) - .open(path)?; - file.write_all(&encoded)?; - file.sync_all()?; - Ok(()) -} - -fn invalid_input(message: &'static str) -> IoError { - IoError::new(ErrorKind::InvalidInput, message) -} diff --git a/executor/tests/support/pg18_driver.rs b/executor/tests/support/pg18_driver.rs deleted file mode 100644 index 6ab78b2..0000000 --- a/executor/tests/support/pg18_driver.rs +++ /dev/null @@ -1,505 +0,0 @@ -use std::error::Error; -use std::fs; -use std::io::{Error as IoError, ErrorKind}; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use hmac::{Hmac, KeyInit, Mac}; -use reqwest::blocking::{Client, Response}; -use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue}; -use reqwest::{Certificate, StatusCode}; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; - -const EXECUTE_PATH: &str = "/v1/sql/execute"; -const WIRE_VERSION: &str = "v1"; -const DEFAULT_BASE_URL: &str = "https://executor:8443"; - -type HmacSha256 = Hmac; - -struct Harness { - base_url: String, - client: Client, - secret: Arc>, - nonce: AtomicU64, -} - -impl Harness { - fn from_environment() -> Result> { - let ca_path = required_environment("MTMPG_EXECUTOR_CA_PATH")?; - let secret_path = required_environment("MTMPG_EXECUTOR_HMAC_PATH")?; - let ca = Certificate::from_pem(&fs::read(ca_path)?)?; - let client = Client::builder() - .add_root_certificate(ca) - .no_proxy() - .timeout(Duration::from_secs(15)) - .build()?; - Ok(Self { - base_url: std::env::var("MTMPG_EXECUTOR_URL") - .unwrap_or_else(|_| DEFAULT_BASE_URL.into()), - client, - secret: Arc::new(fs::read(secret_path)?), - nonce: AtomicU64::new(1), - }) - } - - fn ready(&self) -> Result<(), Box> { - for _ in 0..50 { - match self.client.get(format!("{}/ready", self.base_url)).send() { - Ok(response) if response.status() == StatusCode::OK => return Ok(()), - Ok(_) | Err(_) => thread::sleep(Duration::from_millis(100)), - } - } - Err(invalid_data("executor readiness failed").into()) - } - - fn execute(&self, body: Value) -> Result<(StatusCode, Value), Box> { - let timestamp = unix_time()?; - let nonce = format!("{:032x}", self.nonce.fetch_add(1, Ordering::Relaxed)); - self.execute_with_envelope(body, timestamp, &nonce, None) - } - - fn execute_with_envelope( - &self, - body: Value, - timestamp: i64, - nonce: &str, - signature_override: Option<&str>, - ) -> Result<(StatusCode, Value), Box> { - let body = serde_json::to_vec(&body)?; - let signature = match signature_override { - Some(signature) => signature.to_owned(), - None => sign(&self.secret, timestamp, nonce, &body)?, - }; - let response = self - .client - .post(format!("{}{}", self.base_url, EXECUTE_PATH)) - .headers(headers(timestamp, nonce, &signature)?) - .body(body) - .send()?; - decode_response(response) - } - - fn request(&self, profile: &str, method: &str, statement: &str) -> Value { - let actor = if method == "oauth" { - json!({"client_id": format!("client_{profile}")}) - } else { - json!({"credential_id": format!("credential_{profile}")}) - }; - let mut principal = json!({ - "user_id": format!("user_{profile}"), - "delegation_id": format!("delegation_{profile}"), - "auth_method": method, - "authority_version": 7, - "database_scope": "database", - "profile": profile, - "credential_expires_at": unix_time().expect("system time") + 300 - }); - principal - .as_object_mut() - .expect("principal object") - .extend(actor.as_object().expect("actor object").clone()); - json!({ - "principal": principal, - "statement": statement, - "binds": [], - "intent": "read", - "change_confirmed": false, - "correlation_id": format!( - "integration-{:08}", - self.nonce.load(Ordering::Relaxed) - ) - }) - } -} - -fn main() -> Result<(), Box> { - let harness = Arc::new(Harness::from_environment()?); - harness.ready()?; - verify_hmac_boundary(&harness)?; - verify_concurrent_identity_isolation(&harness)?; - verify_extended_protocol_and_statement_boundary(&harness)?; - verify_transaction_and_budget_boundaries(&harness)?; - verify_deadline_and_cancellation(&harness)?; - verify_tls_hostname(&harness)?; - println!("PG18 executor OAuth and SQL matrix passed"); - Ok(()) -} - -fn verify_hmac_boundary(harness: &Harness) -> Result<(), Box> { - let body = harness.request("ordinary", "oauth", "SELECT 1"); - let timestamp = unix_time()?; - let nonce = "00112233445566778899aabbccddeeff"; - let encoded = serde_json::to_vec(&body)?; - let signature = sign(&harness.secret, timestamp, nonce, &encoded)?; - let first = harness.execute_with_envelope(body.clone(), timestamp, nonce, Some(&signature))?; - expect_success(&first, "valid HMAC request")?; - let replay = harness.execute_with_envelope(body.clone(), timestamp, nonce, Some(&signature))?; - expect_error( - &replay, - StatusCode::UNAUTHORIZED, - "unauthorized", - "replayed HMAC request", - )?; - let tampered = harness.execute_with_envelope( - body, - timestamp, - "ffeeddccbbaa99887766554433221100", - Some(&"00".repeat(32)), - )?; - expect_error( - &tampered, - StatusCode::UNAUTHORIZED, - "unauthorized", - "tampered HMAC request", - )?; - Ok(()) -} - -fn verify_concurrent_identity_isolation(harness: &Arc) -> Result<(), Box> { - let cases = [ - ("ordinary", "oauth"), - ("business_admin", "api_key"), - ("database_developer", "oauth"), - ]; - let mut workers = Vec::new(); - for (profile, method) in cases { - let harness = Arc::clone(harness); - workers.push(thread::spawn(move || -> Result<(), String> { - let request = harness.request(profile, method, "SELECT current_user, system_user"); - let response = harness - .execute(request) - .map_err(|error| error.to_string())?; - let result = expect_success(&response, "concurrent identity request") - .map_err(|error| error.to_string())?; - let row = result["rows"] - .as_array() - .and_then(|rows| rows.first()) - .and_then(Value::as_array) - .ok_or_else(|| "identity result row is unavailable".to_owned())?; - if row.first().and_then(Value::as_str) != Some(profile) { - return Err("current_user did not match profile".into()); - } - let system_user = row - .get(1) - .and_then(Value::as_str) - .ok_or_else(|| "system_user is unavailable".to_owned())?; - if !system_user.starts_with("oauth:pggomtm:v2;") - || !system_user.contains(&format!(";p={profile}")) - { - return Err("system_user did not match delegated principal".into()); - } - Ok(()) - })); - } - for worker in workers { - worker - .join() - .map_err(|_| invalid_data("identity worker panicked"))? - .map_err(|message| IoError::new(ErrorKind::InvalidData, message))?; - } - Ok(()) -} - -fn verify_extended_protocol_and_statement_boundary( - harness: &Harness, -) -> Result<(), Box> { - let mut binds = harness.request( - "ordinary", - "oauth", - "SELECT $1::text, $2::bigint, $3::boolean, $4::jsonb, $5::text", - ); - binds["binds"] = json!([ - {"type": "text", "value": "alpha"}, - {"type": "int64", "value": 42}, - {"type": "boolean", "value": true}, - {"type": "json", "value": {"key": "value"}}, - {"type": "null"} - ]); - let response = harness.execute(binds)?; - let result = expect_success(&response, "parameter bind query")?; - assert_json( - &result["rows"][0], - &json!(["alpha", "42", "t", {"key": "value"}, null]), - "parameter bind result", - )?; - - let multiple = harness.execute(harness.request( - "ordinary", - "oauth", - "SELECT 1; INSERT INTO app.executor_probe(value) VALUES ('forbidden')", - ))?; - expect_error( - &multiple, - StatusCode::UNPROCESSABLE_ENTITY, - "database_rejected", - "multiple statement request", - )?; - - let cte = harness.execute(harness.request( - "ordinary", - "oauth", - "WITH input(value) AS (VALUES (41)) SELECT value + 1 FROM input", - ))?; - expect_success(&cte, "CTE query")?; - - let mut call = harness.request( - "business_admin", - "api_key", - "CALL app.record_probe($1::text)", - ); - call["binds"] = json!([{"type": "text", "value": "call"}]); - call["intent"] = json!("change"); - call["change_confirmed"] = json!(true); - expect_success(&harness.execute(call)?, "CALL statement")?; - - let mut do_statement = harness.request( - "database_developer", - "oauth", - "DO $$ BEGIN PERFORM 1; END $$", - ); - do_statement["intent"] = json!("change"); - do_statement["change_confirmed"] = json!(true); - expect_success(&harness.execute(do_statement)?, "DO statement")?; - Ok(()) -} - -fn verify_transaction_and_budget_boundaries(harness: &Harness) -> Result<(), Box> { - let read_write = harness.execute(harness.request( - "ordinary", - "oauth", - "INSERT INTO app.executor_probe(value) VALUES ('read-write')", - ))?; - expect_error( - &read_write, - StatusCode::UNPROCESSABLE_ENTITY, - "database_rejected", - "read-only write request", - )?; - - let mut confirmed = harness.request( - "business_admin", - "api_key", - "INSERT INTO app.executor_probe(value) VALUES ('confirmed')", - ); - confirmed["intent"] = json!("change"); - confirmed["change_confirmed"] = json!(true); - let confirmed_response = harness.execute(confirmed)?; - let confirmed_result = expect_success(&confirmed_response, "confirmed change")?; - assert_json( - &confirmed_result["affected_rows"], - &json!(1), - "affected row count", - )?; - - let mut failure = harness.request("business_admin", "api_key", "CALL app.fail_after_insert()"); - failure["intent"] = json!("change"); - failure["change_confirmed"] = json!(true); - expect_error( - &harness.execute(failure)?, - StatusCode::UNPROCESSABLE_ENTITY, - "database_rejected", - "failed routine request", - )?; - - let mut oversized_change = harness.request( - "business_admin", - "api_key", - "INSERT INTO app.executor_probe(value) SELECT 'budget-' || value::text FROM generate_series(1, 1001) AS generated(value) RETURNING value", - ); - oversized_change["intent"] = json!("change"); - oversized_change["change_confirmed"] = json!(true); - expect_error( - &harness.execute(oversized_change)?, - StatusCode::PAYLOAD_TOO_LARGE, - "budget_exceeded", - "change result budget", - )?; - - for statement in [ - "SELECT generate_series(1, 1001)", - "SELECT repeat('x', 262145)", - "SELECT repeat('x', 2048) FROM generate_series(1, 1000)", - ] { - expect_error( - &harness.execute(harness.request("ordinary", "oauth", statement))?, - StatusCode::PAYLOAD_TOO_LARGE, - "budget_exceeded", - "read result budget", - )?; - } - - let count = harness.execute(harness.request( - "ordinary", - "oauth", - "SELECT count(*) FROM app.executor_probe WHERE value IN ('read-write', 'failed') OR value LIKE 'budget-%'", - ))?; - let result = expect_success(&count, "rollback count")?; - assert_json(&result["rows"][0][0], &json!("0"), "rollback count")?; - Ok(()) -} - -fn verify_deadline_and_cancellation(harness: &Harness) -> Result<(), Box> { - let deadline = harness.execute(harness.request("ordinary", "oauth", "SELECT pg_sleep(10)"))?; - expect_error( - &deadline, - StatusCode::GATEWAY_TIMEOUT, - "deadline_exceeded", - "statement deadline", - )?; - - let ca_path = required_environment("MTMPG_EXECUTOR_CA_PATH")?; - let ca = Certificate::from_pem(&fs::read(ca_path)?)?; - let short_client = Client::builder() - .add_root_certificate(ca) - .no_proxy() - .timeout(Duration::from_millis(250)) - .build()?; - let body = harness.request("ordinary", "oauth", "SELECT pg_sleep(10)"); - let encoded = serde_json::to_vec(&body)?; - let timestamp = unix_time()?; - let nonce = "102132435465768798a9bacbdcedfe0f"; - let signature = sign(&harness.secret, timestamp, nonce, &encoded)?; - let cancelled = short_client - .post(format!("{}{}", harness.base_url, EXECUTE_PATH)) - .headers(headers(timestamp, nonce, &signature)?) - .body(encoded) - .send(); - if cancelled.is_ok() { - return Err(invalid_data("cancelled HTTP request unexpectedly completed").into()); - } - thread::sleep(Duration::from_secs(1)); - expect_success( - &harness.execute(harness.request("ordinary", "oauth", "SELECT 1"))?, - "post-cancel query", - )?; - Ok(()) -} - -fn verify_tls_hostname(harness: &Harness) -> Result<(), Box> { - let request = harness.request("ordinary", "oauth", "SELECT 1"); - let body = serde_json::to_vec(&request)?; - let timestamp = unix_time()?; - let nonce = "abcdefabcdefabcdefabcdefabcdefab"; - let signature = sign(&harness.secret, timestamp, nonce, &body)?; - let invalid_host = harness - .client - .post(format!("https://127.0.0.1:8443{EXECUTE_PATH}")) - .headers(headers(timestamp, nonce, &signature)?) - .body(body) - .send(); - if invalid_host.is_ok() { - return Err(invalid_data("TLS hostname mismatch was accepted").into()); - } - Ok(()) -} - -fn headers(timestamp: i64, nonce: &str, signature: &str) -> Result> { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - headers.insert("x-executor-version", HeaderValue::from_static(WIRE_VERSION)); - headers.insert( - "x-executor-timestamp", - HeaderValue::from_str(×tamp.to_string())?, - ); - headers.insert("x-executor-nonce", HeaderValue::from_str(nonce)?); - headers.insert("x-executor-signature", HeaderValue::from_str(signature)?); - Ok(headers) -} - -fn sign(secret: &[u8], timestamp: i64, nonce: &str, body: &[u8]) -> Result { - let digest = Sha256::digest(body); - let digest_hex = digest - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - let canonical = - format!("{WIRE_VERSION}\nPOST\n{EXECUTE_PATH}\n{timestamp}\n{nonce}\n{digest_hex}"); - let mut mac = - HmacSha256::new_from_slice(secret).map_err(|_| invalid_data("invalid HMAC test secret"))?; - mac.update(canonical.as_bytes()); - Ok(mac - .finalize() - .into_bytes() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect()) -} - -fn decode_response(response: Response) -> Result<(StatusCode, Value), Box> { - let status = response.status(); - let body = response.json::()?; - Ok((status, body)) -} - -fn expect_success<'a>( - response: &'a (StatusCode, Value), - context: &str, -) -> Result<&'a Value, IoError> { - if response.0 != StatusCode::OK { - return Err(IoError::new( - ErrorKind::InvalidData, - format!("{context} returned HTTP {}", response.0), - )); - } - response.1.get("result").ok_or_else(|| { - IoError::new( - ErrorKind::InvalidData, - format!("{context} omitted the result"), - ) - }) -} - -fn expect_error( - response: &(StatusCode, Value), - status: StatusCode, - category: &str, - context: &str, -) -> Result<(), IoError> { - if response.0 != status || response.1["error"]["category"] != category { - let actual_category = response.1["error"]["category"] - .as_str() - .unwrap_or("missing"); - return Err(IoError::new( - ErrorKind::InvalidData, - format!( - "{context} expected HTTP {status} category {category}, got HTTP {} category {actual_category}", - response.0 - ), - )); - } - Ok(()) -} - -fn assert_json(actual: &Value, expected: &Value, context: &str) -> Result<(), IoError> { - if actual != expected { - return Err(IoError::new( - ErrorKind::InvalidData, - format!("{context} did not match"), - )); - } - Ok(()) -} - -fn required_environment(name: &str) -> Result { - std::env::var(name).map_err(|_| { - IoError::new( - ErrorKind::InvalidInput, - format!("required environment is unavailable: {name}"), - ) - }) -} - -fn unix_time() -> Result { - let seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| invalid_data("system clock is before Unix epoch"))? - .as_secs(); - i64::try_from(seconds).map_err(|_| invalid_data("system clock is out of range")) -} - -fn invalid_data(message: &'static str) -> IoError { - IoError::new(ErrorKind::InvalidData, message) -} diff --git a/executor/tests/token_registry.rs b/executor/tests/token_registry.rs deleted file mode 100644 index e46ea25..0000000 --- a/executor/tests/token_registry.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::sync::{Arc, Barrier}; -use std::thread; - -use mtmpg_executor::token_registry::{ConnectionId, ConnectionTokenRegistry, TokenRegistryError}; - -#[test] -fn token_is_claimed_exactly_once_for_its_connection() { - let registry = ConnectionTokenRegistry::with_capacity(4).expect("bounded registry"); - let connection = ConnectionId::new(1).expect("non-null connection identity"); - registry - .register(connection, "token-alpha".into()) - .expect("register token"); - - let claimed = registry.claim(connection).expect("claim registered token"); - assert_eq!(claimed.as_str(), "token-alpha"); - assert_eq!( - registry.claim(connection), - Err(TokenRegistryError::UnknownConnection) - ); -} - -#[test] -fn duplicate_registration_fails_without_replacing_the_original_token() { - let registry = ConnectionTokenRegistry::with_capacity(4).expect("bounded registry"); - let connection = ConnectionId::new(2).expect("non-null connection identity"); - registry - .register(connection, "token-original".into()) - .expect("register original token"); - assert_eq!( - registry.register(connection, "token-replacement".into()), - Err(TokenRegistryError::DuplicateConnection) - ); - assert_eq!( - registry.claim(connection).expect("claim original").as_str(), - "token-original" - ); -} - -#[test] -fn connection_failure_cleanup_removes_unclaimed_material() { - let registry = ConnectionTokenRegistry::with_capacity(4).expect("bounded registry"); - let connection = ConnectionId::new(3).expect("non-null connection identity"); - registry - .register(connection, "token-cleanup".into()) - .expect("register token"); - assert!(registry.cleanup(connection)); - assert!(!registry.cleanup(connection)); - assert_eq!( - registry.claim(connection), - Err(TokenRegistryError::UnknownConnection) - ); -} - -#[test] -fn bounded_registry_fails_closed_when_full() { - let registry = ConnectionTokenRegistry::with_capacity(1).expect("bounded registry"); - let first = ConnectionId::new(4).expect("connection identity"); - let second = ConnectionId::new(5).expect("connection identity"); - registry - .register(first, "token-first".into()) - .expect("register first token"); - assert_eq!( - registry.register(second, "token-second".into()), - Err(TokenRegistryError::CapacityExceeded) - ); - assert_eq!( - registry.claim(first).expect("claim first token").as_str(), - "token-first" - ); - assert_eq!( - registry.claim(second), - Err(TokenRegistryError::UnknownConnection) - ); -} - -#[test] -fn concurrent_connections_never_observe_another_principals_token() { - const CONNECTIONS: usize = 32; - let registry = - Arc::new(ConnectionTokenRegistry::with_capacity(CONNECTIONS).expect("bounded registry")); - let barrier = Arc::new(Barrier::new(CONNECTIONS)); - let mut threads = Vec::new(); - - for index in 1..=CONNECTIONS { - let registry = Arc::clone(®istry); - let barrier = Arc::clone(&barrier); - threads.push(thread::spawn(move || { - let connection = ConnectionId::new(index).expect("connection identity"); - let expected = format!("token-{index:02}"); - registry - .register(connection, expected.clone()) - .expect("register isolated token"); - barrier.wait(); - let claimed = registry.claim(connection).expect("claim isolated token"); - assert_eq!(claimed.as_str(), expected); - })); - } - - for thread in threads { - thread.join().expect("registry worker did not panic"); - } - assert_eq!(registry.len(), 0); -} - -#[test] -fn debug_output_never_contains_token_material() { - let registry = ConnectionTokenRegistry::with_capacity(2).expect("bounded registry"); - let connection = ConnectionId::new(6).expect("connection identity"); - registry - .register(connection, "token-sensitive".into()) - .expect("register token"); - assert!(!format!("{registry:?}").contains("token-sensitive")); - let claimed = registry.claim(connection).expect("claim token"); - assert!(!format!("{claimed:?}").contains("token-sensitive")); -} diff --git a/openspec/changes/publish-rust-sql-executor/tasks.md b/openspec/changes/publish-rust-sql-executor/tasks.md index b7497f7..69e8641 100644 --- a/openspec/changes/publish-rust-sql-executor/tasks.md +++ b/openspec/changes/publish-rust-sql-executor/tasks.md @@ -1,3 +1,5 @@ +> ⚠️ 本 change 已被 gomtm issue #310 硬切取代:executor 产品整个删除(见 change `slim-validator-only-database-token-v1`)。以下任务不再执行,保留本文件作为不可变历史。 + ## 1. 固化双产品仓库边界 - [x] 1.1 更新`AGENTS.md`、README与维护文档,明确一个workspace、根validator package、唯一`executor/` package、独立image/tag/version及消费者不构建边界 diff --git a/openspec/changes/release-host-artifacts/tasks.md b/openspec/changes/release-host-artifacts/tasks.md index b9e418a..d99970e 100644 --- a/openspec/changes/release-host-artifacts/tasks.md +++ b/openspec/changes/release-host-artifacts/tasks.md @@ -1,3 +1,5 @@ +> ⚠️ 本 change 的 executor 宿主产物部分(`mtmpg-executor`)已被 gomtm issue #310 硬切取代:executor 产品整个删除。validator 宿主产物(`pggomtm.so`)仍保留。以下 executor 相关任务不再执行,保留本文件作为不可变历史。 + ## 1. release 裸宿主产物 - [x] 新增 ARTIFACT_NAME env(executor→mtmpg-executor,validator→pggomtm.so) diff --git a/openspec/changes/slim-validator-only-database-token-v1/.openspec.yaml b/openspec/changes/slim-validator-only-database-token-v1/.openspec.yaml new file mode 100644 index 0000000..1496314 --- /dev/null +++ b/openspec/changes/slim-validator-only-database-token-v1/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-17 diff --git a/openspec/changes/slim-validator-only-database-token-v1/design.md b/openspec/changes/slim-validator-only-database-token-v1/design.md new file mode 100644 index 0000000..480d9cf --- /dev/null +++ b/openspec/changes/slim-validator-only-database-token-v1/design.md @@ -0,0 +1,60 @@ +## Context + +gomtm issue #310 硬切方向已批准。executor 是独立 HTTP 二进制(axum 0.8 + axum-server TLS + libpq),与 PostgreSQL 内核无关,其铸币/HMAC/signer 职责上移到 gomtmui(better-auth 签名)与 gomtm(Go sql-relay 透传)。mtmpg 只保留必须驻留在数据库内核中的 validator(`pggomtm.so`)。 + +当前 database-token contract v2 携带铸造链字段(delegation_id/auth_method/authority_version/client_id/credential_id/db_role + db_profile/db_role 双字段),这些字段源于 executor 把 DelegatedPrincipal 转写进 token。硬切后数据库层只需要「真实 issuer 签给该用户、允许访问本库、在有效期内、profile 合法」的最小契约。 + +## Goals / Non-Goals + +**Goals:** + +- 删除 executor 产品全部源码、测试、Dockerfile、CI/release 入口与文档。 +- database-token 契约最小化为 v1:iss/aud/sub/iat/exp/jti/scope/profile,deny unknown。 +- system_user 身份编码改为 `oauth::v1;u=;p=`。 +- validator package 升 0.3.0,保持离线 JWKS、双文件契约、fail-closed、reason-code 闭集、ES256/P-256、aud≠iss、TTL 30–300s、零网络/SQL/SPI 不变。 +- 通过现有标准 CI 全绿,PR 交付。 + +**Non-Goals:** + +- 不提供 executor 兼容层、过渡设计或第三 crate。 +- 不保留旧 identity 解码、旧 claims 字段、旧 profile-role 映射、alias 或 fallback。 +- 不物理删除他人 openspec change 文件;只标注被硬切取代。 +- 不在本地运行 Cargo/Docker/PostgreSQL。 + +## Decisions + +### 1. Executor 整个物理删除,不做软删除 + +executor 是独立二进制、无复用价值(职责已外置),按 AGENTS.md「无复用价值直接删除」处理,不保留 `--` 软删除文件。根 Dockerfile 保留,因为 CI final-image 测试依赖它构建并验证 production `pggomtm.so` 镜像。 + +### 2. 最小 claims 与 deny unknown + +`DatabaseTokenClaims` 仅保留 iss/aud/sub/iat/exp/jti/scope/profile,`#[serde(deny_unknown_fields)]` 保持。删除的旧字段(delegation_id/auth_method/authority_version/client_id/credential_id/db_role/db_profile)在反序列化时作为 unknown 字段被拒绝为 InvalidToken,不新增显式字段白名单逻辑。 + +sub 采用 `^[A-Za-z0-9_-]{1,64}$`(复用 `is_valid_internal_id`,字节级等价约束);jti 保持现有 internal-id 约束(alphanumeric + `_`/`-`,≤64 字节)。 + +### 3. profile 即角色 + +`DatabaseProfile` 保持三值 ordinary/business_admin/database_developer,`database_role()` 返回同名。删除 db_role 字段后,validate_claims 直接比较 requested_role 与 profile 的 role 名,不等返回 RequestedRoleMismatch。 + +### 4. 身份编码 `oauth::v1` + +identity 字段收敛为 user_id + profile + issuer_host(issuer_host 取自 policy.issuer URL 的 host)。编码为 `:v1;u=;p=`,system_user 前缀 `oauth:` 不变。解码按 `;` 切 3 段,首段用 `rsplit_once(':')` 分离 issuer-host 与版本 `v1`(兼容 IPv6 host 的方括号形式),版本不符或字段缺失均 InvalidIdentity。 + +### 5. reason-code 闭集不变 + +现有 24 个 reason-code 全部仍被新契约使用(旧 claims 字段此前只映射到 token-claims-invalid / identity-invalid 一类,无专用 code),因此 `auth_failure.rs` 与闭集字符串不变,不新增/删除/改名 code。 + +### 6. 版本与发布 + +validator `Cargo.toml` 升 0.3.0(契约变更 = 新 module+consumer 版本,按 docs/runtime-configuration.md 规则)。release 仅发布 `pggomtm.so` 宿主产物(validator image 仍由 CI final-image 验证);`executor-v*` release 入口删除。 + +## Risks / Trade-offs + +- 旧 v0.2.x 调用方(executor 铸的 v2 token/identity)全部失效——显式硬切,消费者同步切换。 +- CI 结构较大改动(删除 executor 三个 job + release 分支)——保持 validator job 的解析/ABI/PG18/image 门禁不变,删除部分只触及 executor。 +- 他人 deps PR(#10)可能先合并——push 前 rebase 最新 origin/main,冲突时按本任务优先删除 executor。 + +## Open Questions + +无。 diff --git a/openspec/changes/slim-validator-only-database-token-v1/proposal.md b/openspec/changes/slim-validator-only-database-token-v1/proposal.md new file mode 100644 index 0000000..023d1c7 --- /dev/null +++ b/openspec/changes/slim-validator-only-database-token-v1/proposal.md @@ -0,0 +1,32 @@ +## Why + +gomtm issue #310 已批准硬切:mtmpg 只保留数据库内核部分(`pggomtm.so` validator),Rust SQL executor 整个删除,不做任何兼容/过渡设计。executor 是独立 HTTP 二进制(axum + libpq),与 PostgreSQL 内核无关,其唯一 DB 接点是 libpq 客户端;其 HTTP 端点、HMAC 验签、ES256 signer key 与 libpq OAuth hook 等职责全部外置到 gomtm(Go sql-relay 透传)与 gomtmui(better-auth 签名上移)。删除后 mtmpg 只保留「数据库层按用户身份限权」所需的最小内核干预:validator 校验真实 issuer 签发的短令牌 + 3 个 profile 角色 + per-user RLS。 + +同时把 database-token 契约最小化到 v1:数据库层只需要「真实 issuer 签给这个用户、允许访问本库、在有效期内、profile 合法」,删除 delegation_id、auth_method、authority_version、client_id、credential_id、db_role 等铸造链字段,`profile` 即数据库角色。 + +## What Changes + +- **BREAKING**:删除 executor 产品全部——`executor/` 源码/测试/Dockerfile、根 Cargo workspace 成员、`executor-v*` release 入口与 executor CI 步骤、`docs/executor-runtime.md`、README/MAINTAINERS 中 executor 引用、共享 PG18 harness 的 run-executor 矩阵。 +- **BREAKING**:database-token contract 收敛为 v1 最小 claims(iss/aud/sub/iat/exp/jti/scope/profile),deny unknown;删除 delegation_id/auth_method/authority_version/client_id/credential_id/db_role,`profile` 即数据库角色(startup role 必须与 profile 精确同名)。 +- **BREAKING**:system_user 身份编码改为 `oauth::v1;u=;p=`,彻底移除 `oauth:pggomtm:v2;u=...;actor=...;d=...;m=...;a=...;p=...` 编码及前缀常量。 +- 不变项:离线 JWKS 快照、`/etc/pggomtm` 双文件契约(schema `pggomtm-validator-config/v1`)、fail-closed、reason-code 脱敏闭集、ES256/P-256/`use=sig`/`key_ops=verify`、aud≠iss、TTL 30–300s、拒绝网络/SQL/SPI。 +- validator `Cargo.toml` 升 `0.3.0`(契约变更 = 新 module+consumer 版本)。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `pggomtm-validator-module`:database-token 与 identity 契约收敛为最小 v1。 +- `pggomtm-release-supply-chain`:仓库边界收敛为 validator-only(删除 executor 产品、CI 与 release 线)。 + +## Impact + +- Rust 源码:`src/database_auth.rs`(claims、identity 编码、枚举与错误路径)、`src/runtime_config.rs`(测试 fixture)。 +- 测试:Rust JWT/identity 领域矩阵、共享 OAuth fixture、真实 PG18 harness(system_user 断言)与 final-image smoke;ABI 测试不动;删除 executor 全部测试。 +- 交付:根 Cargo.toml、Dockerfile、`.dockerignore`、`.github/workflows/ci.yml` 与 `release.yml`、README、MAINTAINERS、`docs/runtime-configuration.md`、`docs/authentication-failures.md`、`docs/release-and-compatibility.md`、删除 `docs/executor-runtime.md`。 +- OpenSpec:`publish-rust-sql-executor` 与 `release-host-artifacts` 的 executor 相关任务标注「被 issue-310 硬切取代」后保留为历史;`standardize-profile-role-contract-v2` 与本任务重叠,同样标注取代。 +- 消费者:gomtm(M2 sql-relay)与 gomtmui(M3 签名上移)必须按本契约签发/解析最小 v1 令牌与 `oauth::v1` identity。 diff --git a/openspec/changes/slim-validator-only-database-token-v1/specs/pggomtm-release-supply-chain/spec.md b/openspec/changes/slim-validator-only-database-token-v1/specs/pggomtm-release-supply-chain/spec.md new file mode 100644 index 0000000..830fc19 --- /dev/null +++ b/openspec/changes/slim-validator-only-database-token-v1/specs/pggomtm-release-supply-chain/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: mtmpg必须是精简的唯一源码与发布权威 +`codeh007/mtmpg` SHALL只维护根 `pggomtm` validator crate、测试、CI 和 PostgreSQL image 定义,并 MUST删除 executor 产品全部:`executor/` 目录、根 Cargo workspace 成员、`executor-v*` release 入口与 executor CI 步骤、`docs/executor-runtime.md` 及 README/MAINTAINERS 中的 executor 引用。仓库 MUST NOT保留 executor 源码副本、第二 Dockerfile、executor image fallback 或 executor release 线。 + +#### Scenario: 精简为validator-only +- **WHEN** 维护者完成硬切 +- **THEN** 仓库 SHALL只保留 pggomtm validator 及其唯一 Dockerfile/CI/release,executor 及其发布历史由 Git/Release 不可变记录保存 + +#### Scenario: gomtm/gomtmui消费pggomtm +- **WHEN** gomtm(Go sql-relay)与 gomtmui(签名上移)部署带 pggomtm 的 PostgreSQL +- **THEN** 它们 SHALL 引用 mtmpg 发布的版本化 image/宿主产物,不得重新构建 module 或维护第二套 native 矩阵 + +### Requirement: gomtmui必须最小化消费mtmpg release +Gomtmui SHALL在内测Compose中把PostgreSQL image设置为明确的`ghcr.io/codeh007/mtmpg:`,并 SHALL复用现有platform初始化、配置与运行契约。Gomtmui MUST NOT本地构建Rust module或mtmpg image,也 MUST NOT增加旧validator、认证fallback、private pull credential或第二份native测试矩阵。 + +Gomtmui SHALL删除专用mtmpg consumer workflow与测试harness。TLS、sub2api、pgAdmin、ACL/RLS、OAuth issuer和数据库SQL relay的真实集成 SHALL由gomtmui/gomtm对应领域change在功能启用时验证,不得作为mtmpg release前置条件。平台在pull、启动或备份时 SHALL记录实际resolved digest,但tracked配置 SHALL以mtmpg SemVer表达用户选择。 + +#### Scenario: 更新内测Compose版本 +- **WHEN** gomtmui选择一个已发布mtmpg SemVer用于可重建内测平台 +- **THEN** Compose与platform单一常量 SHALL引用该versioned image,且仓库不得新增专用native consumer workflow或测试目录 + +#### Scenario: 平台领域集成失败 +- **WHEN** gomtmui后续启用TLS、profile role、ACL/RLS或数据库SQL relay时发现与某个mtmpg release不兼容 +- **THEN** gomtmui SHALL在自身领域change中保持该能力停用并修复前进,不得复制mtmpg native矩阵或要求覆盖既有release diff --git a/openspec/changes/slim-validator-only-database-token-v1/specs/pggomtm-validator-module/spec.md b/openspec/changes/slim-validator-only-database-token-v1/specs/pggomtm-validator-module/spec.md new file mode 100644 index 0000000..69cd7a2 --- /dev/null +++ b/openspec/changes/slim-validator-only-database-token-v1/specs/pggomtm-validator-module/spec.md @@ -0,0 +1,38 @@ +## MODIFIED Requirements + +### Requirement: Database JWT必须按闭集验证 +Validator SHALL只接受database-token contract v1:固定ES256、唯一issuer/audience、`database` scope、30至300秒TTL。Claims SHALL只允许 iss、aud、sub、iat、exp、jti、scope、profile 八个字段,`sub` MUST匹配 `^[A-Za-z0-9_-]{1,64}$`,`profile` MUST属于 ordinary/business_admin/database_developer;delegation_id、auth_method、authority_version、client_id、credential_id、db_role、db_profile 等旧字段及其他未知字段 MUST deny unknown 拒绝。外部 OAuth token、长期 API key、Supabase JWT、opaque token、未知字段或算法 MUST fail closed。 + +#### Scenario: 合法v1 database JWT +- **WHEN** token 签名有效、claims 完整且只含最小 v1 字段、profile 合法 +- **THEN** validator SHALL 授权匹配 startup role 并生成不含 secret 的规范 v1 identity + +#### Scenario: 外部凭据直达PostgreSQL +- **WHEN** client 提交非 database JWT 或其他 issuer token +- **THEN** validator SHALL 拒绝且不得调用在线认证器 + +#### Scenario: 旧铸造链字段进入 v0.3 validator +- **WHEN** token 含 delegation_id、auth_method、authority_version、client_id、credential_id、db_role 或 db_profile 任一旧字段 +- **THEN** validator SHALL deny unknown 拒绝且不得 alias、重写或回退 + +### Requirement: Profile与requested role必须精确匹配 +Database-token contract v1 的 `profile` SHALL 只允许 ordinary、business_admin、database_developer,并 SHALL 直接以同一字符串作为 closed PostgreSQL role。Token 的 `profile` 与 startup requested role MUST 精确相等。Runtime config MUST NOT 扩展算法、issuer、profile 或 role 集合。 + +#### Scenario: Token请求同名role +- **WHEN** 三个合法 profile 分别请求完全同名的 startup role +- **THEN** validator SHALL 通过 profile-role 检查并继续其余认证门禁 + +#### Scenario: Token请求越权或未知role +- **WHEN** ordinary token 请求 business_admin、database_developer、service、migration、cluster 或未知 role +- **THEN** validator SHALL 在认证阶段拒绝,不得依赖 RLS、alias 或 SET ROLE 修正 + +### Requirement: Authenticated identity必须版本化且无secret +V0.3.x 授权结果 SHALL 使用 `oauth::v1;u=;p=` 规范 system_user 编码(issuer-host 取自 config issuer URL 的 host),并 SHALL 能从 PostgreSQL system_user 无歧义解析。Encoder MUST 只产生 v1 identity,decoder MUST 只接受 v1。Identity MUST NOT 包含 JWT、API key、显示名称或 key prefix;旧 `oauth:pggomtm:v2;...`、非法、超长或未知版本 MUST 拒绝而非截断或兼容解码。 + +#### Scenario: V1 identity往返 +- **WHEN** 合法 v1 token 完成认证 +- **THEN** `authn_id -> system_user -> decoded identity` SHALL 无损保留 user/profile 与 issuer-host 且不含 secret + +#### Scenario: 旧v2 identity进入v0.3 decoder +- **WHEN** system_user 含 `oauth:pggomtm:v2;...` 或未知版本 +- **THEN** decoder SHALL 拒绝且不得转换为 v1 identity diff --git a/openspec/changes/slim-validator-only-database-token-v1/tasks.md b/openspec/changes/slim-validator-only-database-token-v1/tasks.md new file mode 100644 index 0000000..6a65ed8 --- /dev/null +++ b/openspec/changes/slim-validator-only-database-token-v1/tasks.md @@ -0,0 +1,41 @@ +## 1. 规划与基线 + +- [x] 1.1 读取 AGENTS/MAINTAINERS/docs/openspec 现状,确认 3 个 active change 与 executor 边界 +- [x] 1.2 建立 worktree 与分支 slim-validator-only(base=origin/main fba114e) +- [x] 1.3 创建本 openspec change 并完成 proposal/design/specs/tasks + +## 2. 删除 executor 产品 + +- [x] 2.1 删除 executor/ 目录(源码+测试+Dockerfile) +- [x] 2.2 根 Cargo.toml 移除 executor workspace 成员 +- [x] 2.3 .github/workflows/ci.yml 移除 executor CI 步骤(executor_domain/executor_pg18/executor_image 及 validator 内 executor 解析/libpq probe) +- [x] 2.4 .github/workflows/release.yml 移除 executor-v* release 入口与 publish 内 executor 分支 +- [x] 2.5 根 Dockerfile 与 .dockerignore 移除 executor COPY/白名单 +- [x] 2.6 删除 docs/executor-runtime.md;更新 README/MAINTAINERS/AGENTS/docs 中 executor 引用 +- [x] 2.7 共享 PG18 harness(postgres_integration*.sh)移除 run-executor 矩阵 + +## 3. 契约最小化 + +- [x] 3.1 src/database_auth.rs 改写:最小 claims、identity 编码、枚举与错误路径 +- [x] 3.2 src/auth_failure.rs 确认闭集不变(无只属于旧 claims 的 code) +- [x] 3.3 validator Cargo.toml 升 0.3.0 + +## 4. 测试更新 + +- [x] 4.1 更新 Rust 领域测试(新 claims 矩阵、拒绝旧字段、身份编码、TTL、profile==role) +- [x] 4.2 更新真实 PG18 harness fixture(新身份编码断言)与 final-image smoke +- [x] 4.3 更新 src/runtime_config.rs 测试 fixture +- [x] 4.4 ABI 测试不动;删除 executor 全部测试 + +## 5. 文档与 OpenSpec 收敛 + +- [x] 5.1 同步 README、docs/runtime-configuration.md、docs/authentication-failures.md、docs/release-and-compatibility.md +- [x] 5.2 publish-rust-sql-executor / release-host-artifacts 标注「被 issue-310 硬切取代」保留为历史 +- [x] 5.3 standardize-profile-role-contract-v2 标注「被 issue-310 硬切取代」不冲突编辑 + +## 6. 验证与交付 + +- [x] 6.1 openspec validate --strict 通过 +- [x] 6.2 自审 diff(最小变更、无无关重构) +- [ ] 6.3 提交、推送分支、gh pr create(label enhancement+rust) +- [ ] 6.4 轮询 CI 至全绿(失败只向前修复) diff --git a/openspec/changes/standardize-profile-role-contract-v2/tasks.md b/openspec/changes/standardize-profile-role-contract-v2/tasks.md index 106f63c..2e8da8a 100644 --- a/openspec/changes/standardize-profile-role-contract-v2/tasks.md +++ b/openspec/changes/standardize-profile-role-contract-v2/tasks.md @@ -1,3 +1,5 @@ +> ⚠️ 本 change 的 profile-role/identity 契约方向已被 gomtm issue #310 硬切取代:database-token 最小化 v1 + `oauth::v1`(见 change `slim-validator-only-database-token-v1`)。保留本文件作为不可变历史。 + ## 1. 规划与基线 - [x] 1.1 严格校验本change的proposal、design、增量spec和tasks,并确认工作区只包含本change的规划改动 diff --git a/src/database_auth.rs b/src/database_auth.rs index ee79e04..56db267 100644 --- a/src/database_auth.rs +++ b/src/database_auth.rs @@ -24,7 +24,7 @@ const MAX_INTERNAL_ID_BYTES: usize = 64; const MAX_KEY_ID_BYTES: usize = 128; const DATABASE_SCOPE: &str = "database"; const SYSTEM_USER_PREFIX: &str = "oauth:"; -const AUTHN_ID_PREFIX: &str = "pggomtm:v2"; +const AUTHN_ID_VERSION: &str = "v1"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum JwtValidationError { @@ -107,35 +107,6 @@ impl DatabaseTokenPolicy { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub enum AuthMethod { - #[serde(rename = "oauth")] - OAuth, - #[serde(rename = "api_key")] - ApiKey, -} - -impl AuthMethod { - const fn as_str(self) -> &'static str { - match self { - Self::OAuth => "oauth", - Self::ApiKey => "api_key", - } - } -} - -impl FromStr for AuthMethod { - type Err = JwtValidationError; - - fn from_str(value: &str) -> Result { - match value { - "oauth" => Ok(Self::OAuth), - "api_key" => Ok(Self::ApiKey), - _ => Err(JwtValidationError::InvalidIdentity), - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum DatabaseProfile { #[serde(rename = "ordinary")] @@ -178,35 +149,21 @@ impl FromStr for DatabaseProfile { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AuthenticatedActor { - OAuthClient(String), - ApiKeyCredential(String), -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthenticatedIdentity { pub user_id: String, - pub actor: AuthenticatedActor, - pub delegation_id: String, - pub auth_method: AuthMethod, - pub authority_version: u64, pub profile: DatabaseProfile, + pub issuer_host: String, } impl AuthenticatedIdentity { pub fn encode_authn_id(&self) -> Result { validate_identity(self)?; - let (actor_kind, actor_id) = match &self.actor { - AuthenticatedActor::OAuthClient(id) => ("client", id.as_str()), - AuthenticatedActor::ApiKeyCredential(id) => ("credential", id.as_str()), - }; let encoded = format!( - "{AUTHN_ID_PREFIX};u={};actor={actor_kind}:{actor_id};d={};m={};a={};p={}", + "{}:{};u={};p={}", + self.issuer_host, + AUTHN_ID_VERSION, self.user_id, - self.delegation_id, - self.auth_method.as_str(), - self.authority_version, self.profile.as_str(), ); @@ -234,30 +191,7 @@ pub struct DatabaseTokenClaims { #[serde(rename = "jti")] pub token_id: String, pub scope: String, - pub delegation_id: String, - pub auth_method: AuthMethod, - pub authority_version: u64, - pub db_profile: DatabaseProfile, - pub db_role: String, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_present_actor_id" - )] - pub client_id: Option, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_present_actor_id" - )] - pub credential_id: Option, -} - -fn deserialize_present_actor_id<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - String::deserialize(deserializer).map(Some) + pub profile: DatabaseProfile, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -348,38 +282,24 @@ pub fn decode_authn_id(value: &str) -> Result>(); - if parts.len() != 7 || parts[0] != AUTHN_ID_PREFIX { + if parts.len() != 3 { return Err(JwtValidationError::InvalidIdentity); } - let user_id = required_part(parts[1], "u=")?.to_owned(); - let actor_value = required_part(parts[2], "actor=")?; - let (actor_kind, actor_id) = actor_value - .split_once(':') + let (issuer_host, version) = parts[0] + .rsplit_once(':') .ok_or(JwtValidationError::InvalidIdentity)?; - let actor = match actor_kind { - "client" => AuthenticatedActor::OAuthClient(actor_id.to_owned()), - "credential" => AuthenticatedActor::ApiKeyCredential(actor_id.to_owned()), - _ => return Err(JwtValidationError::InvalidIdentity), - }; - let delegation_id = required_part(parts[3], "d=")?.to_owned(); - let auth_method = required_part(parts[4], "m=")?.parse()?; - let authority_version_raw = required_part(parts[5], "a=")?; - let authority_version = authority_version_raw - .parse::() - .map_err(|_| JwtValidationError::InvalidIdentity)?; - if authority_version.to_string() != authority_version_raw { + if issuer_host.is_empty() || version != AUTHN_ID_VERSION { return Err(JwtValidationError::InvalidIdentity); } - let profile = required_part(parts[6], "p=")?.parse()?; + + let user_id = required_part(parts[1], "u=")?.to_owned(); + let profile = required_part(parts[2], "p=")?.parse()?; let identity = AuthenticatedIdentity { user_id, - actor, - delegation_id, - auth_method, - authority_version, profile, + issuer_host: issuer_host.to_owned(), }; validate_identity(&identity)?; Ok(identity) @@ -424,6 +344,15 @@ fn is_strict_https_resource(value: &str) -> bool { && url.fragment().is_none() } +fn issuer_host(issuer: &str) -> Result { + let url = Url::parse(issuer).map_err(|_| JwtValidationError::InvalidPolicy)?; + let host = url.host_str().ok_or(JwtValidationError::InvalidPolicy)?; + if host.is_empty() { + return Err(JwtValidationError::InvalidPolicy); + } + Ok(host.to_owned()) +} + fn validate_jwk_entry(entry: &JwkEntry) -> Result<(), JwtValidationError> { if entry.kty != "EC" || entry.crv != "P-256" @@ -481,54 +410,26 @@ fn validate_claims( || claims.issued_at > now || claims.expires_at <= now || !(MIN_TOKEN_TTL_SECONDS..=MAX_TOKEN_TTL_SECONDS).contains(&ttl) - || claims.authority_version == 0 - || claims.db_role != claims.db_profile.database_role() || !is_valid_internal_id(&claims.token_id) { return Err(JwtValidationError::InvalidClaims); } - if requested_role != claims.db_role { + if requested_role != claims.profile.database_role() { return Err(JwtValidationError::RequestedRoleMismatch); } - let actor = match ( - claims.auth_method, - claims.client_id.as_deref(), - claims.credential_id.as_deref(), - ) { - (AuthMethod::OAuth, Some(client_id), None) => { - AuthenticatedActor::OAuthClient(client_id.to_owned()) - } - (AuthMethod::ApiKey, None, Some(credential_id)) => { - AuthenticatedActor::ApiKeyCredential(credential_id.to_owned()) - } - _ => return Err(JwtValidationError::InvalidClaims), - }; - let identity = AuthenticatedIdentity { user_id: claims.subject.clone(), - actor, - delegation_id: claims.delegation_id.clone(), - auth_method: claims.auth_method, - authority_version: claims.authority_version, - profile: claims.db_profile, + profile: claims.profile, + issuer_host: issuer_host(&policy.issuer)?, }; validate_identity(&identity)?; Ok(identity) } fn validate_identity(identity: &AuthenticatedIdentity) -> Result<(), JwtValidationError> { - let actor_id = match (&identity.auth_method, &identity.actor) { - (AuthMethod::OAuth, AuthenticatedActor::OAuthClient(id)) => id, - (AuthMethod::ApiKey, AuthenticatedActor::ApiKeyCredential(id)) => id, - _ => return Err(JwtValidationError::InvalidIdentity), - }; - if identity.authority_version == 0 - || !is_valid_internal_id(&identity.user_id) - || !is_valid_internal_id(actor_id) - || !is_valid_internal_id(&identity.delegation_id) - { + if !is_valid_internal_id(&identity.user_id) || identity.issuer_host.is_empty() { return Err(JwtValidationError::InvalidIdentity); } Ok(()) diff --git a/src/runtime_config.rs b/src/runtime_config.rs index 673896b..3d07974 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -252,9 +252,7 @@ mod tests { MAX_PUBLIC_JWKS_BYTES, MAX_VALIDATOR_CONFIG_BYTES, PUBLIC_JWKS_PATH, RuntimeConfigError, VALIDATOR_CONFIG_PATH, ValidatorSnapshot, load_validator_snapshot_from_paths, }; - use crate::database_auth::{ - AuthMethod, DatabaseProfile, DatabaseTokenClaims, JwtValidationError, - }; + use crate::database_auth::{DatabaseProfile, DatabaseTokenClaims, JwtValidationError}; const ISSUER: &str = "https://candidate.example.test/oauth/database"; const AUDIENCE: &str = "https://candidate.example.test/resources/database/gomtm-test"; @@ -558,13 +556,7 @@ mod tests { expires_at: NOW + 120, token_id: "jti_snapshot_gate".into(), scope: "database".into(), - delegation_id: "dlg_snapshot_gate".into(), - auth_method: AuthMethod::OAuth, - authority_version: 1, - db_profile: DatabaseProfile::Ordinary, - db_role: DatabaseProfile::Ordinary.database_role().into(), - client_id: Some("cli_snapshot_gate".into()), - credential_id: None, + profile: DatabaseProfile::Ordinary, }; let mut token = Token::compact((), claims); *token.header_mut().key_id() = Some(kid.into()); diff --git a/tests/jwt_identity.rs b/tests/jwt_identity.rs index f4cc213..d7ac1ba 100644 --- a/tests/jwt_identity.rs +++ b/tests/jwt_identity.rs @@ -5,15 +5,16 @@ use jaws::Token; use jaws::key::JsonWebKey; use p256::ecdsa::{Signature, SigningKey}; use pggomtm::database_auth::{ - AuthMethod, AuthenticatedActor, AuthenticatedIdentity, DatabaseProfile, DatabaseTokenClaims, - DatabaseTokenPolicy, DatabaseTokenVerifier, JwtValidationError, MAX_AUTHN_ID_BYTES, - MAX_TOKEN_TTL_SECONDS, MIN_TOKEN_TTL_SECONDS, decode_authn_id, decode_system_user, + AuthenticatedIdentity, DatabaseProfile, DatabaseTokenClaims, DatabaseTokenPolicy, + DatabaseTokenVerifier, JwtValidationError, MAX_AUTHN_ID_BYTES, MAX_TOKEN_TTL_SECONDS, + MIN_TOKEN_TTL_SECONDS, decode_authn_id, decode_system_user, }; use serde::Serialize; use serde_json::{Value, json}; const ISSUER: &str = "https://candidate.example.test/oauth/database"; const AUDIENCE: &str = "https://candidate.example.test/resources/database/gomtm-test"; +const ISSUER_HOST: &str = "candidate.example.test"; const NOW: i64 = 1_800_000_000; const KID: &str = "candidate-es256-2026-07"; @@ -57,7 +58,7 @@ fn policy_requires_distinct_absolute_https_resources() { ); } -fn valid_oauth_claims() -> DatabaseTokenClaims { +fn valid_claims() -> DatabaseTokenClaims { DatabaseTokenClaims { issuer: ISSUER.into(), audience: AUDIENCE.into(), @@ -66,22 +67,16 @@ fn valid_oauth_claims() -> DatabaseTokenClaims { expires_at: NOW + 120, token_id: "jti_01J00000000000000000000000".into(), scope: "database".into(), - delegation_id: "dlg_01J00000000000000000000000".into(), - auth_method: AuthMethod::OAuth, - authority_version: 7, - db_profile: DatabaseProfile::Ordinary, - db_role: DatabaseProfile::Ordinary.database_role().into(), - client_id: Some("cli_01J00000000000000000000000".into()), - credential_id: None, + profile: DatabaseProfile::Ordinary, } } -fn valid_api_key_claims() -> DatabaseTokenClaims { - let mut claims = valid_oauth_claims(); - claims.auth_method = AuthMethod::ApiKey; - claims.client_id = None; - claims.credential_id = Some("crd_01J00000000000000000000000".into()); - claims +fn ordinary_identity() -> AuthenticatedIdentity { + AuthenticatedIdentity { + user_id: "usr_01J00000000000000000000000".into(), + profile: DatabaseProfile::Ordinary, + issuer_host: ISSUER_HOST.into(), + } } fn sign_payload(payload: impl Serialize, kid: &str, key: &SigningKey) -> String { @@ -107,20 +102,21 @@ fn mutate_header(token: &str, mutate: impl FnOnce(&mut serde_json::Map valid_oauth_claims(), - AuthMethod::ApiKey => valid_api_key_claims(), + let mut claims = valid_claims(); + claims.profile = profile; + claims.expires_at = NOW + + if profile_index % 2 == 0 { + MIN_TOKEN_TTL_SECONDS + } else { + MAX_TOKEN_TTL_SECONDS }; - claims.db_profile = profile; - claims.db_role = profile.database_role().into(); - claims.authority_version = u64::try_from(profile_index * 2 + method_index + 1) - .expect("small authority version"); - claims.issued_at = NOW; - claims.expires_at = NOW - + if method_index == 0 { - MIN_TOKEN_TTL_SECONDS - } else { - MAX_TOKEN_TTL_SECONDS - }; - let token = sign_payload(claims.clone(), KID, &key); - - let verified = verifier - .verify(&token, profile.database_role(), NOW) - .expect("closed actor/profile combination must verify"); - assert_eq!(verified.claims, claims); - assert_eq!(verified.identity.profile, profile); - assert_eq!( - verified.identity.authority_version, - claims.authority_version - ); - assert!(matches!( - (method, &verified.identity.actor), - (AuthMethod::OAuth, AuthenticatedActor::OAuthClient(_)) - | (AuthMethod::ApiKey, AuthenticatedActor::ApiKeyCredential(_)) - )); - assert_eq!( - decode_authn_id(&verified.authn_id), - Ok(verified.identity.clone()) - ); - assert_eq!( - decode_system_user(&format!("oauth:{}", verified.authn_id)), - Ok(verified.identity) - ); - } + let token = sign_payload(claims.clone(), KID, &key); + + let verified = verifier + .verify(&token, profile.database_role(), NOW) + .expect("closed profile must verify"); + assert_eq!(verified.claims, claims); + assert_eq!(verified.identity.profile, profile); + assert_eq!( + decode_authn_id(&verified.authn_id), + Ok(verified.identity.clone()) + ); + assert_eq!( + decode_system_user(&format!("oauth:{}", verified.authn_id)), + Ok(verified.identity) + ); } } #[test] -fn actor_authority_profile_role_and_id_matrix_fails_closed() { +fn removed_legacy_claims_and_unknown_profile_fail_closed() { let key = signing_key(); let verifier = verifier(&jwks_with(vec![jwk_value(&key, KID)])); - let base = valid_oauth_claims(); - let expected_role = base.db_profile.database_role(); - - let mut no_actor = base.clone(); - no_actor.client_id = None; - - let mut both_actors = base.clone(); - both_actors.credential_id = Some("crd_01J00000000000000000000000".into()); - - let mut oauth_with_credential = base.clone(); - oauth_with_credential.client_id = None; - oauth_with_credential.credential_id = Some("crd_01J00000000000000000000000".into()); - - let mut api_key_with_client = base.clone(); - api_key_with_client.auth_method = AuthMethod::ApiKey; + let base = valid_claims(); + let expected_role = base.profile.database_role(); - let mut zero_authority = base.clone(); - zero_authority.authority_version = 0; - - let mut profile_role_mismatch = base.clone(); - profile_role_mismatch.db_role = DatabaseProfile::BusinessAdmin.database_role().into(); - - for claims in [ - no_actor, - both_actors, - oauth_with_credential, - api_key_with_client, - zero_authority, - profile_role_mismatch, + for (field, value) in [ + ("delegation_id", json!("dlg_01J00000000000000000000000")), + ("auth_method", json!("oauth")), + ("authority_version", json!(7)), + ("client_id", json!("cli_01J00000000000000000000000")), + ("credential_id", json!("crd_01J00000000000000000000000")), + ("db_role", json!("ordinary")), + ("db_profile", json!("ordinary")), ] { + let mut claims = serde_json::to_value(base.clone()).expect("claims JSON"); + claims + .as_object_mut() + .expect("claims object") + .insert(field.into(), value); let token = sign_payload(claims, KID, &key); assert_eq!( verifier.verify(&token, expected_role, NOW), - Err(JwtValidationError::InvalidClaims) + Err(JwtValidationError::InvalidToken), + "legacy field {field} must be denied as unknown" ); } - for (field, value) in [ - ("auth_method", json!("service")), - ("db_profile", json!("cluster-admin")), + for profile in [ + "cluster-admin", + "business-admin", + "database-developer", + "gomtm_ordinary", ] { let mut claims = serde_json::to_value(base.clone()).expect("claims JSON"); claims .as_object_mut() .expect("claims object") - .insert(field.into(), value); + .insert("profile".into(), json!(profile)); let token = sign_payload(claims, KID, &key); assert_eq!( verifier.verify(&token, expected_role, NOW), Err(JwtValidationError::InvalidToken), - "unknown {field} must fail closed" + "unknown profile {profile} must fail closed" ); } +} + +#[test] +fn subject_and_jti_id_matrix_fails_closed() { + let key = signing_key(); + let verifier = verifier(&jwks_with(vec![jwk_value(&key, KID)])); + let base = valid_claims(); + let expected_role = base.profile.database_role(); let maximum_id = "x".repeat(64); let mut boundary = base.clone(); boundary.subject = maximum_id.clone(); boundary.token_id = maximum_id.clone(); - boundary.delegation_id = maximum_id.clone(); - boundary.client_id = Some(maximum_id.clone()); let token = sign_payload(boundary, KID, &key); verifier .verify(&token, expected_role, NOW) @@ -299,13 +243,11 @@ fn actor_authority_profile_role_and_id_matrix_fails_closed() { for (field, expected) in [ ("sub", JwtValidationError::InvalidIdentity), ("jti", JwtValidationError::InvalidClaims), - ("delegation_id", JwtValidationError::InvalidIdentity), - ("client_id", JwtValidationError::InvalidIdentity), ] { - let mut claims = serde_json::to_value(base.clone()).expect("OAuth claims JSON"); + let mut claims = serde_json::to_value(base.clone()).expect("claims JSON"); claims .as_object_mut() - .expect("OAuth claims object") + .expect("claims object") .insert(field.into(), json!(invalid_id.clone())); let token = sign_payload(claims, KID, &key); assert_eq!( @@ -314,46 +256,6 @@ fn actor_authority_profile_role_and_id_matrix_fails_closed() { "field {field} must reject ID {invalid_id:?}" ); } - - let mut claims = - serde_json::to_value(valid_api_key_claims()).expect("API-key-derived claims JSON"); - claims - .as_object_mut() - .expect("API-key-derived claims object") - .insert("credential_id".into(), json!(invalid_id.clone())); - let token = sign_payload(claims, KID, &key); - assert_eq!( - verifier.verify(&token, expected_role, NOW), - Err(JwtValidationError::InvalidIdentity), - "credential_id must reject ID {invalid_id:?}" - ); - } -} - -#[test] -fn actor_schema_rejects_explicit_null_for_the_unselected_actor() { - let key = signing_key(); - let verifier = verifier(&jwks_with(vec![jwk_value(&key, KID)])); - let expected_role = DatabaseProfile::Ordinary.database_role(); - - let mut oauth = serde_json::to_value(valid_oauth_claims()).expect("OAuth claims JSON"); - oauth - .as_object_mut() - .expect("OAuth claims object") - .insert("credential_id".into(), Value::Null); - - let mut api_key = serde_json::to_value(valid_api_key_claims()).expect("API key claims JSON"); - api_key - .as_object_mut() - .expect("API key claims object") - .insert("client_id".into(), Value::Null); - - for claims in [oauth, api_key] { - let token = sign_payload(claims, KID, &key); - assert_eq!( - verifier.verify(&token, expected_role, NOW), - Err(JwtValidationError::InvalidToken) - ); } } @@ -396,7 +298,7 @@ fn jwks_rejects_duplicate_private_or_non_es256_keys() { fn token_header_rejects_missing_kid_embedded_keys_urls_and_custom_fields() { let key = signing_key(); let verifier = verifier(&jwks_with(vec![jwk_value(&key, KID)])); - let claims = valid_oauth_claims(); + let claims = valid_claims(); let valid = sign_payload(claims.clone(), KID, &key); for (field, value) in [ @@ -408,7 +310,7 @@ fn token_header_rejects_missing_kid_embedded_keys_urls_and_custom_fields() { header.insert(field.into(), value); }); assert_eq!( - verifier.verify(&invalid, claims.db_profile.database_role(), NOW + 1), + verifier.verify(&invalid, claims.profile.database_role(), NOW + 1), Err(JwtValidationError::InvalidHeader) ); } @@ -417,7 +319,7 @@ fn token_header_rejects_missing_kid_embedded_keys_urls_and_custom_fields() { header.remove("kid"); }); assert_eq!( - verifier.verify(&missing_kid, claims.db_profile.database_role(), NOW + 1,), + verifier.verify(&missing_kid, claims.profile.database_role(), NOW + 1,), Err(JwtValidationError::InvalidHeader) ); } @@ -426,11 +328,11 @@ fn token_header_rejects_missing_kid_embedded_keys_urls_and_custom_fields() { fn token_rejects_unknown_kid_wrong_algorithm_and_tampered_signature() { let key = signing_key(); let verifier = verifier(&jwks_with(vec![jwk_value(&key, KID)])); - let claims = valid_oauth_claims(); + let claims = valid_claims(); let unknown_kid = sign_payload(claims.clone(), "unknown-kid", &key); assert_eq!( - verifier.verify(&unknown_kid, claims.db_profile.database_role(), NOW + 1), + verifier.verify(&unknown_kid, claims.profile.database_role(), NOW + 1), Err(JwtValidationError::UnknownKeyId) ); @@ -439,7 +341,7 @@ fn token_rejects_unknown_kid_wrong_algorithm_and_tampered_signature() { header.insert("alg".into(), json!("RS256")); }); assert_eq!( - verifier.verify(&wrong_algorithm, claims.db_profile.database_role(), NOW + 1,), + verifier.verify(&wrong_algorithm, claims.profile.database_role(), NOW + 1,), Err(JwtValidationError::InvalidHeader) ); @@ -453,7 +355,7 @@ fn token_rejects_unknown_kid_wrong_algorithm_and_tampered_signature() { assert_eq!( verifier.verify( &segments.join("."), - claims.db_profile.database_role(), + claims.profile.database_role(), NOW + 1, ), Err(JwtValidationError::InvalidSignature) @@ -461,11 +363,11 @@ fn token_rejects_unknown_kid_wrong_algorithm_and_tampered_signature() { } #[test] -fn claims_reject_wrong_resource_time_actor_and_requested_role() { +fn claims_reject_wrong_resource_time_and_requested_role() { let key = signing_key(); let verifier = verifier(&jwks_with(vec![jwk_value(&key, KID)])); - let base = valid_oauth_claims(); - let expected_role = base.db_profile.database_role(); + let base = valid_claims(); + let expected_role = base.profile.database_role(); let mut invalid_claims = Vec::new(); @@ -499,14 +401,6 @@ fn claims_reject_wrong_resource_time_actor_and_requested_role() { ttl_too_short.expires_at = NOW + 29; invalid_claims.push(ttl_too_short); - let mut both_actors = base.clone(); - both_actors.credential_id = Some("crd_01J00000000000000000000000".into()); - invalid_claims.push(both_actors); - - let mut method_mismatch = base.clone(); - method_mismatch.auth_method = AuthMethod::ApiKey; - invalid_claims.push(method_mismatch); - for claims in invalid_claims { let token = sign_payload(claims, KID, &key); assert_eq!( @@ -526,14 +420,14 @@ fn claims_reject_wrong_resource_time_actor_and_requested_role() { fn claims_schema_rejects_missing_unknown_and_illegal_identity_fields() { let key = signing_key(); let verifier = verifier(&jwks_with(vec![jwk_value(&key, KID)])); - let base = valid_oauth_claims(); - let expected_role = base.db_profile.database_role(); + let base = valid_claims(); + let expected_role = base.profile.database_role(); let mut missing_claim = serde_json::to_value(base.clone()).expect("claims JSON"); missing_claim .as_object_mut() .expect("claims object") - .remove("delegation_id"); + .remove("profile"); let token = sign_payload(missing_claim, KID, &key); assert_eq!( verifier.verify(&token, expected_role, NOW), @@ -565,25 +459,27 @@ fn claims_schema_rejects_missing_unknown_and_illegal_identity_fields() { #[test] fn identity_codec_rejects_ambiguity_unknown_versions_and_oversize_values() { - let identity = AuthenticatedIdentity { - user_id: "usr_01J00000000000000000000000".into(), - actor: AuthenticatedActor::OAuthClient("cli_01J00000000000000000000000".into()), - delegation_id: "dlg_01J00000000000000000000000".into(), - auth_method: AuthMethod::OAuth, - authority_version: 7, - profile: DatabaseProfile::Ordinary, - }; + let identity = ordinary_identity(); let encoded = identity.encode_authn_id().expect("encode identity"); - assert!(encoded.starts_with("pggomtm:v2;")); + assert_eq!( + encoded, + "candidate.example.test:v1;u=usr_01J00000000000000000000000;p=ordinary" + ); assert_eq!(decode_authn_id(&encoded), Ok(identity.clone())); let system_user = format!("oauth:{encoded}"); assert_eq!(decode_system_user(&system_user), Ok(identity)); - assert!(decode_system_user(&system_user.replacen("pggomtm:v2", "pggomtm:v1", 1)).is_err()); + + let wrong_version = "candidate.example.test:v2;u=usr_01J00000000000000000000000;p=ordinary"; + assert!(decode_authn_id(wrong_version).is_err()); assert!(decode_authn_id(&encoded.replacen("p=ordinary", "p=business-admin", 1)).is_err()); - assert!(decode_system_user(&format!("scram:{}", encoded)).is_err()); + assert!(decode_system_user(&format!("scram:{encoded}")).is_err()); assert!(decode_authn_id(&"x".repeat(MAX_AUTHN_ID_BYTES + 1)).is_err()); assert!(decode_system_user(&format!("oauth:{}", "x".repeat(MAX_AUTHN_ID_BYTES + 1))).is_err()); + assert!(decode_system_user( + "oauth:pggomtm:v2;u=usr;actor=client:cli;d=dlg;m=oauth;a=7;p=ordinary" + ) + .is_err()); } #[test] @@ -602,11 +498,8 @@ fn profile_role_mapping_is_closed_and_non_inheriting() { ); let identity = AuthenticatedIdentity { user_id: "usr_profile_mapping".into(), - actor: AuthenticatedActor::OAuthClient("cli_profile_mapping".into()), - delegation_id: "dlg_profile_mapping".into(), - auth_method: AuthMethod::OAuth, - authority_version: 1, profile, + issuer_host: ISSUER_HOST.into(), }; assert!( identity @@ -618,45 +511,46 @@ fn profile_role_mapping_is_closed_and_non_inheriting() { } #[test] -fn v1_profiles_and_prefixed_roles_fail_closed() { +fn hyphenated_profiles_and_prefixed_roles_fail_closed() { let key = signing_key(); let verifier = verifier(&jwks_with(vec![jwk_value(&key, KID)])); - let base = valid_oauth_claims(); + let base = valid_claims(); - for (profile, role) in [ - ("business-admin", "gomtm_candidate_business_admin"), - ("database-developer", "gomtm_candidate_database_developer"), - ] { + for profile in ["business-admin", "database-developer"] { let mut claims = serde_json::to_value(base.clone()).expect("claims JSON"); - let object = claims.as_object_mut().expect("claims object"); - object.insert("db_profile".into(), json!(profile)); - object.insert("db_role".into(), json!(role)); + claims + .as_object_mut() + .expect("claims object") + .insert("profile".into(), json!(profile)); let token = sign_payload(claims, KID, &key); assert_eq!( - verifier.verify(&token, role, NOW), + verifier.verify(&token, profile, NOW), Err(JwtValidationError::InvalidToken), - "v1 profile {profile} must not enter the v2 contract" + "hyphenated profile {profile} must not enter the v1 contract" ); } - for role in ["gomtm_candidate_ordinary", "gomtm_ordinary"] { - let mut claims = base.clone(); - claims.db_role = role.into(); - let token = sign_payload(claims, KID, &key); + let ordinary_token = sign_payload(base.clone(), KID, &key); + for role in [ + "gomtm_candidate_ordinary", + "gomtm_ordinary", + "gomtm_candidate_business_admin", + "gomtm_platform_admin", + ] { assert_eq!( - verifier.verify(&token, role, NOW), - Err(JwtValidationError::InvalidClaims), - "prefixed role {role} must not enter the v2 contract" + verifier.verify(&ordinary_token, role, NOW), + Err(JwtValidationError::RequestedRoleMismatch), + "prefixed role {role} must not override the signed profile" ); } } #[test] -fn signed_or_requested_service_migration_cluster_and_unknown_roles_fail_closed() { +fn requested_service_migration_cluster_and_unknown_roles_fail_closed() { let key = signing_key(); let verifier = verifier(&jwks_with(vec![jwk_value(&key, KID)])); - let base = valid_oauth_claims(); - let ordinary_token = sign_payload(base.clone(), KID, &key); + let base = valid_claims(); + let ordinary_token = sign_payload(base, KID, &key); for forbidden_role in [ DatabaseProfile::BusinessAdmin.database_role(), @@ -665,20 +559,14 @@ fn signed_or_requested_service_migration_cluster_and_unknown_roles_fail_closed() "gomtm_test_migration_owner", "gomtm_platform_admin", "gomtm_candidate_unknown", + "service", + "migration", + "cluster-admin", ] { assert_eq!( verifier.verify(&ordinary_token, forbidden_role, NOW), Err(JwtValidationError::RequestedRoleMismatch), "requested role {forbidden_role} must not override the signed profile" ); - - let mut claims = base.clone(); - claims.db_role = forbidden_role.into(); - let token = sign_payload(claims, KID, &key); - assert_eq!( - verifier.verify(&token, forbidden_role, NOW), - Err(JwtValidationError::InvalidClaims), - "signed role {forbidden_role} must not expand the closed profile mapping" - ); } } diff --git a/tests/oauth_smoke_client.c b/tests/oauth_smoke_client.c index eba5730..263eb30 100644 --- a/tests/oauth_smoke_client.c +++ b/tests/oauth_smoke_client.c @@ -218,8 +218,8 @@ verify_authenticated_session(PGconn *conn, const char *expected_role, if (result == NULL || PQresultStatus(result) != PGRES_TUPLES_OK || PQntuples(result) != 1 || PQnfields(result) != 3 || PQgetisnull(result, 0, 0) || - strncmp(PQgetvalue(result, 0, 0), "oauth:pggomtm:v2;", - strlen("oauth:pggomtm:v2;")) != 0 || + strncmp(PQgetvalue(result, 0, 0), "oauth:candidate.example.test:v1;", + strlen("oauth:candidate.example.test:v1;")) != 0 || strcmp(PQgetvalue(result, 0, 1), expected_role) != 0 || strncmp(PQgetvalue(result, 0, 2), "18", 2) != 0) { diff --git a/tests/postgres_integration.sh b/tests/postgres_integration.sh index b470862..eeb5330 100755 --- a/tests/postgres_integration.sh +++ b/tests/postgres_integration.sh @@ -51,30 +51,6 @@ validate_artifacts() { done } -validate_executor_artifacts() { - local artifact_root="$1" - local artifact - for artifact in \ - mtmpg-executor \ - mtmpg_executor_fixture \ - mtmpg_executor_pg18_driver \ - pggomtm.so \ - executor_postgres_setup.sql \ - runtime/ca.crt \ - runtime/executor.crt \ - runtime/executor.key \ - runtime/hmac.secret \ - runtime/jwks.json \ - runtime/postgres.crt \ - runtime/postgres.key \ - runtime/signing-key.pem \ - runtime/validator.json; do - if test ! -f "${artifact_root}/${artifact}" || test -L "${artifact_root}/${artifact}"; then - fail "required executor integration artifact is unavailable: ${artifact}" - fi - done -} - run_integration() { test "$#" -eq 2 || fail "run requires a mode and one artifact directory" local mode="$1" @@ -87,7 +63,6 @@ run_integration() { test -d "${artifact_root}" || fail "artifact directory is unavailable" case "${mode}" in run) validate_artifacts "${artifact_root}" ;; - run-executor) validate_executor_artifacts "${artifact_root}" ;; *) fail "unknown integration mode" ;; esac @@ -120,7 +95,6 @@ run_integration() { usage() { printf '%s\n' \ 'usage: tests/postgres_integration.sh run ARTIFACT_DIRECTORY' \ - ' tests/postgres_integration.sh run-executor ARTIFACT_DIRECTORY' \ '' \ "runtime: ${POSTGRES_IMAGE}" } @@ -134,11 +108,6 @@ case "${1:-}" in shift run_integration run "$@" ;; - run-executor) - require_github_actions - shift - run_integration run-executor "$@" - ;; *) usage >&2 exit 2 diff --git a/tests/postgres_integration_container.sh b/tests/postgres_integration_container.sh index 0b87baa..b8a8c32 100755 --- a/tests/postgres_integration_container.sh +++ b/tests/postgres_integration_container.sh @@ -6,7 +6,6 @@ export LC_ALL=C ARTIFACT_ROOT="" ACTIVE_PGDATA="" -EXECUTOR_PID="" PKGLIBDIR="" fail() { @@ -81,16 +80,7 @@ stop_active_cluster() { ACTIVE_PGDATA="" } -stop_executor() { - if test -n "${EXECUTOR_PID}" && kill -0 "${EXECUTOR_PID}" >/dev/null 2>&1; then - kill -TERM "${EXECUTOR_PID}" >/dev/null 2>&1 || true - wait "${EXECUTOR_PID}" >/dev/null 2>&1 || true - fi - EXECUTOR_PID="" -} - cleanup() { - stop_executor stop_active_cluster rm -rf \ /etc/pggomtm \ @@ -102,11 +92,7 @@ cleanup() { /tmp/pggomtm-oauth-fixtures \ /tmp/pggomtm-production-backend-pgdata \ /tmp/pggomtm-production-backend-server.log \ - /tmp/pggomtm-production-backend-fixtures \ - /tmp/mtmpg-executor-pgdata \ - /tmp/mtmpg-executor-runtime \ - /tmp/mtmpg-executor-server.log \ - /tmp/mtmpg-executor-service.log + /tmp/pggomtm-production-backend-fixtures if test -n "${PKGLIBDIR}"; then rm -f \ @@ -122,13 +108,11 @@ trap cleanup EXIT INT TERM usage() { printf '%s\n' \ 'usage: tests/postgres_integration_container.sh run ARTIFACT_DIRECTORY' \ - ' tests/postgres_integration_container.sh run-executor ARTIFACT_DIRECTORY' \ '' \ 'matrices:' \ ' abi-runtime' \ ' oauth-gate' \ - ' production-backend' \ - ' executor-oauth-sql' + ' production-backend' } require_artifact() { @@ -168,48 +152,6 @@ verify_runtime() { "${ARTIFACT_ROOT}/pggomtm_oauth_smoke_fixture" } -verify_executor_runtime() { - test "$(id -u)" -eq 0 || fail "container harness must run as root" - command -v gosu >/dev/null || fail "official postgres image does not provide gosu" - command -v pg_config >/dev/null || fail "official postgres image does not provide pg_config" - [[ "$(pg_config --version)" =~ ^PostgreSQL\ 18\. ]] || \ - fail "container runtime is not PostgreSQL 18" - PKGLIBDIR="$(pg_config --pkglibdir)" - test "${PKGLIBDIR}" = "/usr/lib/postgresql/18/lib" || \ - fail "container runtime has an unexpected module directory" - - local artifact - for artifact in \ - mtmpg-executor \ - mtmpg_executor_fixture \ - mtmpg_executor_pg18_driver \ - pggomtm.so \ - executor_postgres_setup.sql \ - runtime/ca.crt \ - runtime/executor.crt \ - runtime/executor.key \ - runtime/hmac.secret \ - runtime/jwks.json \ - runtime/postgres.crt \ - runtime/postgres.key \ - runtime/signing-key.pem \ - runtime/validator.json; do - require_artifact "${artifact}" - done - chmod 0755 \ - "${ARTIFACT_ROOT}/mtmpg-executor" \ - "${ARTIFACT_ROOT}/mtmpg_executor_fixture" \ - "${ARTIFACT_ROOT}/mtmpg_executor_pg18_driver" - - local executor_linkage - if ! executor_linkage="$(ldd "${ARTIFACT_ROOT}/mtmpg-executor" 2>&1)"; then - fail "executor binary is incompatible with the PG18 runtime" - fi - if grep --quiet 'not found' <<<"${executor_linkage}"; then - fail "executor binary is incompatible with the PG18 runtime" - fi -} - install_module() { local source_name="$1" local module_name="$2" @@ -461,15 +403,6 @@ run_production_backend_smoke() { sed -i \ '1ilocal all business_admin oauth issuer="https://candidate.example.test/oauth/database" scope="database" validator=pggomtm delegate_ident_mapping=1' \ "${pgdata}/pg_hba.conf" - sed -i \ - '1ilocal all gomtm_candidate_business_admin oauth issuer="https://candidate.example.test/oauth/database" scope="database" validator=pggomtm delegate_ident_mapping=1' \ - "${pgdata}/pg_hba.conf" - sed -i \ - '1ilocal all gomtm_candidate_ordinary oauth issuer="https://candidate.example.test/oauth/database" scope="database" validator=pggomtm delegate_ident_mapping=1' \ - "${pgdata}/pg_hba.conf" - sed -i \ - '1ilocal all gomtm_ordinary oauth issuer="https://candidate.example.test/oauth/database" scope="database" validator=pggomtm delegate_ident_mapping=1' \ - "${pgdata}/pg_hba.conf" ACTIVE_PGDATA="${pgdata}" gosu postgres pg_ctl \ --pgdata="${pgdata}" \ @@ -478,7 +411,7 @@ run_production_backend_smoke() { --wait start >/dev/null psql_command \ - 'CREATE ROLE ordinary LOGIN; CREATE ROLE business_admin LOGIN; CREATE ROLE gomtm_candidate_ordinary LOGIN; CREATE ROLE gomtm_candidate_business_admin LOGIN; CREATE ROLE gomtm_ordinary LOGIN' + 'CREATE ROLE ordinary LOGIN; CREATE ROLE business_admin LOGIN' generate_fixtures "${fixture_root}" "${ARTIFACT_ROOT}/pggomtm_oauth_smoke_client" \ --expect-startup-rejected \ @@ -489,9 +422,9 @@ run_production_backend_smoke() { expect_allowed "${fixture_root}" oauth-ordinary ordinary expect_rejected "${fixture_root}" oauth-ordinary business_admin expect_rejected "${fixture_root}" tampered ordinary - expect_rejected "${fixture_root}" oauth-v1-profile gomtm_candidate_business_admin - expect_rejected "${fixture_root}" oauth-project-role gomtm_ordinary - expect_rejected "${fixture_root}" oauth-stage-role gomtm_candidate_ordinary + expect_rejected "${fixture_root}" oauth-legacy-delegation ordinary + expect_rejected "${fixture_root}" oauth-legacy-role ordinary + expect_rejected "${fixture_root}" oauth-legacy-profile ordinary stop_cluster verify_production_log "${log_file}" "${fixture_root}" @@ -500,164 +433,6 @@ run_production_backend_smoke() { printf 'PG18 production backend smoke passed\n' } -install_executor_runtime() { - local runtime_root="$1" - rm -rf /etc/pggomtm "${runtime_root}" - install -d -m 0555 /etc/pggomtm - install -m 0444 "${ARTIFACT_ROOT}/runtime/validator.json" /etc/pggomtm/validator.json - install -m 0444 "${ARTIFACT_ROOT}/runtime/jwks.json" /etc/pggomtm/jwks.json - - install -d -m 0700 -o postgres -g postgres "${runtime_root}" - install -m 0444 -o postgres -g postgres \ - "${ARTIFACT_ROOT}/runtime/ca.crt" \ - "${ARTIFACT_ROOT}/runtime/executor.crt" \ - "${runtime_root}" - install -m 0400 -o postgres -g postgres \ - "${ARTIFACT_ROOT}/runtime/executor.key" \ - "${ARTIFACT_ROOT}/runtime/hmac.secret" \ - "${ARTIFACT_ROOT}/runtime/signing-key.pem" \ - "${runtime_root}" -} - -run_executor_oauth_sql_matrix() { - local pgdata="/tmp/mtmpg-executor-pgdata" - local postgres_log="/tmp/mtmpg-executor-server.log" - local executor_log="/tmp/mtmpg-executor-service.log" - local runtime_root="/tmp/mtmpg-executor-runtime" - - install_module pggomtm.so pggomtm.so - install_executor_runtime "${runtime_root}" - install -d -m 0700 -o postgres -g postgres "${pgdata}" - gosu postgres initdb \ - --pgdata="${pgdata}" \ - --encoding=UTF8 \ - --no-locale \ - --auth-local=trust \ - --auth-host=reject >/dev/null - install -m 0600 -o postgres -g postgres \ - "${ARTIFACT_ROOT}/runtime/postgres.key" \ - "${pgdata}/server.key" - install -m 0644 -o postgres -g postgres \ - "${ARTIFACT_ROOT}/runtime/postgres.crt" \ - "${pgdata}/server.crt" - sed -i \ - '1ihostssl gomtm database_developer 0.0.0.0/0 oauth issuer="https://auth.example.test/database" scope="database" validator=pggomtm delegate_ident_mapping=1' \ - "${pgdata}/pg_hba.conf" - sed -i \ - '1ihostssl gomtm business_admin 0.0.0.0/0 oauth issuer="https://auth.example.test/database" scope="database" validator=pggomtm delegate_ident_mapping=1' \ - "${pgdata}/pg_hba.conf" - sed -i \ - '1ihostssl gomtm ordinary 0.0.0.0/0 oauth issuer="https://auth.example.test/database" scope="database" validator=pggomtm delegate_ident_mapping=1' \ - "${pgdata}/pg_hba.conf" - ACTIVE_PGDATA="${pgdata}" - gosu postgres pg_ctl \ - --pgdata="${pgdata}" \ - --log="${postgres_log}" \ - --options="-c listen_addresses='*' -k /tmp -c ssl=on -c log_min_messages=log -c oauth_validator_libraries=pggomtm" \ - --wait start >/dev/null - psql_file "${ARTIFACT_ROOT}/executor_postgres_setup.sql" - - grep --quiet ' executor$' /etc/hosts || printf '127.0.0.1 executor\n' >>/etc/hosts - gosu postgres env \ - MTMPG_EXECUTOR_AUDIENCE=https://postgres.example.test/database/main \ - MTMPG_EXECUTOR_HMAC_SECRET_PATH="${runtime_root}/hmac.secret" \ - MTMPG_EXECUTOR_ISSUER=https://auth.example.test/database \ - MTMPG_EXECUTOR_KEY_ID=executor-es256-test \ - MTMPG_EXECUTOR_LISTEN=0.0.0.0:8443 \ - MTMPG_EXECUTOR_POSTGRES_CA_PATH="${runtime_root}/ca.crt" \ - MTMPG_EXECUTOR_SIGNING_KEY_PATH="${runtime_root}/signing-key.pem" \ - MTMPG_EXECUTOR_TLS_CERT_PATH="${runtime_root}/executor.crt" \ - MTMPG_EXECUTOR_TLS_KEY_PATH="${runtime_root}/executor.key" \ - "${ARTIFACT_ROOT}/mtmpg-executor" >"${executor_log}" 2>&1 & - EXECUTOR_PID=$! - - sleep 1 - if ! kill -0 "${EXECUTOR_PID}" >/dev/null 2>&1; then - local startup_stage - for startup_stage in \ - hmac \ - signing_key \ - issuer \ - token_registry \ - libpq \ - database_tls \ - listen \ - https_tls \ - https_server; do - if grep --quiet "^executor startup failed: ${startup_stage}$" "${executor_log}"; then - fail "executor service exited during ${startup_stage} startup" - fi - done - fail "executor service exited before readiness" - fi - if ! MTMPG_EXECUTOR_CA_PATH="${runtime_root}/ca.crt" \ - MTMPG_EXECUTOR_HMAC_PATH="${runtime_root}/hmac.secret" \ - MTMPG_EXECUTOR_URL=https://executor:8443 \ - "${ARTIFACT_ROOT}/mtmpg_executor_pg18_driver"; then - local request_stage - for request_stage in \ - client \ - connect \ - begin \ - lock_budget \ - statement_budget \ - transaction_budget \ - statement \ - result \ - commit; do - if grep --quiet "^executor request failed: ${request_stage}$" "${executor_log}"; then - fail "executor request failed during ${request_stage}" - fi - done - fail "executor request matrix failed without a classified stage" - fi - - local active_sleep - active_sleep="$(psql_scalar "SELECT count(*) FROM pg_stat_activity WHERE state = 'active' AND query LIKE 'SELECT pg_sleep(%'")" - test "${active_sleep}" = "0" || fail "cancelled executor query remained active" - - stop_executor - stop_cluster - assert_file_contents_absent \ - "${runtime_root}/hmac.secret" \ - "${executor_log}" \ - "executor log disclosed the HMAC secret" - assert_file_contents_absent \ - "${runtime_root}/signing-key.pem" \ - "${executor_log}" \ - "executor log disclosed the signing key" - assert_no_extended_match \ - 'Authorization: Bearer|postgres(ql)?://|eyJ[A-Za-z0-9_-]+\.|BEGIN (EC )?PRIVATE KEY|SELECT pg_sleep|INSERT INTO app\.executor_probe|panicked at|stack backtrace' \ - "${executor_log}" \ - "executor log disclosed sensitive request content" - assert_no_extended_match \ - 'eyJ[A-Za-z0-9_-]+\.|BEGIN (EC )?PRIVATE KEY' \ - "${postgres_log}" \ - "PostgreSQL log disclosed executor token material" - - rm -rf "${pgdata}" "${runtime_root}" /etc/pggomtm - rm -f "${postgres_log}" "${executor_log}" "${PKGLIBDIR}/pggomtm.so" - printf 'PG18 executor OAuth and SQL integration matrix passed\n' -} - -run_executor() { - test "$#" -eq 1 || fail "run-executor requires exactly one artifact directory" - ARTIFACT_ROOT="$(realpath -- "$1" 2>/dev/null)" || \ - fail "artifact directory is unavailable" - test -d "${ARTIFACT_ROOT}" || fail "artifact directory is unavailable" - verify_executor_runtime - run_executor_oauth_sql_matrix - cleanup - - local leaked_path - for leaked_path in \ - /etc/pggomtm \ - /tmp/mtmpg-executor-pgdata \ - /tmp/mtmpg-executor-runtime; do - test ! -e "${leaked_path}" || fail "executor cleanup left runtime state: ${leaked_path}" - done -} - run_all() { test "$#" -eq 1 || fail "run requires exactly one artifact directory" ARTIFACT_ROOT="$(realpath -- "$1" 2>/dev/null)" || \ @@ -690,11 +465,6 @@ case "${1:-}" in shift run_all "$@" ;; - run-executor) - require_github_actions - shift - run_executor "$@" - ;; *) usage >&2 exit 2 diff --git a/tests/support/oauth_fixture.rs b/tests/support/oauth_fixture.rs index 2b03d39..a712c0f 100644 --- a/tests/support/oauth_fixture.rs +++ b/tests/support/oauth_fixture.rs @@ -9,19 +9,18 @@ use std::time::{SystemTime, UNIX_EPOCH}; use jaws::Token; use p256::ecdsa::{Signature, SigningKey}; use pggomtm::database_auth::{ - AuthMethod, AuthenticatedActor, AuthenticatedIdentity, DatabaseProfile, DatabaseTokenClaims, - MAX_AUTHN_ID_BYTES, decode_system_user, + AuthenticatedIdentity, DatabaseProfile, DatabaseTokenClaims, MAX_AUTHN_ID_BYTES, + decode_system_user, }; use serde::Serialize; -use serde_json::Value; +use serde_json::{Value, json}; const ISSUER: &str = "https://candidate.example.test/oauth/database"; const AUDIENCE: &str = "https://candidate.example.test/resources/database/gomtm-test"; const KID: &str = "candidate-es256-pgx-gate"; const SCENARIO: &str = "oauth-ordinary"; const SUBJECT: &str = "usr_oauth_ordinary"; -const CLIENT_ID: &str = "cli_oauth_ordinary"; -const DELEGATION_ID: &str = "dlg_oauth_ordinary"; +const ISSUER_HOST: &str = "candidate.example.test"; fn ordinary_claims(now: i64) -> DatabaseTokenClaims { DatabaseTokenClaims { @@ -32,24 +31,15 @@ fn ordinary_claims(now: i64) -> DatabaseTokenClaims { expires_at: now.saturating_add(299), token_id: format!("jti_{SCENARIO}"), scope: "database".into(), - delegation_id: DELEGATION_ID.into(), - auth_method: AuthMethod::OAuth, - authority_version: 1, - db_profile: DatabaseProfile::Ordinary, - db_role: DatabaseProfile::Ordinary.database_role().into(), - client_id: Some(CLIENT_ID.into()), - credential_id: None, + profile: DatabaseProfile::Ordinary, } } fn ordinary_identity() -> AuthenticatedIdentity { AuthenticatedIdentity { user_id: SUBJECT.into(), - actor: AuthenticatedActor::OAuthClient(CLIENT_ID.into()), - delegation_id: DELEGATION_ID.into(), - auth_method: AuthMethod::OAuth, - authority_version: 1, profile: DatabaseProfile::Ordinary, + issuer_host: ISSUER_HOST.into(), } } @@ -106,16 +96,16 @@ fn generate_fixtures(output_dir: &Path) -> Result<(), Box> { ordinary_oauth_token.as_bytes(), )?; - for (scenario, profile, role) in [ + for (scenario, field, value) in [ ( - "oauth-v1-profile", - "business-admin", - "gomtm_candidate_business_admin", + "oauth-legacy-delegation", + "delegation_id", + json!("dlg_oauth_ordinary"), ), - ("oauth-project-role", "ordinary", "gomtm_ordinary"), - ("oauth-stage-role", "ordinary", "gomtm_candidate_ordinary"), + ("oauth-legacy-role", "db_role", json!("ordinary")), + ("oauth-legacy-profile", "db_profile", json!("ordinary")), ] { - let token = sign_named_claims(now, profile, role, &key)?; + let token = sign_claims_with_extra_field(now, field, value, &key)?; write_ephemeral_fixture( &output_dir.join(format!("{scenario}.jwt")), token.as_bytes(), @@ -141,18 +131,17 @@ fn generate_fixtures(output_dir: &Path) -> Result<(), Box> { Ok(()) } -fn sign_named_claims( +fn sign_claims_with_extra_field( now: i64, - profile: &str, - role: &str, + field: &str, + value: Value, key: &SigningKey, ) -> Result> { let mut claims = serde_json::to_value(ordinary_claims(now))?; let object = claims .as_object_mut() .ok_or_else(|| invalid_input("database claims must be a JSON object"))?; - object.insert("db_profile".into(), Value::String(profile.into())); - object.insert("db_role".into(), Value::String(role.into())); + object.insert(field.into(), value); sign_claims(claims, key) } From bdbae1d3edf39741118d15387eb45326d0a21dec Mon Sep 17 00:00:00 2001 From: a Date: Mon, 17 Aug 2026 08:37:25 +0000 Subject: [PATCH 2/3] style(pggomtm): apply remote rustfmt to jwt_identity matrix --- tests/jwt_identity.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/jwt_identity.rs b/tests/jwt_identity.rs index d7ac1ba..841dede 100644 --- a/tests/jwt_identity.rs +++ b/tests/jwt_identity.rs @@ -353,11 +353,7 @@ fn token_rejects_unknown_kid_wrong_algorithm_and_tampered_signature() { }; segments[2].replace_range(..1, replacement); assert_eq!( - verifier.verify( - &segments.join("."), - claims.profile.database_role(), - NOW + 1, - ), + verifier.verify(&segments.join("."), claims.profile.database_role(), NOW + 1,), Err(JwtValidationError::InvalidSignature) ); } @@ -476,10 +472,10 @@ fn identity_codec_rejects_ambiguity_unknown_versions_and_oversize_values() { assert!(decode_system_user(&format!("scram:{encoded}")).is_err()); assert!(decode_authn_id(&"x".repeat(MAX_AUTHN_ID_BYTES + 1)).is_err()); assert!(decode_system_user(&format!("oauth:{}", "x".repeat(MAX_AUTHN_ID_BYTES + 1))).is_err()); - assert!(decode_system_user( - "oauth:pggomtm:v2;u=usr;actor=client:cli;d=dlg;m=oauth;a=7;p=ordinary" - ) - .is_err()); + assert!( + decode_system_user("oauth:pggomtm:v2;u=usr;actor=client:cli;d=dlg;m=oauth;a=7;p=ordinary") + .is_err() + ); } #[test] From 713e8c4a48a6737fb1b700d384dfaeddbec2a52b Mon Sep 17 00:00:00 2001 From: a Date: Mon, 17 Aug 2026 08:49:46 +0000 Subject: [PATCH 3/3] docs(openspec): mark slim-validator-only-database-token-v1 tasks complete with CI evidence --- .../tasks.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/openspec/changes/slim-validator-only-database-token-v1/tasks.md b/openspec/changes/slim-validator-only-database-token-v1/tasks.md index 6a65ed8..f66a49a 100644 --- a/openspec/changes/slim-validator-only-database-token-v1/tasks.md +++ b/openspec/changes/slim-validator-only-database-token-v1/tasks.md @@ -1,13 +1,13 @@ ## 1. 规划与基线 - [x] 1.1 读取 AGENTS/MAINTAINERS/docs/openspec 现状,确认 3 个 active change 与 executor 边界 -- [x] 1.2 建立 worktree 与分支 slim-validator-only(base=origin/main fba114e) +- [x] 1.2 建立 worktree 与分支 slim-validator-only(base=origin/main fba114e,rebase 到 1d407dc) - [x] 1.3 创建本 openspec change 并完成 proposal/design/specs/tasks ## 2. 删除 executor 产品 - [x] 2.1 删除 executor/ 目录(源码+测试+Dockerfile) -- [x] 2.2 根 Cargo.toml 移除 executor workspace 成员 +- [x] 2.2 根 Cargo.toml 移除 executor workspace 成员(并移除孤儿 dev-dep sha2) - [x] 2.3 .github/workflows/ci.yml 移除 executor CI 步骤(executor_domain/executor_pg18/executor_image 及 validator 内 executor 解析/libpq probe) - [x] 2.4 .github/workflows/release.yml 移除 executor-v* release 入口与 publish 内 executor 分支 - [x] 2.5 根 Dockerfile 与 .dockerignore 移除 executor COPY/白名单 @@ -35,7 +35,14 @@ ## 6. 验证与交付 -- [x] 6.1 openspec validate --strict 通过 +- [x] 6.1 openspec validate --strict 通过(4 change + 2 spec 全绿) - [x] 6.2 自审 diff(最小变更、无无关重构) -- [ ] 6.3 提交、推送分支、gh pr create(label enhancement+rust) -- [ ] 6.4 轮询 CI 至全绿(失败只向前修复) +- [x] 6.3 提交、推送分支、gh pr create(PR #11,label enhancement+rust) +- [x] 6.4 轮询 CI 至全绿(失败只向前修复) + +## 验证证据 + +- PR:https://github.com/codeh007/mtmpg/pull/11 +- GREEN run:https://github.com/codeh007/mtmpg/actions/runs/32011254449(head bdbae1d,conclusion success) +- 首次 RED(cargo fmt):run 32010963197,两处 jwt_identity.rs 格式差异,追加 commit bdbae1d 修复后转绿 +- 分支:slim-validator-only(base main 1d407dc,经 rebase 解决 executor modify/delete 冲突,无 force push)