Skip to content

Commit 3e5ec86

Browse files
committed
chore: merge main into feat/conditional-entity-mutations
Resolves the patch-handler conflict between the in-process constraint check (#133) and the conditional revision persist helper: the constraint check now runs before persist_entity_update, matching the replace path.
2 parents 40ef3bd + 2998dac commit 3e5ec86

19 files changed

Lines changed: 978 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,16 @@ is pre-1.0; breaking changes bump the **minor** version per
111111

112112
### Changed
113113

114+
- Upgraded `acton-service` to **0.39.0** in `schema-forge-acton`,
115+
`schema-forge-backend`, `schema-forge-cli`, and `schema-forge-mssql`. The
116+
release is additive: it introduces a SAML 2.0 service provider behind a new
117+
`saml` feature and changes nothing in the features this workspace already
118+
enables. SchemaForge does **not** turn `saml` on yet — acton-service ships
119+
the SP as a library (`SamlServiceProvider`, config, replay/pending stores),
120+
not as mounted routes, so consuming it means wiring
121+
`/saml/metadata`, `/saml/login`, and `/saml/acs`, plumbing
122+
`[auth.saml]` config, and deciding how an assertion maps onto a `User`
123+
entity and a tenant. Tracked separately.
114124
- Upgraded `acton-service` to **0.26** in `schema-forge-acton`,
115125
`schema-forge-cli`, and `schema-forge-backend`. 0.26's new
116126
`crypto-aws-lc-rs` feature (enabled by default in our build) propagates
@@ -122,6 +132,36 @@ is pre-1.0; breaking changes bump the **minor** version per
122132

123133
### Fixed
124134

135+
- **`enum`, `text(max:)`, and `integer(min:/max:)` are now enforced
136+
in-process.** They were declared in the DSL but never checked before the
137+
write reached the database, so the only thing refusing them was the
138+
generated `CHECK` constraint (or `VARCHAR(n)`) — and a check violation is
139+
not mapped to a client error. A caller sending `kind: "gamma"` to an
140+
`enum("alpha", "beta")` field got `502 backend_unavailable`, a retryable
141+
status for a request that could never succeed, with a raw driver message
142+
and no field name. `FieldType::check_value` now checks every declared
143+
constraint and the write path answers `422 validation_failed` naming the
144+
field and, for an enum, the allowed variants. The check runs at the last
145+
seam before the backend, so it also covers values produced by `@default`
146+
and `@compute` rules and by `before_*` hooks — not only client JSON.
147+
Nothing about which writes succeed changes; only the status code, the
148+
message, and where the refusal happens.
149+
**Fixes [#133](https://github.com/Govcraft/schemaforge/issues/133).**
150+
- **`unique` on a `@tenant(root)` schema is enforced again.** The unique
151+
index was scoped to `(_tenant, field)` for every schema carrying any
152+
`@tenant` annotation, root included. On a root schema that does not weaken
153+
the constraint, it removes it: `_tenant` is NULL on a platform-level
154+
create and PostgreSQL treats NULLs as distinct, so two organizations could
155+
carry the same `short_code` and the migration would still apply cleanly.
156+
Root-tenant rows are also the rows most likely to need global uniqueness —
157+
they *are* the tenants, so their identifying fields have to be unique
158+
table-wide by definition, and there is no outer tenant to scope them to.
159+
The scoping decision now runs off the new
160+
`SchemaDefinition::unique_scoped_by_tenant`, which is true only for
161+
`@tenant(parent: ...)`. `is_tenanted` keeps its old meaning and still
162+
drives the `_tenant` column; the two questions were being answered by one
163+
predicate. Affects the PostgreSQL and SurrealDB backends alike.
164+
**Fixes [#134](https://github.com/Govcraft/schemaforge/issues/134).**
125165
- `schema-forge-backend` was still pinned to `acton-service 0.23` while
126166
the rest of the workspace had moved to 0.26.1. The dual-version
127167
trait mismatch refused to compile (`filter_visible`/`can_modify`/
@@ -157,6 +197,54 @@ is pre-1.0; breaking changes bump the **minor** version per
157197

158198
### Migration
159199

200+
#### `unique` on a tenant root (#134)
201+
202+
New databases need nothing. An **existing** database applied before this fix
203+
still carries the tenant-scoped index, and the schema diff cannot see the
204+
difference: the stored schema is unchanged, so no `AddUnique` step is
205+
emitted and the stale index stays. The scope changed in the code, not in the
206+
schema.
207+
208+
Check for it, then replace it. For each `@tenant(root)` schema with a
209+
`unique` field, on PostgreSQL:
210+
211+
```sql
212+
-- Confirm the stale shape: indexdef will name (_tenant, <field>).
213+
SELECT indexdef FROM pg_indexes WHERE indexname = 'uq_Organization_short_code';
214+
215+
-- Find duplicates the broken index let through, and resolve them first —
216+
-- the ALTER below will fail while any remain.
217+
SELECT short_code, count(*) FROM "Organization"
218+
GROUP BY short_code HAVING count(*) > 1;
219+
220+
DROP INDEX "uq_Organization_short_code";
221+
ALTER TABLE "Organization"
222+
ADD CONSTRAINT "uq_Organization_short_code" UNIQUE ("short_code");
223+
```
224+
225+
The old index and the new constraint share a name, so drop before adding.
226+
On SurrealDB the equivalent is `REMOVE INDEX uq_Organization_short_code ON
227+
Organization;` followed by the `DEFINE INDEX ... FIELDS short_code UNIQUE;`
228+
that `schemaforge apply` would now emit.
229+
230+
Run the duplicate query before scheduling the change. A deployment that has
231+
been live on the broken index may already hold rows that global uniqueness
232+
would reject, and that is a data decision, not a migration step.
233+
234+
#### Constraint violations now return 422 (#133)
235+
236+
No schema or config change. Clients that were treating a `502` from a write
237+
as "backend down, retry" will now see `422` for a value that violates a
238+
declared `enum`, `text(max:)`, or `integer(min:/max:)` constraint. That is
239+
the point — the request was never going to succeed — but any retry logic
240+
keyed on the old status should be checked.
241+
242+
Projects that worked around this by declaring a `@require` CEL rule
243+
alongside the column constraint (`integer(min: 1, max: 5) required
244+
@require("size >= 1 && size <= 5", "...")`) can drop the rule; the column
245+
declaration now produces a `422` on its own. Keeping it is harmless — it
246+
simply fires first, with its own message.
247+
160248
#### Demo-user seeding (security fix)
161249

162250
Operators upgrading from `schema-forge-cli` 0.27.x:

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/schema-forge-acton/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ serde = { version = "1", features = ["derive"] }
1111
serde_json = "1"
1212
chrono = { version = "0.4", features = ["serde"] }
1313
tokio = { version = "1", features = ["sync"] }
14-
acton-service = { version = "0.38.0", default-features = false, features = ["http", "observability", "otel-metrics", "journald", "governor", "resilience", "audit", "openapi", "auth", "crypto-aws-lc-rs", "grpc", "tls", "windows-auth"] }
14+
acton-service = { version = "0.39.0", default-features = false, features = ["http", "observability", "otel-metrics", "journald", "governor", "resilience", "audit", "openapi", "auth", "crypto-aws-lc-rs", "grpc", "tls", "windows-auth"] }
1515
schema-forge-dsl = { path = "../schema-forge-dsl" }
1616
schema-forge-surrealdb = { path = "../schema-forge-surrealdb", optional = true }
1717
schema-forge-postgres = { path = "../schema-forge-postgres", optional = true }

crates/schema-forge-acton/src/routes/entities.rs

Lines changed: 160 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use schema_forge_backend::conditional::{ConditionalMutationError, EntityRevision
1212
use schema_forge_backend::entity::Entity;
1313
use schema_forge_core::query::{validate_filter, FieldPath, Filter, SortOrder};
1414
use schema_forge_core::types::{
15-
Cardinality, DynamicValue, EntityId, FieldType, SchemaDefinition, SchemaName,
15+
Cardinality, ConstraintViolation, DynamicValue, EntityId, FieldType, SchemaDefinition,
16+
SchemaName,
1617
};
1718
use serde::{Deserialize, Serialize};
1819
use tokio::sync::oneshot;
@@ -817,14 +818,50 @@ pub fn json_to_entity_fields_with_mode(
817818
/// this to a 422), never truncated or silently accepted.
818819
fn enforce_bytes_max_size(bytes: &[u8], max_size: Option<usize>) -> Result<(), String> {
819820
match max_size {
820-
Some(max) if bytes.len() > max => Err(format!(
821-
"bytes value of {} bytes exceeds the field's max_size of {max} bytes",
822-
bytes.len()
823-
)),
821+
Some(max) if bytes.len() > max => Err(ConstraintViolation::BytesTooLarge {
822+
len: bytes.len(),
823+
max,
824+
}
825+
.to_string()),
824826
_ => Ok(()),
825827
}
826828
}
827829

830+
/// Check every value in a write against the constraints declared on its
831+
/// field's type, collecting all violations into a single 422.
832+
///
833+
/// Runs at the last seam before the backend, which is what makes it
834+
/// complete: by this point the field map holds client JSON, `@default` and
835+
/// `@compute` rule output, server-injected columns, and anything a
836+
/// `before_*` hook substituted. Checking earlier would leave the later
837+
/// sources unguarded, and an unguarded violation reaches the database, whose
838+
/// refusal arrives as an untyped driver error and surfaces as a 502 — a
839+
/// retryable status for a request that can never succeed. See #133.
840+
///
841+
/// Values whose names are not in the schema (`_tenant` and friends) carry no
842+
/// declared constraints and are skipped.
843+
fn check_field_constraints(
844+
schema: &SchemaDefinition,
845+
fields: &BTreeMap<String, DynamicValue>,
846+
) -> Result<(), ForgeError> {
847+
let details: Vec<String> = fields
848+
.iter()
849+
.filter_map(|(name, value)| {
850+
let field_def = schema.field(name)?;
851+
field_def
852+
.field_type
853+
.check_value(value)
854+
.err()
855+
.map(|violation| format!("field '{name}': {violation}"))
856+
})
857+
.collect();
858+
if details.is_empty() {
859+
Ok(())
860+
} else {
861+
Err(ForgeError::ValidationFailed { details })
862+
}
863+
}
864+
828865
fn convert_json_with_type_hint(
829866
value: &serde_json::Value,
830867
field_type: &FieldType,
@@ -2272,6 +2309,7 @@ pub async fn create_entity(
22722309
claims.as_ref(),
22732310
FieldFilterDirection::Write,
22742311
);
2312+
check_field_constraints(&schema_def, &entity.fields)?;
22752313

22762314
// Create entity via actor (supervised backend call)
22772315
let (tx, rx) = oneshot::channel();
@@ -3014,6 +3052,7 @@ pub async fn update_entity(
30143052
claims.as_ref(),
30153053
FieldFilterDirection::Write,
30163054
);
3055+
check_field_constraints(&schema_def, &entity.fields)?;
30173056

30183057
let (mut updated, revision) = persist_entity_update(&forge, entity, expected).await?;
30193058

@@ -3319,6 +3358,7 @@ pub async fn patch_entity(
33193358
claims.as_ref(),
33203359
FieldFilterDirection::Write,
33213360
);
3361+
check_field_constraints(&schema_def, &entity.fields)?;
33223362
persist_entity_update(&forge, entity, expected).await?
33233363
};
33243364

@@ -3703,6 +3743,121 @@ mod tests {
37033743
.unwrap()
37043744
}
37053745

3746+
// ---- check_field_constraints (#133) ----
3747+
3748+
/// The neutral repro from #133: every constraint the DSL can express,
3749+
/// on one schema.
3750+
fn make_constrained_schema() -> SchemaDefinition {
3751+
use schema_forge_core::types::{EnumVariants, IntegerConstraints};
3752+
SchemaDefinition::new(
3753+
SchemaId::new(),
3754+
SchemaName::new("Widget").unwrap(),
3755+
vec![
3756+
FieldDefinition::new(
3757+
FieldName::new("name").unwrap(),
3758+
FieldType::Text(TextConstraints::with_max_length(10)),
3759+
),
3760+
FieldDefinition::new(
3761+
FieldName::new("size").unwrap(),
3762+
FieldType::Integer(IntegerConstraints::with_range(1, 5).unwrap()),
3763+
),
3764+
FieldDefinition::new(
3765+
FieldName::new("kind").unwrap(),
3766+
FieldType::Enum(
3767+
EnumVariants::new(vec!["alpha".into(), "beta".into()]).unwrap(),
3768+
),
3769+
),
3770+
],
3771+
vec![],
3772+
)
3773+
.unwrap()
3774+
}
3775+
3776+
fn constraint_errors(fields: &[(&str, DynamicValue)]) -> Vec<String> {
3777+
let map: BTreeMap<String, DynamicValue> = fields
3778+
.iter()
3779+
.map(|(k, v)| ((*k).to_string(), v.clone()))
3780+
.collect();
3781+
match check_field_constraints(&make_constrained_schema(), &map) {
3782+
Ok(()) => Vec::new(),
3783+
Err(ForgeError::ValidationFailed { details }) => details,
3784+
Err(other) => panic!("expected ValidationFailed, got {other:?}"),
3785+
}
3786+
}
3787+
3788+
#[test]
3789+
fn check_field_constraints_accepts_a_conforming_write() {
3790+
assert!(constraint_errors(&[
3791+
("name", DynamicValue::Text("ok".into())),
3792+
("size", DynamicValue::Integer(3)),
3793+
("kind", DynamicValue::Enum("alpha".into())),
3794+
])
3795+
.is_empty());
3796+
}
3797+
3798+
#[test]
3799+
fn check_field_constraints_rejects_an_unknown_enum_variant() {
3800+
let errors = constraint_errors(&[("kind", DynamicValue::Enum("gamma".into()))]);
3801+
assert_eq!(errors.len(), 1);
3802+
assert!(
3803+
errors[0].contains("field 'kind'")
3804+
&& errors[0].contains("gamma")
3805+
&& errors[0].contains("alpha, beta"),
3806+
"the 422 must name the field and the allowed variants, got: {}",
3807+
errors[0]
3808+
);
3809+
}
3810+
3811+
#[test]
3812+
fn check_field_constraints_rejects_an_over_length_text() {
3813+
let errors = constraint_errors(&[(
3814+
"name",
3815+
DynamicValue::Text("this is far too long".into()),
3816+
)]);
3817+
assert_eq!(errors.len(), 1);
3818+
assert!(errors[0].contains("field 'name'"), "got: {}", errors[0]);
3819+
}
3820+
3821+
#[test]
3822+
fn check_field_constraints_rejects_an_out_of_range_integer() {
3823+
assert_eq!(constraint_errors(&[("size", DynamicValue::Integer(0))]).len(), 1);
3824+
assert_eq!(constraint_errors(&[("size", DynamicValue::Integer(9))]).len(), 1);
3825+
}
3826+
3827+
#[test]
3828+
fn check_field_constraints_reports_every_violation_at_once() {
3829+
// One round trip should tell the caller everything that is wrong,
3830+
// the way the existing type and required-field errors already do.
3831+
let errors = constraint_errors(&[
3832+
("name", DynamicValue::Text("this is far too long".into())),
3833+
("size", DynamicValue::Integer(0)),
3834+
("kind", DynamicValue::Enum("gamma".into())),
3835+
]);
3836+
assert_eq!(errors.len(), 3, "got: {errors:?}");
3837+
}
3838+
3839+
#[test]
3840+
fn check_field_constraints_skips_fields_not_in_the_schema() {
3841+
// Server-injected columns like `_tenant` carry no declared
3842+
// constraints and must pass straight through.
3843+
assert!(constraint_errors(&[
3844+
("_tenant", DynamicValue::Text("org_0123456789".repeat(10))),
3845+
])
3846+
.is_empty());
3847+
}
3848+
3849+
#[test]
3850+
fn check_field_constraints_ignores_nulls() {
3851+
// Nullability is the `required` modifier's job, enforced in
3852+
// `json_to_entity_fields_with_mode`.
3853+
assert!(constraint_errors(&[
3854+
("kind", DynamicValue::Null),
3855+
("size", DynamicValue::Null),
3856+
("name", DynamicValue::Null),
3857+
])
3858+
.is_empty());
3859+
}
3860+
37063861
#[test]
37073862
fn json_to_entity_fields_basic() {
37083863
let schema = make_test_schema();

crates/schema-forge-backend/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version = "0.14.0"
44
edition = "2021"
55

66
[dependencies]
7-
acton-service = { version = "0.38.0", default-features = false, features = ["crypto-aws-lc-rs"] }
7+
acton-service = { version = "0.39.0", default-features = false, features = ["crypto-aws-lc-rs"] }
88
argon2 = { version = "0.5", features = ["std"] }
99
async-trait = "0.1.89"
1010
chrono = { version = "0.4.44", features = ["serde"] }

crates/schema-forge-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ console = "0.15"
2727
dialoguer = "0.11"
2828
glob = "0.3"
2929
axum = { version = "0.8" }
30-
acton-service = { version = "0.38.0", default-features = false, features = ["http", "observability", "otel-metrics", "journald", "governor", "resilience", "audit", "openapi", "auth", "crypto-aws-lc-rs", "windows-auth"] }
30+
acton-service = { version = "0.39.0", default-features = false, features = ["http", "observability", "otel-metrics", "journald", "governor", "resilience", "audit", "openapi", "auth", "crypto-aws-lc-rs", "windows-auth"] }
3131
heck = "0.5.0"
3232
minijinja = "2.19.0"
3333
tracing = "0.1.44"

0 commit comments

Comments
 (0)