diff --git a/crates/jwtlet-core/src/resource/mem.rs b/crates/jwtlet-core/src/resource/mem.rs index 4d05a1d..5d79f8b 100644 --- a/crates/jwtlet-core/src/resource/mem.rs +++ b/crates/jwtlet-core/src/resource/mem.rs @@ -102,9 +102,12 @@ impl ResourceStore for MemoryResourceStore { Ok(()) } - async fn save_scope_mapping(&self, mapping: ScopeMapping) -> Result<(), ResourceError> { + async fn save_scope_mappings(&self, mappings: Vec) -> Result<(), ResourceError> { + // Holding the write lock for the whole batch makes the insert atomic. let mut store = self.store.write().await; - store.scope_mappings.insert(mapping.scope.clone(), mapping); + for mapping in mappings { + store.scope_mappings.insert(mapping.scope.clone(), mapping); + } Ok(()) } diff --git a/crates/jwtlet-core/src/resource/mod.rs b/crates/jwtlet-core/src/resource/mod.rs index e11c914..902d331 100644 --- a/crates/jwtlet-core/src/resource/mod.rs +++ b/crates/jwtlet-core/src/resource/mod.rs @@ -35,7 +35,8 @@ pub trait ResourceStore: Send + Sync { async fn remove_mapping(&self, client_identifier: &str, participant_context: &str) -> Result<(), ResourceError>; async fn remove_mappings_for(&self, client_identifier: &str) -> Result<(), ResourceError>; - async fn save_scope_mapping(&self, mapping: ScopeMapping) -> Result<(), ResourceError>; + /// Persists all `mappings` atomically: either every mapping is saved or none is. + async fn save_scope_mappings(&self, mappings: Vec) -> Result<(), ResourceError>; async fn update_scope_mapping(&self, mapping: ScopeMapping) -> Result<(), ResourceError>; async fn remove_scope_mapping(&self, scope: &str) -> Result<(), ResourceError>; @@ -184,9 +185,13 @@ impl ResourceService { self.store.remove_mappings_for(client_id).await } - pub async fn save_scope_mapping(&self, mapping: ScopeMapping) -> Result<(), ResourceError> { - validate_scope_claims(&mapping.claims)?; - self.store.save_scope_mapping(mapping).await + /// Validates and persists all `mappings` atomically. If any mapping fails + /// validation, none is persisted. + pub async fn save_scope_mappings(&self, mappings: Vec) -> Result<(), ResourceError> { + for mapping in &mappings { + validate_scope_claims(&mapping.claims)?; + } + self.store.save_scope_mappings(mappings).await } pub async fn update_scope_mapping(&self, mapping: ScopeMapping) -> Result<(), ResourceError> { diff --git a/crates/jwtlet-core/src/resource/tests/mem.rs b/crates/jwtlet-core/src/resource/tests/mem.rs index 8e00bf1..c379340 100644 --- a/crates/jwtlet-core/src/resource/tests/mem.rs +++ b/crates/jwtlet-core/src/resource/tests/mem.rs @@ -109,7 +109,7 @@ async fn remove_mappings_for_deletes_all_client_entries() { #[tokio::test] async fn save_and_update_scope_mapping() { let store = MemoryResourceStore::new(); - store.save_scope_mapping(scope_mapping("read")).await.unwrap(); + store.save_scope_mappings(vec![scope_mapping("read")]).await.unwrap(); let updated = ScopeMapping::builder() .scope("read".to_string()) @@ -122,6 +122,22 @@ async fn save_and_update_scope_mapping() { store.update_scope_mapping(updated).await.unwrap(); } +#[tokio::test] +async fn save_scope_mappings_persists_all_entries() { + let store = MemoryResourceStore::new(); + store + .save_scope_mappings(vec![ + scope_mapping("read"), + scope_mapping("write"), + scope_mapping("admin"), + ]) + .await + .unwrap(); + + let all = store.list_scope_mappings().await.unwrap(); + assert_eq!(all.len(), 3); +} + #[tokio::test] async fn update_scope_mapping_returns_not_found_for_missing() { let store = MemoryResourceStore::new(); @@ -133,7 +149,7 @@ async fn update_scope_mapping_returns_not_found_for_missing() { #[tokio::test] async fn delete_scope_mapping_removes_entry() { let store = MemoryResourceStore::new(); - store.save_scope_mapping(scope_mapping("write")).await.unwrap(); + store.save_scope_mappings(vec![scope_mapping("write")]).await.unwrap(); store.remove_scope_mapping("write").await.unwrap(); @@ -148,8 +164,8 @@ async fn resolve_mapping_includes_registered_scope_mappings() { .save_mapping(mapping("client1", "ctx1", &["read", "write"])) .await .unwrap(); - store.save_scope_mapping(scope_mapping("read")).await.unwrap(); - store.save_scope_mapping(scope_mapping("write")).await.unwrap(); + store.save_scope_mappings(vec![scope_mapping("read")]).await.unwrap(); + store.save_scope_mappings(vec![scope_mapping("write")]).await.unwrap(); let pair = store.resolve_mapping("client1", "ctx1").await.unwrap().unwrap(); assert_eq!(pair.scope_mappings.len(), 2); @@ -164,7 +180,7 @@ async fn resolve_mapping_omits_scope_mappings_not_registered() { .save_mapping(mapping("client1", "ctx1", &["read", "write"])) .await .unwrap(); - store.save_scope_mapping(scope_mapping("read")).await.unwrap(); + store.save_scope_mappings(vec![scope_mapping("read")]).await.unwrap(); let pair = store.resolve_mapping("client1", "ctx1").await.unwrap().unwrap(); assert_eq!(pair.scope_mappings.len(), 1); diff --git a/crates/jwtlet-core/src/resource/tests/resource_service.rs b/crates/jwtlet-core/src/resource/tests/resource_service.rs index 32739f4..9568939 100644 --- a/crates/jwtlet-core/src/resource/tests/resource_service.rs +++ b/crates/jwtlet-core/src/resource/tests/resource_service.rs @@ -98,24 +98,24 @@ async fn verify_populates_claims_from_scope_mappings() { let mut read_claims = Map::new(); read_claims.insert("role".to_string(), Value::String("reader".to_string())); service - .save_scope_mapping( + .save_scope_mappings(vec![ ScopeMapping::builder() .scope("read".to_string()) .claims(read_claims) .build(), - ) + ]) .await .unwrap(); let mut write_claims = Map::new(); write_claims.insert("level".to_string(), Value::String("editor".to_string())); service - .save_scope_mapping( + .save_scope_mappings(vec![ ScopeMapping::builder() .scope("write".to_string()) .claims(write_claims) .build(), - ) + ]) .await .unwrap(); @@ -155,24 +155,24 @@ async fn verify_returns_error_when_scopes_have_conflicting_claim_keys() { let mut read_claims = Map::new(); read_claims.insert("role".to_string(), Value::Number(42.into())); service - .save_scope_mapping( + .save_scope_mappings(vec![ ScopeMapping::builder() .scope("read".to_string()) .claims(read_claims) .build(), - ) + ]) .await .unwrap(); let mut write_claims = Map::new(); write_claims.insert("role".to_string(), Value::String("writer".to_string())); service - .save_scope_mapping( + .save_scope_mappings(vec![ ScopeMapping::builder() .scope("write".to_string()) .claims(write_claims) .build(), - ) + ]) .await .unwrap(); @@ -193,24 +193,24 @@ async fn verify_returns_merged_claim_when_scopes_have_mergeable_claim_keys() { let mut read_claims = Map::new(); read_claims.insert("role".to_string(), Value::String("reader".to_string())); service - .save_scope_mapping( + .save_scope_mappings(vec![ ScopeMapping::builder() .scope("read".to_string()) .claims(read_claims) .build(), - ) + ]) .await .unwrap(); let mut write_claims = Map::new(); write_claims.insert("role".to_string(), Value::String("writer".to_string())); service - .save_scope_mapping( + .save_scope_mappings(vec![ ScopeMapping::builder() .scope("write".to_string()) .claims(write_claims) .build(), - ) + ]) .await .unwrap(); @@ -221,7 +221,6 @@ async fn verify_returns_merged_claim_when_scopes_have_mergeable_claim_keys() { let result = result.unwrap(); assert!(result.verified); assert_eq!(result.claims["role"], Value::String("reader writer".to_string())); - } #[tokio::test] @@ -235,24 +234,24 @@ async fn verify_succeeds_when_scopes_have_distinct_claim_keys() { let mut read_claims = Map::new(); read_claims.insert("read_role".to_string(), Value::String("reader".to_string())); service - .save_scope_mapping( + .save_scope_mappings(vec![ ScopeMapping::builder() .scope("read".to_string()) .claims(read_claims) .build(), - ) + ]) .await .unwrap(); let mut write_claims = Map::new(); write_claims.insert("write_role".to_string(), Value::String("writer".to_string())); service - .save_scope_mapping( + .save_scope_mappings(vec![ ScopeMapping::builder() .scope("write".to_string()) .claims(write_claims) .build(), - ) + ]) .await .unwrap(); @@ -272,7 +271,9 @@ async fn save_scope_mapping_rejects_reserved_claims() { let mut claims = Map::new(); claims.insert((*reserved).to_string(), Value::String("x".to_string())); let result = service - .save_scope_mapping(ScopeMapping::builder().scope("read".to_string()).claims(claims).build()) + .save_scope_mappings(vec![ + ScopeMapping::builder().scope("read".to_string()).claims(claims).build(), + ]) .await; assert!( matches!(result, Err(ResourceError::ReservedClaim(ref k)) if k == reserved), @@ -288,7 +289,9 @@ async fn save_scope_mapping_allows_non_reserved_claims() { claims.insert("role".to_string(), Value::String("reader".to_string())); claims.insert("department".to_string(), Value::String("eng".to_string())); let result = service - .save_scope_mapping(ScopeMapping::builder().scope("read".to_string()).claims(claims).build()) + .save_scope_mappings(vec![ + ScopeMapping::builder().scope("read".to_string()).claims(claims).build(), + ]) .await; assert!(result.is_ok()); } @@ -299,12 +302,12 @@ async fn update_scope_mapping_rejects_reserved_claims() { let mut ok_claims = Map::new(); ok_claims.insert("role".to_string(), Value::String("reader".to_string())); service - .save_scope_mapping( + .save_scope_mappings(vec![ ScopeMapping::builder() .scope("read".to_string()) .claims(ok_claims) .build(), - ) + ]) .await .unwrap(); diff --git a/crates/jwtlet-core/src/token/tests/mod.rs b/crates/jwtlet-core/src/token/tests/mod.rs index 96236c5..d7a2910 100644 --- a/crates/jwtlet-core/src/token/tests/mod.rs +++ b/crates/jwtlet-core/src/token/tests/mod.rs @@ -377,13 +377,13 @@ async fn exchange_token_returns_scope_conflict_when_scopes_share_unmergeable_cla ok_generator(), mapping_store_with_scopes(mapping(&["read", "write"]), scope_mappings), ) - .exchange_token( - PARTICIPANT_CONTEXT, - vec!["read".to_string(), "write".to_string()], - "input-token", - None, - ) - .await; + .exchange_token( + PARTICIPANT_CONTEXT, + vec!["read".to_string(), "write".to_string()], + "input-token", + None, + ) + .await; assert!(matches!(result, Err(ExchangeError::ScopeConflict(_)))); } @@ -447,7 +447,7 @@ impl ResourceStore for StubStore { async fn remove_mappings_for(&self, _: &str) -> Result<(), ResourceError> { unimplemented!() } - async fn save_scope_mapping(&self, _: ScopeMapping) -> Result<(), ResourceError> { + async fn save_scope_mappings(&self, _: Vec) -> Result<(), ResourceError> { unimplemented!() } async fn update_scope_mapping(&self, _: ScopeMapping) -> Result<(), ResourceError> { diff --git a/crates/jwtlet-postgres/src/resource_store.rs b/crates/jwtlet-postgres/src/resource_store.rs index ca03b1d..c3398e6 100644 --- a/crates/jwtlet-postgres/src/resource_store.rs +++ b/crates/jwtlet-postgres/src/resource_store.rs @@ -228,18 +228,24 @@ impl ResourceStore for PostgresResourceStore { Ok(()) } - async fn save_scope_mapping(&self, mapping: ScopeMapping) -> Result<(), ResourceError> { - let claims = Value::Object(mapping.claims.clone()); + async fn save_scope_mappings(&self, mappings: Vec) -> Result<(), ResourceError> { + let mut tx = self.pool.begin().await.map_err(db_error)?; - sqlx::query( - "INSERT INTO scope_mappings (scope, claims) VALUES ($1, $2) - ON CONFLICT (scope) DO UPDATE SET claims = EXCLUDED.claims", - ) - .bind(&mapping.scope) - .bind(&claims) - .execute(&self.pool) - .await - .map_err(db_error)?; + for mapping in &mappings { + let claims = Value::Object(mapping.claims.clone()); + + sqlx::query( + "INSERT INTO scope_mappings (scope, claims) VALUES ($1, $2) + ON CONFLICT (scope) DO UPDATE SET claims = EXCLUDED.claims", + ) + .bind(&mapping.scope) + .bind(&claims) + .execute(&mut *tx) + .await + .map_err(db_error)?; + } + + tx.commit().await.map_err(db_error)?; Ok(()) } diff --git a/crates/jwtlet-postgres/tests/resource_store_tests.rs b/crates/jwtlet-postgres/tests/resource_store_tests.rs index c125860..86866cb 100644 --- a/crates/jwtlet-postgres/tests/resource_store_tests.rs +++ b/crates/jwtlet-postgres/tests/resource_store_tests.rs @@ -297,7 +297,7 @@ async fn save_scope_mapping() { store.initialize().await.unwrap(); let sm = scope_mapping("read", claims(&[("role", json!("viewer"))])); - store.save_scope_mapping(sm).await.unwrap(); + store.save_scope_mappings(vec![sm]).await.unwrap(); let all = store.list_scope_mappings().await.unwrap(); assert_eq!(all.len(), 1); @@ -305,6 +305,29 @@ async fn save_scope_mapping() { assert_eq!(all[0].claims["role"], json!("viewer")); } +#[tokio::test] +async fn save_scope_mappings_persists_all_entries_in_one_transaction() { + let (pool, _container) = setup_postgres().await; + let store = PostgresResourceStore::new(pool); + store.initialize().await.unwrap(); + + store + .save_scope_mappings(vec![ + scope_mapping("read", claims(&[("role", json!("viewer"))])), + scope_mapping("write", claims(&[("role", json!("editor"))])), + scope_mapping("admin", claims(&[("role", json!("admin"))])), + ]) + .await + .unwrap(); + + let all = store.list_scope_mappings().await.unwrap(); + assert_eq!(all.len(), 3); + let scopes: HashSet = all.iter().map(|s| s.scope.clone()).collect(); + assert!(scopes.contains("read")); + assert!(scopes.contains("write")); + assert!(scopes.contains("admin")); +} + #[tokio::test] async fn save_scope_mapping_is_upsert() { let (pool, _container) = setup_postgres().await; @@ -312,13 +335,13 @@ async fn save_scope_mapping_is_upsert() { store.initialize().await.unwrap(); store - .save_scope_mapping(scope_mapping("read", claims(&[("role", json!("viewer"))]))) + .save_scope_mappings(vec![scope_mapping("read", claims(&[("role", json!("viewer"))]))]) .await .unwrap(); // Save again with different claims — should overwrite, not error. store - .save_scope_mapping(scope_mapping("read", claims(&[("role", json!("editor"))]))) + .save_scope_mappings(vec![scope_mapping("read", claims(&[("role", json!("editor"))]))]) .await .unwrap(); @@ -334,7 +357,7 @@ async fn update_scope_mapping() { store.initialize().await.unwrap(); store - .save_scope_mapping(scope_mapping("read", claims(&[("role", json!("viewer"))]))) + .save_scope_mappings(vec![scope_mapping("read", claims(&[("role", json!("viewer"))]))]) .await .unwrap(); @@ -371,7 +394,7 @@ async fn remove_scope_mapping() { store.initialize().await.unwrap(); store - .save_scope_mapping(scope_mapping("read", claims(&[("role", json!("viewer"))]))) + .save_scope_mappings(vec![scope_mapping("read", claims(&[("role", json!("viewer"))]))]) .await .unwrap(); @@ -407,15 +430,15 @@ async fn list_scope_mappings_returns_all() { store.initialize().await.unwrap(); store - .save_scope_mapping(scope_mapping("read", claims(&[("role", json!("viewer"))]))) + .save_scope_mappings(vec![scope_mapping("read", claims(&[("role", json!("viewer"))]))]) .await .unwrap(); store - .save_scope_mapping(scope_mapping("write", claims(&[("role", json!("editor"))]))) + .save_scope_mappings(vec![scope_mapping("write", claims(&[("role", json!("editor"))]))]) .await .unwrap(); store - .save_scope_mapping(scope_mapping("admin", claims(&[("role", json!("admin"))]))) + .save_scope_mappings(vec![scope_mapping("admin", claims(&[("role", json!("admin"))]))]) .await .unwrap(); @@ -435,15 +458,15 @@ async fn resolve_mapping_returns_matching_scope_mappings() { store.initialize().await.unwrap(); store - .save_scope_mapping(scope_mapping("read", claims(&[("can_read", json!(true))]))) + .save_scope_mappings(vec![scope_mapping("read", claims(&[("can_read", json!(true))]))]) .await .unwrap(); store - .save_scope_mapping(scope_mapping("write", claims(&[("can_write", json!(true))]))) + .save_scope_mappings(vec![scope_mapping("write", claims(&[("can_write", json!(true))]))]) .await .unwrap(); store - .save_scope_mapping(scope_mapping("admin", claims(&[("is_admin", json!(true))]))) + .save_scope_mappings(vec![scope_mapping("admin", claims(&[("is_admin", json!(true))]))]) .await .unwrap(); @@ -492,7 +515,7 @@ async fn resolve_mapping_with_partial_scope_mappings() { // Only define scope mapping for "read", not "write". store - .save_scope_mapping(scope_mapping("read", claims(&[("role", json!("viewer"))]))) + .save_scope_mappings(vec![scope_mapping("read", claims(&[("role", json!("viewer"))]))]) .await .unwrap(); @@ -521,7 +544,7 @@ async fn resolve_mapping_scope_mapping_with_complex_claims() { ]); store - .save_scope_mapping(scope_mapping("read", complex_claims.clone())) + .save_scope_mappings(vec![scope_mapping("read", complex_claims.clone())]) .await .unwrap(); store diff --git a/crates/jwtlet-server/src/exchange/tests/mod.rs b/crates/jwtlet-server/src/exchange/tests/mod.rs index c77280a..efcc3d1 100644 --- a/crates/jwtlet-server/src/exchange/tests/mod.rs +++ b/crates/jwtlet-server/src/exchange/tests/mod.rs @@ -423,7 +423,7 @@ impl ResourceStore for StubStore { unimplemented!() } - async fn save_scope_mapping(&self, _: ScopeMapping) -> Result<(), ResourceError> { + async fn save_scope_mappings(&self, _: Vec) -> Result<(), ResourceError> { unimplemented!() } async fn update_scope_mapping(&self, _: ScopeMapping) -> Result<(), ResourceError> { diff --git a/crates/jwtlet-server/src/management/mod.rs b/crates/jwtlet-server/src/management/mod.rs index e38bf06..dbd95d4 100644 --- a/crates/jwtlet-server/src/management/mod.rs +++ b/crates/jwtlet-server/src/management/mod.rs @@ -26,10 +26,28 @@ use axum::{ use dsdk_facet_core::jwt::{JwtVerificationError, JwtVerifier}; use jwtlet_core::resource::{ResourceMapping, ResourceService, ScopeMapping}; use jwtlet_core::saccount::{AuthError, ServiceAccountAuthorizer}; +use serde::Deserialize; use std::collections::HashSet; use std::sync::Arc; use tracing::info; +/// Accepts either a single `T` or an array of `T` in a JSON request body. +#[derive(Deserialize)] +#[serde(untagged)] +enum OneOrMany { + One(T), + Many(Vec), +} + +impl OneOrMany { + fn into_vec(self) -> Vec { + match self { + OneOrMany::One(item) => vec![item], + OneOrMany::Many(items) => items, + } + } +} + /// The authenticated caller's `sub` claim, inserted by the auth middleware. #[derive(Clone)] struct Actor(String); @@ -203,10 +221,13 @@ async fn delete_client_mappings( async fn create_scope_mapping( State(service): State>, Extension(actor): Extension, - Json(mapping): Json, + Json(payload): Json>, ) -> Result { - service.save_scope_mapping(mapping.clone()).await?; - info!(actor = %actor.0, scope = %mapping.scope, "scope mapping created"); + let mappings = payload.into_vec(); + service.save_scope_mappings(mappings.clone()).await?; + for mapping in &mappings { + info!(actor = %actor.0, scope = %mapping.scope, "scope mapping created"); + } Ok(StatusCode::CREATED) } diff --git a/crates/jwtlet-server/src/management/tests/mod.rs b/crates/jwtlet-server/src/management/tests/mod.rs index 8a58d97..0b58153 100644 --- a/crates/jwtlet-server/src/management/tests/mod.rs +++ b/crates/jwtlet-server/src/management/tests/mod.rs @@ -420,6 +420,91 @@ async fn create_scope_with_non_reserved_claims_returns_201() { assert_eq!(response.status(), StatusCode::CREATED); } +// ============================================================================ +// POST /scopes accepts either a single ScopeMapping or an array of them +// ============================================================================ + +#[tokio::test] +async fn create_scope_mapping_accepts_array_returns_201() { + let router = make_router(); + let body = json!([scope_mapping_json("read"), scope_mapping_json("write")]); + let response = post_scope(&router, body).await; + assert_eq!(response.status(), StatusCode::CREATED); +} + +#[tokio::test] +async fn create_scope_mapping_array_persists_all_entries() { + let router = make_router_with_both_roles(); + let body = json!([ + scope_mapping_json("read"), + scope_mapping_json("write"), + scope_mapping_json("admin"), + ]); + let resp = post_scope(&router, body).await; + assert_eq!(resp.status(), StatusCode::CREATED); + + let resp = get_scopes(&router).await; + assert_eq!(resp.status(), StatusCode::OK); + let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body.as_array().unwrap().len(), 3); +} + +#[tokio::test] +async fn create_scope_mapping_empty_array_returns_201() { + let router = make_router(); + let response = post_scope(&router, json!([])).await; + assert_eq!(response.status(), StatusCode::CREATED); +} + +#[tokio::test] +async fn create_scope_mapping_single_object_still_returns_201() { + let router = make_router(); + let response = post_scope(&router, scope_mapping_json("read")).await; + assert_eq!(response.status(), StatusCode::CREATED); +} + +#[tokio::test] +async fn create_scope_mapping_array_with_reserved_claim_returns_400() { + let router = make_router(); + let body = json!([ + scope_mapping_json("read"), + { "scope": "write", "claims": { "sub": "injected" } }, + ]); + let response = post_scope(&router, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn create_scope_mapping_array_is_atomic_on_invalid_entry() { + // A single invalid entry must roll back the whole batch: the valid "read" + // entry preceding the reserved-claim "write" entry must not be persisted. + let router = make_router_with_both_roles(); + let body = json!([ + scope_mapping_json("read"), + { "scope": "write", "claims": { "sub": "injected" } }, + ]); + let response = post_scope(&router, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let resp = get_scopes(&router).await; + let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let stored: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(stored, json!([]), "no scope mapping should be persisted when the batch fails"); +} + +#[traced_test] +#[tokio::test] +async fn create_scope_mapping_array_logs_each_scope() { + let router = make_router(); + let body = json!([scope_mapping_json("read"), scope_mapping_json("write")]); + let resp = post_scope(&router, body).await; + assert_eq!(resp.status(), StatusCode::CREATED); + assert!(logs_contain("scope mapping created")); + assert!(logs_contain("read")); + assert!(logs_contain("write")); +} + // ============================================================================ // Audit logging: actor and key fields appear in log output // ============================================================================ @@ -564,7 +649,7 @@ async fn database_error_does_not_expose_internal_message_in_response_body() { async fn remove_mappings_for(&self, _: &str) -> Result<(), ResourceError> { unimplemented!() } - async fn save_scope_mapping(&self, _: ScopeMapping) -> Result<(), ResourceError> { + async fn save_scope_mappings(&self, _: Vec) -> Result<(), ResourceError> { unimplemented!() } async fn update_scope_mapping(&self, _: ScopeMapping) -> Result<(), ResourceError> {