Skip to content

Commit 2a3f258

Browse files
authored
Merge pull request #79 from VibeEngineeringAI/fix/put-metadata-staging-consistency
Publish RBAC changes once durable metadata is written, even if staging fails
2 parents f02972f + ef0b308 commit 2a3f258

3 files changed

Lines changed: 107 additions & 23 deletions

File tree

‎src/handlers/http/modal/utils/rbac_utils.rs‎

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
*
1717
*/
1818

19+
use std::{future::Future, io};
20+
1921
use crate::{
2022
parseable::PARSEABLE,
2123
storage::{self, ObjectStorageError, StorageMetadata},
@@ -33,11 +35,108 @@ pub async fn get_metadata(
3335
Ok(serde_json::from_slice::<StorageMetadata>(&metadata)?)
3436
}
3537

38+
/// Persist authoritative metadata and refresh its best-effort local staging copy.
39+
/// Success lets callers publish the committed change to live RBAC state.
3640
pub async fn put_metadata(
3741
metadata: &StorageMetadata,
3842
tenant_id: &Option<String>,
3943
) -> Result<(), ObjectStorageError> {
40-
storage::put_remote_metadata(metadata, tenant_id).await?;
41-
storage::put_staging_metadata(metadata, tenant_id)?;
44+
put_metadata_with_writers(
45+
storage::put_remote_metadata(metadata, tenant_id),
46+
|| storage::put_staging_metadata(metadata, tenant_id),
47+
tenant_id,
48+
)
49+
.await
50+
}
51+
52+
async fn put_metadata_with_writers(
53+
remote_write: impl Future<Output = Result<(), ObjectStorageError>>,
54+
staging_write: impl FnOnce() -> io::Result<()>,
55+
tenant_id: &Option<String>,
56+
) -> Result<(), ObjectStorageError> {
57+
remote_write.await?;
58+
// A staging failure cannot undo the durable commit. Returning an error here
59+
// would leave callers' live authorization state behind the source of truth.
60+
if let Err(error) = staging_write() {
61+
tracing::error!(
62+
error = %error,
63+
tenant_id = ?tenant_id,
64+
"Failed to refresh staging metadata after durable metadata write"
65+
);
66+
}
4267
Ok(())
4368
}
69+
70+
#[cfg(test)]
71+
mod tests {
72+
use std::cell::Cell;
73+
74+
use super::*;
75+
76+
#[tokio::test]
77+
async fn staging_failure_after_durable_success_returns_success() {
78+
let durable_written = Cell::new(false);
79+
let staging_attempted = Cell::new(false);
80+
let result = put_metadata_with_writers(
81+
async {
82+
durable_written.set(true);
83+
Ok(())
84+
},
85+
|| {
86+
assert!(
87+
durable_written.get(),
88+
"durable metadata must be written first"
89+
);
90+
staging_attempted.set(true);
91+
Err(io::Error::new(
92+
io::ErrorKind::PermissionDenied,
93+
"staging is read-only",
94+
))
95+
},
96+
&None,
97+
)
98+
.await;
99+
100+
assert!(staging_attempted.get());
101+
assert!(
102+
result.is_ok(),
103+
"callers must publish durable changes to live authorization: {result:?}"
104+
);
105+
}
106+
107+
#[tokio::test]
108+
async fn durable_failure_is_returned_without_attempting_staging() {
109+
let result = put_metadata_with_writers(
110+
async { Err(ObjectStorageError::Custom("durable write failed".into())) },
111+
|| panic!("staging must not be changed after a failed durable write"),
112+
&None,
113+
)
114+
.await;
115+
116+
assert!(
117+
matches!(result, Err(ObjectStorageError::Custom(message)) if message == "durable write failed")
118+
);
119+
}
120+
121+
#[tokio::test]
122+
async fn successful_writes_run_durable_before_staging() {
123+
let durable_written = Cell::new(false);
124+
let staging_written = Cell::new(false);
125+
let result = put_metadata_with_writers(
126+
async {
127+
durable_written.set(true);
128+
Ok(())
129+
},
130+
|| {
131+
assert!(durable_written.get());
132+
staging_written.set(true);
133+
Ok(())
134+
},
135+
&None,
136+
)
137+
.await;
138+
139+
assert!(result.is_ok());
140+
assert!(staging_written.get());
141+
}
142+
}

‎src/handlers/http/oidc.rs‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ use crate::{
4747
handlers::{
4848
COOKIE_AGE_DAYS, SESSION_COOKIE_NAME, USER_COOKIE_NAME, USER_ID_COOKIE_NAME,
4949
http::{
50-
API_BASE_PATH, API_VERSION, cluster::sync_user_creation, modal::OIDC_CLIENT,
50+
API_BASE_PATH, API_VERSION,
51+
cluster::sync_user_creation,
52+
modal::{OIDC_CLIENT, utils::rbac_utils::put_metadata},
5153
rbac::UPDATE_LOCK,
5254
},
5355
},
@@ -58,7 +60,7 @@ use crate::{
5860
map::{DEFAULT_ROLE, SessionKey, mut_sessions, mut_users, roles, write_user_groups},
5961
user::{self, GroupUser, User, UserType},
6062
},
61-
storage::{self, ObjectStorageError, StorageMetadata},
63+
storage::{ObjectStorageError, StorageMetadata},
6264
utils::{
6365
actix::extract_session_key_from_req, get_tenant_id_from_key, get_tenant_id_from_request,
6466
},
@@ -825,15 +827,6 @@ async fn get_metadata(
825827
Ok(serde_json::from_slice::<StorageMetadata>(&metadata)?)
826828
}
827829

828-
async fn put_metadata(
829-
metadata: &StorageMetadata,
830-
tenant_id: &Option<String>,
831-
) -> Result<(), ObjectStorageError> {
832-
storage::put_remote_metadata(metadata, tenant_id).await?;
833-
storage::put_staging_metadata(metadata, tenant_id)?;
834-
Ok(())
835-
}
836-
837830
#[derive(Debug, thiserror::Error)]
838831
pub enum OIDCError {
839832
#[error("Failed to connect to storage: {0}")]

‎src/handlers/http/role.rs‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use actix_web::{
2525
web::{self, Json},
2626
};
2727

28+
use super::modal::utils::rbac_utils::put_metadata;
2829
use crate::rbac::map::roles;
2930
use crate::rbac::role::model::{Role, RoleType, RoleUI};
3031
use crate::{
@@ -34,7 +35,7 @@ use crate::{
3435
map::{DEFAULT_ROLE, mut_roles, mut_sessions, mut_users, read_user_groups, users},
3536
user::UserType,
3637
},
37-
storage::{self, ObjectStorageError, StorageMetadata},
38+
storage::{ObjectStorageError, StorageMetadata},
3839
utils::get_tenant_id_from_request,
3940
validator::{self, error::UsernameValidationError},
4041
};
@@ -302,15 +303,6 @@ async fn get_metadata(
302303
Ok(serde_json::from_slice::<StorageMetadata>(&metadata)?)
303304
}
304305

305-
async fn put_metadata(
306-
metadata: &StorageMetadata,
307-
tenant_id: &Option<String>,
308-
) -> Result<(), ObjectStorageError> {
309-
storage::put_remote_metadata(metadata, tenant_id).await?;
310-
storage::put_staging_metadata(metadata, tenant_id)?;
311-
Ok(())
312-
}
313-
314306
#[derive(Debug, thiserror::Error)]
315307
pub enum RoleError {
316308
#[error("Default OIDC role must name an existing role.")]

0 commit comments

Comments
 (0)