//! Group membership storage //! //! Tracks which principals are members of which groups. use std::sync::Arc; use iam_types::{Error, PrincipalKind, PrincipalRef, Result}; use crate::backend::{Backend, CasResult, JsonStore, KvPair, StorageBackend}; /// Key prefixes for group membership storage mod keys { /// Members of a group /// Format: iam/groups/{group_id}/members/{kind}/{member_id} pub const GROUP_MEMBERS: &str = "iam/groups/"; /// Groups a principal belongs to (reverse index) /// Format: iam/memberships/{kind}/{principal_id}/{group_id} pub const PRINCIPAL_GROUPS: &str = "iam/memberships/"; } /// Store for group memberships pub struct GroupStore { backend: Arc, } impl JsonStore for GroupStore { fn backend(&self) -> &Backend { &self.backend } } impl GroupStore { /// Create a new group store pub fn new(backend: Arc) -> Self { Self { backend } } /// Add a member to a group pub async fn add_member(&self, group_id: &str, member: &PrincipalRef) -> Result<()> { // Create forward index (group -> member) let forward_key = format!( "{}{}/members/{}/{}", keys::GROUP_MEMBERS, group_id, member.kind, member.id ); // Create reverse index (member -> group) let reverse_key = format!( "{}{}/{}/{}", keys::PRINCIPAL_GROUPS, member.kind, member.id, group_id ); // Store membership marker (empty value, just presence matters) let marker = b"1"; // Create forward index match self.backend.cas(forward_key.as_bytes(), 0, marker).await? { CasResult::Success(_) => {} CasResult::Conflict { .. } => { // Already a member, that's fine return Ok(()); } CasResult::NotFound => return Err(Error::Internal("Unexpected CAS result".into())), } // Create reverse index self.backend .cas(reverse_key.as_bytes(), 0, marker) .await .ok(); Ok(()) } /// Remove a member from a group pub async fn remove_member(&self, group_id: &str, member: &PrincipalRef) -> Result { let forward_key = format!( "{}{}/members/{}/{}", keys::GROUP_MEMBERS, group_id, member.kind, member.id ); let reverse_key = format!( "{}{}/{}/{}", keys::PRINCIPAL_GROUPS, member.kind, member.id, group_id ); // Delete forward index let deleted = self.backend.delete(forward_key.as_bytes()).await?; // Delete reverse index self.backend.delete(reverse_key.as_bytes()).await.ok(); Ok(deleted) } /// List all members of a group pub async fn list_members(&self, group_id: &str) -> Result> { let prefix = format!("{}{}/members/", keys::GROUP_MEMBERS, group_id); let pairs = self.backend.scan_prefix(prefix.as_bytes(), 1000).await?; let mut members = Vec::new(); for KvPair { key, .. } in pairs { // Key format: iam/groups/{group_id}/members/{kind}/{member_id} let key_str = String::from_utf8_lossy(&key); let parts: Vec<&str> = key_str.split('/').collect(); if parts.len() >= 6 { let kind_str = parts[4]; let member_id = parts[5]; let kind = match kind_str { "user" => PrincipalKind::User, "service_account" => PrincipalKind::ServiceAccount, "group" => PrincipalKind::Group, _ => continue, }; members.push(PrincipalRef::new(kind, member_id)); } } Ok(members) } /// List all groups a principal belongs to pub async fn list_groups(&self, principal: &PrincipalRef) -> Result> { let prefix = format!( "{}{}/{}/", keys::PRINCIPAL_GROUPS, principal.kind, principal.id ); let pairs = self.backend.scan_prefix(prefix.as_bytes(), 1000).await?; let mut groups = Vec::new(); for KvPair { key, .. } in pairs { // Key format: iam/memberships/{kind}/{principal_id}/{group_id} let key_str = String::from_utf8_lossy(&key); let parts: Vec<&str> = key_str.split('/').collect(); if parts.len() >= 5 { groups.push(parts[4].to_string()); } } Ok(groups) } /// Check if a principal is a member of a group pub async fn is_member(&self, group_id: &str, member: &PrincipalRef) -> Result { let key = format!( "{}{}/members/{}/{}", keys::GROUP_MEMBERS, group_id, member.kind, member.id ); let result = self.backend.get(key.as_bytes()).await?; Ok(result.is_some()) } } #[cfg(test)] mod tests { use super::*; use crate::Backend; #[tokio::test] async fn test_add_and_list_members() { let backend = Arc::new(Backend::memory()); let store = GroupStore::new(backend); let alice = PrincipalRef::user("alice"); let bob = PrincipalRef::user("bob"); store.add_member("devs", &alice).await.unwrap(); store.add_member("devs", &bob).await.unwrap(); let members = store.list_members("devs").await.unwrap(); assert_eq!(members.len(), 2); } #[tokio::test] async fn test_remove_member() { let backend = Arc::new(Backend::memory()); let store = GroupStore::new(backend); let alice = PrincipalRef::user("alice"); store.add_member("devs", &alice).await.unwrap(); assert!(store.is_member("devs", &alice).await.unwrap()); store.remove_member("devs", &alice).await.unwrap(); assert!(!store.is_member("devs", &alice).await.unwrap()); } #[tokio::test] async fn test_list_groups_for_principal() { let backend = Arc::new(Backend::memory()); let store = GroupStore::new(backend); let alice = PrincipalRef::user("alice"); store.add_member("devs", &alice).await.unwrap(); store.add_member("admins", &alice).await.unwrap(); let groups = store.list_groups(&alice).await.unwrap(); assert_eq!(groups.len(), 2); assert!(groups.contains(&"devs".to_string())); assert!(groups.contains(&"admins".to_string())); } }