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
7 changes: 5 additions & 2 deletions crates/jwtlet-core/src/resource/mem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScopeMapping>) -> 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(())
}

Expand Down
13 changes: 9 additions & 4 deletions crates/jwtlet-core/src/resource/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScopeMapping>) -> Result<(), ResourceError>;
async fn update_scope_mapping(&self, mapping: ScopeMapping) -> Result<(), ResourceError>;
async fn remove_scope_mapping(&self, scope: &str) -> Result<(), ResourceError>;

Expand Down Expand Up @@ -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<ScopeMapping>) -> 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> {
Expand Down
26 changes: 21 additions & 5 deletions crates/jwtlet-core/src/resource/tests/mem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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();
Expand All @@ -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();

Expand All @@ -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);
Expand All @@ -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);
Expand Down
45 changes: 24 additions & 21 deletions crates/jwtlet-core/src/resource/tests/resource_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Expand All @@ -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();

Expand All @@ -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]
Expand All @@ -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();

Expand All @@ -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),
Expand All @@ -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());
}
Expand All @@ -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();

Expand Down
16 changes: 8 additions & 8 deletions crates/jwtlet-core/src/token/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(_))));
}

Expand Down Expand Up @@ -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<ScopeMapping>) -> Result<(), ResourceError> {
unimplemented!()
}
async fn update_scope_mapping(&self, _: ScopeMapping) -> Result<(), ResourceError> {
Expand Down
28 changes: 17 additions & 11 deletions crates/jwtlet-postgres/src/resource_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScopeMapping>) -> 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(())
}
Expand Down
Loading
Loading