Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions crates/schema-forge-acton/src/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,9 @@ pub fn filter_entity_fields(
schema: &SchemaDefinition,
claims: Option<&Claims>,
direction: FieldFilterDirection,
) {
) -> Vec<String> {
if claims.is_none() {
return;
return Vec::new();
}

let cedar_dir = match direction {
Expand Down Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions crates/schema-forge-acton/src/routes/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}'"),
});
Expand Down Expand Up @@ -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}'"),
});
Expand Down Expand Up @@ -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}'"),
});
Expand Down
86 changes: 83 additions & 3 deletions crates/schema-forge-acton/src/routes/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SchemaForgeConfig>,
policy_store: &Arc<crate::authz::PolicyStore>,
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,
Expand Down Expand Up @@ -170,7 +210,15 @@ pub async fn mint_upload_url(
) -> Result<Json<MintUploadUrlResponse>, 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)?;

Expand Down Expand Up @@ -261,7 +309,15 @@ pub async fn confirm_upload(
) -> Result<Json<AttachmentResponse>, 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
Expand Down Expand Up @@ -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(),
});
Expand Down Expand Up @@ -538,7 +610,15 @@ pub async fn download_file(
) -> Result<Response, 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::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 {
Expand Down
Loading