Skip to content

Commit 40ef3bd

Browse files
committed
fix(api): retain immutable owner in replacement rule context
1 parent 91cd893 commit 40ef3bd

5 files changed

Lines changed: 93 additions & 7 deletions

File tree

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
@@ -1,6 +1,6 @@
11
[package]
22
name = "schema-forge-acton"
3-
version = "0.37.2"
3+
version = "0.37.3"
44
edition = "2021"
55

66
[dependencies]

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2923,6 +2923,13 @@ pub async fn update_entity(
29232923
.map_err(|errors| ForgeError::ValidationFailed { details: errors })?;
29242924

29252925
strip_owner_on_update(&mut fields, &schema_def);
2926+
// PUT replaces supplied fields, but immutable ownership remains part of
2927+
// the post-update rule context, even when an administrator is the caller.
2928+
if let Some(owner_field) = schema_def.fields.iter().find(|field| field.has_owner()) {
2929+
if let Some(owner) = existing.fields.get(owner_field.name.as_str()) {
2930+
fields.insert(owner_field.name.as_str().to_string(), owner.clone());
2931+
}
2932+
}
29262933
// Single request-time instant reused for audit columns and the `now` CEL binding.
29272934
let rules_now = chrono::Utc::now();
29282935
inject_audit_columns_on_update(&mut fields, &schema_def, claims.as_ref(), rules_now);

crates/schema-forge-acton/tests/conditional_entities.rs

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ async fn fixture_with_backend(
6262
vec![],
6363
vec![FieldAnnotation::Owner],
6464
),
65+
FieldDefinition::with_annotations(
66+
FieldName::new("name_key").unwrap(),
67+
FieldType::Text(TextConstraints::unconstrained()),
68+
vec![],
69+
vec![
70+
FieldAnnotation::Hidden,
71+
FieldAnnotation::Compute {
72+
expr: "string(size(owner)) + ':' + owner + title".into(),
73+
},
74+
],
75+
),
6576
FieldDefinition::with_annotations(
6677
FieldName::new("restricted").unwrap(),
6778
FieldType::Text(TextConstraints::unconstrained()),
@@ -104,6 +115,15 @@ async fn fixture_with_backend(
104115
DynEntityStore::create(backend.as_ref(), &entity)
105116
.await
106117
.unwrap();
118+
let app = app_with_backend(backend, schema, roles).await;
119+
(app, format!("/schemas/Note/entities/{}", entity.id))
120+
}
121+
122+
async fn app_with_backend(
123+
backend: Arc<dyn DynForgeBackend>,
124+
schema: SchemaDefinition,
125+
roles: &[&str],
126+
) -> Router {
107127
let service = ServiceBuilder::new()
108128
.with_config(Config::<SchemaForgeConfig>::default())
109129
.with_actor::<ForgeActor>()
@@ -142,7 +162,7 @@ async fn fixture_with_backend(
142162
username: None,
143163
custom: HashMap::new(),
144164
};
145-
let app = forge_routes()
165+
forge_routes()
146166
.layer(axum::middleware::from_fn(
147167
move |mut req: axum::extract::Request, next: axum::middleware::Next| {
148168
let caller = caller.clone();
@@ -152,8 +172,7 @@ async fn fixture_with_backend(
152172
}
153173
},
154174
))
155-
.with_state(service.state().clone());
156-
(app, format!("/schemas/Note/entities/{}", entity.id))
175+
.with_state(service.state().clone())
157176
}
158177

159178
async fn request(
@@ -329,6 +348,7 @@ fn assert_revision_conflict(result: &(StatusCode, axum::http::HeaderMap, serde_j
329348
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
330349
#[ignore = "requires a scoped disposable PostgreSQL URL and SCHEMAFORGE_TEST_POSTGRES_DISPOSABLE=1"]
331350
async fn postgres_http_revisions_guard_updates_noops_races_and_deletes() {
351+
use schema_forge_acton::state::DynSchemaBackend;
332352
assert_eq!(
333353
std::env::var("SCHEMAFORGE_TEST_POSTGRES_DISPOSABLE").as_deref(),
334354
Ok("1"),
@@ -624,6 +644,65 @@ async fn postgres_http_revisions_guard_updates_noops_races_and_deletes() {
624644
assert_eq!(&response_revision(&headers), baseline);
625645
}
626646
}
647+
let schema = DynSchemaBackend::load_schema_metadata(backend.as_ref(), &other.schema)
648+
.await
649+
.unwrap()
650+
.unwrap();
651+
let admin_app = app_with_backend(backend.clone(), schema, &["platform_admin"]).await;
652+
for (index, conditional) in [false, true].into_iter().enumerate() {
653+
for supplied_owner in [None, Some("attempted-transfer")] {
654+
let (_, headers, _) =
655+
request(&admin_app, &other_path, "GET", None, serde_json::json!({})).await;
656+
let baseline = response_revision(&headers);
657+
let title = format!("Admin {index} {}", supplied_owner.unwrap_or("omitted"));
658+
let mut body = serde_json::json!({"title": title});
659+
if let Some(owner) = supplied_owner {
660+
body["owner"] = serde_json::json!(owner);
661+
}
662+
let (status, headers, response) = request(
663+
&admin_app,
664+
&other_path,
665+
"PUT",
666+
conditional.then_some(baseline.as_str()),
667+
body,
668+
)
669+
.await;
670+
assert_eq!(status, StatusCode::OK, "{response}");
671+
assert_eq!(response["fields"]["owner"], "other");
672+
assert!(response["fields"].get("name_key").is_none());
673+
if conditional {
674+
assert_ne!(response_revision(&headers), baseline);
675+
}
676+
let stored = DynEntityStore::get(backend.as_ref(), &other.schema, &other.id)
677+
.await
678+
.unwrap();
679+
assert_eq!(stored.fields["owner"], DynamicValue::Text("other".into()));
680+
assert_eq!(
681+
stored.fields["name_key"],
682+
DynamicValue::Text(format!("5:other{title}"))
683+
);
684+
}
685+
}
686+
}
687+
688+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
689+
async fn ordinary_put_computation_preserves_immutable_owner() {
690+
for (owner, roles) in [
691+
("editor", vec!["editor"]),
692+
("other", vec!["platform_admin"]),
693+
] {
694+
let (app, path) = fixture(owner, &roles).await;
695+
for supplied_owner in [None, Some("attempted-transfer")] {
696+
let mut body = serde_json::json!({"title": "Replacement"});
697+
if let Some(value) = supplied_owner {
698+
body["owner"] = serde_json::json!(value);
699+
}
700+
let (status, _, response) = request(&app, &path, "PUT", None, body).await;
701+
assert_eq!(status, StatusCode::OK, "{response}");
702+
assert_eq!(response["fields"]["owner"], owner);
703+
assert!(response["fields"].get("name_key").is_none());
704+
}
705+
}
627706
}
628707

629708
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

crates/schema-forge-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "schema-forge-cli"
3-
version = "0.38.2"
3+
version = "0.38.3"
44
edition = "2021"
55

66
[[bin]]

0 commit comments

Comments
 (0)