1607 lines
54 KiB
Rust
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);
|
|
}
|
|
}
|