diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index b400ace..09cf3ae 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -237,9 +237,9 @@ pub fn filter_entity_fields( schema: &SchemaDefinition, claims: Option<&Claims>, direction: FieldFilterDirection, -) { +) -> Vec { if claims.is_none() { - return; + return Vec::new(); } let cedar_dir = match direction { @@ -268,9 +268,10 @@ pub fn filter_entity_fields( .cloned() .collect(); - for name in fields_to_remove { - entity.fields.remove(&name); + for name in &fields_to_remove { + entity.fields.remove(name); } + fields_to_remove } /// Inject tenant scoping filter into a query. diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index 632000f..52607e9 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -2289,6 +2289,21 @@ pub async fn update_entity( .await; let existing = ask_forge(rx).await?.map_err(ForgeError::from)?; if !policy.can_modify(&schema_def, c, &existing).await { + if let Some(logger) = state.audit_logger() { + logger + .log_custom( + "forge.access.denied", + acton_service::audit::AuditSeverity::Warning, + Some(serde_json::json!({ + "schema": &schema, + "entity_id": existing.id.as_str(), + "action": "update", + "user": &c.sub, + "reason": "record_can_modify", + })), + ) + .await; + } return Err(ForgeError::Forbidden { message: format!("not authorized to modify entity '{id}'"), }); @@ -2501,6 +2516,21 @@ pub async fn patch_entity( if let (Some(ref policy), Some(ref c)) = (&record_access_policy, &claims) { if !policy.can_modify(&schema_def, c, &existing).await { + if let Some(logger) = state.audit_logger() { + logger + .log_custom( + "forge.access.denied", + acton_service::audit::AuditSeverity::Warning, + Some(serde_json::json!({ + "schema": &schema, + "entity_id": existing.id.as_str(), + "action": "patch", + "user": &c.sub, + "reason": "record_can_modify", + })), + ) + .await; + } return Err(ForgeError::Forbidden { message: format!("not authorized to modify entity '{id}'"), }); @@ -2736,6 +2766,21 @@ pub async fn delete_entity( .await; let entity = ask_forge(rx).await?.map_err(ForgeError::from)?; if !policy.can_delete(&schema_def, c, &entity).await { + if let Some(logger) = state.audit_logger() { + logger + .log_custom( + "forge.access.denied", + acton_service::audit::AuditSeverity::Warning, + Some(serde_json::json!({ + "schema": &schema, + "entity_id": entity.id.as_str(), + "action": "delete", + "user": &c.sub, + "reason": "record_can_delete", + })), + ) + .await; + } return Err(ForgeError::Forbidden { message: format!("not authorized to delete entity '{id}'"), }); diff --git a/crates/schema-forge-acton/src/routes/files.rs b/crates/schema-forge-acton/src/routes/files.rs index 51b2cec..9145be6 100644 --- a/crates/schema-forge-acton/src/routes/files.rs +++ b/crates/schema-forge-acton/src/routes/files.rs @@ -63,6 +63,46 @@ struct FileTarget<'a> { field: &'a str, } +/// Wrap a `check_schema_access` call so any deny lands in the durable +/// audit chain before the error propagates to the client. +/// +/// Without this, schema-level access denials on file routes only ever +/// reach `tracing` via the Cedar engine — they never reach the +/// hash-chained audit ledger. The route-layer audit entry carries the +/// schema/entity/field triple the engine doesn't see, which is what an +/// incident responder needs to triage "who tried to grab what". +async fn check_file_schema_access( + state: &AppState, + policy_store: &Arc, + ctx: &FileContext, + field: &str, + claims: Option<&Claims>, + access: AccessAction, +) -> Result<(), ForgeError> { + match check_schema_access(policy_store, &ctx.schema, claims, access) { + Ok(()) => Ok(()), + Err(e) => { + audit_file( + state, + "forge.access.denied", + AuditSeverity::Warning, + claims.map(|c| c.sub.as_str()), + FileTarget { + schema: ctx.schema.name.as_str(), + entity_id: ctx.entity.id.as_str(), + field, + }, + serde_json::json!({ + "action": format!("{access:?}"), + "reason": "schema_access", + }), + ) + .await; + Err(e) + } + } +} + /// Emit a file-lifecycle audit event to the durable chain. /// /// File handlers carry highly variable per-event metadata (key, mime, size, @@ -170,7 +210,15 @@ pub async fn mint_upload_url( ) -> Result, ForgeError> { let ctx = load_file_context(&state, &schema, &entity_id, &field, claims.as_ref()).await?; let policy_store = fetch_policy_store(&state).await?; - check_schema_access(&policy_store, &ctx.schema, claims.as_ref(), AccessAction::Write)?; + check_file_schema_access( + &state, + &policy_store, + &ctx, + &field, + claims.as_ref(), + AccessAction::Write, + ) + .await?; validate_upload_request(&body, &ctx.constraints)?; @@ -261,7 +309,15 @@ pub async fn confirm_upload( ) -> Result, ForgeError> { let ctx = load_file_context(&state, &schema, &entity_id, &field, claims.as_ref()).await?; let policy_store = fetch_policy_store(&state).await?; - check_schema_access(&policy_store, &ctx.schema, claims.as_ref(), AccessAction::Write)?; + check_file_schema_access( + &state, + &policy_store, + &ctx, + &field, + claims.as_ref(), + AccessAction::Write, + ) + .await?; // Reject keys that don't match the expected per-entity/per-field prefix. // Prevents a caller from attaching a confirmed object that was uploaded @@ -429,6 +485,22 @@ pub async fn scan_complete( message: "scan-complete requires authenticated platform_admin".into(), })?; if !caller.has_role(PLATFORM_ADMIN_ROLE) { + audit_file( + &state, + "forge.access.denied", + AuditSeverity::Warning, + Some(&caller.sub), + FileTarget { + schema: &schema, + entity_id: &entity_id, + field: &field, + }, + serde_json::json!({ + "action": "scan_complete", + "reason": "platform_admin_required", + }), + ) + .await; return Err(ForgeError::Forbidden { message: "scan-complete requires platform_admin role".into(), }); @@ -538,7 +610,15 @@ pub async fn download_file( ) -> Result { let ctx = load_file_context(&state, &schema, &entity_id, &field, claims.as_ref()).await?; let policy_store = fetch_policy_store(&state).await?; - check_schema_access(&policy_store, &ctx.schema, claims.as_ref(), AccessAction::Read)?; + check_file_schema_access( + &state, + &policy_store, + &ctx, + &field, + claims.as_ref(), + AccessAction::Read, + ) + .await?; let attachment = current_attachment(&ctx.entity, &field).ok_or_else(|| { ForgeError::EntityNotFound {