diff --git a/.Codex/plans/migration-safety.md b/.Codex/plans/migration-safety.md new file mode 100644 index 00000000..89b2ec2b --- /dev/null +++ b/.Codex/plans/migration-safety.md @@ -0,0 +1,25 @@ +# Migration safety (#167, #174, #175, #176) + +1. Centralize validation of tenant transitions and rename hints in DiffEngine. Reject tenant annotation transitions before any DDL or metadata write, requiring a manual migration/backfill and matching metadata. Preserve backend integrity until automatic backfill is available. +2. Add typed FieldAnnotation::RenamedFrom with DSL parsing and display, retained in signed serialized schemas. Collect active hints centrally, validate missing/ambiguous/conflicting sources, and retain no-op hints on repeat apply. +3. Preflight serve migrations before executing user schema changes; require explicit allow-destructive-migrations opt-in. Gate runtime schema PUT with explicit body opt-in and preserve existing schema annotations. +4. Reconcile PostgreSQL relation foreign keys consistently at schema persistence/startup checkpoints, after tables exist, using explicit restrictive delete behavior and idempotent checks. Validate existing data and report failures. Translate 23503 into typed conflict errors. +5. Reject empty PostgreSQL UPDATE fields before emitting SQL. + +Tests: DSL roundtrip and invalid rename hints, repeat apply, tenant add/remove/root-parent refusal, startup/runtime destructive refusals, PostgreSQL fresh/add/backfill/cyclic FK behavior and deletion/write error mapping; nextest and clippy, cross-backend compilation. No dependencies required. Public annotation and error variants warrant minor release; root owns version bump. + +## Atomic runtime schema persistence followup + +Add apply_schema_change(name, steps, optional definition) to SchemaBackend and dynamic adapter with unsupported default. Each supported backend executes schema DDL, required validation, metadata upsert/delete, and integrity reconciliation in a single transaction. Extract existing statement/connection helpers to preserve rename behavior. PostgreSQL uses transaction-local metadata and strict FK finalization; cache invalidates only after commit. Runtime DELETE keeps its existing registry-only semantics (parent decision). Add focused rollback contracts exercising failure after DDL and metadata-only/deletion paths, run focused nextest and Clippy only; CI owns broad gates. + +## SurrealDB transactional rename visibility + +Measured SELECT snapshots show REMOVE FIELD followed by UPDATE in the same transaction discards unrelated FLEXIBLE object data. Individual statements in separate transactions do not. Preserve one transaction: copy to fully defined destination, temporarily relax source required/default constraints, unset old values while source definition still exists, defer only rename-source definition removals until all data writes finish. Regression must retain unrelated nested data and verify a later write still works, besides renamed nested data and metadata rollback. + +## SQL Server sparse update parity + +CI exposed that SQL Server replaces its entire JSON payload on EntityStore::update, unlike PostgreSQL/SurrealDB and the runtime's field-filtered partial update path. Implement one bound JSON_MODIFY UPDATE with OUTPUT inserted row, preserving omitted values and explicit tagged nulls without a read/modify/write race. Clarify trait semantics, retain rename regression, and add SQL Server direct null/empty-update checks. Run focused SQL Server integration tests only. + +## Supported SurrealDB engine correction + +Reproduced simultaneous successful attachment clears on the 2.6 Mem engine even with explicit transactions. SurrealKV0.9.3 advances commit oracle before index publication; snapshots can mix old data with the newer conflict watermark. Latest2.6.4 uses identical engine; no compatible SurrealKV0.9 patch exists. Upgrade the backend SDK to stable3.3.0, whose Mem engine uses SurrealMX0.27 completed-commit watermark. Adapt native value/error APIs without changing entity semantics, add multi-thread competing clear/replace regression, run focused CAS and migration tests. Root approved supported upgrade over local mutex or vendored engine fork. diff --git a/.github/workflows/cel-kani.yml b/.github/workflows/cel-kani.yml index 41d9e87a..3f7a7ce0 100644 --- a/.github/workflows/cel-kani.yml +++ b/.github/workflows/cel-kani.yml @@ -27,7 +27,7 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 - name: Install build prerequisites run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - name: Install pinned integer solver diff --git a/.github/workflows/mssql-integration.yml b/.github/workflows/mssql-integration.yml index fd732954..f032783b 100644 --- a/.github/workflows/mssql-integration.yml +++ b/.github/workflows/mssql-integration.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - "crates/schema-forge-mssql/**" + - "crates/schema-forge-core/**" + - "crates/schema-forge-backend/**" - "crates/schema-forge-cli/Cargo.toml" - "crates/schema-forge-acton/Cargo.toml" - "Cargo.lock" @@ -23,7 +25,7 @@ jobs: - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.97.1 - name: Install Protocol Buffers compiler shell: pwsh @@ -79,14 +81,20 @@ jobs: run: sudo apt-get update && sudo apt-get install -y protobuf-compiler libkrb5-dev - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.97.1 - name: Rust cache uses: Swatinem/rust-cache@v2 + - name: Install nextest + run: cargo install cargo-nextest --locked + - name: Run SQL Server integration test env: TEST_NAME: ${{ matrix.test }} run: >- - cargo test --package schema-forge-mssql --test sql_server - "$TEST_NAME" -- --ignored --exact --nocapture + cargo nextest run --package schema-forge-mssql --test sql_server + --run-ignored only -E "test(=$TEST_NAME)" + - name: Deny SQL Server lints + if: matrix.version == 2019 + run: cargo clippy -p schema-forge-mssql --all-targets -- -D warnings diff --git a/.github/workflows/postgres-conditional.yml b/.github/workflows/postgres-conditional.yml index b328de0e..f4903f54 100644 --- a/.github/workflows/postgres-conditional.yml +++ b/.github/workflows/postgres-conditional.yml @@ -3,6 +3,9 @@ name: PostgreSQL conditional mutations on: pull_request: paths: + - "crates/schema-forge-core/**" + - "crates/schema-forge-dsl/**" + - "crates/schema-forge-surrealdb/**" - "crates/schema-forge-backend/**" - "crates/schema-forge-postgres/**" - "crates/schema-forge-acton/**" @@ -17,7 +20,7 @@ permissions: jobs: conditional: runs-on: ubuntu-24.04 - timeout-minutes: 40 + timeout-minutes: 60 services: postgres: image: postgres:16 @@ -41,27 +44,39 @@ jobs: - uses: actions/checkout@v6 - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler libkrb5-dev postgresql-client - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - name: Install nextest run: cargo install cargo-nextest --locked - name: Run storage concurrency cases - run: cargo nextest run -p schema-forge-backend -p schema-forge-postgres --run-ignored all + run: cargo nextest run -p schema-forge-backend -p schema-forge-postgres --run-ignored all --no-fail-fast - name: Prepare disposable HTTP namespace run: psql -X -v ON_ERROR_STOP=1 -c 'CREATE SCHEMA conditional_http' + - name: Run complete runtime and schema suites + run: cargo nextest run -p schema-forge-acton -p schema-forge-surrealdb -p schema-forge-core -p schema-forge-dsl --features schema-forge-acton/postgres,schema-forge-acton/graphql --no-fail-fast - name: Run HTTP authorization and concurrency cases + if: ${{ !cancelled() }} env: SCHEMAFORGE_TEST_POSTGRES_DISPOSABLE: "1" SCHEMAFORGE_TEST_POSTGRES_URL: postgres://schemaforge:schemaforge-test@localhost:5432/schemaforge_test?options=-csearch_path%3Dconditional_http - run: cargo nextest run -p schema-forge-acton --features postgres --test conditional_entities --run-ignored all - - name: Run audit authorization and bounded verification regressions - run: cargo nextest run -p schema-forge-acton --features postgres --test audit_api - - name: Run exact authorized count policy and paging regressions - run: | - cargo nextest run -p schema-forge-acton --features postgres --lib --test authorization_context -E 'test(authz::read_scope) | binary(authorization_context)' - cargo nextest run -p schema-forge-acton --features postgres --test auth_demo -E 'test(readable_paging)' - - name: Check CLI preparation behavior - run: cargo nextest run -p schema-forge-cli --no-default-features --features postgres -E 'test(explicit_revision_preparation)' + run: cargo nextest run -p schema-forge-acton --features postgres,graphql --test conditional_entities --run-ignored only --no-fail-fast + - name: Check CLI preparation and security behavior + if: ${{ !cancelled() }} + run: cargo nextest run -p schema-forge-cli --no-default-features --features postgres --no-fail-fast + - name: Check SurrealDB CLI integration + if: ${{ !cancelled() }} + run: cargo nextest run -p schema-forge-cli --no-fail-fast + - name: Deny runtime and backend lints + if: ${{ !cancelled() }} + run: cargo clippy -p schema-forge-acton -p schema-forge-surrealdb -p schema-forge-core -p schema-forge-dsl -p schema-forge-backend -p schema-forge-postgres --features schema-forge-acton/postgres,schema-forge-acton/graphql --all-targets -- -D warnings + - name: Deny CLI lints + if: ${{ !cancelled() }} + run: cargo clippy -p schema-forge-cli --no-default-features --features postgres --all-targets -- -D warnings + - name: Deny SurrealDB CLI lints + if: ${{ !cancelled() }} + run: cargo clippy -p schema-forge-cli --all-targets -- -D warnings - name: Clean disposable HTTP namespace if: always() run: psql -X -v ON_ERROR_STOP=1 -c 'DROP SCHEMA IF EXISTS conditional_http CASCADE' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80ce501d..97df79e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.97.1 - name: Install Linux system build dependencies if: runner.os == 'Linux' @@ -76,6 +76,13 @@ jobs: - name: Verify Rust toolchain run: rustc --version && cargo --version + - name: Verify release tag matches CLI version + shell: bash + run: | + PACKAGE_ID="$(cargo pkgid --locked -p schema-forge-cli)" + PACKAGE_VERSION="${PACKAGE_ID##*#}" + test "$TAG_NAME" = "v${PACKAGE_VERSION##*@}" + - name: Install cosign uses: sigstore/cosign-installer@v3 @@ -119,7 +126,7 @@ jobs: # Points rust-embed at the verified console bundle (build.rs re-exports # it for the `#[folder = "$SCHEMAFORGE_CONSOLE_DIST"]` interpolation). SCHEMAFORGE_CONSOLE_DIST: ${{ github.workspace }}/console-dist - run: cargo build --release --package schema-forge-cli --no-default-features --features ${{ matrix.backend }},embedded-console + run: cargo build --locked --release --package schema-forge-cli --no-default-features --features ${{ matrix.backend }},embedded-console - name: Sign and verify Windows binary (Sigstore keyless) if: runner.os == 'Windows' @@ -170,6 +177,19 @@ jobs: contents: write id-token: write steps: + - uses: actions/checkout@v6 + + - name: Prepare release notes from changelog + env: + TAG_NAME: ${{ github.ref_name }} + run: | + awk -v version="${TAG_NAME#v}" ' + index($0, "## [" version "]") == 1 { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md > RELEASE_NOTES.md + test -s RELEASE_NOTES.md + - uses: actions/download-artifact@v8 with: merge-multiple: true @@ -192,6 +212,7 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v3 with: + body_path: RELEASE_NOTES.md generate_release_notes: true files: | schemaforge-*.tar.gz diff --git a/.github/workflows/site-e2e.yml b/.github/workflows/site-e2e.yml index e0ad279a..8020de9e 100644 --- a/.github/workflows/site-e2e.yml +++ b/.github/workflows/site-e2e.yml @@ -25,7 +25,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y protobuf-compiler libkrb5-dev - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.97.1 - name: Rust cache uses: Swatinem/rust-cache@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e1ba7b..a18fc5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,84 @@ is pre-1.0; breaking changes bump the **minor** version per ## [Unreleased] +## [0.45.0] - 2026-09-24 + +### Security and correctness + +- Tenant scope applies only to tenanted schemas. Tenant roots are authorized by + their own identity, including legacy roots with NULL metadata. Unattributed + child records fail closed for tenant users, and PUT/PATCH cannot move a record + to another tenant unless the caller is a platform administrator. Invitations + validate both the configured tenant type and the caller's effective scope. +- Field write authorization precedes defaults, computed expressions, validation + rules, and hooks. Retained caller input is reauthorized after denied fields + are removed. Rules see optional absent fields as null; required fields are + checked after server-supplied values are available. Filtered empty PATCH + requests no longer emit invalid SQL. PUT rules and persisted optional values + now agree: PUT clears omitted writable optional fields and preserves denied + or server-managed fields. PATCH remains a partial update. +- SurrealDB uses the supported 3.3 storage engine, correcting a concurrency race + that could let two conditional writes both succeed. +- SQL Server updates merge supplied fields atomically, preserving omitted fields + and explicit nulls. Concurrent updates to different fields no longer replace + each other's stored values. +- Canonical mutation handlers enforce concrete Cedar authorization before + operator policies and hooks. An operator policy can further restrict access + but cannot bypass tenant or Cedar checks. +- GraphQL creates, updates, and deletes share the REST mutation pipeline. GraphQL reads, + relations, and deletes enforce concrete Cedar decisions. Each request captures + matching live definitions and policies, so runtime field restrictions also + govern existing GraphQL fields. Nested relations honor operator visibility + restrictions, and SurrealDB to-many relations retain their record references. + Unproven raw + GraphQL totals are withheld instead of disclosing counts of inaccessible rows. +- CLI help hides secret environment values, including database and server URLs. + `serve --host` controls the actual listener and accepts validated IPv4/IPv6 + addresses. The default listener is now the documented `127.0.0.1`. + +### Schema migrations + +- Declare field renames with `@renamed_from("old_name")` to preserve data instead + of dropping and recreating a field. Rename hints are validated before migration + and remain valid after the rename completes. +- `serve` preflights startup plans and refuses destructive changes unless + `--allow-destructive-migrations` is supplied. Runtime schema PUT requires + `allow_destructive_migrations: true` for destructive plans, including lossy + type conversions. Proposed tenant hierarchies and custom Cedar policies are + validated before application schema changes. +- PostgreSQL to-one relation foreign keys are installed consistently for fresh, + altered, and legacy schemas. Missing targets or orphan references fail schema + administration clearly. Integrity violations return HTTP 409 + `foreign_key_violation` rather than 502, without exposing SQL or row values. +- Automatic changes to an existing schema's tenancy are refused because row + ownership cannot be inferred safely. The + [migration guide](docs/migrations/safe-schema-changes.md) documents explicit + ownership backfill, constraint changes, and metadata updates. +- Explicit empty access lists produce diagnostics; documentation clarifies that + within access annotations, empty and omitted role lists grant access to every + authenticated user. + +### Upgrade notes + +Source builds use Rust 1.97.1. SurrealDB deployments require a stable 3.x server at version 3.3 or newer. Upgrade remote servers before +connecting this release; embedded development databases use the bundled engine. + +Review pending schema changes before restarting. Destructive startup migrations +now require deliberate opt-in. PostgreSQL schema administration repairs missing +foreign keys and can require cleanup of existing orphan references. Tenanted +children created by a platform administrator require an explicit tenant. +Runtime changes to tenant topology require offline schema application and a +restart so the actor and HTTP middleware activate the same configuration. + +Rust embedders must update the schema-aware tenant helper and rule-binding call +signatures. GraphQL registration now requires initialized +`AppState`; see [GraphQL writes](docs/graphql-writes.md). +The backend trait adds a defaulted `finalize_schema_migrations` operation for +completing batch migration integrity checks. Custom backends must implement +the atomic `apply_schema_change` operation to support runtime schema creation +and updates; the default refuses these operations rather than applying a +partial migration. + ## [0.44.2] - 2026-09-10 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 1144b764..02767051 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,7 +17,7 @@ dependencies = [ "derive-new", "derive_more", "mti", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -40,7 +40,7 @@ dependencies = [ "acton-macro", "anyhow", "async-trait", - "dashmap 6.1.0", + "dashmap", "derive-new", "dyn-clone", "futures", @@ -68,12 +68,12 @@ dependencies = [ "argon2", "async-trait", "axum", - "base64 0.22.1", + "base64", "bb8", "bb8-tiberius", "blake3", "chrono", - "dashmap 6.1.0", + "dashmap", "figment", "futures", "governor", @@ -96,8 +96,8 @@ dependencies = [ "serde", "serde_json", "sqlx", - "surrealdb 3.0.5", - "thiserror 2.0.18", + "surrealdb", + "thiserror 2.0.21", "tiberius", "tokio", "tokio-rustls 0.26.4", @@ -162,40 +162,17 @@ dependencies = [ [[package]] name = "affinitypool" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dde2a385b82232b559baeec740c37809051c596f9b56e7da0d0da2c8e8f54f6" -dependencies = [ - "async-channel", - "num_cpus", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "affinitypool" -version = "0.4.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a58b64a64aecad4ba7f2ccf0f79115f5d2d184b1e55307f78c20be07adc6633" +checksum = "a487a26c23775316bc010e9425fd7cf046c384ab37efbb2cfd4ba87a58f68c6d" dependencies = [ - "crossbeam", - "libc", + "arc-swap", + "async-task", + "crossbeam-deque", + "crossbeam-utils", "num_cpus", "parking_lot 0.12.5", - "thiserror 2.0.18", - "tokio", - "winapi", -] - -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", + "thiserror 2.0.21", ] [[package]] @@ -228,14 +205,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "ammonia" -version = "4.1.2" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +checksum = "3f7ed3ac4252be4d3e36fb589ed5659356842587b21a31596a9dc89d3b2f5e2a" dependencies = [ "cssparser", "html5ever", "maplit", - "tendril", "url", ] @@ -307,17 +283,11 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "any_ascii" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90c6333e01ba7235575b6ab53e5af10f1c327927fd97c36462917e289557ea64" - [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -367,12 +337,6 @@ dependencies = [ "password-hash 0.5.0", ] -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" version = "0.5.2" @@ -397,22 +361,13 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "ascii-canvas" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" -dependencies = [ - "term 0.7.0", -] - [[package]] name = "ascii-canvas" version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" dependencies = [ - "term 1.2.1", + "term", ] [[package]] @@ -433,7 +388,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", ] @@ -515,20 +470,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-executor" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - [[package]] name = "async-graphql" version = "7.2.1" @@ -541,14 +482,14 @@ dependencies = [ "async-io", "async-trait", "asynk-strim", - "base64 0.22.1", + "base64", "bytes", "fast_chemail", "fnv", "futures-util", "handlebars", "http 1.4.0", - "indexmap 2.13.0", + "indexmap 2.14.2", "mime", "multer", "num-traits", @@ -559,7 +500,7 @@ dependencies = [ "serde_urlencoded", "static_assertions_next", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -591,9 +532,9 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "strum", + "strum 0.27.2", "syn 2.0.119", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -615,7 +556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e3ef112905abea9dea592fc868a6873b10ebd3f983e83308f995d6284e9ba41" dependencies = [ "bytes", - "indexmap 2.13.0", + "indexmap 2.14.2", "serde", "serde_json", ] @@ -700,17 +641,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "async_io_stream" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" -dependencies = [ - "futures", - "pharos", - "rustc_version", -] - [[package]] name = "asynchronous-codec" version = "0.6.2" @@ -928,7 +858,7 @@ dependencies = [ "http 0.2.12", "http 1.4.0", "http-body 1.0.1", - "lru 0.16.4", + "lru", "percent-encoding", "regex-lite", "sha2 0.10.9", @@ -1271,7 +1201,7 @@ checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "axum-macros", - "base64 0.22.1", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -1293,7 +1223,7 @@ dependencies = [ "sha1 0.10.6", "sync_wrapper", "tokio", - "tokio-tungstenite 0.28.0", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -1366,12 +1296,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -1419,26 +1343,13 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "bcrypt" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" -dependencies = [ - "base64 0.22.1", - "blowfish", - "getrandom 0.2.17", - "subtle", - "zeroize", -] - [[package]] name = "bcrypt" version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a0f5948f30df5f43ac29d310b7476793be97c50787e6ef4a63d960a0d0be827" dependencies = [ - "base64 0.22.1", + "base64", "blowfish", "getrandom 0.3.4", "subtle", @@ -1451,15 +1362,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bincode" version = "2.0.1" @@ -1523,30 +1425,15 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec 0.6.3", -] - [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec 0.8.0", + "bit-vec", ] -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bit-vec" version = "0.8.0" @@ -1568,18 +1455,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake2" version = "0.10.6" @@ -1591,16 +1466,15 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec 0.7.6", "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.2.17", + "cpufeatures 0.3.0", ] [[package]] @@ -1645,7 +1519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220" dependencies = [ "async-stream", - "base64 0.22.1", + "base64", "bitflags 2.11.0", "bollard-buildkit-proto", "bollard-stubs", @@ -1672,7 +1546,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_urlencoded", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "tokio", "tokio-stream", @@ -1701,7 +1575,7 @@ version = "1.53.1-rc.29.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" dependencies = [ - "base64 0.22.1", + "base64", "bollard-buildkit-proto", "bytes", "prost", @@ -1759,24 +1633,25 @@ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytecheck" -version = "0.6.12" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" dependencies = [ "bytecheck_derive", "ptr_meta", + "rancor", "simdutf8", ] [[package]] name = "bytecheck_derive" -version = "0.6.12" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 3.0.3", ] [[package]] @@ -1784,6 +1659,20 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] name = "byteorder" @@ -1793,9 +1682,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -1857,64 +1746,24 @@ dependencies = [ "shlex", ] -[[package]] -name = "cedar-policy" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d91e3b10a0f7f2911774d5e49713c4d25753466f9e11d1cd2ec627f8a2dc857" -dependencies = [ - "cedar-policy-core 2.4.2", - "cedar-policy-validator", - "itertools 0.10.5", - "lalrpop-util 0.20.2", - "ref-cast", - "serde", - "serde_json", - "smol_str 0.2.2", - "thiserror 1.0.69", -] - [[package]] name = "cedar-policy" version = "4.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50368b44367cd7664627bbee9bfe5721d10ab2433daf77645833645e8eb746da" dependencies = [ - "cedar-policy-core 4.9.1", + "cedar-policy-core", "cedar-policy-formatter", "itertools 0.14.0", "linked-hash-map", - "miette 7.6.0", + "miette", "ref-cast", "semver", "serde", "serde_json", "serde_with", - "smol_str 0.3.6", - "thiserror 2.0.18", -] - -[[package]] -name = "cedar-policy-core" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd2315591c6b7e18f8038f0a0529f254235fd902b6c217aabc04f2459b0d9995" -dependencies = [ - "either", - "ipnet", - "itertools 0.10.5", - "lalrpop 0.20.2", - "lalrpop-util 0.20.2", - "lazy_static", - "miette 5.10.0", - "regex", - "rustc_lexer", - "serde", - "serde_json", - "serde_with", - "smol_str 0.2.2", - "stacker", - "thiserror 1.0.69", + "smol_str", + "thiserror 2.0.21", ] [[package]] @@ -1927,11 +1776,11 @@ dependencies = [ "educe", "either", "itertools 0.14.0", - "lalrpop 0.22.2", - "lalrpop-util 0.22.2", + "lalrpop", + "lalrpop-util", "linked-hash-map", "linked_hash_set", - "miette 7.6.0", + "miette", "nonempty", "ref-cast", "regex", @@ -1939,9 +1788,9 @@ dependencies = [ "serde", "serde_json", "serde_with", - "smol_str 0.3.6", + "smol_str", "stacker", - "thiserror 2.0.18", + "thiserror 2.0.21", "unicode-security", ] @@ -1951,30 +1800,13 @@ version = "4.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18c03e1d143e1c222d2ea48453ab4f4b11e545ac5a268a15bb163769fe568b90" dependencies = [ - "cedar-policy-core 4.9.1", + "cedar-policy-core", "itertools 0.14.0", "logos 0.16.1", - "miette 7.6.0", + "miette", "pretty", "regex", - "smol_str 0.3.6", -] - -[[package]] -name = "cedar-policy-validator" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e756e1b2a5da742ed97e65199ad6d0893e9aa4bd6b34be1de9e70bd1e6adc7df" -dependencies = [ - "cedar-policy-core 2.4.2", - "itertools 0.10.5", - "serde", - "serde_json", - "serde_with", - "smol_str 0.2.2", - "stacker", - "thiserror 1.0.69", - "unicode-security", + "smol_str", ] [[package]] @@ -2160,7 +1992,7 @@ version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -2358,6 +2190,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crawdad" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abed0ad19907fc8472dae05f0418dfa82fdf0eaef18427c7c7cc4e42db41534b" + [[package]] name = "crc" version = "3.3.0" @@ -2412,19 +2250,6 @@ dependencies = [ "x509-cert", ] -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -2436,9 +2261,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -2455,9 +2280,9 @@ dependencies = [ [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] @@ -2529,27 +2354,15 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.35.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59" dependencies = [ - "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.3", "smallvec", ] -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.119", -] - [[package]] name = "csv" version = "1.4.0" @@ -2607,6 +2420,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "daachorse" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd10668980c9e7ba8aa2e616207d9ec52f7db66ebb47db857ed1f3c342530dad" + [[package]] name = "darling" version = "0.20.11" @@ -2711,19 +2530,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core 0.9.12", -] - [[package]] name = "dashmap" version = "6.1.0" @@ -2958,16 +2764,6 @@ dependencies = [ "dirs-sys", ] -[[package]] -name = "dirs-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - [[package]] name = "dirs-sys" version = "0.5.0" @@ -2976,19 +2772,66 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] [[package]] -name = "dirs-sys-next" -version = "0.1.2" +name = "diskann" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +checksum = "3ead115d8a4f3917c1ae9c3db0aa4dd56251841864ef8637329969f8e5145dd3" dependencies = [ - "libc", - "redox_users 0.4.6", - "winapi", + "anyhow", + "bytemuck", + "diskann-utils", + "diskann-vector", + "diskann-wide", + "futures-util", + "half", + "hashbrown 0.16.1", + "num-traits", + "rand 0.9.4", + "thiserror 2.0.21", + "tokio", +] + +[[package]] +name = "diskann-utils" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac627181d402b5dd63318e434fc2e7107843be0a10cf0a6a99a24666453239bc" +dependencies = [ + "bytemuck", + "cfg-if", + "diskann-vector", + "diskann-wide", + "half", + "rand 0.9.4", + "rand_distr", + "rayon", + "thiserror 2.0.21", +] + +[[package]] +name = "diskann-vector" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee9ba401eba2a12cea01aba7ee2ba7c755019f2b73c2417fecc49a2da26dd4c" +dependencies = [ + "cfg-if", + "diskann-wide", + "half", +] + +[[package]] +name = "diskann-wide" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343f4ea154bad7f8a9c6936746379faa880899165740a20092775bec97c9a141" +dependencies = [ + "cfg-if", + "half", ] [[package]] @@ -3018,7 +2861,7 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ - "base64 0.22.1", + "base64", "serde", "serde_json", ] @@ -3029,12 +2872,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "double-ended-peekable" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0d05e1c0dbad51b52c38bda7adceef61b9efc2baf04acfe8726a8c4630a6f57" - [[package]] name = "dtoa" version = "1.0.11" @@ -3062,16 +2899,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "earcutr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" -dependencies = [ - "itertools 0.11.0", - "num-traits", -] - [[package]] name = "ecdsa" version = "0.14.8" @@ -3190,7 +3017,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ - "base64 0.22.1", + "base64", "memchr", ] @@ -3225,16 +3052,13 @@ dependencies = [ ] [[package]] -name = "endian-type" -version = "0.1.2" +name = "encoding_rs_io" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" - -[[package]] -name = "endian-type" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] [[package]] name = "enum-ordinalize" @@ -3461,12 +3285,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "fixedbitset" version = "0.5.7" @@ -3581,22 +3399,6 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.32" @@ -3662,10 +3464,7 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand", "futures-core", - "futures-io", - "parking", "pin-project-lite", ] @@ -3753,79 +3552,41 @@ dependencies = [ "zeroize", ] -[[package]] -name = "geo" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f811f663912a69249fa620dcd2a005db7254529da2d8a0b23942e81f47084501" -dependencies = [ - "earcutr", - "float_next_after", - "geo-types", - "geographiclib-rs", - "log", - "num-traits", - "robust", - "rstar 0.12.2", - "serde", - "spade", -] - -[[package]] -name = "geo" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc1a1678e54befc9b4bcab6cd43b8e7f834ae8ea121118b0fd8c42747675b4a" -dependencies = [ - "earcutr", - "float_next_after", - "geo-types", - "geographiclib-rs", - "i_overlay", - "log", - "num-traits", - "robust", - "rstar 0.12.2", - "serde", - "spade", -] - [[package]] name = "geo" version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3901269ec6d4f6068d3f09e5f02f995bd076398dcd1dfec407cd230b02d11b" dependencies = [ - "earcutr", "float_next_after", "geo-types", "geographiclib-rs", "i_overlay", "log", "num-traits", - "rand 0.8.5", + "rand 0.8.8", "robust", "rstar 0.12.2", "serde", "sif-itree", - "spade", ] [[package]] name = "geo-types" -version = "0.7.18" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24f8647af4005fa11da47cd56252c6ef030be8fa97bdbf355e7dfb6348f0a82c" +checksum = "777d18aa0f12f8b285331cd867133ee14422b3f023f6d388034c47d43e28786a" dependencies = [ "approx", "num-traits", - "rayon", "rstar 0.10.0", "rstar 0.11.0", "rstar 0.12.2", + "rstar 0.13.0", "rstar 0.8.4", "rstar 0.9.3", "serde", + "thiserror 2.0.21", ] [[package]] @@ -3899,9 +3660,9 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" @@ -3923,7 +3684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" dependencies = [ "cfg-if", - "dashmap 6.1.0", + "dashmap", "futures-sink", "futures-timer", "futures-util", @@ -3973,7 +3734,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.13.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -3992,7 +3753,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap 2.13.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -4005,8 +3766,12 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ + "bytemuck", "cfg-if", "crunchy", + "num-traits", + "rand 0.9.4", + "rand_distr", "zerocopy", ] @@ -4023,7 +3788,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -4058,19 +3823,12 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash 0.7.8", -] [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash 0.8.12", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -4094,6 +3852,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.10.0" @@ -4109,7 +3873,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "headers-core", "http 1.4.0", @@ -4162,6 +3926,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -4238,13 +4008,13 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.35.0" +version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130" dependencies = [ "log", "markup5ever", - "match_token", + "memchr", ] [[package]] @@ -4442,7 +4212,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", @@ -4501,7 +4271,6 @@ dependencies = [ "i_key_sort", "i_shape", "i_tree", - "rayon", ] [[package]] @@ -4531,7 +4300,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -4670,12 +4439,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -4728,9 +4497,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "iri-string" @@ -4763,24 +4532,6 @@ dependencies = [ "nom 8.0.0", ] -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -4869,21 +4620,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "jsonwebtoken" version = "10.4.0" @@ -4891,7 +4627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "aws-lc-rs", - "base64 0.22.1", + "base64", "ed25519-dalek", "getrandom 0.2.17", "hmac 0.12.1", @@ -4899,7 +4635,7 @@ dependencies = [ "p256 0.13.2", "p384", "pem", - "rand 0.8.5", + "rand 0.8.8", "rsa", "serde", "serde_json", @@ -4918,59 +4654,28 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "lalrpop" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" -dependencies = [ - "ascii-canvas 3.0.0", - "bit-set 0.5.3", - "ena", - "itertools 0.11.0", - "lalrpop-util 0.20.2", - "petgraph 0.6.5", - "pico-args", - "regex", - "regex-syntax", - "string_cache", - "term 0.7.0", - "tiny-keccak", - "unicode-xid", - "walkdir", -] - [[package]] name = "lalrpop" version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" dependencies = [ - "ascii-canvas 4.0.0", - "bit-set 0.8.0", + "ascii-canvas", + "bit-set", "ena", "itertools 0.14.0", - "lalrpop-util 0.22.2", + "lalrpop-util", "petgraph 0.7.1", "pico-args", "regex", "regex-syntax", "sha3", - "string_cache", - "term 1.2.1", + "string_cache 0.8.9", + "term", "unicode-xid", "walkdir", ] -[[package]] -name = "lalrpop-util" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" -dependencies = [ - "regex-automata", -] - [[package]] name = "lalrpop-util" version = "0.22.2" @@ -5009,7 +4714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" dependencies = [ "async-trait", - "base64 0.22.1", + "base64", "email-encoding", "email_address", "fastrand", @@ -5030,15 +4735,6 @@ dependencies = [ "webpki-roots 1.0.9", ] -[[package]] -name = "lexicmp" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7378d131ddf24063b32cbd7e91668d183140c4b3906270635a4d633d1068ea5d" -dependencies = [ - "any_ascii", -] - [[package]] name = "lexicmp" version = "0.2.0" @@ -5120,15 +4816,47 @@ dependencies = [ ] [[package]] -name = "linfa-linalg" -version = "0.1.0" +name = "lindera" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e7562b41c8876d3367897067013bb2884cc78e6893f092ecd26b305176ac82" +checksum = "2ba619c086410a8d3b82f0208e45a8d496d895a8b883fe8975713abd340957db" dependencies = [ - "ndarray 0.15.6", - "num-traits", - "rand 0.8.5", - "thiserror 1.0.69", + "anyhow", + "lindera-dictionary", + "log", + "percent-encoding", + "serde", + "serde_json", + "strum 0.28.0", + "strum_macros 0.28.0", + "url", +] + +[[package]] +name = "lindera-dictionary" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1b3280d38586314078049deb66f332f544765cc23b683c56c28e335cabcb20" +dependencies = [ + "anyhow", + "byteorder", + "crawdad", + "csv", + "daachorse", + "encoding_rs", + "encoding_rs_io", + "glob", + "log", + "memchr", + "memmap2", + "once_cell", + "rayon", + "rkyv", + "serde", + "serde_json", + "strum 0.28.0", + "strum_macros 0.28.0", + "thiserror 2.0.21", ] [[package]] @@ -5178,9 +4906,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "logos" @@ -5248,15 +4976,6 @@ dependencies = [ "logos-codegen 0.16.1", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru" version = "0.16.4" @@ -5300,12 +5019,6 @@ dependencies = [ "sha2 0.11.0", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "maplit" version = "1.0.2" @@ -5314,26 +5027,15 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "markup5ever" -version = "0.35.0" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842" dependencies = [ "log", "tendril", "web_atoms", ] -[[package]] -name = "match_token" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "matchers" version = "0.2.0" @@ -5387,27 +5089,24 @@ checksum = "7e6bcd6433cff03a4bfc3d9834d504467db1f1cf6d0ea765d37d330249ed629d" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "memo-map" -version = "0.3.3" +name = "memmap2" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] [[package]] -name = "miette" -version = "5.10.0" +name = "memo-map" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" -dependencies = [ - "miette-derive 5.10.0", - "once_cell", - "thiserror 1.0.69", - "unicode-width 0.1.14", -] +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" [[package]] name = "miette" @@ -5418,7 +5117,7 @@ dependencies = [ "backtrace", "backtrace-ext", "cfg-if", - "miette-derive 7.6.0", + "miette-derive", "owo-colors", "serde", "supports-color", @@ -5429,17 +5128,6 @@ dependencies = [ "unicode-width 0.1.14", ] -[[package]] -name = "miette-derive" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "miette-derive" version = "7.6.0" @@ -5539,12 +5227,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] -name = "nanoid" -version = "0.4.0" +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ - "rand 0.8.5", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -5564,20 +5263,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "ndarray" -version = "0.15.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32" -dependencies = [ - "approx", - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "rawpointer", -] - [[package]] name = "ndarray" version = "0.17.2" @@ -5593,34 +5278,19 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "ndarray-stats" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af5a8477ac96877b5bd1fd67e0c28736c12943aba24eda92b127e036b0c8f400" -dependencies = [ - "indexmap 1.9.3", - "itertools 0.10.5", - "ndarray 0.15.6", - "noisy_float", - "num-integer", - "num-traits", - "rand 0.8.5", -] - [[package]] name = "ndarray-stats" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6e54a8b65764f71827a90ca1d56965ec0c67f069f996477bd493402a901d1f" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "itertools 0.13.0", - "ndarray 0.17.2", + "ndarray", "noisy_float", "num-integer", "num-traits", - "rand 0.8.5", + "rand 0.8.8", ] [[package]] @@ -5629,15 +5299,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nibble_vec" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" -dependencies = [ - "smallvec", -] - [[package]] name = "noisy_float" version = "0.2.1" @@ -5740,7 +5401,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -5862,46 +5523,36 @@ dependencies = [ [[package]] name = "object_store" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures", - "http 1.4.0", - "humantime", - "itertools 0.14.0", - "parking_lot 0.12.5", - "percent-encoding", - "thiserror 2.0.18", - "tokio", - "tracing", - "url", - "walkdir", - "wasm-bindgen-futures", - "web-time", -] - -[[package]] -name = "object_store" -version = "0.13.2" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49" dependencies = [ "async-trait", + "base64", "bytes", "chrono", + "form_urlencoded", "futures-channel", "futures-core", "futures-util", "http 1.4.0", + "http-body-util", + "httparse", "humantime", + "hyper 1.8.1", "itertools 0.14.0", + "md-5 0.10.6", "parking_lot 0.12.5", "percent-encoding", - "thiserror 2.0.18", + "quick-xml", + "rand 0.10.0", + "reqwest 0.12.28", + "ring", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.21", "tokio", "tracing", "url", @@ -5932,9 +5583,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -5995,7 +5646,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", ] @@ -6042,7 +5693,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest 0.12.28", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tonic", "tracing", @@ -6079,7 +5730,7 @@ dependencies = [ "opentelemetry", "percent-encoding", "rand 0.9.4", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -6151,9 +5802,9 @@ dependencies = [ [[package]] name = "papaya" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +checksum = "da2442474a9404698c42509b8967f437249dbc7b50493e83020333d3943ec0ae" dependencies = [ "equivalent", "seize", @@ -6324,7 +5975,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64 0.22.1", + "base64", "serde_core", ] @@ -6386,24 +6037,14 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "petgraph" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" -dependencies = [ - "fixedbitset 0.4.2", - "indexmap 2.13.0", -] - [[package]] name = "petgraph" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ - "fixedbitset 0.5.7", - "indexmap 2.13.0", + "fixedbitset", + "indexmap 2.14.2", ] [[package]] @@ -6412,29 +6053,9 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset 0.5.7", + "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.13.0", -] - -[[package]] -name = "pharos" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" -dependencies = [ - "futures", - "rustc_version", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", + "indexmap 2.14.2", ] [[package]] @@ -6452,29 +6073,29 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros 0.13.1", + "phf_macros", "phf_shared 0.13.1", "serde", ] [[package]] -name = "phf_codegen" -version = "0.11.3" +name = "phf" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "phf_shared 0.14.0", + "serde", ] [[package]] -name = "phf_generator" -version = "0.11.3" +name = "phf_codegen" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", + "phf_generator 0.14.0", + "phf_shared 0.14.0", ] [[package]] @@ -6488,17 +6109,13 @@ dependencies = [ ] [[package]] -name = "phf_macros" -version = "0.11.3" +name = "phf_generator" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.119", - "unicase", + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -6522,7 +6139,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ "siphasher", - "unicase", ] [[package]] @@ -6544,6 +6160,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -6572,9 +6197,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -6787,8 +6412,8 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set", + "bit-vec", "bitflags 2.11.0", "num-traits", "rand 0.9.4", @@ -6816,7 +6441,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", @@ -6881,22 +6506,22 @@ dependencies = [ [[package]] name = "ptr_meta" -version = "0.1.4" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" dependencies = [ "ptr_meta_derive", ] [[package]] name = "ptr_meta_derive" -version = "0.1.4" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 3.0.3", ] [[package]] @@ -6948,27 +6573,16 @@ checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "encoding_rs", "memchr", + "serde", ] [[package]] name = "quick_cache" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb55a1aa7668676bb93926cd4e9cdfe60f03bb866553bcca9112554911b6d3dc" -dependencies = [ - "ahash 0.8.12", - "equivalent", - "hashbrown 0.14.5", - "parking_lot 0.12.5", -] - -[[package]] -name = "quick_cache" -version = "0.6.18" +version = "0.6.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3" +checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" dependencies = [ - "ahash 0.8.12", + "ahash", "equivalent", "hashbrown 0.16.1", "parking_lot 0.12.5", @@ -6988,7 +6602,7 @@ dependencies = [ "rustc-hash 2.1.1", "rustls 0.23.40", "socket2 0.6.5", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -7010,7 +6624,7 @@ dependencies = [ "rustls 0.23.40", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -7052,31 +6666,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "radix_trie" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" -dependencies = [ - "endian-type 0.1.2", - "nibble_vec", - "serde", -] - -[[package]] -name = "radix_trie" -version = "0.3.0" +name = "rancor" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" dependencies = [ - "endian-type 0.2.0", - "nibble_vec", - "serde", + "ptr_meta", ] [[package]] @@ -7094,9 +6689,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -7187,6 +6782,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -7222,9 +6827,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -7273,17 +6878,6 @@ dependencies = [ "bitflags 2.11.0", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -7292,7 +6886,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -7350,17 +6944,11 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "relative-path" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" - [[package]] name = "rend" -version = "0.4.2" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" dependencies = [ "bytecheck", ] @@ -7371,11 +6959,12 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", "futures-util", + "h2 0.4.13", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -7384,11 +6973,11 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime_guess", "percent-encoding", "pin-project-lite", "quinn", "rustls 0.23.40", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -7414,7 +7003,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-core", @@ -7455,71 +7044,25 @@ dependencies = [ [[package]] name = "revision" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f53179a035f881adad8c4d58a2c599c6b4a8325b989c68d178d7a34d1b1e4c" -dependencies = [ - "revision-derive 0.10.0", -] - -[[package]] -name = "revision" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b8ee532f15b2f0811eb1a50adf10d036e14a6cdae8d99893e7f3b921cb227d" -dependencies = [ - "chrono", - "geo 0.28.0", - "regex", - "revision-derive 0.11.0", - "roaring 0.10.12", - "rust_decimal", - "uuid", -] - -[[package]] -name = "revision" -version = "0.17.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b66f44139d1fe8e1b6c21bf1a855f12df38517aab94e21cea2a077e9753f216" +checksum = "13b48b66c1cd4bf814516ea48f727d4b3697e3fa8841be99a7d9cbb8c9ac1666" dependencies = [ "bytes", "chrono", - "geo 0.31.0", + "geo", "regex", - "revision-derive 0.17.1", - "roaring 0.11.4", + "revision-derive", + "roaring", "rust_decimal", "uuid", ] [[package]] name = "revision-derive" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0ec466e5d8dca9965eb6871879677bef5590cf7525ad96cae14376efb75073" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "revision-derive" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3415e1bc838c36f9a0a2ac60c0fa0851c72297685e66592c44870d82834dfa2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "revision-derive" -version = "0.17.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "696cbf6f9d0bdeb7d75ef3a037c8295ea9fb665c89c6b70c23022f5918713353" +checksum = "3d266d798eaaa2921e14188dfe998bb7cc0528ec26e894b4be0e35fe2b19c89d" dependencies = [ "proc-macro2", "quote", @@ -7563,31 +7106,32 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.7.46" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" dependencies = [ - "bitvec", "bytecheck", "bytes", - "hashbrown 0.12.3", + "hashbrown 0.17.1", + "indexmap 2.14.2", + "munge", "ptr_meta", + "rancor", "rend", "rkyv_derive", - "seahash", "tinyvec", "uuid", ] [[package]] name = "rkyv_derive" -version = "0.7.46" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 3.0.3", ] [[package]] @@ -7609,26 +7153,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rmpv" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" -dependencies = [ - "rmp", -] - -[[package]] -name = "roaring" -version = "0.10.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e8d2cfa184d94d0726d650a9f4a1be7f9b76ac9fdb954219878dc00c1c1e7b" -dependencies = [ - "bytemuck", - "byteorder", - "serde", -] - [[package]] name = "roaring" version = "0.11.4" @@ -7729,32 +7253,15 @@ dependencies = [ ] [[package]] -name = "rstest" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" -dependencies = [ - "futures-timer", - "futures-util", - "rstest_macros", -] - -[[package]] -name = "rstest_macros" -version = "0.26.1" +name = "rstar" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe" dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn 2.0.119", - "unicode-ident", + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", ] [[package]] @@ -7804,18 +7311,19 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.40.0" +version = "1.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" dependencies = [ "arrayvec 0.7.6", "borsh", "bytes", "num-traits", - "rand 0.8.5", - "rkyv", + "rand 0.8.8", + "rand 0.9.4", "serde", "serde_json", + "wasm-bindgen", ] [[package]] @@ -7852,15 +7360,6 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be87abb9e40db7466e0681dc8ecd9dcfd40360cb10b4c8fe24a7c4c3669b198" -[[package]] -name = "rustc_lexer" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86aae0c77166108c01305ee1a36a1e77289d7dc6ca0a3cd91ff4992de2d16a5" -dependencies = [ - "unicode-xid", -] - [[package]] name = "rustc_version" version = "0.4.1" @@ -8040,7 +7539,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b23be9a89285428532551a6b46d303bb00321afceac9919058b995394553eece" dependencies = [ - "base64 0.22.1", + "base64", "blake2", "chacha20 0.9.1", "digest 0.10.7", @@ -8053,7 +7552,7 @@ dependencies = [ "serde", "serde_json", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "zeroize", ] @@ -8099,7 +7598,7 @@ dependencies = [ [[package]] name = "schema-forge-acton" -version = "0.43.1" +version = "0.44.0" dependencies = [ "acton-service", "arc-swap", @@ -8113,7 +7612,7 @@ dependencies = [ "axum", "bytes", "calamine", - "cedar-policy 4.9.1", + "cedar-policy", "chrono", "futures", "hex", @@ -8140,7 +7639,7 @@ dependencies = [ "sha2 0.11.0", "sync_wrapper", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-stream", "toml 1.1.4+spec-1.1.0", @@ -8156,7 +7655,7 @@ dependencies = [ [[package]] name = "schema-forge-backend" -version = "0.17.1" +version = "0.18.0" dependencies = [ "acton-service", "argon2", @@ -8171,9 +7670,9 @@ dependencies = [ [[package]] name = "schema-forge-cel" -version = "0.10.0" +version = "0.11.0" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "chrono-tz", "prost", @@ -8187,7 +7686,7 @@ dependencies = [ [[package]] name = "schema-forge-cli" -version = "0.44.2" +version = "0.45.0" dependencies = [ "acton-service", "assert_cmd", @@ -8197,9 +7696,9 @@ dependencies = [ "console", "dialoguer", "glob", - "heck", + "heck 0.5.0", "indicatif", - "miette 7.6.0", + "miette", "mime_guess", "minijinja", "predicates", @@ -8219,7 +7718,7 @@ dependencies = [ "sigstore-trust-root", "ssh-key", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-util", "toml 0.8.23", @@ -8229,9 +7728,9 @@ dependencies = [ [[package]] name = "schema-forge-core" -version = "0.17.0" +version = "0.18.0" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "csv", "mti", @@ -8243,7 +7742,7 @@ dependencies = [ [[package]] name = "schema-forge-dsl" -version = "0.13.0" +version = "0.14.0" dependencies = [ "logos 0.15.1", "proptest", @@ -8254,7 +7753,7 @@ dependencies = [ [[package]] name = "schema-forge-mssql" -version = "0.4.0" +version = "0.5.0" dependencies = [ "acton-service", "bb8", @@ -8270,7 +7769,7 @@ dependencies = [ [[package]] name = "schema-forge-postgres" -version = "0.12.2" +version = "0.13.0" dependencies = [ "arc-swap", "argon2", @@ -8290,7 +7789,7 @@ dependencies = [ name = "schema-forge-signing" version = "0.1.0" dependencies = [ - "base64 0.22.1", + "base64", "ed25519-dalek", "globset", "hex", @@ -8301,21 +7800,21 @@ dependencies = [ "sigstore-verify", "ssh-key", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "toml 1.1.4+spec-1.1.0", ] [[package]] name = "schema-forge-surrealdb" -version = "0.12.0" +version = "0.13.0" dependencies = [ "chrono", "schema-forge-backend", "schema-forge-core", "serde", "serde_json", - "surrealdb 2.6.0", + "surrealdb", "tokio", "tracing", ] @@ -8372,12 +7871,6 @@ dependencies = [ "untrusted 0.9.0", ] -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "sec1" version = "0.3.0" @@ -8441,57 +7934,42 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", ] -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", ] -[[package]] -name = "serde-content" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3753ca04f350fa92d00b6146a3555e63c55388c9ef2e11e09bce2ff1c0b509c6" -dependencies = [ - "serde", -] - [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -8500,7 +7978,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "itoa", "memchr", "serde", @@ -8586,11 +8064,11 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.2", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -8743,7 +8221,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb2255028e90ba8e7abe7cc49fb04e6f824a8f7a061fc6922f67a48e84ab052" dependencies = [ - "base64 0.22.1", + "base64", "hex", "serde", "serde_json", @@ -8752,7 +8230,7 @@ dependencies = [ "sigstore-rekor", "sigstore-tsa", "sigstore-types", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -8762,7 +8240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac7172898e15789d69d12469bb3d33397aab0771fb4f8d32cd56972e97808bf3" dependencies = [ "aws-lc-rs", - "base64 0.22.1", + "base64", "const-oid 0.9.6", "der 0.7.10", "digest 0.10.7", @@ -8772,7 +8250,7 @@ dependencies = [ "signature 2.2.0", "sigstore-types", "spki 0.7.3", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "x509-cert", ] @@ -8783,11 +8261,11 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e86ee272adfcfa21a2248b2fbf05a79b6e39a1fb699ef70c578c814af16e4db" dependencies = [ - "base64 0.22.1", + "base64", "hex", "sigstore-crypto", "sigstore-types", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -8796,7 +8274,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2448f8a13b91c615c23badca4d7e51b0376539b8723a2a2d43d27c016f9cce08" dependencies = [ - "base64 0.22.1", + "base64", "hex", "reqwest 0.13.3", "serde", @@ -8804,7 +8282,7 @@ dependencies = [ "sigstore-crypto", "sigstore-merkle", "sigstore-types", - "thiserror 2.0.18", + "thiserror 2.0.21", "url", ] @@ -8814,7 +8292,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bf02c1ab8f7a10db78dbf37cc80bb0f93d2afd636d5c37a572c815cb418b925" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "directories", "futures", @@ -8825,7 +8303,7 @@ dependencies = [ "serde_json", "sigstore-crypto", "sigstore-types", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tough", "tracing", @@ -8840,7 +8318,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31ea02ad335b7386c9a72455aecd2be964bef1d7118199858ee4c6d679af48ed" dependencies = [ "aws-lc-rs", - "base64 0.22.1", + "base64", "chrono", "cmpv2", "cms", @@ -8853,7 +8331,7 @@ dependencies = [ "rustls-webpki 0.102.8", "sigstore-crypto", "sigstore-types", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "x509-cert", "x509-tsp", @@ -8865,13 +8343,13 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce83bc56c60bbfb197a054d541839ff248c7e1aabf7016f704f8f3e6552fd13c" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "hex", "pem", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -8880,7 +8358,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "080e191482c7e040b9bdfd5bd60032915bb0052e5a0944c4881055ef41b6c2cb" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "cms", "const-oid 0.9.6", @@ -8898,7 +8376,7 @@ dependencies = [ "sigstore-trust-root", "sigstore-tsa", "sigstore-types", - "thiserror 2.0.18", + "thiserror 2.0.21", "tls_codec", "tracing", "x509-cert", @@ -8924,7 +8402,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", ] @@ -8942,18 +8420,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[package]] -name = "smol_str" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" dependencies = [ "serde", ] @@ -8985,18 +8454,12 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", ] -[[package]] -name = "snap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" - [[package]] name = "socket2" version = "0.5.10" @@ -9017,18 +8480,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spade" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb313e1c8afee5b5647e00ee0fe6855e3d529eb863a0fdae1d60006c4d1e9990" -dependencies = [ - "hashbrown 0.15.5", - "num-traits", - "robust", - "smallvec", -] - [[package]] name = "spin" version = "0.9.8" @@ -9092,7 +8543,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "chrono", "crc", @@ -9105,7 +8556,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap 2.13.0", + "indexmap 2.14.2", "log", "memchr", "once_cell", @@ -9115,7 +8566,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -9145,7 +8596,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", - "heck", + "heck 0.5.0", "hex", "once_cell", "proc-macro2", @@ -9169,7 +8620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64 0.22.1", + "base64", "bitflags 2.11.0", "byteorder", "bytes", @@ -9192,7 +8643,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.5", + "rand 0.8.8", "rsa", "serde", "sha1 0.10.6", @@ -9200,7 +8651,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "uuid", "whoami", @@ -9213,7 +8664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64 0.22.1", + "base64", "bitflags 2.11.0", "byteorder", "chrono", @@ -9232,14 +8683,14 @@ dependencies = [ "md-5 0.10.6", "memchr", "once_cell", - "rand 0.8.5", + "rand 0.8.8", "serde", "serde_json", "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "uuid", "whoami", @@ -9265,7 +8716,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "url", "uuid", @@ -9344,18 +8795,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766" -[[package]] -name = "storekey" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c42833834a5d23b344f71d87114e0cc9994766a5c42938f4b50e7b2aef85b2" -dependencies = [ - "byteorder", - "memchr", - "serde", - "thiserror 1.0.69", -] - [[package]] name = "storekey" version = "0.11.0" @@ -9388,17 +8827,28 @@ dependencies = [ "parking_lot 0.12.5", "phf_shared 0.11.3", "precomputed-hash", - "serde", +] + +[[package]] +name = "string_cache" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6" +dependencies = [ + "new_debug_unreachable", + "parking_lot 0.12.5", + "phf_shared 0.14.0", + "precomputed-hash", ] [[package]] name = "string_cache_codegen" -version = "0.5.4" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", ] @@ -9455,7 +8905,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -9464,7 +8923,19 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -9499,355 +8970,660 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "surrealdb" -version = "2.6.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b7720b39ce2985efbfa10858b7397ffd95655a9bab6d9dfaa03622bbdc3bc2" +checksum = "f04b42991e6a01e2d01f1ea6183714cf364d3c40fa2197bd8993ec7834e0b4c4" dependencies = [ - "arrayvec 0.7.6", + "anyhow", "async-channel", - "bincode 1.3.3", + "boxcar", "chrono", - "dmp", "futures", - "geo 0.28.0", "getrandom 0.3.4", - "indexmap 2.13.0", + "indexmap 2.14.2", + "js-sys", "path-clean", - "pharos", - "reblessive", - "reqwest 0.12.28", - "revision 0.11.0", + "reqwest 0.13.3", "ring", - "rust_decimal", "rustls 0.23.40", "rustls-pki-types", "semver", "serde", - "serde-content", "serde_json", - "surrealdb-core 2.6.0", - "thiserror 1.0.69", + "surrealdb-engine-api", + "surrealdb-engine-local", + "surrealdb-iam", + "surrealdb-kvs", + "surrealdb-rpc", + "surrealdb-syn", + "surrealdb-types", + "surrealdb-types-derive", "tokio", - "tokio-tungstenite 0.23.1", + "tokio-tungstenite", + "tokio-tungstenite-wasm", "tokio-util", + "tonic", "tracing", - "trice", "url", "uuid", + "wasm-bindgen", "wasm-bindgen-futures", - "wasmtimer 0.2.1", - "ws_stream_wasm", + "wasmtimer", + "web-sys", ] [[package]] -name = "surrealdb" -version = "3.0.5" +name = "surrealdb-catalog" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "504a96b55e86ef8653a03b6b97e771f49c954e26bcc0308160b0134d94f334fd" +checksum = "467526a3b49854f7ea1fb1d5948eee35973700791018a093b62ae366de87df4e" dependencies = [ + "ahash", "anyhow", - "async-channel", - "boxcar", "chrono", - "futures", - "getrandom 0.3.4", - "indexmap 2.13.0", - "js-sys", - "path-clean", - "reqwest 0.13.3", - "ring", - "rustls 0.23.40", - "rustls-pki-types", - "semver", + "md-5 0.10.6", + "revision", "serde", - "serde_json", - "surrealdb-core 3.0.5", + "sha2 0.10.9", + "storekey", + "surrealdb-collections", + "surrealdb-common", + "surrealdb-expr", + "surrealdb-iam", + "surrealdb-kvs", + "surrealdb-sql", + "surrealdb-strand", + "surrealdb-syn", "surrealdb-types", - "surrealdb-types-derive", - "tokio", - "tokio-tungstenite 0.28.0", - "tokio-tungstenite-wasm", - "tokio-util", + "thiserror 2.0.21", "tracing", - "url", "uuid", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasmtimer 0.4.3", - "web-sys", +] + +[[package]] +name = "surrealdb-cnf" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcdec4b7e7287d812c48b58e3495f7204524a2b31d51c20dbca6eff2d32d36e" +dependencies = [ + "num-traits", + "path-clean", + "surrealdb-common", + "surrealdb-parse-common", + "tracing", +] + +[[package]] +name = "surrealdb-collections" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90aed29a6512c5974634412cb3ad07c91a5b99353ec9bc7d96f4ae7491f243cf" +dependencies = [ + "revision", + "storekey", +] + +[[package]] +name = "surrealdb-common" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a1273ef6215f5b40c78a3e43a4ada9c0cb7199d839f25745b71fee57dae0ca0" +dependencies = [ + "chrono", + "futures", + "hashbrown 0.16.1", + "parking_lot 0.12.5", + "phf 0.13.1", + "rust_decimal", + "surrealdb-types", + "thiserror 2.0.21", + "tokio", + "unicase", + "wasmtimer", ] [[package]] name = "surrealdb-core" -version = "2.6.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c48e42c81713be2f9b3dae64328999eafe8b8060dd584059445a908748b39787" +checksum = "1490b831b5090b9bdbdacec5e8060d9dbc1944574809d157fabb4de25e53b1aa" dependencies = [ - "addr", - "affinitypool 0.3.1", - "ahash 0.8.12", - "ammonia", - "any_ascii", + "ahash", + "anyhow", + "arc-swap", "argon2", "async-channel", - "async-executor", - "async-graphql", - "base64 0.21.7", - "bcrypt 0.15.1", - "bincode 1.3.3", - "blake3", + "base64", + "bcrypt", "bytes", - "castaway", - "cedar-policy 2.4.2", "chrono", "ciborium", - "dashmap 5.5.3", - "deunicode", - "dmp", + "dashmap", "ext-sort", - "fst", "futures", - "fuzzy-matcher", - "geo 0.28.0", + "geo", "geo-types", "getrandom 0.3.4", - "hashbrown 0.14.5", + "headers", "hex", "http 1.4.0", "ipnet", - "jsonwebtoken 9.3.1", - "lexicmp 0.1.0", - "linfa-linalg", + "jsonwebtoken", "md-5 0.10.6", - "nanoid", - "ndarray 0.15.6", - "ndarray-stats 0.5.1", - "num-traits", + "memchr", + "mime", "num_cpus", - "object_store 0.12.5", + "object_store", "parking_lot 0.12.5", + "path-clean", "pbkdf2 0.12.2", - "pharos", - "phf 0.11.3", "pin-project-lite", - "quick_cache 0.5.2", - "radix_trie 0.2.1", - "rand 0.8.5", + "quick_cache", + "rand 0.9.4", + "rand_core 0.6.4", "rayon", "reblessive", "regex", - "revision 0.11.0", + "revision", "ring", - "rmpv", - "roaring 0.10.12", - "rust-stemmers", + "roaring", "rust_decimal", "scrypt", "semver", "serde", - "serde-content", "serde_json", "sha1 0.10.6", "sha2 0.10.9", - "snap", - "storekey 0.5.0", - "strsim 0.11.1", + "storekey", "subtle", - "surrealkv", - "sysinfo 0.33.1", + "surrealdb-catalog", + "surrealdb-cnf", + "surrealdb-collections", + "surrealdb-common", + "surrealdb-datastore", + "surrealdb-expr", + "surrealdb-iam", + "surrealdb-idx", + "surrealdb-kvs", + "surrealdb-kvs-any", + "surrealdb-observe", + "surrealdb-rpc", + "surrealdb-runtime", + "surrealdb-sql", + "surrealdb-strand", + "surrealdb-syn", + "surrealdb-types", + "sysinfo", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.21", "tokio", + "tokio-util", "tracing", - "trice", - "ulid", - "unicase", "url", "uuid", - "vart 0.8.1", "wasm-bindgen-futures", - "wasmtimer 0.2.1", - "ws_stream_wasm", + "wasmtimer", + "web-time", ] [[package]] -name = "surrealdb-core" -version = "3.0.5" +name = "surrealdb-datastore" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e6a7f248c958fd5000c4fab5759503663bf93c622be10a6d7bf7d2d676b8fc" +checksum = "c36f4accb0bf3f90f4072dc4e3ada965c2fe2a5adbde1b18be55ddbd62703f7c" dependencies = [ - "addr", - "affinitypool 0.4.0", - "ahash 0.8.12", - "ammonia", "anyhow", - "argon2", - "async-channel", - "async-stream", - "async-trait", - "base64 0.22.1", - "bcrypt 0.18.0", "blake3", "bytes", "chrono", - "ciborium", - "dashmap 6.1.0", - "deunicode", + "parking_lot 0.12.5", + "quick_cache", + "rand 0.9.4", + "revision", + "roaring", + "semver", + "serde", + "storekey", + "surrealdb-catalog", + "surrealdb-cnf", + "surrealdb-collections", + "surrealdb-common", + "surrealdb-expr", + "surrealdb-iam", + "surrealdb-keyspace-macro", + "surrealdb-kvs", + "surrealdb-observe", + "surrealdb-rpc", + "surrealdb-strand", + "surrealdb-types", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "tracing", + "uuid", + "web-time", +] + +[[package]] +name = "surrealdb-engine-api" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff233f2d7c1b5b0eac9f54abb01aaaa42dc466c381095d6b16588ae95482383" +dependencies = [ + "async-channel", + "surrealdb-rpc", + "surrealdb-types", + "tokio", + "uuid", +] + +[[package]] +name = "surrealdb-engine-local" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a354808ead8cdaead6ba4bc00318405739d869cf249911e16013f35cd927cbfd" +dependencies = [ + "anyhow", + "async-channel", + "futures", + "surrealdb-cnf", + "surrealdb-core", + "surrealdb-datastore", + "surrealdb-engine-api", + "surrealdb-iam", + "surrealdb-kvs", + "surrealdb-rpc", + "surrealdb-types", + "tokio", + "tokio-util", + "tracing", + "uuid", + "wasm-bindgen-futures", + "wasmtimer", +] + +[[package]] +name = "surrealdb-expr" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e494e4696ac372ead8c0a8b38f830db056a34c8f4192be4cfadeea3089c8687" +dependencies = [ + "anyhow", + "bytes", + "chrono", "dmp", - "ext-sort", "fastnum", + "geo", + "geo-types", + "half", + "hex", + "http 1.4.0", + "jsonwebtoken", + "lexicmp", + "quick_cache", + "rand 0.9.4", + "reblessive", + "regex", + "revision", + "rust_decimal", + "serde", + "serde_json", + "storekey", + "surrealdb-cnf", + "surrealdb-collections", + "surrealdb-common", + "surrealdb-iam", + "surrealdb-kvs", + "surrealdb-sql", + "surrealdb-strand", + "surrealdb-syn", + "surrealdb-types", + "thiserror 2.0.21", + "tracing", + "ulid", + "uuid", +] + +[[package]] +name = "surrealdb-iam" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ebf02070f99ebcdeb672b8ca8ca1ebe6b7f287c399ff868c1ac15a9e9702bc" +dependencies = [ + "anyhow", + "argon2", + "base64", + "hmac 0.12.1", + "pbkdf2 0.12.2", + "rand_core 0.6.4", + "revision", + "serde", + "sha2 0.10.9", + "stringprep", + "subtle", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "surrealdb-idx" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1871d2912896d5ae39d04e3989a20f3a69b8286b95a86a9c8ba2831fbb938e49" +dependencies = [ + "ahash", + "anyhow", + "bytes", + "dashmap", + "deunicode", + "diskann", + "diskann-utils", + "diskann-vector", "fst", - "futures", + "half", + "lindera", + "ndarray", + "ndarray-stats", + "parking_lot 0.12.5", + "quick_cache", + "rand 0.9.4", + "reblessive", + "roaring", + "rust-stemmers", + "storekey", + "surrealdb-catalog", + "surrealdb-cnf", + "surrealdb-common", + "surrealdb-datastore", + "surrealdb-expr", + "surrealdb-kvs", + "surrealdb-runtime", + "surrealdb-strand", + "surrealdb-types", + "thiserror 2.0.21", + "tokio", + "tracing", + "uuid", + "vart", + "web-time", +] + +[[package]] +name = "surrealdb-keyspace-macro" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911cd40a40f9ed6372c6b462157a4249a1fde0a0648b713805a5884018afdda2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "surrealdb-kvs" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b95075a800cc6c566506440ec2febde31e0ee21f48bc5fc1f2bc7c3bb6ea6e" +dependencies = [ + "affinitypool", + "anyhow", + "chrono", + "num_cpus", + "revision", + "roaring", + "surrealdb-cnf", + "surrealdb-common", + "surrealdb-types", + "thiserror 2.0.21", + "tracing", + "web-time", +] + +[[package]] +name = "surrealdb-kvs-any" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d40f18b879bce7ce4c1017dfdbd84feba9fc4a46a15e68084239ba498cb24b" +dependencies = [ + "surrealdb-cnf", + "surrealdb-kvs", + "surrealdb-kvs-mem", + "tokio-util", + "tracing", +] + +[[package]] +name = "surrealdb-kvs-mem" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a9c2f9830dcb58a208c9ee18b2720b80e614821ea370b386f706bb1df336c1" +dependencies = [ + "affinitypool", + "chrono", + "surrealdb-cnf", + "surrealdb-kvs", + "surrealmx", + "tokio", + "tracing", +] + +[[package]] +name = "surrealdb-observe" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09954bd399347da4ac4edaa9462f5074838648eac032c9b815edb1a2dee91815" +dependencies = [ + "surrealdb-iam", + "surrealdb-rpc", + "surrealdb-types", + "uuid", +] + +[[package]] +name = "surrealdb-parse-common" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b6b07e5e179c8993ddcf3bcddd34d8c781846df0b37157a4f37cfddf7c9257" +dependencies = [ + "chrono", + "logos 0.16.1", + "uuid", +] + +[[package]] +name = "surrealdb-protocol" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53501c853fe88d5ead8e50612d310b43389f3c66fda7ef323a1af6c393809c6" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "flatbuffers", + "geo", + "rust_decimal", + "semver", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "surrealdb-rpc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6d64c1a7783562566122795d56da73f83647fc7229f944b0558817bbe897b0" +dependencies = [ + "anyhow", + "humantime", + "ipnet", + "revision", + "serde", + "surrealdb-cnf", + "surrealdb-common", + "surrealdb-iam", + "surrealdb-types", + "thiserror 2.0.21", + "tracing", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "surrealdb-runtime" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e9d6ee0def941387d16dbf3730e1fc8faa38d477102035e6d8ba7f3f112676c" +dependencies = [ + "addr", + "ammonia", + "anyhow", + "argon2", + "bcrypt", + "blake3", + "chrono", + "deunicode", "fuzzy-matcher", - "geo 0.32.0", - "geo-types", - "getrandom 0.3.4", - "headers", - "hex", - "http 1.4.0", - "humantime", - "ipnet", - "jsonwebtoken 10.4.0", - "lexicmp 0.2.0", + "geo", "md-5 0.10.6", - "mime", - "ndarray 0.17.2", - "ndarray-stats 0.7.0", - "num-traits", - "num_cpus", - "object_store 0.13.2", - "parking_lot 0.12.5", - "path-clean", "pbkdf2 0.12.2", - "phf 0.13.1", - "pin-project-lite", - "quick_cache 0.6.18", - "radix_trie 0.3.0", - "rand 0.8.5", - "rayon", + "rand 0.9.4", + "rand_core 0.6.4", "reblessive", "regex", - "revision 0.17.1", - "ring", - "roaring 0.11.4", - "rust-stemmers", "rust_decimal", "scrypt", "semver", - "serde", - "serde_json", "sha1 0.10.6", "sha2 0.10.9", - "storekey 0.11.0", "strsim 0.11.1", - "subtle", - "surrealdb-protocol", + "surrealdb-cnf", + "surrealdb-common", + "surrealdb-expr", + "surrealdb-strand", + "surrealdb-syn", "surrealdb-types", - "surrealmx", - "sysinfo 0.37.2", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", "ulid", - "unicase", "url", "uuid", - "vart 0.9.3", - "wasm-bindgen-futures", - "wasmtimer 0.4.3", - "web-time", ] [[package]] -name = "surrealdb-protocol" -version = "0.8.3" +name = "surrealdb-sql" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb37698e0493bcfac3229ecb6ec6894a3ad705a3a2087b1562eeb881b3db19d4" +checksum = "6397f3b9141dbceca638116045b525c9f31bea3a6b374ab3546fa052bfb07e33" dependencies = [ - "anyhow", - "async-trait", "bytes", "chrono", - "flatbuffers", - "futures", - "geo 0.32.0", - "prost", - "prost-types", + "geo", + "getrandom 0.3.4", + "hex", + "phf 0.13.1", + "rand 0.9.4", + "regex", "rust_decimal", - "semver", "serde", - "serde_json", - "tonic", - "tonic-prost", + "surrealdb-common", + "surrealdb-iam", + "surrealdb-strand", + "surrealdb-types", + "unicase", + "uuid", +] + +[[package]] +name = "surrealdb-strand" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc99fc8f56f55b3bcadd4ee4fe6ec4811dc04a3e686099fd0c69af7ee4edde0" +dependencies = [ + "revision", + "serde", + "storekey", +] + +[[package]] +name = "surrealdb-syn" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab01f470fa61bc45a4c46286f7ba94297d6ec7c46708845d9e164311fb8e443" +dependencies = [ + "bytes", + "chrono", + "geo", + "phf 0.13.1", + "reblessive", + "regex", + "rust_decimal", + "surrealdb-cnf", + "surrealdb-common", + "surrealdb-iam", + "surrealdb-sql", + "surrealdb-strand", + "surrealdb-types", + "thiserror 2.0.21", + "tracing", + "unicase", "uuid", ] [[package]] name = "surrealdb-types" -version = "3.0.5" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c79e71d035367b933cf528c09b7ed186bc17dea58c66a1bca84d22f9abf167db" +checksum = "18cd057ad378914c5c32701d8e2298fc0b717dd07b087d895627832d7c834051" dependencies = [ "anyhow", + "async-channel", "bytes", + "castaway", "chrono", "flatbuffers", - "geo 0.32.0", + "geo", + "getrandom 0.2.17", + "getrandom 0.3.4", "hex", "http 1.4.0", "papaya", - "rand 0.8.5", + "rand 0.9.4", "regex", - "rstest", + "reqwest 0.13.3", "rust_decimal", + "semver", "serde", "serde_json", + "surrealdb-parse-common", "surrealdb-protocol", "surrealdb-types-derive", + "tracing", "ulid", + "url", "uuid", ] [[package]] name = "surrealdb-types-derive" -version = "3.0.5" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a76abdbfc597e062daae5269251e18a84553f9090cfff423591f57c8c6765aa8" +checksum = "f53d89700a64a5c249a23a3436e4b2091099d9074ace28effa757421ea37f0b9" dependencies = [ + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.119", ] -[[package]] -name = "surrealkv" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08a5041979bdff8599a1d5f6cb7365acb9a79664e2a84e5c4fddac2b3969f7d1" -dependencies = [ - "ahash 0.8.12", - "bytes", - "chrono", - "crc32fast", - "double-ended-peekable", - "getrandom 0.2.17", - "lru 0.12.5", - "parking_lot 0.12.5", - "quick_cache 0.6.18", - "revision 0.10.0", - "vart 0.9.3", -] - [[package]] name = "surrealmx" -version = "0.18.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6508449a7d1379a92a51ba49391b48ccab0b60dd11a4277c0dda965d8c99dbff" +checksum = "1b5f1002984c1c3918014d924435a7280a6de620dadd51d4db59de4f56e7202c" dependencies = [ "arc-swap", - "bincode 2.0.1", + "bincode", "bytes", "crossbeam-deque", "crossbeam-queue", @@ -9857,22 +9633,11 @@ dependencies = [ "parking_lot 0.12.5", "serde", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "web-time", ] -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -9915,20 +9680,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "sysinfo" -version = "0.33.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" -dependencies = [ - "core-foundation-sys", - "libc", - "memchr", - "ntapi", - "rayon", - "windows 0.57.0", -] - [[package]] name = "sysinfo" version = "0.37.2" @@ -9940,7 +9691,7 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows 0.61.3", + "windows", ] [[package]] @@ -9964,12 +9715,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tempfile" version = "3.27.0" @@ -9985,24 +9730,11 @@ dependencies = [ [[package]] name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "term" -version = "0.7.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ - "dirs-next", - "rustversion", - "winapi", + "new_debug_unreachable", ] [[package]] @@ -10063,7 +9795,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -10100,11 +9832,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.21", ] [[package]] @@ -10120,13 +9852,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -10199,15 +9931,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.8.2" @@ -10314,22 +10037,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "tokio-tungstenite" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" -dependencies = [ - "futures-util", - "log", - "rustls 0.23.40", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.26.4", - "tungstenite 0.23.0", - "webpki-roots 0.26.11", -] - [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -10342,7 +10049,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", - "tungstenite 0.28.0", + "tungstenite", "webpki-roots 0.26.11", ] @@ -10358,9 +10065,9 @@ dependencies = [ "http 1.4.0", "httparse", "js-sys", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", - "tokio-tungstenite 0.28.0", + "tokio-tungstenite", "wasm-bindgen", "web-sys", ] @@ -10398,7 +10105,7 @@ version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -10440,7 +10147,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -10454,7 +10161,7 @@ version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.14", @@ -10489,7 +10196,7 @@ checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", "axum", - "base64 0.22.1", + "base64", "bytes", "h2 0.4.13", "http 1.4.0", @@ -10621,7 +10328,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.13.0", + "indexmap 2.14.2", "pin-project-lite", "slab", "sync_wrapper", @@ -10671,7 +10378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "954ebc262351e20f2cbd76b916ce59d887fb32ce0e579061c8c24f17e5a68411" dependencies = [ "futures", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tower", "tower-layer", @@ -10686,7 +10393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62dfa8324283dd6ce8102ae60be686e0849ff2a0bd182c80d8ccb0f5fd55f118" dependencies = [ "futures", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tower", "tower-resilience-core", @@ -10698,7 +10405,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756171904b6fe320288e48b3741a5ea9490006f17734091b883f340d1e1e28af" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", ] @@ -10727,7 +10434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "tracing-subscriber", ] @@ -10839,27 +10546,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http 1.4.0", - "httparse", - "log", - "rand 0.8.5", - "rustls 0.23.40", - "rustls-pki-types", - "sha1 0.10.6", - "thiserror 1.0.69", - "url", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.28.0" @@ -10875,7 +10561,7 @@ dependencies = [ "rustls 0.23.40", "rustls-pki-types", "sha1 0.10.6", - "thiserror 2.0.18", + "thiserror 2.0.21", "url", "utf-8", ] @@ -11101,7 +10787,7 @@ version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "serde", "serde_json", "utoipa-gen", @@ -11126,7 +10812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" dependencies = [ "axum", - "base64 0.22.1", + "base64", "mime_guess", "regex", "rust-embed", @@ -11139,9 +10825,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "atomic", "getrandom 0.4.1", @@ -11158,12 +10844,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vart" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87782b74f898179396e93c0efabb38de0d58d50bbd47eae00c71b3a1144dbbae" - [[package]] name = "vart" version = "0.9.3" @@ -11273,6 +10953,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -11340,7 +11021,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.2", "wasm-encoder", "wasmparser", ] @@ -11379,23 +11060,10 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.2", "semver", ] -[[package]] -name = "wasmtimer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7ed9d8b15c7fb594d72bfb4b5a276f3d2029333cd93a932f376f5937f6f80ee" -dependencies = [ - "futures", - "js-sys", - "parking_lot 0.12.5", - "pin-utils", - "wasm-bindgen", -] - [[package]] name = "wasmtimer" version = "0.4.3" @@ -11431,13 +11099,13 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.1.3" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada" dependencies = [ - "phf 0.11.3", + "phf 0.14.0", "phf_codegen", - "string_cache", + "string_cache 0.11.0", "string_cache_codegen", ] @@ -11534,16 +11202,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "windows" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" -dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.3" @@ -11551,7 +11209,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -11563,19 +11221,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" -dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -11584,8 +11230,8 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -11597,22 +11243,11 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", "windows-threading", ] -[[package]] -name = "windows-implement" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -11624,17 +11259,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -11664,7 +11288,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", ] @@ -11679,15 +11303,6 @@ dependencies = [ "windows-strings 0.5.1", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -12061,7 +11676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -12072,8 +11687,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", - "indexmap 2.13.0", + "heck 0.5.0", + "indexmap 2.14.2", "prettyplease", "syn 2.0.119", "wasm-metadata", @@ -12104,7 +11719,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap 2.13.0", + "indexmap 2.14.2", "log", "serde", "serde_derive", @@ -12123,7 +11738,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.2", "log", "semver", "serde", @@ -12139,34 +11754,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "ws_stream_wasm" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" -dependencies = [ - "async_io_stream", - "futures", - "js-sys", - "log", - "pharos", - "rustc_version", - "send_wrapper", - "thiserror 2.0.18", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x509-cert" version = "0.2.5" @@ -12194,7 +11781,7 @@ dependencies = [ "nom 7.1.3", "oid-registry", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", ] @@ -12363,7 +11950,7 @@ dependencies = [ "arbitrary", "crc32fast", "flate2", - "indexmap 2.13.0", + "indexmap 2.14.2", "memchr", "zopfli", ] @@ -12376,7 +11963,7 @@ checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" dependencies = [ "crc32fast", "flate2", - "indexmap 2.13.0", + "indexmap 2.14.2", "memchr", "typed-path 0.12.3", "zopfli", @@ -12396,7 +11983,7 @@ dependencies = [ "flate2", "getrandom 0.4.1", "hmac 0.13.0", - "indexmap 2.13.0", + "indexmap 2.14.2", "lzma-rust2", "memchr", "pbkdf2 0.13.0", diff --git a/README.md b/README.md index 87fcb720..2c77e341 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The repo ships a self-contained demo: an in-memory backend with twelve seeded en | Tool | Why it's needed | |---|---| -| [Rust 1.75+](https://rustup.rs) | The demo builds the CLI from source | +| [Rust 1.97.1+](https://rustup.rs) | The demo builds the CLI from source | | [Task](https://taskfile.dev/installation/) | Runs the bundled demo recipes | | [pnpm](https://pnpm.io/installation) + Node 20+ | Builds and serves the React admin | @@ -88,8 +88,8 @@ project, define a schema, and serve it. If you only want to kick the tires, the ### Prerequisites -- A running SurrealDB 2.x, PostgreSQL 14+, or Microsoft SQL Server instance (SurrealDB embedded mode works for development) -- Rust 1.75+ only if you intend to build from source +- A running SurrealDB 3.3+, PostgreSQL 14+, or Microsoft SQL Server instance (SurrealDB embedded mode works for development) +- Rust 1.97.1+ only if you intend to build from source ### Install the Prebuilt Binary @@ -1054,3 +1054,5 @@ See the project repository for license information. ### Audit browsing and verification Platform administrators can browse recorded audit events and verify bounded chain ranges through the [audit API](docs/audit-api-reference.md). Access is deployment-wide, uses the active framework audit store, and reports collection limits separately from local chain consistency. Available in v0.42.0. + +See [safe schema changes](docs/migrations/safe-schema-changes.md) for declared field renames, destructive migration opt-ins, PostgreSQL relation integrity, and explicit tenancy migrations. diff --git a/crates/schema-forge-acton/Cargo.toml b/crates/schema-forge-acton/Cargo.toml index cc1d9a9a..0cfdd659 100644 --- a/crates/schema-forge-acton/Cargo.toml +++ b/crates/schema-forge-acton/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-acton" -version = "0.43.1" +version = "0.44.0" edition = "2021" [dependencies] @@ -44,7 +44,7 @@ aws-lc-rs = { version = "1", features = ["fips"], optional = true } rustls = { version = "0.23", default-features = false, features = ["std", "aws_lc_rs", "logging"] } schema-forge-signing = { version = "0.1.0", path = "../schema-forge-signing" } lettre = { version = "0.11.22", default-features = false, features = ["tokio1-rustls", "aws-lc-rs", "webpki-roots", "smtp-transport", "builder", "pool", "hostname"] } -schema-forge-cel = { version = "0.10.0", path = "../schema-forge-cel" } +schema-forge-cel = { version = "0.11.0", path = "../schema-forge-cel" } rust_xlsxwriter = { version = "0.95.0", features = ["chrono"] } zip = "8.6.0" diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index 92c3f6f4..493c6f6a 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -318,11 +318,10 @@ pub(crate) fn filter_patch_fields( continue; } let decision = - authorize_field(store, claims, schema, resource, name, FieldDirection::Write).map_err( - |_| ForgeError::Forbidden { + crate::authz::engine::authorize_input_field(store, claims, schema, resource, name) + .map_err(|_| ForgeError::Forbidden { message: "Could not authorize a patched field.".into(), - }, - )?; + })?; if !decision.errors.is_empty() { return Err(ForgeError::Forbidden { message: "Could not authorize a patched field.".into(), @@ -340,8 +339,8 @@ pub(crate) fn filter_patch_fields( /// Inject tenant scoping filter into a query. /// -/// Adds `_tenant = ` filter based on the deepest tenant in the -/// claims' `tenant_chain` custom claim. No-ops when: +/// Filters child `_tenant` or root `id` by the effective tenant chain. +/// Shared schemas are not scoped. No-ops when: /// - `tenant_config` is `None` or disabled /// - `claims` is `None` /// - user is `platform_admin` (bypass) @@ -349,7 +348,11 @@ pub fn inject_tenant_scope( query: &mut Query, claims: Option<&Claims>, tenant_config: &Option, + schema: &SchemaDefinition, ) { + if !schema.is_tenanted() { + return; + } let _config = match tenant_config { Some(c) if c.is_enabled() => c, _ => return, @@ -377,13 +380,18 @@ pub fn inject_tenant_scope( .iter() .map(|t| DynamicValue::Text(t.entity_id.clone())) .collect(); + let tenant_field = if is_tenant_root(schema) { + "id" + } else { + "_tenant" + }; let tenant_filter = if tenant_values.len() == 1 { Filter::eq( - FieldPath::single("_tenant"), + FieldPath::single(tenant_field), tenant_values.into_iter().next().unwrap(), ) } else { - Filter::in_set(FieldPath::single("_tenant"), tenant_values) + Filter::in_set(FieldPath::single(tenant_field), tenant_values) }; query.filter = Some(match query.filter.take() { Some(existing) => Filter::and(vec![existing, tenant_filter]), @@ -395,12 +403,17 @@ pub fn inject_tenant_scope( /// /// Sets `_tenant` to the deepest tenant entity ID in the claims' /// `tenant_chain` custom claim. No-ops when tenancy is disabled, -/// claims is `None`, or the tenant chain is empty. +/// claims is `None`, or the tenant chain is empty. Shared schemas and tenant +/// roots are excluded; roots are stamped by [`stamp_root_tenant`]. pub fn inject_tenant_on_create( fields: &mut BTreeMap, claims: Option<&Claims>, tenant_config: &Option, + schema: &SchemaDefinition, ) { + if !schema.is_tenanted() || is_tenant_root(schema) { + return; + } let _config = match tenant_config { Some(c) if c.is_enabled() => c, _ => return, @@ -420,6 +433,42 @@ pub fn inject_tenant_on_create( } } +/// Whether a schema defines tenant identities rather than tenant-owned data. +pub fn is_tenant_root(schema: &SchemaDefinition) -> bool { + schema.annotations.iter().any(|annotation| { + matches!( + annotation, + schema_forge_core::types::Annotation::Tenant( + schema_forge_core::types::TenantKind::Root + ) + ) + }) +} + +/// A tenant root always belongs to its own identity, including admin creates. +pub fn stamp_root_tenant(entity: &mut Entity, schema: &SchemaDefinition) { + if is_tenant_root(schema) { + entity + .fields + .insert("_tenant".into(), DynamicValue::Text(entity.id.to_string())); + } +} + +/// Keep tenant ownership server-controlled on updates. +/// Only platform administrators can reassign child records; roots are immutable. +pub fn strip_tenant_on_update( + fields: &mut BTreeMap, + schema: &SchemaDefinition, + claims: Option<&Claims>, +) { + if !schema.is_tenanted() + || is_tenant_root(schema) + || !claims.is_some_and(|claims| claims.has_role(PLATFORM_ADMIN_ROLE)) + { + fields.remove("_tenant"); + } +} + /// Force-set the schema's `@owner` field to the authenticated principal. /// /// The Cedar `owner_write` / `owner_restrict` policies decide per-record @@ -565,6 +614,25 @@ mod tests { use schema_forge_core::types::DynamicValue; + fn tenant_child_schema() -> SchemaDefinition { + let mut schema = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("Note").unwrap(), + vec![FieldDefinition::new( + FieldName::new("body").unwrap(), + FieldType::Text(TextConstraints::default()), + )], + vec![], + ) + .unwrap(); + schema + .annotations + .push(Annotation::Tenant(TenantKind::Child { + parent: SchemaName::new("Organization").unwrap(), + })); + schema + } + fn make_claims(roles: &[&str]) -> Claims { Claims { sub: format!("user:{}", EntityId::new("user").as_str()), @@ -658,7 +726,12 @@ mod tests { let claims = make_claims_with_tenant(&["member"], tenant_id.as_str()); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + inject_tenant_scope( + &mut query, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(query.filter.is_some()); let filter = query.filter.unwrap(); @@ -681,7 +754,12 @@ mod tests { let claims = make_claims_with_tenant(&["member"], tenant_id.as_str()); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + inject_tenant_scope( + &mut query, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(query.filter.is_none()); } @@ -693,7 +771,12 @@ mod tests { let claims = make_claims_with_tenant(&["platform_admin"], tenant_id.as_str()); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + inject_tenant_scope( + &mut query, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(query.filter.is_none()); } @@ -703,7 +786,7 @@ mod tests { let tenant_config = make_enabled_tenant_config(); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, None, &tenant_config); + inject_tenant_scope(&mut query, None, &tenant_config, &tenant_child_schema()); assert!(query.filter.is_none()); } @@ -721,7 +804,12 @@ mod tests { ); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + inject_tenant_scope( + &mut query, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); let filter = query.filter.expect("filter set"); match filter { @@ -748,7 +836,12 @@ mod tests { let claims = make_claims(&["member"]); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + inject_tenant_scope( + &mut query, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(query.filter.is_none()); } @@ -765,7 +858,12 @@ mod tests { ); let mut query = Query::new(SchemaId::new()).with_filter(existing_filter); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + inject_tenant_scope( + &mut query, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(query.filter.is_some()); let filter = query.filter.unwrap(); @@ -802,7 +900,12 @@ mod tests { let mut fields = BTreeMap::new(); fields.insert("name".to_string(), DynamicValue::Text("Alice".to_string())); - inject_tenant_on_create(&mut fields, Some(&claims), &tenant_config); + inject_tenant_on_create( + &mut fields, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(fields.contains_key("_tenant")); assert_eq!( @@ -819,7 +922,12 @@ mod tests { let mut fields = BTreeMap::new(); fields.insert("name".to_string(), DynamicValue::Text("Alice".to_string())); - inject_tenant_on_create(&mut fields, Some(&claims), &tenant_config); + inject_tenant_on_create( + &mut fields, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(!fields.contains_key("_tenant")); } @@ -830,7 +938,7 @@ mod tests { let mut fields = BTreeMap::new(); fields.insert("name".to_string(), DynamicValue::Text("Alice".to_string())); - inject_tenant_on_create(&mut fields, None, &tenant_config); + inject_tenant_on_create(&mut fields, None, &tenant_config, &tenant_child_schema()); assert!(!fields.contains_key("_tenant")); } @@ -842,7 +950,12 @@ mod tests { let mut fields = BTreeMap::new(); fields.insert("name".to_string(), DynamicValue::Text("Alice".to_string())); - inject_tenant_on_create(&mut fields, Some(&claims), &tenant_config); + inject_tenant_on_create( + &mut fields, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(!fields.contains_key("_tenant")); } @@ -1133,6 +1246,105 @@ mod tests { Arc::new(PolicyStore::new(snapshot)) } + #[test] + fn shared_schema_never_receives_tenant_filter_or_stamp() { + let schema = schema_without_owner("Catalog"); + let claims = make_claims_with_tenant(&["member"], "organization_a"); + let config = make_enabled_tenant_config(); + let mut query = Query::new(schema.id.clone()); + inject_tenant_scope(&mut query, Some(&claims), &config, &schema); + assert!(query.filter.is_none()); + let mut fields = BTreeMap::new(); + inject_tenant_on_create(&mut fields, Some(&claims), &config, &schema); + assert!(!fields.contains_key("_tenant")); + } + + #[test] + fn tenant_updates_preserve_member_boundaries_and_root_identity() { + let child = tenant_child_schema(); + let mut root = child.clone(); + root.annotations = vec![Annotation::Tenant(TenantKind::Root)]; + for (schema, role, retained) in [ + (&child, "member", false), + (&child, "platform_admin", true), + (&root, "platform_admin", false), + ] { + let claims = make_claims(&[role]); + let mut fields = + BTreeMap::from([("_tenant".into(), DynamicValue::Text("other".into()))]); + strip_tenant_on_update(&mut fields, schema, Some(&claims)); + assert_eq!(fields.contains_key("_tenant"), retained); + } + } + + #[test] + fn tenant_root_stamp_and_query_use_own_identity() { + let mut schema = tenant_child_schema(); + schema.annotations = vec![Annotation::Tenant(TenantKind::Root)]; + let mut entity = Entity::new(schema.name.clone(), BTreeMap::new()); + stamp_root_tenant(&mut entity, &schema); + assert_eq!( + entity.fields["_tenant"], + DynamicValue::Text(entity.id.to_string()) + ); + let claims = make_claims_with_tenant(&["member"], entity.id.as_str()); + let mut query = Query::new(schema.id.clone()); + inject_tenant_scope( + &mut query, + Some(&claims), + &make_enabled_tenant_config(), + &schema, + ); + assert!(matches!(query.filter, Some(Filter::Eq { path, .. }) if path.root() == "id")); + } + + #[test] + fn cedar_tenant_guard_denies_null_children_and_cross_tenant_legacy_roots() { + for annotation in ["@tenant(root)", "@tenant(parent: \"Organization\")"] { + let source = format!("{annotation}\n@access(read: [\"member\"], write: [\"member\"], delete: [\"member\"])\nschema Note {{ body: text }}"); + let schema = schema_forge_dsl::parse(&source).unwrap().remove(0); + let store = store_for(&schema, None); + let mut entity = Entity::new( + schema.name.clone(), + BTreeMap::from([("body".into(), DynamicValue::Text("secret".into()))]), + ); + let outsider = make_claims_with_tenant(&["member"], "organization_other"); + let member = make_claims_with_tenant(&["member"], entity.id.as_str()); + let admin = make_claims(&["platform_admin"]); + for tenant in [None, Some(DynamicValue::Null)] { + if let Some(value) = tenant { + entity.fields.insert("_tenant".into(), value); + } + for action in [ + ActionVerb::Read, + ActionVerb::List, + ActionVerb::Update, + ActionVerb::Delete, + ] { + assert!( + !authorize(&store, Some(&outsider), action, &schema, Some(&entity)) + .unwrap() + .is_allow() + ); + assert!( + authorize(&store, Some(&admin), action, &schema, Some(&entity)) + .unwrap() + .is_allow() + ); + assert_eq!( + authorize(&store, Some(&member), action, &schema, Some(&entity)) + .unwrap() + .is_allow(), + is_tenant_root(&schema) + ); + } + } + assert!( + check_schema_access(&store, &schema, Some(&member), AccessAction::Read).is_ok() + ); + } + } + #[test] fn patch_authorization_errors_abort_without_filtering_the_delta() { let schema = schema_forge_dsl::parse( @@ -1152,9 +1364,38 @@ mod tests { schema.name.clone(), BTreeMap::from([("draft".into(), DynamicValue::Text("changed".into()))]), ); - // A malformed full resource must never turn authorization failure - // into a successful empty update, even for a permitted role. + // Required values may still be supplied by defaults after input + // authorization. Absence alone is not a Cedar evaluation error. let incomplete = delta.clone(); + filter_patch_fields(&store, &mut delta, &incomplete, &schema, Some(&claims)).unwrap(); + assert_eq!(delta.fields, incomplete.fields); + + // A forbid that actually reads the absent attribute must fail closed, + // even though the role-based permit would otherwise allow the write. + let store = store_for( + &schema, + Some( + r#" + forbid ( + principal, + action == Action::"WriteFieldSettings_draft", + resource is Settings + ) when { resource.key == "locked" }; + "#, + ), + ); + let decision = crate::authz::engine::authorize_input_field( + &store, + Some(&claims), + &schema, + &incomplete, + "draft", + ) + .unwrap(); + assert!( + !decision.errors.is_empty(), + "reading the absent key must be a Cedar evaluation error" + ); let error = filter_patch_fields(&store, &mut delta, &incomplete, &schema, Some(&claims)) .unwrap_err(); assert!(matches!(error, ForgeError::Forbidden { .. })); diff --git a/crates/schema-forge-acton/src/actor.rs b/crates/schema-forge-acton/src/actor.rs index 5c3e870d..380bf05f 100644 --- a/crates/schema-forge-acton/src/actor.rs +++ b/crates/schema-forge-acton/src/actor.rs @@ -28,6 +28,8 @@ use crate::messages::{ use crate::state::DynForgeBackend; use crate::storage::StorageRegistry; +mod schema_changes; + // --------------------------------------------------------------------------- // ForgeActor // --------------------------------------------------------------------------- @@ -264,6 +266,28 @@ fn configure_registry_reads(actor: &mut ManagedActor) { }) }); + actor.act_on::(|actor, ctx| { + let snapshot = + actor + .model + .policy_store + .as_ref() + .map(|store| crate::messages::AuthorizationSnapshot { + registry: actor.model.registry.clone(), + policy: store.current(), + }); + let reply = ctx.message().reply.clone(); + Reply::pending(async move { reply.send(snapshot).await }) + }); + + actor.act_on::(|actor, ctx| { + let directory = actor.model.custom_policies_dir.clone(); + let reply = ctx.message().reply.clone(); + Reply::pending(async move { + reply.send(directory).await; + }) + }); + actor.act_on::(|actor, ctx| { let registry = actor.model.storage_registry.clone(); let reply = ctx.message().reply.clone(); @@ -278,6 +302,7 @@ fn configure_registry_reads(actor: &mut ManagedActor) { // --------------------------------------------------------------------------- fn configure_registry_mutations(actor: &mut ManagedActor) { + schema_changes::configure(actor); actor.mutate_on::(|actor, ctx| { let msg = ctx.message(); let name = msg.name.clone(); diff --git a/crates/schema-forge-acton/src/actor/schema_changes.rs b/crates/schema-forge-acton/src/actor/schema_changes.rs new file mode 100644 index 00000000..1c64a88f --- /dev/null +++ b/crates/schema-forge-acton/src/actor/schema_changes.rs @@ -0,0 +1,106 @@ +//! Serialized storage/registry commits using immutable preflight policy bundles. + +use std::sync::Arc; + +use acton_service::prelude::{Idle, ManagedActor, Reply}; +use schema_forge_core::types::{SchemaDefinition, SchemaName}; + +use super::ForgeActor; +use crate::{error::ForgeError, messages::ApplyPreparedSchemaChange}; + +#[derive(Debug)] +struct SchemaChangeFailure { + name: SchemaName, + previous: Option>, + cause: ForgeError, +} + +impl std::fmt::Display for SchemaChangeFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.cause, f) + } +} + +impl std::error::Error for SchemaChangeFailure {} + +pub(super) fn configure(actor: &mut ManagedActor) { + actor.try_mutate_on::(|actor, context| { + let change = context.message().clone(); + let store = actor.model.policy_store.clone(); + let unchanged = actor.model.registry == change.expected_registry + && store + .as_ref() + .is_some_and(|store| Arc::ptr_eq(&store.current(), &change.expected_policy)); + if !unchanged { + return Reply::try_pending(async move { + change + .reply + .send(Err(ForgeError::Conflict { + reason: "schema_preflight_stale", + message: + "schemas or policies changed after validation; retry the schema change" + .into(), + })) + .await; + Ok(()) + }); + } + let (Some(backend), Some(store)) = (actor.model.backend.clone(), store) else { + return Reply::try_pending(async move { + change + .reply + .send(Err(ForgeError::Internal { + message: "schema backend is not initialized".into(), + })) + .await; + Ok(()) + }); + }; + let name = change.definition.name.clone(); + // Mutable handler futures are awaited inline. No subsequent actor + // message observes this provisional registry until storage succeeds, + // or the on_error handler below has restored it. + let previous = if change.remove { + actor.model.registry.remove(name.as_str()) + } else { + actor + .model + .registry + .insert(name.to_string(), change.definition.clone()) + }; + Reply::try_pending(async move { + // HTTP DELETE unregisters the schema for this process. Preserve its + // existing storage lifecycle: no implicit metadata or table deletion. + let result = if change.remove { + Ok(()) + } else { + backend + .apply_schema_change(&name, &change.steps, Some(&change.definition)) + .await + }; + if let Err(error) = result { + return Err(SchemaChangeFailure { + name, + previous: previous.map(Box::new), + cause: error.into(), + }); + } + store.swap_prepared(change.next_policy); + change.reply.send(Ok(())).await; + Ok(()) + }) + }); + actor.on_error::(|actor, context, failure| { + if let Some(previous) = &failure.previous { + actor + .model + .registry + .insert(failure.name.to_string(), previous.as_ref().clone()); + } else { + actor.model.registry.remove(failure.name.as_str()); + } + let reply = context.message().reply.clone(); + let error = failure.cause.clone(); + Reply::pending(async move { reply.send(Err(error)).await }) + }); +} diff --git a/crates/schema-forge-acton/src/authz/adapters.rs b/crates/schema-forge-acton/src/authz/adapters.rs index 93b43fa4..ffb949ce 100644 --- a/crates/schema-forge-acton/src/authz/adapters.rs +++ b/crates/schema-forge-acton/src/authz/adapters.rs @@ -189,13 +189,12 @@ pub fn build_principal_entities( parents.extend(group_uids); parents.extend(tenant_uids); - let principal_entity = - CedarEntity::new(principal_uid_value, attrs, parents).map_err(|e| { - AdapterError::UnrepresentableValue { - field: "principal".into(), - detail: e.to_string(), - } - })?; + let principal_entity = CedarEntity::new(principal_uid_value, attrs, parents).map_err(|e| { + AdapterError::UnrepresentableValue { + field: "principal".into(), + detail: e.to_string(), + } + })?; let mut all = Vec::with_capacity(1 + group_entities.len() + tenant_entities.len()); all.push(principal_entity); @@ -260,7 +259,13 @@ pub fn build_resource_entity( // Resource carries _tenant as a Cedar entity reference when present so // tenant policies can do `resource._tenant in principal`. - if let Some(DynamicValue::Text(tenant_id)) = entity.fields.get("_tenant") { + let root_tenant = DynamicValue::Text(entity.id.to_string()); + let tenant = if crate::access::is_tenant_root(schema) { + Some(&root_tenant) + } else { + entity.fields.get("_tenant") + }; + if let Some(DynamicValue::Text(tenant_id)) = tenant { let raw_uid = format!("{TENANT_TYPE}::\"{tenant_id}\""); if let Ok(t_uid) = EntityUid::from_str(&raw_uid) { attrs.insert( diff --git a/crates/schema-forge-acton/src/authz/engine.rs b/crates/schema-forge-acton/src/authz/engine.rs index 14771427..7da3a3ec 100644 --- a/crates/schema-forge-acton/src/authz/engine.rs +++ b/crates/schema-forge-acton/src/authz/engine.rs @@ -282,6 +282,38 @@ pub fn authorize_field( entity: &Entity, field_name: &str, direction: FieldDirection, +) -> Result { + authorize_field_resource(store, claims, schema, entity, field_name, direction, true) +} + +/// Authorize provisional caller input before server rules and final validation. +/// Policies reading unavailable attributes fail closed at the caller. +pub(crate) fn authorize_input_field( + store: &Arc, + claims: Option<&Claims>, + schema: &SchemaDefinition, + entity: &Entity, + field_name: &str, +) -> Result { + authorize_field_resource( + store, + claims, + schema, + entity, + field_name, + FieldDirection::Write, + false, + ) +} + +fn authorize_field_resource( + store: &Arc, + claims: Option<&Claims>, + schema: &SchemaDefinition, + entity: &Entity, + field_name: &str, + direction: FieldDirection, + complete: bool, ) -> Result { let snapshot = store.current(); @@ -314,7 +346,7 @@ pub fn authorize_field( let mut all_entities = principal_entities; all_entities.push(resource_entity); - let entities = Entities::from_entities(all_entities, Some(&snapshot.schema)) + let entities = Entities::from_entities(all_entities, complete.then_some(&snapshot.schema)) .map_err(|e| AuthzError::Request(render_error_chain(&e)))?; // `filter_entity_fields` only calls `authorize_field` for fields whose diff --git a/crates/schema-forge-acton/src/authz/store.rs b/crates/schema-forge-acton/src/authz/store.rs index a3059de9..ad9a2262 100644 --- a/crates/schema-forge-acton/src/authz/store.rs +++ b/crates/schema-forge-acton/src/authz/store.rs @@ -77,6 +77,14 @@ impl PolicyStore { } } + /// Pin an existing immutable snapshot for a single request. + #[cfg(feature = "graphql")] + pub(crate) fn from_snapshot(snapshot: Arc) -> Self { + Self { + inner: ArcSwap::from(snapshot), + } + } + /// Returns the current snapshot. Cheap pointer-clone. pub fn current(&self) -> Arc { self.inner.load_full() @@ -87,6 +95,11 @@ impl PolicyStore { self.inner.store(Arc::new(next)); } + /// Install an immutable, already-validated snapshot without recompilation. + pub fn swap_prepared(&self, next: Arc) { + self.inner.store(next); + } + /// Compiles a fresh snapshot from `schemas` (reusing the current /// snapshot's [`RoleRanks`]) and atomically installs it. /// @@ -103,12 +116,8 @@ impl PolicyStore { let current = self.current(); let role_ranks = current.role_ranks.clone(); let principal_claims = current.principal_claims.clone(); - let next = PolicyStoreSnapshot::from_schemas( - schemas, - custom_dir, - role_ranks, - principal_claims, - )?; + let next = + PolicyStoreSnapshot::from_schemas(schemas, custom_dir, role_ranks, principal_claims)?; self.swap(next); Ok(()) } @@ -185,7 +194,10 @@ impl PolicyStoreSnapshot { let result = validator.validate(&policy_set, ValidationMode::Strict); if !result.validation_passed() { let errors: Vec = result.validation_errors().map(|e| e.to_string()).collect(); - let warns: Vec = result.validation_warnings().map(|w| w.to_string()).collect(); + let warns: Vec = result + .validation_warnings() + .map(|w| w.to_string()) + .collect(); let mut combined = errors; combined.extend(warns); return Err(PolicyStoreError::Validation(combined.join("\n"))); @@ -245,14 +257,13 @@ mod tests { #[test] fn compile_succeeds_for_validated_bundle() { - let snap = - PolicyStoreSnapshot::compile( - MINIMAL_SCHEMA, - MINIMAL_POLICY, - RoleRanks::empty(), - PrincipalClaimMappings::default(), - ) - .unwrap(); + let snap = PolicyStoreSnapshot::compile( + MINIMAL_SCHEMA, + MINIMAL_POLICY, + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(); assert_eq!(snap.policy_count, 1); assert_eq!(snap.policy_hash.len(), 64); } @@ -272,24 +283,22 @@ mod tests { #[test] fn store_swap_makes_new_snapshot_visible() { - let s1 = - PolicyStoreSnapshot::compile( - MINIMAL_SCHEMA, - MINIMAL_POLICY, - RoleRanks::empty(), - PrincipalClaimMappings::default(), - ) - .unwrap(); + let s1 = PolicyStoreSnapshot::compile( + MINIMAL_SCHEMA, + MINIMAL_POLICY, + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(); let store = PolicyStore::new(s1); let h1 = store.current().policy_hash.clone(); - let s2 = - PolicyStoreSnapshot::compile( - MINIMAL_SCHEMA, - MINIMAL_POLICY, - RoleRanks::empty(), - PrincipalClaimMappings::default(), - ) - .unwrap(); + let s2 = PolicyStoreSnapshot::compile( + MINIMAL_SCHEMA, + MINIMAL_POLICY, + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(); store.swap(s2); assert_eq!(store.current().policy_hash, h1); } @@ -482,7 +491,10 @@ when { principal_claims_with_org(), ) .expect("strict-mode validation should accept guarded reads of mapped attributes"); - assert!(snap.principal_claims.iter().any(|m| m.attribute_name == "client_org_id")); + assert!(snap + .principal_claims + .iter() + .any(|m| m.attribute_name == "client_org_id")); } #[test] diff --git a/crates/schema-forge-acton/src/cedar/policy_gen.rs b/crates/schema-forge-acton/src/cedar/policy_gen.rs index cbbf1c36..ba407085 100644 --- a/crates/schema-forge-acton/src/cedar/policy_gen.rs +++ b/crates/schema-forge-acton/src/cedar/policy_gen.rs @@ -214,20 +214,18 @@ forbid ( /// Generates the per-schema tenant-isolation forbid. /// -/// Cedar — not the query layer — is the authoritative gate on cross-tenant -/// access. The policy fires for any per-record action when: -/// -/// - the resource carries a `_tenant` reference (i.e., it's tenant-scoped), AND -/// - the principal is not a member of that tenant via parent chain, AND -/// - the principal is not `platform_admin`. -/// -/// The `resource has "_tenant"` precondition keeps the rule inert for -/// non-tenant resources and for the schema-level placeholder (which has no -/// attributes), so it composes cleanly with the schema-level `@access` -/// permits and with `inject_tenant_scope` (which stays as defense in depth -/// at the query layer). +/// Concrete tenanted records fail closed when `_tenant` is absent, or when +/// the caller is outside that tenant. Root adapters derive `_tenant` from +/// the root identity. Platform administrators and schema placeholders are +/// exempt. Legacy untenanted resources retain their metadata-based guard. +/// Query filters are defense in depth; Cedar remains authoritative. fn tenant_guard_forbid_policy(schema: &SchemaDefinition) -> CedarPolicy { let name = schema.name.as_str(); + let condition = if schema.is_tenanted() { + format!("resource != {name}::\"_any\" && !(resource has \"_tenant\" && principal in resource[\"_tenant\"])") + } else { + "resource has \"_tenant\" && !(principal in resource[\"_tenant\"])".to_string() + }; CedarPolicy { description: format!( "Forbid per-record actions on {name} when the principal is not a member of the resource's tenant" @@ -244,8 +242,7 @@ forbid ( ], resource is {name} ) when {{ - resource has "_tenant" - && !(principal in resource["_tenant"]) + {condition} && !(principal in Forge::Group::"platform_admin") }};"#, lname = name.to_ascii_lowercase() diff --git a/crates/schema-forge-acton/src/error.rs b/crates/schema-forge-acton/src/error.rs index d2c06d88..e7bcf871 100644 --- a/crates/schema-forge-acton/src/error.rs +++ b/crates/schema-forge-acton/src/error.rs @@ -30,6 +30,8 @@ pub enum ForgeError { /// with a structured body carrying the offending schema + field so the /// client can surface an inline form error. UniqueViolation { schema: String, field: String }, + /// A relation cannot be written or removed without violating integrity. + ForeignKeyViolation { schema: String, constraint: String }, /// Request body failed validation. Maps to 422. ValidationFailed { details: Vec }, /// Invalid schema name (not PascalCase). Maps to 400. @@ -79,6 +81,10 @@ impl fmt::Display for ForgeError { Self::Conflict { reason, message } => { write!(f, "conflict ({reason}): {message}") } + Self::ForeignKeyViolation { schema, constraint } => write!( + f, + "relation constraint '{constraint}' violated in schema '{schema}'" + ), Self::UniqueViolation { schema, field } => { write!( f, @@ -143,7 +149,8 @@ impl ForgeError { Self::SchemaNotFound { .. } | Self::EntityNotFound { .. } => StatusCode::NOT_FOUND, Self::SchemaAlreadyExists { .. } | Self::Conflict { .. } - | Self::UniqueViolation { .. } => StatusCode::CONFLICT, + | Self::UniqueViolation { .. } + | Self::ForeignKeyViolation { .. } => StatusCode::CONFLICT, Self::ValidationFailed { .. } => StatusCode::UNPROCESSABLE_ENTITY, Self::InvalidSchemaName { .. } | Self::InvalidEntityId { .. } @@ -167,6 +174,7 @@ impl ForgeError { Self::EntityNotFound { .. } => "entity_not_found", Self::SchemaAlreadyExists { .. } => "schema_already_exists", Self::Conflict { .. } => "conflict", + Self::ForeignKeyViolation { .. } => "foreign_key_violation", Self::UniqueViolation { .. } => "unique_violation", Self::ValidationFailed { .. } => "validation_failed", Self::InvalidSchemaName { .. } => "invalid_schema_name", @@ -194,6 +202,9 @@ impl IntoResponse for ForgeError { "reason": reason, "message": message, }), + Self::ForeignKeyViolation { schema, constraint } => { + serde_json::json!({ "schema": schema, "constraint": constraint }) + } Self::UniqueViolation { schema, field } => serde_json::json!({ "error": "unique_violation", "schema": schema, @@ -273,6 +284,9 @@ impl From for ForgeError { }, BackendError::ConnectionError { message } => Self::BackendUnavailable { message }, BackendError::QueryError { message } => Self::BackendUnavailable { message }, + BackendError::ForeignKeyViolation { schema, constraint } => { + Self::ForeignKeyViolation { schema, constraint } + } BackendError::UniqueViolation { schema, field } => { Self::UniqueViolation { schema, field } } @@ -603,12 +617,7 @@ mod tests { let response = err.into_response(); assert_eq!(response.status(), StatusCode::CONFLICT); - let bytes = response - .into_body() - .collect() - .await - .unwrap() - .to_bytes(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(json["error"], "conflict"); assert_eq!(json["reason"], "last_platform_admin"); @@ -624,12 +633,7 @@ mod tests { let response = err.into_response(); assert_eq!(response.status(), StatusCode::CONFLICT); - let bytes = response - .into_body() - .collect() - .await - .unwrap() - .to_bytes(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(json["error"], "unique_violation"); assert_eq!(json["schema"], "Contact"); diff --git a/crates/schema-forge-acton/src/extension.rs b/crates/schema-forge-acton/src/extension.rs index 8eaf786f..9a8b5c16 100644 --- a/crates/schema-forge-acton/src/extension.rs +++ b/crates/schema-forge-acton/src/extension.rs @@ -426,11 +426,11 @@ impl SchemaForgeExtension { // won't have `derived_from` set, so this pass recomputes it on // every daemon start — cheap and idempotent. let mut paired: Vec = registry.values().cloned().collect(); - schema_forge_core::inverse_relations::pair_inverse_relations(&mut paired).map_err( - |e| ForgeError::Internal { + schema_forge_core::inverse_relations::pair_inverse_relations(&mut paired).map_err(|e| { + ForgeError::Internal { message: format!("invalid inverse relation: {e}"), - }, - )?; + } + })?; for schema in paired { registry.insert(schema.name.as_str().to_string(), schema); } @@ -448,12 +448,11 @@ impl SchemaForgeExtension { }; // Initialize the S3 storage registry (empty if no backends configured). - let storage_registry = - StorageRegistry::from_config(storage_config) - .await - .map_err(|e| ForgeError::Internal { - message: format!("Failed to initialize storage registry: {e}"), - })?; + let storage_registry = StorageRegistry::from_config(storage_config) + .await + .map_err(|e| ForgeError::Internal { + message: format!("Failed to initialize storage registry: {e}"), + })?; validate_file_references(&all_schemas, &storage_registry)?; if storage_registry.is_enabled() { tracing::info!( @@ -544,17 +543,17 @@ impl SchemaForgeExtension { /// Adds `POST /forge/graphql` (handler) and `GET /forge/graphql` (GraphiQL playground). /// Claims are extracted from request extensions (injected by upstream token middleware). #[cfg(feature = "graphql")] - pub fn register_graphql_routes(&self, router: Router) -> Router - where - S: Clone + Send + Sync + 'static, - { + pub fn register_graphql_routes( + &self, + router: Router>, + ) -> Router> { let gql_router = Router::new() .route( "/graphql", axum::routing::get(crate::graphql::graphql_playground) .post(crate::graphql::graphql_handler), ) - .with_state(self.state.clone()); + .layer(axum::Extension(self.state.clone())); router.nest("/forge", gql_router) } diff --git a/crates/schema-forge-acton/src/graphql/context.rs b/crates/schema-forge-acton/src/graphql/context.rs index a19e9441..d5c657ed 100644 --- a/crates/schema-forge-acton/src/graphql/context.rs +++ b/crates/schema-forge-acton/src/graphql/context.rs @@ -7,5 +7,7 @@ use crate::state::ForgeState; /// Resolvers access it with `ctx.data::()`. pub struct ForgeGraphqlContext { pub state: ForgeState, + /// Shared actor-backed state used by the canonical entity write handlers. + pub app_state: acton_service::state::AppState, pub claims: Option, } diff --git a/crates/schema-forge-acton/src/graphql/input_types.rs b/crates/schema-forge-acton/src/graphql/input_types.rs index d27929ca..b6e8b7b8 100644 --- a/crates/schema-forge-acton/src/graphql/input_types.rs +++ b/crates/schema-forge-acton/src/graphql/input_types.rs @@ -24,7 +24,30 @@ pub fn build_create_input(schema: &SchemaDefinition) -> InputObject { for field in &schema.fields { let field_name = field.name.as_str(); - let required = field.is_required(); + // The write pipeline materializes defaults, computed values, and ownership. + // GraphQL must allow those fields to be omitted just as REST does. + let server_supplied = field.annotations.iter().any(|annotation| { + matches!( + annotation, + schema_forge_core::types::FieldAnnotation::Default { .. } + | schema_forge_core::types::FieldAnnotation::Compute { .. } + | schema_forge_core::types::FieldAnnotation::Owner + ) + }) || field.modifiers.iter().any(|modifier| { + matches!( + modifier, + schema_forge_core::types::FieldModifier::Default { .. } + ) + }); + let audit_supplied = matches!( + (field_name, &field.field_type), + ("created_at" | "updated_at", FieldType::DateTime) + | ( + "created_by" | "updated_by", + FieldType::Text(_) | FieldType::RichText + ) + ); + let required = field.is_required() && !server_supplied && !audit_supplied; let type_ref = input_field_type_ref( schema.name.as_str(), field_name, @@ -34,6 +57,10 @@ pub fn build_create_input(schema: &SchemaDefinition) -> InputObject { input = input.field(InputValue::new(field_name, type_ref)); } + if schema.unique_scoped_by_tenant() && schema.field("_tenant").is_none() { + input = input.field(InputValue::new("_tenant", TypeRef::named(TypeRef::STRING))); + } + input } @@ -49,6 +76,10 @@ pub fn build_update_input(schema: &SchemaDefinition) -> InputObject { input = input.field(InputValue::new(field_name, type_ref)); } + if schema.unique_scoped_by_tenant() && schema.field("_tenant").is_none() { + input = input.field(InputValue::new("_tenant", TypeRef::named(TypeRef::STRING))); + } + input } diff --git a/crates/schema-forge-acton/src/graphql/mod.rs b/crates/schema-forge-acton/src/graphql/mod.rs index b8efa9e5..d99304c9 100644 --- a/crates/schema-forge-acton/src/graphql/mod.rs +++ b/crates/schema-forge-acton/src/graphql/mod.rs @@ -10,6 +10,7 @@ use async_graphql::http::GraphiQLSource; use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use axum::extract::State; use axum::response::{Html, IntoResponse}; +use axum::Extension; use self::context::ForgeGraphqlContext; use self::schema_builder::build_graphql_schema; @@ -18,18 +19,59 @@ use crate::state::ForgeState; /// GraphQL POST handler. pub async fn graphql_handler( - State(state): State, + State(app_state): State>, + Extension(mut state): Extension, OptionalClaims(claims): OptionalClaims, req: GraphQLRequest, ) -> GraphQLResponse { + if let Err(error) = pin_authorization_snapshot(&app_state, &mut state).await { + return async_graphql::Response::from_errors(vec![async_graphql::ServerError::new( + error.to_string(), + None, + )]) + .into(); + } let schema = state.graphql_schema.load(); let request = req.into_inner().data(ForgeGraphqlContext { state: state.clone(), + app_state, claims, }); schema.execute(request).await.into() } +/// GraphQL reads authorize and project against one coherent request snapshot. +/// Mutations continue to use the live actor-backed REST handlers. +async fn pin_authorization_snapshot( + app_state: &acton_service::state::AppState, + state: &mut ForgeState, +) -> Result<(), crate::error::ForgeError> { + use acton_service::prelude::ActorHandleInterface; + let unavailable = || crate::error::ForgeError::Forbidden { + message: "GraphQL authorization state unavailable".into(), + }; + let actor = app_state + .actor::() + .ok_or_else(unavailable)?; + let (tx, rx) = tokio::sync::oneshot::channel(); + actor + .send(crate::messages::GetAuthorizationSnapshot { + reply: crate::messages::ReplyChannel::new(tx), + }) + .await; + let snapshot = tokio::time::timeout(std::time::Duration::from_secs(5), rx) + .await + .map_err(|_| unavailable())? + .map_err(|_| unavailable())? + .ok_or_else(unavailable)?; + state.registry = crate::state::SchemaRegistry::new(); + for (name, definition) in snapshot.registry { + state.registry.insert(name, definition).await; + } + state.policy_store = Arc::new(crate::authz::PolicyStore::from_snapshot(snapshot.policy)); + Ok(()) +} + /// GraphiQL playground GET handler. pub async fn graphql_playground() -> impl IntoResponse { Html(GraphiQLSource::build().endpoint("/forge/graphql").finish()) diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index a88b7360..db8f4e31 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -7,13 +7,12 @@ use schema_forge_core::query::{validate_filter, FieldPath, SortOrder}; use schema_forge_core::types::{DynamicValue, EntityId, SchemaDefinition, SchemaName}; use super::context::ForgeGraphqlContext; -use super::input_types::{ - filter_input_to_filter, gql_input_to_entity_fields, gql_input_to_partial_fields, -}; +use super::input_types::filter_input_to_filter; use crate::access::{ - check_schema_access, filter_entity_fields, inject_tenant_on_create, inject_tenant_scope, - AccessAction, FieldFilterDirection, + check_schema_access, filter_entity_fields, inject_tenant_scope, AccessAction, + FieldFilterDirection, }; +use crate::authz::{authorize, namespace::ActionVerb}; use crate::error::ForgeError; /// Entity data stored in resolver parent values. @@ -23,44 +22,17 @@ pub struct EntityFields { pub fields: BTreeMap, } -/// Reject any GraphQL input that names a `@hidden` schema field. -/// -/// Mirrors the REST-side `reject_hidden_fields_in_body` guard so a -/// password_hash (or any other operator-marked secret) can't be supplied -/// through the GraphQL mutation surface either. -fn reject_hidden_input( - schema_def: &SchemaDefinition, - input: &async_graphql::indexmap::IndexMap, -) -> Result<(), ForgeError> { - let offenders: Vec = input - .keys() - .filter_map(|key| { - let name = key.as_str(); - schema_def - .field(name) - .filter(|f| f.is_hidden()) - .map(|_| name.to_string()) - }) - .collect(); - if offenders.is_empty() { - Ok(()) - } else { - Err(ForgeError::ValidationFailed { - details: vec![format!( - "fields cannot be set via the GraphQL API (marked @hidden): {}", - offenders.join(", ") - )], - }) - } -} - /// Convert ForgeError to async_graphql::Error with extension codes. pub fn forge_error_to_gql(err: ForgeError) -> async_graphql::Error { let code = match &err { ForgeError::SchemaNotFound { .. } | ForgeError::EntityNotFound { .. } => "NOT_FOUND", ForgeError::Forbidden { .. } => "FORBIDDEN", ForgeError::Unauthorized { .. } => "UNAUTHORIZED", - ForgeError::ValidationFailed { .. } => "VALIDATION_ERROR", + ForgeError::ValidationFailed { .. } | ForgeError::HookAborted { .. } => "VALIDATION_ERROR", + ForgeError::Conflict { .. } + | ForgeError::UniqueViolation { .. } + | ForgeError::ForeignKeyViolation { .. } + | ForgeError::SchemaAlreadyExists { .. } => "CONFLICT", ForgeError::InvalidQuery { .. } | ForgeError::InvalidSchemaName { .. } | ForgeError::InvalidEntityId { .. } => "BAD_REQUEST", @@ -73,14 +45,20 @@ pub fn forge_error_to_gql(err: ForgeError) -> async_graphql::Error { pub async fn resolve_get_entity<'a>( ctx: &ResolverContext<'a>, schema_name: &str, - schema_def: &SchemaDefinition, type_name: &str, ) -> async_graphql::Result>> { let gql_ctx = ctx.data::()?; + let live_definition = request_schema(gql_ctx, schema_name).await?; + let schema_def = &live_definition; let claims = gql_ctx.claims.as_ref(); - check_schema_access(&gql_ctx.state.policy_store, schema_def, claims, AccessAction::Read) - .map_err(forge_error_to_gql)?; + check_schema_access( + &gql_ctx.state.policy_store, + schema_def, + claims, + AccessAction::Read, + ) + .map_err(forge_error_to_gql)?; let id_arg = ctx.args.try_get("id")?.string()?.to_string(); @@ -98,10 +76,11 @@ pub async fn resolve_get_entity<'a>( Err(e) => return Err(forge_error_to_gql(ForgeError::from(e))), }; + require_record_access(gql_ctx, schema_def, &entity, ActionVerb::Read)?; // Record-level visibility check - if let (Some(ref policy), Some(c)) = (&gql_ctx.state.record_access_policy, claims) { + if let Some(policy) = &gql_ctx.state.record_access_policy { let visible = policy - .filter_visible(schema_def, c, vec![entity.clone()]) + .filter_visible_optional(schema_def, claims, vec![entity.clone()]) .await; if visible.is_empty() { return Err(forge_error_to_gql(ForgeError::Forbidden { @@ -125,15 +104,21 @@ pub async fn resolve_get_entity<'a>( /// Resolve a list of entities with filter/sort/pagination. pub async fn resolve_list_entities<'a>( ctx: &ResolverContext<'a>, - _schema_name: &str, - schema_def: &SchemaDefinition, + schema_name: &str, type_name: &str, ) -> async_graphql::Result>> { let gql_ctx = ctx.data::()?; + let live_definition = request_schema(gql_ctx, schema_name).await?; + let schema_def = &live_definition; let claims = gql_ctx.claims.as_ref(); - check_schema_access(&gql_ctx.state.policy_store, schema_def, claims, AccessAction::Read) - .map_err(forge_error_to_gql)?; + check_schema_access( + &gql_ctx.state.policy_store, + schema_def, + claims, + AccessAction::Read, + ) + .map_err(forge_error_to_gql)?; let mut query = schema_forge_core::query::Query::new(schema_def.id.clone()); @@ -193,7 +178,7 @@ pub async fn resolve_list_entities<'a>( } // Inject tenant scope - inject_tenant_scope(&mut query, claims, &gql_ctx.state.tenant_config); + inject_tenant_scope(&mut query, claims, &gql_ctx.state.tenant_config, schema_def); let result = gql_ctx .state @@ -202,16 +187,27 @@ pub async fn resolve_list_entities<'a>( .await .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; + // Canonical Cedar decisions apply even without a custom record policy. + let authorized = result + .entities + .into_iter() + .filter(|entity| { + require_record_access(gql_ctx, schema_def, entity, ActionVerb::Read).is_ok() + && require_record_access(gql_ctx, schema_def, entity, ActionVerb::List).is_ok() + }) + .collect(); // Record-level access filtering - let visible_entities = - if let (Some(ref policy), Some(c)) = (&gql_ctx.state.record_access_policy, claims) { - policy.filter_visible(schema_def, c, result.entities).await - } else { - result.entities - }; + let visible_entities = if let Some(policy) = &gql_ctx.state.record_access_policy { + policy + .filter_visible_optional(schema_def, claims, authorized) + .await + } else { + authorized + }; let count = visible_entities.len(); - let total_count = result.total_count; + // The raw storage count precedes authorization and could disclose hidden rows. + let total_count = None; let items: Vec = visible_entities .into_iter() @@ -250,7 +246,8 @@ pub struct ConnectionData { pub total_count: Option, } -/// Resolve create entity mutation. +/// Resolve create through the canonical write pipeline, including authorization, +/// defaults, rules, hooks, tenant/owner injection, and audit events. pub async fn resolve_create_entity<'a>( ctx: &ResolverContext<'a>, schema_name: &str, @@ -258,183 +255,118 @@ pub async fn resolve_create_entity<'a>( type_name: &str, ) -> async_graphql::Result>> { let gql_ctx = ctx.data::()?; - let claims = gql_ctx.claims.as_ref(); - - check_schema_access(&gql_ctx.state.policy_store, schema_def, claims, AccessAction::Write) - .map_err(forge_error_to_gql)?; - - let input_accessor = ctx.args.try_get("input")?; - let input_obj = input_accessor.object()?; - - let input_map = input_obj.as_index_map(); - reject_hidden_input(schema_def, input_map).map_err(forge_error_to_gql)?; - - let mut fields = gql_input_to_entity_fields(input_map, schema_def) - .map_err(|errors| forge_error_to_gql(ForgeError::ValidationFailed { details: errors }))?; - - // Inject tenant - inject_tenant_on_create(&mut fields, claims, &gql_ctx.state.tenant_config); - - let schema = SchemaName::new(schema_name).map_err(|_| { - forge_error_to_gql(ForgeError::InvalidSchemaName { - name: schema_name.to_string(), - }) - })?; - - let mut entity = Entity::new(schema, fields); - filter_entity_fields( - &gql_ctx.state.policy_store, - &mut entity, - schema_def, - claims, - FieldFilterDirection::Write, - ); - - let mut created = gql_ctx - .state - .backend - .create(&entity) - .await - .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; - - created.strip_hidden(schema_def); - filter_entity_fields( - &gql_ctx.state.policy_store, - &mut created, - schema_def, - claims, - FieldFilterDirection::Read, - ); - - Ok(Some(entity_to_field_value(created, type_name))) + let response = crate::routes::entities::create_entity( + axum::extract::State(gql_ctx.app_state.clone()), + axum::extract::Path(schema_name.to_owned()), + crate::access::OptionalClaims(gql_ctx.claims.clone()), + axum::http::HeaderMap::new(), + axum::Json(mutation_request(ctx)?), + ) + .await + .map_err(forge_error_to_gql)?; + mutation_response(response, schema_def, type_name).await } -/// Resolve update entity mutation. +/// GraphQL updates have patch semantics and share the REST PATCH pipeline. pub async fn resolve_update_entity<'a>( ctx: &ResolverContext<'a>, schema_name: &str, schema_def: &SchemaDefinition, type_name: &str, ) -> async_graphql::Result>> { + use axum::response::IntoResponse; let gql_ctx = ctx.data::()?; - let claims = gql_ctx.claims.as_ref(); - - check_schema_access(&gql_ctx.state.policy_store, schema_def, claims, AccessAction::Write) - .map_err(forge_error_to_gql)?; + let id = ctx.args.try_get("id")?.string()?.to_owned(); + let response = crate::routes::entities::patch_entity( + axum::extract::State(gql_ctx.app_state.clone()), + axum::extract::Path((schema_name.to_owned(), id)), + crate::access::OptionalClaims(gql_ctx.claims.clone()), + axum::http::HeaderMap::new(), + axum::Json(mutation_request(ctx)?), + ) + .await + .map_err(forge_error_to_gql)? + .into_response(); + mutation_response(response, schema_def, type_name).await +} - let id_arg = ctx.args.try_get("id")?.string()?.to_string(); +fn mutation_request( + ctx: &ResolverContext<'_>, +) -> async_graphql::Result { + let input = ctx.args.try_get("input")?.object()?; + Ok(crate::routes::entities::EntityRequest { + fields: input + .as_index_map() + .iter() + .map(|(key, value)| { + ( + key.to_string(), + super::type_mapping::gql_value_to_json(value), + ) + }) + .collect(), + }) +} - let schema = SchemaName::new(schema_name).map_err(|_| { - forge_error_to_gql(ForgeError::InvalidSchemaName { - name: schema_name.to_string(), +/// Adapt the authorized REST projection, never reload the unfiltered stored row. +async fn mutation_response( + response: axum::response::Response, + schema_def: &SchemaDefinition, + type_name: &str, +) -> async_graphql::Result>> { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .map_err(|_| { + forge_error_to_gql(ForgeError::Internal { + message: "failed to read entity mutation response".into(), + }) + })?; + #[derive(serde::Deserialize)] + struct MutationProjection { + id: String, + fields: serde_json::Map, + } + let response: MutationProjection = serde_json::from_slice(&bytes).map_err(|_| { + forge_error_to_gql(ForgeError::Internal { + message: "invalid entity mutation response".into(), }) })?; - - let entity_id = EntityId::parse(&id_arg) - .map_err(|_| forge_error_to_gql(ForgeError::InvalidEntityId { id: id_arg.clone() }))?; - - // Record-level ownership check - if let (Some(ref policy), Some(c)) = (&gql_ctx.state.record_access_policy, claims) { - let existing = gql_ctx - .state - .backend - .get(&schema, &entity_id) - .await - .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; - if !policy.can_modify(schema_def, c, &existing).await { - return Err(forge_error_to_gql(ForgeError::Forbidden { - message: format!("not authorized to modify entity '{id_arg}'"), - })); - } - } - - let input_accessor = ctx.args.try_get("input")?; - let input_obj = input_accessor.object()?; - - let input_map = input_obj.as_index_map(); - reject_hidden_input(schema_def, input_map).map_err(forge_error_to_gql)?; - - let fields = gql_input_to_partial_fields(input_map, schema_def) - .map_err(|errors| forge_error_to_gql(ForgeError::ValidationFailed { details: errors }))?; - - let mut entity = Entity::with_id(entity_id, schema, fields); - filter_entity_fields( - &gql_ctx.state.policy_store, - &mut entity, - schema_def, - claims, - FieldFilterDirection::Write, - ); - - let mut updated = gql_ctx - .state - .backend - .update(&entity) - .await - .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; - - updated.strip_hidden(schema_def); - filter_entity_fields( - &gql_ctx.state.policy_store, - &mut updated, + let fields = crate::routes::entities::json_to_entity_fields_with_mode( schema_def, - claims, - FieldFilterDirection::Read, - ); - - Ok(Some(entity_to_field_value(updated, type_name))) + &response.fields, + crate::routes::entities::ConversionMode::Merge, + ) + .map_err(|_| { + forge_error_to_gql(ForgeError::Internal { + message: "invalid entity mutation projection".into(), + }) + })?; + let id = EntityId::parse(&response.id).map_err(|_| { + forge_error_to_gql(ForgeError::Internal { + message: "invalid entity mutation identifier".into(), + }) + })?; + Ok(Some(entity_to_field_value( + Entity::with_id(id, schema_def.name.clone(), fields), + type_name, + ))) } -/// Resolve delete entity mutation. +/// Resolve deletion through the canonical REST authorization, hooks, and storage pipeline. pub async fn resolve_delete_entity( ctx: &ResolverContext<'_>, schema_name: &str, - schema_def: &SchemaDefinition, ) -> async_graphql::Result { let gql_ctx = ctx.data::()?; - let claims = gql_ctx.claims.as_ref(); - - check_schema_access( - &gql_ctx.state.policy_store, - schema_def, - claims, - AccessAction::Delete, + let id = ctx.args.try_get("id")?.string()?.to_owned(); + crate::routes::entities::delete_entity( + axum::extract::State(gql_ctx.app_state.clone()), + axum::extract::Path((schema_name.to_owned(), id)), + crate::access::OptionalClaims(gql_ctx.claims.clone()), + axum::http::HeaderMap::new(), ) + .await .map_err(forge_error_to_gql)?; - - let id_arg = ctx.args.try_get("id")?.string()?.to_string(); - - let schema = SchemaName::new(schema_name).map_err(|_| { - forge_error_to_gql(ForgeError::InvalidSchemaName { - name: schema_name.to_string(), - }) - })?; - - let entity_id = EntityId::parse(&id_arg) - .map_err(|_| forge_error_to_gql(ForgeError::InvalidEntityId { id: id_arg.clone() }))?; - - // Record-level ownership check - if let (Some(ref policy), Some(c)) = (&gql_ctx.state.record_access_policy, claims) { - let entity = gql_ctx - .state - .backend - .get(&schema, &entity_id) - .await - .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; - if !policy.can_delete(schema_def, c, &entity).await { - return Err(forge_error_to_gql(ForgeError::Forbidden { - message: format!("not authorized to delete entity '{id_arg}'"), - })); - } - } - - gql_ctx - .state - .backend - .delete(&schema, &entity_id) - .await - .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; - Ok(GqlValue::Boolean(true)) } @@ -444,7 +376,6 @@ pub async fn resolve_relation_one<'a>( parent: &EntityFields, field_name: &str, target_schema_name: &str, - target_schema_def: &SchemaDefinition, target_type_name: &str, ) -> async_graphql::Result>> { let ref_id = match parent.fields.get(field_name) { @@ -454,6 +385,8 @@ pub async fn resolve_relation_one<'a>( }; let gql_ctx = ctx.data::()?; + let live_definition = request_schema(gql_ctx, target_schema_name).await?; + let target_schema_def = &live_definition; let claims = gql_ctx.claims.as_ref(); check_schema_access( @@ -475,6 +408,13 @@ pub async fn resolve_relation_one<'a>( Err(_) => return Ok(None), }; + if require_record_access(gql_ctx, target_schema_def, &entity, ActionVerb::Read).is_err() { + return Ok(None); + } + if !operator_visible(gql_ctx, target_schema_def, &entity).await { + return Ok(None); + } + entity.strip_hidden(target_schema_def); filter_entity_fields( &gql_ctx.state.policy_store, &mut entity, @@ -492,7 +432,6 @@ pub async fn resolve_relation_many<'a>( parent: &EntityFields, field_name: &str, target_schema_name: &str, - target_schema_def: &SchemaDefinition, target_type_name: &str, ) -> async_graphql::Result>> { let ref_ids = match parent.fields.get(field_name) { @@ -504,6 +443,8 @@ pub async fn resolve_relation_many<'a>( }; let gql_ctx = ctx.data::()?; + let live_definition = request_schema(gql_ctx, target_schema_name).await?; + let target_schema_def = &live_definition; let claims = gql_ctx.claims.as_ref(); check_schema_access( @@ -523,6 +464,13 @@ pub async fn resolve_relation_many<'a>( let mut results = Vec::new(); for ref_id in ref_ids { if let Ok(mut entity) = gql_ctx.state.backend.get(&target_schema, &ref_id).await { + if require_record_access(gql_ctx, target_schema_def, &entity, ActionVerb::Read).is_err() + { + continue; + } + if !operator_visible(gql_ctx, target_schema_def, &entity).await { + continue; + } entity.strip_hidden(target_schema_def); filter_entity_fields( &gql_ctx.state.policy_store, @@ -538,20 +486,81 @@ pub async fn resolve_relation_many<'a>( Ok(Some(FieldValue::list(results))) } +async fn operator_visible( + context: &ForgeGraphqlContext, + schema: &SchemaDefinition, + entity: &Entity, +) -> bool { + match &context.state.record_access_policy { + Some(policy) => !policy + .filter_visible_optional(schema, context.claims.as_ref(), vec![entity.clone()]) + .await + .is_empty(), + None => true, + } +} + +async fn request_schema( + context: &ForgeGraphqlContext, + name: &str, +) -> async_graphql::Result { + context + .state + .registry + .get(name) + .await + .ok_or_else(|| forge_error_to_gql(ForgeError::SchemaNotFound { name: name.into() })) +} + +fn require_record_access( + context: &ForgeGraphqlContext, + schema: &SchemaDefinition, + entity: &Entity, + action: ActionVerb, +) -> async_graphql::Result<()> { + let decision = authorize( + &context.state.policy_store, + context.claims.as_ref(), + action, + schema, + Some(entity), + ) + .map_err(|_| { + forge_error_to_gql(ForgeError::Forbidden { + message: "could not authorize entity".into(), + }) + })?; + if decision.is_allow() && decision.errors.is_empty() { + Ok(()) + } else { + Err(forge_error_to_gql(ForgeError::Forbidden { + message: "not authorized for this entity".into(), + })) + } +} + /// Convert an Entity to a FieldValue wrapping EntityFields. -fn entity_to_field_value(entity: Entity, type_name: &str) -> FieldValue<'static> { +fn entity_to_field_value(entity: Entity, _type_name: &str) -> FieldValue<'static> { FieldValue::owned_any(EntityFields { id: entity.id.clone(), schema: entity.schema.clone(), fields: entity.fields, }) - .with_type(type_name.to_string()) } #[cfg(test)] mod tests { use super::*; + #[test] + fn foreign_key_conflict_is_not_an_internal_graphql_error() { + let error = forge_error_to_gql(ForgeError::ForeignKeyViolation { + schema: "Pet".into(), + constraint: "Pet_owner_fkey".into(), + }); + assert_eq!(extension_code(&error).as_deref(), Some("CONFLICT")); + } + fn extension_code(err: &async_graphql::Error) -> Option { err.extensions .as_ref() diff --git a/crates/schema-forge-acton/src/graphql/schema_builder.rs b/crates/schema-forge-acton/src/graphql/schema_builder.rs index 8143433f..b92a2b9a 100644 --- a/crates/schema-forge-acton/src/graphql/schema_builder.rs +++ b/crates/schema-forge-acton/src/graphql/schema_builder.rs @@ -25,12 +25,6 @@ use super::type_mapping::{ pub fn build_graphql_schema(schemas: &[SchemaDefinition]) -> Result { let non_system: Vec<&SchemaDefinition> = schemas.iter().filter(|s| !s.is_system()).collect(); - // Build a lookup map for relation resolvers - let schema_map: HashMap = schemas - .iter() - .map(|s| (s.name.as_str().to_string(), s)) - .collect(); - let mut query = Object::new("Query"); let mut mutation = Object::new("Mutation"); @@ -59,7 +53,7 @@ pub fn build_graphql_schema(schemas: &[SchemaDefinition]) -> Result Result Result Result Result Result Result, - type_name: &str, -) -> Result { +fn build_output_type(schema_def: &SchemaDefinition, type_name: &str) -> Result { let schema_name = schema_def.name.as_str().to_string(); let mut obj = Object::new(type_name); @@ -315,7 +297,6 @@ fn build_output_type( // Relation type ref is always nullable let type_ref = field_type_to_type_ref(&schema_name, &field_name, field_type, false); - let target_def = schema_map.get(&target_name).map(|s| Arc::new((*s).clone())); let card = *cardinality; let fn_clone = field_name.clone(); @@ -323,13 +304,9 @@ fn build_output_type( let fn_clone = fn_clone.clone(); let target_name = target_name.clone(); let target_type = target_type.clone(); - let target_def = target_def.clone(); let card = card; FieldFuture::new(async move { let parent = ctx.parent_value.try_downcast_ref::()?; - let Some(td) = target_def else { - return Ok(None); - }; match card { Cardinality::One => { resolve_relation_one( @@ -337,7 +314,6 @@ fn build_output_type( parent, &fn_clone, &target_name, - &td, &target_type, ) .await @@ -348,7 +324,6 @@ fn build_output_type( parent, &fn_clone, &target_name, - &td, &target_type, ) .await @@ -389,14 +364,11 @@ fn build_output_type( /// Build a `{Schema}Connection` type. fn build_connection_type(connection_name: &str, item_type_name: &str) -> Object { - let item_tn = item_type_name.to_string(); - Object::new(connection_name) .field(Field::new( "items", TypeRef::named_nn_list(item_type_name), move |ctx| { - let item_tn = item_tn.clone(); FieldFuture::new(async move { let conn = ctx.parent_value.try_downcast_ref::()?; let items: Vec = conn @@ -408,7 +380,6 @@ fn build_connection_type(connection_name: &str, item_type_name: &str) -> Object schema: ef.schema.clone(), fields: ef.fields.clone(), }) - .with_type(item_tn.clone()) }) .collect(); Ok(Some(FieldValue::list(items))) diff --git a/crates/schema-forge-acton/src/messages.rs b/crates/schema-forge-acton/src/messages.rs index 857606dd..cef2b3e2 100644 --- a/crates/schema-forge-acton/src/messages.rs +++ b/crates/schema-forge-acton/src/messages.rs @@ -127,10 +127,43 @@ pub struct GetPolicyStore { pub reply: ReplyChannel>>, } +/// Coherent definitions and policies captured under the actor message barrier. +#[derive(Clone, Debug)] +pub struct AuthorizationSnapshot { + pub registry: HashMap, + pub policy: Arc, +} + +/// Capture a request's authorization context without mixing registry versions. +#[derive(Clone, Debug)] +pub struct GetAuthorizationSnapshot { + pub reply: ReplyChannel>, +} + +/// Retrieve the custom policy source selected when the actor was initialized. +/// This includes CLI overrides and must be used for schema preflight checks. +#[derive(Clone, Debug)] +pub struct GetCustomPoliciesDir { + pub reply: ReplyChannel>, +} + // --------------------------------------------------------------------------- // Registry mutations // --------------------------------------------------------------------------- +/// Commit a prevalidated schema change under the actor's mutation barrier. +/// The expected registry and policy identity guard against stale preflight plans. +#[derive(Clone, Debug)] +pub struct ApplyPreparedSchemaChange { + pub expected_registry: HashMap, + pub expected_policy: Arc, + pub next_policy: Arc, + pub definition: SchemaDefinition, + pub remove: bool, + pub steps: Vec, + pub reply: ReplyChannel>, +} + /// Insert or update a schema definition in the in-memory registry. /// /// On success, the actor recompiles the Cedar policy bundle from the new diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index f90b4843..95f5ea1b 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -12,8 +12,8 @@ use schema_forge_backend::conditional::{ConditionalMutationError, EntityRevision use schema_forge_backend::entity::Entity; use schema_forge_core::query::{validate_filter, FieldPath, Filter, SortOrder}; use schema_forge_core::types::{ - Cardinality, ConstraintViolation, DynamicValue, EntityId, FieldType, SchemaDefinition, - SchemaName, + Cardinality, ConstraintViolation, DynamicValue, EntityId, FieldAnnotation, FieldType, + SchemaDefinition, SchemaName, }; use serde::{Deserialize, Serialize}; use tokio::sync::oneshot; @@ -23,8 +23,9 @@ use super::query_params::{parse_fields_param, parse_filter_params, parse_sort_pa use crate::access::{ check_schema_access, entity_permissions, filter_entity_fields, filter_patch_fields, inject_audit_columns_on_create, inject_audit_columns_on_update, inject_owner_on_create, - inject_tenant_on_create, inject_tenant_scope, schema_permissions, strip_owner_on_update, - AccessAction, EntityPermissions, FieldFilterDirection, OptionalClaims, SchemaPermissions, + inject_tenant_on_create, inject_tenant_scope, schema_permissions, stamp_root_tenant, + strip_owner_on_update, strip_tenant_on_update, AccessAction, EntityPermissions, + FieldFilterDirection, OptionalClaims, SchemaPermissions, }; use crate::actor::ForgeActor; use crate::authz::{authorize, namespace::ActionVerb}; @@ -51,13 +52,15 @@ use std::sync::Arc; /// Map a [`RuleError`] from CEL `@require` validation onto a [`ForgeError`]. /// /// A definite rejection becomes a 422 `ValidationFailed`; a predicate that -/// could not be evaluated (errored or non-bool) becomes a 500 `Internal`, +/// could not be evaluated (errored or non-bool) becomes a 422 `ValidationFailed`, /// preserving the fail-closed contract documented on [`crate::rules`]. fn rule_error_to_forge(err: RuleError) -> ForgeError { match err { RuleError::Rejected(details) => ForgeError::ValidationFailed { details }, - RuleError::Eval { field, detail } => ForgeError::Internal { - message: format!("@require on field '{field}' could not be evaluated: {detail}"), + RuleError::Eval { field, detail } => ForgeError::ValidationFailed { + details: vec![format!( + "rule on field '{field}' could not be evaluated: {detail}" + )], }, } } @@ -828,6 +831,113 @@ fn enforce_bytes_max_size(bytes: &[u8], max_size: Option) -> Result<(), S } } +/// Concrete Cedar authorization is mandatory even when an operator supplies +/// an additional record policy. Transport middleware is defense in depth. +fn require_entity_action( + store: &Arc, + schema: &SchemaDefinition, + claims: Option<&Claims>, + entity: &Entity, + action: ActionVerb, +) -> Result<(), ForgeError> { + let permitted = authorize(store, claims, action, schema, Some(entity)) + .is_ok_and(|decision| decision.is_allow() && decision.errors.is_empty()); + if permitted { + Ok(()) + } else { + Err(ForgeError::Forbidden { + message: "not authorized for this entity".into(), + }) + } +} + +/// Reevaluate retained input against the resource after every denied-field removal. +/// Each pass removes keys, so authorization reaches a stable result in finite time. +fn filter_update_input( + store: &Arc, + schema: &SchemaDefinition, + claims: Option<&Claims>, + existing: &Entity, + supplied: &mut Entity, +) -> Result<(), ForgeError> { + loop { + let mut candidate = existing.clone(); + candidate.fields.extend(supplied.fields.clone()); + let before = supplied.fields.len(); + filter_patch_fields(store, supplied, &candidate, schema, claims)?; + if supplied.fields.len() == before { + return Ok(()); + } + } +} + +fn filter_create_input( + store: &Arc, + schema: &SchemaDefinition, + claims: Option<&Claims>, + supplied: &mut Entity, +) -> Result<(), ForgeError> { + loop { + let mut denied = Vec::new(); + for name in supplied.fields.keys() { + if schema + .field(name) + .is_none_or(|field| field.has_owner() || field.field_access().is_none()) + { + continue; + } + let decision = + crate::authz::engine::authorize_input_field(store, claims, schema, supplied, name) + .map_err(|_| ForgeError::Forbidden { + message: "Could not authorize a supplied field.".into(), + })?; + if !decision.errors.is_empty() { + return Err(ForgeError::Forbidden { + message: "Could not authorize a supplied field.".into(), + }); + } + if !decision.is_allow() { + denied.push(name.clone()); + } + } + if denied.is_empty() { + return Ok(()); + } + for name in denied { + supplied.fields.remove(&name); + } + } +} + +/// Reject missing or null required fields after server values have been applied. +pub(crate) fn validate_required_fields( + schema: &SchemaDefinition, + fields: &BTreeMap, +) -> Result<(), ForgeError> { + let details: Vec<_> = schema + .fields + .iter() + .filter(|field| !field.is_derived() && field.is_required()) + .filter(|field| { + matches!( + fields.get(field.name.as_str()), + None | Some(DynamicValue::Null) + ) + }) + .map(|field| { + format!( + "required field '{}' is missing or null", + field.name.as_str() + ) + }) + .collect(); + if details.is_empty() { + Ok(()) + } else { + Err(ForgeError::ValidationFailed { details }) + } +} + /// Check every value in a write against the constraints declared on its /// field's type, collecting all violations into a single 422. /// @@ -1467,7 +1577,7 @@ async fn execute_entity_query( }) .await; let tenant_config = ask_forge(rx).await?; - inject_tenant_scope(query, claims, &tenant_config); + inject_tenant_scope(query, claims, &tenant_config, schema_def); // NOTE: the client `fields` projection is deliberately NOT pushed into the // DB query as a column selection. Record-level authorization @@ -1707,7 +1817,7 @@ async fn resolve_relation_displays( } // Apply tenant scope so we never leak rows the caller couldn't // otherwise see through a direct list call. - inject_tenant_scope(&mut display_query, claims, tenant_config); + inject_tenant_scope(&mut display_query, claims, tenant_config, target_def); let source_fields: Vec = fields_pointing_at_target .iter() @@ -1860,7 +1970,7 @@ async fn check_requires_with_related( now: chrono::DateTime, tenant_config: &Option, ) -> Result<(), ForgeError> { - let mut bindings = build_bindings(fields, claims, now); + let mut bindings = build_bindings(schema, fields, claims, now); let related_map = resolve_related_bindings(forge, schema, fields, claims, tenant_config).await?; @@ -2055,7 +2165,7 @@ async fn load_related_row( values: vec![DynamicValue::Text(fk_id.to_string())], }) .without_total_count(); - inject_tenant_scope(&mut query, claims, tenant_config); + inject_tenant_scope(&mut query, claims, tenant_config, target_def); let (tx, rx) = oneshot::channel(); forge @@ -2189,7 +2299,7 @@ async fn populate_derived_collections( if claims.is_some() { child_query.projection = Some(vec!["id".to_string(), fk_field_name.clone()]); } - inject_tenant_scope(&mut child_query, claims, tenant_config); + inject_tenant_scope(&mut child_query, claims, tenant_config, target_def); jobs.push((target_def, parent_field_name, fk_field_name, child_query)); } @@ -2457,10 +2567,10 @@ pub async fn create_entity( reject_hidden_fields_in_body(&schema_def, &body.fields)?; // Convert JSON fields to DynamicValue fields - let mut fields = json_to_entity_fields(&schema_def, &body.fields) - .map_err(|errors| ForgeError::ValidationFailed { details: errors })?; + let mut fields = + json_to_entity_fields_with_mode(&schema_def, &body.fields, ConversionMode::Merge) + .map_err(|errors| ForgeError::ValidationFailed { details: errors })?; - // Get tenant config via actor let (tx, rx) = oneshot::channel(); forge .send(GetTenantConfig { @@ -2468,7 +2578,30 @@ pub async fn create_entity( }) .await; let tenant_config = ask_forge(rx).await?; - inject_tenant_on_create(&mut fields, claims.as_ref(), &tenant_config); + let mut supplied = Entity::new(schema_name.clone(), fields); + stamp_root_tenant(&mut supplied, &schema_def); + inject_tenant_on_create( + &mut supplied.fields, + claims.as_ref(), + &tenant_config, + &schema_def, + ); + inject_owner_on_create(&mut supplied.fields, &schema_def, claims.as_ref()); + filter_create_input(&policy_store, &schema_def, claims.as_ref(), &mut supplied)?; + // Server-owned values are injected again after caller input filtering. + stamp_root_tenant(&mut supplied, &schema_def); + fields = supplied.fields; + inject_tenant_on_create(&mut fields, claims.as_ref(), &tenant_config, &schema_def); + if schema_def.unique_scoped_by_tenant() + && !matches!(fields.get("_tenant"), Some(DynamicValue::Text(tenant)) if !tenant.is_empty()) + { + return Err(ForgeError::ValidationFailed { + details: vec![format!( + "tenanted schema '{}' requires _tenant; platform_admin must supply a tenant", + schema_def.name + )], + }); + } inject_owner_on_create(&mut fields, &schema_def, claims.as_ref()); // Single request-time instant: reused for audit columns and as the `now` // CEL binding so all rules in this write observe the same clock. @@ -2488,6 +2621,14 @@ pub async fn create_entity( // CEL @compute derived fields (#93) — evaluated before @require, stored. apply_computed(&schema_def, &mut fields, claims.as_ref(), rules_now) .map_err(rule_error_to_forge)?; + validate_required_fields(&schema_def, &fields)?; + require_entity_action( + &policy_store, + &schema_def, + claims.as_ref(), + &Entity::with_id(supplied.id.clone(), schema_name.clone(), fields.clone()), + ActionVerb::Create, + )?; // CEL @require validation rules (#92) — fail-closed, in-transaction, // pre-persistence. Cross-entity reads (#95) are resolved here: any @@ -2544,15 +2685,16 @@ pub async fn create_entity( } // Create the entity, filtering write-restricted fields - let mut entity = Entity::new(schema_name, fields); - filter_entity_fields( + let entity = Entity::with_id(supplied.id, schema_name, fields); + validate_required_fields(&schema_def, &entity.fields)?; + check_field_constraints(&schema_def, &entity.fields)?; + require_entity_action( &policy_store, - &mut entity, &schema_def, claims.as_ref(), - FieldFilterDirection::Write, - ); - check_field_constraints(&schema_def, &entity.fields)?; + &entity, + ActionVerb::Create, + )?; if let Some(intent) = intent { let changed_fields: Vec<_> = entity.fields.keys().cloned().collect(); @@ -3221,6 +3363,13 @@ pub async fn update_entity( conditional_requested(&headers), ) .await?; + require_entity_action( + &policy_store, + &schema_def, + claims.as_ref(), + &existing, + ActionVerb::Update, + )?; // Record-level ownership check: fetch existing entity and verify ownership let (tx, rx) = oneshot::channel(); @@ -3269,15 +3418,58 @@ pub async fn update_entity( reject_hidden_fields_in_body(&schema_def, &body.fields)?; // Convert JSON fields - let mut fields = json_to_entity_fields(&schema_def, &body.fields) - .map_err(|errors| ForgeError::ValidationFailed { details: errors })?; + let mut fields = + json_to_entity_fields_with_mode(&schema_def, &body.fields, ConversionMode::Merge) + .map_err(|errors| ForgeError::ValidationFailed { details: errors })?; + // Immutable input cannot influence field authorization. strip_owner_on_update(&mut fields, &schema_def); - // PUT replaces supplied fields, but immutable ownership remains part of - // the post-update rule context, even when an administrator is the caller. - if let Some(owner_field) = schema_def.fields.iter().find(|field| field.has_owner()) { - if let Some(owner) = existing.fields.get(owner_field.name.as_str()) { - fields.insert(owner_field.name.as_str().to_string(), owner.clone()); + strip_tenant_on_update(&mut fields, &schema_def, claims.as_ref()); + if let Some(tenant) = existing.fields.get("_tenant") { + fields + .entry("_tenant".into()) + .or_insert_with(|| tenant.clone()); + } + // PUT removes omitted writable optional values. Hidden/server-owned + // fields remain present, and denied removals are restored below. + for field in &schema_def.fields { + let name = field.name.as_str(); + if field.is_derived() || fields.contains_key(name) { + continue; + } + let server_owned = field.is_hidden() + || field.has_owner() + || field + .annotations + .iter() + .any(|annotation| matches!(annotation, FieldAnnotation::Compute { .. })) + || matches!( + name, + "created_at" | "created_by" | "updated_at" | "updated_by" + ); + if server_owned { + if let Some(value) = existing.fields.get(name) { + fields.insert(name.into(), value.clone()); + } + } else if !field.is_required() || field.field_access().is_some() { + fields.insert(name.into(), DynamicValue::Null); + } + } + let proposed_names: Vec<_> = fields.keys().cloned().collect(); + let mut supplied = Entity::with_id(entity_id.clone(), schema_name.clone(), fields); + filter_update_input( + &policy_store, + &schema_def, + claims.as_ref(), + &existing, + &mut supplied, + )?; + fields = supplied.fields; + for name in proposed_names { + if let std::collections::btree_map::Entry::Vacant(entry) = fields.entry(name) { + if let Some(value) = existing.fields.get(entry.key()) { + entry.insert(value.clone()); + } } } // Single request-time instant reused for audit columns and the `now` CEL binding. @@ -3294,6 +3486,14 @@ pub async fn update_entity( apply_computed(&schema_def, &mut fields, claims.as_ref(), rules_now) .map_err(rule_error_to_forge)?; + validate_required_fields(&schema_def, &fields).map_err(|error| match error { + ForgeError::ValidationFailed { mut details } => { + details.push("PUT requires a complete entity; use PATCH for partial updates".into()); + ForgeError::ValidationFailed { details } + } + error => error, + })?; + // Tenant config for cross-entity-read tenant scoping (#95). Fetched here so // a `related.` prefetch honors the caller's tenant boundary. let (tx, rx) = oneshot::channel(); @@ -3356,14 +3556,8 @@ pub async fn update_entity( } // Build entity with specific ID, filtering write-restricted fields - let mut entity = Entity::with_id(entity_id, schema_name, fields); - filter_entity_fields( - &policy_store, - &mut entity, - &schema_def, - claims.as_ref(), - FieldFilterDirection::Write, - ); + let entity = Entity::with_id(entity_id, schema_name, fields); + validate_required_fields(&schema_def, &entity.fields)?; check_field_constraints(&schema_def, &entity.fields)?; let (mut updated, revision) = persist_entity_update(&forge, entity, expected).await?; @@ -3501,6 +3695,13 @@ pub async fn patch_entity( conditional_requested(&headers), ) .await?; + require_entity_action( + &policy_store, + &schema_def, + claims.as_ref(), + &existing, + ActionVerb::Update, + )?; // Record-level ownership check let (tx, rx) = oneshot::channel(); @@ -3557,6 +3758,18 @@ pub async fn patch_entity( // Owner field is immutable post-create; refuse to transfer ownership // via PATCH the same way we refuse via PUT. strip_owner_on_update(&mut patch_fields, &schema_def); + strip_tenant_on_update(&mut patch_fields, &schema_def, claims.as_ref()); + + let mut supplied = Entity::with_id(entity_id.clone(), schema_name.clone(), patch_fields); + filter_update_input( + &policy_store, + &schema_def, + claims.as_ref(), + &existing, + &mut supplied, + )?; + patch_fields = supplied.fields; + // Single request-time instant reused for audit columns and the `now` CEL binding. let rules_now = chrono::Utc::now(); inject_audit_columns_on_update(&mut patch_fields, &schema_def, claims.as_ref(), rules_now); @@ -3584,6 +3797,8 @@ pub async fn patch_entity( apply_computed(&schema_def, &mut merged, claims.as_ref(), rules_now) .map_err(rule_error_to_forge)?; + validate_required_fields(&schema_def, &merged)?; + // Tenant config for cross-entity-read tenant scoping (#95). let (tx, rx) = oneshot::channel(); forge @@ -3645,6 +3860,9 @@ pub async fn patch_entity( .await?; } + validate_required_fields(&schema_def, &merged)?; + check_field_constraints(&schema_def, &merged)?; + // Compute the delta: only keys whose final value differs from the // loaded baseline go to the backend. This keeps PATCH's SQL UPDATE // actually partial, which makes the whole class of "null column @@ -3667,15 +3885,7 @@ pub async fn patch_entity( let (mut updated, revision) = if delta.is_empty() && expected.is_none() { (existing, None) } else { - let resource = Entity::with_id(entity_id.clone(), schema_name.clone(), merged); - let mut entity = Entity::with_id(entity_id, schema_name, delta); - filter_patch_fields( - &policy_store, - &mut entity, - &resource, - &schema_def, - claims.as_ref(), - )?; + let entity = Entity::with_id(entity_id, schema_name, delta); check_field_constraints(&schema_def, &entity.fields)?; persist_entity_update(&forge, entity, expected).await? }; @@ -3801,6 +4011,13 @@ pub async fn delete_entity( conditional_requested(&headers), ) .await?; + require_entity_action( + &policy_store, + &schema_def, + claims.as_ref(), + &existing, + ActionVerb::Delete, + )?; // Record-level ownership check: fetch entity first and verify ownership let (tx, rx) = oneshot::channel(); diff --git a/crates/schema-forge-acton/src/routes/export.rs b/crates/schema-forge-acton/src/routes/export.rs index 7ea04643..6574c564 100644 --- a/crates/schema-forge-acton/src/routes/export.rs +++ b/crates/schema-forge-acton/src/routes/export.rs @@ -167,8 +167,7 @@ struct FieldDisplay<'a> { impl RelationDisplay for FieldDisplay<'_> { fn display_for(&self, id: &EntityId) -> Option { - self.id_to_display - .and_then(|m| m.get(id.as_str()).cloned()) + self.id_to_display.and_then(|m| m.get(id.as_str()).cloned()) } } @@ -488,9 +487,11 @@ pub async fn prepare_export( // max_rows + 1 so an over-cap result is detectable without draining the table. let mut query = Query::new(schema_def.id.clone()).without_total_count(); if let Some(filter_json) = filter_json { - let filter = crate::routes::entities::json_to_filter(filter_json, schema_def) - .map_err(|errors| ForgeError::InvalidQuery { - message: errors.join("; "), + let filter = + crate::routes::entities::json_to_filter(filter_json, schema_def).map_err(|errors| { + ForgeError::InvalidQuery { + message: errors.join("; "), + } })?; validate_filter(&filter, schema_def).map_err(|errors| ForgeError::InvalidQuery { message: errors @@ -507,7 +508,7 @@ pub async fn prepare_export( query = query.with_limit(probe_limit); // Same tenant injection as the query path. - inject_tenant_scope(&mut query, claims, tenant_config); + inject_tenant_scope(&mut query, claims, tenant_config, schema_def); // Execute. let (tx, rx) = oneshot::channel(); @@ -615,9 +616,12 @@ pub async fn materialize_zip_bundle( if bundle_files { let blob_refs = collect_file_blob_refs(&prepared.entities, schema_def, &prepared.columns); for blob in blob_refs { - let bytes = store.get(&blob.key).await.map_err(|e| ForgeError::Internal { - message: format!("failed to read file blob '{}' for bundle: {e}", blob.key), - })?; + let bytes = store + .get(&blob.key) + .await + .map_err(|e| ForgeError::Internal { + message: format!("failed to read file blob '{}' for bundle: {e}", blob.key), + })?; entries.push(BundleEntry::new(blob.member_name, bytes)); } } @@ -669,11 +673,7 @@ async fn enforce_export_rate_limit( })?; let key = rate_limit_key(claims.map(|c| c.sub.as_str())); - let window_ms = settings - .rate_limit - .window_secs - .saturating_mul(1000) - .max(1); + let window_ms = settings.rate_limit.window_secs.saturating_mul(1000).max(1); let (tx, rx) = oneshot::channel(); limiter @@ -811,7 +811,8 @@ pub async fn export_entities( // id-only file (which would mask the attempt). Narrowing a partially-valid // request is still allowed — only a wholly-non-exportable request is denied. if let Some(requested) = body.fields.as_deref() { - if !requested.is_empty() && resolve_export_columns(&schema_def, Some(requested)).is_empty() { + if !requested.is_empty() && resolve_export_columns(&schema_def, Some(requested)).is_empty() + { audit_export( &state, "forge.export.denied", @@ -858,7 +859,8 @@ pub async fn export_entities( // The effective row cap is the schema's `@export(max_rows)` intersected with // the server-wide ceiling: a schema may declare a tighter cap, never one // above what the operator permits (fail-closed). - let max_rows = crate::export_config::resolve_max_rows(max_rows, export_settings.default_max_rows); + let max_rows = + crate::export_config::resolve_max_rows(max_rows, export_settings.default_max_rows); // Initiated: the request passed the entity-level and authz gates. Record // the requested filter/fields/format for the exfiltration trail. @@ -945,9 +947,7 @@ pub async fn export_entities( .await; return Err(ForgeError::ExportTooLarge { max_rows, - message: format!( - "{message}; use the async job endpoint (POST with async:true)" - ), + message: format!("{message}; use the async job endpoint (POST with async:true)"), }); } Err(e) => return Err(e), @@ -1289,7 +1289,7 @@ async fn resolve_export_displays( }) .without_total_count(); display_query.projection = Some(vec!["id".to_string(), display_field.clone()]); - inject_tenant_scope(&mut display_query, claims, tenant_config); + inject_tenant_scope(&mut display_query, claims, tenant_config, &target_def); let (tx, rx) = oneshot::channel(); forge @@ -1396,11 +1396,7 @@ mod tests { for (k, v) in fields { map.insert((*k).to_string(), DynamicValue::Text((*v).to_string())); } - Entity::with_id( - EntityId::new(id), - SchemaName::new("Subject").unwrap(), - map, - ) + Entity::with_id(EntityId::new(id), SchemaName::new("Subject").unwrap(), map) } #[test] @@ -1455,8 +1451,14 @@ mod tests { let schema = export_schema(); let cols = resolve_export_columns(&schema, None); let rows = vec![ - row("a", &[("name", "Ada"), ("ssn", "111-22-3333"), ("notes", "vip")]), - row("b", &[("name", "Bob"), ("ssn", "999-88-7777"), ("notes", "x,y")]), + row( + "a", + &[("name", "Ada"), ("ssn", "111-22-3333"), ("notes", "vip")], + ), + row( + "b", + &[("name", "Bob"), ("ssn", "999-88-7777"), ("notes", "x,y")], + ), ]; let bytes = entities_to_xlsx(&rows, &cols, &HashMap::new()).unwrap(); @@ -1582,8 +1584,14 @@ mod tests { let schema = export_schema(); let cols = resolve_export_columns(&schema, None); let rows = vec![ - row("a", &[("name", "Ada"), ("ssn", "111-22-3333"), ("notes", "vip")]), - row("b", &[("name", "Bob"), ("ssn", "999-88-7777"), ("notes", "x,y")]), + row( + "a", + &[("name", "Ada"), ("ssn", "111-22-3333"), ("notes", "vip")], + ), + row( + "b", + &[("name", "Bob"), ("ssn", "999-88-7777"), ("notes", "x,y")], + ), ]; let csv = entities_to_csv(&rows, &cols, &HashMap::new()).unwrap(); let mut lines = csv.lines(); diff --git a/crates/schema-forge-acton/src/routes/invites.rs b/crates/schema-forge-acton/src/routes/invites.rs index 0df317d7..d880d075 100644 --- a/crates/schema-forge-acton/src/routes/invites.rs +++ b/crates/schema-forge-acton/src/routes/invites.rs @@ -33,9 +33,13 @@ use std::sync::Arc; use std::time::Duration; +use crate::actor::ForgeActor; +use crate::messages::{GetTenantConfig, ReplyChannel}; use acton_service::audit::AuditSeverity; use acton_service::auth::tokens::paseto_generator::PasetoGenerator; use acton_service::middleware::paseto::PasetoAuth; +use acton_service::middleware::Claims; +use acton_service::prelude::ActorHandleInterface; use acton_service::state::AppState; use axum::extract::State; use axum::http::StatusCode; @@ -43,8 +47,10 @@ use axum::response::IntoResponse; use axum::{Extension, Json}; use chrono::Utc; use schema_forge_backend::user_store::ForgeUser; +use schema_forge_backend::{tenant::TenantConfig, TenantRef}; use schema_forge_backend::{InviteStore, NewInvitation}; use serde::{Deserialize, Serialize}; +use tokio::sync::oneshot; use tracing::instrument; use crate::access::{check_schema_access, AccessAction, OptionalClaims}; @@ -166,7 +172,10 @@ fn validate_email(email: &str) -> Result<(), ForgeError> { /// site-relative path when no public base URL is configured. fn build_accept_url(base: Option<&str>, invite_id: &str) -> String { match base { - Some(b) => format!("{}/invite/accept?invite={invite_id}", b.trim_end_matches('/')), + Some(b) => format!( + "{}/invite/accept?invite={invite_id}", + b.trim_end_matches('/') + ), None => format!("/invite/accept?invite={invite_id}"), } } @@ -187,6 +196,51 @@ fn invite_email_subject(project_name: &str) -> String { format!("You've been invited to {project_name}") } +/// Validate the signed invitation's tenant target before minting or persistence. +fn validate_invite_tenant( + claims: &Claims, + config: Option<&TenantConfig>, + tenant_type: Option<&str>, + tenant_id: Option<&str>, +) -> Result<(), ForgeError> { + let (tenant_type, tenant_id) = match (tenant_type, tenant_id) { + (None, None) => return Ok(()), + (Some(kind), Some(id)) if !kind.is_empty() && !id.is_empty() => (kind, id), + _ => { + return Err(ForgeError::ValidationFailed { + details: vec![ + "tenant_type and tenant_id must be supplied together and must not be empty" + .into(), + ], + }) + } + }; + if !config.is_some_and(|config| { + config + .hierarchy + .iter() + .any(|level| level.schema.as_str() == tenant_type) + }) { + return Err(ForgeError::ValidationFailed { + details: vec!["tenant_type must name a configured tenant schema".into()], + }); + } + let chain = claims + .custom_claim_as::>("tenant_chain") + .unwrap_or_default(); + if claims.has_role("platform_admin") + || chain + .iter() + .any(|tenant| tenant.schema == tenant_type && tenant.entity_id == tenant_id) + { + Ok(()) + } else { + Err(ForgeError::Forbidden { + message: "invitation tenant is outside the caller's effective tenant scope".into(), + }) + } +} + // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -213,9 +267,34 @@ pub async fn create_invite( let user_schema = fetch_user_schema(&state).await?; let policy_store = fetch_policy_store(&state).await?; - check_schema_access(&policy_store, &user_schema, Some(claims), AccessAction::Create)?; + check_schema_access( + &policy_store, + &user_schema, + Some(claims), + AccessAction::Create, + )?; validate_email(&body.email)?; + let forge = state + .actor::() + .ok_or_else(|| ForgeError::Internal { + message: "ForgeActor not registered".into(), + })?; + let (tx, rx) = oneshot::channel(); + forge + .send(GetTenantConfig { + reply: ReplyChannel::new(tx), + }) + .await; + let tenant_config = rx.await.map_err(|_| ForgeError::Internal { + message: "ForgeActor reply channel dropped while fetching tenant configuration".into(), + })?; + validate_invite_tenant( + claims, + tenant_config.as_ref(), + body.tenant_type.as_deref(), + body.tenant_id.as_deref(), + )?; // The invite grants a single role (or none). Apply the same role-grant // guards `create_user` applies to its `roles` list. @@ -403,10 +482,11 @@ pub async fn accept_invite( // Reconstruct + verify the full token from the stored column. Signed // claims are authoritative; the DB columns are a convenience mirror. - let verified = - verify_invite_token(validator.as_ref(), &invite.token).map_err(|e| ForgeError::Internal { + let verified = verify_invite_token(validator.as_ref(), &invite.token).map_err(|e| { + ForgeError::Internal { message: format!("stored invite token failed verification: {e}"), - })?; + } + })?; if verified.invite_id != invite.jti { return Err(ForgeError::Internal { message: "invite token does not match the invitation it was stored under".to_string(), @@ -438,8 +518,10 @@ pub async fn accept_invite( .create_user(&verified.email, &body.password, &roles, &display_name) .await?; - if let (Some(tt), Some(tid)) = (verified.tenant_type.as_deref(), verified.tenant_id.as_deref()) - { + if let (Some(tt), Some(tid)) = ( + verified.tenant_type.as_deref(), + verified.tenant_id.as_deref(), + ) { auth_store .add_tenant_membership(&verified.email, tt, tid, verified.role.as_deref()) .await?; @@ -479,6 +561,51 @@ pub async fn accept_invite( mod tests { use super::*; + #[test] + fn invitation_target_requires_configured_type_and_effective_membership() { + use schema_forge_backend::tenant::TenantLevel; + use schema_forge_core::types::{EntityId, SchemaName}; + let mut claims: Claims = serde_json::from_value(serde_json::json!({ + "sub": "user_inviter", "roles": ["owner"], "perms": [], "exp": 9999999999_u64, + "tenant_chain": [{"schema": "Org", "entity_id": "org_a"}] + })) + .unwrap(); + // Claims custom fields are explicitly populated to match middleware output. + claims.custom.insert( + "tenant_chain".into(), + serde_json::json!([{"schema": "Org", "entity_id": "org_a"}]), + ); + let config = TenantConfig { + root_schema: Some(SchemaName::new("Org").unwrap()), + hierarchy: vec![TenantLevel { + schema: SchemaName::new("Org").unwrap(), + parent: None, + parent_field: None, + }], + }; + assert!(validate_invite_tenant(&claims, Some(&config), Some("Org"), Some("org_a")).is_ok()); + assert!(matches!( + validate_invite_tenant( + &claims, + Some(&config), + Some("Org"), + Some(EntityId::new("org").as_str()) + ), + Err(ForgeError::Forbidden { .. }) + )); + assert!(matches!( + validate_invite_tenant(&claims, Some(&config), Some("Other"), Some("org_a")), + Err(ForgeError::ValidationFailed { .. }) + )); + assert!(validate_invite_tenant(&claims, Some(&config), Some("Org"), None).is_err()); + assert!(validate_invite_tenant(&claims, Some(&config), None, None).is_ok()); + claims.roles = vec!["platform_admin".into()]; + assert!(validate_invite_tenant(&claims, Some(&config), Some("Org"), Some("org_b")).is_ok()); + assert!( + validate_invite_tenant(&claims, Some(&config), Some("Unknown"), Some("org_b")).is_err() + ); + } + #[test] fn validate_email_accepts_plausible_addresses() { assert!(validate_email("invitee@example.gov").is_ok()); diff --git a/crates/schema-forge-acton/src/routes/meta.rs b/crates/schema-forge-acton/src/routes/meta.rs index a1bf4cf1..9ebfe118 100644 --- a/crates/schema-forge-acton/src/routes/meta.rs +++ b/crates/schema-forge-acton/src/routes/meta.rs @@ -26,7 +26,7 @@ pub struct MetaInfo { /// Lowercase, stable: API clients should match on this token, not the /// human label below. pub backend: &'static str, - /// Backend label suitable for human display (`"SurrealDB 2.x"`). + /// Backend label suitable for human display (`"SurrealDB 3.3+"`). pub backend_label: String, /// Auth posture (always PASETO V4 today). pub auth: MetaAuth, @@ -148,9 +148,9 @@ mod tests { #[test] fn meta_info_carries_compile_time_version() { - let info = MetaInfo::new("surrealdb", "SurrealDB 2.x", 3600); + let info = MetaInfo::new("surrealdb", "SurrealDB 3.3+", 3600); assert_eq!(info.backend, "surrealdb"); - assert_eq!(info.backend_label, "SurrealDB 2.x"); + assert_eq!(info.backend_label, "SurrealDB 3.3+"); assert_eq!(info.auth.kind, "paseto"); assert_eq!(info.auth.ttl_seconds, 3600); // The build version is whatever Cargo stamped on this crate. diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index 1cd3ae92..935a5481 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -9,8 +9,8 @@ use axum::response::IntoResponse; use axum::Json; use schema_forge_core::migration::DiffEngine; use schema_forge_core::types::{ - Annotation, BytesConstraints, FieldDefinition, FieldModifier, FieldName, FieldType, - SchemaDefinition, SchemaId, SchemaName, TextConstraints, + Annotation, BytesConstraints, FieldAnnotation, FieldDefinition, FieldModifier, FieldName, + FieldType, SchemaDefinition, SchemaId, SchemaName, TextConstraints, }; use serde::{Deserialize, Serialize}; use tokio::sync::oneshot; @@ -23,10 +23,7 @@ use crate::access::{ use crate::actor::ForgeActor; use crate::config::SchemaForgeConfig; use crate::error::ForgeError; -use crate::messages::{ - ApplyMigration, GetSchema, InsertSchema, ListSchemas, RemoveSchema, ReplyChannel, - StoreSchemaMetadata, -}; +use crate::messages::{ApplyPreparedSchemaChange, GetSchema, ListSchemas, ReplyChannel}; // --------------------------------------------------------------------------- // Actor request helper @@ -48,7 +45,8 @@ const ACTOR_TIMEOUT: Duration = Duration::from_secs(5); async fn pair_with_registry( forge: &acton_service::prelude::ActorHandle, target: &mut SchemaDefinition, -) -> Result<(), ForgeError> { +) -> Result, ForgeError> { + validate_schema_definition(target)?; let (tx, rx) = oneshot::channel(); forge .send(ListSchemas { @@ -56,12 +54,32 @@ async fn pair_with_registry( }) .await; let mut batch = ask_forge(rx).await?; + let registry = batch + .iter() + .map(|schema| (schema.name.to_string(), schema.clone())) + .collect(); // Replace any existing entry with the same name so we pair against // the incoming definition — not the stale one. batch.retain(|s| s.name.as_str() != target.name.as_str()); batch.push(target.clone()); + for field in &target.fields { + if let FieldType::Relation { + target: related, .. + } = &field.field_type + { + if !batch.iter().any(|schema| &schema.name == related) { + return Err(ForgeError::ValidationFailed { + details: vec![format!( + "relation '{}.{}' references missing schema '{related}'", + target.name, field.name + )], + }); + } + } + } + schema_forge_core::inverse_relations::pair_inverse_relations(&mut batch).map_err(|e| { ForgeError::ValidationFailed { details: vec![e.to_string()], @@ -72,7 +90,7 @@ async fn pair_with_registry( if let Some(paired) = batch.pop() { *target = paired; } - Ok(()) + Ok(registry) } /// Dry-run the Cedar policy bundle that would result from inserting (or @@ -80,14 +98,16 @@ async fn pair_with_registry( /// /// Surfacing the validation error here turns it into a 400-class response — /// the caller's request is rejected before any DB migration runs. The actor -/// will recompile and atomically swap on the subsequent `InsertSchema` / -/// `RemoveSchema` regardless; this is purely a fail-closed pre-check. +/// commits the prepared snapshot under its mutation barrier, guarded by the +/// exact registry and policy identities this preflight observed. async fn precheck_policy_bundle( state: &AppState, forge: &acton_service::prelude::ActorHandle, target: &SchemaDefinition, removing: bool, -) -> Result<(), ForgeError> { + expected_target: Option<&SchemaDefinition>, + paired_registry: Option<&std::collections::HashMap>, +) -> Result { let (tx, rx) = oneshot::channel(); forge .send(ListSchemas { @@ -95,37 +115,148 @@ async fn precheck_policy_bundle( }) .await; let mut proposed = ask_forge(rx).await?; + let expected_registry: std::collections::HashMap<_, _> = proposed + .iter() + .map(|schema| (schema.name.to_string(), schema.clone())) + .collect(); + if expected_registry.get(target.name.as_str()) != expected_target + || paired_registry.is_some_and(|registry| registry != &expected_registry) + { + return Err(ForgeError::Conflict { + reason: "schema_preflight_stale", + message: "schema changed while preparing the update; retry the schema change".into(), + }); + } + let current_tenant_structure = tenant_structure(&proposed)?; proposed.retain(|s| s.name.as_str() != target.name.as_str()); if !removing { proposed.push(target.clone()); } + if tenant_structure(&proposed)? != current_tenant_structure { + return Err(ForgeError::ValidationFailed { details: vec!["tenant hierarchy changes require applying schema files and restarting serve so actor and middleware configuration change together".into()] }); + } + let policy_store = fetch_policy_store(state).await?; let snapshot = policy_store.current(); let role_ranks = snapshot.role_ranks.clone(); let principal_claims = snapshot.principal_claims.clone(); - crate::authz::store::PolicyStoreSnapshot::from_schemas( + let (tx, rx) = oneshot::channel(); + forge + .send(crate::messages::GetCustomPoliciesDir { + reply: ReplyChannel::new(tx), + }) + .await; + let custom_dir = ask_forge(rx).await?; + + let next_policy = crate::authz::store::PolicyStoreSnapshot::from_schemas( &proposed, - None, + custom_dir.as_deref(), role_ranks, principal_claims, ) .map_err(|e| ForgeError::ValidationFailed { - details: vec![format!("Cedar policy validation failed for proposed schema: {e}")], + details: vec![format!( + "Cedar policy validation failed for proposed schema: {e}" + )], })?; + Ok(PreparedSchemaPolicies { + expected_registry, + expected_policy: snapshot, + next_policy: std::sync::Arc::new(next_policy), + }) +} + +/// Reuse canonical DSL validation for annotations supplied through the JSON schema API. +fn validate_schema_definition(definition: &SchemaDefinition) -> Result<(), ForgeError> { + let parsed = + schema_forge_dsl::parse(&schema_forge_dsl::print(definition)).map_err(|errors| { + ForgeError::ValidationFailed { + details: errors.iter().map(ToString::to_string).collect(), + } + })?; + if !matches!(parsed.as_slice(), [validated] if validated.fields == definition.fields && validated.annotations == definition.annotations) + { + return Err(ForgeError::ValidationFailed { details: vec!["schema annotations and fields must roundtrip through the canonical DSL without semantic changes".into()] }); + } Ok(()) } +/// One schema's place in the effective runtime tenant hierarchy. +#[derive(PartialEq, Eq)] +struct TenantParent { + schema: Option, + field: Option, +} + +/// Tenant topology normalized independently of registry iteration order. +#[derive(PartialEq, Eq)] +struct TenantStructure(std::collections::BTreeMap); + +fn tenant_structure(schemas: &[SchemaDefinition]) -> Result { + let config = + schema_forge_backend::tenant::TenantConfig::from_schemas(schemas).map_err(|error| { + ForgeError::ValidationFailed { + details: vec![format!("invalid proposed tenant hierarchy: {error}")], + } + })?; + Ok(TenantStructure( + config + .hierarchy + .into_iter() + .map(|level| { + ( + level.schema, + TenantParent { + schema: level.parent, + field: level.parent_field, + }, + ) + }) + .collect(), + )) +} + +struct PreparedSchemaPolicies { + expected_registry: std::collections::HashMap, + expected_policy: std::sync::Arc, + next_policy: std::sync::Arc, +} + +async fn apply_prepared_schema_change( + forge: &acton_service::prelude::ActorHandle, + prepared: PreparedSchemaPolicies, + definition: SchemaDefinition, + remove: bool, + steps: Vec, +) -> Result<(), ForgeError> { + let (tx, rx) = oneshot::channel(); + forge + .send(ApplyPreparedSchemaChange { + expected_registry: prepared.expected_registry, + expected_policy: prepared.expected_policy, + next_policy: prepared.next_policy, + definition, + remove, + steps, + reply: ReplyChannel::new(tx), + }) + .await; + ask_forge(rx).await? +} + /// Fetch the current Cedar [`PolicyStore`] from the actor. async fn fetch_policy_store( state: &AppState, ) -> Result, ForgeError> { - let forge = state.actor::().ok_or_else(|| ForgeError::Internal { - message: "ForgeActor not registered".into(), - })?; + let forge = state + .actor::() + .ok_or_else(|| ForgeError::Internal { + message: "ForgeActor not registered".into(), + })?; let (tx, rx) = oneshot::channel(); forge .send(crate::messages::GetPolicyStore { @@ -174,18 +305,24 @@ fn require_admin(claims: &Claims) -> Result<(), ForgeError> { /// Request body for creating a schema. #[derive(Debug, Deserialize)] pub struct CreateSchemaRequest { + /// Explicit acknowledgement that an update may drop stored data. + #[serde(default)] + pub allow_destructive_migrations: bool, /// The schema name (must be PascalCase). pub name: String, /// The field definitions. pub fields: Vec, /// Optional annotations. #[serde(default)] - pub annotations: Vec, + pub annotations: Option>, } /// A field in a create/update schema request. #[derive(Debug, Deserialize)] pub struct FieldDefinitionRequest { + /// Optional field annotations; omitted annotations are preserved on existing fields. + #[serde(default)] + pub annotations: Option>, /// The field name. pub name: String, /// The field type specification as a JSON value. @@ -270,11 +407,12 @@ fn request_field_to_definition( } } - if modifiers.is_empty() { - Ok(FieldDefinition::new(name, field_type)) - } else { - Ok(FieldDefinition::with_modifiers(name, field_type, modifiers)) - } + Ok(FieldDefinition::with_annotations( + name, + field_type, + modifiers, + req.annotations.clone().unwrap_or_default(), + )) } /// Parse a JSON value into a `FieldType`. @@ -356,11 +494,11 @@ fn parse_map_field_type( obj: &serde_json::Map, ) -> Result { let data = obj.get("data").and_then(|d| d.as_object()); - let value_json = data - .and_then(|d| d.get("value")) - .ok_or_else(|| ForgeError::ValidationFailed { - details: vec!["Map field type requires a 'value' type in 'data'".to_string()], - })?; + let value_json = + data.and_then(|d| d.get("value")) + .ok_or_else(|| ForgeError::ValidationFailed { + details: vec!["Map field type requires a 'value' type in 'data'".to_string()], + })?; let value = parse_field_type(value_json)?; if let Some(key_json) = data.and_then(|d| d.get("key")) { @@ -524,64 +662,38 @@ pub async fn create_schema( schema_id, schema_name.clone(), fields, - Vec::::new(), + body.annotations.clone().unwrap_or_default(), ) .map_err(|e| ForgeError::ValidationFailed { details: vec![e.to_string()], })?; + if definition.is_tenanted() { + return Err(ForgeError::ValidationFailed { details: vec!["creating a tenanted schema at runtime requires applying the schema files and restarting serve so actor and middleware tenant configuration change together".into()] }); + } + // 4a. Run the inverse-relation pairing pass across the full registry so // any `-> X[]` field paired with an FK from an existing schema is marked // as derived before the migration plan is generated. - pair_with_registry(&forge, &mut definition).await?; + let paired_registry = pair_with_registry(&forge, &mut definition).await?; // 4b. Pre-validate the proposed Cedar bundle BEFORE running any DB - // migration. The actor will recompile and atomically swap on InsertSchema - // anyway, but doing the dry-run here means a malformed schema is rejected - // with a 400 instead of leaving the database in a state the running - // policy bundle can't reason about. - precheck_policy_bundle(&state, &forge, &definition, false).await?; + // migration. The actor commits this immutable bundle with the storage + // change; no policy files are read after DDL. + let prepared = precheck_policy_bundle( + &state, + &forge, + &definition, + false, + None, + Some(&paired_registry), + ) + .await?; // 5. Generate migration plan let plan = DiffEngine::create_new(&definition); - // 6. Apply migration to backend via actor - let (tx, rx) = oneshot::channel(); - forge - .send(ApplyMigration { - schema_name: schema_name.clone(), - steps: plan.steps, - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx).await?.map_err(ForgeError::from)?; - - // 7. Store schema metadata in backend via actor - let (tx, rx) = oneshot::channel(); - forge - .send(StoreSchemaMetadata { - definition: definition.clone(), - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx).await?.map_err(ForgeError::from)?; - - // 8. Update registry cache + recompile Cedar bundle. The actor swap is - // the source of truth: if the recompile fails here despite the dry-run - // above, the actor reverts the registry mutation and returns the error. - let (tx, rx) = oneshot::channel(); - forge - .send(InsertSchema { - name: schema_name.as_str().to_string(), - definition: definition.clone(), - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx) - .await? - .map_err(|err| ForgeError::Internal { - message: format!("Cedar policy recompile failed during schema insertion: {err}"), - })?; + apply_prepared_schema_change(&forge, prepared, definition.clone(), false, plan.steps).await?; // 9. Rebuild GraphQL schema // NOTE: GraphQL rebuild will be re-integrated when the graphql module @@ -750,18 +862,28 @@ pub async fn update_schema( }); } - let fields: Vec = body + let mut fields: Vec = body .fields .iter() .map(request_field_to_definition) .collect::, _>>()?; + for (field, request) in fields.iter_mut().zip(&body.fields) { + if request.annotations.is_none() { + if let Some(existing) = old_schema.field(field.name.as_str()) { + field.annotations = existing.annotations.clone(); + } + } + } + // 4. Build new SchemaDefinition (preserving the original ID) let mut new_definition = SchemaDefinition::new( old_schema.id.clone(), schema_name.clone(), fields, - Vec::::new(), + body.annotations + .clone() + .unwrap_or_else(|| old_schema.annotations.clone()), ) .map_err(|e| ForgeError::ValidationFailed { details: vec![e.to_string()], @@ -770,53 +892,33 @@ pub async fn update_schema( // 4a. Run the inverse-relation pairing pass before diffing, so newly // added `-> X[]` fields are classified as derived (and therefore // produce no AddRelation step for a physical column). - pair_with_registry(&forge, &mut new_definition).await?; + let paired_registry = pair_with_registry(&forge, &mut new_definition).await?; // 4b. Dry-run the Cedar bundle for the proposed registry state so an // invalid schema fails fast — before any DB migration. - precheck_policy_bundle(&state, &forge, &new_definition, false).await?; + let prepared = precheck_policy_bundle( + &state, + &forge, + &new_definition, + false, + Some(&old_schema), + Some(&paired_registry), + ) + .await?; // 5. Compute diff and generate migration plan - let plan = DiffEngine::diff(&old_schema, &new_definition); - - // 6. Apply migration steps via actor - let step_count = plan.steps.len(); - if !plan.is_empty() { - let (tx, rx) = oneshot::channel(); - forge - .send(ApplyMigration { - schema_name: schema_name.clone(), - steps: plan.steps, - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx).await?.map_err(ForgeError::from)?; + let plan = DiffEngine::plan_update(&old_schema, &new_definition).map_err(|error| { + ForgeError::ValidationFailed { + details: vec![error.to_string()], + } + })?; + if plan.has_destructive_steps() && !body.allow_destructive_migrations { + return Err(ForgeError::ValidationFailed { details: vec![format!("destructive schema update refused: {}; set allow_destructive_migrations=true to acknowledge data loss", plan.steps.iter().map(ToString::to_string).collect::>().join("; "))] }); } - // 7. Store updated metadata via actor - let (tx, rx) = oneshot::channel(); - forge - .send(StoreSchemaMetadata { - definition: new_definition.clone(), - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx).await?.map_err(ForgeError::from)?; - - // 8. Update registry cache + recompile Cedar bundle. - let (tx, rx) = oneshot::channel(); - forge - .send(InsertSchema { - name: schema_name.as_str().to_string(), - definition: new_definition.clone(), - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx) - .await? - .map_err(|err| ForgeError::Internal { - message: format!("Cedar policy recompile failed during schema update: {err}"), - })?; + let step_count = plan.steps.len(); + apply_prepared_schema_change(&forge, prepared, new_definition.clone(), false, plan.steps) + .await?; // 9. Rebuild GraphQL schema // NOTE: GraphQL rebuild will be re-integrated when the graphql module @@ -840,7 +942,8 @@ pub async fn update_schema( Ok(Json(schema_to_response(&new_definition))) } -/// DELETE /schemas/{name} -- Remove a schema. Requires platform_admin role. +/// DELETE /schemas/{name} -- Unregister a schema from the running process. +/// Requires platform_admin. Stored metadata and entity data remain unchanged. #[instrument(skip_all)] pub async fn delete_schema( State(state): State>, @@ -876,25 +979,14 @@ pub async fn delete_schema( reply: ReplyChannel::new(tx), }) .await; - let _schema = ask_forge(rx) + let schema = ask_forge(rx) .await? .ok_or(ForgeError::SchemaNotFound { name: name.clone() })?; - // 2. Remove from registry cache + recompile Cedar bundle. The actor - // reverts the registry mutation if the recompile fails so the running - // bundle and registry never drift. - let (tx, rx) = oneshot::channel(); - forge - .send(RemoveSchema { - name: name.clone(), - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx) - .await? - .map_err(|err| ForgeError::Internal { - message: format!("Cedar policy recompile failed during schema deletion: {err}"), - })?; + let prepared = + precheck_policy_bundle(&state, &forge, &schema, true, Some(&schema), None).await?; + + apply_prepared_schema_change(&forge, prepared, schema, true, vec![]).await?; // 3. Rebuild GraphQL schema // NOTE: GraphQL rebuild will be re-integrated when the graphql module @@ -1016,6 +1108,7 @@ mod tests { #[test] fn request_field_to_definition_simple() { let req = FieldDefinitionRequest { + annotations: None, name: "email".into(), field_type: serde_json::json!("Text"), modifiers: vec![], @@ -1028,6 +1121,7 @@ mod tests { #[test] fn request_field_to_definition_with_modifiers() { let req = FieldDefinitionRequest { + annotations: None, name: "email".into(), field_type: serde_json::json!("Text"), modifiers: vec!["required".into(), "indexed".into()], @@ -1040,6 +1134,7 @@ mod tests { #[test] fn request_field_to_definition_unknown_modifier() { let req = FieldDefinitionRequest { + annotations: None, name: "email".into(), field_type: serde_json::json!("Text"), modifiers: vec!["unknown".into()], diff --git a/crates/schema-forge-acton/src/rules.rs b/crates/schema-forge-acton/src/rules.rs index b45057d1..71eaf3c4 100644 --- a/crates/schema-forge-acton/src/rules.rs +++ b/crates/schema-forge-acton/src/rules.rs @@ -53,7 +53,7 @@ //! pass when it evaluates to exactly `Ok(CelValue::Bool(true))`. Any other //! outcome surfaces as either a rejection (the predicate definitively returned //! `false`) or a [`RuleError::Eval`] (the predicate could not yield a definite -//! boolean — treated as a schema-authoring/server fault, mapped to 500). +//! boolean — treated as a schema-authoring/server fault, mapped to 422). //! //! ## The `now` binding (request-time clock) //! @@ -74,7 +74,9 @@ use chrono::{DateTime, Utc}; use acton_service::middleware::Claims; use schema_forge_cel::{cel_to_dynamic, dynamic_to_cel, CelKey, CelValue}; -use schema_forge_core::types::{DynamicValue, FieldAnnotation, FieldType, SchemaDefinition}; +use schema_forge_core::types::{ + DefaultValue, DynamicValue, FieldAnnotation, FieldModifier, FieldType, SchemaDefinition, +}; /// The outcome of a failed rule evaluation. #[derive(Debug, Clone, PartialEq)] @@ -85,7 +87,7 @@ pub enum RuleError { Rejected(Vec), /// A `@require` predicate could not be evaluated to a definite boolean — /// it errored or returned a non-boolean. This is a schema-authoring or - /// server fault, so it fails closed and maps to HTTP 500. + /// server fault, so it fails closed and maps to HTTP 422. Eval { /// The field whose `@require` annotation could not be evaluated. field: String, @@ -120,6 +122,9 @@ impl std::error::Error for RuleError {} /// "undeclared reference" eval error, which [`check_requires`] handles /// fail-closed. /// +/// Absent non-derived schema fields and the tenant field on tenanted schemas +/// are bound as null, allowing explicit null guards in rules. +/// /// A `principal` map is always bound (even when `claims` is `None`, in which /// case it is an empty map) so that `has(principal.sub)` is a clean `false` /// rather than an undeclared-reference error. @@ -128,15 +133,26 @@ impl std::error::Error for RuleError {} /// `now` (see the module docs); the caller passes a single instant so all rules /// in one write see the same clock. pub fn build_bindings( + schema: &SchemaDefinition, fields: &BTreeMap, claims: Option<&Claims>, now: DateTime, ) -> schema_forge_cel::Bindings { let mut bindings = schema_forge_cel::Bindings::new(); + for field in &schema.fields { + if !field.is_derived() { + bindings.insert(field.name.as_str().to_string(), CelValue::Null); + } + } + if schema.is_tenanted() { + bindings.insert("_tenant".into(), CelValue::Null); + } for (name, value) in fields { if let Ok(cel) = dynamic_to_cel(value) { bindings.insert(name.clone(), cel); + } else { + bindings.remove(name); } // On conversion failure we intentionally omit the binding; a predicate // referencing it will error and be handled fail-closed downstream. @@ -192,7 +208,7 @@ fn principal_map(claims: Option<&Claims>) -> CelValue { /// Fail-closed (see the module docs): a predicate passes only on /// `Ok(CelValue::Bool(true))`. A definite `false` is collected as a rejection /// (→ [`RuleError::Rejected`], 422). A non-boolean result or an evaluation -/// error short-circuits immediately to [`RuleError::Eval`] (500) so a broken +/// error short-circuits immediately to [`RuleError::Eval`] (422) so a broken /// predicate can never let a write through. pub fn check_requires( schema: &SchemaDefinition, @@ -200,7 +216,7 @@ pub fn check_requires( claims: Option<&Claims>, now: DateTime, ) -> Result<(), RuleError> { - let bindings = build_bindings(fields, claims, now); + let bindings = build_bindings(schema, fields, claims, now); check_requires_with_bindings(schema, &bindings) } @@ -274,7 +290,7 @@ pub fn check_requires_with_bindings( /// chainable). /// /// Fail-closed: an evaluation error or a value that cannot be converted / -/// coerced to the field's declared type returns [`RuleError::Eval`] (500) and +/// coerced to the field's declared type returns [`RuleError::Eval`] (422) and /// stores nothing for that field — a half-evaluated value is never persisted. /// This runs *before* [`check_requires`] so `@require` predicates validate the /// computed values. @@ -292,15 +308,14 @@ pub fn apply_computed( // Rebuild bindings from the current fields so this compute sees the // results of any earlier computed fields (chaining). - let bindings = build_bindings(fields, claims, now); + let bindings = build_bindings(schema, fields, claims, now); let field_name = field.name.as_str(); - let cel_value = schema_forge_cel::evaluate(expr, &bindings).map_err(|e| { - RuleError::Eval { + let cel_value = + schema_forge_cel::evaluate(expr, &bindings).map_err(|e| RuleError::Eval { field: field_name.to_string(), detail: e.to_string(), - } - })?; + })?; let natural = cel_to_dynamic(&cel_value).map_err(|e| RuleError::Eval { field: field_name.to_string(), @@ -323,13 +338,9 @@ pub fn apply_computed( /// This is wired into entity **creation only** — never PUT/PATCH. A default /// seeds an initial value; it must not silently re-materialize on later writes. /// -/// ## Distinct from the static default -/// -/// `@default("")` (this annotation, [`FieldAnnotation::Default`]) is an -/// *expression-valued* default evaluated by the CEL engine at write time. It is -/// entirely separate from the literal [`FieldModifier::Default`](schema_forge_core::types::FieldModifier::Default) -/// (e.g. `default(5)`), which is applied as a storage-layer SQL `DEFAULT`. This -/// function does not touch the static-default path, whose behavior is unchanged. +/// Literal `default(...)` modifiers are materialized for absent fields before +/// their CEL `@default` annotations. This makes storage defaults visible to +/// required validation, computations, and hooks before persistence. /// /// ## Absent-vs-null /// @@ -341,13 +352,11 @@ pub fn apply_computed( /// /// 1. client-supplied non-null value /// 2. value stamped by `@owner` / tenant / audit injection (runs before hooks) -/// 3. value set by a before-hook +/// 3. literal `default(...)` for an absent field /// 4. expression `@default` /// -/// Because `apply_defaults` runs *after* owner/tenant/audit injection and after -/// the before-hooks, and only fills absent/null fields, any of those earlier -/// stages "wins" over `@default` for the same field — in particular `@owner` -/// always beats `@default`. +/// Owner/tenant/audit injection precedes defaults. Before-hooks run after all +/// rule phases and can replace their output before final validation. /// /// ## Order relative to the other rules /// @@ -363,7 +372,7 @@ pub fn apply_computed( /// example is spelled `@default("now")`. /// /// Fail-closed: an evaluation error or a value that cannot be converted / -/// coerced to the field's declared type returns [`RuleError::Eval`] (500) and +/// coerced to the field's declared type returns [`RuleError::Eval`] (422) and /// stores nothing for that field. pub fn apply_defaults( schema: &SchemaDefinition, @@ -372,6 +381,26 @@ pub fn apply_defaults( now: DateTime, ) -> Result<(), RuleError> { for field in &schema.fields { + if !fields.contains_key(field.name.as_str()) { + for modifier in &field.modifiers { + if let FieldModifier::Default { value } = modifier { + let natural = match value { + DefaultValue::String(value) => DynamicValue::Text(value.clone()), + DefaultValue::Integer(value) => DynamicValue::Integer(*value), + DefaultValue::Boolean(value) => DynamicValue::Boolean(*value), + DefaultValue::Float(value) => { + DynamicValue::Float(value.parse().map_err(|_| RuleError::Eval { + field: field.name.as_str().into(), + detail: "invalid numeric default".into(), + })?) + } + }; + let value = + coerce_to_field_type(natural, &field.field_type, field.name.as_str())?; + fields.insert(field.name.as_str().into(), value); + } + } + } for annotation in &field.annotations { let FieldAnnotation::Default { expr } = annotation else { continue; @@ -392,14 +421,13 @@ pub fn apply_defaults( // Rebuild bindings from the current fields so this default can read // other fields, including an earlier-defaulted one (chaining). - let bindings = build_bindings(fields, claims, now); + let bindings = build_bindings(schema, fields, claims, now); - let cel_value = schema_forge_cel::evaluate(expr, &bindings).map_err(|e| { - RuleError::Eval { + let cel_value = + schema_forge_cel::evaluate(expr, &bindings).map_err(|e| RuleError::Eval { field: field_name.to_string(), detail: e.to_string(), - } - })?; + })?; let natural = cel_to_dynamic(&cel_value).map_err(|e| RuleError::Eval { field: field_name.to_string(), @@ -447,14 +475,12 @@ fn coerce_to_field_type( }) } } - (FieldType::DateTime, DynamicValue::Text(s)) => { - chrono::DateTime::parse_from_rfc3339(&s) - .map(|dt| DynamicValue::DateTime(dt.with_timezone(&chrono::Utc))) - .map_err(|e| RuleError::Eval { - field: field.to_string(), - detail: format!("computed value '{s}' is not a valid RFC 3339 datetime: {e}"), - }) - } + (FieldType::DateTime, DynamicValue::Text(s)) => chrono::DateTime::parse_from_rfc3339(&s) + .map(|dt| DynamicValue::DateTime(dt.with_timezone(&chrono::Utc))) + .map_err(|e| RuleError::Eval { + field: field.to_string(), + detail: format!("computed value '{s}' is not a valid RFC 3339 datetime: {e}"), + }), // No coercion applies; store the natural value as-is. (_, value) => Ok(value), } @@ -478,8 +504,13 @@ mod tests { } fn schema_with(fields: Vec) -> SchemaDefinition { - SchemaDefinition::new(SchemaId::new(), SchemaName::new("Thing").unwrap(), fields, vec![]) - .unwrap() + SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("Thing").unwrap(), + fields, + vec![], + ) + .unwrap() } fn require(expr: &str, message: &str) -> FieldAnnotation { @@ -518,6 +549,24 @@ mod tests { Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap() } + #[test] + fn absent_declared_fields_and_tenant_are_bound_as_null() { + let schema = schema_forge_dsl::parse(r#" + @tenant(root) + schema Tenant { + optional: text @require("optional == null && _tenant == null", "expected null context") + copy: text @default("optional") + derived: boolean @compute("optional == null") + } + "#).unwrap().remove(0); + let mut values = BTreeMap::new(); + apply_defaults(&schema, &mut values, None, fixed_now()).unwrap(); + apply_computed(&schema, &mut values, None, fixed_now()).unwrap(); + assert_eq!(check_requires(&schema, &values, None, fixed_now()), Ok(())); + assert_eq!(values.get("copy"), Some(&DynamicValue::Null)); + assert_eq!(values.get("derived"), Some(&DynamicValue::Boolean(true))); + } + #[test] fn passing_require_ok() { let schema = schema_with(vec![text_field( @@ -545,10 +594,7 @@ mod tests { fn multiple_failing_requires_collected_in_order() { let schema = schema_with(vec![ text_field("age", vec![require("age >= 18", "too young")]), - text_field( - "name", - vec![require("size(name) > 0", "name required")], - ), + text_field("name", vec![require("size(name) > 0", "name required")]), ]); let f = fields(&[ ("age", DynamicValue::Integer(10)), @@ -598,7 +644,10 @@ mod tests { ("status", DynamicValue::Text("closed".to_string())), ("close_reason", DynamicValue::Text("done".to_string())), ]); - assert_eq!(check_requires(&schema, &closed_with_reason, None, fixed_now()), Ok(())); + assert_eq!( + check_requires(&schema, &closed_with_reason, None, fixed_now()), + Ok(()) + ); } #[test] @@ -686,10 +735,7 @@ mod tests { fn skipped_binding_fails_closed() { // A field that converts fine but an annotation references a field that // was never supplied → undeclared reference → Eval (fail-closed). - let schema = schema_with(vec![text_field( - "a", - vec![require("b == 1", "needs b")], - )]); + let schema = schema_with(vec![text_field("a", vec![require("b == 1", "needs b")])]); let f = fields(&[("a", DynamicValue::Integer(1))]); assert!(matches!( check_requires(&schema, &f, None, fixed_now()), @@ -748,16 +794,12 @@ mod tests { let schema = schema_with(vec![ typed_field( "quantity", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![], ), typed_field( "unit_price", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![], ), typed_field( @@ -818,23 +860,17 @@ mod tests { let schema = schema_with(vec![ typed_field( "base", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![], ), typed_field( "a", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![compute("base + 1")], ), typed_field( "b", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![compute("a * 10")], ), ]); @@ -864,10 +900,7 @@ mod tests { #[test] fn eval_error_compute_is_eval() { - let schema = schema_with(vec![text_field( - "x", - vec![compute("missing_field + 1")], - )]); + let schema = schema_with(vec![text_field("x", vec![compute("missing_field + 1")])]); let mut f = fields(&[]); match apply_computed(&schema, &mut f, None, fixed_now()) { Err(RuleError::Eval { field, detail }) => { @@ -882,15 +915,10 @@ mod tests { #[test] fn enum_coercion_success() { - let variants = - EnumVariants::new(vec!["low".to_string(), "high".to_string()]).unwrap(); + let variants = EnumVariants::new(vec!["low".to_string(), "high".to_string()]).unwrap(); let schema = schema_with(vec![ text_field("level", vec![]), - typed_field( - "tier", - FieldType::Enum(variants), - vec![compute("level")], - ), + typed_field("tier", FieldType::Enum(variants), vec![compute("level")]), ]); let mut f = fields(&[("level", DynamicValue::Text("high".to_string()))]); assert_eq!(apply_computed(&schema, &mut f, None, fixed_now()), Ok(())); @@ -899,15 +927,10 @@ mod tests { #[test] fn enum_coercion_invalid_variant_is_eval() { - let variants = - EnumVariants::new(vec!["low".to_string(), "high".to_string()]).unwrap(); + let variants = EnumVariants::new(vec!["low".to_string(), "high".to_string()]).unwrap(); let schema = schema_with(vec![ text_field("level", vec![]), - typed_field( - "tier", - FieldType::Enum(variants), - vec![compute("level")], - ), + typed_field("tier", FieldType::Enum(variants), vec![compute("level")]), ]); let mut f = fields(&[("level", DynamicValue::Text("medium".to_string()))]); match apply_computed(&schema, &mut f, None, fixed_now()) { @@ -984,10 +1007,7 @@ mod tests { "created_by", vec![default_expr("principal.sub")], )]); - let mut f = fields(&[( - "created_by", - DynamicValue::Text("explicit".to_string()), - )]); + let mut f = fields(&[("created_by", DynamicValue::Text("explicit".to_string()))]); assert_eq!( apply_defaults(&schema, &mut f, Some(&claims(&[])), fixed_now()), Ok(()) @@ -1035,16 +1055,12 @@ mod tests { let schema = schema_with(vec![ typed_field( "a", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![default_expr("10")], ), typed_field( "b", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![default_expr("a + 5")], ), ]); diff --git a/crates/schema-forge-acton/src/state.rs b/crates/schema-forge-acton/src/state.rs index 96cd3947..0bc1f2e9 100644 --- a/crates/schema-forge-acton/src/state.rs +++ b/crates/schema-forge-acton/src/state.rs @@ -27,6 +27,28 @@ use tokio::sync::RwLock; /// RPITIT traits cannot be used as `dyn Trait`. This wrapper uses boxed futures /// to enable dynamic dispatch for HTTP handler state. pub trait DynSchemaBackend: Send + Sync { + /// Atomically persist a complete schema change or refuse without writes. + fn apply_schema_change<'a>( + &'a self, + _name: &'a SchemaName, + _steps: &'a [MigrationStep], + _definition: Option<&'a SchemaDefinition>, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async { + Err(BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: "backend does not support atomic schema changes".into(), + }) + }) + } + + /// Finish cross-schema constraints after explicit schema administration. + fn finalize_schema_migrations( + &self, + ) -> Pin> + Send + Sync + '_>> { + Box::pin(async { Ok(()) }) + } + /// Whether this adapter can explicitly prepare record revisions. fn supports_record_revisions(&self) -> bool { false @@ -73,6 +95,25 @@ pub trait DynSchemaBackend: Send + Sync { /// Blanket impl: any concrete `SchemaBackend` automatically implements `DynSchemaBackend`. impl DynSchemaBackend for T { + fn apply_schema_change<'a>( + &'a self, + name: &'a SchemaName, + steps: &'a [MigrationStep], + definition: Option<&'a SchemaDefinition>, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(SyncFuture::new(SchemaBackend::apply_schema_change( + self, name, steps, definition, + ))) + } + + fn finalize_schema_migrations( + &self, + ) -> Pin> + Send + Sync + '_>> { + Box::pin(SyncFuture::new(SchemaBackend::finalize_schema_migrations( + self, + ))) + } + fn supports_record_revisions(&self) -> bool { SchemaBackend::supports_record_revisions(self) } diff --git a/crates/schema-forge-acton/tests/auth_demo.rs b/crates/schema-forge-acton/tests/auth_demo.rs index 90be70a8..a57d2877 100644 --- a/crates/schema-forge-acton/tests/auth_demo.rs +++ b/crates/schema-forge-acton/tests/auth_demo.rs @@ -584,7 +584,6 @@ async fn demo_multi_tenancy_isolation() { let surreal = SurrealBackend::connect_memory("test", "demo_tenant") .await .unwrap(); - let db_client = surreal.client().clone(); let backend: Arc = Arc::new(surreal); let mut registry = HashMap::new(); @@ -610,7 +609,7 @@ async fn demo_multi_tenancy_isolation() { .unwrap(); register_schema(&org_schema, &backend, &mut registry).await; - // Create Project schema (regular, will be tenant-scoped) + // Declare Project as a tenant child; unannotated schemas are shared. // @access with empty lists = all authenticated users permitted (testing tenancy, not schema-level) let project_schema = SchemaDefinition::new( SchemaId::new(), @@ -619,22 +618,21 @@ async fn demo_multi_tenancy_isolation() { FieldName::new("title").unwrap(), FieldType::Text(TextConstraints::unconstrained()), )], - vec![Annotation::Access { - read: vec![], - write: vec![], - delete: vec![], - cross_tenant_read: vec![], - }], + vec![ + Annotation::Tenant(TenantKind::Child { + parent: SchemaName::new("Organization").unwrap(), + }), + Annotation::Access { + read: vec![], + write: vec![], + delete: vec![], + cross_tenant_read: vec![], + }, + ], ) .unwrap(); register_schema(&project_schema, &backend, &mut registry).await; - // Define _tenant field on tenant-scoped tables (SCHEMAFULL requires explicit field definition) - db_client - .query("DEFINE FIELD _tenant ON Project TYPE option;") - .await - .expect("define _tenant field"); - // Build tenant config from schemas let all_schemas: Vec = registry.values().cloned().collect(); let tenant_config = TenantConfig::from_schemas(&all_schemas).expect("valid tenant config"); @@ -668,7 +666,7 @@ async fn demo_multi_tenancy_isolation() { Some(serde_json::json!({"fields": {"title": "Tenant A Project"}})), ) .await; - assert_eq!(status, StatusCode::CREATED); + assert_eq!(status, StatusCode::CREATED, "{json}"); println!( " Created: {} (title={})", json["id"], json["fields"]["title"] @@ -701,7 +699,7 @@ async fn demo_multi_tenancy_isolation() { Some(serde_json::json!({"fields": {"title": "Tenant B Project"}})), ) .await; - assert_eq!(status, StatusCode::CREATED); + assert_eq!(status, StatusCode::CREATED, "{json}"); println!( " Created: {} (title={})", json["id"], json["fields"]["title"] diff --git a/crates/schema-forge-acton/tests/conditional_entities.rs b/crates/schema-forge-acton/tests/conditional_entities.rs index f0d06940..c577b684 100644 --- a/crates/schema-forge-acton/tests/conditional_entities.rs +++ b/crates/schema-forge-acton/tests/conditional_entities.rs @@ -124,6 +124,29 @@ async fn app_with_backend( schema: SchemaDefinition, roles: &[&str], ) -> Router { + app_with_policies(backend, schema, roles, None).await +} + +async fn app_with_policies( + backend: Arc, + schema: SchemaDefinition, + roles: &[&str], + custom_policies_dir: Option, +) -> Router { + let policy_store = custom_policies_dir.as_ref().map(|directory| { + use schema_forge_acton::authz::{ + PolicyStore, PolicyStoreSnapshot, PrincipalClaimMappings, RoleRanks, + }; + Arc::new(PolicyStore::new( + PolicyStoreSnapshot::from_schemas( + std::slice::from_ref(&schema), + Some(directory), + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(), + )) + }); let service = ServiceBuilder::new() .with_config(Config::::default()) .with_actor::() @@ -140,8 +163,8 @@ async fn app_with_backend( record_access_policy: None, hook_dispatcher: None, storage_registry: StorageRegistry::default(), - policy_store: None, - custom_policies_dir: None, + policy_store, + custom_policies_dir, reply: ReplyChannel::new(tx), }) .await; @@ -431,17 +454,13 @@ async fn conditional_delete_preserves_owner_denial_without_hiding_the_record() { .await; assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); assert!(!headers.contains_key("entity-revision")); - assert!(!body.to_string().contains("conditional_mutation_unsupported")); + assert!(!body + .to_string() + .contains("conditional_mutation_unsupported")); // `@owner` governs the write, never the read: the editor role the schema grants read to // still sees a record it does not own. - let (status, headers, body) = request( - &app, - &path, - "GET", - Some("malformed"), - serde_json::json!({}), - ) - .await; + let (status, headers, body) = + request(&app, &path, "GET", Some("malformed"), serde_json::json!({})).await; assert_eq!(status, StatusCode::OK, "{body}"); assert_eq!(body["fields"]["title"], "Original"); assert!(!headers.contains_key("entity-revision")); @@ -1106,3 +1125,340 @@ async fn postgres_http_exact_counts_preserve_projection_and_malformed_row_fallba ); } } + +async fn write_pipeline_fixture() -> Router { + write_pipeline_fixture_with_policy(&["editor"], None).await +} + +async fn write_pipeline_fixture_with_policy( + roles: &[&str], + custom_policies_dir: Option, +) -> Router { + use schema_forge_backend::SchemaBackend; + let backend = Arc::new( + SurrealBackend::connect_memory("writes", "writes") + .await + .unwrap(), + ); + let schema = schema_forge_dsl::parse(r#" + @access(read: ["editor", "manager"], write: ["editor", "manager"], delete: ["manager"]) + schema Line { + title: text required + stage: text required @default("'pending'") + literal: text required default("literal") + owner: text required @owner + guarded: text required @default("'locked'") @field_access(read: ["editor", "manager"], write: ["manager"]) + optional: text @require("optional == null || size(optional) > 2", "optional too short") + number: text @field_access(read: ["editor", "manager"], write: ["manager"]) + status: text @default("'pending'") @require("status != 'live' || number != null", "live needs number") + has_number: boolean required @compute("number != null") @field_access(read: ["editor", "manager"], write: ["manager"]) + } + "#).unwrap().remove(0); + let plan = schema_forge_core::migration::DiffEngine::create_new(&schema); + backend + .apply_migration(&schema.name, &plan.steps) + .await + .unwrap(); + backend.store_schema_metadata(&schema).await.unwrap(); + app_with_policies(backend, schema, roles, custom_policies_dir).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn write_rules_observe_only_authorized_input_and_keep_server_values() { + let app = write_pipeline_fixture().await; + let base = "/schemas/Line/entities"; + let (status, _, body) = request( + &app, + base, + "POST", + None, + serde_json::json!({"title":"line", "number":"forbidden", "status":"live"}), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + let (status, _, body) = request( + &app, + base, + "POST", + None, + serde_json::json!({"title":"line", "number":"forbidden"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert_eq!(body["fields"]["stage"], "pending"); + assert_eq!(body["fields"]["literal"], "literal"); + assert_eq!(body["fields"]["owner"], "editor"); + assert_eq!(body["fields"]["has_number"], false); + assert!(body["fields"]["number"].is_null()); + let path = format!("{base}/{}", body["id"].as_str().unwrap()); + let (status, _, body) = request( + &app, + &path, + "PATCH", + None, + serde_json::json!({"number":"forbidden", "status":"live"}), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + let (status, _, body) = request( + &app, + &path, + "PATCH", + None, + serde_json::json!({"number":"forbidden"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body["fields"]["number"].is_null()); + assert_eq!(body["fields"]["has_number"], false); + let (status, _, body) = request(&app, &path, "PUT", None, + serde_json::json!({"title":"updated", "stage":"pending", "literal":"literal", "status":"live", "number":"forbidden"})).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn required_fields_reject_null_and_put_does_not_apply_create_defaults() { + let app = write_pipeline_fixture().await; + let base = "/schemas/Line/entities"; + let (status, _, body) = + request(&app, base, "POST", None, serde_json::json!({"title":null})).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert!(!body.to_string().contains("PUT")); + let (status, _, body) = request( + &app, + base, + "POST", + None, + serde_json::json!({"title":"line"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + let path = format!("{base}/{}", body["id"].as_str().unwrap()); + let (status, _, body) = request( + &app, + &path, + "PATCH", + None, + serde_json::json!({"guarded":null}), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "denied null must not be validated as accepted input: {body}" + ); + assert_eq!(body["fields"]["guarded"], "locked"); + for method in ["PATCH", "PUT"] { + let (status, _, body) = request( + &app, + &path, + method, + None, + serde_json::json!({"title":null, "stage":"pending", "literal":"literal"}), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{method}: {body}"); + } + let (status, _, body) = request( + &app, + &path, + "PUT", + None, + serde_json::json!({"title":"updated"}), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert!(body.to_string().contains("stage")); + let (status, _, body) = request( + &app, + &path, + "PUT", + None, + serde_json::json!({"title":"updated", "stage":"pending", "literal":"literal"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["fields"]["owner"], "editor"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_field_authorization_accepts_defaults_but_fails_closed_on_missing_policy_attributes() +{ + let base = "/schemas/Line/entities"; + let app = write_pipeline_fixture_with_policy(&["manager"], None).await; + let (status, _, body) = request( + &app, + base, + "POST", + None, + serde_json::json!({"title":"line", "number":"allowed", "status":"live"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert_eq!(body["fields"]["number"], "allowed"); + assert_eq!(body["fields"]["has_number"], true); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("custom.cedar"), + r#" + forbid(principal, action == Action::"WriteFieldLine_number", resource is Line) + when { resource.stage == "blocked" }; + "#, + ) + .unwrap(); + let app = + write_pipeline_fixture_with_policy(&["manager"], Some(dir.path().to_path_buf())).await; + for stage in [None, Some("blocked")] { + let mut fields = serde_json::json!({"title":"line", "number":"allowed"}); + if let Some(stage) = stage { + fields["stage"] = stage.into(); + } + let (status, _, body) = request(&app, base, "POST", None, fields).await; + if stage.is_none() { + // An errored forbid must not be bypassed by the generated role permit. + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + } else { + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert!(body["fields"]["number"].is_null()); + assert_eq!(body["fields"]["has_number"], false); + } + } + let (status, _, body) = request( + &app, + base, + "POST", + None, + serde_json::json!({"title":"line", "stage":"open", "number":"allowed"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert_eq!(body["fields"]["has_number"], true); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn put_omission_matches_persisted_values_and_preserves_denied_fields() { + use schema_forge_backend::SchemaBackend; + for role in ["editor", "manager"] { + let backend = Arc::new(SurrealBackend::connect_memory("put", "put").await.unwrap()); + let schema = schema_forge_dsl::parse( + r#" + @access(read: ["editor", "manager"], write: ["editor", "manager"], delete: ["manager"]) + schema Contact { + title: text required + number: text @field_access(read: ["editor", "manager"], write: ["manager"]) + has_number: boolean @compute("number != null") + } + "#, + ) + .unwrap() + .remove(0); + let plan = schema_forge_core::migration::DiffEngine::create_new(&schema); + backend + .apply_migration(&schema.name, &plan.steps) + .await + .unwrap(); + backend.store_schema_metadata(&schema).await.unwrap(); + let seed = Entity::new( + schema.name.clone(), + BTreeMap::from([ + ("title".into(), DynamicValue::Text("original".into())), + ("number".into(), DynamicValue::Text("stored".into())), + ("has_number".into(), DynamicValue::Boolean(true)), + ]), + ); + DynEntityStore::create(backend.as_ref(), &seed) + .await + .unwrap(); + let app = app_with_backend(backend, schema, &[role]).await; + let path = format!("/schemas/Contact/entities/{}", seed.id); + let (status, _, body) = request( + &app, + &path, + "PUT", + None, + serde_json::json!({"title":"updated"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{role}: {body}"); + assert_eq!(body["fields"]["has_number"], role == "editor", "{body}"); + if role == "editor" { + assert_eq!(body["fields"]["number"], "stored"); + } else { + assert!(body["fields"]["number"].is_null()); + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn denied_inputs_cannot_authorize_other_fields() { + use schema_forge_backend::SchemaBackend; + let backend = Arc::new( + SurrealBackend::connect_memory("field_auth", "field_auth") + .await + .unwrap(), + ); + let schema = schema_forge_dsl::parse( + r#" + @access(read: ["editor"], write: ["editor"], delete: ["editor"]) + schema Pair { + title: text required + value: text @field_access(read: ["editor"], write: ["editor"]) + gate: text @field_access(read: ["editor"], write: ["manager"]) + } + "#, + ) + .unwrap() + .remove(0); + let plan = schema_forge_core::migration::DiffEngine::create_new(&schema); + backend + .apply_migration(&schema.name, &plan.steps) + .await + .unwrap(); + backend.store_schema_metadata(&schema).await.unwrap(); + let seed = Entity::new( + schema.name.clone(), + BTreeMap::from([ + ("title".into(), DynamicValue::Text("original".into())), + ("value".into(), DynamicValue::Text("original".into())), + ("gate".into(), DynamicValue::Text("locked".into())), + ]), + ); + DynEntityStore::create(backend.as_ref(), &seed) + .await + .unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("custom.cedar"), + r#" + forbid(principal, action == Action::"WriteFieldPair_value", resource is Pair) + when { !(resource has gate && resource.gate == "unlocked") }; + "#, + ) + .unwrap(); + let app = app_with_policies(backend, schema, &["editor"], Some(dir.path().to_path_buf())).await; + let path = format!("/schemas/Pair/entities/{}", seed.id); + for method in ["PATCH", "PUT"] { + let (status, _, body) = request( + &app, + &path, + method, + None, + serde_json::json!({"title":"updated", "value":"attacker", "gate":"unlocked"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["fields"]["value"], "original"); + assert_eq!(body["fields"]["gate"], "locked"); + } + let (status, _, body) = request( + &app, + "/schemas/Pair/entities", + "POST", + None, + serde_json::json!({"title":"new", "value":"attacker", "gate":"unlocked"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert!(body["fields"]["value"].is_null()); + assert!(body["fields"]["gate"].is_null()); +} diff --git a/crates/schema-forge-acton/tests/cross_entity_reads.rs b/crates/schema-forge-acton/tests/cross_entity_reads.rs index 8fdd09b7..a99ad775 100644 --- a/crates/schema-forge-acton/tests/cross_entity_reads.rs +++ b/crates/schema-forge-acton/tests/cross_entity_reads.rs @@ -447,13 +447,15 @@ async fn cross_tenant_related_row_is_not_readable() { .unwrap(), ); let mut registry = HashMap::new(); - apply_and_register(&backend, &mut registry, approval_schema(vec![])).await; - apply_and_register( - &backend, - &mut registry, - document_schema(REQUIRE_GRANTED, Cardinality::One), - ) - .await; + let tenant_child = Annotation::Tenant(TenantKind::Child { + parent: SchemaName::new("Organization").unwrap(), + }); + let mut approval = approval_schema(vec![]); + approval.annotations.push(tenant_child.clone()); + apply_and_register(&backend, &mut registry, approval).await; + let mut document = document_schema(REQUIRE_GRANTED, Cardinality::One); + document.annotations.push(tenant_child); + apply_and_register(&backend, &mut registry, document).await; // Tenancy enabled with an Organization root. let org = SchemaDefinition::new( @@ -463,7 +465,9 @@ async fn cross_tenant_related_row_is_not_readable() { vec![Annotation::Tenant(TenantKind::Root)], ) .unwrap(); - let tenant_config = TenantConfig::from_schemas(&[org]).unwrap(); + apply_and_register(&backend, &mut registry, org).await; + let schemas: Vec<_> = registry.values().cloned().collect(); + let tenant_config = TenantConfig::from_schemas(&schemas).unwrap(); let state = build_state(backend, registry, Some(tenant_config)).await; @@ -479,6 +483,18 @@ async fn cross_tenant_related_row_is_not_readable() { assert_eq!(status, StatusCode::CREATED, "{approval}"); let approval_id = approval["id"].as_str().unwrap().to_string(); + // The same rule succeeds when the caller owns the related tenant row. + let (status, body) = json_request( + &app_a, + Method::POST, + "/schemas/Document/entities", + Some(serde_json::json!({ + "fields": { "title": "own", "status": "closed", "approval": approval_id } + })), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "same-tenant rule read: {body}"); + // Tenant B references tenant A's approval id in a closed document. Because // the related read is tenant-scoped to org-b, the row is invisible → the // related.approval binding is absent → fail-closed (422), proving a rule diff --git a/crates/schema-forge-acton/tests/graphql_tenants.rs b/crates/schema-forge-acton/tests/graphql_tenants.rs new file mode 100644 index 00000000..43e985ca --- /dev/null +++ b/crates/schema-forge-acton/tests/graphql_tenants.rs @@ -0,0 +1,388 @@ +#![cfg(feature = "graphql")] + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use acton_service::{ + config::Config, middleware::Claims, prelude::ActorHandleInterface, + service_builder::ServiceBuilder, +}; +use axum::{ + body::Body, + http::{Method, Request}, + Router, +}; +use http_body_util::BodyExt; +use schema_forge_acton::{ + config::SchemaForgeConfig, + messages::{InitForge, ReplyChannel}, + ForgeActor, HookDispatchActor, SchemaForgeExtension, +}; +use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; +use schema_forge_core::migration::DiffEngine; +use schema_forge_core::types::{DynamicValue, SchemaName}; +use schema_forge_surrealdb::SurrealBackend; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use tokio::sync::oneshot; +use tower::ServiceExt; + +async fn app() -> (Router, String, String) { + app_with_relations(false).await +} + +async fn app_with_relations(relations: bool) -> (Router, String, String) { + let backend = SurrealBackend::connect_with_auth("mem://", "graphql", "tenants", None, None) + .await + .unwrap(); + let mut schemas = schema_forge_dsl::parse( + r#" + @tenant(root) + @access(read: ["member"], write: ["member"], delete: ["member"]) + schema Org { name: text required } + @access(read: ["member"], write: ["member"]) + schema Catalog { name: text required secret: text hidden_value: text } + "#, + ) + .unwrap(); + if relations { + schemas.extend( + schema_forge_dsl::parse( + r#" + @access(read: ["member"], write: ["member"]) + schema Link { one: -> Catalog many: -> Catalog[] } + "#, + ) + .unwrap(), + ); + } + for schema in &schemas { + backend + .apply_migration(&schema.name, &DiffEngine::create_new(schema).steps) + .await + .unwrap(); + backend.store_schema_metadata(schema).await.unwrap(); + } + // Direct storage seeds reproduce pre-fix tenant roots with NULL metadata. + let mut roots = Vec::new(); + for name in ["a", "b"] { + let entity = Entity::new( + SchemaName::new("Org").unwrap(), + BTreeMap::from([("name".into(), DynamicValue::Text(name.into()))]), + ); + roots.push(entity.id.to_string()); + EntityStore::create(&backend, &entity).await.unwrap(); + } + let extension = SchemaForgeExtension::builder() + .with_backend(backend) + .with_record_access_policy(OperatorVisibility) + .build() + .await + .unwrap(); + let forge_state = extension.state(); + let service = ServiceBuilder::new() + .with_config(Config::::default()) + .with_actor::() + .with_actor::() + .build(); + let (tx, rx) = oneshot::channel(); + service + .state() + .actor::() + .unwrap() + .send(InitForge { + registry: forge_state + .registry + .list() + .await + .into_iter() + .map(|s| (s.name.as_str().to_owned(), s)) + .collect(), + backend: forge_state.backend.clone(), + tenant_config: forge_state.tenant_config.clone(), + record_access_policy: forge_state.record_access_policy.clone(), + hook_dispatcher: None, + storage_registry: forge_state.storage_registry.clone(), + policy_store: Some(Arc::clone(&forge_state.policy_store)), + custom_policies_dir: None, + reply: ReplyChannel::new(tx), + }) + .await; + tokio::time::timeout(Duration::from_secs(10), rx) + .await + .unwrap() + .unwrap(); + let app = extension + .register_graphql_routes(schema_forge_acton::routes::forge_routes()) + .with_state(service.state().clone()); + (app, roots.remove(0), roots.remove(0)) +} + +fn test_claims(tenant: &str, roles: &[&str]) -> Claims { + Claims { + sub: "user:graphql-test".into(), + roles: roles.iter().map(|role| (*role).into()).collect(), + perms: vec![], + exp: 9_999_999_999, + iat: None, + jti: None, + iss: None, + aud: None, + email: None, + username: None, + custom: HashMap::from([( + "tenant_chain".into(), + json!([{"schema":"Org","entity_id":tenant}]), + )]), + } +} + +async fn query(app: &Router, query: &str, tenant: &str) -> Value { + let claims = test_claims(tenant, &["member"]); + let mut request = Request::post("/forge/graphql") + .header("content-type", "application/json") + .body(Body::from(json!({"query": query}).to_string())) + .unwrap(); + request.extensions_mut().insert(claims); + let response = app.clone().oneshot(request).await.unwrap(); + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graphql_scopes_legacy_roots_and_keeps_shared_catalog_usable() { + let (app, own, foreign) = app().await; + let get = query( + &app, + &format!("{{ org(id: \"{own}\") {{ id name }} }}"), + &own, + ) + .await; + assert_eq!(get["data"]["org"]["id"], own, "{get}"); + let denied = query( + &app, + &format!("{{ org(id: \"{foreign}\") {{ id name }} }}"), + &own, + ) + .await; + assert!(denied.get("errors").is_some(), "{denied}"); + let list = query(&app, "{ orgs { items { id } totalCount } }", &own).await; + assert_eq!( + list["data"]["orgs"]["items"].as_array().unwrap().len(), + 1, + "{list}" + ); + assert!(list["data"]["orgs"]["totalCount"].is_null()); + let denied = query( + &app, + &format!("mutation {{ deleteOrg(id: \"{foreign}\") }}"), + &own, + ) + .await; + assert!(denied.get("errors").is_some(), "{denied}"); + let created = query( + &app, + "mutation { createCatalog(input: {name: \"shared\"}) { id name } }", + &own, + ) + .await; + assert!(created.get("errors").is_none(), "{created}"); + let catalog = query(&app, "{ catalogs { items { name } } }", &foreign).await; + assert_eq!( + catalog["data"]["catalogs"]["items"][0]["name"], "shared", + "{catalog}" + ); + // These routes intentionally have no tenant middleware. A permissive + // operator policy must never replace concrete Cedar tenant authorization. + for method in [Method::PUT, Method::PATCH, Method::DELETE] { + let mut request = Request::builder() + .method(method) + .uri(format!("/schemas/Org/entities/{foreign}")) + .header("content-type", "application/json") + .body(Body::from( + json!({"fields":{"name":"foreign overwrite"}}).to_string(), + )) + .unwrap(); + request + .extensions_mut() + .insert(test_claims(&own, &["member"])); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN); + } + let deleted = query( + &app, + &format!("mutation {{ deleteOrg(id: \"{own}\") }}"), + &own, + ) + .await; + assert_eq!(deleted["data"]["deleteOrg"], true, "{deleted}"); + let remaining = query( + &app, + &format!("{{ org(id: \"{foreign}\") {{ id name }} }}"), + &foreign, + ) + .await; + assert_eq!(remaining["data"]["org"]["id"], foreign, "{remaining}"); + assert_eq!(remaining["data"]["org"]["name"], "b", "{remaining}"); +} + +async fn administer_catalog(app: &Router, method: Method, body: Value) { + let mut request = Request::builder() + .method(method) + .uri("/schemas/Catalog") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + request + .extensions_mut() + .insert(test_claims("", &["platform_admin"])); + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + assert!( + status.is_success(), + "{status}: {}", + String::from_utf8_lossy(&bytes) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graphql_uses_live_field_security_and_refuses_removed_schemas() { + let (app, tenant, _) = app().await; + let created = query(&app, + "mutation { createCatalog(input: {name: \"visible\", secret: \"restricted\", hidden_value: \"private\"}) { id } }", + &tenant).await; + let id = created["data"]["createCatalog"]["id"].as_str().unwrap(); + let get = format!("{{ catalog(id: \"{id}\") {{ name secret hidden_value }} }}"); + let before = query(&app, &get, &tenant).await; + assert_eq!( + before["data"]["catalog"]["secret"], "restricted", + "{before}" + ); + + let fields = json!([ + {"name":"name", "field_type":"Text", "modifiers":["required"]}, + {"name":"secret", "field_type":"Text", "annotations":[{"annotation":"FieldAccess", "read":["admin"], "write":["admin"]}]}, + {"name":"hidden_value", "field_type":"Text", "annotations":[{"annotation":"Hidden"}]} + ]); + administer_catalog( + &app, + Method::PUT, + json!({"name":"Catalog", "fields":fields}), + ) + .await; + let after = query(&app, &get, &tenant).await; + assert_eq!(after["data"]["catalog"]["name"], "visible", "{after}"); + assert!(after["data"]["catalog"]["secret"].is_null(), "{after}"); + assert!( + after["data"]["catalog"]["hidden_value"].is_null(), + "{after}" + ); + let list = query( + &app, + "{ catalogs { items { name secret hidden_value } } }", + &tenant, + ) + .await; + assert_eq!( + list["data"]["catalogs"]["items"][0]["name"], "visible", + "{list}" + ); + assert!( + list["data"]["catalogs"]["items"][0]["secret"].is_null(), + "{list}" + ); + assert!( + list["data"]["catalogs"]["items"][0]["hidden_value"].is_null(), + "{list}" + ); + + administer_catalog(&app, Method::PUT, json!({"name":"Catalog", "fields":fields, + "annotations":[{"annotation":"Access", "read":["admin"], "write":["admin"], "delete":["admin"], "cross_tenant_read":[]}] + })).await; + let revoked = query(&app, &get, &tenant).await; + assert!(revoked.get("errors").is_some(), "{revoked}"); + administer_catalog(&app, Method::DELETE, Value::Null).await; + let removed = query(&app, &get, &tenant).await; + assert!(removed.get("errors").is_some(), "{removed}"); +} + +struct OperatorVisibility; +impl schema_forge_backend::auth::RecordAccessPolicy for OperatorVisibility { + fn filter_visible<'a>( + &'a self, + _schema: &'a schema_forge_core::types::SchemaDefinition, + _claims: &'a Claims, + entities: Vec, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + entities + .into_iter() + .filter(|entity| { + entity.fields.get("name") != Some(&DynamicValue::Text("operator-denied".into())) + }) + .collect() + }) + } + fn can_modify<'a>( + &'a self, + _schema: &'a schema_forge_core::types::SchemaDefinition, + _claims: &'a Claims, + _entity: &'a Entity, + ) -> std::pin::Pin + Send + 'a>> { + Box::pin(async { true }) + } + fn can_delete<'a>( + &'a self, + _schema: &'a schema_forge_core::types::SchemaDefinition, + _claims: &'a Claims, + _entity: &'a Entity, + ) -> std::pin::Pin + Send + 'a>> { + Box::pin(async { true }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graphql_relation_reads_honor_operator_visibility_veto() { + let (app, tenant, _) = app_with_relations(true).await; + let denied = query( + &app, + "mutation { createCatalog(input: {name: \"operator-denied\"}) { id } }", + &tenant, + ) + .await; + let allowed = query( + &app, + "mutation { createCatalog(input: {name: \"operator-visible\"}) { id } }", + &tenant, + ) + .await; + let denied_id = denied["data"]["createCatalog"]["id"].as_str().unwrap(); + let allowed_id = allowed["data"]["createCatalog"]["id"].as_str().unwrap(); + let direct = query( + &app, + &format!("{{ catalog(id: \"{denied_id}\") {{ id name }} }}"), + &tenant, + ) + .await; + assert!(direct.get("errors").is_some(), "{direct}"); + let list = query(&app, "{ catalogs { items { id } } }", &tenant).await; + assert_eq!( + list["data"]["catalogs"]["items"], + json!([{"id":allowed_id}]), + "{list}" + ); + let link = query(&app, &format!("mutation {{ createLink(input: {{one: \"{denied_id}\", many: [\"{denied_id}\", \"{allowed_id}\"]}}) {{ id }} }}"), &tenant).await; + let id = link["data"]["createLink"]["id"].as_str().unwrap(); + let nested = query( + &app, + &format!("{{ link(id: \"{id}\") {{ id one {{ id name }} many {{ id name }} }} }}"), + &tenant, + ) + .await; + assert_eq!(nested["data"]["link"]["id"], id, "{nested}"); + assert!(nested["data"]["link"]["one"].is_null(), "{nested}"); + assert_eq!( + nested["data"]["link"]["many"], + json!([{"id":allowed_id, "name":"operator-visible"}]), + "{nested}" + ); +} diff --git a/crates/schema-forge-acton/tests/graphql_writes.rs b/crates/schema-forge-acton/tests/graphql_writes.rs new file mode 100644 index 00000000..5da47910 --- /dev/null +++ b/crates/schema-forge-acton/tests/graphql_writes.rs @@ -0,0 +1,234 @@ +#![cfg(feature = "graphql")] + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use acton_service::{ + config::Config, middleware::Claims, prelude::ActorHandleInterface, + service_builder::ServiceBuilder, +}; +use axum::{body::Body, http::Request, Router}; +use http_body_util::BodyExt; +use schema_forge_acton::{ + config::SchemaForgeConfig, + messages::{InitForge, ReplyChannel}, + ForgeActor, HookDispatchActor, SchemaForgeExtension, +}; +use schema_forge_backend::SchemaBackend; +use schema_forge_core::migration::DiffEngine; +use schema_forge_surrealdb::SurrealBackend; +use serde_json::{json, Value}; +use tokio::sync::oneshot; +use tower::ServiceExt; + +async fn app() -> Router { + let backend = SurrealBackend::connect_with_auth("mem://", "graphql", "writes", None, None) + .await + .unwrap(); + let schemas = schema_forge_dsl::parse( + r#" + @access(read: ["staff", "manager"], write: ["staff", "manager"]) + schema Line { + label: text required + number: text @field_access(read: ["staff", "manager"], write: ["manager"]) + status: text required @default("'pending'") + @require("status != 'live' || number != null", "live needs number") + has_number: boolean required @compute("number != null") + enabled: boolean required default(true) + created_at: datetime required + updated_at: datetime required + created_by: text required + updated_by: text required + } + @tenant(root) + @access(read: ["staff"], write: ["staff"]) + schema Org { name: text required } + @tenant(parent: "Org") + @access(read: ["staff"], write: ["staff"]) + schema Ticket { title: text required } + "#, + ) + .unwrap(); + for schema in &schemas { + backend + .apply_migration(&schema.name, &DiffEngine::create_new(schema).steps) + .await + .unwrap(); + backend.store_schema_metadata(schema).await.unwrap(); + } + let extension = SchemaForgeExtension::builder() + .with_backend(backend) + .build() + .await + .unwrap(); + let forge_state = extension.state(); + let service = ServiceBuilder::new() + .with_config(Config::::default()) + .with_actor::() + .with_actor::() + .build(); + let (tx, rx) = oneshot::channel(); + service + .state() + .actor::() + .unwrap() + .send(InitForge { + registry: forge_state + .registry + .list() + .await + .into_iter() + .map(|s| (s.name.as_str().to_owned(), s)) + .collect(), + backend: forge_state.backend.clone(), + tenant_config: forge_state.tenant_config.clone(), + record_access_policy: forge_state.record_access_policy.clone(), + hook_dispatcher: None, + storage_registry: forge_state.storage_registry.clone(), + policy_store: Some(Arc::clone(&forge_state.policy_store)), + custom_policies_dir: None, + reply: ReplyChannel::new(tx), + }) + .await; + tokio::time::timeout(Duration::from_secs(10), rx) + .await + .unwrap() + .unwrap(); + extension + .register_graphql_routes(Router::new()) + .with_state(service.state().clone()) +} + +async fn query(app: &Router, query: &str, role: &str) -> Value { + query_with_tenant(app, query, role, None).await +} + +async fn query_with_tenant(app: &Router, query: &str, role: &str, tenant: Option<&str>) -> Value { + let claims = Claims { + sub: "user:graphql-test".into(), + roles: vec![role.into()], + perms: vec![], + exp: 9_999_999_999, + iat: None, + jti: None, + iss: None, + aud: None, + email: None, + username: None, + custom: tenant + .map(|tenant| { + HashMap::from([( + "tenant_chain".into(), + json!([{"schema":"Org", "entity_id":tenant}]), + )]) + }) + .unwrap_or_default(), + }; + let mut request = Request::post("/forge/graphql") + .header("content-type", "application/json") + .body(Body::from(json!({"query": query}).to_string())) + .unwrap(); + request.extensions_mut().insert(claims); + let response = app.clone().oneshot(request).await.unwrap(); + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graphql_writes_share_defaults_authorization_rules_and_noop_semantics() { + let app = app().await; + let created = query(&app, r#"mutation { createLine(input: {label: "test", number: "denied"}) { id status has_number enabled number created_at updated_at created_by updated_by } }"#, "staff").await; + assert!(created.get("errors").is_none(), "{created}"); + let row = &created["data"]["createLine"]; + assert_eq!(row["status"], "pending"); + assert_eq!(row["has_number"], false); + assert_eq!(row["enabled"], true); + assert_eq!(row["created_by"], "graphql-test"); + assert_eq!(row["updated_by"], "graphql-test"); + assert!(row["created_at"].as_str().is_some()); + assert!(row["updated_at"].as_str().is_some()); + assert!(row["number"].is_null()); + let id = row["id"].as_str().unwrap(); + + let rejected = query(&app, r#"mutation { createLine(input: {label: "invalid", status: "live", number: "denied"}) { id } }"#, "staff").await; + assert!(rejected.get("errors").is_some(), "{rejected}"); + let noop = query(&app, &format!(r#"mutation {{ updateLine(id: "{id}", input: {{number: "denied"}}) {{ id number has_number }} }}"#), "staff").await; + assert!(noop.get("errors").is_none(), "{noop}"); + assert_eq!(noop["data"]["updateLine"]["has_number"], false); + let rejected = query(&app, &format!(r#"mutation {{ updateLine(id: "{id}", input: {{status: "live", number: "denied"}}) {{ id }} }}"#), "staff").await; + assert!(rejected.get("errors").is_some(), "{rejected}"); + + let authorized = query(&app, &format!(r#"mutation {{ updateLine(id: "{id}", input: {{status: "live", number: "allowed"}}) {{ number has_number status }} }}"#), "manager").await; + assert!(authorized.get("errors").is_none(), "{authorized}"); + assert_eq!(authorized["data"]["updateLine"]["has_number"], true); + assert_eq!(authorized["data"]["updateLine"]["number"], "allowed"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graphql_tenant_inputs_use_canonical_admin_and_member_rules() { + let app = app().await; + let mut roots = Vec::new(); + for name in ["one", "two"] { + let created = query( + &app, + &format!(r#"mutation {{ createOrg(input: {{name: "{name}"}}) {{ id }} }}"#), + "platform_admin", + ) + .await; + assert!(created.get("errors").is_none(), "{created}"); + roots.push( + created["data"]["createOrg"]["id"] + .as_str() + .unwrap() + .to_string(), + ); + } + let missing = query( + &app, + r#"mutation { createTicket(input: {title:"missing tenant"}) { id } }"#, + "platform_admin", + ) + .await; + assert_eq!( + missing["errors"][0]["extensions"]["code"], "VALIDATION_ERROR", + "{missing}" + ); + let created = query( + &app, + &format!( + r#"mutation {{ createTicket(input: {{title: "ticket", _tenant: "{}"}}) {{ id }} }}"#, + roots[0] + ), + "platform_admin", + ) + .await; + assert!(created.get("errors").is_none(), "{created}"); + let id = created["data"]["createTicket"]["id"].as_str().unwrap(); + let spoof = query_with_tenant(&app, &format!(r#"mutation {{ updateTicket(id: "{id}", input: {{title:"changed", _tenant:"{}"}}) {{ id }} }}"#, roots[1]), "staff", Some(&roots[0])).await; + assert!(spoof.get("errors").is_none(), "{spoof}"); + let lookup = format!(r#"{{ ticket(id:"{id}") {{ title }} }}"#); + let own = query_with_tenant(&app, &lookup, "staff", Some(&roots[0])).await; + assert!(own.get("errors").is_none(), "{own}"); + let foreign = query_with_tenant(&app, &lookup, "staff", Some(&roots[1])).await; + assert!(foreign.get("errors").is_some(), "{foreign}"); + let moved = query( + &app, + &format!( + r#"mutation {{ updateTicket(id: "{id}", input: {{_tenant:"{}"}}) {{ id }} }}"#, + roots[1] + ), + "platform_admin", + ) + .await; + assert!(moved.get("errors").is_none(), "{moved}"); + let own = query_with_tenant(&app, &lookup, "staff", Some(&roots[1])).await; + assert!(own.get("errors").is_none(), "{own}"); + let missing_required = query( + &app, + "mutation { createLine(input: {}) { id } }", + "platform_admin", + ) + .await; + assert!( + missing_required.get("errors").is_some(), + "{missing_required}" + ); +} diff --git a/crates/schema-forge-acton/tests/integration.rs b/crates/schema-forge-acton/tests/integration.rs index 8cdd641d..95b35114 100644 --- a/crates/schema-forge-acton/tests/integration.rs +++ b/crates/schema-forge-acton/tests/integration.rs @@ -281,6 +281,102 @@ async fn update_schema_triggers_migration() { assert_eq!(json["fields"].as_array().unwrap().len(), 2); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn destructive_schema_updates_require_explicit_opt_in_and_preserve_data_on_refusal() { + let app = test_app().await; + let create = + serde_json::json!({"name": "Line", "fields": [{"name": "number", "field_type": "Text"}]}); + assert_eq!( + json_request(&app, Method::POST, "/schemas", Some(create)) + .await + .0, + StatusCode::CREATED + ); + let (status, row) = json_request( + &app, + Method::POST, + "/schemas/Line/entities", + Some(serde_json::json!({"fields": {"number": "555-9999"}})), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let entity_path = format!("/schemas/Line/entities/{}", row["id"].as_str().unwrap()); + let mut update = serde_json::json!({"name": "Line", "fields": [{"name": "business_number", "field_type": "Text"}]}); + for explicit_false in [false, true] { + if explicit_false { + update["allow_destructive_migrations"] = serde_json::json!(false); + } + let (status, error) = + json_request(&app, Method::PUT, "/schemas/Line", Some(update.clone())).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{error}"); + let (status, unchanged) = json_request(&app, Method::GET, &entity_path, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(unchanged["fields"]["number"], "555-9999"); + let (_, schema) = json_request(&app, Method::GET, "/schemas/Line", None).await; + assert_eq!(schema["fields"][0]["name"], "number"); + } + update["allow_destructive_migrations"] = serde_json::json!(true); + let (status, result) = json_request(&app, Method::PUT, "/schemas/Line", Some(update)).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["fields"][0]["name"], "business_number"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn runtime_annotations_are_validated_and_rename_hints_preserve_values() { + let app = test_app().await; + let root = serde_json::json!({"name": "Org", "annotations": [schema_forge_core::types::Annotation::Tenant(schema_forge_core::types::TenantKind::Root)], "fields": [{"name": "name", "field_type": "Text"}]}); + assert_eq!( + json_request(&app, Method::POST, "/schemas", Some(root)) + .await + .0, + StatusCode::UNPROCESSABLE_ENTITY + ); + assert_eq!( + json_request(&app, Method::GET, "/schemas/Org", None) + .await + .0, + StatusCode::NOT_FOUND + ); + let invalid_rule = serde_json::json!({"name": "InvalidRule", "fields": [{"name": "value", "field_type": "Text", "annotations": [{"annotation": "Require", "expr": "value", "message": "invalid return type"}]}]}); + assert_eq!( + json_request(&app, Method::POST, "/schemas", Some(invalid_rule)) + .await + .0, + StatusCode::UNPROCESSABLE_ENTITY + ); + assert_eq!( + json_request(&app, Method::GET, "/schemas/InvalidRule", None) + .await + .0, + StatusCode::NOT_FOUND + ); + let create = + serde_json::json!({"name": "Line", "fields": [{"name": "number", "field_type": "Text"}]}); + assert_eq!( + json_request(&app, Method::POST, "/schemas", Some(create)) + .await + .0, + StatusCode::CREATED + ); + let (_, row) = json_request( + &app, + Method::POST, + "/schemas/Line/entities", + Some(serde_json::json!({"fields": {"number": "555-9999"}})), + ) + .await; + let rename = serde_json::json!({"name": "Line", "fields": [{"name": "business_number", "field_type": "Text", "annotations": [{"annotation": "RenamedFrom", "name": "number"}]}]}); + for _ in 0..2 { + let (status, body) = + json_request(&app, Method::PUT, "/schemas/Line", Some(rename.clone())).await; + assert_eq!(status, StatusCode::OK, "{body}"); + } + let entity_path = format!("/schemas/Line/entities/{}", row["id"].as_str().unwrap()); + let (status, loaded) = json_request(&app, Method::GET, &entity_path, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(loaded["fields"]["business_number"], "555-9999"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn delete_schema_removes_from_registry() { let app = test_app().await; diff --git a/crates/schema-forge-acton/tests/policy_preflight.rs b/crates/schema-forge-acton/tests/policy_preflight.rs new file mode 100644 index 00000000..8a33b181 --- /dev/null +++ b/crates/schema-forge-acton/tests/policy_preflight.rs @@ -0,0 +1,322 @@ +//! Schema mutations must honor the actor's custom policy contract before DDL. + +use std::{ + collections::{BTreeMap, HashMap}, + sync::Arc, + time::Duration, +}; + +use acton_service::{ + config::Config, middleware::Claims, prelude::ActorHandleInterface, + service_builder::ServiceBuilder, +}; +use axum::{ + body::Body, + http::{Method, Request, StatusCode}, + Router, +}; +use http_body_util::BodyExt; +use schema_forge_acton::{ + authz::{PolicyStore, PolicyStoreSnapshot, PrincipalClaimMappings, RoleRanks}, + config::SchemaForgeConfig, + messages::{ApplyPreparedSchemaChange, GetSchema, InitForge, ReplyChannel}, + routes::forge_routes, + ForgeActor, +}; +use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; +use schema_forge_core::{ + migration::DiffEngine, + types::{DynamicValue, FieldDefinition, FieldModifier, FieldName, FieldType, TextConstraints}, +}; +use schema_forge_surrealdb::SurrealBackend; +use serde_json::{json, Value}; +use tokio::sync::oneshot; +use tower::ServiceExt; + +async fn request(app: &Router, method: Method, path: &str, body: Value) -> (StatusCode, Value) { + let claims = Claims { + sub: "user:policy-admin".into(), + roles: vec!["platform_admin".into()], + perms: vec![], + exp: 9_999_999_999, + iat: None, + jti: None, + iss: None, + aud: None, + email: None, + username: None, + custom: HashMap::new(), + }; + let mut request = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + request.extensions_mut().insert(claims); + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn custom_policy_field_contract_refuses_mutations_before_storage_changes() { + let schema = schema_forge_dsl::parse("schema Thing { code: text required label: text }") + .unwrap() + .remove(0); + let backend = Arc::new( + SurrealBackend::connect_memory("policy", "preflight") + .await + .unwrap(), + ); + backend + .apply_migration(&schema.name, &DiffEngine::create_new(&schema).steps) + .await + .unwrap(); + backend.store_schema_metadata(&schema).await.unwrap(); + let entity = Entity::new( + schema.name.clone(), + BTreeMap::from([ + ("code".into(), DynamicValue::Text("keep".into())), + ("label".into(), DynamicValue::Text("untouched".into())), + ]), + ); + backend.create(&entity).await.unwrap(); + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("contract.cedar"), + r#"forbid (principal is Forge::Principal, action == Action::"ReadThing", resource is Thing) when { resource.code == "secret" };"#).unwrap(); + let snapshot = PolicyStoreSnapshot::from_schemas( + std::slice::from_ref(&schema), + Some(directory.path()), + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(); + let policy_hash = snapshot.policy_hash.clone(); + let policies = Arc::new(PolicyStore::new(snapshot)); + // Config has no custom directory: the actor path models a CLI override. + let service = ServiceBuilder::new() + .with_config(Config::::default()) + .with_actor::() + .build(); + let (tx, rx) = oneshot::channel(); + service + .state() + .actor::() + .unwrap() + .send(InitForge { + registry: HashMap::from([("Thing".into(), schema.clone())]), + backend: backend.clone(), + tenant_config: None, + record_access_policy: None, + hook_dispatcher: None, + storage_registry: Default::default(), + policy_store: Some(policies.clone()), + custom_policies_dir: Some(directory.path().to_path_buf()), + reply: ReplyChannel::new(tx), + }) + .await; + tokio::time::timeout(Duration::from_secs(5), rx) + .await + .unwrap() + .unwrap(); + let app = forge_routes().with_state(service.state().clone()); + let drop_field = json!({"name":"Thing", "allow_destructive_migrations":true, "fields":[{"name":"label","field_type":"Text"}]}); + let rename_field = json!({"name":"Thing", "allow_destructive_migrations":true, "fields":[ + {"name":"renamed","field_type":"Text","modifiers":["required"],"annotations":[{"annotation":"RenamedFrom","name":"code"}]}, + {"name":"label","field_type":"Text"} + ]}); + for (method, body) in [ + (Method::PUT, drop_field), + (Method::PUT, rename_field), + (Method::DELETE, Value::Null), + ] { + let (status, result) = request(&app, method, "/schemas/Thing", body).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{result}"); + assert!( + result + .to_string() + .contains("Cedar policy validation failed"), + "{result}" + ); + assert_eq!( + backend + .load_schema_metadata(&schema.name) + .await + .unwrap() + .unwrap(), + schema + ); + assert_eq!( + backend.get(&schema.name, &entity.id).await.unwrap().fields, + entity.fields + ); + assert_eq!(policies.current().policy_hash, policy_hash); + let (status, result) = request(&app, Method::GET, "/schemas/Thing", Value::Null).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert!(result["fields"] + .as_array() + .unwrap() + .iter() + .any(|field| field["name"] == "code")); + } + // Prepare two competing changes from the same immutable registry/bundle. + let mut desired = schema.clone(); + desired.fields.push(FieldDefinition::new( + FieldName::new("extra").unwrap(), + FieldType::Text(TextConstraints::default()), + )); + let mut competing = schema.clone(); + competing + .fields + .retain(|field| field.name.as_str() != "label"); + let compile = |definition: &schema_forge_core::types::SchemaDefinition| { + Arc::new( + PolicyStoreSnapshot::from_schemas( + std::slice::from_ref(definition), + Some(directory.path()), + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(), + ) + }; + let desired_policy = compile(&desired); + let competing_policy = compile(&competing); + let expected_policy = policies.current(); + let expected_registry = HashMap::from([("Thing".into(), schema.clone())]); + // If commit rereads the source, this would fail after already changing DDL. + std::fs::write(directory.path().join("contract.cedar"), "invalid policy").unwrap(); + let forge = service.state().actor::().unwrap(); + let (tx, rx) = oneshot::channel(); + forge + .send(ApplyPreparedSchemaChange { + expected_registry: expected_registry.clone(), + expected_policy: expected_policy.clone(), + next_policy: desired_policy.clone(), + definition: desired.clone(), + remove: false, + steps: DiffEngine::plan_update(&schema, &desired).unwrap().steps, + reply: ReplyChannel::new(tx), + }) + .await; + tokio::time::timeout(Duration::from_secs(5), rx) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(Arc::ptr_eq(&policies.current(), &desired_policy)); + assert_eq!( + backend + .load_schema_metadata(&schema.name) + .await + .unwrap() + .unwrap(), + desired + ); + let (tx, rx) = oneshot::channel(); + forge + .send(ApplyPreparedSchemaChange { + expected_registry, + expected_policy, + next_policy: competing_policy, + definition: competing.clone(), + remove: false, + steps: DiffEngine::plan_update(&schema, &competing).unwrap().steps, + reply: ReplyChannel::new(tx), + }) + .await; + let error = tokio::time::timeout(Duration::from_secs(5), rx) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(matches!( + error, + schema_forge_acton::error::ForgeError::Conflict { + reason: "schema_preflight_stale", + .. + } + )); + assert_eq!( + backend.get(&schema.name, &entity.id).await.unwrap().fields["label"], + DynamicValue::Text("untouched".into()) + ); + assert_eq!( + backend + .load_schema_metadata(&schema.name) + .await + .unwrap() + .unwrap(), + desired + ); + + // A later storage rejection rolls back earlier DDL and restores the + // provisional registry before replying. Field removal precedes the index. + std::fs::write(directory.path().join("contract.cedar"), + r#"forbid (principal is Forge::Principal, action == Action::"ReadThing", resource is Thing) when { resource.code == "secret" };"#).unwrap(); + backend + .create(&Entity::new(schema.name.clone(), entity.fields.clone())) + .await + .unwrap(); + let mut invalid = desired.clone(); + invalid + .fields + .retain(|field| field.name.as_str() != "label"); + invalid + .fields + .iter_mut() + .find(|field| field.name.as_str() == "code") + .unwrap() + .modifiers + .push(FieldModifier::Unique); + let (tx, rx) = oneshot::channel(); + forge + .send(ApplyPreparedSchemaChange { + expected_registry: HashMap::from([("Thing".into(), desired.clone())]), + expected_policy: policies.current(), + next_policy: compile(&invalid), + definition: invalid.clone(), + remove: false, + steps: DiffEngine::plan_update(&desired, &invalid).unwrap().steps, + reply: ReplyChannel::new(tx), + }) + .await; + assert!(tokio::time::timeout(Duration::from_secs(5), rx) + .await + .unwrap() + .unwrap() + .is_err()); + assert!(Arc::ptr_eq(&policies.current(), &desired_policy)); + assert_eq!( + backend.get(&schema.name, &entity.id).await.unwrap().fields["label"], + DynamicValue::Text("untouched".into()) + ); + let (tx, rx) = oneshot::channel(); + forge + .send(GetSchema { + name: "Thing".into(), + reply: ReplyChannel::new(tx), + }) + .await; + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), rx) + .await + .unwrap() + .unwrap() + .unwrap(), + desired + ); + assert_eq!( + backend + .load_schema_metadata(&schema.name) + .await + .unwrap() + .unwrap(), + desired + ); +} diff --git a/crates/schema-forge-backend/Cargo.toml b/crates/schema-forge-backend/Cargo.toml index 27786b3e..f59c493d 100644 --- a/crates/schema-forge-backend/Cargo.toml +++ b/crates/schema-forge-backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-backend" -version = "0.17.1" +version = "0.18.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-backend/src/error.rs b/crates/schema-forge-backend/src/error.rs index 0675fe08..f9c7d35d 100644 --- a/crates/schema-forge-backend/src/error.rs +++ b/crates/schema-forge-backend/src/error.rs @@ -33,6 +33,8 @@ pub enum BackendError { /// field name when the backend reported a recognisable constraint; /// otherwise it falls back to the literal constraint name. UniqueViolation { schema: String, field: String }, + /// A relation references a missing record or prevents removal of a referenced record. + ForeignKeyViolation { schema: String, constraint: String }, /// Internal or unexpected error. Internal { message: String }, } @@ -74,6 +76,10 @@ impl fmt::Display for BackendError { Self::QueryError { message } => { write!(f, "query execution error: {message}") } + Self::ForeignKeyViolation { schema, constraint } => write!( + f, + "relation constraint '{constraint}' violated in schema '{schema}'" + ), Self::UniqueViolation { schema, field } => { write!( f, diff --git a/crates/schema-forge-backend/src/traits.rs b/crates/schema-forge-backend/src/traits.rs index 9d9a07da..4cdebb83 100644 --- a/crates/schema-forge-backend/src/traits.rs +++ b/crates/schema-forge-backend/src/traits.rs @@ -16,6 +16,29 @@ use crate::error::BackendError; /// Uses RPITIT (return position impl Trait in trait) for async methods, /// avoiding the `async-trait` crate. pub trait SchemaBackend: Send + Sync { + /// Atomically apply DDL and persist the resulting metadata, including integrity checks. + /// `None` removes metadata; physical deletion requires explicit migration steps. + /// Unsupported adapters must refuse before making any changes. + fn apply_schema_change( + &self, + _name: &SchemaName, + _steps: &[MigrationStep], + _definition: Option<&SchemaDefinition>, + ) -> impl Future> + Send { + async { + Err(BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: "backend does not support atomic schema changes".into(), + }) + } + } + + /// Finish cross-schema constraints after applying a batch, including legacy repair. + /// Called only during explicit schema administration, never on a read connection. + fn finalize_schema_migrations(&self) -> impl Future> + Send { + async { Ok(()) } + } + /// Whether explicit preparation of record revisions is available. fn supports_record_revisions(&self) -> bool { false @@ -144,7 +167,8 @@ pub trait EntityStore: Send + Sync { /// Update an existing entity. /// /// The entity's `id` and `schema` determine which record to update. - /// All fields in `entity.fields` replace the existing fields. + /// Supplied fields replace their corresponding values; omitted fields remain unchanged. + /// An explicit `DynamicValue::Null` writes null rather than omitting the field. /// Returns the updated entity. fn update(&self, entity: &Entity) -> impl Future> + Send; diff --git a/crates/schema-forge-backend/tests/support/migration_renames.rs b/crates/schema-forge-backend/tests/support/migration_renames.rs new file mode 100644 index 00000000..0817948c --- /dev/null +++ b/crates/schema-forge-backend/tests/support/migration_renames.rs @@ -0,0 +1,88 @@ +use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; +use schema_forge_core::{ + migration::DiffEngine, + types::{ + DynamicValue, FieldAnnotation, FieldDefinition, FieldModifier, FieldName, FieldType, + SchemaDefinition, SchemaId, SchemaName, TextConstraints, + }, +}; +use std::collections::BTreeMap; + +pub async fn exercise(backend: &B) { + let old = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("RenameProbe").unwrap(), + vec![ + FieldDefinition::with_modifiers( + FieldName::new("number").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + vec![ + FieldModifier::Required, + FieldModifier::Unique, + FieldModifier::Indexed, + ], + ), + FieldDefinition::new(FieldName::new("payload").unwrap(), FieldType::Json), + FieldDefinition::new(FieldName::new("unrelated").unwrap(), FieldType::Json), + ], + vec![], + ) + .unwrap(); + backend + .apply_migration(&old.name, &DiffEngine::create_new(&old).steps) + .await + .unwrap(); + backend.store_schema_metadata(&old).await.unwrap(); + let nested = + DynamicValue::Json(serde_json::json!({"nested": [null, false, 12, {"quoted": "a\"b"}]})); + let row = backend + .create(&Entity::new( + old.name.clone(), + BTreeMap::from([ + ("number".into(), DynamicValue::Text("555-9999".into())), + ("payload".into(), nested.clone()), + ("unrelated".into(), nested.clone()), + ]), + )) + .await + .unwrap(); + let stored_nested = row.fields.get("payload").cloned().unwrap(); + let mut new = old.clone(); + for (field, target) in new.fields.iter_mut().zip(["business_number", "document"]) { + field.annotations.push(FieldAnnotation::RenamedFrom { + name: field.name.clone(), + }); + field.name = FieldName::new(target).unwrap(); + } + DiffEngine::validate_transition(&old, &new).unwrap(); + backend + .apply_migration(&new.name, &DiffEngine::diff(&old, &new).steps) + .await + .unwrap(); + backend.store_schema_metadata(&new).await.unwrap(); + let loaded = backend.get(&new.name, &row.id).await.unwrap(); + assert_eq!( + loaded.fields.get("business_number"), + Some(&DynamicValue::Text("555-9999".into())) + ); + assert_eq!(loaded.fields.get("document"), Some(&stored_nested)); + assert_eq!(loaded.fields.get("unrelated"), row.fields.get("unrelated")); + assert!(!loaded.fields.contains_key("number")); + assert!(!loaded.fields.contains_key("payload")); + assert!(DiffEngine::diff(&new, &new).is_empty()); + backend.store_schema_metadata(&new).await.unwrap(); + // A later ordinary write must retain both renamed and unrelated objects. + let updated = backend + .update(&Entity { + id: row.id.clone(), + schema: new.name.clone(), + fields: BTreeMap::from([( + "business_number".into(), + DynamicValue::Text("555-0000".into()), + )]), + }) + .await + .unwrap(); + assert_eq!(updated.fields.get("document"), Some(&stored_nested)); + assert_eq!(updated.fields.get("unrelated"), row.fields.get("unrelated")); +} diff --git a/crates/schema-forge-cel/Cargo.toml b/crates/schema-forge-cel/Cargo.toml index 9c604fe4..d1c4e141 100644 --- a/crates/schema-forge-cel/Cargo.toml +++ b/crates/schema-forge-cel/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-cel" -version = "0.10.0" +version = "0.11.0" edition = "2021" description = "Minimal, owned CEL (Common Expression Language) evaluator over SchemaForge DynamicValue." @@ -17,7 +17,7 @@ chrono-tz = "0.10.4" # catastrophic backtracking (no ReDoS), matching the evaluator's # guaranteed-terminating / DoS-hardened posture. regex = "1.12.3" -schema-forge-core = { version = "0.17.0", path = "../schema-forge-core" } +schema-forge-core = { version = "0.18.0", path = "../schema-forge-core" } serde_json = "1.0.150" tracing = "0.1" diff --git a/crates/schema-forge-cli/Cargo.toml b/crates/schema-forge-cli/Cargo.toml index b2aa6254..53a75695 100644 --- a/crates/schema-forge-cli/Cargo.toml +++ b/crates/schema-forge-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-cli" -version = "0.44.2" +version = "0.45.0" edition = "2021" [[bin]] @@ -38,7 +38,7 @@ rust-embed = { version = "8.11.0", features = ["interpolate-folder-path"], optio mime_guess = { version = "2.0.5" } sha2 = "0.11.0" tokio-util = { version = "0.7.18", features = ["io"] } -schema-forge-mssql = { version = "0.4.0", path = "../schema-forge-mssql", optional = true } +schema-forge-mssql = { version = "0.5.0", path = "../schema-forge-mssql", optional = true } [features] default = ["surrealdb"] diff --git a/crates/schema-forge-cli/src/cli.rs b/crates/schema-forge-cli/src/cli.rs index b0a4c55a..b38101fc 100644 --- a/crates/schema-forge-cli/src/cli.rs +++ b/crates/schema-forge-cli/src/cli.rs @@ -1,3 +1,4 @@ +use std::net::IpAddr; use std::path::PathBuf; use clap::{ArgAction, Args, Parser, Subcommand}; @@ -57,7 +58,12 @@ pub struct GlobalOpts { pub no_color: bool, /// Database connection URL (auto-detects backend from scheme) [env: SCHEMA_FORGE_DB_URL] - #[arg(long = "db-url", global = true, env = "SCHEMA_FORGE_DB_URL")] + #[arg( + long = "db-url", + global = true, + env = "SCHEMA_FORGE_DB_URL", + hide_env_values = true + )] pub db_url: Option, /// Database namespace (SurrealDB only) [env: SCHEMA_FORGE_DB_NS] @@ -349,7 +355,11 @@ pub struct BootstrapAdminArgs { /// interactively — operators run this command from provisioning /// pipelines (init containers, ansible playbooks) where stdin isn't /// available. - #[arg(long = "password", env = "SCHEMA_FORGE_BOOTSTRAP_ADMIN_PASSWORD")] + #[arg( + long = "password", + env = "SCHEMA_FORGE_BOOTSTRAP_ADMIN_PASSWORD", + hide_env_values = true + )] pub password: String, /// Display name written into the user record. Cosmetic only. @@ -696,9 +706,13 @@ pub struct MigrateArgs { /// Arguments for `schema-forge serve`. #[derive(Args)] pub struct ServeArgs { + /// Allow startup migrations that drop fields or data; use @renamed_from for renames. + #[arg(long)] + pub allow_destructive_migrations: bool, + /// Host address to bind #[arg(short = 'H', long = "host", default_value = "127.0.0.1")] - pub host: String, + pub host: IpAddr, /// Port to listen on #[arg(short = 'p', long = "port", default_value = "3000")] @@ -721,7 +735,11 @@ pub struct ServeArgs { pub admin_user: String, /// Admin password used to bootstrap the initial user on first run. - #[arg(long = "admin-password", env = "FORGE_ADMIN_PASSWORD")] + #[arg( + long = "admin-password", + env = "FORGE_ADMIN_PASSWORD", + hide_env_values = true + )] pub admin_password: Option, /// Seed the bundled SchemaForge demo personas (alice/bob/charlie/dana/eve) @@ -896,7 +914,7 @@ pub struct CompletionsArgs { pub struct EntityConnectionArgs { /// Base URL of the running instance (e.g. https://forge.agency.gov). The /// versioned API path is appended automatically. [env: SCHEMAFORGE_SERVER] - #[arg(long, env = "SCHEMAFORGE_SERVER")] + #[arg(long, env = "SCHEMAFORGE_SERVER", hide_env_values = true)] pub server: Option, /// API version path segment. @@ -1496,7 +1514,7 @@ mod tests { ]) .unwrap(); if let Commands::Serve(args) = cli.command { - assert_eq!(args.host, "0.0.0.0"); + assert_eq!(args.host, std::net::Ipv4Addr::UNSPECIFIED); assert_eq!(args.port, 8080); assert!(args.watch); } else { diff --git a/crates/schema-forge-cli/src/commands/apply.rs b/crates/schema-forge-cli/src/commands/apply.rs index 921696f6..ee15fa61 100644 --- a/crates/schema-forge-cli/src/commands/apply.rs +++ b/crates/schema-forge-cli/src/commands/apply.rs @@ -34,6 +34,7 @@ pub(super) async fn apply_to_backend( backend: &dyn DynSchemaBackend, output: &OutputContext, ) -> Result<(), CliError> { + super::schema_update::preflight_schema_batch(backend, schemas).await?; if args.prepare_record_revisions && !backend.supports_record_revisions() { return Err(CliError::Config { message: "--prepare-record-revisions requires a PostgreSQL backend with record revision support".into() }); } @@ -45,7 +46,7 @@ pub(super) async fn apply_to_backend( for schema in schemas { let existing = backend.load_schema_metadata(&schema.name).await?; - let update = SchemaUpdate::plan(existing.as_ref(), schema); + let update = SchemaUpdate::plan(existing.as_ref(), schema)?; let plan = &update.migration; if update.is_empty() { output.status(&format!(" {} .... no changes", schema.name.as_str())); @@ -139,6 +140,10 @@ pub(super) async fn apply_to_backend( applied_schemas += 1; } + if !args.dry_run { + backend.finalize_schema_migrations().await?; + } + // Generate policies if requested if args.with_policies && !args.dry_run { for schema in schemas { diff --git a/crates/schema-forge-cli/src/commands/migrate.rs b/crates/schema-forge-cli/src/commands/migrate.rs index c8a5d25a..db1e5d9d 100644 --- a/crates/schema-forge-cli/src/commands/migrate.rs +++ b/crates/schema-forge-cli/src/commands/migrate.rs @@ -32,6 +32,16 @@ pub(super) async fn migrate_on_backend( backend: &dyn DynSchemaBackend, output: &OutputContext, ) -> Result<(), CliError> { + let desired: Vec<_> = schemas + .iter() + .filter(|schema| { + args.schema + .as_ref() + .is_none_or(|filter| schema.name.as_str() == filter) + }) + .cloned() + .collect(); + super::schema_update::preflight_schema_batch(backend, &desired).await?; let mut plans = Vec::new(); let mut total_steps = 0usize; let mut schemas_affected = 0usize; @@ -45,7 +55,7 @@ pub(super) async fn migrate_on_backend( } let existing = backend.load_schema_metadata(&schema.name).await?; - let update = SchemaUpdate::plan(existing.as_ref(), schema); + let update = SchemaUpdate::plan(existing.as_ref(), schema)?; let plan = &update.migration; if update.is_empty() { @@ -178,6 +188,8 @@ pub(super) async fn migrate_on_backend( update.persist(backend).await?; } + backend.finalize_schema_migrations().await?; + output.success(&format!( "Executed {total_steps} migration steps across {schemas_affected} schemas." )); diff --git a/crates/schema-forge-cli/src/commands/mod.rs b/crates/schema-forge-cli/src/commands/mod.rs index 91c8195f..d435718e 100644 --- a/crates/schema-forge-cli/src/commands/mod.rs +++ b/crates/schema-forge-cli/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod login; pub mod migrate; pub mod parse; pub mod policies; +mod policy_preflight; mod schema_update; pub mod serve; #[cfg(feature = "embedded-console")] diff --git a/crates/schema-forge-cli/src/commands/parse.rs b/crates/schema-forge-cli/src/commands/parse.rs index ac7936b3..7463a981 100644 --- a/crates/schema-forge-cli/src/commands/parse.rs +++ b/crates/schema-forge-cli/src/commands/parse.rs @@ -34,6 +34,12 @@ pub async fn run( match schema_forge_dsl::parse(&source_text) { Ok(schemas) => { + let warnings = schema_forge_dsl::empty_access_grants(&source_text); + if output.mode != OutputMode::Json { + for warning in &warnings { + output.warn(&format!("{filename}: {warning}")); + } + } let count = schemas.len(); total_schemas += count; @@ -47,6 +53,7 @@ pub async fn run( "file": filename, "schemas": count, "errors": [], + "warnings": warnings.iter().map(ToString::to_string).collect::>(), })); } else { output.status(&format!(" {filename} .... {count} schemas")); @@ -235,8 +242,7 @@ fn manifest_root_for(input_paths: &[PathBuf], files: &[PathBuf]) -> Result, +) -> Result { + PolicyStoreSnapshot::from_schemas( + schemas, + custom_dir, + current.role_ranks.clone(), + current.principal_claims.clone(), + ) + .map_err(|error| CliError::Server { + message: format!("Cedar policy preflight failed before schema changes: {error}"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use schema_forge_acton::authz::{PrincipalClaimMappings, RoleRanks}; + + #[test] + fn proposed_bundle_keeps_custom_policy_contract_and_is_ready_for_installation() { + let old = + schema_forge_dsl::parse("schema Thing { code: text required label: text }").unwrap(); + let new = + schema_forge_dsl::parse("schema Thing { renamed: text required label: text }").unwrap(); + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("contract.cedar"), + r#"forbid (principal is Forge::Principal, action == Action::"ReadThing", resource is Thing) when { resource.code == "secret" };"#).unwrap(); + let current = PolicyStoreSnapshot::from_schemas( + &old, + Some(directory.path()), + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(); + assert!(compile(&new, ¤t, Some(directory.path())).is_err()); + // A coordinated policy/schema rename is valid even though the new + // policy would not compile against the old stored registry. + std::fs::write(directory.path().join("contract.cedar"), + r#"forbid (principal is Forge::Principal, action == Action::"ReadThing", resource is Thing) when { resource.renamed == "secret" };"#).unwrap(); + assert!(compile(&new, ¤t, Some(directory.path())).is_ok()); + std::fs::write(directory.path().join("contract.cedar"), + r#"forbid (principal is Forge::Principal, action == Action::"ReadThing", resource is Thing) when { resource.code == "secret" };"#).unwrap(); + let prepared = compile(&old, ¤t, Some(directory.path())).unwrap(); + assert_eq!(prepared.policy_hash, current.policy_hash); + // The caller installs this exact snapshot after migration; a subsequent + // disk edit cannot silently change the bundle it already validated. + std::fs::write(directory.path().join("contract.cedar"), "invalid policy").unwrap(); + let store = schema_forge_acton::authz::PolicyStore::new(current); + store.swap(prepared); + assert!(store.current().policy_count > 0); + } +} diff --git a/crates/schema-forge-cli/src/commands/schema_update.rs b/crates/schema-forge-cli/src/commands/schema_update.rs index 95528b88..34253c17 100644 --- a/crates/schema-forge-cli/src/commands/schema_update.rs +++ b/crates/schema-forge-cli/src/commands/schema_update.rs @@ -8,6 +8,41 @@ use schema_forge_core::{ use crate::error::CliError; +/// Construct the complete desired registry before performing any migration. +pub(super) fn merge_schema_definitions( + existing: impl IntoIterator, + desired: &[SchemaDefinition], +) -> Vec { + let mut registry: std::collections::BTreeMap<_, _> = existing + .into_iter() + .map(|schema| (schema.name.clone(), schema)) + .collect(); + registry.extend( + desired + .iter() + .cloned() + .map(|schema| (schema.name.clone(), schema)), + ); + registry.into_values().collect() +} + +pub(super) fn validate_tenant_hierarchy(proposed: &[SchemaDefinition]) -> Result<(), CliError> { + schema_forge_backend::tenant::TenantConfig::from_schemas(proposed).map_err(|error| { + CliError::Config { + message: format!("invalid proposed tenant hierarchy: {error}"), + } + })?; + Ok(()) +} + +pub(super) async fn preflight_schema_batch( + backend: &dyn DynSchemaBackend, + desired: &[SchemaDefinition], +) -> Result<(), CliError> { + let existing = backend.list_schema_metadata().await?; + validate_tenant_hierarchy(&merge_schema_definitions(existing, desired)) +} + pub(super) struct SchemaUpdate { pub schema: SchemaDefinition, pub migration: MigrationPlan, @@ -16,21 +51,28 @@ pub(super) struct SchemaUpdate { impl SchemaUpdate { /// Preserve stored identity when comparing freshly parsed definitions. - pub fn plan(existing: Option<&SchemaDefinition>, desired: &SchemaDefinition) -> Self { + pub fn plan( + existing: Option<&SchemaDefinition>, + desired: &SchemaDefinition, + ) -> Result { let mut schema = desired.clone(); if let Some(existing) = existing { schema.id = existing.id.clone(); } let metadata_changed = existing != Some(&schema); - let migration = existing.map_or_else( - || DiffEngine::create_new(&schema), - |existing| DiffEngine::diff(existing, &schema), - ); - Self { + let migration = existing + .map_or_else( + || Ok(DiffEngine::create_new(&schema)), + |existing| DiffEngine::plan_update(existing, &schema), + ) + .map_err(|error| CliError::Config { + message: error.to_string(), + })?; + Ok(Self { schema, migration, metadata_changed, - } + }) } pub fn is_empty(&self) -> bool { @@ -222,6 +264,107 @@ mod tests { } } + #[tokio::test] + async fn tenancy_changes_never_write_even_with_force() { + for force in [false, true] { + for dry_run in [false, true] { + let original = schema("schema Contact { phone: text unique }"); + let backend = Backend::seeded(original.clone()); + let result = super::super::apply::apply_to_backend( + &ApplyArgs { + paths: vec![], + force, + dry_run, + with_policies: false, + prepare_record_revisions: false, + }, + &[schema( + r#"@tenant(parent: "Org") schema Contact { phone: text unique }"#, + )], + &backend, + &output(), + ) + .await; + assert!(matches!(result, Err(CliError::Config { .. }))); + let stored = backend.stored.lock().unwrap(); + assert_eq!(stored.migrations, 0); + assert_eq!(stored.writes, 0); + assert_eq!(stored.schema.as_ref(), Some(&original)); + } + } + } + + #[tokio::test] + async fn invalid_combined_hierarchy_is_rejected_before_all_writes() { + for command in [Command::Apply, Command::Migrate] { + let original = schema("@tenant(root) schema Org { name: text }"); + for source in [ + "@tenant(root) schema Other { name: text }", + r#"@tenant(parent: "Missing") schema Child { name: text }"#, + ] { + let backend = Backend::seeded(original.clone()); + assert!(command.run(&backend, schema(source), true).await.is_err()); + let stored = backend.stored.lock().unwrap(); + assert_eq!(stored.migrations, 0); + assert_eq!(stored.writes, 0); + assert_eq!(stored.schema.as_ref(), Some(&original)); + } + } + } + + #[test] + fn proposed_hierarchy_replaces_old_definitions_and_accepts_valid_batches() { + let old = schema("schema Contact { phone: text }"); + let desired = [ + schema("@tenant(root) schema Org { name: text }"), + schema(r#"@tenant(parent: "Org") schema Contact { phone: text }"#), + ]; + let proposed = merge_schema_definitions([old], &desired); + assert_eq!(proposed.len(), 2); + validate_tenant_hierarchy(&proposed).unwrap(); + assert!(validate_tenant_hierarchy(&[ + schema(r#"@tenant(parent: "Other") schema Org { name: text }"#), + schema(r#"@tenant(parent: "Org") schema Other { name: text }"#) + ]) + .is_err()); + } + + #[tokio::test] + async fn lossy_transforms_require_force_before_any_writes() { + for command in [Command::Apply, Command::Migrate] { + for (old, new) in [ + ( + "schema Sample { value: float }", + "schema Sample { value: integer }", + ), + ( + "schema Sample { value: integer }", + "schema Sample { value: float }", + ), + ( + r#"schema Sample { value: enum("old", "stay") }"#, + r#"schema Sample { value: enum("stay") }"#, + ), + ( + "schema Sample { value: boolean }", + "schema Sample { value: datetime }", + ), + ] { + let original = schema(old); + let backend = Backend::seeded(original.clone()); + let result = command.run(&backend, schema(new), true).await; + assert!( + matches!(result, Err(CliError::RequiresForce)), + "{command:?}: {result:?}" + ); + let stored = backend.stored.lock().unwrap(); + assert_eq!(stored.migrations, 0); + assert_eq!(stored.writes, 0); + assert_eq!(stored.schema.as_ref(), Some(&original)); + } + } + } + const ORIGINAL: &str = "@version(1) schema Person { age: integer }"; const METADATA_CHANGES: [&str; 3] = [ "@version(2) schema Person { age: integer }", diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index a84081ea..50811d9d 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -135,7 +135,9 @@ pub async fn run( &storage_config, role_ranks, principal_claims, - custom_dir.as_deref(), + // Validate custom policies against the proposed registry below, not the + // old registry: a coordinated field/policy rename must be deployable. + None, ) .await .map_err(|e| CliError::Server { @@ -144,8 +146,23 @@ pub async fn run( // 5. Apply parsed schemas (using the backend directly, before actor spawning) let mut registry = init_data.registry; + let proposed_schemas = + super::schema_update::merge_schema_definitions(registry.values().cloned(), &schemas); + super::schema_update::validate_tenant_hierarchy(&proposed_schemas)?; + let prepared_policy = init_data + .policy_store + .as_ref() + .map(|store| { + super::policy_preflight::compile( + &proposed_schemas, + &store.current(), + custom_dir.as_deref(), + ) + }) + .transpose()?; if !schemas.is_empty() { output.status("Applying schemas..."); + let mut plans = Vec::new(); for schema in &schemas { let existing = backend_arc .load_schema_metadata(&schema.name) @@ -153,11 +170,19 @@ pub async fn run( .map_err(CliError::Backend)?; let plan = if let Some(old) = existing { - DiffEngine::diff(&old, schema) + DiffEngine::plan_update(&old, schema).map_err(|error| CliError::Config { + message: error.to_string(), + })? } else { DiffEngine::create_new(schema) }; + if plan.has_destructive_steps() && !args.allow_destructive_migrations { + return Err(CliError::Config { message: format!("schema '{}': destructive startup migration refused: {}. Declare field renames with @renamed_from(\"old_name\") or explicitly pass --allow-destructive-migrations", schema.name, plan.steps.iter().map(ToString::to_string).collect::>().join("; ")) }); + } + plans.push(plan); + } + for (schema, plan) in schemas.iter().zip(plans) { if !plan.is_empty() { backend_arc .apply_migration(&schema.name, &plan.steps) @@ -178,6 +203,11 @@ pub async fn run( } } + backend_arc + .finalize_schema_migrations() + .await + .map_err(CliError::Backend)?; + // Rebuild tenant config after applying parsed schemas let all_schemas: Vec<_> = registry.values().cloned().collect(); let tenant_config = schema_forge_backend::tenant::TenantConfig::from_schemas(&all_schemas) @@ -190,17 +220,11 @@ pub async fn run( None }; - // Recompile the Cedar policy bundle now that --schemas have been merged - // into the registry. `build_init` ran before the parsed schemas were - // applied, so its initial PolicyStore covers only the system schemas; - // without this step the runtime would reject every authz check against - // an app schema with "type X is not declared in the schema". - if let Some(policy_store) = &init_data.policy_store { - policy_store - .recompile_from_schemas(&all_schemas, custom_dir.as_deref()) - .map_err(|e| CliError::Server { - message: format!("Cedar policy recompile failed after schema apply: {e}"), - })?; + // Install exactly the bundle validated before DDL. Do not reread policy + // files after storage changes and risk discovering a late compile failure. + if let (Some(policy_store), Some(prepared_policy)) = (&init_data.policy_store, prepared_policy) + { + policy_store.swap(prepared_policy); // Log the final bundle posture so misconfiguration (missing custom // policies, wrong directory) is obvious at startup. Mirrors the @@ -297,6 +321,7 @@ pub async fn run( // fields here. Database/SurrealDB sections are not touched here — they // were resolved up-front by `load_svc_config` so acton-service's pool // and the schema-forge backend pool see the same URL by construction. + svc_config.service.bind = args.host; svc_config.service.port = args.port; svc_config.service.name = "schemaforge".to_string(); @@ -442,7 +467,7 @@ pub async fn run( }; let console_served = cfg!(feature = "embedded-console") && !args.no_console; - let bind_addr = format!("{}:{}", args.host, args.port); + let bind_addr = std::net::SocketAddr::new(svc_config.service.bind, svc_config.service.port); output.success(&format!( "SchemaForge server listening on http://{bind_addr}" )); @@ -959,7 +984,7 @@ fn resolve_custom_policies_dir( fn build_meta_info(db_params: &DbParams) -> Arc { let (backend, label) = match db_params { #[cfg(feature = "surrealdb")] - DbParams::Surrealdb(_) => ("surrealdb", "SurrealDB 2.x"), + DbParams::Surrealdb(_) => ("surrealdb", "SurrealDB 3.3+"), #[cfg(feature = "postgres")] DbParams::Postgres(_) => ("postgres", "PostgreSQL"), #[cfg(feature = "mssql")] @@ -1174,7 +1199,7 @@ mod tests { let meta = Arc::new(schema_forge_acton::MetaInfo::new( "surrealdb", - "SurrealDB 2.x", + "SurrealDB 3.3+", 3600, )); let principal_claims = diff --git a/crates/schema-forge-cli/src/error.rs b/crates/schema-forge-cli/src/error.rs index 03777348..c079a28d 100644 --- a/crates/schema-forge-cli/src/error.rs +++ b/crates/schema-forge-cli/src/error.rs @@ -81,7 +81,7 @@ pub enum CliError { DirectoryExists { path: PathBuf }, /// Non-TTY requires --force for destructive operations. - #[error("destructive changes require --force in non-interactive mode")] + #[error("destructive changes require --force in non-interactive mode; for field renames declare @renamed_from(\"old_name\") to preserve data")] RequiresForce, /// HTTP server errors. diff --git a/crates/schema-forge-cli/templates/site/src/generated/api-client.ts.jinja b/crates/schema-forge-cli/templates/site/src/generated/api-client.ts.jinja index c62ce27a..ba06a01d 100644 --- a/crates/schema-forge-cli/templates/site/src/generated/api-client.ts.jinja +++ b/crates/schema-forge-cli/templates/site/src/generated/api-client.ts.jinja @@ -273,7 +273,7 @@ export function isSystemSchema(schema: SchemaResponse): boolean { export type MetaResponse = { /** Stable lowercase backend kind (`"surrealdb"`, `"postgres"`, `"turso"`). */ backend: string - /** Human-readable backend label (`"SurrealDB 2.x"`). */ + /** Human-readable backend label (`"SurrealDB 3.3+"`). */ backend_label: string auth: { /** Token scheme — `"paseto"` today. */ diff --git a/crates/schema-forge-cli/tests/cli_integration.rs b/crates/schema-forge-cli/tests/cli_integration.rs index 95756b0f..acccecd9 100644 --- a/crates/schema-forge-cli/tests/cli_integration.rs +++ b/crates/schema-forge-cli/tests/cli_integration.rs @@ -666,3 +666,44 @@ fn entity_file_clear_requires_yes_in_scripts() { .failure() .stderr(predicate::str::contains("requires --yes")); } + +#[test] +fn help_does_not_disclose_secret_environment_values() { + for args in [ + vec!["--help"], + vec!["parse", "--help"], + vec!["serve", "--help"], + vec!["bootstrap-admin", "--help"], + vec!["entity", "list", "--help"], + ] { + schema_forge() + .env( + "SCHEMA_FORGE_DB_URL", + "postgres://operator:HELP_SECRET_DB@localhost/test", + ) + .env("FORGE_ADMIN_PASSWORD", "HELP_SECRET_SERVE") + .env( + "SCHEMA_FORGE_BOOTSTRAP_ADMIN_PASSWORD", + "HELP_SECRET_BOOTSTRAP", + ) + .env( + "SCHEMAFORGE_SERVER", + "https://operator:HELP_SECRET_SERVER@localhost", + ) + .args(args) + .assert() + .success() + .stdout(predicate::str::contains("SCHEMA_FORGE_DB_URL")) + .stdout(predicate::str::contains("HELP_SECRET").not()) + .stderr(predicate::str::contains("HELP_SECRET").not()); + } +} + +#[test] +fn serve_rejects_invalid_host_before_connecting() { + schema_forge() + .args(["serve", "--host", "not-an-ip-address"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid IP address syntax")); +} diff --git a/crates/schema-forge-core/Cargo.toml b/crates/schema-forge-core/Cargo.toml index cf5489a1..c549cc88 100644 --- a/crates/schema-forge-core/Cargo.toml +++ b/crates/schema-forge-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-core" -version = "0.17.0" +version = "0.18.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-core/src/migration.rs b/crates/schema-forge-core/src/migration.rs index 6ffa947e..dda69d86 100644 --- a/crates/schema-forge-core/src/migration.rs +++ b/crates/schema-forge-core/src/migration.rs @@ -242,6 +242,15 @@ impl MigrationStep { /// Classify the safety level of this migration step. pub fn safety(&self) -> MigrationSafety { match self { + Self::ChangeType { + transform: + ValueTransform::SetNull + | ValueTransform::SetDefault { .. } + | ValueTransform::NullRemovedEnumVariants { .. } + | ValueTransform::FloatToInteger + | ValueTransform::IntegerToFloat, + .. + } => MigrationSafety::Destructive, Self::ChangeType { transform: ValueTransform::Identity, .. @@ -452,15 +461,83 @@ impl fmt::Display for MigrationPlan { pub struct DiffEngine; impl DiffEngine { - /// Compare two schema definitions and produce a migration plan. + /// Plan an existing schema update, rejecting transitions that need manual migration. + /// Production callers should use this checked entry point before any DDL or metadata write. + pub fn plan_update( + old: &crate::types::SchemaDefinition, + new: &crate::types::SchemaDefinition, + ) -> Result { + Self::validate_transition(old, new)?; + Ok(Self::diff(old, new)) + } + + /// Validate transitions that cannot safely be inferred from field differences. + /// Tenant changes require a manual data backfill and constraint migration. + pub fn validate_transition( + old: &crate::types::SchemaDefinition, + new: &crate::types::SchemaDefinition, + ) -> Result<(), MigrationError> { + let tenancy = |schema: &crate::types::SchemaDefinition| { + schema.annotations.iter().find_map(|annotation| { + if let Annotation::Tenant(kind) = annotation { + Some(kind.clone()) + } else { + None + } + }) + }; + if tenancy(old) != tenancy(new) { + return Err(MigrationError::ManualMigrationRequired { reason: format!("schema '{}': changing @tenant requires a manual migration of _tenant, tenant backfill, unique constraints, and stored schema metadata; automatic migration refused", new.name) }); + } + let mut sources = std::collections::HashSet::new(); + for field in &new.fields { + for annotation in &field.annotations { + if let crate::types::FieldAnnotation::RenamedFrom { name } = annotation { + let source_exists = old.field(name.as_str()).is_some(); + let target_exists = old.field(field.name.as_str()).is_some(); + if name == &field.name + || !sources.insert(name) + || new.field(name.as_str()).is_some() + || (source_exists && target_exists) + || (!source_exists && !target_exists) + { + return Err(MigrationError::ManualMigrationRequired { reason: format!("invalid @renamed_from(\"{name}\") on '{}': source must identify one removed field and target one new field (or an already completed rename)", field.name) }); + } + } + } + } + Ok(()) + } + + /// Compute an unchecked structural diff of two schema definitions. /// + /// This compatibility API does not validate tenancy transitions or rename hints. + /// Use [`Self::plan_update`] for executable migration plans. /// This is a pure function: no I/O, no side effects. #[instrument(skip(old, new), fields(old_schema = %old.name.as_str(), new_schema = %new.name.as_str()))] pub fn diff( old: &crate::types::SchemaDefinition, new: &crate::types::SchemaDefinition, ) -> MigrationPlan { - Self::diff_with_renames(old, new, &[]) + let renames: Vec<_> = new + .fields + .iter() + .flat_map(|field| { + field + .annotations + .iter() + .filter_map(|annotation| match annotation { + crate::types::FieldAnnotation::RenamedFrom { name } + if old.field(name.as_str()).is_some() + && old.field(field.name.as_str()).is_none() => + { + Some((name.clone(), field.name.clone())) + } + _ => None, + }) + }) + .collect(); + Self::diff_with_renames(old, new, &renames) } /// Compare two schema definitions with explicit rename hints. @@ -900,6 +977,8 @@ impl DiffEngine { #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum MigrationError { + /// This transition requires an explicit manual migration. + ManualMigrationRequired { reason: String }, /// The migration ID string could not be parsed. InvalidMigrationId(String), /// Attempted to apply a destructive migration without confirmation. @@ -921,6 +1000,7 @@ pub enum MigrationError { impl fmt::Display for MigrationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::ManualMigrationRequired { reason } => write!(f, "{reason}"), Self::InvalidMigrationId(s) => { write!(f, "invalid migration id: {s}") } @@ -1158,8 +1238,8 @@ mod tests { MigrationStep::ChangeType { name: FieldName::new("score").unwrap(), old_type: FieldType::Integer(IntegerConstraints::unconstrained()), - new_type: FieldType::Float(FloatConstraints::unconstrained()), - transform: ValueTransform::IntegerToFloat, + new_type: FieldType::Text(TextConstraints::unconstrained()), + transform: ValueTransform::ToString, }, MigrationStep::AddRequired { field: FieldName::new("email").unwrap(), @@ -1823,6 +1903,35 @@ mod tests { .any(|s| matches!(s, MigrationStep::RenameField { .. }))); } + #[test] + fn lossy_transforms_are_destructive() { + for transform in [ + ValueTransform::SetNull, + ValueTransform::SetDefault { + value: DefaultValue::String("replacement".into()), + }, + ValueTransform::NullRemovedEnumVariants { + variants: vec!["old".into()], + }, + ValueTransform::FloatToInteger, + ValueTransform::IntegerToFloat, + ] { + let step = MigrationStep::ChangeType { + name: FieldName::new("value").unwrap(), + old_type: FieldType::Float(FloatConstraints::unconstrained()), + new_type: FieldType::Integer(IntegerConstraints::unconstrained()), + transform, + }; + assert_eq!(step.safety(), MigrationSafety::Destructive); + let plan = MigrationPlan::new( + SchemaId::new(), + SchemaName::new("Sample").unwrap(), + vec![step], + ); + assert!(plan.has_destructive_steps()); + } + } + // -- MigrationError tests -- #[test] diff --git a/crates/schema-forge-core/src/types/field_annotation.rs b/crates/schema-forge-core/src/types/field_annotation.rs index ef0a7d4f..2af3a860 100644 --- a/crates/schema-forge-core/src/types/field_annotation.rs +++ b/crates/schema-forge-core/src/types/field_annotation.rs @@ -526,6 +526,8 @@ impl fmt::Display for ExportFlatten { #[serde(tag = "annotation")] #[non_exhaustive] pub enum FieldAnnotation { + /// Previous physical field name, retained as an idempotent migration hint. + RenamedFrom { name: super::FieldName }, /// `@field_access(...)` -- role-based access control on a specific field. FieldAccess { read: Vec, @@ -544,9 +546,7 @@ pub enum FieldAnnotation { /// responsible for ensuring every key names a valid variant of the /// enum field the annotation is attached to. Missing keys render with /// the default neutral badge. - EnumColors { - colors: BTreeMap, - }, + EnumColors { colors: BTreeMap }, /// `@list(primary|column|hidden)` -- controls whether the field /// appears in the generated list view and, for `primary`, marks it /// as the headline cell rendered with display styling. @@ -593,6 +593,7 @@ impl FieldAnnotation { /// Returns the annotation kind as a string, for dedup checking. pub fn kind(&self) -> &'static str { match self { + Self::RenamedFrom { .. } => "renamed_from", Self::FieldAccess { .. } => "field_access", Self::Owner => "owner", Self::Widget { .. } => "widget", @@ -612,6 +613,7 @@ impl FieldAnnotation { impl fmt::Display for FieldAnnotation { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::RenamedFrom { name } => write!(f, "@renamed_from(\"{name}\")"), Self::FieldAccess { read, write } => { write!( f, @@ -1039,7 +1041,10 @@ mod tests { #[test] fn export_flatten_str_roundtrip() { - assert_eq!("json".parse::().unwrap(), ExportFlatten::Json); + assert_eq!( + "json".parse::().unwrap(), + ExportFlatten::Json + ); assert_eq!(ExportFlatten::Json.to_string(), "json"); assert!("xml".parse::().is_err()); } @@ -1262,7 +1267,10 @@ mod tests { expr: "age >= 18".to_string(), message: "must be 18 or older".to_string(), }; - assert_eq!(a.to_string(), "@require(\"age >= 18\", \"must be 18 or older\")"); + assert_eq!( + a.to_string(), + "@require(\"age >= 18\", \"must be 18 or older\")" + ); } #[test] diff --git a/crates/schema-forge-core/tests/migration_integration.rs b/crates/schema-forge-core/tests/migration_integration.rs index 9977c5c8..4d0132c6 100644 --- a/crates/schema-forge-core/tests/migration_integration.rs +++ b/crates/schema-forge-core/tests/migration_integration.rs @@ -170,7 +170,7 @@ fn scenario_change_field_type() { let plan = DiffEngine::diff(&v1, &v2); assert_eq!(plan.len(), 1); - assert_eq!(plan.overall_safety(), MigrationSafety::RequiresConfirmation); + assert_eq!(plan.overall_safety(), MigrationSafety::Destructive); assert!(matches!( &plan.steps[0], MigrationStep::ChangeType { diff --git a/crates/schema-forge-dsl/Cargo.toml b/crates/schema-forge-dsl/Cargo.toml index e06a412b..77f024bc 100644 --- a/crates/schema-forge-dsl/Cargo.toml +++ b/crates/schema-forge-dsl/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "schema-forge-dsl" -version = "0.13.0" +version = "0.14.0" edition = "2021" [dependencies] schema-forge-core = { path = "../schema-forge-core" } -schema-forge-cel = { version = "0.10.0", path = "../schema-forge-cel" } +schema-forge-cel = { version = "0.11.0", path = "../schema-forge-cel" } logos = "0.15" tracing = "0.1" diff --git a/crates/schema-forge-dsl/src/lib.rs b/crates/schema-forge-dsl/src/lib.rs index ccb1efbd..36ac522c 100644 --- a/crates/schema-forge-dsl/src/lib.rs +++ b/crates/schema-forge-dsl/src/lib.rs @@ -38,3 +38,6 @@ pub mod token; pub use error::{DslError, Span}; pub use parser::parse; pub use printer::{print, print_all}; + +mod warnings; +pub use warnings::{empty_access_grants, EmptyAccessGrant}; diff --git a/crates/schema-forge-dsl/src/parser.rs b/crates/schema-forge-dsl/src/parser.rs index 294c33a4..204d45c6 100644 --- a/crates/schema-forge-dsl/src/parser.rs +++ b/crates/schema-forge-dsl/src/parser.rs @@ -807,6 +807,19 @@ impl Parser { self.expect(&Token::At)?; let name_tok = self.expect_ident("field annotation name")?; match name_tok.text.as_str() { + "renamed_from" => { + self.expect(&Token::LParen)?; + let value = self.expect_string_literal()?; + let name = FieldName::new(unquote_string(&value.text)).map_err(|error| { + DslError::UnexpectedToken { + expected: "valid previous field name".into(), + found: error.to_string(), + span: value.span, + } + })?; + self.expect(&Token::RParen)?; + Ok(FieldAnnotation::RenamedFrom { name }) + } "owner" => Ok(FieldAnnotation::Owner), "hidden" => Ok(FieldAnnotation::Hidden), "field_access" => { @@ -915,14 +928,13 @@ impl Parser { } self.expect(&Token::Colon)?; let hint_tok = self.expect_ident("flatten hint (json)")?; - let flatten = - ExportFlatten::from_str(&hint_tok.text).map_err(|()| { - DslError::UnknownExportFlatten { - value: hint_tok.text.clone(), - valid: VALID_EXPORT_FLATTEN, - span: hint_tok.span.clone(), - } - })?; + let flatten = ExportFlatten::from_str(&hint_tok.text).map_err(|()| { + DslError::UnknownExportFlatten { + value: hint_tok.text.clone(), + valid: VALID_EXPORT_FLATTEN, + span: hint_tok.span.clone(), + } + })?; self.expect(&Token::RParen)?; Ok(FieldAnnotation::Exportable { flatten: Some(flatten), @@ -1481,7 +1493,8 @@ impl Parser { // type-check pass keys on top-level schema field names). let mut composite_rule_sites: Vec = Vec::new(); let mut composite_exportable_sites: Vec = Vec::new(); - let fields = self.parse_fields(&mut composite_rule_sites, &mut composite_exportable_sites)?; + let fields = + self.parse_fields(&mut composite_rule_sites, &mut composite_exportable_sites)?; // `@exportable` is a top-level field opt-in; a composite sub-field cannot // carry it (the whole composite is exported as a single value). Reject it diff --git a/crates/schema-forge-dsl/src/printer.rs b/crates/schema-forge-dsl/src/printer.rs index baa1debd..cd298652 100644 --- a/crates/schema-forge-dsl/src/printer.rs +++ b/crates/schema-forge-dsl/src/printer.rs @@ -389,7 +389,8 @@ fn print_field_annotation(annotation: &FieldAnnotation, output: &mut String) { output.push(')'); } FieldAnnotation::Hidden => output.push_str("@hidden"), - FieldAnnotation::Require { .. } + FieldAnnotation::RenamedFrom { .. } + | FieldAnnotation::Require { .. } | FieldAnnotation::Compute { .. } | FieldAnnotation::Default { .. } | FieldAnnotation::Exportable { .. } => { @@ -1247,8 +1248,7 @@ schema S { let reparsed = crate::parser::parse(&printed).unwrap(); for i in 0..3 { assert_eq!( - parsed[0].fields[i].annotations, - reparsed[0].fields[i].annotations, + parsed[0].fields[i].annotations, reparsed[0].fields[i].annotations, "annotation mismatch on field {i}" ); } diff --git a/crates/schema-forge-dsl/src/warnings.rs b/crates/schema-forge-dsl/src/warnings.rs new file mode 100644 index 00000000..3679941e --- /dev/null +++ b/crates/schema-forge-dsl/src/warnings.rs @@ -0,0 +1,91 @@ +//! Nonbreaking diagnostics for ambiguous authorization grants. + +use crate::lexer::tokenize; +use crate::token::Token; +use crate::Span; + +/// An explicit empty access grant, which permits every authenticated user. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmptyAccessGrant { + /// Annotation containing the grant (`access` or `field_access`). + pub annotation: String, + /// Direction with the explicit empty grant. + pub direction: String, + /// Location of the direction and list in the original source. + pub span: Span, +} + +impl std::fmt::Display for EmptyAccessGrant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "@{}({}: []) grants access to every authenticated user; use a nonempty role list such as [\"platform_admin\"] to restrict access", self.annotation, self.direction) + } +} + +/// Report explicit empty read/write/delete grants without changing semantics. +/// Comments, string contents, omitted grants and cross_tenant_read are ignored. +/// Invalid token streams are left to the parser's error diagnostics. +pub fn empty_access_grants(source: &str) -> Vec { + let Ok(tokens) = tokenize(source) else { + return vec![]; + }; + let mut warnings = Vec::new(); + let mut annotation = None; + for (index, token) in tokens.iter().enumerate() { + if token.token == Token::At { + annotation = tokens.get(index + 1).and_then(|name| { + matches!(name.text.as_str(), "access" | "field_access") + .then_some(name.text.as_str()) + }); + } + if token.token == Token::RParen { + annotation = None; + } + let Some(annotation) = annotation else { + continue; + }; + if !matches!(token.text.as_str(), "read" | "write" | "delete") + || (annotation == "field_access" && token.text == "delete") + { + continue; + } + let Some(rest) = tokens.get(index + 1..index + 4) else { + continue; + }; + if rest[0].token == Token::Colon + && rest[1].token == Token::LBracket + && rest[2].token == Token::RBracket + { + warnings.push(EmptyAccessGrant { + annotation: annotation.into(), + direction: token.text.clone(), + span: Span::new(token.span.start, rest[2].span.end), + }); + } + } + warnings +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reports_only_explicit_empty_access_directions() { + let source = r#" + // @access(read: []) + @access(read: ["staff"], write: [], cross_tenant_read: []) + schema Setting { + value: text @field_access(read: [], write: ["admin"]) + comment: text default("@access(delete: [])") + } + "#; + let warnings = empty_access_grants(source); + assert_eq!(warnings.len(), 2); + assert_eq!(warnings[0].direction, "write"); + assert_eq!(warnings[1].annotation, "field_access"); + assert_eq!( + &source[warnings[0].span.start..warnings[0].span.end], + "write: []" + ); + } +} diff --git a/crates/schema-forge-dsl/tests/migration_hints.rs b/crates/schema-forge-dsl/tests/migration_hints.rs new file mode 100644 index 00000000..13666948 --- /dev/null +++ b/crates/schema-forge-dsl/tests/migration_hints.rs @@ -0,0 +1,58 @@ +use schema_forge_core::migration::{DiffEngine, MigrationStep}; + +fn schema(source: &str) -> schema_forge_core::types::SchemaDefinition { + schema_forge_dsl::parse(source).unwrap().remove(0) +} + +#[test] +fn declared_rename_preserves_data_plan_and_becomes_noop() { + let old = schema("schema Line { number: text required unique }"); + let new = + schema(r#"schema Line { business_number: text required unique @renamed_from("number") }"#); + DiffEngine::validate_transition(&old, &new).unwrap(); + let plan = DiffEngine::plan_update(&old, &new).unwrap(); + assert!(matches!( + plan.steps.as_slice(), + [MigrationStep::RenameField { .. }] + )); + DiffEngine::validate_transition(&new, &new).unwrap(); + assert!(DiffEngine::diff(&new, &new).is_empty()); + let printed = schema_forge_dsl::print_all(std::slice::from_ref(&new)); + assert_eq!(schema(&printed).fields, new.fields); +} + +#[test] +fn invalid_rename_hints_fail_before_migration() { + let old = schema("schema Line { number: text other: text }"); + for source in [ + r#"schema Line { business_number: text @renamed_from("missing") }"#, + r#"schema Line { number: text @renamed_from("number") }"#, + r#"schema Line { number: text other: text @renamed_from("number") }"#, + r#"schema Line { first: text @renamed_from("number") second: text @renamed_from("number") }"#, + ] { + assert!( + DiffEngine::plan_update(&old, &schema(source)).is_err(), + "{source}" + ); + } +} + +#[test] +fn all_tenant_transitions_require_explicit_manual_migration() { + let annotations = [ + "", + "@tenant(root)", + r#"@tenant(parent: "Org")"#, + r#"@tenant(parent: "Other")"#, + ]; + for old in annotations { + for new in annotations { + let old_schema = schema(&format!("{old} schema Contact {{ phone: text unique }}")); + let new_schema = schema(&format!("{new} schema Contact {{ phone: text unique }}")); + assert_eq!( + DiffEngine::plan_update(&old_schema, &new_schema).is_ok(), + old == new + ); + } + } +} diff --git a/crates/schema-forge-mssql/Cargo.toml b/crates/schema-forge-mssql/Cargo.toml index 8b62ebd3..4ee2252a 100644 --- a/crates/schema-forge-mssql/Cargo.toml +++ b/crates/schema-forge-mssql/Cargo.toml @@ -1,14 +1,14 @@ [package] name = "schema-forge-mssql" -version = "0.4.0" +version = "0.5.0" edition = "2021" [dependencies] acton-service = { version = "0.43.1", features = ["mssql", "crypto-aws-lc-rs"] } bb8 = "0.9.1" bb8-tiberius = "0.16.0" -schema-forge-backend = { version = "0.17.0", path = "../schema-forge-backend" } -schema-forge-core = { version = "0.17.0", path = "../schema-forge-core" } +schema-forge-backend = { version = "0.18.0", path = "../schema-forge-backend" } +schema-forge-core = { version = "0.18.0", path = "../schema-forge-core" } serde_json = "1.0.151" tiberius = "0.12.3" diff --git a/crates/schema-forge-mssql/src/backend.rs b/crates/schema-forge-mssql/src/backend.rs index 21fdb43c..2be7fcba 100644 --- a/crates/schema-forge-mssql/src/backend.rs +++ b/crates/schema-forge-mssql/src/backend.rs @@ -67,8 +67,50 @@ impl MssqlBackend { } } -impl SchemaBackend for MssqlBackend { - async fn apply_migration( +/// Compile one atomic batch while keeping arbitrary variant text in bound parameters. +fn migration_statements( + schema_name: &SchemaName, + steps: &[MigrationStep], +) -> (String, Vec) { + let table = quote(schema_name.as_str()); + let mut statements = Vec::new(); + let mut parameters = Vec::new(); + for step in steps { + match step { + MigrationStep::RenameField { old_name, new_name } => { + let old_parameter = parameters.len() + 1; + let new_parameter = old_parameter + 1; + parameters.extend([format!("$.\"{old_name}\""), format!("$.\"{new_name}\"")]); + statements.push(format!(r#"IF EXISTS (SELECT 1 FROM [dbo].{table} WITH (UPDLOCK, HOLDLOCK) + WHERE JSON_QUERY([data], @P{old_parameter}) IS NOT NULL AND JSON_QUERY([data], @P{new_parameter}) IS NOT NULL) + THROW 50001, 'rename destination already contains data', 1; + UPDATE [dbo].{table} + SET [data] = JSON_MODIFY(JSON_MODIFY([data], @P{new_parameter}, JSON_QUERY([data], @P{old_parameter})), @P{old_parameter}, NULL) + WHERE JSON_QUERY([data], @P{old_parameter}) IS NOT NULL;"#)); + } + MigrationStep::ChangeType { name, transform: ValueTransform::NullRemovedEnumVariants { variants }, .. } => { + for variant in variants { + let path_parameter = parameters.len() + 1; + let value_parameter = path_parameter + 1; + let variant_parameter = path_parameter + 2; + parameters.extend([format!("$.\"{name}\""), format!("$.\"{name}\".value"), variant.clone()]); + statements.push(format!("UPDATE [dbo].{table} SET [data] = JSON_MODIFY([data], @P{path_parameter}, JSON_QUERY(N'{{\"type\":\"Null\"}}')) WHERE JSON_VALUE([data], @P{value_parameter}) COLLATE Latin1_General_100_BIN2 = @P{variant_parameter};")); + } + } + MigrationStep::CreateSchema { name, .. } => statements.push(format!("IF OBJECT_ID(N'[dbo].{table}', N'U') IS NULL CREATE TABLE [dbo].{table} ([id] NVARCHAR(255) NOT NULL PRIMARY KEY, [data] NVARCHAR(MAX) NOT NULL CHECK (ISJSON([data]) = 1));", table = quote(name.as_str()))), + MigrationStep::DropSchema { name } => statements.push(format!("IF OBJECT_ID(N'[dbo].{table}', N'U') IS NOT NULL DROP TABLE [dbo].{table};", table = quote(name.as_str()))), + _ => {} + } + } + (statements.join("\n"), parameters) +} + +fn transaction_batch(statements: &str) -> String { + format!("SET XACT_ABORT ON; BEGIN TRY BEGIN TRANSACTION; {statements} COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; THROW; END CATCH;") +} + +impl MssqlBackend { + async fn validate_migration( &self, schema_name: &SchemaName, steps: &[MigrationStep], @@ -100,55 +142,84 @@ impl SchemaBackend for MssqlBackend { } } } - let mut connection = connection(&self.pool).await?; - for step in steps { - if let MigrationStep::ChangeType { - name, - transform: ValueTransform::NullRemovedEnumVariants { variants }, - .. - } = step - { - // JSON payloads store tagged DynamicValue objects. Bind both the - // path and variant so enum strings cannot become SQL syntax. - let path = format!("$.\"{}\"", name.as_str()); - let value_path = format!("{path}.value"); - let sql = format!("UPDATE [dbo].{} SET [data] = JSON_MODIFY([data], @P1, JSON_QUERY(N'{{\"type\":\"Null\"}}')) WHERE JSON_VALUE([data], @P2) COLLATE Latin1_General_100_BIN2 = @P3;", quote(schema_name.as_str())); - for variant in variants { - connection - .execute( - &sql, - &[&path.as_str(), &value_path.as_str(), &variant.as_str()], - ) - .await - .map_err(query_error)?; - } - continue; + Ok(()) + } +} + +impl SchemaBackend for MssqlBackend { + async fn apply_schema_change( + &self, + name: &SchemaName, + steps: &[MigrationStep], + definition: Option<&SchemaDefinition>, + ) -> Result<(), BackendError> { + if let Some(definition) = definition { + if &definition.name != name { + return Err(BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: "schema name does not match metadata".into(), + }); } - let sql = match step { - MigrationStep::CreateSchema { name, .. } => Some(format!( - "IF OBJECT_ID(N'[dbo].{}', N'U') IS NULL CREATE TABLE [dbo].{} \ - ([id] NVARCHAR(255) NOT NULL PRIMARY KEY, \ - [data] NVARCHAR(MAX) NOT NULL CHECK (ISJSON([data]) = 1));", - quote(name.as_str()), - quote(name.as_str()) - )), - MigrationStep::DropSchema { name } => Some(format!( - "IF OBJECT_ID(N'[dbo].{}', N'U') IS NOT NULL DROP TABLE [dbo].{};", - quote(name.as_str()), - quote(name.as_str()) - )), - _ => None, - }; - if let Some(sql) = sql { - connection.execute(sql, &[]).await.map_err(|error| { - BackendError::MigrationFailed { - step: step.to_string(), - reason: error.to_string(), - } + if let Some(existing) = self.load_schema_metadata(name).await? { + schema_forge_core::migration::DiffEngine::validate_transition( + &existing, definition, + ) + .map_err(|error| BackendError::MigrationFailed { + step: "validate schema transition".into(), + reason: error.to_string(), })?; } } - let _ = schema_name; + self.validate_migration(name, steps).await?; + let (mut sql, mut parameters) = migration_statements(name, steps); + let name_parameter = parameters.len() + 1; + parameters.push(name.to_string()); + if let Some(definition) = definition { + let definition_parameter = parameters.len() + 1; + parameters.push(serde_json::to_string(definition).map_err(json_error)?); + sql.push_str(&format!(" MERGE [dbo].[{METADATA}] AS target USING (SELECT @P{name_parameter} AS [name], @P{definition_parameter} AS [definition]) source ON target.[name] = source.[name] WHEN MATCHED THEN UPDATE SET [definition] = source.[definition] WHEN NOT MATCHED THEN INSERT ([name], [definition]) VALUES (source.[name], source.[definition]);")); + } else { + sql.push_str(&format!( + " DELETE FROM [dbo].[{METADATA}] WHERE [name] = @P{name_parameter};" + )); + } + let bindings: Vec<&dyn tiberius::ToSql> = parameters + .iter() + .map(|value| value as &dyn tiberius::ToSql) + .collect(); + let mut connection = connection(&self.pool).await?; + connection + .execute(transaction_batch(&sql), &bindings) + .await + .map_err(|error| BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: error.to_string(), + })?; + Ok(()) + } + + async fn apply_migration( + &self, + schema_name: &SchemaName, + steps: &[MigrationStep], + ) -> Result<(), BackendError> { + self.validate_migration(schema_name, steps).await?; + let (sql, parameters) = migration_statements(schema_name, steps); + if sql.is_empty() { + return Ok(()); + } + let bindings: Vec<&dyn tiberius::ToSql> = parameters + .iter() + .map(|value| value as &dyn tiberius::ToSql) + .collect(); + let mut connection = connection(&self.pool).await?; + connection + .execute(transaction_batch(&sql), &bindings) + .await + .map_err(|error| BackendError::MigrationFailed { + step: "apply migration transaction".into(), + reason: error.to_string(), + })?; Ok(()) } @@ -156,6 +227,13 @@ impl SchemaBackend for MssqlBackend { &self, definition: &SchemaDefinition, ) -> Result<(), BackendError> { + if let Some(existing) = self.load_schema_metadata(&definition.name).await? { + schema_forge_core::migration::DiffEngine::validate_transition(&existing, definition) + .map_err(|error| BackendError::MigrationFailed { + step: "validate schema transition".into(), + reason: error.to_string(), + })?; + } let json = serde_json::to_string(definition).map_err(json_error)?; let sql = format!( "MERGE [dbo].[{METADATA}] AS target \ @@ -241,24 +319,43 @@ impl EntityStore for MssqlBackend { } async fn update(&self, entity: &Entity) -> Result { - let data = serde_json::to_string(&entity.fields).map_err(json_error)?; + if entity.fields.is_empty() { + return self.get(&entity.schema, &entity.id).await; + } + let mut parameters = vec![entity.id.to_string()]; + let mut expression = "[data]".to_string(); + for (field, value) in &entity.fields { + let path_parameter = parameters.len() + 1; + let value_parameter = path_parameter + 1; + parameters.push(format!("$.\"{field}\"")); + parameters.push(serde_json::to_string(value).map_err(json_error)?); + expression = format!( + "JSON_MODIFY({expression}, @P{path_parameter}, JSON_QUERY(@P{value_parameter}))" + ); + } let sql = format!( - "UPDATE {} SET [data] = @P2 WHERE [id] = @P1;", + "UPDATE {} SET [data] = {expression} OUTPUT INSERTED.[id], INSERTED.[data] WHERE [id] = @P1;", quote(entity.schema.as_str()) ); + let bindings: Vec<&dyn tiberius::ToSql> = parameters + .iter() + .map(|value| value as &dyn tiberius::ToSql) + .collect(); let mut connection = connection(&self.pool).await?; - let affected = connection - .execute(sql, &[&entity.id.as_str(), &data.as_str()]) + let rows = connection + .query(sql, &bindings) .await .map_err(query_error)? - .total(); - if affected == 0 { - return Err(BackendError::EntityNotFound { + .into_first_result() + .await + .map_err(query_error)?; + rows.first() + .map(|row| entity_from_row(row, &entity.schema)) + .transpose()? + .ok_or_else(|| BackendError::EntityNotFound { schema: entity.schema.to_string(), entity_id: entity.id.to_string(), - }); - } - Ok(entity.clone()) + }) } async fn update_field_if_matches( diff --git a/crates/schema-forge-mssql/tests/sql_server.rs b/crates/schema-forge-mssql/tests/sql_server.rs index d59c578c..3d7922be 100644 --- a/crates/schema-forge-mssql/tests/sql_server.rs +++ b/crates/schema-forge-mssql/tests/sql_server.rs @@ -1,3 +1,6 @@ +#[path = "../../schema-forge-backend/tests/support/migration_renames.rs"] +mod migration_renames; + #[path = "../../schema-forge-backend/tests/support/data_correctness.rs"] mod data_correctness; @@ -59,8 +62,61 @@ async fn connects_and_initializes_metadata(image_tag: &str) { .expect("list initialized metadata") .is_empty()); + sparse_updates_preserve_other_fields_and_explicit_null(&backend).await; + atomic_metadata_failure_rolls_back_rename(&backend).await; exercises_backend_contract(&backend).await; data_correctness::exercise(&backend).await; + migration_renames::exercise(&backend).await; + rename_collision_rolls_back_entire_plan(&backend).await; +} + +async fn rename_collision_rolls_back_entire_plan(backend: &MssqlBackend) { + let schema = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("RenameRollback").unwrap(), + ["first", "second"] + .map(|name| { + FieldDefinition::new( + FieldName::new(name).unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + ) + }) + .to_vec(), + vec![], + ) + .unwrap(); + backend + .apply_migration(&schema.name, &DiffEngine::create_new(&schema).steps) + .await + .unwrap(); + backend.store_schema_metadata(&schema).await.unwrap(); + let row = backend + .create(&Entity::new( + schema.name.clone(), + BTreeMap::from([ + ("first".into(), DynamicValue::Text("keep".into())), + ("second".into(), DynamicValue::Text("other".into())), + ("destination".into(), DynamicValue::Text("occupied".into())), + ]), + )) + .await + .unwrap(); + let steps = [ + MigrationStep::RenameField { + old_name: FieldName::new("first").unwrap(), + new_name: FieldName::new("third").unwrap(), + }, + MigrationStep::RenameField { + old_name: FieldName::new("second").unwrap(), + new_name: FieldName::new("destination").unwrap(), + }, + ]; + assert!(backend.apply_migration(&schema.name, &steps).await.is_err()); + assert_eq!(backend.get(&schema.name, &row.id).await.unwrap(), row); + assert_eq!( + backend.load_schema_metadata(&schema.name).await.unwrap(), + Some(schema) + ); } async fn exercises_backend_contract(backend: &MssqlBackend) { @@ -212,3 +268,133 @@ fn product(schema: &SchemaName, name: &str, price: i64, active: bool) -> Entity ]), ) } + +async fn atomic_metadata_failure_rolls_back_rename(backend: &MssqlBackend) { + let original = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("AtomicSchema").unwrap(), + vec![FieldDefinition::new( + FieldName::new("label").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + )], + vec![], + ) + .unwrap(); + backend + .apply_schema_change( + &original.name, + &DiffEngine::create_new(&original).steps, + Some(&original), + ) + .await + .unwrap(); + let row = Entity { + id: schema_forge_core::types::EntityId::new("atomic"), + schema: original.name.clone(), + fields: BTreeMap::from([("label".into(), DynamicValue::Text("retained".into()))]), + }; + backend.create(&row).await.unwrap(); + let mut proposed = original.clone(); + proposed.fields[0].name = FieldName::new("replacement").unwrap(); + proposed.fields[0] + .annotations + .push(schema_forge_core::types::FieldAnnotation::RenamedFrom { + name: FieldName::new("label").unwrap(), + }); + { + let mut connection = backend.pool().get().await.unwrap(); + connection.simple_query("CREATE TRIGGER [reject_atomic_metadata] ON [dbo].[_schema_metadata] AFTER UPDATE AS BEGIN THROW 50001, 'test metadata failure', 1; END;").await.unwrap().into_results().await.unwrap(); + } + let plan = DiffEngine::plan_update(&original, &proposed).unwrap(); + assert!(backend + .apply_schema_change(&original.name, &plan.steps, Some(&proposed)) + .await + .is_err()); + assert_eq!( + backend.load_schema_metadata(&original.name).await.unwrap(), + Some(original.clone()) + ); + assert_eq!(backend.get(&original.name, &row.id).await.unwrap(), row); + { + let mut connection = backend.pool().get().await.unwrap(); + connection + .simple_query("DROP TRIGGER [reject_atomic_metadata]") + .await + .unwrap() + .into_results() + .await + .unwrap(); + } + backend + .apply_schema_change(&original.name, &plan.steps, Some(&proposed)) + .await + .unwrap(); + assert_eq!( + backend.load_schema_metadata(&original.name).await.unwrap(), + Some(proposed) + ); +} + +async fn sparse_updates_preserve_other_fields_and_explicit_null(backend: &MssqlBackend) { + let schema = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("SparseUpdate").unwrap(), + ["first", "second", "nullable"] + .into_iter() + .map(|name| { + FieldDefinition::new( + FieldName::new(name).unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + ) + }) + .collect(), + vec![], + ) + .unwrap(); + backend + .apply_schema_change( + &schema.name, + &DiffEngine::create_new(&schema).steps, + Some(&schema), + ) + .await + .unwrap(); + let initial = Entity::new( + schema.name.clone(), + BTreeMap::from([ + ("first".into(), DynamicValue::Text("before".into())), + ("second".into(), DynamicValue::Text("before".into())), + ("nullable".into(), DynamicValue::Text("clear me".into())), + ]), + ); + backend.create(&initial).await.unwrap(); + let first = Entity::with_id( + initial.id.clone(), + schema.name.clone(), + BTreeMap::from([("first".into(), DynamicValue::Text("first changed".into()))]), + ); + let second = Entity::with_id( + initial.id.clone(), + schema.name.clone(), + BTreeMap::from([("second".into(), DynamicValue::Text("second changed".into()))]), + ); + let (first_result, second_result) = + tokio::join!(backend.update(&first), backend.update(&second)); + assert_eq!(first_result.unwrap().fields.len(), 3); + assert_eq!(second_result.unwrap().fields.len(), 3); + let null_update = Entity::with_id( + initial.id.clone(), + schema.name.clone(), + BTreeMap::from([("nullable".into(), DynamicValue::Null)]), + ); + let updated = backend.update(&null_update).await.unwrap(); + assert_eq!(updated.fields.get("first"), first.fields.get("first")); + assert_eq!(updated.fields.get("second"), second.fields.get("second")); + assert_eq!(updated.fields.get("nullable"), Some(&DynamicValue::Null)); + assert_eq!( + backend.get(&schema.name, &initial.id).await.unwrap(), + updated + ); + let empty = Entity::with_id(initial.id, schema.name, BTreeMap::new()); + assert_eq!(backend.update(&empty).await.unwrap(), updated); +} diff --git a/crates/schema-forge-postgres/Cargo.toml b/crates/schema-forge-postgres/Cargo.toml index 820b95b5..0ff24227 100644 --- a/crates/schema-forge-postgres/Cargo.toml +++ b/crates/schema-forge-postgres/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-postgres" -version = "0.12.2" +version = "0.13.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-postgres/src/backend.rs b/crates/schema-forge-postgres/src/backend.rs index bce6c64d..1293fe32 100644 --- a/crates/schema-forge-postgres/src/backend.rs +++ b/crates/schema-forge-postgres/src/backend.rs @@ -38,6 +38,12 @@ const PG_UNIQUE_VIOLATION: &str = "23505"; /// client still has *something* actionable to display. pub(crate) fn map_write_error(err: sqlx::Error, schema: &str, context: &str) -> BackendError { if let sqlx::Error::Database(ref db_err) = err { + if matches!(db_err.code().as_deref(), Some("23503" | "23001")) { + return BackendError::ForeignKeyViolation { + schema: db_err.table().unwrap_or(schema).to_owned(), + constraint: db_err.constraint().unwrap_or("").to_owned(), + }; + } if db_err.code().as_deref() == Some(PG_UNIQUE_VIOLATION) { let constraint = db_err.constraint().unwrap_or(""); let field = extract_field_from_unique_constraint(constraint, schema) @@ -160,6 +166,83 @@ impl PgBackend { } } + /// Repair legacy missing relation constraints and finish deferred table creation. + /// All available tables are checked together so cycles and file order are harmless. + async fn reconcile_relation_constraints( + &self, + incoming: Option<&SchemaDefinition>, + ) -> Result<(), BackendError> { + let finalizing = incoming.is_none(); + let mut definitions = self.fetch_schema_metadata_from_db().await?; + if let Some(incoming) = incoming { + definitions.retain(|definition| definition.name != incoming.name); + definitions.push(incoming.clone()); + } + let mut tx = self + .pool + .begin() + .await + .map_err(|error| BackendError::ConnectionError { + message: error.to_string(), + })?; + Self::reconcile_constraints_on(&mut tx, &definitions, finalizing).await?; + tx.commit() + .await + .map_err(|error| BackendError::MigrationFailed { + step: "commit relation constraints".into(), + reason: error.to_string(), + }) + } + + async fn reconcile_constraints_on( + connection: &mut sqlx::PgConnection, + definitions: &[SchemaDefinition], + finalizing: bool, + ) -> Result<(), BackendError> { + for definition in definitions { + for field in &definition.fields { + if let FieldType::Relation { + target, + cardinality: schema_forge_core::types::Cardinality::One, + } = &field.field_type + { + if field.is_derived() { + continue; + } + let table = definition.name.as_str(); + let column = field.name.as_str(); + // Names are validated SchemaName/FieldName values, never raw SQL input. + // Missing targets defer installation until that table's metadata is stored. + let statement = format!( + r#"DO $forge_fk$ + BEGIN + IF {finalizing} AND to_regclass('"{target}"') IS NULL THEN + RAISE EXCEPTION 'relation target {target} for {table}.{column} does not exist'; + END IF; + IF to_regclass('"{table}"') IS NOT NULL AND to_regclass('"{target}"') IS NOT NULL + AND EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = to_regclass('"{table}"') AND attname = '{column}' AND NOT attisdropped) + AND NOT EXISTS ( + SELECT 1 FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid + JOIN pg_attribute referenced ON referenced.attrelid = c.confrelid + WHERE c.contype = 'f' AND c.conrelid = to_regclass('"{table}"') + AND c.confrelid = to_regclass('"{target}"') AND a.attname = '{column}' + AND c.conkey = ARRAY[a.attnum] AND referenced.attname = 'id' + AND c.confkey = ARRAY[referenced.attnum] + ) THEN + ALTER TABLE "{table}" ADD FOREIGN KEY ("{column}") REFERENCES "{target}"("id") ON DELETE RESTRICT; + END IF; + END $forge_fk$;"# + ); + sqlx::query(&statement).execute(&mut *connection).await.map_err(|error| BackendError::MigrationFailed { + step: format!("ensure relation constraint {table}.{column} -> {target}"), + reason: format!("{error}; repair orphaned relation values before retrying the migration"), + })?; + } + } + } + Ok(()) + } + /// Drop the cached schema metadata so the next read refetches from /// the database. Called from every mutation path that changes the /// `_schema_metadata` table. @@ -253,6 +336,12 @@ impl PgBackend { entity: &Entity, schema_def: Option<&SchemaDefinition>, ) -> Result<(String, PgArguments), BackendError> { + if entity.fields.is_empty() { + return Err(BackendError::ValidationFailed { + field: "fields".into(), + reason: "update must contain at least one writable field".into(), + }); + } let table = entity.schema.as_str(); let mut args = PgArguments::default(); @@ -316,10 +405,23 @@ impl PgBackend { } async fn fetch_schema_metadata_from_db(&self) -> Result, BackendError> { + let mut connection = + self.pool + .acquire() + .await + .map_err(|e| BackendError::ConnectionError { + message: e.to_string(), + })?; + Self::fetch_metadata_on(&mut connection).await + } + + async fn fetch_metadata_on( + connection: &mut sqlx::PgConnection, + ) -> Result, BackendError> { let rows: Vec = sqlx::query(&format!( "SELECT \"definition\" FROM \"{SCHEMA_META_TABLE}\";" )) - .fetch_all(&self.pool) + .fetch_all(&mut *connection) .await .map_err(|e| BackendError::QueryError { message: format!("failed to list schema metadata: {e}"), @@ -371,6 +473,21 @@ impl PgBackend { async fn repair_float_columns( &self, definition: &SchemaDefinition, + ) -> Result<(), BackendError> { + let mut tx = self + .pool + .begin() + .await + .map_err(|e| map_write_error(e, definition.name.as_str(), "begin float repair"))?; + Self::repair_float_columns_on(&mut tx, definition).await?; + tx.commit() + .await + .map_err(|e| map_write_error(e, definition.name.as_str(), "commit float repair")) + } + + async fn repair_float_columns_on( + connection: &mut sqlx::PgConnection, + definition: &SchemaDefinition, ) -> Result<(), BackendError> { let float_columns: Vec<&str> = definition .fields @@ -392,7 +509,7 @@ impl PgBackend { WHERE table_schema = current_schema() AND table_name = $1;", ) .bind(table) - .fetch_all(&self.pool) + .fetch_all(&mut *connection) .await .map_err(|e| BackendError::QueryError { message: format!("failed to introspect columns for '{table}': {e}"), @@ -423,21 +540,14 @@ impl PgBackend { let alter = format!( "ALTER TABLE \"{table}\" ALTER COLUMN \"{col}\" TYPE DOUBLE PRECISION USING \"{col}\"::double precision;" ); - let mut tx = self - .pool - .begin() + sqlx::query(&alter) + .execute(&mut *connection) .await - .map_err(|e| map_write_error(e, table, "begin float repair"))?; - sqlx::query(&alter).execute(&mut *tx).await.map_err(|e| { - BackendError::MigrationFailed { + .map_err(|e| BackendError::MigrationFailed { step: format!("repair_float_column({table}.{col})"), reason: e.to_string(), - } - })?; - Self::invalidate_schema_revisions(&mut tx, &definition.name).await?; - tx.commit() - .await - .map_err(|e| map_write_error(e, table, "commit float repair"))?; + })?; + Self::invalidate_schema_revisions(connection, &definition.name).await?; } Ok(()) @@ -453,6 +563,80 @@ impl PgBackend { } impl SchemaBackend for PgBackend { + async fn apply_schema_change( + &self, + name: &SchemaName, + steps: &[MigrationStep], + definition: Option<&SchemaDefinition>, + ) -> Result<(), BackendError> { + let fail = |error: sqlx::Error| BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: error.to_string(), + }; + let mut tx = self.pool.begin().await.map_err(fail)?; + // Serialize administrative metadata changes while retaining transactional DDL visibility. + sqlx::query(&format!( + "LOCK TABLE \"{SCHEMA_META_TABLE}\" IN SHARE ROW EXCLUSIVE MODE" + )) + .execute(&mut *tx) + .await + .map_err(fail)?; + let mut definitions = Self::fetch_metadata_on(&mut tx).await?; + if let Some(definition) = definition { + if &definition.name != name { + return Err(BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: "schema name does not match metadata".into(), + }); + } + if let Some(existing) = definitions.iter().find(|schema| &schema.name == name) { + schema_forge_core::migration::DiffEngine::validate_transition(existing, definition) + .map_err(|error| BackendError::MigrationFailed { + step: "validate schema transition".into(), + reason: error.to_string(), + })?; + } + } + for step in steps { + for statement in migration_step_to_sql(name.as_str(), step) { + sqlx::query(&statement) + .execute(&mut *tx) + .await + .map_err(fail)?; + } + } + if !steps.is_empty() { + Self::invalidate_schema_revisions(&mut tx, name).await?; + } + definitions.retain(|schema| &schema.name != name); + if let Some(definition) = definition { + Self::repair_float_columns_on(&mut tx, definition).await?; + let json = + serde_json::to_value(definition).map_err(|error| BackendError::Internal { + message: error.to_string(), + })?; + sqlx::query(&format!("INSERT INTO \"{SCHEMA_META_TABLE}\" (name, definition) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET definition = $2")) + .bind(name.as_str()).bind(json).execute(&mut *tx).await.map_err(fail)?; + definitions.push(definition.clone()); + } else { + sqlx::query(&format!( + "DELETE FROM \"{SCHEMA_META_TABLE}\" WHERE name = $1" + )) + .bind(name.as_str()) + .execute(&mut *tx) + .await + .map_err(fail)?; + } + Self::reconcile_constraints_on(&mut tx, &definitions, true).await?; + tx.commit().await.map_err(fail)?; + self.invalidate_schema_cache(); + Ok(()) + } + + async fn finalize_schema_migrations(&self) -> Result<(), BackendError> { + self.reconcile_relation_constraints(None).await + } + fn supports_record_revisions(&self) -> bool { true } @@ -519,7 +703,16 @@ impl SchemaBackend for PgBackend { // float value. Detect and silently re-type any such columns to // DOUBLE PRECISION whenever the schema is (re)stored — this is // the canonical "schema is authoritative" checkpoint. See GH #37. + if let Some(existing) = self.load_schema_metadata(&definition.name).await? { + schema_forge_core::migration::DiffEngine::validate_transition(&existing, definition) + .map_err(|error| BackendError::MigrationFailed { + step: "validate schema transition".into(), + reason: error.to_string(), + })?; + } self.repair_float_columns(definition).await?; + self.reconcile_relation_constraints(Some(definition)) + .await?; let json = serde_json::to_value(definition).map_err(|e| BackendError::Internal { message: format!("failed to serialize schema metadata: {e}"), diff --git a/crates/schema-forge-postgres/src/cedar_read.rs b/crates/schema-forge-postgres/src/cedar_read.rs index 579dfc0b..43b459c5 100644 --- a/crates/schema-forge-postgres/src/cedar_read.rs +++ b/crates/schema-forge-postgres/src/cedar_read.rs @@ -114,7 +114,7 @@ pub(crate) async fn query_compatible( } return Ok(None); } - let scoped = scoped_query(query, scope, proof.has_tenant); + let scoped = scoped_query(query, scope, proof.has_tenant, expected); let total = if scoped.include_total { let count = count_to_sql(&scoped, expected.name.as_str()); let total: i64 = sqlx::query_scalar_with(&count.sql, arguments(&count.params)?) @@ -255,7 +255,8 @@ fn certify_columns( ))); } } - if !names.contains("id") + if (schema.is_tenanted() && !proof.has_tenant) + || !names.contains("id") || schema .fields .iter() @@ -290,18 +291,38 @@ fn supported_type(field: &FieldType) -> Option<&'static str> { } } -fn scoped_query(query: &Query, scope: &CedarReadScope, has_tenant: bool) -> Query { +fn scoped_query( + query: &Query, + scope: &CedarReadScope, + has_tenant: bool, + schema: &SchemaDefinition, +) -> Query { let mut query = query.clone(); if let CedarReadScope::TenantMembers(members) = scope { if has_tenant { - let tenant = Filter::Or { - filters: vec![ - Filter::eq(FieldPath::single("_tenant"), DynamicValue::Null), - Filter::in_set( - FieldPath::single("_tenant"), - members.iter().cloned().map(DynamicValue::Text).collect(), - ), - ], + let root = schema.annotations.iter().any(|annotation| { + matches!( + annotation, + schema_forge_core::types::Annotation::Tenant( + schema_forge_core::types::TenantKind::Root + ) + ) + }); + let tenant = Filter::in_set( + FieldPath::single(if root { "id" } else { "_tenant" }), + members.iter().cloned().map(DynamicValue::Text).collect(), + ); + let tenant = if schema.is_tenanted() { + tenant + } else { + // Legacy unannotated resources may carry tenant metadata. Their + // generated guard permits missing metadata, so retain exact parity. + Filter::Or { + filters: vec![ + Filter::eq(FieldPath::single("_tenant"), DynamicValue::Null), + tenant, + ], + } }; query.filter = Some(match query.filter.take() { Some(filter) => Filter::And { diff --git a/crates/schema-forge-postgres/src/codegen.rs b/crates/schema-forge-postgres/src/codegen.rs index 4aa349ec..e4402563 100644 --- a/crates/schema-forge-postgres/src/codegen.rs +++ b/crates/schema-forge-postgres/src/codegen.rs @@ -97,9 +97,24 @@ pub fn migration_step_to_sql(table: &str, step: &MigrationStep) -> Vec { )] } MigrationStep::RenameField { old_name, new_name } => { - vec![format!( - "ALTER TABLE \"{table}\" RENAME COLUMN \"{old_name}\" TO \"{new_name}\";" - )] + let mut statements = vec![ + format!("ALTER TABLE \"{table}\" RENAME COLUMN \"{old_name}\" TO \"{new_name}\";"), + format!("ALTER INDEX IF EXISTS \"uq_{table}_{old_name}\" RENAME TO \"uq_{table}_{new_name}\";"), + format!("ALTER INDEX IF EXISTS \"idx_{table}_{old_name}\" RENAME TO \"idx_{table}_{new_name}\";"), + ]; + for suffix in ["enum", "range", "size", "file"] { + statements.push(rename_constraint_sql( + table, + &format!("chk_{table}_{old_name}_{suffix}"), + &format!("chk_{table}_{new_name}_{suffix}"), + )); + } + statements.push(rename_constraint_sql( + table, + &format!("{table}_{old_name}_fkey"), + &format!("{table}_{new_name}_fkey"), + )); + statements } MigrationStep::ChangeType { name, @@ -162,12 +177,12 @@ pub fn migration_step_to_sql(table: &str, step: &MigrationStep) -> Vec { } MigrationStep::AddRelation { name, - target, + target: _, cardinality, } => match cardinality { Cardinality::One => { vec![format!( - "ALTER TABLE \"{table}\" ADD COLUMN IF NOT EXISTS \"{name}\" TEXT REFERENCES \"{target}\"(\"id\");" + "ALTER TABLE \"{table}\" ADD COLUMN IF NOT EXISTS \"{name}\" TEXT;" )] } Cardinality::Many => { @@ -177,7 +192,7 @@ pub fn migration_step_to_sql(table: &str, step: &MigrationStep) -> Vec { } _ => { vec![format!( - "ALTER TABLE \"{table}\" ADD COLUMN IF NOT EXISTS \"{name}\" TEXT REFERENCES \"{target}\"(\"id\");" + "ALTER TABLE \"{table}\" ADD COLUMN IF NOT EXISTS \"{name}\" TEXT;" )] } }, @@ -445,6 +460,17 @@ pub fn tenant_ddl_statements(table: &str) -> Vec { ] } +/// Rename generated constraints only when the source object exists on this table. +fn rename_constraint_sql(table: &str, old_name: &str, new_name: &str) -> String { + format!( + r#"DO $forge_rename$ BEGIN + IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = to_regclass('"{table}"') AND conname = left('{old_name}', 63)) THEN + ALTER TABLE "{table}" RENAME CONSTRAINT "{old_name}" TO "{new_name}"; + END IF; + END $forge_rename$;"# + ) +} + /// Escape single quotes in strings for PostgreSQL string literals. fn escape_sql_string(s: &str) -> String { s.replace('\'', "''") @@ -641,7 +667,7 @@ mod tests { new_name: FieldName::new("full_name").unwrap(), }; let stmts = migration_step_to_sql("Contact", &step); - assert_eq!(stmts.len(), 1); + assert_eq!(stmts.len(), 8); assert_eq!( stmts[0], "ALTER TABLE \"Contact\" RENAME COLUMN \"name\" TO \"full_name\";" @@ -678,7 +704,8 @@ mod tests { }; let stmts = migration_step_to_sql("Contact", &step); assert_eq!(stmts.len(), 1); - assert!(stmts[0].contains("\"company\" TEXT REFERENCES \"Company\"(\"id\")")); + assert!(stmts[0].contains("\"company\" TEXT;")); + assert!(!stmts[0].contains("REFERENCES")); } #[test] diff --git a/crates/schema-forge-postgres/tests/cedar_read.rs b/crates/schema-forge-postgres/tests/cedar_read.rs index 485cff8b..2115b3ec 100644 --- a/crates/schema-forge-postgres/tests/cedar_read.rs +++ b/crates/schema-forge-postgres/tests/cedar_read.rs @@ -460,6 +460,50 @@ async fn required_and_hidden_json_cannot_change_tenant_visibility() { }).await; } +#[tokio::test] +#[ignore = "requires SCHEMAFORGE_TEST_POSTGRES_URL with CREATE SCHEMA privilege"] +async fn tenanted_children_exclude_null_and_roots_scope_by_identity() { + use schema_forge_core::types::{Annotation, TenantKind}; + for kind in [ + TenantKind::Root, + TenantKind::Child { + parent: SchemaName::new("Org").unwrap(), + }, + ] { + with_database(|backend| async move { + let mut schema = definition(); + schema.annotations.push(Annotation::Tenant(kind.clone())); + install(&backend, &schema).await; + let own = insert(&backend, &schema, "own").await; + let _foreign = insert(&backend, &schema, "foreign").await; + let _unstamped = insert(&backend, &schema, "unstamped").await; + let scope = CedarReadScope::TenantMembers(vec![own.id.to_string()]); + if matches!(kind, TenantKind::Child { .. }) { + sqlx::query("UPDATE \"CountProof\" SET _tenant = id WHERE id = $1") + .bind(own.id.as_str()) + .execute(backend.pool()) + .await + .unwrap(); + } + let result = backend + .query_cedar_compatible(&schema, &Query::new(schema.id.clone()), &scope) + .await + .unwrap() + .unwrap(); + assert_eq!(result.total_count, Some(1)); + assert_eq!(result.entities.len(), 1); + assert_eq!(result.entities[0].id, own.id); + execute(&backend, "ALTER TABLE \"CountProof\" DROP COLUMN _tenant").await; + assert!(backend + .query_cedar_compatible(&schema, &Query::new(schema.id.clone()), &scope) + .await + .unwrap() + .is_none()); + }) + .await; + } +} + async fn with_database(test: Test) where Test: FnOnce(Arc) -> Pending, diff --git a/crates/schema-forge-postgres/tests/migration_safety.rs b/crates/schema-forge-postgres/tests/migration_safety.rs new file mode 100644 index 00000000..cbea4708 --- /dev/null +++ b/crates/schema-forge-postgres/tests/migration_safety.rs @@ -0,0 +1,425 @@ +//! Live checks for migration parity, legacy repair, and relation conflicts. +use schema_forge_backend::{BackendError, Entity, EntityStore, SchemaBackend}; +use schema_forge_core::{ + migration::DiffEngine, + types::{ + Cardinality, DynamicValue, EntityId, FieldDefinition, FieldName, FieldType, + SchemaDefinition, SchemaId, SchemaName, TextConstraints, + }, +}; +use schema_forge_postgres::PgBackend; +use sqlx::postgres::PgPoolOptions; +use std::{collections::BTreeMap, sync::Arc}; + +fn definition(name: &str, relation: Option<(&str, &str)>) -> SchemaDefinition { + let mut fields = vec![FieldDefinition::new( + FieldName::new("label").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + )]; + if let Some((field, target)) = relation { + fields.push(FieldDefinition::new( + FieldName::new(field).unwrap(), + FieldType::Relation { + target: SchemaName::new(target).unwrap(), + cardinality: Cardinality::One, + }, + )); + } + SchemaDefinition::new( + SchemaId::new(), + SchemaName::new(name).unwrap(), + fields, + vec![], + ) + .unwrap() +} + +async fn apply(backend: &PgBackend, definition: &SchemaDefinition) { + let old = backend + .load_schema_metadata(&definition.name) + .await + .unwrap(); + let plan = old.as_ref().map_or_else( + || DiffEngine::create_new(definition), + |old| DiffEngine::diff(old, definition), + ); + backend + .apply_migration(&definition.name, &plan.steps) + .await + .unwrap(); + backend.store_schema_metadata(definition).await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires SCHEMAFORGE_TEST_POSTGRES_URL with CREATE SCHEMA privilege"] +async fn relations_have_consistent_integrity_and_legacy_constraints_are_repaired() { + let url = std::env::var("SCHEMAFORGE_TEST_POSTGRES_URL").unwrap(); + let admin = PgPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .unwrap(); + let namespace = EntityId::new("migrationtest").to_string(); + sqlx::query(&format!("CREATE SCHEMA \"{namespace}\"")) + .execute(&admin) + .await + .unwrap(); + let scope = namespace.clone(); + let pool = PgPoolOptions::new() + .max_connections(4) + .after_connect(move |connection, _| { + let scope = scope.clone(); + Box::pin(async move { + sqlx::query("SELECT set_config('search_path', $1, false)") + .bind(scope) + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect(&url) + .await + .unwrap(); + let backend = Arc::new(PgBackend::from_pool(pool.clone()).await.unwrap()); + let result = tokio::spawn(async move { exercise(&backend).await }).await; + pool.close().await; + sqlx::query(&format!("DROP SCHEMA \"{namespace}\" CASCADE")) + .execute(&admin) + .await + .unwrap(); + result.unwrap(); +} + +async fn exercise(backend: &PgBackend) { + atomic_schema_failure_preserves_data_and_metadata(backend).await; + // Pet precedes Owner, and both reference each other. + let mut pet = definition("Pet", Some(("owner", "Owner"))); + pet.fields[0] + .modifiers + .push(schema_forge_core::types::FieldModifier::Unique); + let owner = definition("Owner", Some(("pet", "Pet"))); + apply(backend, &pet).await; + assert!(matches!( + backend.finalize_schema_migrations().await, + Err(BackendError::MigrationFailed { .. }) + )); + apply(backend, &owner).await; + let owner_row = backend + .create(&Entity::new( + owner.name.clone(), + BTreeMap::from([("label".into(), DynamicValue::Text("owner".into()))]), + )) + .await + .unwrap(); + let pet_row = backend + .create(&Entity::new( + pet.name.clone(), + BTreeMap::from([ + ("owner".into(), DynamicValue::Ref(owner_row.id.clone())), + ("label".into(), DynamicValue::Text("kept".into())), + ]), + )) + .await + .unwrap(); + let deletion = backend.delete(&owner.name, &owner_row.id).await; + assert!( + matches!(deletion, Err(BackendError::ForeignKeyViolation { .. })), + "unexpected deletion outcome: {deletion:?}" + ); + let missing = Entity::new( + pet.name.clone(), + BTreeMap::from([("owner".into(), DynamicValue::Ref(EntityId::new("owner")))]), + ); + assert!(matches!( + backend.create(&missing).await, + Err(BackendError::ForeignKeyViolation { .. }) + )); + assert!(matches!( + backend + .update(&Entity::with_id( + pet_row.id.clone(), + pet.name.clone(), + BTreeMap::new() + )) + .await, + Err(BackendError::ValidationFailed { .. }) + )); + + // A later relation gets exactly the same integrity behavior. + let mut changed = pet.clone(); + changed.fields.push(FieldDefinition::new( + FieldName::new("vet").unwrap(), + FieldType::Relation { + target: owner.name.clone(), + cardinality: Cardinality::One, + }, + )); + apply(backend, &changed).await; + let patch = Entity::with_id( + pet_row.id.clone(), + pet.name.clone(), + BTreeMap::from([("vet".into(), DynamicValue::Ref(EntityId::new("owner")))]), + ); + assert!(matches!( + backend.update(&patch).await, + Err(BackendError::ForeignKeyViolation { .. }) + )); + + // Simulate legacy table without its original FK, then reconnect. + sqlx::query("ALTER TABLE \"Pet\" DROP CONSTRAINT \"Pet_owner_fkey\"") + .execute(backend.pool()) + .await + .unwrap(); + let repaired = PgBackend::from_pool(backend.pool().clone()).await.unwrap(); + repaired.finalize_schema_migrations().await.unwrap(); + assert!(matches!( + repaired.delete(&owner.name, &owner_row.id).await, + Err(BackendError::ForeignKeyViolation { .. }) + )); + // Reconciliation is idempotent. + apply(&repaired, &changed).await; + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_constraint WHERE conrelid='\"Pet\"'::regclass AND contype='f'", + ) + .fetch_one(backend.pool()) + .await + .unwrap(); + assert_eq!(count, 2); + + // Renaming a populated unique field preserves both its data and named constraints. + let mut renamed = changed.clone(); + renamed.fields[0].name = FieldName::new("title").unwrap(); + renamed.fields[0] + .annotations + .push(schema_forge_core::types::FieldAnnotation::RenamedFrom { + name: FieldName::new("label").unwrap(), + }); + apply(&repaired, &renamed).await; + let value: String = sqlx::query_scalar("SELECT title FROM \"Pet\" WHERE id = $1") + .bind(pet_row.id.as_str()) + .fetch_one(backend.pool()) + .await + .unwrap(); + assert_eq!(value, "kept"); + apply(&repaired, &renamed).await; + let mut without_unique = renamed.clone(); + without_unique.fields[0].modifiers.clear(); + apply(&repaired, &without_unique).await; + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_constraint WHERE conrelid='\"Pet\"'::regclass AND contype='u'", + ) + .fetch_one(backend.pool()) + .await + .unwrap(); + assert_eq!(count, 0); + + // A renamed enum must also rename its CHECK, so widening removes the old restriction. + let mut enum_schema = definition("EnumProbe", None); + enum_schema.fields[0].field_type = + FieldType::Enum(schema_forge_core::types::EnumVariants::new(vec!["old".into()]).unwrap()); + apply(backend, &enum_schema).await; + let enum_row = backend + .create(&Entity::new( + enum_schema.name.clone(), + BTreeMap::from([("label".into(), DynamicValue::Enum("old".into()))]), + )) + .await + .unwrap(); + let mut renamed_enum = enum_schema.clone(); + renamed_enum.fields[0].name = FieldName::new("status").unwrap(); + renamed_enum.fields[0].annotations.push( + schema_forge_core::types::FieldAnnotation::RenamedFrom { + name: FieldName::new("label").unwrap(), + }, + ); + apply(backend, &renamed_enum).await; + renamed_enum.fields[0].field_type = FieldType::Enum( + schema_forge_core::types::EnumVariants::new(vec!["old".into(), "new".into()]).unwrap(), + ); + apply(backend, &renamed_enum).await; + let updated = backend + .update(&Entity::with_id( + enum_row.id, + renamed_enum.name, + BTreeMap::from([("status".into(), DynamicValue::Enum("new".into()))]), + )) + .await + .unwrap(); + assert_eq!( + updated.fields.get("status"), + Some(&DynamicValue::Enum("new".into())) + ); + + exercise_manual_tenancy(backend).await; + + // Legacy orphan values must fail repair visibly rather than weakening integrity. + sqlx::query("ALTER TABLE \"Pet\" DROP CONSTRAINT \"Pet_owner_fkey\"") + .execute(backend.pool()) + .await + .unwrap(); + sqlx::query("UPDATE \"Pet\" SET owner = 'orphan'") + .execute(backend.pool()) + .await + .unwrap(); + assert!(matches!( + backend.finalize_schema_migrations().await, + Err(BackendError::MigrationFailed { .. }) + )); +} + +async fn exercise_manual_tenancy(backend: &PgBackend) { + use schema_forge_core::types::{Annotation, FieldModifier, TenantKind}; + let mut root = definition("TenantOrg", None); + root.annotations.push(Annotation::Tenant(TenantKind::Root)); + apply(backend, &root).await; + let tenant = backend + .create(&Entity::new( + root.name.clone(), + BTreeMap::from([("label".into(), DynamicValue::Text("first".into()))]), + )) + .await + .unwrap(); + let mut old = definition("TenantContact", None); + old.fields[0].modifiers.push(FieldModifier::Unique); + apply(backend, &old).await; + let original = backend + .create(&Entity::new( + old.name.clone(), + BTreeMap::from([("label".into(), DynamicValue::Text("retained".into()))]), + )) + .await + .unwrap(); + let mut child = old.clone(); + child + .annotations + .push(Annotation::Tenant(TenantKind::Child { + parent: root.name.clone(), + })); + assert!(DiffEngine::plan_update(&old, &child).is_err()); + assert!(backend.store_schema_metadata(&child).await.is_err()); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM pg_attribute WHERE attrelid='\"TenantContact\"'::regclass AND attname='_tenant'").fetch_one(backend.pool()).await.unwrap(); + assert_eq!(count, 0); + + // The documented manual path commits ownership, physical uniqueness and canonical metadata together. + let mut tx = backend.pool().begin().await.unwrap(); + for statement in [ + "LOCK TABLE \"TenantContact\", \"TenantOrg\", \"_schema_metadata\" IN ACCESS EXCLUSIVE MODE", + "ALTER TABLE \"TenantContact\" ADD COLUMN _tenant TEXT", + "ALTER TABLE \"TenantContact\" DROP CONSTRAINT \"uq_TenantContact_label\"", + "CREATE INDEX \"idx_TenantContact_tenant\" ON \"TenantContact\" (_tenant)", + "CREATE UNIQUE INDEX \"uq_TenantContact_label\" ON \"TenantContact\" (_tenant, label)", + ] { sqlx::query(statement).execute(&mut *tx).await.unwrap(); } + sqlx::query("UPDATE \"TenantContact\" SET _tenant = $1") + .bind(tenant.id.as_str()) + .execute(&mut *tx) + .await + .unwrap(); + let invalid: i64 = sqlx::query_scalar("SELECT count(*) FROM \"TenantContact\" c LEFT JOIN \"TenantOrg\" o ON c._tenant=o.id WHERE c._tenant IS NULL OR o.id IS NULL").fetch_one(&mut *tx).await.unwrap(); + assert_eq!(invalid, 0); + let updated = sqlx::query("UPDATE \"_schema_metadata\" SET definition=jsonb_set(definition, '{annotations}', $1) WHERE name='TenantContact'").bind(serde_json::to_value(&child.annotations).unwrap()).execute(&mut *tx).await.unwrap(); + assert_eq!(updated.rows_affected(), 1); + tx.commit().await.unwrap(); + backend.store_schema_metadata(&child).await.unwrap(); + let stored = backend + .load_schema_metadata(&child.name) + .await + .unwrap() + .unwrap(); + assert!(DiffEngine::plan_update(&stored, &child).unwrap().is_empty()); + assert_eq!( + backend + .get(&child.name, &original.id) + .await + .unwrap() + .fields + .get("label"), + Some(&DynamicValue::Text("retained".into())) + ); + let other = backend + .create(&Entity::new( + root.name.clone(), + BTreeMap::from([("label".into(), DynamicValue::Text("second".into()))]), + )) + .await + .unwrap(); + backend + .create(&Entity::new( + child.name.clone(), + BTreeMap::from([ + ("label".into(), DynamicValue::Text("retained".into())), + ("_tenant".into(), DynamicValue::Text(other.id.to_string())), + ]), + )) + .await + .unwrap(); + let duplicate = Entity::new( + child.name.clone(), + BTreeMap::from([ + ("label".into(), DynamicValue::Text("retained".into())), + ("_tenant".into(), DynamicValue::Text(tenant.id.to_string())), + ]), + ); + assert!(matches!( + backend.create(&duplicate).await, + Err(BackendError::UniqueViolation { .. }) + )); + // Disabling tenant scoping cannot silently discard cross-tenant duplicate values. + let mut tx = backend.pool().begin().await.unwrap(); + sqlx::query("DROP INDEX \"uq_TenantContact_label\"") + .execute(&mut *tx) + .await + .unwrap(); + assert!(sqlx::query( + "ALTER TABLE \"TenantContact\" ADD CONSTRAINT \"uq_TenantContact_label\" UNIQUE(label)" + ) + .execute(&mut *tx) + .await + .is_err()); + tx.rollback().await.unwrap(); + assert!(matches!( + backend.create(&duplicate).await, + Err(BackendError::UniqueViolation { .. }) + )); +} + +async fn atomic_schema_failure_preserves_data_and_metadata(backend: &PgBackend) { + let original = definition("AtomicSchema", None); + backend + .apply_schema_change( + &original.name, + &DiffEngine::create_new(&original).steps, + Some(&original), + ) + .await + .unwrap(); + let row = Entity { + id: EntityId::new("atomic"), + schema: original.name.clone(), + fields: BTreeMap::from([("label".into(), DynamicValue::Text("retained".into()))]), + }; + backend.create(&row).await.unwrap(); + // Dropping label succeeds, then strict FK finalization fails. Every earlier + // DDL, metadata write, and revision invalidation must be rolled back. + let mut proposed = definition("AtomicSchema", Some(("owner", "AbsentAtomicOwner"))); + proposed.id = original.id.clone(); + proposed.fields.remove(0); + let plan = DiffEngine::plan_update(&original, &proposed).unwrap(); + assert!(backend + .apply_schema_change(&original.name, &plan.steps, Some(&proposed)) + .await + .is_err()); + assert_eq!( + backend.load_schema_metadata(&original.name).await.unwrap(), + Some(original.clone()) + ); + assert_eq!(backend.get(&original.name, &row.id).await.unwrap(), row); + backend + .apply_schema_change(&original.name, &[], None) + .await + .unwrap(); + assert!(backend + .load_schema_metadata(&original.name) + .await + .unwrap() + .is_none()); +} diff --git a/crates/schema-forge-surrealdb/Cargo.toml b/crates/schema-forge-surrealdb/Cargo.toml index acdd197b..f0775b5f 100644 --- a/crates/schema-forge-surrealdb/Cargo.toml +++ b/crates/schema-forge-surrealdb/Cargo.toml @@ -1,12 +1,13 @@ [package] +rust-version = "1.97.1" name = "schema-forge-surrealdb" -version = "0.12.0" +version = "0.13.0" edition = "2021" [dependencies] schema-forge-core = { path = "../schema-forge-core" } schema-forge-backend = { path = "../schema-forge-backend" } -surrealdb = { version = "2", features = ["kv-mem", "protocol-ws", "rustls"] } +surrealdb = { version = "3.3.0", features = ["kv-mem", "protocol-ws", "rustls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/schema-forge-surrealdb/src/backend.rs b/crates/schema-forge-surrealdb/src/backend.rs index 93d9b86a..6dd5d421 100644 --- a/crates/schema-forge-surrealdb/src/backend.rs +++ b/crates/schema-forge-surrealdb/src/backend.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; +use crate::value::record_key_string; use schema_forge_backend::entity::{Entity, QueryResult}; use schema_forge_backend::error::BackendError; use schema_forge_backend::traits::{EntityStore, SchemaBackend}; @@ -12,6 +13,7 @@ use schema_forge_core::migration::MigrationStep; use schema_forge_core::query::{AggregateQuery, AggregateResult, Query}; use schema_forge_core::types::{DynamicValue, EntityId, FieldType, SchemaDefinition, SchemaName}; use surrealdb::engine::any::Any; +use surrealdb::types::ToSql; use surrealdb::Surreal; use crate::codegen::migration_step_to_surql; @@ -20,6 +22,10 @@ use crate::value::{ entity_to_surreal_map, first_negative_duration, first_oversized_bytes, surreal_to_dynamic, }; +fn supported_server_version(major: u64, minor: u64, stable: bool) -> bool { + major == 3 && minor >= 3 && stable +} + /// The schema metadata table name used to store `SchemaDefinition` records. const SCHEMA_META_TABLE: &str = "_schema_metadata"; @@ -63,6 +69,7 @@ fn reclassify_unique_violation(err: BackendError, table: &str) -> BackendError { /// `SchemaBackend` (DDL/metadata) and `EntityStore` (CRUD/query). pub struct SurrealBackend { db: Surreal, + version_checked: std::sync::OnceLock<()>, } impl SurrealBackend { @@ -72,7 +79,10 @@ impl SurrealBackend { /// connection pooling). The caller is responsible for ensuring the client /// has the correct namespace and database selected. pub fn from_client(db: Surreal) -> Self { - Self { db } + Self { + db, + version_checked: std::sync::OnceLock::new(), + } } /// Get a reference to the underlying SurrealDB client. @@ -101,7 +111,9 @@ impl SurrealBackend { message: e.to_string(), })?; - Ok(Self { db }) + let backend = Self::from_client(db); + backend.ensure_supported_version().await?; + Ok(backend) } /// Connect to a remote SurrealDB instance. @@ -130,10 +142,14 @@ impl SurrealBackend { } })?; + let backend = Self::from_client(db); + backend.ensure_supported_version().await?; + let db = backend.client(); + if let (Some(user), Some(pass)) = (username, password) { db.signin(surrealdb::opt::auth::Root { - username: user, - password: pass, + username: user.to_owned(), + password: pass.to_owned(), }) .await .map_err(|e| BackendError::ConnectionError { @@ -148,11 +164,30 @@ impl SurrealBackend { message: format!("failed to select namespace/database: {e}"), })?; - Ok(Self { db }) + Ok(backend) + } + + async fn ensure_supported_version(&self) -> Result<(), BackendError> { + if self.version_checked.get().is_some() { + return Ok(()); + } + let version = self + .db + .version() + .await + .map_err(|error| BackendError::ConnectionError { + message: format!("cannot verify SurrealDB server version: {error}"), + })?; + if !supported_server_version(version.major, version.minor, version.pre.is_empty()) { + return Err(BackendError::ConnectionError { message: format!("SurrealDB {version} is unsupported; upgrade the server to stable 3.3 or newer within the 3.x series before using SchemaForge. Older embedded engines can lose concurrent conditional updates.") }); + } + let _ = self.version_checked.set(()); + Ok(()) } /// Execute a raw SurrealQL statement, returning the response. - async fn execute_raw(&self, sql: &str) -> Result { + async fn execute_raw(&self, sql: &str) -> Result { + self.ensure_supported_version().await?; self.db .query(sql) .await @@ -162,24 +197,25 @@ impl SurrealBackend { } /// Execute a raw SurrealQL statement and extract the result as a list of - /// `surrealdb::sql::Value` objects. + /// `surrealdb::types::Value` objects. /// - /// Uses `response.take::(0)` which bypasses serde + /// Uses `response.take::(0)` which bypasses serde /// deserialization (the SDK has a special `QueryResult` impl that /// wraps the core value directly). We then unwrap the `Array` variant /// manually to get individual row values. async fn execute_and_take_rows( &self, sql: &str, - ) -> Result, BackendError> { + ) -> Result, BackendError> { let mut response = self.execute_raw(sql).await?; - let value: surrealdb::Value = response.take(0).map_err(|e| BackendError::QueryError { - message: e.to_string(), - })?; - let core_val = value.into_inner(); + let value: surrealdb::types::Value = + response.take(0).map_err(|e| BackendError::QueryError { + message: e.to_string(), + })?; + let core_val = value; match core_val { - surrealdb::sql::Value::Array(arr) => Ok(arr.0), - surrealdb::sql::Value::None | surrealdb::sql::Value::Null => Ok(Vec::new()), + surrealdb::types::Value::Array(arr) => Ok(arr.into_inner()), + surrealdb::types::Value::None | surrealdb::types::Value::Null => Ok(Vec::new()), // Single object result (e.g. from CREATE) other => Ok(vec![other]), } @@ -270,31 +306,60 @@ impl SurrealBackend { } } -impl SchemaBackend for SurrealBackend { - async fn apply_migration( +impl SurrealBackend { + async fn compile_migration( &self, schema_name: &SchemaName, steps: &[MigrationStep], - ) -> Result<(), BackendError> { + ) -> Result, BackendError> { let table = schema_name.as_str(); - let needs_enum_metadata = steps.iter().any(|step| { - matches!( - step, - MigrationStep::ChangeType { - old_type: FieldType::Enum(_), - new_type: FieldType::Enum(_), - .. - } - ) - }); + let needs_enum_metadata = steps + .iter() + .any(|step| matches!(step, MigrationStep::RenameField { .. })) + || steps.iter().any(|step| { + matches!( + step, + MigrationStep::ChangeType { + old_type: FieldType::Enum(_), + new_type: FieldType::Enum(_), + .. + } + ) + }); let metadata = if needs_enum_metadata { self.load_schema_metadata(schema_name).await? } else { None }; let mut statements = Vec::new(); + let mut rename_cleanup = Vec::new(); for step in steps { - let mut compiled = migration_step_to_surql(table, step); + let mut compiled = if let MigrationStep::RenameField { old_name, new_name } = step { + let schema = metadata + .as_ref() + .ok_or_else(|| BackendError::MigrationFailed { + step: step.to_string(), + reason: "rename requires stored schema metadata".into(), + })?; + let field = schema.field(old_name.as_str()).ok_or_else(|| { + BackendError::MigrationFailed { + step: step.to_string(), + reason: "rename source missing from stored metadata".into(), + } + })?; + // SurrealDB's transaction-local field refresh after REMOVE FIELD + // can strip unrelated object data on a later UPDATE. Complete + // every data write before removing renamed source definitions. + rename_cleanup.push(format!("REMOVE FIELD {old_name} ON {table};")); + crate::codegen::rename_field_stmts( + table, + field, + new_name, + schema.unique_scoped_by_tenant(), + ) + } else { + migration_step_to_surql(table, step) + }; if let MigrationStep::ChangeType { name, old_type: FieldType::Enum(_), @@ -324,6 +389,81 @@ impl SchemaBackend for SurrealBackend { } statements.extend(compiled); } + statements.extend(rename_cleanup); + Ok(statements) + } +} + +impl SchemaBackend for SurrealBackend { + async fn apply_schema_change( + &self, + name: &SchemaName, + steps: &[MigrationStep], + definition: Option<&SchemaDefinition>, + ) -> Result<(), BackendError> { + self.ensure_supported_version().await?; + if let Some(definition) = definition { + if &definition.name != name { + return Err(BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: "schema name does not match metadata".into(), + }); + } + if let Some(existing) = self.load_schema_metadata(name).await? { + schema_forge_core::migration::DiffEngine::validate_transition( + &existing, definition, + ) + .map_err(|error| BackendError::MigrationFailed { + step: "validate schema transition".into(), + reason: error.to_string(), + })?; + } + } + let mut statements = self.compile_migration(name, steps).await?; + if let Some(definition) = definition { + let json = + serde_json::to_string(definition).map_err(|error| BackendError::Internal { + message: error.to_string(), + })?; + statements.push(format!("UPSERT {SCHEMA_META_TABLE}:`{name}` CONTENT {{ name: '{name}', definition: $schema_definition }};")); + self.db + .query(format!( + "BEGIN TRANSACTION;\n{}\nCOMMIT TRANSACTION;", + statements.join("\n") + )) + .bind(("schema_definition", json)) + .await + .map_err(|error| BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: error.to_string(), + })? + .check() + .map_err(|error| BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: error.to_string(), + })?; + } else { + statements.push(format!("DELETE {SCHEMA_META_TABLE}:`{name}`;")); + self.execute_raw(&format!( + "BEGIN TRANSACTION;\n{}\nCOMMIT TRANSACTION;", + statements.join("\n") + )) + .await? + .check() + .map_err(|error| BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: error.to_string(), + })?; + } + Ok(()) + } + + async fn apply_migration( + &self, + schema_name: &SchemaName, + steps: &[MigrationStep], + ) -> Result<(), BackendError> { + let statements = self.compile_migration(schema_name, steps).await?; if !statements.is_empty() { let sql = format!( "BEGIN TRANSACTION;\n{}\nCOMMIT TRANSACTION;", @@ -343,16 +483,32 @@ impl SchemaBackend for SurrealBackend { &self, definition: &SchemaDefinition, ) -> Result<(), BackendError> { + if let Some(existing) = self.load_schema_metadata(&definition.name).await? { + schema_forge_core::migration::DiffEngine::validate_transition(&existing, definition) + .map_err(|error| BackendError::MigrationFailed { + step: "validate schema transition".into(), + reason: error.to_string(), + })?; + } let json = serde_json::to_string(definition).map_err(|e| BackendError::Internal { message: format!("failed to serialize schema metadata: {e}"), })?; let name = definition.name.as_str(); - let sql = format!( - "UPSERT {SCHEMA_META_TABLE}:`{name}` CONTENT {{ name: '{name}', definition: '{json_escaped}' }};", - json_escaped = json.replace('\'', "\\'") - ); - self.execute_raw(&sql).await?; + self.db + .query(format!( + "UPSERT {SCHEMA_META_TABLE}:`{name}` CONTENT {{ name: $schema_name, definition: $schema_definition }};" + )) + .bind(("schema_name", name.to_owned())) + .bind(("schema_definition", json)) + .await + .map_err(|error| BackendError::QueryError { + message: error.to_string(), + })? + .check() + .map_err(|error| BackendError::QueryError { + message: error.to_string(), + })?; Ok(()) } @@ -364,10 +520,7 @@ impl SchemaBackend for SurrealBackend { let sql = format!("SELECT definition FROM {SCHEMA_META_TABLE}:`{name_str}`;"); let mut response = self.execute_raw(&sql).await?; - let rows: Vec = - response.take(0).map_err(|e| BackendError::QueryError { - message: e.to_string(), - })?; + let rows = take_metadata_rows(&mut response)?; if rows.is_empty() { return Ok(None); @@ -388,10 +541,7 @@ impl SchemaBackend for SurrealBackend { let sql = format!("SELECT definition FROM {SCHEMA_META_TABLE};"); let mut response = self.execute_raw(&sql).await?; - let rows: Vec = - response.take(0).map_err(|e| BackendError::QueryError { - message: e.to_string(), - })?; + let rows = take_metadata_rows(&mut response)?; let mut definitions = Vec::new(); for row in &rows { @@ -409,6 +559,28 @@ impl SchemaBackend for SurrealBackend { } } +/// An absent metadata table represents a fresh database. Other missing resources +/// and malformed metadata remain errors; reads never initialize database state. +fn take_metadata_rows( + response: &mut surrealdb::IndexedResults, +) -> Result, BackendError> { + match response.take(0) { + Ok(rows) => Ok(rows), + Err(error) + if matches!( + error.not_found_details(), + Some(surrealdb::types::NotFoundError::Table { name }) + if name == SCHEMA_META_TABLE + ) => + { + Ok(Vec::new()) + } + Err(error) => Err(BackendError::QueryError { + message: error.to_string(), + }), + } +} + /// Parse a stored schema metadata JSON blob and migrate any legacy /// `@widget("...")` annotations on the fly. Removed widget tokens (e.g. /// `currency`, `relative_time`) are dropped from the field's annotation list @@ -526,15 +698,15 @@ impl EntityStore for SurrealBackend { "UPDATE `{schema}`:`{id}` SET `{field}` = {new_value} WHERE `{field}` = {literal} RETURN AFTER;" ); let mut response = self.execute_raw(&sql).await?; - match response.take::(0) { - Ok(value) => Ok(match value.into_inner() { - surrealdb::sql::Value::Array(rows) => !rows.0.is_empty(), - surrealdb::sql::Value::None | surrealdb::sql::Value::Null => false, + match response.take::(0) { + Ok(value) => Ok(match value { + surrealdb::types::Value::Array(rows) => !rows.is_empty(), + surrealdb::types::Value::None | surrealdb::types::Value::Null => false, _ => true, }), - Err(surrealdb::Error::Db(surrealdb::error::Db::TxRetryable)) => Ok(false), - Err(surrealdb::Error::Db(surrealdb::error::Db::QueryNotExecutedDetail { message })) - if message == surrealdb::error::Db::TxRetryable.to_string() => + Err(error) + if error.query_details() + == Some(&surrealdb::types::QueryError::TransactionConflict) => { Ok(false) } @@ -609,9 +781,12 @@ impl EntityStore for SurrealBackend { let rows = self.execute_and_take_rows(&sql).await?; // SurrealDB returns [{ "count": N }] for GROUP ALL, or [] if no rows. - if let Some(surrealdb::sql::Value::Object(obj)) = rows.first() { - if let Some(surrealdb::sql::Value::Number(n)) = obj.get("count") { - return Ok(n.as_usize()); + if let Some(surrealdb::types::Value::Object(obj)) = rows.first() { + if let Some(surrealdb::types::Value::Number(n)) = obj.get("count") { + return Ok(n + .to_int() + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(0)); } } Ok(0) @@ -635,12 +810,12 @@ impl EntityStore for SurrealBackend { let mut results = Vec::with_capacity(query.ops.len()); if let Some(row) = rows.first() { - if let surrealdb::sql::Value::Object(obj) = row { + if let surrealdb::types::Value::Object(obj) = row { for (i, op) in query.ops.iter().enumerate() { let key = format!("agg_{i}"); let value = match obj.get(&key) { - Some(surrealdb::sql::Value::Number(n)) => { - let v = n.as_float(); + Some(surrealdb::types::Value::Number(n)) => { + let v = n.to_f64().unwrap_or(f64::NAN); if v.is_nan() { 0.0 } else { @@ -669,18 +844,18 @@ impl EntityStore for SurrealBackend { } } -/// Convert a `surrealdb::sql::Value` response row to an `Entity`. +/// Convert a `surrealdb::types::Value` response row to an `Entity`. /// /// This is the primary deserialization path. It works directly with -/// `surrealdb::sql::Value` (the core value type) which handles `Thing` +/// `surrealdb::types::Value` (the core value type) which handles `Thing` /// record IDs natively, avoiding the serialization errors that occur /// when trying to deserialize SurrealDB internal types through serde. fn surreal_row_to_entity( schema: &SchemaName, - row: &surrealdb::sql::Value, + row: &surrealdb::types::Value, ) -> Result { match row { - surrealdb::sql::Value::Object(obj) => { + surrealdb::types::Value::Object(obj) => { // Extract ID let id_value = obj.get("id").ok_or_else(|| BackendError::Internal { message: "SurrealDB record missing 'id' field".to_string(), @@ -703,69 +878,81 @@ fn surreal_row_to_entity( Ok(Entity::with_id(entity_id, schema.clone(), fields)) } other => Err(BackendError::Internal { - message: format!("expected Object in query result, got: {other}"), + message: format!("expected Object in query result, got: {other:?}"), }), } } -/// Extract entity ID string from a `surrealdb::sql::Value`. +/// Extract entity ID string from a `surrealdb::types::Value`. /// -/// SurrealDB returns IDs as `Thing` (table:id), `Strand` (string), or other formats. -fn extract_id_from_surreal(value: &surrealdb::sql::Value) -> String { +/// SurrealDB returns IDs as native record identifiers or strings. +fn extract_id_from_surreal(value: &surrealdb::types::Value) -> String { match value { - surrealdb::sql::Value::Thing(thing) => { - // thing.id is the record's unique part - thing.id.to_raw() + surrealdb::types::Value::RecordId(thing) => { + // Preserve the raw record key without SQL quoting. + record_key_string(&thing.key) } - surrealdb::sql::Value::Strand(s) => s.0.clone(), - other => other.to_string(), + surrealdb::types::Value::String(s) => s.clone(), + other => other.to_sql(), } } -/// Convert a surrealdb::sql::Value to a SurrealQL literal string for use in SET clauses. -fn field_surreal_value_to_literal(value: &surrealdb::sql::Value) -> String { +/// Convert a surrealdb::types::Value to a SurrealQL literal string for use in SET clauses. +fn field_surreal_value_to_literal(value: &surrealdb::types::Value) -> String { match value { - surrealdb::sql::Value::None | surrealdb::sql::Value::Null => "NONE".to_string(), - surrealdb::sql::Value::Bool(b) => b.to_string(), - surrealdb::sql::Value::Number(n) => n.to_string(), - surrealdb::sql::Value::Strand(s) => { + surrealdb::types::Value::None | surrealdb::types::Value::Null => "NONE".to_string(), + surrealdb::types::Value::Bool(b) => b.to_string(), + surrealdb::types::Value::Number(n) => n.to_sql(), + surrealdb::types::Value::String(s) => { // Detect ISO 8601 datetime strings and use SurrealQL d'...' literal if chrono::DateTime::parse_from_rfc3339(s.as_str()).is_ok() { format!("d'{}'", s.as_str()) } else { - value.to_string() + value.to_sql() } } - surrealdb::sql::Value::Datetime(dt) => format!("d'{}'", dt.0.to_rfc3339()), + surrealdb::types::Value::Datetime(dt) => { + format!("d'{}'", (*dt).into_inner().to_rfc3339()) + } // Duration literals are bare in SurrealQL (e.g. `2w3d`); the Display impl // produces a parseable form. - surrealdb::sql::Value::Duration(dur) => dur.to_string(), + surrealdb::types::Value::Duration(dur) => dur.to_sql(), // The `Bytes` Display impl emits a parseable SurrealQL literal of the form // `encoding::base64::decode("...")`, round-tripping to native bytes. - surrealdb::sql::Value::Bytes(b) => b.to_string(), - surrealdb::sql::Value::Array(arr) => { + surrealdb::types::Value::Bytes(b) => b.to_sql(), + surrealdb::types::Value::Array(arr) => { let items: Vec = arr.iter().map(field_surreal_value_to_literal).collect(); format!("[{}]", items.join(", ")) } - surrealdb::sql::Value::Object(obj) => { + surrealdb::types::Value::Object(obj) => { let entries: Vec = obj .iter() .map(|(k, v)| { format!( "{}: {}", - surrealdb::sql::Value::from(k.as_str()), + surrealdb::types::Value::String(k.clone()).to_sql(), field_surreal_value_to_literal(v) ) }) .collect(); format!("{{ {} }}", entries.join(", ")) } - other => format!("'{}'", other.to_string().replace('\'', "\\'")), + other => format!("'{}'", other.to_sql().replace('\'', "\\'")), } } #[cfg(test)] mod tests { + #[test] + fn rejects_engines_without_supported_transaction_guarantees() { + assert!(!super::supported_server_version(2, 6, true)); + assert!(!super::supported_server_version(3, 2, true)); + assert!(!super::supported_server_version(3, 3, false)); + assert!(super::supported_server_version(3, 3, true)); + assert!(super::supported_server_version(3, 4, true)); + assert!(!super::supported_server_version(4, 0, true)); + } + use super::*; #[tokio::test] @@ -790,16 +977,16 @@ mod tests { #[test] fn extract_id_from_thing() { - use surrealdb::sql::{Id, Thing}; - let thing = Thing::from(("Contact", Id::String("entity_abc123".into()))); - let thing_val = surrealdb::sql::Value::Thing(thing); + use surrealdb::types::{RecordId, RecordIdKey}; + let thing = RecordId::new("Contact", RecordIdKey::String("entity_abc123".into())); + let thing_val = surrealdb::types::Value::RecordId(thing); assert_eq!(extract_id_from_surreal(&thing_val), "entity_abc123"); } #[test] fn extract_id_from_strand() { - let strand = surrealdb::sql::Strand::from("entity_abc123"); - let strand_val = surrealdb::sql::Value::Strand(strand); + let strand = String::from("entity_abc123"); + let strand_val = surrealdb::types::Value::String(strand); assert_eq!(extract_id_from_surreal(&strand_val), "entity_abc123"); } @@ -833,20 +1020,27 @@ mod tests { #[test] fn duration_literal_is_bare() { - let dur = surrealdb::sql::Duration::from(std::time::Duration::from_secs(3600)); - let val = surrealdb::sql::Value::Duration(dur); + let dur = surrealdb::types::Duration::from(std::time::Duration::from_secs(3600)); + let val = surrealdb::types::Value::Duration(dur); // Bare SurrealQL duration literal, no quotes. assert_eq!(field_surreal_value_to_literal(&val), "1h"); } - #[test] - fn bytes_literal_is_base64_decode_call() { - let val = surrealdb::sql::Value::Bytes(surrealdb::sql::Bytes::from(b"hello".to_vec())); - // Parseable SurrealQL: decodes back to the same bytes. - assert_eq!( - field_surreal_value_to_literal(&val), - "encoding::base64::decode(\"aGVsbG8\")" - ); + #[tokio::test] + async fn bytes_literal_round_trips_through_surrealql() { + let backend = SurrealBackend::connect_memory("bytes", "bytes") + .await + .unwrap(); + for bytes in [vec![], b"hello".to_vec(), vec![0, 255, 128, 34, 39, 92]] { + let value = surrealdb::types::Value::Bytes(surrealdb::types::Bytes::from(bytes)); + let literal = field_surreal_value_to_literal(&value); + let mut response = backend + .execute_raw(&format!("RETURN {literal};")) + .await + .unwrap(); + let decoded: surrealdb::types::Value = response.take(0).unwrap(); + assert_eq!(decoded, value); + } } #[tokio::test] diff --git a/crates/schema-forge-surrealdb/src/codegen.rs b/crates/schema-forge-surrealdb/src/codegen.rs index b7a8b745..745165fa 100644 --- a/crates/schema-forge-surrealdb/src/codegen.rs +++ b/crates/schema-forge-surrealdb/src/codegen.rs @@ -43,14 +43,10 @@ pub fn migration_step_to_surql(table: &str, step: &MigrationStep) -> Vec MigrationStep::RemoveField { name } => { vec![format!("REMOVE FIELD {name} ON {table};")] } - MigrationStep::RenameField { old_name, new_name } => { - // SurrealDB does not have a native RENAME FIELD command. - // We define the new field, copy data, then remove the old one. - vec![ - format!("DEFINE FIELD {new_name} ON {table} TYPE any;"), - format!("UPDATE {table} SET {new_name} = {old_name};"), - format!("REMOVE FIELD {old_name} ON {table};"), - ] + MigrationStep::RenameField { .. } => { + // A context-free generator cannot preserve type/modifier/index metadata. + // The backend compiles renames with rename_field_stmts and stored schema context. + vec!["THROW 'field rename requires stored schema metadata; execute through SchemaBackend';".into()] } MigrationStep::ChangeType { name, @@ -60,13 +56,13 @@ pub fn migration_step_to_surql(table: &str, step: &MigrationStep) -> Vec } => { let surql_type = field_type_to_surql(new_type); let assertions = field_assertions(new_type); - let flex_prefix = if needs_flexible(new_type) { - "FLEXIBLE " + let flex_suffix = if needs_flexible(new_type) { + " FLEXIBLE" } else { "" }; let mut stmt = - format!("DEFINE FIELD OVERWRITE {name} ON {table} {flex_prefix}TYPE {surql_type}"); + format!("DEFINE FIELD OVERWRITE {name} ON {table} TYPE {surql_type}{flex_suffix}"); if !assertions.is_empty() { stmt.push_str(&format!(" ASSERT {}", assertions.join(" AND "))); } @@ -274,6 +270,50 @@ pub fn field_assertions(field_type: &FieldType) -> Vec { } } +/// Preserve the field's physical type and constraints while copying its values. +pub(crate) fn rename_field_stmts( + table: &str, + source: &FieldDefinition, + new_name: &schema_forge_core::types::FieldName, + per_tenant: bool, +) -> Vec { + let old_name = &source.name; + let mut destination = source.clone(); + destination.name = new_name.clone(); + let mut statements = define_field_stmts(table, &destination); + statements.push(format!("UPDATE {table} SET {new_name} = {old_name};")); + if source.is_unique() { + statements.push(format!( + "REMOVE INDEX {} ON {table};", + unique_index_name(table, old_name.as_str()) + )); + } + if source.is_indexed() { + statements.push(format!("REMOVE INDEX idx_{table}_{old_name} ON {table};")); + } + // Remove required/default rules before unsetting the old value. Its + // definition stays present until the backend finishes all data writes. + let original_type = field_type_to_surql(&source.field_type); + let optional_type = if original_type == "any" || original_type.starts_with("option<") { + original_type + } else { + format!("option<{original_type}>") + }; + let flexible = if needs_flexible(&source.field_type) { + " FLEXIBLE" + } else { + "" + }; + statements.push(format!( + "DEFINE FIELD OVERWRITE {old_name} ON {table} TYPE {optional_type}{flexible};" + )); + statements.push(format!("UPDATE {table} UNSET {old_name};")); + if source.is_unique() { + statements.push(add_unique_surql(table, new_name.as_str(), per_tenant)); + } + statements +} + /// Generate a complete DEFINE FIELD statement (possibly multiple for composites). pub(crate) fn define_field_stmts(table: &str, field: &FieldDefinition) -> Vec { let name = &field.name; @@ -289,8 +329,8 @@ pub(crate) fn define_field_stmts(table: &str, field: &FieldDefinition) -> Vec Vec Vec;" + "DEFINE FIELD metadata ON Employee TYPE option FLEXIBLE;" ); } @@ -854,7 +893,7 @@ mod tests { assert_eq!(stmts.len(), 1); assert_eq!( stmts[0], - "DEFINE FIELD config ON Workflow FLEXIBLE TYPE object ASSERT $value != NONE;" + "DEFINE FIELD config ON Workflow TYPE object FLEXIBLE ASSERT $value != NONE;" ); } @@ -883,7 +922,7 @@ mod tests { // object (and any extra keys — though only declared sub-fields // are enforced). assert!( - stmts[0].contains("FLEXIBLE TYPE option"), + stmts[0].contains("TYPE option FLEXIBLE"), "parent: {}", stmts[0] ); @@ -919,7 +958,7 @@ mod tests { assert_eq!(stmts.len(), 1); assert_eq!( stmts[0], - "DEFINE FIELD OVERWRITE metadata ON Employee FLEXIBLE TYPE object;" + "DEFINE FIELD OVERWRITE metadata ON Employee TYPE object FLEXIBLE;" ); } diff --git a/crates/schema-forge-surrealdb/src/value.rs b/crates/schema-forge-surrealdb/src/value.rs index 49b72960..554441f8 100644 --- a/crates/schema-forge-surrealdb/src/value.rs +++ b/crates/schema-forge-surrealdb/src/value.rs @@ -1,10 +1,9 @@ -//! Pure functions for converting between `DynamicValue` and `surrealdb::sql::Value`. +//! Pure functions for converting between `DynamicValue` and `surrealdb::types::Value`. //! //! These conversions are used when reading from and writing to SurrealDB. //! -//! We use the `surrealdb::sql` module types (re-exported from `surrealdb_core`) -//! for pattern matching on query results. Construction of composite values -//! goes through the public `surrealdb::Object` wrapper which exposes `insert`. +//! The SDK exposes native values through `surrealdb::types`, preserving +//! records, durations, bytes, and nested objects without JSON coercion. //! //! A SchemaForge `duration` is a signed [`chrono::TimeDelta`], but SurrealDB's //! native `duration` type is unsigned. A negative duration therefore cannot be @@ -16,20 +15,27 @@ use std::collections::BTreeMap; use schema_forge_backend::entity::Entity; use schema_forge_backend::error::BackendError; use schema_forge_core::types::{DynamicValue, EntityId, FieldType, SchemaName}; -use surrealdb::sql::Value as SurrealValue; +use surrealdb::types::{SurrealValue as IntoSurrealValue, ToSql, Value as SurrealValue}; -/// Convert a `DynamicValue` to a `surrealdb::sql::Value`. +pub(crate) fn record_key_string(key: &surrealdb::types::RecordIdKey) -> String { + match key { + surrealdb::types::RecordIdKey::String(value) => value.clone(), + other => other.to_sql(), + } +} + +/// Convert a `DynamicValue` to a `surrealdb::types::Value`. pub fn dynamic_to_surreal(value: &DynamicValue) -> SurrealValue { match value { DynamicValue::Null => SurrealValue::None, - DynamicValue::Text(s) => SurrealValue::from(s.as_str()), - DynamicValue::Integer(i) => SurrealValue::from(*i), - DynamicValue::Float(f) => SurrealValue::from(*f), - DynamicValue::Boolean(b) => SurrealValue::from(*b), + DynamicValue::Text(s) => s.as_str().into_value(), + DynamicValue::Integer(i) => (*i).into_value(), + DynamicValue::Float(f) => (*f).into_value(), + DynamicValue::Boolean(b) => (*b).into_value(), DynamicValue::DateTime(dt) => { // Store as ISO 8601 string — the literal serializer in backend.rs // will wrap it with d'...' for SurrealQL datetime fields. - SurrealValue::from(dt.to_rfc3339()) + dt.to_rfc3339().into_value() } DynamicValue::Duration(d) => { timedelta_to_surreal_duration(d).map_or(SurrealValue::None, SurrealValue::Duration) @@ -37,42 +43,36 @@ pub fn dynamic_to_surreal(value: &DynamicValue) -> SurrealValue { DynamicValue::Bytes(b) => { // SurrealDB has a native (unsigned-length) `bytes` type; store the // bytes verbatim. - SurrealValue::Bytes(surrealdb::sql::Bytes::from(b.clone())) + SurrealValue::Bytes(surrealdb::types::Bytes::from(b.clone())) } - DynamicValue::Enum(s) => SurrealValue::from(s.as_str()), + DynamicValue::Enum(s) => s.as_str().into_value(), DynamicValue::Json(v) => json_to_surreal(v), DynamicValue::Array(arr) => { let items: Vec = arr.iter().map(dynamic_to_surreal).collect(); - SurrealValue::from(items) + items.into_value() } DynamicValue::Composite(map) | DynamicValue::Map(map) => { // A fixed-field `Composite` and a typed open-key `Map` are both // stored as a native string-keyed SurrealDB object. - let mut obj = surrealdb::Object::new(); + let mut obj = surrealdb::types::Object::new(); for (k, v) in map { - obj.insert( - k.clone(), - surrealdb::Value::from_inner(dynamic_to_surreal(v)), - ); + obj.insert(k.clone(), dynamic_to_surreal(v)); } - SurrealValue::Object(obj.into_inner()) + SurrealValue::Object(obj) } - DynamicValue::Ref(id) => SurrealValue::from(id.as_str()), + DynamicValue::Ref(id) => id.as_str().into_value(), DynamicValue::RefArray(ids) => { - let items: Vec = ids - .iter() - .map(|id| SurrealValue::from(id.as_str())) - .collect(); - SurrealValue::from(items) + let items: Vec = ids.iter().map(|id| id.as_str().into_value()).collect(); + items.into_value() } _ => { // Future DynamicValue variants -- store as string fallback. - SurrealValue::from(format!("{value:?}").as_str()) + format!("{value:?}").into_value() } } } -/// Convert a `surrealdb::sql::Value` back to a `DynamicValue`. +/// Convert a `surrealdb::types::Value` back to a `DynamicValue`. /// /// This is a best-effort conversion. SurrealDB values that do not have /// a corresponding `DynamicValue` variant are stored as JSON. @@ -83,32 +83,47 @@ pub fn surreal_to_dynamic(value: &SurrealValue) -> Result { // Match on the Number enum variants directly. match n { - surrealdb::sql::Number::Int(i) => Ok(DynamicValue::Integer(*i)), - surrealdb::sql::Number::Float(f) => Ok(DynamicValue::Float(*f)), + surrealdb::types::Number::Int(i) => Ok(DynamicValue::Integer(*i)), + surrealdb::types::Number::Float(f) => Ok(DynamicValue::Float(*f)), _ => { // Decimal or future variants -- convert to float. - Ok(DynamicValue::Float((*n).as_float())) + Ok(DynamicValue::Float(n.to_f64().unwrap_or(f64::NAN))) } } } - SurrealValue::Strand(s) => Ok(DynamicValue::Text(s.0.clone())), + SurrealValue::String(s) => Ok(DynamicValue::Text(s.clone())), SurrealValue::Datetime(dt) => { - // surrealdb_core::sql::Datetime wraps chrono::DateTime as pub field .0 - let chrono_dt: chrono::DateTime = dt.0; + let chrono_dt: chrono::DateTime = (*dt).into_inner(); Ok(DynamicValue::DateTime(chrono_dt)) } SurrealValue::Duration(dur) => { - // surrealdb::sql::Duration wraps an unsigned std::time::Duration. - let delta = chrono::TimeDelta::from_std(dur.0).map_err(|e| BackendError::Internal { - message: format!("duration out of representable range: {e}"), + // surrealdb::types::Duration wraps an unsigned std::time::Duration. + let delta = chrono::TimeDelta::from_std(dur.into_inner()).map_err(|e| { + BackendError::Internal { + message: format!("duration out of representable range: {e}"), + } })?; Ok(DynamicValue::Duration(delta)) } SurrealValue::Bytes(b) => Ok(DynamicValue::Bytes(b.to_vec())), SurrealValue::Array(arr) => { - let items: Result, BackendError> = - arr.iter().map(surreal_to_dynamic).collect(); - Ok(DynamicValue::Array(items?)) + let items: Vec = arr + .iter() + .map(surreal_to_dynamic) + .collect::>()?; + // Native record-reference arrays are the persisted representation + // of to-many relations. Preserve that distinction for consumers. + let references: Option> = items + .iter() + .map(|item| match item { + DynamicValue::Ref(id) => Some(id.clone()), + _ => None, + }) + .collect(); + match references { + Some(ids) if !ids.is_empty() => Ok(DynamicValue::RefArray(ids)), + _ => Ok(DynamicValue::Array(items)), + } } SurrealValue::Object(obj) => { let mut map = BTreeMap::new(); @@ -117,17 +132,17 @@ pub fn surreal_to_dynamic(value: &SurrealValue) -> Result { + SurrealValue::RecordId(thing) => { // Record reference from a relation field - let id_str = thing.id.to_raw(); + let id_str = record_key_string(&thing.key); match EntityId::parse(&id_str) { Ok(entity_id) => Ok(DynamicValue::Ref(entity_id)), - Err(_) => Ok(DynamicValue::Text(format!("{}:{}", thing.tb, id_str))), + Err(_) => Ok(DynamicValue::Text(format!("{}:{}", thing.table, id_str))), } } _ => { // Fallback: convert to JSON representation - let json_str = value.to_string(); + let json_str = value.to_sql(); match serde_json::from_str::(&json_str) { Ok(json_val) => Ok(DynamicValue::Json(json_val)), Err(_) => Ok(DynamicValue::Text(json_str)), @@ -141,7 +156,7 @@ pub fn surreal_to_dynamic(value: &SurrealValue) -> Result BTreeMap { let mut map = BTreeMap::new(); - map.insert("id".to_string(), SurrealValue::from(entity.id.as_str())); + map.insert("id".to_string(), entity.id.as_str().into_value()); for (k, v) in &entity.fields { map.insert(k.clone(), dynamic_to_surreal(v)); } @@ -153,7 +168,7 @@ pub fn entity_to_surreal_map(entity: &Entity) -> BTreeMap /// Expects an `"id"` field containing the entity's identifier. pub fn surreal_object_to_entity( schema: &SchemaName, - obj: &surrealdb::sql::Object, + obj: &surrealdb::types::Object, ) -> Result { // Extract ID let id_value = obj.get("id").ok_or_else(|| BackendError::Internal { @@ -182,17 +197,17 @@ pub fn surreal_object_to_entity( /// SurrealDB may return IDs as `Thing` (table:id), `Strand`, or other formats. fn extract_id_string(value: &SurrealValue) -> Result { match value { - SurrealValue::Strand(s) => Ok(s.0.clone()), - SurrealValue::Thing(thing) => { - // thing.id is the record's unique part; thing.tb is the table name - Ok(thing.id.to_raw()) + SurrealValue::String(s) => Ok(s.clone()), + SurrealValue::RecordId(thing) => { + // thing.id is the record's unique part; thing.table is the table name + Ok(record_key_string(&thing.key)) } - other => Ok(other.to_string()), + other => Ok(other.to_sql()), } } /// Convert a signed `chrono::TimeDelta` to SurrealDB's native (unsigned) -/// `surrealdb::sql::Duration`. +/// `surrealdb::types::Duration`. /// /// SurrealDB durations wrap an unsigned `std::time::Duration`, so a negative /// `TimeDelta` has no native representation and yields `None`. A negative value @@ -202,8 +217,8 @@ fn extract_id_string(value: &SurrealValue) -> Result { /// `duration` field uses on a records platform (retention windows, TTLs, SLA /// timers) are non-negative, so `None` here is only ever the unreachable /// belt-and-braces case for an already-validated value. -fn timedelta_to_surreal_duration(d: &chrono::TimeDelta) -> Option { - d.to_std().ok().map(surrealdb::sql::Duration::from) +fn timedelta_to_surreal_duration(d: &chrono::TimeDelta) -> Option { + d.to_std().ok().map(surrealdb::types::Duration::from) } /// Find the first negative `duration` anywhere in a value tree. @@ -262,31 +277,31 @@ pub(crate) fn first_oversized_bytes( } } -/// Convert a `serde_json::Value` to a `surrealdb::sql::Value`. +/// Convert a `serde_json::Value` to a `surrealdb::types::Value`. fn json_to_surreal(json: &serde_json::Value) -> SurrealValue { match json { serde_json::Value::Null => SurrealValue::None, - serde_json::Value::Bool(b) => SurrealValue::from(*b), + serde_json::Value::Bool(b) => (*b).into_value(), serde_json::Value::Number(n) => { if let Some(i) = n.as_i64() { - SurrealValue::from(i) + (i).into_value() } else if let Some(f) = n.as_f64() { - SurrealValue::from(f) + (f).into_value() } else { - SurrealValue::from(n.to_string().as_str()) + n.to_string().into_value() } } - serde_json::Value::String(s) => SurrealValue::from(s.as_str()), + serde_json::Value::String(s) => s.as_str().into_value(), serde_json::Value::Array(arr) => { let items: Vec = arr.iter().map(json_to_surreal).collect(); - SurrealValue::from(items) + items.into_value() } serde_json::Value::Object(map) => { - let mut obj = surrealdb::Object::new(); + let mut obj = surrealdb::types::Object::new(); for (k, v) in map { - obj.insert(k.clone(), surrealdb::Value::from_inner(json_to_surreal(v))); + obj.insert(k.clone(), json_to_surreal(v)); } - SurrealValue::Object(obj.into_inner()) + SurrealValue::Object(obj) } } } @@ -460,6 +475,37 @@ mod tests { ); } + #[test] + fn record_arrays_preserve_relation_values() { + use surrealdb::types::{RecordId, RecordIdKey}; + let first = EntityId::new("target"); + let second = EntityId::new("target"); + let record = |id: &EntityId| { + SurrealValue::RecordId(RecordId::new( + "Target", + RecordIdKey::String(id.as_str().into()), + )) + }; + let records = vec![record(&first), record(&second)].into_value(); + assert_eq!( + surreal_to_dynamic(&records).unwrap(), + DynamicValue::RefArray(vec![first.clone(), second]) + ); + let mixed = vec![record(&first), "ordinary text".into_value()].into_value(); + assert_eq!( + surreal_to_dynamic(&mixed).unwrap(), + DynamicValue::Array(vec![ + DynamicValue::Ref(first), + DynamicValue::Text("ordinary text".into()), + ]) + ); + let empty = Vec::::new().into_value(); + assert_eq!( + surreal_to_dynamic(&empty).unwrap(), + DynamicValue::Array(vec![]) + ); + } + #[test] fn array_round_trip() { let dv = DynamicValue::Array(vec![DynamicValue::Integer(1), DynamicValue::Integer(2)]); @@ -532,19 +578,25 @@ mod tests { #[test] fn thing_converts_to_ref() { - use surrealdb::sql::{Id, Thing}; + use surrealdb::types::{RecordId, RecordIdKey}; let entity_id = EntityId::new("project"); - let thing = Thing::from(("Project", Id::String(entity_id.as_str().to_string()))); - let sv = SurrealValue::Thing(thing); + let thing = RecordId::new( + "Project", + RecordIdKey::String(entity_id.as_str().to_string()), + ); + let sv = SurrealValue::RecordId(thing); let back = surreal_to_dynamic(&sv).unwrap(); assert!(matches!(back, DynamicValue::Ref(ref id) if id.as_str() == entity_id.as_str())); } #[test] fn thing_non_entity_id_converts_to_text() { - use surrealdb::sql::{Id, Thing}; - let thing = Thing::from(("SomeTable", Id::String("not_an_entity_id".to_string()))); - let sv = SurrealValue::Thing(thing); + use surrealdb::types::{RecordId, RecordIdKey}; + let thing = RecordId::new( + "SomeTable", + RecordIdKey::String("not_an_entity_id".to_string()), + ); + let sv = SurrealValue::RecordId(thing); let back = surreal_to_dynamic(&sv).unwrap(); assert_eq!( back, diff --git a/crates/schema-forge-surrealdb/tests/data_correctness.rs b/crates/schema-forge-surrealdb/tests/data_correctness.rs index a0761295..707f968f 100644 --- a/crates/schema-forge-surrealdb/tests/data_correctness.rs +++ b/crates/schema-forge-surrealdb/tests/data_correctness.rs @@ -9,3 +9,96 @@ async fn null_filters_stable_pages_and_enum_migrations() { .unwrap(); contract::exercise(&backend).await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn competing_clients_cannot_both_replace_the_same_snapshot() { + use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; + use schema_forge_core::{migration::DiffEngine, types::*}; + use schema_forge_surrealdb::SurrealBackend; + use std::collections::BTreeMap; + + let backend = SurrealBackend::connect_memory("cas", "cas").await.unwrap(); + let schema = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("ConcurrentCas").unwrap(), + vec![FieldDefinition::new( + FieldName::new("value").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + )], + vec![], + ) + .unwrap(); + backend + .apply_schema_change( + &schema.name, + &DiffEngine::create_new(&schema).steps, + Some(&schema), + ) + .await + .unwrap(); + let original = DynamicValue::Text("snapshot".into()); + let replacement = DynamicValue::Text("replacement".into()); + let row = backend + .create(&Entity::new( + schema.name.clone(), + BTreeMap::from([("value".into(), original.clone())]), + )) + .await + .unwrap(); + // Independent backend wrappers share the database client, never an + // application mutex. The engine must enforce the conditional write. + let first = SurrealBackend::from_client(backend.client().clone()); + let second = SurrealBackend::from_client(backend.client().clone()); + let field = FieldName::new("value").unwrap(); + for _ in 0..256 { + backend.update(&row).await.unwrap(); + let (replaced, cleared) = tokio::join!( + first.update_field_if_matches(&schema.name, &row.id, &field, &original, &replacement), + second.update_field_if_matches( + &schema.name, + &row.id, + &field, + &original, + &DynamicValue::Null + ), + ); + let replaced = replaced.unwrap(); + let cleared = cleared.unwrap(); + assert_ne!(replaced, cleared, "exactly one competing write commits"); + let stored = backend.get(&schema.name, &row.id).await.unwrap(); + if replaced { + assert_eq!( + stored.field("value"), + Some(&replacement), + "a losing clear must preserve the replacement" + ); + } else { + assert!(matches!( + stored.field("value"), + None | Some(DynamicValue::Null) + )); + } + backend.update(&row).await.unwrap(); + let (first_clear, second_clear) = tokio::join!( + first.update_field_if_matches( + &schema.name, + &row.id, + &field, + &original, + &DynamicValue::Null + ), + second.update_field_if_matches( + &schema.name, + &row.id, + &field, + &original, + &DynamicValue::Null + ), + ); + assert_ne!( + first_clear.unwrap(), + second_clear.unwrap(), + "exactly one clear commits" + ); + } +} diff --git a/crates/schema-forge-surrealdb/tests/migration_renames.rs b/crates/schema-forge-surrealdb/tests/migration_renames.rs new file mode 100644 index 00000000..4ec6be5c --- /dev/null +++ b/crates/schema-forge-surrealdb/tests/migration_renames.rs @@ -0,0 +1,130 @@ +#[path = "../../schema-forge-backend/tests/support/migration_renames.rs"] +mod contract; + +#[tokio::test] +async fn declared_renames_preserve_values_and_schema_constraints() { + let backend = schema_forge_surrealdb::SurrealBackend::connect_memory("renames", "renames") + .await + .unwrap(); + contract::exercise(&backend).await; +} + +#[tokio::test] +async fn metadata_failure_rolls_back_destructive_schema_steps() { + use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; + use schema_forge_core::{migration::DiffEngine, types::*}; + use std::collections::BTreeMap; + let backend = schema_forge_surrealdb::SurrealBackend::connect_memory("atomic", "atomic") + .await + .unwrap(); + let original = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("AtomicSchema").unwrap(), + vec![FieldDefinition::new( + FieldName::new("label").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + )], + vec![], + ) + .unwrap(); + backend + .apply_schema_change( + &original.name, + &DiffEngine::create_new(&original).steps, + Some(&original), + ) + .await + .unwrap(); + let row = Entity { + id: EntityId::new("atomic"), + schema: original.name.clone(), + fields: BTreeMap::from([("label".into(), DynamicValue::Text("retained".into()))]), + }; + backend.create(&row).await.unwrap(); + let mut proposed = original.clone(); + proposed.fields[0].name = FieldName::new("replacement").unwrap(); + let old_json = serde_json::to_string(&original).unwrap(); + backend.client().query("DEFINE FIELD definition ON _schema_metadata TYPE string ASSERT $value = $original_definition;") + .bind(("original_definition", old_json)).await.unwrap().check().unwrap(); + let plan = DiffEngine::plan_update(&original, &proposed).unwrap(); + assert!(backend + .apply_schema_change(&original.name, &plan.steps, Some(&proposed)) + .await + .is_err()); + assert_eq!( + backend.load_schema_metadata(&original.name).await.unwrap(), + Some(original.clone()) + ); + assert_eq!(backend.get(&original.name, &row.id).await.unwrap(), row); +} + +#[tokio::test] +async fn fresh_metadata_reads_are_empty_without_creating_tables() { + use schema_forge_backend::SchemaBackend; + use schema_forge_core::types::SchemaName; + let backend = schema_forge_surrealdb::SurrealBackend::connect_memory("fresh", "fresh") + .await + .unwrap(); + assert!(backend.list_schema_metadata().await.unwrap().is_empty()); + assert!(backend + .load_schema_metadata(&SchemaName::new("Missing").unwrap()) + .await + .unwrap() + .is_none()); + for table in ["_schema_metadata", "Missing"] { + let error = backend + .client() + .query(format!("SELECT * FROM {table};")) + .await + .unwrap() + .check() + .unwrap_err(); + assert!(matches!( + error.not_found_details(), + Some(surrealdb::types::NotFoundError::Table { name }) if name == table + )); + } +} + +#[tokio::test] +async fn metadata_write_checks_statement_failures() { + use schema_forge_backend::SchemaBackend; + use schema_forge_core::types::*; + let backend = schema_forge_surrealdb::SurrealBackend::connect_memory("metadata", "metadata") + .await + .unwrap(); + let mut schema = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("MetadataProbe").unwrap(), + vec![FieldDefinition::new( + FieldName::new("label").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + )], + vec![], + ) + .unwrap(); + schema.fields[0].annotations.push(FieldAnnotation::Require { + expr: "true".into(), + message: "Apostrophe ' and quote \" and backslash \\ and newline\nretained".into(), + }); + backend.store_schema_metadata(&schema).await.unwrap(); + assert_eq!( + backend.load_schema_metadata(&schema.name).await.unwrap(), + Some(schema.clone()) + ); + backend + .client() + .query("DEFINE FIELD definition ON _schema_metadata TYPE string ASSERT $value = $original;") + .bind(("original", serde_json::to_string(&schema).unwrap())) + .await + .unwrap() + .check() + .unwrap(); + let mut changed = schema.clone(); + changed.fields[0].name = FieldName::new("changed").unwrap(); + assert!(backend.store_schema_metadata(&changed).await.is_err()); + assert_eq!( + backend.load_schema_metadata(&schema.name).await.unwrap(), + Some(schema) + ); +} diff --git a/docs/graphql-writes.md b/docs/graphql-writes.md new file mode 100644 index 00000000..9fb9816b --- /dev/null +++ b/docs/graphql-writes.md @@ -0,0 +1,19 @@ +# GraphQL entity writes + +GraphQL `create{Schema}`, `update{Schema}`, and `delete{Schema}` mutations use the same entity write pipelines as REST POST, PATCH, and DELETE. Field authorization runs before defaults, computed expressions, validation rules, and lifecycle hooks. Updates merge authorized input with stored fields before evaluating rules. A mutation containing only denied fields returns the unchanged entity, unless server-generated values change. + +Required fields supplied by a literal default, `@default`, `@compute`, or `@owner` may be omitted from create input. Required fields without a server-supplied value remain mandatory. The completed entity is validated before persistence. + +Mutation responses use the same field projection as REST, including hidden-field removal and field-level read authorization. A GraphQL update has partial-update semantics; it does not behave like REST PUT. + +## Rust integration migration + +`SchemaForgeExtension::register_graphql_routes` now accepts and returns `Router>`. Register GraphQL routes on the service router before supplying the initialized application state. The service must register and initialize `ForgeActor`, as required by the REST routes; register `HookDispatchActor` when lifecycle hooks are enabled. GraphQL no longer writes directly through a separate backend-only state. + +The request context carries both the schema/query state and the initialized actor-backed application state. Embedders constructing `ForgeGraphqlContext` directly must supply its new `app_state` field. + +GraphQL reads capture the actor's current schema definitions and Cedar policy +bundle together at request start. Record checks, field permissions, and hidden +field projection use that immutable request snapshot, including runtime schema +annotation changes. Removed schemas fail closed even when their names remain in +the structural GraphQL schema until restart. All entity mutations use the live actor-backed REST pipeline for each operation. diff --git a/docs/invitations-reference.md b/docs/invitations-reference.md index 1c9da94e..042d4626 100644 --- a/docs/invitations-reference.md +++ b/docs/invitations-reference.md @@ -164,3 +164,7 @@ project_name = "Bob's Dog Scheduling" - **Signed claims authoritative.** On accept, role and tenant come from the cryptographically verified token, not from mutable DB columns. - **Fail-closed delivery.** A send failure is a `5xx` with the invite left `Pending`; it never reports success on undelivered mail. - **No secret on disk.** The SMTP password enters only through the environment. + +## Tenant delegation + +An invitation with a tenant target must supply both `tenant_type` and `tenant_id`. The type must be a configured tenant schema. Before creating the signed invitation or sending email, the server checks that the pair belongs to the caller's effective tenant chain, including any active-tenant narrowing. A cross-tenant target returns 403. Platform administrators may invite into any configured tenant type; incomplete or unknown targets return 422. diff --git a/docs/migrations/safe-schema-changes.md b/docs/migrations/safe-schema-changes.md new file mode 100644 index 00000000..c06b3de9 --- /dev/null +++ b/docs/migrations/safe-schema-changes.md @@ -0,0 +1,166 @@ +# Safe schema changes + +## Rename a field without losing its values + +Declare the previous field name on the replacement field: + +```schema +schema Line { + business_number: text required @renamed_from("number") +} +``` + +`apply`, `migrate`, and `serve` use the hint to rename the existing column. +It remains valid after the rename and can stay in signed schema artifacts. +The old name must identify exactly one removed field. A source and destination +that both already exist, a missing source and destination, duplicate sources, +and self-renames are rejected before migration. PostgreSQL also renames generated indexes and CHECK/foreign-key constraints, +allowing later modifier and enum changes. SurrealDB preserves the full field +definition during its transactional copy. SQL Server renames keys in its tagged +JSON payloads; all steps execute in one transaction and destination collisions +roll back the whole plan. Context-free SurrealDB SQL generation refuses renames +because preserving constraints requires stored schema metadata. + +Without this declaration, changing a field name means removing one field and +adding another. `apply` and `migrate --execute` require confirmation for that +operation. `serve` refuses destructive startup plans before applying any of the +user's schema plans. After reviewing the plan, use +`serve --allow-destructive-migrations` to explicitly accept data loss. +Admin schema PUT requests must include `"allow_destructive_migrations": true` +for destructive changes. Omitted or false values refuse the operation. Lossy +type conversions and enum variant removal also count as destructive. + +Runtime requests preserve omitted schema and field annotations. Explicit +annotation arrays replace them; declared tenancy transitions are rejected. +Creating or deleting tenanted schemas and changing their parent-reference +structure require offline apply and restart, because the actor and HTTP +middleware must activate the same tenant configuration together. Invalid +combined hierarchies are rejected before schema writes. + +Runtime schema changes validate the complete Cedar bundle before storage and +install that exact immutable bundle after storage succeeds. Concurrent changes +with an outdated preflight return HTTP 409 and must be retried. Runtime changes +commit DDL, metadata, and constraint reconciliation in one backend transaction. +Storage failures preserve the prior database state, active registry, and policy +bundle. Backends without atomic schema-change support refuse the operation. +CLI batches can still commit each schema separately; use a maintenance window +for migrations. + +## PostgreSQL relation integrity + +To-one relations use foreign keys to the target table's `id`, with restrictive +deletion behavior. Table creation and later relation additions follow the same +policy. Constraint installation is deferred until the referenced table exists, +so file order and cyclic schemas work. Schema administration finishes with an +idempotent reconciliation pass over stored metadata. `apply`, +`migrate --execute`, and startup also repair legacy missing constraints even +when the schema text has no changes. Read commands and connections do not run +this repair. Dry runs do not install constraints; they do not inspect existing +rows for orphan references. + +Finalization fails if a target table is missing. An orphaned value makes +constraint installation fail with the source schema and field; repair or remove +that orphan and rerun the operation. Already completed migrations are not +rolled back across the whole batch. Run migrations during a maintenance window: +adding a validated foreign key scans existing rows and takes PostgreSQL locks. + +Writing a nonexistent related ID or deleting a referenced record returns HTTP +409 `foreign_key_violation`, with schema and constraint names. It does not +return raw SQL or row values. Existing foreign keys with default `NO ACTION` +remain valid: these are nondeferrable and prevent referenced deletions as well. + +## Changing tenancy requires an explicit data migration + +Adding, removing, or changing `@tenant` on a stored schema is rejected before +automatic DDL or metadata replacement. `--force` and the destructive startup +opt-in do not bypass this check. A schema annotation cannot establish which +tenant owns each existing row. Leaving old rows unattributed or guessing an +owner would change authorization and unique constraints without a sound data +mapping. + +For PostgreSQL, use this procedure with the service and other writers offline. +If a systemd restart policy is configured, mask the service for the maintenance +window. Take a database backup and test the procedure against a restored copy. +Perform tenancy changes separately from field changes. + +1. Apply the complete desired schema set to an empty staging database. Start the staging server once to validate the desired hierarchy. The staging + database provides canonical metadata and DDL. + Compare the staging table's columns, indexes, and constraints with production. +2. Prepare an explicit mapping from every affected row ID to a valid tenant ID + in the desired hierarchy. Verify completeness and tenant existence. `_tenant` + stores the tenant entity ID, without a schema-name prefix. Update token tenant + chains and related tables when changing the hierarchy. +3. In one production transaction, lock affected tables, change the physical + columns/constraints, backfill ownership, verify all invariants, and copy only + the desired annotation array into the existing metadata row. Preserve the + production schema ID and fields. Roll back on any failed check. +4. Unmask any service masked for maintenance, then restart using the matching + desired schema files. Verify tenant-isolated reads + and writes with real tenant principals, and only then restore normal service. + +The following example converts a global `Contact.phone unique` schema to +`@tenant(parent: "Org")`. It assumes Org already exists as a tenant root and +all Contact rows belong to **one explicitly chosen Org**. For multiple tenants, +replace the UPDATE with a join against your reviewed row-to-tenant mapping. + +First export the canonical annotations from the staging database that has the +desired definition: + +```sh +psql "$STAGING_DATABASE_URL" -X -Atc \ + "SELECT definition::jsonb->'annotations' FROM \"_schema_metadata\" WHERE name = 'Contact'" \ + > contact-annotations.json +psql "$PRODUCTION_DATABASE_URL" -X -v ON_ERROR_STOP=1 \ + --set=annotations="$(cat contact-annotations.json)" \ + --set=tenant_id='REPLACE_WITH_VERIFIED_ORG_ID' -f contact-tenancy.sql +``` + +Contents of `contact-tenancy.sql`: + +```sql +BEGIN; +LOCK TABLE "Contact", "Org", "_schema_metadata" IN ACCESS EXCLUSIVE MODE; +ALTER TABLE "Contact" ADD COLUMN "_tenant" TEXT; +UPDATE "Contact" SET "_tenant" = :'tenant_id'; +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM "Contact" c LEFT JOIN "Org" o ON c."_tenant" = o.id + WHERE c."_tenant" IS NULL OR o.id IS NULL + ) THEN RAISE EXCEPTION 'tenant attribution incomplete or invalid'; END IF; +END $$; +ALTER TABLE "Contact" DROP CONSTRAINT "uq_Contact_phone"; +CREATE INDEX "idx_Contact_tenant" ON "Contact" ("_tenant"); +CREATE UNIQUE INDEX "uq_Contact_phone" ON "Contact" ("_tenant", "phone"); +UPDATE "_schema_metadata" +SET definition = jsonb_set(definition::jsonb, '{annotations}', :'annotations'::jsonb) +WHERE name = 'Contact'; +COMMIT; +``` + +Check that exactly one metadata row was updated. The annotation file must come +from the validated desired staging schema, not a hand-invented serialization. +Repeat the unique-constraint conversion for every unique field. + +Other transitions have different requirements: + +- **Adding a root:** add `_tenant` and its index, then set `_tenant = id` on + every root row. Root unique fields stay global. + Establish a valid single-root hierarchy and reviewed ownership values; root + rows represent tenant boundaries and must not be assigned to arbitrary roots. +- **Removing tenancy from a child:** verify and resolve duplicate values across + tenants, drop each composite unique index, create global unique constraints, + then drop `_tenant` (its supporting index is dropped with the column). Removing + isolation changes who can read records; review the resulting policies before + accepting the change. Removing a root also requires updating its descendants. +- **Changing child parent:** keep `_tenant` and composite unique indexes, but + remap every affected row to a valid entity in the new hierarchy. Update + descendants and principal tenant chains together. +- **Root to child:** establish a different valid root, backfill parent ownership, + and replace global unique constraints with composite indexes. +- **Child to root:** establish a valid single-root hierarchy, set `_tenant = id` + on every root row, resolve cross-tenant duplicates, and replace composite + indexes with global unique constraints. + +For every case, commit physical changes, validated ownership, and the canonical +annotation update together. Other database backends require equivalent native +DDL and metadata changes; the PostgreSQL SQL above is not portable. diff --git a/docs/public-reads.md b/docs/public-reads.md index 9c52f782..e0bc763d 100644 --- a/docs/public-reads.md +++ b/docs/public-reads.md @@ -18,3 +18,7 @@ Public schema access does not override record ownership, tenant isolation, expli Custom `RecordAccessPolicy` implementations deny anonymous reads by default. To support public reads, override `filter_visible_optional` and explicitly handle absent claims. Authenticated requests continue through the existing `filter_visible` implementation. The built-in Cedar policy supports both callers without inventing authenticated claims. Embedding applications must opt selected GET/HEAD entity routes into acton-service's optional token authentication and retain schema authorization. Marking an entity prefix as `public_paths` skips token verification and is not an equivalent configuration. + +## Empty role lists + +For `@access` read/write/delete and `@field_access` read/write, explicit `[]` has the same meaning as omission: any authenticated principal, subject to the remaining Cedar policies. It does not deny access. Use a nonempty role list to restrict an action, for example `write: ["platform_admin"]`. `schemaforge parse` reports explicit empty grants as warnings while preserving existing schema semantics. diff --git a/docs/tenant-isolation.md b/docs/tenant-isolation.md new file mode 100644 index 00000000..9a5b9d44 --- /dev/null +++ b/docs/tenant-isolation.md @@ -0,0 +1,11 @@ +# Tenant isolation + +Only schemas with `@tenant(root)` or `@tenant(parent: "...")` receive automatic tenant filters and tenant stamps. Shared schemas remain accessible according to their Cedar policies, including when referenced from tenant-owned rows. + +A root row's identity defines its tenant. New root rows store `_tenant = id`; authorization and list scoping derive that value from `id` for existing roots too. Legacy roots with NULL or inconsistent `_tenant` metadata therefore remain accessible to their own members without exposing other roots or requiring a data rewrite. + +A tenant-owned child must carry valid `_tenant` metadata. Generated Cedar policies deny reads, updates and deletes of missing or NULL child tenants for non-platform administrators. The server regenerates these policies from registered schemas at startup. The canonical query filter and PostgreSQL authorized counts exclude these unstamped child rows. + +Tenant members cannot move rows by supplying `_tenant` in PUT or PATCH: the server removes that input and preserves stored ownership. Platform administrators can reassign child rows. Root identities remain immutable for every caller. + +Invitation tenant targets require a configured tenant schema and a type/id pair in the caller's effective tenant chain. Active-tenant narrowing applies before delegation; platform administrators may delegate across tenants. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 03199c42..5ec58696 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.91.1" +channel = "1.97.1" components = ["rustfmt", "clippy"] profile = "minimal" diff --git a/skills/schemaforge/SKILL.md b/skills/schemaforge/SKILL.md index 956c7d92..ecdd8027 100644 --- a/skills/schemaforge/SKILL.md +++ b/skills/schemaforge/SKILL.md @@ -9,7 +9,7 @@ description: Use when writing, creating, editing, or reviewing SchemaForge .sche SchemaForge is an Adaptive Object Model runtime with a human-readable DSL. One `.schema` file produces database tables, REST API endpoints, migrations, Cedar authorization policies, and OpenAPI specs — no recompilation required. -**Version:** 0.39.1 +**Version:** 0.45.0 **Core principle:** Schemas are the single source of truth for the entire entity lifecycle. Authorization is **Cedar-canonical**: every read/write/delete decision flows through the embedded Cedar engine — there are no parallel custom guards. @@ -25,15 +25,15 @@ integrated SSPI/Kerberos authentication. | Crate | Version | Purpose | |-------|---------|---------| -| `schema-forge-core` | 0.17.0 | Core types: schemas, fields (incl. `FieldType::File`, `Duration`, `Bytes`, `Map`), annotations (incl. `@hidden` and the CEL rule annotations `@require`/`@compute`/`@default`), modifiers (incl. `unique`), migrations (incl. `AddUnique`/`RemoveUnique` with per-tenant scope), queries, hook events | -| `schema-forge-cel` | 0.10.0 | First-party CEL evaluator over `DynamicValue` (ADR-0002): lexer/parser, tree-walking evaluator, stdlib (incl. `base64.encode`/`decode`), apply-time type-checker, and the `related_paths` cross-entity-read AST walker. No upstream `cel` dependency; verified against the cel-spec conformance corpus. | -| `schema-forge-dsl` | 0.13.0 | Lexer/parser for `.schema` DSL (logos-based) incl. `file(...)` syntax, size literals, `duration`/`bytes(max)`/`map` types, the `@hidden` field annotation, the `unique` modifier with parse-time type-guard, and `@require`/`@compute`/`@default` rule annotations (CEL syntax-validated at parse, type-checked at apply) | -| `schema-forge-backend` | 0.17.0 | Backend trait abstraction (depends on acton-service); owns the `PLATFORM_ADMIN_ROLE` constant, `EntityAuthStore` (the user-mgmt impl over the system `User` schema), and the typed `BackendError::UniqueViolation` discriminator | -| `schema-forge-surrealdb` | 0.12.0 | SurrealDB backend implementation (incl. `DEFINE INDEX ... UNIQUE` codegen, unique-violation reclassification, native `duration`/`bytes` storage, and fail-closed rejection of negative durations) | -| `schema-forge-postgres` | 0.12.0 | PostgreSQL backend implementation (via sqlx), incl. JSONB-backed file/map columns, `BIGINT`-nanosecond durations, `BYTEA` bytes with octet-length CHECK, and SQLSTATE 23505 → typed `UniqueViolation` mapping | -| `schema-forge-mssql` | 0.4.0 | Microsoft SQL Server backend via Tiberius and acton-service pools, including integrated SSPI/Kerberos authentication, JSON document storage, CRUD, filtering, sorting, pagination, and aggregates | -| `schema-forge-acton` | 0.41.0 | Axum/acton-service integration: REST API, unified bearer/mTLS/Windows claims, the write-time rule phases (`@default`→`@compute`→`@require`, incl. tenant-scoped cross-entity reads), Cedar policy store, auth, hook dispatch, S3 storage, and typed HTTP errors | -| `schema-forge-cli` | 0.42.0 | CLI binary (`schemaforge`) with SurrealDB, PostgreSQL, and SQL Server release flavors; routes configuration through `acton_service::Config` and supports trusted-proxy Windows authentication | +| `schema-forge-core` | 0.18.0 | Core types: schemas, fields (incl. `FieldType::File`, `Duration`, `Bytes`, `Map`), annotations (incl. `@hidden` and the CEL rule annotations `@require`/`@compute`/`@default`), modifiers (incl. `unique`), migrations (incl. `AddUnique`/`RemoveUnique` with per-tenant scope), queries, hook events | +| `schema-forge-cel` | 0.11.0 | First-party CEL evaluator over `DynamicValue` (ADR-0002): lexer/parser, tree-walking evaluator, stdlib (incl. `base64.encode`/`decode`), apply-time type-checker, and the `related_paths` cross-entity-read AST walker. No upstream `cel` dependency; verified against the cel-spec conformance corpus. | +| `schema-forge-dsl` | 0.14.0 | Lexer/parser for `.schema` DSL (logos-based) incl. `file(...)` syntax, size literals, `duration`/`bytes(max)`/`map` types, the `@hidden` field annotation, the `unique` modifier with parse-time type-guard, and `@require`/`@compute`/`@default` rule annotations (CEL syntax-validated at parse, type-checked at apply) | +| `schema-forge-backend` | 0.18.0 | Backend trait abstraction (depends on acton-service); owns the `PLATFORM_ADMIN_ROLE` constant, `EntityAuthStore` (the user-mgmt impl over the system `User` schema), and the typed `BackendError::UniqueViolation` discriminator | +| `schema-forge-surrealdb` | 0.13.0 | SurrealDB backend implementation (incl. `DEFINE INDEX ... UNIQUE` codegen, unique-violation reclassification, native `duration`/`bytes` storage, and fail-closed rejection of negative durations) | +| `schema-forge-postgres` | 0.13.0 | PostgreSQL backend implementation (via sqlx), incl. JSONB-backed file/map columns, `BIGINT`-nanosecond durations, `BYTEA` bytes with octet-length CHECK, and SQLSTATE 23505 → typed `UniqueViolation` mapping | +| `schema-forge-mssql` | 0.5.0 | Microsoft SQL Server backend via Tiberius and acton-service pools, including integrated SSPI/Kerberos authentication, JSON document storage, CRUD, filtering, sorting, pagination, and aggregates | +| `schema-forge-acton` | 0.44.0 | Axum/acton-service integration: REST API, unified bearer/mTLS/Windows claims, the write-time rule phases (`@default`→`@compute`→`@require`, incl. tenant-scoped cross-entity reads), Cedar policy store, auth, hook dispatch, S3 storage, and typed HTTP errors | +| `schema-forge-cli` | 0.45.0 | CLI binary (`schemaforge`) with SurrealDB, PostgreSQL, and SQL Server release flavors; routes configuration through `acton_service::Config` and supports trusted-proxy Windows authentication | ## Before You Build: acton-service Owns the Platform Layer @@ -883,3 +883,10 @@ For complete details, load these supporting files: ### Audit access (v0.42.0) Platform administrators can use `/api/v1/forge/audit/status`, `/audit/events` and `/audit/verify` for deployment-wide audit browsing and bounded chain checks. Reuses acton-service 0.42.0 storage, including the 0.40.1 suffix-verification fix. Tenant administrators cannot access these routes. See [audit API reference](../../docs/audit-api-reference.md) for the field projection, fixed upper sequence pagination, limits, permission flags and verification trust boundaries. + + +### Safe schema changes (v0.45.0) + +Use `@renamed_from("old_name")` on a replacement field to preserve values across a rename. Startup refuses destructive migration plans unless `serve --allow-destructive-migrations` is explicit; runtime schema PUT requires `allow_destructive_migrations: true`. Existing tenancy annotations cannot change through automatic migration because ownership and unique constraints need an explicit data migration. PostgreSQL schema administration reconciles legacy missing relation foreign keys and refuses orphaned references. See [safe schema changes](../../docs/migrations/safe-schema-changes.md). + +Field write authorization precedes defaults, computed expressions, rules, and hooks. Required fields are validated after server-supplied values; omitted optional fields bind to null in rules. PUT clears omitted writable optional fields, while PATCH retains partial-update semantics. GraphQL mutations share the same write pipeline. See [rule ordering](../../docs/rule-ordering-reference.md) and [GraphQL writes](../../docs/graphql-writes.md). diff --git a/skills/schemaforge/dsl-reference.md b/skills/schemaforge/dsl-reference.md index 99b5f8d0..ba8f2033 100644 --- a/skills/schemaforge/dsl-reference.md +++ b/skills/schemaforge/dsl-reference.md @@ -442,6 +442,10 @@ schema Department { ... } ### @access(read: [...], write: [...], delete: [...], cross_tenant_read: [...]) +For `read`, `write`, and `delete`, an omitted role list and an explicit `[]` both grant access to every authenticated principal. **An empty list does not mean nobody.** For example, `@access(read: ["staff"], write: [], delete: [])` lets any authenticated user create, update, and delete, subject to other Cedar forbids such as tenant and owner guards. Use a nonempty list, such as `write: ["platform_admin"], delete: ["platform_admin"]`, to restrict those actions. `schemaforge parse` warns about explicit empty grants. + +`cross_tenant_read: []` does not grant cross-tenant access. Anonymous access requires the explicit `"public"` role in `read`; empty grants require authentication. + Role-based access control. Generates Cedar authorization policies. ``` @@ -525,6 +529,16 @@ Export is gated by a **distinct Cedar `Export{Entity}` action**, not `Read` — Field-level annotations appear after modifiers on the field line. +### @renamed_from("old_name") + +Declare a field rename during migration: + +```schema +business_number: text required @renamed_from("number") +``` + +The old field must be removed from the desired schema. A source may name only one replacement, and a rename cannot overwrite another declared field. The hint becomes a no-op after migration and may remain in a signed schema file. Removing it is necessary before reusing the old name for a different field. PostgreSQL renames its column and generated constraints; SurrealDB and SQL Server preserve the stored values through their backend migration implementations. See [safe schema changes](../../docs/migrations/safe-schema-changes.md). + ### @owner Marks a field as the record ownership tracker. @@ -646,6 +660,8 @@ pipeline_stage: enum( ### @field_access(read: [...], write: [...]) +An omitted direction or an explicit empty list grants that direction to every authenticated user who can access the entity. For example, `@field_access(read: ["hr"], write: [])` allows any authenticated entity writer to write the field. To restrict writes, provide a nonempty role list such as `write: ["hr"]` or `write: ["platform_admin"]`. + Field-level access control — restricts who can read/write specific fields. ```