photoncloud-monorepo/iam/crates/iam-api/src/iam_service.rs

1607 lines
54 KiB
Rust

//! 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<PolicyEvaluator>,
principal_store: Arc<PrincipalStore>,
audit_logger: Option<Arc<AuditLogger>>,
}
impl IamAuthzService {
/// Create a new authorization service without audit logging
pub fn new(evaluator: Arc<PolicyEvaluator>, principal_store: Arc<PrincipalStore>) -> Self {
Self {
evaluator,
principal_store,
audit_logger: None,
}
}
/// Create a new authorization service with audit logging
pub fn with_audit(
evaluator: Arc<PolicyEvaluator>,
principal_store: Arc<PrincipalStore>,
audit_logger: Arc<AuditLogger>,
) -> 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<AuthorizeRequest>,
) -> Result<Response<AuthorizeResponse>, 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::<IpAddr>() {
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<BatchAuthorizeRequest>,
) -> Result<Response<BatchAuthorizeResponse>, 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<PrincipalStore>,
role_store: Arc<RoleStore>,
binding_store: Arc<BindingStore>,
org_store: Arc<OrgStore>,
project_store: Arc<ProjectStore>,
group_store: Arc<GroupStore>,
evaluator: Option<Arc<PolicyEvaluator>>,
}
impl IamAdminService {
/// Create a new admin service
pub fn new(
principal_store: Arc<PrincipalStore>,
role_store: Arc<RoleStore>,
binding_store: Arc<BindingStore>,
org_store: Arc<OrgStore>,
project_store: Arc<ProjectStore>,
group_store: Arc<GroupStore>,
) -> 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<PolicyEvaluator>) -> 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<TypesPrincipalKind, Status> {
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<PrincipalRef, Status> {
Ok(PrincipalRef::new(
parse_principal_kind(principal.kind)?,
principal.id,
))
}
fn decode_page_token(page_token: &str) -> Result<usize, Status> {
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::<usize>()
.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<T>(
mut items: Vec<T>,
page_size: i32,
page_token: &str,
) -> Result<(Vec<T>, 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<CreatePrincipalRequest>,
) -> Result<Response<proto::Principal>, 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<GetPrincipalRequest>,
) -> Result<Response<proto::Principal>, 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<UpdatePrincipalRequest>,
) -> Result<Response<proto::Principal>, 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<DeletePrincipalRequest>,
) -> Result<Response<DeletePrincipalResponse>, 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<ListPrincipalsRequest>,
) -> Result<Response<ListPrincipalsResponse>, 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<CreateOrganizationRequest>,
) -> Result<Response<proto::Organization>, 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<GetOrganizationRequest>,
) -> Result<Response<proto::Organization>, 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<UpdateOrganizationRequest>,
) -> Result<Response<proto::Organization>, 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<DeleteOrganizationRequest>,
) -> Result<Response<DeleteOrganizationResponse>, 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<ListOrganizationsRequest>,
) -> Result<Response<ListOrganizationsResponse>, 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<CreateProjectRequest>,
) -> Result<Response<proto::Project>, 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<GetProjectRequest>,
) -> Result<Response<proto::Project>, 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<UpdateProjectRequest>,
) -> Result<Response<proto::Project>, 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<DeleteProjectRequest>,
) -> Result<Response<DeleteProjectResponse>, 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<ListProjectsRequest>,
) -> Result<Response<ListProjectsResponse>, 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<CreateRoleRequest>,
) -> Result<Response<proto::Role>, 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<GetRoleRequest>,
) -> Result<Response<proto::Role>, 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<UpdateRoleRequest>,
) -> Result<Response<proto::Role>, 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<DeleteRoleRequest>,
) -> Result<Response<DeleteRoleResponse>, 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<ListRolesRequest>,
) -> Result<Response<ListRolesResponse>, 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<CreateBindingRequest>,
) -> Result<Response<proto::PolicyBinding>, 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<GetBindingRequest>,
) -> Result<Response<proto::PolicyBinding>, 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<UpdateBindingRequest>,
) -> Result<Response<proto::PolicyBinding>, 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<DeleteBindingRequest>,
) -> Result<Response<DeleteBindingResponse>, 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<ListBindingsRequest>,
) -> Result<Response<ListBindingsResponse>, 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<AddGroupMemberRequest>,
) -> Result<Response<AddGroupMemberResponse>, 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<RemoveGroupMemberRequest>,
) -> Result<Response<RemoveGroupMemberResponse>, 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<ListGroupMembersRequest>,
) -> Result<Response<ListGroupMembersResponse>, 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<ListPrincipalGroupsRequest>,
) -> Result<Response<ListPrincipalGroupsResponse>, 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<PrincipalStore>,
Arc<RoleStore>,
Arc<BindingStore>,
) {
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<PrincipalStore>, Arc<RoleStore>, Arc<BindingStore>) {
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);
}
}