//! IAM Authorization gRPC service implementation use std::net::IpAddr; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use tonic::{Request, Response, Status}; use iam_audit::{AuditEvent, AuditLogger}; use iam_authz::{AuthzContext, AuthzRequest as InternalAuthzRequest, PolicyEvaluator}; use iam_store::{BindingStore, GroupStore, OrgStore, PrincipalStore, ProjectStore, RoleStore}; use iam_types::{ Error as TypesError, IamError, Organization, PolicyBinding, Principal, PrincipalKind as TypesPrincipalKind, PrincipalRef, Project, Resource, Role, Scope, StorageError, }; use tracing::warn; use uuid::Uuid; use crate::proto::{ self, iam_admin_server::IamAdmin, iam_authz_server::IamAuthz, AddGroupMemberRequest, AddGroupMemberResponse, AuthorizeRequest, AuthorizeResponse, BatchAuthorizeRequest, BatchAuthorizeResponse, CreateBindingRequest, CreateOrganizationRequest, CreatePrincipalRequest, CreateProjectRequest, CreateRoleRequest, DeleteBindingRequest, DeleteBindingResponse, DeleteOrganizationRequest, DeleteOrganizationResponse, DeletePrincipalRequest, DeletePrincipalResponse, DeleteProjectRequest, DeleteProjectResponse, DeleteRoleRequest, DeleteRoleResponse, GetBindingRequest, GetOrganizationRequest, GetPrincipalRequest, GetProjectRequest, GetRoleRequest, ListBindingsRequest, ListBindingsResponse, ListGroupMembersRequest, ListGroupMembersResponse, ListOrganizationsRequest, ListOrganizationsResponse, ListPrincipalGroupsRequest, ListPrincipalGroupsResponse, ListPrincipalsRequest, ListPrincipalsResponse, ListProjectsRequest, ListProjectsResponse, ListRolesRequest, ListRolesResponse, PrincipalKind, RemoveGroupMemberRequest, RemoveGroupMemberResponse, UpdateBindingRequest, UpdateOrganizationRequest, UpdatePrincipalRequest, UpdateProjectRequest, UpdateRoleRequest, }; /// IAM Authorization service implementation pub struct IamAuthzService { evaluator: Arc, principal_store: Arc, audit_logger: Option>, } impl IamAuthzService { /// Create a new authorization service without audit logging pub fn new(evaluator: Arc, principal_store: Arc) -> Self { Self { evaluator, principal_store, audit_logger: None, } } /// Create a new authorization service with audit logging pub fn with_audit( evaluator: Arc, principal_store: Arc, audit_logger: Arc, ) -> Self { Self { evaluator, principal_store, audit_logger: Some(audit_logger), } } /// Log an audit event (non-blocking) async fn log_event(&self, event: AuditEvent) { if let Some(logger) = &self.audit_logger { if let Err(e) = logger.log(event).await { warn!("Failed to log audit event: {}", e); } } } } #[tonic::async_trait] impl IamAuthz for IamAuthzService { async fn authorize( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); // Get principal ref from request let principal_ref = req .principal .as_ref() .ok_or_else(|| Status::invalid_argument("principal is required"))?; let principal_kind = match PrincipalKind::try_from(principal_ref.kind) { Ok(PrincipalKind::User) => TypesPrincipalKind::User, Ok(PrincipalKind::ServiceAccount) => TypesPrincipalKind::ServiceAccount, Ok(PrincipalKind::Group) => TypesPrincipalKind::Group, _ => return Err(Status::invalid_argument("invalid principal kind")), }; let types_principal_ref = PrincipalRef::new(principal_kind.clone(), &principal_ref.id); // Get principal from store let principal = self .principal_store .get(&types_principal_ref) .await .map_err(|e| Status::internal(format!("Failed to get principal: {}", e)))? .ok_or_else(|| Status::not_found("Principal not found"))?; // Build resource let resource_ref = req .resource .as_ref() .ok_or_else(|| Status::invalid_argument("resource is required"))?; let mut resource = Resource::new( &resource_ref.kind, &resource_ref.id, &resource_ref.org_id, &resource_ref.project_id, ); if let Some(owner) = &resource_ref.owner_id { resource = resource.with_owner(owner); } if let Some(node) = &resource_ref.node_id { resource = resource.with_node(node); } if let Some(region) = &resource_ref.region { resource = resource.with_region(region); } for (k, v) in &resource_ref.tags { resource = resource.with_tag(k, v); } // Build context let mut context = AuthzContext::new(); if let Some(ctx) = &req.context { if !ctx.source_ip.is_empty() { if let Ok(ip) = ctx.source_ip.parse::() { context = context.with_source_ip(ip); } } if ctx.timestamp > 0 { context = context.with_timestamp(ctx.timestamp); } if !ctx.http_method.is_empty() { context = context.with_http_method(&ctx.http_method); } if !ctx.request_path.is_empty() { context = context.with_request_path(&ctx.request_path); } for (k, v) in &ctx.metadata { context = context.with_metadata(k, v); } } // Build internal request let internal_req = InternalAuthzRequest::new(principal, &req.action, resource).with_context(context); // Evaluate let evaluation = self .evaluator .evaluate_with_match(&internal_req) .await .map_err(|e| Status::internal(format!("Authorization error: {}", e)))?; // Determine scope for audit logging let audit_scope = Scope::project(&resource_ref.project_id, &resource_ref.org_id); let response = match evaluation.decision { iam_authz::AuthzDecision::Allow => { // Log allowed event let event = AuditEvent::authz_allowed( &types_principal_ref.id, &req.action, &resource_ref.kind, &resource_ref.id, audit_scope.clone(), ); self.log_event(event).await; AuthorizeResponse { allowed: true, reason: String::new(), matched_binding: evaluation.matched_binding.unwrap_or_default(), matched_role: evaluation.matched_role.unwrap_or_default(), } } iam_authz::AuthzDecision::Deny { reason } => { // Log denied event let event = AuditEvent::authz_denied( &types_principal_ref.id, &req.action, &resource_ref.kind, &resource_ref.id, audit_scope, &reason, ); self.log_event(event).await; AuthorizeResponse { allowed: false, reason, matched_binding: String::new(), matched_role: String::new(), } } }; Ok(Response::new(response)) } async fn batch_authorize( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let mut responses = Vec::with_capacity(req.requests.len()); for auth_req in req.requests { let result = self .authorize(Request::new(auth_req)) .await .map(|r| r.into_inner()) .unwrap_or_else(|e| AuthorizeResponse { allowed: false, reason: e.message().to_string(), matched_binding: String::new(), matched_role: String::new(), }); responses.push(result); } Ok(Response::new(BatchAuthorizeResponse { responses })) } } /// IAM Admin service implementation pub struct IamAdminService { principal_store: Arc, role_store: Arc, binding_store: Arc, org_store: Arc, project_store: Arc, group_store: Arc, evaluator: Option>, } impl IamAdminService { /// Create a new admin service pub fn new( principal_store: Arc, role_store: Arc, binding_store: Arc, org_store: Arc, project_store: Arc, group_store: Arc, ) -> Self { Self { principal_store, role_store, binding_store, org_store, project_store, group_store, evaluator: None, } } /// Attach the active policy evaluator so admin mutations can invalidate cache. pub fn with_evaluator(mut self, evaluator: Arc) -> Self { self.evaluator = Some(evaluator); self } fn invalidate_principal_bindings(&self, principal: &PrincipalRef) { if let Some(evaluator) = &self.evaluator { evaluator.invalidate_principal(principal); } } fn invalidate_role(&self, role_ref: &str) { if let Some(evaluator) = &self.evaluator { evaluator.invalidate_role(role_ref.strip_prefix("roles/").unwrap_or(role_ref)); } } async fn ensure_tenant_registration( &self, org_id: &str, project_id: Option<&str>, ) -> Result<(), Status> { let now = now_ts(); let mut org = Organization::new(org_id, org_id); org.created_at = now; org.updated_at = now; self.org_store .create_if_missing(&org) .await .map_err(map_error)?; if let Some(project_id) = project_id { let mut project = Project::new(project_id, org_id, project_id); project.created_at = now; project.updated_at = now; self.project_store .create_if_missing(&project) .await .map_err(map_error)?; } Ok(()) } async fn ensure_scope_registration(&self, scope: &Scope) -> Result<(), Status> { match scope { Scope::System => Ok(()), Scope::Org { id } => self.ensure_tenant_registration(id, None).await, Scope::Project { id, org_id } => { self.ensure_tenant_registration(org_id, Some(id)).await } Scope::Resource { project_id, org_id, .. } => { self.ensure_tenant_registration(org_id, Some(project_id)) .await } } } async fn validate_binding_principal_scope( &self, principal: &Principal, scope: &Scope, ) -> Result<(), Status> { if principal.kind != TypesPrincipalKind::ServiceAccount { return Ok(()); } let Some(principal_org) = principal.org_id.as_deref() else { return Ok(()); }; let Some(principal_project) = principal.project_id.as_deref() else { return Ok(()); }; match scope { Scope::System => Ok(()), Scope::Org { id } if id == principal_org => Ok(()), Scope::Project { id, org_id } if org_id == principal_org && id == principal_project => { Ok(()) } Scope::Resource { project_id, org_id, .. } if org_id == principal_org && project_id == principal_project => Ok(()), _ => Err(Status::permission_denied( "service account bindings must stay within the service account tenant", )), } } } fn now_ts() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } fn map_error(err: TypesError) -> Status { match err { TypesError::Iam(IamError::PrincipalNotFound(msg)) | TypesError::Iam(IamError::RoleNotFound(msg)) | TypesError::Iam(IamError::BindingNotFound(msg)) | TypesError::Iam(IamError::OrganizationNotFound(msg)) | TypesError::Iam(IamError::ProjectNotFound(msg)) => Status::not_found(msg), TypesError::Iam(IamError::PrincipalAlreadyExists(msg)) | TypesError::Iam(IamError::RoleAlreadyExists(msg)) | TypesError::Iam(IamError::BindingAlreadyExists(msg)) | TypesError::Iam(IamError::OrganizationAlreadyExists(msg)) | TypesError::Iam(IamError::ProjectAlreadyExists(msg)) => Status::already_exists(msg), TypesError::Iam(IamError::CannotModifyBuiltinRole(msg)) => Status::failed_precondition(msg), TypesError::Storage(StorageError::CasConflict { expected, actual }) => Status::aborted( format!("CAS conflict (expected {}, actual {})", expected, actual), ), TypesError::Storage(StorageError::Connection(msg)) => Status::unavailable(msg), TypesError::Storage(StorageError::Backend(msg)) => Status::unavailable(msg), TypesError::Storage(StorageError::Timeout) => Status::deadline_exceeded("storage timeout"), other => Status::internal(other.to_string()), } } fn parse_principal_kind(value: i32) -> Result { match PrincipalKind::try_from(value) { Ok(PrincipalKind::User) => Ok(TypesPrincipalKind::User), Ok(PrincipalKind::ServiceAccount) => Ok(TypesPrincipalKind::ServiceAccount), Ok(PrincipalKind::Group) => Ok(TypesPrincipalKind::Group), _ => Err(Status::invalid_argument("invalid principal kind")), } } fn parse_principal_ref(principal: proto::PrincipalRef) -> Result { Ok(PrincipalRef::new( parse_principal_kind(principal.kind)?, principal.id, )) } fn decode_page_token(page_token: &str) -> Result { if page_token.trim().is_empty() { return Ok(0); } let bytes = URL_SAFE_NO_PAD .decode(page_token.as_bytes()) .map_err(|_| Status::invalid_argument("invalid page_token"))?; let value = String::from_utf8(bytes).map_err(|_| Status::invalid_argument("invalid page_token"))?; value .parse::() .map_err(|_| Status::invalid_argument("invalid page_token")) } fn encode_page_token(offset: usize) -> String { URL_SAFE_NO_PAD.encode(offset.to_string()) } fn paginate( mut items: Vec, page_size: i32, page_token: &str, ) -> Result<(Vec, String), Status> { let start = decode_page_token(page_token)?; if start >= items.len() { return Ok((Vec::new(), String::new())); } let requested = if page_size <= 0 { items.len().saturating_sub(start) } else { page_size as usize }; let end = (start + requested).min(items.len()); let next_page_token = if end < items.len() { encode_page_token(end) } else { String::new() }; let page = items.drain(start..end).collect(); Ok((page, next_page_token)) } #[tonic::async_trait] impl IamAdmin for IamAdminService { async fn create_principal( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let kind = parse_principal_kind(req.kind)?; let mut principal = match kind { TypesPrincipalKind::User => { if req .project_id .as_deref() .is_some_and(|value| !value.trim().is_empty()) { return Err(Status::invalid_argument( "project_id is not valid for user principals", )); } Principal::new_user(&req.id, &req.name) } TypesPrincipalKind::ServiceAccount => { let org_id = req .org_id .clone() .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { Status::invalid_argument("org_id is required for service accounts") })?; let project_id = req .project_id .clone() .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { Status::invalid_argument("project_id is required for service accounts") })?; self.ensure_tenant_registration(&org_id, Some(&project_id)) .await?; Principal::new_service_account(&req.id, &req.name, org_id, project_id) } TypesPrincipalKind::Group => { if req .project_id .as_deref() .is_some_and(|value| !value.trim().is_empty()) { return Err(Status::invalid_argument( "project_id is not valid for group principals", )); } Principal::new_group(&req.id, &req.name) } }; principal.org_id = req.org_id.clone(); principal.project_id = req.project_id.clone(); principal.email = req.email.clone(); principal.metadata = req.metadata.clone(); if let Some(org_id) = principal.org_id.as_deref() { self.ensure_tenant_registration(org_id, principal.project_id.as_deref()) .await?; } let now = now_ts(); principal.created_at = now; principal.updated_at = now; self.principal_store .create(&principal) .await .map_err(map_error)?; Ok(Response::new(proto::Principal::from(principal))) } async fn get_principal( &self, request: Request, ) -> Result, Status> { let principal_ref = request .into_inner() .principal .ok_or_else(|| Status::invalid_argument("principal is required"))?; let principal_ref = parse_principal_ref(principal_ref)?; let principal = self .principal_store .get(&principal_ref) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("principal not found"))?; Ok(Response::new(proto::Principal::from(principal))) } async fn update_principal( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let principal_ref = req .principal .ok_or_else(|| Status::invalid_argument("principal is required"))?; let principal_ref = parse_principal_ref(principal_ref)?; let (mut principal, version) = self .principal_store .get_with_version(&principal_ref) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("principal not found"))?; if let Some(name) = req.name { principal.name = name; } if let Some(email) = req.email { principal.email = Some(email); } if !req.metadata.is_empty() { principal.metadata = req.metadata; } if let Some(enabled) = req.enabled { principal.enabled = enabled; } principal.updated_at = now_ts(); self.principal_store .update(&principal, version) .await .map_err(map_error)?; Ok(Response::new(proto::Principal::from(principal))) } async fn delete_principal( &self, request: Request, ) -> Result, Status> { let principal_ref = request .into_inner() .principal .ok_or_else(|| Status::invalid_argument("principal is required"))?; let principal_ref = parse_principal_ref(principal_ref)?; let deleted = self .principal_store .delete(&principal_ref) .await .map_err(map_error)?; Ok(Response::new(DeletePrincipalResponse { deleted })) } async fn list_principals( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let kind_filter = req.kind.map(parse_principal_kind).transpose()?; let org_filter = req.org_id.clone().filter(|value| !value.trim().is_empty()); let project_filter = req .project_id .clone() .filter(|value| !value.trim().is_empty()); let mut principals = if let (Some(org_id), Some(project_id)) = (org_filter.as_deref(), project_filter.as_deref()) { self.principal_store .list_by_tenant(org_id, project_id) .await .map_err(map_error)? } else if let Some(project_id) = project_filter.as_deref() { self.principal_store .list_by_project(project_id) .await .map_err(map_error)? } else if let Some(org_id) = org_filter.as_deref() { self.principal_store .list_by_org(org_id) .await .map_err(map_error)? } else if let Some(kind) = kind_filter.as_ref() { self.principal_store .list_by_kind(kind) .await .map_err(map_error)? } else { let mut all = Vec::new(); for kind in [ TypesPrincipalKind::User, TypesPrincipalKind::ServiceAccount, TypesPrincipalKind::Group, ] { all.extend( self.principal_store .list_by_kind(&kind) .await .map_err(map_error)?, ); } all }; principals.retain(|principal| { kind_filter .as_ref() .is_none_or(|kind| principal.kind == *kind) && org_filter .as_deref() .is_none_or(|org_id| principal.org_id.as_deref() == Some(org_id)) && project_filter .as_deref() .is_none_or(|project_id| principal.project_id.as_deref() == Some(project_id)) }); principals.sort_by(|left, right| { left.kind .to_string() .cmp(&right.kind.to_string()) .then_with(|| left.id.cmp(&right.id)) }); let (principals, next_page_token) = paginate(principals, req.page_size, &req.page_token)?; let principals = principals.into_iter().map(proto::Principal::from).collect(); Ok(Response::new(ListPrincipalsResponse { principals, next_page_token, })) } async fn create_organization( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let now = now_ts(); let mut org = Organization::new(req.id, req.name); org.description = req.description; org.metadata = req.metadata; org.created_at = now; org.updated_at = now; self.org_store.create(&org).await.map_err(map_error)?; Ok(Response::new(org.into())) } async fn get_organization( &self, request: Request, ) -> Result, Status> { let org = self .org_store .get(&request.into_inner().id) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("organization not found"))?; Ok(Response::new(org.into())) } async fn update_organization( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let (mut org, version) = self .org_store .get_with_version(&req.id) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("organization not found"))?; if let Some(name) = req.name { org.name = name; } if let Some(description) = req.description { org.description = description; } if !req.metadata.is_empty() { org.metadata = req.metadata; } if let Some(enabled) = req.enabled { org.enabled = enabled; } org.updated_at = now_ts(); self.org_store .update(&org, version) .await .map_err(map_error)?; Ok(Response::new(org.into())) } async fn delete_organization( &self, request: Request, ) -> Result, Status> { let deleted = self .org_store .delete(&request.into_inner().id) .await .map_err(map_error)?; Ok(Response::new(DeleteOrganizationResponse { deleted })) } async fn list_organizations( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let mut organizations = self.org_store.list().await.map_err(map_error)?; if !req.include_disabled { organizations.retain(|org| org.enabled); } organizations.sort_by(|left, right| left.id.cmp(&right.id)); let (organizations, next_page_token) = paginate(organizations, req.page_size, &req.page_token)?; Ok(Response::new(ListOrganizationsResponse { organizations: organizations.into_iter().map(Into::into).collect(), next_page_token, })) } async fn create_project( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); self.ensure_tenant_registration(&req.org_id, None).await?; let now = now_ts(); let mut project = Project::new(req.id, req.org_id, req.name); project.description = req.description; project.metadata = req.metadata; project.created_at = now; project.updated_at = now; self.project_store .create(&project) .await .map_err(map_error)?; Ok(Response::new(project.into())) } async fn get_project( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let project = self .project_store .get(&req.org_id, &req.id) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("project not found"))?; Ok(Response::new(project.into())) } async fn update_project( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let (mut project, version) = self .project_store .get_with_version(&req.org_id, &req.id) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("project not found"))?; if let Some(name) = req.name { project.name = name; } if let Some(description) = req.description { project.description = description; } if !req.metadata.is_empty() { project.metadata = req.metadata; } if let Some(enabled) = req.enabled { project.enabled = enabled; } project.updated_at = now_ts(); self.project_store .update(&project, version) .await .map_err(map_error)?; Ok(Response::new(project.into())) } async fn delete_project( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let deleted = self .project_store .delete(&req.org_id, &req.id) .await .map_err(map_error)?; Ok(Response::new(DeleteProjectResponse { deleted })) } async fn list_projects( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let mut projects = if let Some(org_id) = req .org_id .as_deref() .filter(|value| !value.trim().is_empty()) { self.project_store .list_by_org(org_id) .await .map_err(map_error)? } else { self.project_store.list().await.map_err(map_error)? }; if !req.include_disabled { projects.retain(|project| project.enabled); } projects.sort_by(|left, right| { left.org_id .cmp(&right.org_id) .then_with(|| left.id.cmp(&right.id)) }); let (projects, next_page_token) = paginate(projects, req.page_size, &req.page_token)?; Ok(Response::new(ListProjectsResponse { projects: projects.into_iter().map(Into::into).collect(), next_page_token, })) } async fn create_role( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let scope = req .scope .ok_or_else(|| Status::invalid_argument("scope is required"))? .into(); let permissions = req.permissions.into_iter().map(Into::into).collect(); let mut role = Role::new(&req.name, scope, permissions); role.display_name = req.display_name; role.description = req.description; let now = now_ts(); role.created_at = now; role.updated_at = now; self.role_store.create(&role).await.map_err(map_error)?; self.invalidate_role(&role.to_ref()); Ok(Response::new(proto::Role::from(role))) } async fn get_role( &self, request: Request, ) -> Result, Status> { let name = request.into_inner().name; let role = self .role_store .get(&name) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("role not found"))?; Ok(Response::new(proto::Role::from(role))) } async fn update_role( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let (mut role, version) = self .role_store .get_with_version(&req.name) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("role not found"))?; if let Some(name) = req.display_name { role.display_name = name; } if let Some(desc) = req.description { role.description = desc; } if !req.permissions.is_empty() { role.permissions = req.permissions.into_iter().map(Into::into).collect(); } role.updated_at = now_ts(); self.role_store .update(&role, version) .await .map_err(map_error)?; self.invalidate_role(&role.to_ref()); Ok(Response::new(proto::Role::from(role))) } async fn delete_role( &self, request: Request, ) -> Result, Status> { let role_name = request.into_inner().name; let deleted = self .role_store .delete(&role_name) .await .map_err(map_error)?; if deleted { self.invalidate_role(&role_name); } Ok(Response::new(DeleteRoleResponse { deleted })) } async fn list_roles( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let mut roles = self.role_store.list().await.map_err(map_error)?; if let Some(scope) = req.scope.clone() { let scope: Scope = scope.into(); roles.retain(|role| role.scope.applies_to(&scope)); } if !req.include_builtin { roles.retain(|r| !r.builtin); } roles.sort_by(|left, right| left.name.cmp(&right.name)); let (roles, next_page_token) = paginate(roles, req.page_size, &req.page_token)?; let roles = roles.into_iter().map(proto::Role::from).collect(); Ok(Response::new(ListRolesResponse { roles, next_page_token, })) } async fn create_binding( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let principal_ref = req .principal .ok_or_else(|| Status::invalid_argument("principal is required"))?; let principal_ref = parse_principal_ref(principal_ref)?; let scope: Scope = req .scope .ok_or_else(|| Status::invalid_argument("scope is required"))? .into(); self.ensure_scope_registration(&scope).await?; let principal = self .principal_store .get(&principal_ref) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("principal not found"))?; self.validate_binding_principal_scope(&principal, &scope) .await?; let role = self .role_store .get_by_ref(&req.role) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("role not found"))?; if !role.scope.applies_to(&scope) { return Err(Status::invalid_argument( "role scope is not applicable to the requested binding scope", )); } let mut binding = PolicyBinding::new(Uuid::new_v4().to_string(), principal_ref, req.role, scope); binding.condition = req.condition.map(Into::into); binding.expires_at = req.expires_at; binding.created_at = now_ts(); binding.updated_at = binding.created_at; binding.created_by = "iam-admin".into(); self.binding_store .create(&binding) .await .map_err(map_error)?; self.invalidate_principal_bindings(&binding.principal_ref); Ok(Response::new(proto::PolicyBinding::from(binding))) } async fn get_binding( &self, request: Request, ) -> Result, Status> { let id = request.into_inner().id; let binding = self .binding_store .get_by_id(&id) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("binding not found"))?; Ok(Response::new(proto::PolicyBinding::from(binding))) } async fn update_binding( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let (mut binding, version) = self .binding_store .get_by_id_with_version(&req.id) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("binding not found"))?; if let Some(condition) = req.condition { binding.condition = Some(condition.into()); } if let Some(exp) = req.expires_at { binding.expires_at = Some(exp); } if let Some(enabled) = req.enabled { binding.enabled = enabled; } binding.updated_at = now_ts(); self.binding_store .update(&binding, version) .await .map_err(map_error)?; self.invalidate_principal_bindings(&binding.principal_ref); Ok(Response::new(proto::PolicyBinding::from(binding))) } async fn delete_binding( &self, request: Request, ) -> Result, Status> { let id = request.into_inner().id; let binding = self .binding_store .get_by_id(&id) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("binding not found"))?; let deleted = self .binding_store .delete(&binding.scope, &binding.principal_ref, &binding.id) .await .map_err(map_error)?; if deleted { self.invalidate_principal_bindings(&binding.principal_ref); } Ok(Response::new(DeleteBindingResponse { deleted })) } async fn list_bindings( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let mut bindings = if let Some(principal) = req.principal.clone() { let principal_ref = parse_principal_ref(principal)?; self.binding_store .list_by_principal(&principal_ref) .await .map_err(map_error)? } else if let Some(role) = req.role.clone() { self.binding_store .list_by_role(&role) .await .map_err(map_error)? } else if let Some(scope) = req.scope.clone() { let scope: Scope = scope.into(); let mut filtered = self.binding_store.list_all().await.map_err(map_error)?; filtered.retain(|binding| binding.scope == scope); filtered } else { self.binding_store.list_all().await.map_err(map_error)? }; if !req.include_disabled { bindings.retain(|b| b.enabled); } bindings.sort_by(|left, right| left.id.cmp(&right.id)); let (bindings, next_page_token) = paginate(bindings, req.page_size, &req.page_token)?; let bindings = bindings .into_iter() .map(proto::PolicyBinding::from) .collect(); Ok(Response::new(ListBindingsResponse { bindings, next_page_token, })) } async fn add_group_member( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let member_ref = req .principal .ok_or_else(|| Status::invalid_argument("principal is required"))?; let member_ref = parse_principal_ref(member_ref)?; let group_ref = PrincipalRef::group(&req.group_id); let group = self .principal_store .get(&group_ref) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("group not found"))?; if group.kind != TypesPrincipalKind::Group { return Err(Status::invalid_argument( "group_id must reference a group principal", )); } self.principal_store .get(&member_ref) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("member principal not found"))?; let added = !self .group_store .is_member(&req.group_id, &member_ref) .await .map_err(map_error)?; self.group_store .add_member(&req.group_id, &member_ref) .await .map_err(map_error)?; self.invalidate_principal_bindings(&member_ref); Ok(Response::new(AddGroupMemberResponse { added })) } async fn remove_group_member( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let member_ref = req .principal .ok_or_else(|| Status::invalid_argument("principal is required"))?; let member_ref = parse_principal_ref(member_ref)?; let removed = self .group_store .remove_member(&req.group_id, &member_ref) .await .map_err(map_error)?; if removed { self.invalidate_principal_bindings(&member_ref); } Ok(Response::new(RemoveGroupMemberResponse { removed })) } async fn list_group_members( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let group_ref = PrincipalRef::group(&req.group_id); self.principal_store .get(&group_ref) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("group not found"))?; let mut members = Vec::new(); for member_ref in self .group_store .list_members(&req.group_id) .await .map_err(map_error)? { if let Some(principal) = self .principal_store .get(&member_ref) .await .map_err(map_error)? { members.push(principal); } } members.sort_by(|left, right| { left.kind .to_string() .cmp(&right.kind.to_string()) .then_with(|| left.id.cmp(&right.id)) }); let (members, next_page_token) = paginate(members, req.page_size, &req.page_token)?; Ok(Response::new(ListGroupMembersResponse { members: members.into_iter().map(Into::into).collect(), next_page_token, })) } async fn list_principal_groups( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let principal_ref = req .principal .ok_or_else(|| Status::invalid_argument("principal is required"))?; let principal_ref = parse_principal_ref(principal_ref)?; self.principal_store .get(&principal_ref) .await .map_err(map_error)? .ok_or_else(|| Status::not_found("principal not found"))?; let mut groups = Vec::new(); for group_id in self .group_store .list_groups(&principal_ref) .await .map_err(map_error)? { if let Some(group) = self .principal_store .get(&PrincipalRef::group(group_id)) .await .map_err(map_error)? { groups.push(group); } } groups.sort_by(|left, right| left.id.cmp(&right.id)); let (groups, next_page_token) = paginate(groups, req.page_size, &req.page_token)?; Ok(Response::new(ListPrincipalGroupsResponse { groups: groups.into_iter().map(Into::into).collect(), next_page_token, })) } } #[cfg(test)] mod tests { use super::*; use iam_authz::PolicyCache; use iam_store::Backend; fn admin_service() -> ( IamAdminService, Arc, Arc, Arc, ) { let backend = Arc::new(Backend::memory()); let principal_store = Arc::new(PrincipalStore::new(backend.clone())); let role_store = Arc::new(RoleStore::new(backend.clone())); let binding_store = Arc::new(BindingStore::new(backend.clone())); let org_store = Arc::new(OrgStore::new(backend.clone())); let project_store = Arc::new(ProjectStore::new(backend.clone())); let group_store = Arc::new(GroupStore::new(backend)); ( IamAdminService::new( principal_store.clone(), role_store.clone(), binding_store.clone(), org_store, project_store, group_store, ), principal_store, role_store, binding_store, ) } fn test_stores() -> (Arc, Arc, Arc) { let backend = Arc::new(Backend::memory()); let principal_store = Arc::new(PrincipalStore::new(backend.clone())); let role_store = Arc::new(RoleStore::new(backend.clone())); let binding_store = Arc::new(BindingStore::new(backend)); (principal_store, role_store, binding_store) } #[tokio::test] async fn test_authorize_no_principal() { let (principal_store, role_store, binding_store) = test_stores(); let cache = Arc::new(PolicyCache::default_config()); let evaluator = Arc::new(PolicyEvaluator::new(binding_store, role_store, cache)); let service = IamAuthzService::new(evaluator, principal_store); let req = AuthorizeRequest { principal: None, action: "compute:instances:create".into(), resource: Some(proto::ResourceRef { kind: "instance".into(), id: "vm-1".into(), org_id: "org-1".into(), project_id: "proj-1".into(), owner_id: None, node_id: None, region: None, tags: Default::default(), }), context: None, }; let result = service.authorize(Request::new(req)).await; assert!(result.is_err()); } #[tokio::test] async fn test_admin_crud_binding_flow() { let (service, principal_store, role_store, _binding_store) = admin_service(); // Create principal let principal_req = CreatePrincipalRequest { id: "alice".into(), kind: PrincipalKind::User as i32, name: "Alice".into(), org_id: None, project_id: None, email: None, metadata: Default::default(), }; service .create_principal(Request::new(principal_req)) .await .unwrap(); // Create role let role_req = CreateRoleRequest { name: "ProjectViewer".into(), display_name: "Viewer".into(), description: "read-only".into(), scope: Some(proto::Scope { scope: Some(proto::scope::Scope::Project(proto::ProjectScope { id: "proj-1".into(), org_id: "org-1".into(), })), }), permissions: vec![proto::Permission { action: "*:read".into(), resource_pattern: "project/${project}/*".into(), condition: None, }], }; service.create_role(Request::new(role_req)).await.unwrap(); // Create binding let binding_req = CreateBindingRequest { principal: Some(proto::PrincipalRef { kind: PrincipalKind::User as i32, id: "alice".into(), }), role: "roles/ProjectViewer".into(), scope: Some(proto::Scope { scope: Some(proto::scope::Scope::Project(proto::ProjectScope { id: "proj-1".into(), org_id: "org-1".into(), })), }), condition: None, expires_at: None, }; let binding_resp = service .create_binding(Request::new(binding_req)) .await .unwrap() .into_inner(); assert_eq!(binding_resp.role, "roles/ProjectViewer"); // List bindings filtered by principal let list_resp = service .list_bindings(Request::new(ListBindingsRequest { principal: Some(proto::PrincipalRef { kind: PrincipalKind::User as i32, id: "alice".into(), }), role: None, scope: None, include_disabled: false, page_size: 0, page_token: String::new(), })) .await .unwrap() .into_inner(); assert_eq!(list_resp.bindings.len(), 1); // Delete binding let del_resp = service .delete_binding(Request::new(DeleteBindingRequest { id: binding_resp.id, })) .await .unwrap() .into_inner(); assert!(del_resp.deleted); // Principal still exists assert!(principal_store .get(&PrincipalRef::user("alice")) .await .unwrap() .is_some()); // Role still exists assert!(role_store.get("ProjectViewer").await.unwrap().is_some()); } #[tokio::test] async fn test_admin_org_project_crud_flow() { let (service, _principal_store, _role_store, _binding_store) = admin_service(); let organization = service .create_organization(Request::new(CreateOrganizationRequest { id: "org-1".into(), name: "Org 1".into(), description: "primary org".into(), metadata: Default::default(), })) .await .unwrap() .into_inner(); assert_eq!(organization.id, "org-1"); let project = service .create_project(Request::new(CreateProjectRequest { id: "proj-1".into(), org_id: "org-1".into(), name: "Project 1".into(), description: "tenant project".into(), metadata: Default::default(), })) .await .unwrap() .into_inner(); assert_eq!(project.org_id, "org-1"); let projects = service .list_projects(Request::new(ListProjectsRequest { org_id: Some("org-1".into()), include_disabled: false, page_size: 0, page_token: String::new(), })) .await .unwrap() .into_inner(); assert_eq!(projects.projects.len(), 1); assert_eq!(projects.projects[0].id, "proj-1"); } #[tokio::test] async fn test_binding_creation_invalidates_cached_deny() { let (principal_store, role_store, binding_store) = test_stores(); role_store.init_builtin_roles().await.unwrap(); let principal = Principal::new_service_account( "svc-lightningstor", "svc-lightningstor", "org-1", "proj-1", ); principal_store.create(&principal).await.unwrap(); let cache = Arc::new(PolicyCache::default_config()); let evaluator = Arc::new(PolicyEvaluator::new( binding_store.clone(), role_store.clone(), cache, )); let authz_service = IamAuthzService::new(evaluator.clone(), principal_store.clone()); let tenant_backend = Arc::new(Backend::memory()); let admin_service = IamAdminService::new( principal_store, role_store, binding_store, Arc::new(OrgStore::new(tenant_backend.clone())), Arc::new(ProjectStore::new(tenant_backend.clone())), Arc::new(GroupStore::new(tenant_backend)), ) .with_evaluator(evaluator); let authorize_request = || AuthorizeRequest { principal: Some(proto::PrincipalRef { kind: PrincipalKind::ServiceAccount as i32, id: "svc-lightningstor".into(), }), action: "storage:buckets:read".into(), resource: Some(proto::ResourceRef { kind: "bucket".into(), id: "bucket-1".into(), org_id: "org-1".into(), project_id: "proj-1".into(), owner_id: None, node_id: None, region: None, tags: Default::default(), }), context: None, }; let initial = authz_service .authorize(Request::new(authorize_request())) .await .unwrap() .into_inner(); assert!(!initial.allowed); admin_service .create_binding(Request::new(CreateBindingRequest { principal: Some(proto::PrincipalRef { kind: PrincipalKind::ServiceAccount as i32, id: "svc-lightningstor".into(), }), role: "roles/ProjectAdmin".into(), scope: Some(proto::Scope { scope: Some(proto::scope::Scope::Project(proto::ProjectScope { id: "proj-1".into(), org_id: "org-1".into(), })), }), condition: None, expires_at: None, })) .await .unwrap(); let allowed = authz_service .authorize(Request::new(authorize_request())) .await .unwrap() .into_inner(); assert!(allowed.allowed); } }