From b75766af0b1e5b9bcca569d484fbdb81b18517c9 Mon Sep 17 00:00:00 2001
From: centra
Date: Tue, 31 Mar 2026 01:23:16 +0900
Subject: [PATCH] Implement IAM tenant registry and privileged admin surfaces
---
deployer/crates/fleet-scheduler/src/auth.rs | 2 +-
iam/crates/iam-api/src/conversions.rs | 73 +-
iam/crates/iam-api/src/credential_service.rs | 132 ++-
.../iam-api/src/gateway_auth_service.rs | 35 +-
iam/crates/iam-api/src/iam_service.rs | 816 +++++++++++++++---
iam/crates/iam-api/src/token_service.rs | 154 +++-
iam/crates/iam-authn/src/provider.rs | 40 -
iam/crates/iam-client/src/client.rs | 314 ++++++-
iam/crates/iam-server/src/main.rs | 194 ++++-
iam/crates/iam-server/src/rest.rs | 506 ++++++++---
iam/crates/iam-service-auth/src/lib.rs | 32 +-
iam/crates/iam-store/src/lib.rs | 4 +
iam/crates/iam-store/src/org_store.rs | 109 +++
iam/crates/iam-store/src/principal_store.rs | 83 +-
iam/crates/iam-store/src/project_store.rs | 174 ++++
iam/crates/iam-types/src/error.rs | 16 +
iam/crates/iam-types/src/lib.rs | 2 +
iam/crates/iam-types/src/principal.rs | 3 +-
iam/crates/iam-types/src/scope.rs | 73 ++
iam/crates/iam-types/src/tenant.rs | 93 ++
iam/proto/iam.proto | 174 ++++
k8shost/crates/k8shost-server/src/auth.rs | 27 +-
.../lightningstor-server/src/s3/auth.rs | 186 ++--
nix/modules/first-boot-automation.nix | 27 +-
nix/modules/iam.nix | 9 +
.../plasmavmc-server/src/artifact_store.rs | 2 +-
.../crates/plasmavmc-server/src/vm_service.rs | 35 +-
27 files changed, 2837 insertions(+), 478 deletions(-)
create mode 100644 iam/crates/iam-store/src/org_store.rs
create mode 100644 iam/crates/iam-store/src/project_store.rs
create mode 100644 iam/crates/iam-types/src/tenant.rs
diff --git a/deployer/crates/fleet-scheduler/src/auth.rs b/deployer/crates/fleet-scheduler/src/auth.rs
index 4153433..810a9a4 100644
--- a/deployer/crates/fleet-scheduler/src/auth.rs
+++ b/deployer/crates/fleet-scheduler/src/auth.rs
@@ -30,7 +30,7 @@ pub async fn issue_controller_token(
Some(existing) => existing,
None => {
client
- .create_service_account(principal_id, principal_id, project_id)
+ .create_service_account(principal_id, principal_id, org_id, project_id)
.await?
}
};
diff --git a/iam/crates/iam-api/src/conversions.rs b/iam/crates/iam-api/src/conversions.rs
index e765c08..3422bc1 100644
--- a/iam/crates/iam-api/src/conversions.rs
+++ b/iam/crates/iam-api/src/conversions.rs
@@ -4,14 +4,15 @@
use iam_types::{
Condition as TypesCondition, ConditionExpr as TypesConditionExpr,
- Permission as TypesPermission, PolicyBinding as TypesBinding, Principal as TypesPrincipal,
- PrincipalKind as TypesPrincipalKind, PrincipalRef as TypesPrincipalRef, Role as TypesRole,
- Scope as TypesScope,
+ Organization as TypesOrganization, Permission as TypesPermission,
+ PolicyBinding as TypesBinding, Principal as TypesPrincipal,
+ PrincipalKind as TypesPrincipalKind, PrincipalRef as TypesPrincipalRef,
+ Project as TypesProject, Role as TypesRole, Scope as TypesScope,
};
use crate::proto::{
- self, condition_expr, scope, Condition, ConditionExpr, Permission, PolicyBinding, Principal,
- PrincipalKind, PrincipalRef, Role, Scope,
+ self, condition_expr, scope, Condition, ConditionExpr, Organization, Permission, PolicyBinding,
+ Principal, PrincipalKind, PrincipalRef, Project, Role, Scope,
};
// ============================================================================
@@ -98,6 +99,68 @@ impl From for TypesPrincipal {
}
}
+// ============================================================================
+// Organization / Project conversions
+// ============================================================================
+
+impl From for Organization {
+ fn from(org: TypesOrganization) -> Self {
+ Organization {
+ id: org.id,
+ name: org.name,
+ description: org.description,
+ metadata: org.metadata,
+ created_at: org.created_at,
+ updated_at: org.updated_at,
+ enabled: org.enabled,
+ }
+ }
+}
+
+impl From for TypesOrganization {
+ fn from(org: Organization) -> Self {
+ TypesOrganization {
+ id: org.id,
+ name: org.name,
+ description: org.description,
+ metadata: org.metadata,
+ created_at: org.created_at,
+ updated_at: org.updated_at,
+ enabled: org.enabled,
+ }
+ }
+}
+
+impl From for Project {
+ fn from(project: TypesProject) -> Self {
+ Project {
+ id: project.id,
+ org_id: project.org_id,
+ name: project.name,
+ description: project.description,
+ metadata: project.metadata,
+ created_at: project.created_at,
+ updated_at: project.updated_at,
+ enabled: project.enabled,
+ }
+ }
+}
+
+impl From for TypesProject {
+ fn from(project: Project) -> Self {
+ TypesProject {
+ id: project.id,
+ org_id: project.org_id,
+ name: project.name,
+ description: project.description,
+ metadata: project.metadata,
+ created_at: project.created_at,
+ updated_at: project.updated_at,
+ enabled: project.enabled,
+ }
+ }
+}
+
// ============================================================================
// Scope conversions
// ============================================================================
diff --git a/iam/crates/iam-api/src/credential_service.rs b/iam/crates/iam-api/src/credential_service.rs
index 77971c2..74b81fc 100644
--- a/iam/crates/iam-api/src/credential_service.rs
+++ b/iam/crates/iam-api/src/credential_service.rs
@@ -2,19 +2,23 @@ use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit, Nonce};
-use argon2::{password_hash::{PasswordHasher, SaltString}, Argon2};
+use argon2::{
+ password_hash::{PasswordHasher, SaltString},
+ Argon2,
+};
use base64::{engine::general_purpose::STANDARD, Engine};
use rand_core::{OsRng, RngCore};
use tonic::{Request, Response, Status};
-use iam_store::CredentialStore;
-use iam_types::{Argon2Params, CredentialRecord, PrincipalKind as TypesPrincipalKind};
+use iam_store::{CredentialStore, PrincipalStore};
+use iam_types::{
+ Argon2Params, CredentialRecord, PrincipalKind as TypesPrincipalKind, PrincipalRef,
+};
use crate::proto::{
- iam_credential_server::IamCredential, CreateS3CredentialRequest,
- CreateS3CredentialResponse, Credential, GetSecretKeyRequest, GetSecretKeyResponse,
- ListCredentialsRequest, ListCredentialsResponse, PrincipalKind, RevokeCredentialRequest,
- RevokeCredentialResponse,
+ iam_credential_server::IamCredential, CreateS3CredentialRequest, CreateS3CredentialResponse,
+ Credential, GetSecretKeyRequest, GetSecretKeyResponse, ListCredentialsRequest,
+ ListCredentialsResponse, PrincipalKind, RevokeCredentialRequest, RevokeCredentialResponse,
};
fn now_ts() -> u64 {
@@ -26,12 +30,20 @@ fn now_ts() -> u64 {
pub struct IamCredentialService {
store: Arc,
+ principal_store: Arc,
cipher: Aes256Gcm,
key_id: String,
+ admin_token: Option,
}
impl IamCredentialService {
- pub fn new(store: Arc, master_key: &[u8], key_id: &str) -> Result {
+ pub fn new(
+ store: Arc,
+ principal_store: Arc,
+ master_key: &[u8],
+ key_id: &str,
+ admin_token: Option,
+ ) -> Result {
if master_key.len() != 32 {
return Err(Status::failed_precondition(
"IAM_CRED_MASTER_KEY must be 32 bytes",
@@ -40,8 +52,10 @@ impl IamCredentialService {
let cipher = Aes256Gcm::new(Key::::from_slice(master_key));
Ok(Self {
store,
+ principal_store,
cipher,
key_id: key_id.to_string(),
+ admin_token,
})
}
@@ -93,6 +107,41 @@ impl IamCredentialService {
.decrypt(nonce, ct)
.map_err(|e| Status::internal(format!("decrypt failed: {}", e)))
}
+
+ fn admin_token_valid(metadata: &tonic::metadata::MetadataMap, token: &str) -> bool {
+ if let Some(value) = metadata.get("x-iam-admin-token") {
+ if let Ok(raw) = value.to_str() {
+ if raw.trim() == token {
+ return true;
+ }
+ }
+ }
+
+ if let Some(value) = metadata.get("authorization") {
+ if let Ok(raw) = value.to_str() {
+ let raw = raw.trim();
+ if let Some(rest) = raw.strip_prefix("Bearer ") {
+ return rest.trim() == token;
+ }
+ if let Some(rest) = raw.strip_prefix("bearer ") {
+ return rest.trim() == token;
+ }
+ }
+ }
+
+ false
+ }
+
+ fn require_admin_token(&self, request: &Request) -> Result<(), Status> {
+ if let Some(token) = self.admin_token.as_deref() {
+ if !Self::admin_token_valid(request.metadata(), token) {
+ return Err(Status::unauthenticated(
+ "missing or invalid IAM admin token",
+ ));
+ }
+ }
+ Ok(())
+ }
}
fn map_principal_kind(kind: i32) -> Result {
@@ -110,9 +159,22 @@ impl IamCredential for IamCredentialService {
&self,
request: Request,
) -> Result, Status> {
+ self.require_admin_token(&request)?;
let req = request.into_inner();
let now = now_ts();
let principal_kind = map_principal_kind(req.principal_kind)?;
+ let principal_ref = PrincipalRef::new(principal_kind.clone(), &req.principal_id);
+ let principal = self
+ .principal_store
+ .get(&principal_ref)
+ .await
+ .map_err(|e| Status::internal(format!("principal lookup failed: {}", e)))?
+ .ok_or_else(|| Status::not_found("principal not found"))?;
+ if principal.org_id != req.org_id || principal.project_id != req.project_id {
+ return Err(Status::invalid_argument(
+ "credential tenant does not match principal tenant",
+ ));
+ }
let (secret_b64, raw_secret) = Self::generate_secret();
let (hash, kdf) = Self::hash_secret(&raw_secret);
let secret_enc = self.encrypt_secret(&raw_secret)?;
@@ -156,6 +218,7 @@ impl IamCredential for IamCredentialService {
&self,
request: Request,
) -> Result, Status> {
+ self.require_admin_token(&request)?;
let req = request.into_inner();
let record = match self.store.get(&req.access_key_id).await {
Ok(Some((rec, _))) => rec,
@@ -195,6 +258,7 @@ impl IamCredential for IamCredentialService {
&self,
request: Request,
) -> Result, Status> {
+ self.require_admin_token(&request)?;
let req = request.into_inner();
let items = self
.store
@@ -219,13 +283,16 @@ impl IamCredential for IamCredentialService {
},
})
.collect();
- Ok(Response::new(ListCredentialsResponse { credentials: creds }))
+ Ok(Response::new(ListCredentialsResponse {
+ credentials: creds,
+ }))
}
async fn revoke_credential(
&self,
request: Request,
) -> Result, Status> {
+ self.require_admin_token(&request)?;
let req = request.into_inner();
let revoked = self
.store
@@ -241,17 +308,41 @@ mod tests {
use super::*;
use base64::engine::general_purpose::STANDARD;
use iam_store::Backend;
+ use iam_types::Principal;
- fn test_service() -> IamCredentialService {
+ fn test_service() -> (IamCredentialService, Arc) {
let backend = Arc::new(Backend::memory());
- let store = Arc::new(CredentialStore::new(backend));
+ let store = Arc::new(CredentialStore::new(backend.clone()));
+ let principal_store = Arc::new(PrincipalStore::new(backend));
let master_key = [0x42u8; 32];
- IamCredentialService::new(store, &master_key, "test-key").unwrap()
+ (
+ IamCredentialService::new(
+ store,
+ principal_store.clone(),
+ &master_key,
+ "test-key",
+ None,
+ )
+ .unwrap(),
+ principal_store,
+ )
+ }
+
+ async fn seed_service_account(
+ principal_store: &PrincipalStore,
+ principal_id: &str,
+ org_id: &str,
+ project_id: &str,
+ ) {
+ let principal =
+ Principal::new_service_account(principal_id, principal_id, org_id, project_id);
+ principal_store.create(&principal).await.unwrap();
}
#[tokio::test]
async fn create_and_get_roundtrip() {
- let svc = test_service();
+ let (svc, principal_store) = test_service();
+ seed_service_account(&principal_store, "p1", "org-a", "project-a").await;
let create = svc
.create_s3_credential(Request::new(CreateS3CredentialRequest {
principal_id: "p1".into(),
@@ -284,7 +375,9 @@ mod tests {
#[tokio::test]
async fn list_filters_by_principal() {
- let svc = test_service();
+ let (svc, principal_store) = test_service();
+ seed_service_account(&principal_store, "pA", "org-a", "project-a").await;
+ seed_service_account(&principal_store, "pB", "org-b", "project-b").await;
let a = svc
.create_s3_credential(Request::new(CreateS3CredentialRequest {
principal_id: "pA".into(),
@@ -322,7 +415,8 @@ mod tests {
#[tokio::test]
async fn revoke_blocks_get() {
- let svc = test_service();
+ let (svc, principal_store) = test_service();
+ seed_service_account(&principal_store, "p1", "org-a", "project-a").await;
let created = svc
.create_s3_credential(Request::new(CreateS3CredentialRequest {
principal_id: "p1".into(),
@@ -365,7 +459,8 @@ mod tests {
#[tokio::test]
async fn expired_key_is_denied() {
- let svc = test_service();
+ let (svc, principal_store) = test_service();
+ seed_service_account(&principal_store, "p1", "org-a", "project-a").await;
// Manually insert an expired record
let expired = CredentialRecord {
access_key_id: "expired-ak".into(),
@@ -401,8 +496,9 @@ mod tests {
#[test]
fn master_key_length_enforced() {
let backend = Arc::new(Backend::memory());
- let store = Arc::new(CredentialStore::new(backend));
- let bad = IamCredentialService::new(store.clone(), &[0u8; 16], "k");
+ let store = Arc::new(CredentialStore::new(backend.clone()));
+ let principal_store = Arc::new(PrincipalStore::new(backend));
+ let bad = IamCredentialService::new(store.clone(), principal_store, &[0u8; 16], "k", None);
assert!(bad.is_err());
}
}
diff --git a/iam/crates/iam-api/src/gateway_auth_service.rs b/iam/crates/iam-api/src/gateway_auth_service.rs
index 51d93c0..007f457 100644
--- a/iam/crates/iam-api/src/gateway_auth_service.rs
+++ b/iam/crates/iam-api/src/gateway_auth_service.rs
@@ -5,8 +5,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
use apigateway_api::proto::{AuthorizeRequest, AuthorizeResponse, Subject};
use apigateway_api::GatewayAuthService;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
-use iam_authz::{AuthzContext, AuthzDecision, AuthzRequest, PolicyEvaluator};
use iam_authn::InternalTokenService;
+use iam_authz::{AuthzContext, AuthzDecision, AuthzRequest, PolicyEvaluator};
use iam_store::{PrincipalStore, TokenStore};
use iam_types::{InternalTokenClaims, Principal, PrincipalRef, Resource};
use sha2::{Digest, Sha256};
@@ -87,7 +87,10 @@ impl GatewayAuthService for GatewayAuthServiceImpl {
Err(err) => return Ok(Response::new(deny_response(err.to_string()))),
};
- if let Some(reason) = self.check_token_revoked(&claims.principal_id, token).await? {
+ if let Some(reason) = self
+ .check_token_revoked(&claims.principal_id, token)
+ .await?
+ {
return Ok(Response::new(deny_response(reason)));
}
@@ -108,7 +111,10 @@ impl GatewayAuthService for GatewayAuthServiceImpl {
}
let (action, resource, context, org_id, project_id) =
- build_authz_request(&req, &claims, &principal);
+ match build_authz_request(&req, &claims, &principal) {
+ Ok(values) => values,
+ Err(reason) => return Ok(Response::new(deny_response(reason))),
+ };
let authz_request =
AuthzRequest::new(principal.clone(), action, resource).with_context(context);
let decision = self
@@ -181,9 +187,9 @@ fn build_authz_request(
req: &AuthorizeRequest,
claims: &InternalTokenClaims,
principal: &Principal,
-) -> (String, Resource, AuthzContext, String, String) {
+) -> Result<(String, Resource, AuthzContext, String, String), String> {
let action = action_for_request(req);
- let (org_id, project_id) = resolve_org_project(req, claims, principal);
+ let (org_id, project_id) = resolve_org_project(req, claims, principal)?;
let mut resource = Resource::new(
"gateway_route",
resource_id_for_request(req),
@@ -212,7 +218,7 @@ fn build_authz_request(
context = context.with_source_ip(ip);
}
- (action, resource, context, org_id, project_id)
+ Ok((action, resource, context, org_id, project_id))
}
fn action_for_request(req: &AuthorizeRequest) -> String {
@@ -265,7 +271,7 @@ fn resolve_org_project(
req: &AuthorizeRequest,
claims: &InternalTokenClaims,
principal: &Principal,
-) -> (String, String) {
+) -> Result<(String, String), String> {
let allow_header_override = allow_header_tenant_override();
let org_id = claims
.org_id
@@ -279,7 +285,7 @@ fn resolve_org_project(
None
}
})
- .unwrap_or_else(|| "system".to_string());
+ .ok_or_else(|| "tenant resolution failed: missing org_id".to_string())?;
let project_id = claims
.project_id
@@ -293,9 +299,9 @@ fn resolve_org_project(
None
}
})
- .unwrap_or_else(|| "system".to_string());
+ .ok_or_else(|| "tenant resolution failed: missing project_id".to_string())?;
- (org_id, project_id)
+ Ok((org_id, project_id))
}
fn allow_header_tenant_override() -> bool {
@@ -383,7 +389,14 @@ mod tests {
token_store.clone(),
evaluator,
);
- (service, token_service, role_store, binding_store, token_store, principal)
+ (
+ service,
+ token_service,
+ role_store,
+ binding_store,
+ token_store,
+ principal,
+ )
}
#[tokio::test]
diff --git a/iam/crates/iam-api/src/iam_service.rs b/iam/crates/iam-api/src/iam_service.rs
index 2d492c3..a058d18 100644
--- a/iam/crates/iam-api/src/iam_service.rs
+++ b/iam/crates/iam-api/src/iam_service.rs
@@ -4,27 +4,35 @@ 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, PrincipalStore, RoleStore};
+use iam_store::{BindingStore, GroupStore, OrgStore, PrincipalStore, ProjectStore, RoleStore};
use iam_types::{
- Error as TypesError, IamError, PolicyBinding, Principal, PrincipalKind as TypesPrincipalKind,
- PrincipalRef, Resource, Role, Scope, StorageError,
+ 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, AuthorizeRequest,
- AuthorizeResponse, BatchAuthorizeRequest, BatchAuthorizeResponse, CreateBindingRequest,
- CreatePrincipalRequest, CreateRoleRequest, DeleteBindingRequest, DeleteBindingResponse,
- DeletePrincipalRequest, DeletePrincipalResponse, DeleteRoleRequest, DeleteRoleResponse,
- GetBindingRequest, GetPrincipalRequest, GetRoleRequest, ListBindingsRequest,
- ListBindingsResponse, ListPrincipalsRequest, ListPrincipalsResponse, ListRolesRequest,
- ListRolesResponse, PrincipalKind, UpdateBindingRequest, UpdatePrincipalRequest,
- UpdateRoleRequest,
+ 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
@@ -234,6 +242,9 @@ pub struct IamAdminService {
principal_store: Arc,
role_store: Arc,
binding_store: Arc,
+ org_store: Arc,
+ project_store: Arc,
+ group_store: Arc,
evaluator: Option>,
}
@@ -243,11 +254,17 @@ impl IamAdminService {
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,
}
}
@@ -269,6 +286,81 @@ impl IamAdminService {
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 {
@@ -282,10 +374,14 @@ fn map_error(err: TypesError) -> Status {
match err {
TypesError::Iam(IamError::PrincipalNotFound(msg))
| TypesError::Iam(IamError::RoleNotFound(msg))
- | TypesError::Iam(IamError::BindingNotFound(msg)) => Status::not_found(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)) => Status::already_exists(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),
@@ -297,6 +393,66 @@ fn map_error(err: TypesError) -> Status {
}
}
+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(
@@ -304,16 +460,29 @@ impl IamAdmin for IamAdminService {
request: Request,
) -> Result, Status> {
let req = request.into_inner();
- let kind = match PrincipalKind::try_from(req.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 kind = parse_principal_kind(req.kind)?;
let mut principal = match kind {
- TypesPrincipalKind::User => Principal::new_user(&req.id, &req.name),
+ 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()
@@ -321,15 +490,32 @@ impl IamAdmin for IamAdminService {
.ok_or_else(|| {
Status::invalid_argument("project_id is required for service accounts")
})?;
- Principal::new_service_account(&req.id, &req.name, project_id)
+ 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)
}
- TypesPrincipalKind::Group => 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;
@@ -350,16 +536,7 @@ impl IamAdmin for IamAdminService {
.into_inner()
.principal
.ok_or_else(|| Status::invalid_argument("principal is required"))?;
-
- let principal_ref = PrincipalRef::new(
- 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")),
- },
- &principal_ref.id,
- );
+ let principal_ref = parse_principal_ref(principal_ref)?;
let principal = self
.principal_store
@@ -379,16 +556,7 @@ impl IamAdmin for IamAdminService {
let principal_ref = req
.principal
.ok_or_else(|| Status::invalid_argument("principal is required"))?;
-
- let principal_ref = PrincipalRef::new(
- 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")),
- },
- &principal_ref.id,
- );
+ let principal_ref = parse_principal_ref(principal_ref)?;
let (mut principal, version) = self
.principal_store
@@ -427,16 +595,7 @@ impl IamAdmin for IamAdminService {
.into_inner()
.principal
.ok_or_else(|| Status::invalid_argument("principal is required"))?;
-
- let principal_ref = PrincipalRef::new(
- 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")),
- },
- &principal_ref.id,
- );
+ let principal_ref = parse_principal_ref(principal_ref)?;
let deleted = self
.principal_store
@@ -452,25 +611,33 @@ impl IamAdmin for IamAdminService {
request: Request,
) -> Result, Status> {
let req = request.into_inner();
- let mut principals = if let Some(kind) = req.kind {
- let kind = match PrincipalKind::try_from(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 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_kind(&kind)
+ .list_by_tenant(org_id, project_id)
.await
.map_err(map_error)?
- } else if let Some(org) = req.org_id {
+ } else if let Some(project_id) = project_filter.as_deref() {
self.principal_store
- .list_by_org(&org)
+ .list_by_project(project_id)
.await
.map_err(map_error)?
- } else if let Some(project) = req.project_id {
+ } else if let Some(org_id) = org_filter.as_deref() {
self.principal_store
- .list_by_project(&project)
+ .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 {
@@ -490,15 +657,228 @@ impl IamAdmin for IamAdminService {
all
};
- if req.page_size > 0 && principals.len() as i32 > req.page_size {
- principals.truncate(req.page_size as usize);
- }
+ 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: String::new(),
+ 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,
}))
}
@@ -595,28 +975,23 @@ impl IamAdmin for IamAdminService {
request: Request,
) -> Result, Status> {
let req = request.into_inner();
- let mut roles = if let Some(scope) = req.scope {
+ let mut roles = self.role_store.list().await.map_err(map_error)?;
+
+ if let Some(scope) = req.scope.clone() {
let scope: Scope = scope.into();
- self.role_store
- .list_by_scope(&scope)
- .await
- .map_err(map_error)?
- } else {
- self.role_store.list().await.map_err(map_error)?
- };
+ 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));
- if req.page_size > 0 && roles.len() as i32 > req.page_size {
- roles.truncate(req.page_size as usize);
- }
-
+ 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: String::new(),
+ next_page_token,
}))
}
@@ -629,21 +1004,35 @@ impl IamAdmin for IamAdminService {
let principal_ref = req
.principal
.ok_or_else(|| Status::invalid_argument("principal is required"))?;
-
- let principal_ref = PrincipalRef::new(
- 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")),
- },
- &principal_ref.id,
- );
+ 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);
@@ -740,31 +1129,22 @@ impl IamAdmin for IamAdminService {
) -> Result, Status> {
let req = request.into_inner();
- let mut bindings = if let Some(principal) = req.principal {
- let principal_ref = PrincipalRef::new(
- match PrincipalKind::try_from(principal.kind) {
- Ok(PrincipalKind::User) => TypesPrincipalKind::User,
- Ok(PrincipalKind::ServiceAccount) => TypesPrincipalKind::ServiceAccount,
- Ok(PrincipalKind::Group) => TypesPrincipalKind::Group,
- _ => return Err(Status::invalid_argument("invalid principal kind")),
- },
- &principal.id,
- );
+ 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 {
+ } 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 {
+ } else if let Some(scope) = req.scope.clone() {
let scope: Scope = scope.into();
- self.binding_store
- .list_by_scope(&scope)
- .await
- .map_err(map_error)?
+ 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)?
};
@@ -772,11 +1152,9 @@ impl IamAdmin for IamAdminService {
if !req.include_disabled {
bindings.retain(|b| b.enabled);
}
+ bindings.sort_by(|left, right| left.id.cmp(&right.id));
- if req.page_size > 0 && bindings.len() as i32 > req.page_size {
- bindings.truncate(req.page_size as usize);
- }
-
+ let (bindings, next_page_token) = paginate(bindings, req.page_size, &req.page_token)?;
let bindings = bindings
.into_iter()
.map(proto::PolicyBinding::from)
@@ -784,7 +1162,149 @@ impl IamAdmin for IamAdminService {
Ok(Response::new(ListBindingsResponse {
bindings,
- next_page_token: String::new(),
+ 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,
}))
}
}
@@ -804,12 +1324,18 @@ mod tests {
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));
+ 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,
@@ -952,14 +1478,60 @@ mod tests {
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 mut principal =
- Principal::new_service_account("svc-lightningstor", "svc-lightningstor", "proj-1");
- principal.org_id = Some("org-1".into());
+ 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());
@@ -969,8 +1541,16 @@ mod tests {
cache,
));
let authz_service = IamAuthzService::new(evaluator.clone(), principal_store.clone());
- let admin_service = IamAdminService::new(principal_store, role_store, binding_store)
- .with_evaluator(evaluator);
+ 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 {
diff --git a/iam/crates/iam-api/src/token_service.rs b/iam/crates/iam-api/src/token_service.rs
index bc8ca94..6082946 100644
--- a/iam/crates/iam-api/src/token_service.rs
+++ b/iam/crates/iam-api/src/token_service.rs
@@ -25,6 +25,7 @@ pub struct IamTokenService {
token_service: Arc,
principal_store: Arc,
token_store: Arc,
+ admin_token: Option,
}
impl IamTokenService {
@@ -33,11 +34,13 @@ impl IamTokenService {
token_service: Arc,
principal_store: Arc,
token_store: Arc,
+ admin_token: Option,
) -> Self {
Self {
token_service,
principal_store,
token_store,
+ admin_token,
}
}
@@ -117,6 +120,110 @@ impl IamTokenService {
}
Ok(token_id)
}
+
+ fn admin_token_valid(metadata: &tonic::metadata::MetadataMap, token: &str) -> bool {
+ if let Some(value) = metadata.get("x-iam-admin-token") {
+ if let Ok(raw) = value.to_str() {
+ if raw.trim() == token {
+ return true;
+ }
+ }
+ }
+
+ if let Some(value) = metadata.get("authorization") {
+ if let Ok(raw) = value.to_str() {
+ let raw = raw.trim();
+ if let Some(rest) = raw.strip_prefix("Bearer ") {
+ return rest.trim() == token;
+ }
+ if let Some(rest) = raw.strip_prefix("bearer ") {
+ return rest.trim() == token;
+ }
+ }
+ }
+
+ false
+ }
+
+ fn require_admin_token(&self, request: &Request) -> Result<(), Status> {
+ if let Some(token) = self.admin_token.as_deref() {
+ if !Self::admin_token_valid(request.metadata(), token) {
+ return Err(Status::unauthenticated(
+ "missing or invalid IAM admin token",
+ ));
+ }
+ }
+ Ok(())
+ }
+
+ fn validate_scope_for_principal(
+ principal: &iam_types::Principal,
+ scope: &TypesScope,
+ ) -> Result<(), Status> {
+ let principal_org = principal.org_id.as_deref();
+ let principal_project = principal.project_id.as_deref();
+
+ match principal.kind {
+ TypesPrincipalKind::ServiceAccount => match scope {
+ TypesScope::System => Err(Status::permission_denied(
+ "service accounts cannot mint system-scoped tokens",
+ )),
+ TypesScope::Org { id } => {
+ if principal_org == Some(id.as_str()) {
+ Ok(())
+ } else {
+ Err(Status::permission_denied(
+ "service account token scope must match principal tenant",
+ ))
+ }
+ }
+ TypesScope::Project { id, org_id } => {
+ if principal_org == Some(org_id.as_str())
+ && principal_project == Some(id.as_str())
+ {
+ Ok(())
+ } else {
+ Err(Status::permission_denied(
+ "service account token scope must match principal tenant",
+ ))
+ }
+ }
+ TypesScope::Resource {
+ project_id, org_id, ..
+ } => {
+ if principal_org == Some(org_id.as_str())
+ && principal_project == Some(project_id.as_str())
+ {
+ Ok(())
+ } else {
+ Err(Status::permission_denied(
+ "service account token scope must match principal tenant",
+ ))
+ }
+ }
+ },
+ _ => {
+ if let Some(org_id) = principal_org {
+ match scope {
+ TypesScope::System => {}
+ TypesScope::Org { id } if id == org_id => {}
+ TypesScope::Project {
+ org_id: scope_org, ..
+ }
+ | TypesScope::Resource {
+ org_id: scope_org, ..
+ } if scope_org == org_id => {}
+ _ => {
+ return Err(Status::permission_denied(
+ "token scope must match principal tenant",
+ ))
+ }
+ }
+ }
+ Ok(())
+ }
+ }
+ }
}
#[tonic::async_trait]
@@ -125,6 +232,7 @@ impl IamToken for IamTokenService {
&self,
request: Request,
) -> Result, Status> {
+ self.require_admin_token(&request)?;
let req = request.into_inner();
// Get principal kind
@@ -149,6 +257,7 @@ impl IamToken for IamTokenService {
// Convert scope
let scope = Self::convert_scope(&req.scope);
+ Self::validate_scope_for_principal(&principal, &scope)?;
// Determine TTL
let ttl = if req.ttl_seconds > 0 {
@@ -395,7 +504,7 @@ mod tests {
#[tokio::test]
async fn test_issue_token_principal_not_found() {
let (token_service, principal_store, token_store) = test_setup();
- let service = IamTokenService::new(token_service, principal_store, token_store);
+ let service = IamTokenService::new(token_service, principal_store, token_store, None);
let req = IssueTokenRequest {
principal_id: "nonexistent".into(),
@@ -412,8 +521,12 @@ mod tests {
#[tokio::test]
async fn test_revoke_and_validate_blocklist() {
let (token_service, principal_store, token_store) = test_setup();
- let service =
- IamTokenService::new(token_service, principal_store.clone(), token_store.clone());
+ let service = IamTokenService::new(
+ token_service,
+ principal_store.clone(),
+ token_store.clone(),
+ None,
+ );
// create principal
let principal = Principal::new_user("alice", "Alice");
@@ -468,8 +581,12 @@ mod tests {
#[tokio::test]
async fn test_validate_token_principal_disabled() {
let (token_service, principal_store, token_store) = test_setup();
- let service =
- IamTokenService::new(token_service, principal_store.clone(), token_store.clone());
+ let service = IamTokenService::new(
+ token_service,
+ principal_store.clone(),
+ token_store.clone(),
+ None,
+ );
let principal = Principal::new_user("alice", "Alice");
principal_store.create(&principal).await.unwrap();
@@ -505,4 +622,31 @@ mod tests {
assert!(!valid_resp.valid);
assert!(valid_resp.reason.contains("disabled"));
}
+
+ #[tokio::test]
+ async fn test_service_account_system_scope_is_rejected() {
+ let (token_service, principal_store, token_store) = test_setup();
+ let service = IamTokenService::new(
+ token_service,
+ principal_store.clone(),
+ token_store.clone(),
+ None,
+ );
+
+ let principal = Principal::new_service_account("svc-1", "Service 1", "org-1", "proj-1");
+ principal_store.create(&principal).await.unwrap();
+
+ let result = service
+ .issue_token(Request::new(IssueTokenRequest {
+ principal_id: "svc-1".into(),
+ principal_kind: PrincipalKind::ServiceAccount as i32,
+ roles: vec![],
+ scope: Some(IamTokenService::convert_scope_to_proto(&TypesScope::System)),
+ ttl_seconds: 3600,
+ }))
+ .await;
+
+ assert!(result.is_err());
+ assert_eq!(result.unwrap_err().code(), tonic::Code::PermissionDenied);
+ }
}
diff --git a/iam/crates/iam-authn/src/provider.rs b/iam/crates/iam-authn/src/provider.rs
index 354d324..12cb30b 100644
--- a/iam/crates/iam-authn/src/provider.rs
+++ b/iam/crates/iam-authn/src/provider.rs
@@ -41,8 +41,6 @@ pub enum AuthnCredentials {
BearerToken(String),
/// mTLS certificate info
Certificate(CertificateInfo),
- /// API key
- ApiKey(String),
}
/// Authentication provider trait
@@ -153,19 +151,6 @@ impl CombinedAuthProvider {
internal_claims: None,
})
}
-
- /// Authenticate using API key
- async fn authenticate_api_key(&self, _api_key: &str) -> Result {
- // API key authentication would typically:
- // 1. Look up the API key in the store
- // 2. Verify it's valid and not expired
- // 3. Return the associated principal
-
- // For now, this is a stub
- Err(Error::Iam(IamError::AuthnFailed(
- "API key authentication not yet implemented".into(),
- )))
- }
}
impl Default for CombinedAuthProvider {
@@ -180,7 +165,6 @@ impl AuthnProvider for CombinedAuthProvider {
match credentials {
AuthnCredentials::BearerToken(token) => self.authenticate_bearer(token).await,
AuthnCredentials::Certificate(cert_info) => self.authenticate_certificate(cert_info),
- AuthnCredentials::ApiKey(key) => self.authenticate_api_key(key).await,
}
}
}
@@ -242,10 +226,6 @@ fn parse_scheme_credentials(value: &str) -> Option {
if scheme.eq_ignore_ascii_case("bearer") {
return Some(AuthnCredentials::BearerToken(token.to_string()));
}
- if scheme.eq_ignore_ascii_case("apikey") {
- return Some(AuthnCredentials::ApiKey(token.to_string()));
- }
-
None
}
@@ -274,16 +254,6 @@ mod tests {
));
}
- #[test]
- fn test_extract_api_key() {
- let creds = extract_credentials_from_headers(Some("ApiKey secret-key"), None);
-
- assert!(matches!(
- creds,
- Some(AuthnCredentials::ApiKey(k)) if k == "secret-key"
- ));
- }
-
#[test]
fn test_extract_bearer_token_case_insensitive() {
let creds = extract_credentials_from_headers(Some("bearer abc123"), None);
@@ -314,14 +284,4 @@ mod tests {
Some(AuthnCredentials::BearerToken(t)) if t == "abc123"
));
}
-
- #[test]
- fn test_extract_api_key_case_insensitive() {
- let creds = extract_credentials_from_headers(Some("apikey secret-key"), None);
-
- assert!(matches!(
- creds,
- Some(AuthnCredentials::ApiKey(k)) if k == "secret-key"
- ));
- }
}
diff --git a/iam/crates/iam-client/src/client.rs b/iam/crates/iam-client/src/client.rs
index 738fbf1..82db153 100644
--- a/iam/crates/iam-client/src/client.rs
+++ b/iam/crates/iam-client/src/client.rs
@@ -8,17 +8,21 @@ use std::time::Duration;
use iam_api::proto::{
iam_admin_client::IamAdminClient, iam_authz_client::IamAuthzClient,
iam_token_client::IamTokenClient, AuthorizeRequest, AuthzContext, CreateBindingRequest,
- CreatePrincipalRequest, CreateRoleRequest, DeleteBindingRequest, GetPrincipalRequest,
- GetRoleRequest, IssueTokenRequest, ListBindingsRequest, ListPrincipalsRequest,
- ListRolesRequest, Principal as ProtoPrincipal, PrincipalKind as ProtoPrincipalKind,
- PrincipalRef as ProtoPrincipalRef, ResourceRef as ProtoResourceRef, RevokeTokenRequest,
- Scope as ProtoScope, ValidateTokenRequest,
+ CreateOrganizationRequest, CreatePrincipalRequest, CreateProjectRequest, CreateRoleRequest,
+ DeleteBindingRequest, DeleteOrganizationRequest, DeleteProjectRequest, GetOrganizationRequest,
+ GetPrincipalRequest, GetProjectRequest, GetRoleRequest, IssueTokenRequest, ListBindingsRequest,
+ ListOrganizationsRequest, ListPrincipalsRequest, ListProjectsRequest, ListRolesRequest,
+ Organization as ProtoOrganization, Principal as ProtoPrincipal,
+ PrincipalKind as ProtoPrincipalKind, PrincipalRef as ProtoPrincipalRef,
+ Project as ProtoProject, ResourceRef as ProtoResourceRef, RevokeTokenRequest,
+ Scope as ProtoScope, UpdateOrganizationRequest, UpdateProjectRequest, ValidateTokenRequest,
};
use iam_types::{
- AuthMethod, Error, IamError, InternalTokenClaims, PolicyBinding, Principal,
- PrincipalKind as TypesPrincipalKind, PrincipalRef, Resource, Result, Role, Scope,
+ AuthMethod, Error, IamError, InternalTokenClaims, Organization, PolicyBinding, Principal,
+ PrincipalKind as TypesPrincipalKind, PrincipalRef, Project, Resource, Result, Role, Scope,
};
use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
+use tonic::{metadata::MetadataValue, Request};
const TRANSIENT_RPC_RETRY_ATTEMPTS: usize = 3;
const TRANSIENT_RPC_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
@@ -33,6 +37,8 @@ pub struct IamClientConfig {
pub timeout_ms: u64,
/// Enable TLS
pub tls: bool,
+ /// Optional admin token for privileged APIs
+ pub admin_token: Option,
}
impl IamClientConfig {
@@ -42,6 +48,7 @@ impl IamClientConfig {
endpoint: endpoint.into(),
timeout_ms: 5000,
tls: true,
+ admin_token: load_admin_token_from_env(),
}
}
@@ -56,11 +63,23 @@ impl IamClientConfig {
self.tls = false;
self
}
+
+ /// Set admin token for privileged APIs.
+ pub fn with_admin_token(mut self, admin_token: impl Into) -> Self {
+ let token = admin_token.into();
+ self.admin_token = if token.trim().is_empty() {
+ None
+ } else {
+ Some(token)
+ };
+ self
+ }
}
/// IAM client
pub struct IamClient {
channel: Channel,
+ admin_token: Option,
}
impl IamClient {
@@ -90,7 +109,10 @@ impl IamClient {
.await
.map_err(|e| Error::Internal(e.to_string()))?;
- Ok(Self { channel })
+ Ok(Self {
+ channel,
+ admin_token: config.admin_token,
+ })
}
fn authz_client(&self) -> IamAuthzClient {
@@ -105,6 +127,22 @@ impl IamClient {
IamTokenClient::new(self.channel.clone())
}
+ fn inject_admin_token(&self, request: &mut Request) {
+ let Some(token) = self.admin_token.as_ref() else {
+ return;
+ };
+
+ if let Ok(value) = MetadataValue::try_from(token.as_str()) {
+ request.metadata_mut().insert("x-iam-admin-token", value);
+ }
+ }
+
+ fn admin_request(&self, message: T) -> Request {
+ let mut request = Request::new(message);
+ self.inject_admin_token(&mut request);
+ request
+ }
+
async fn call_with_retry(operation: &'static str, mut op: F) -> Result
where
F: FnMut() -> Fut,
@@ -219,7 +257,8 @@ impl IamClient {
let resp = Self::call_with_retry("create_principal", || {
let mut client = self.admin_client();
let req = req.clone();
- async move { client.create_principal(req).await }
+ let request = self.admin_request(req);
+ async move { client.create_principal(request).await }
})
.await?
.into_inner();
@@ -234,7 +273,8 @@ impl IamClient {
let resp = Self::call_with_retry("get_principal", || {
let mut client = self.admin_client();
let req = req.clone();
- async move { client.get_principal(req).await }
+ let request = self.admin_request(req);
+ async move { client.get_principal(request).await }
})
.await;
match resp {
@@ -249,13 +289,14 @@ impl IamClient {
&self,
id: &str,
name: &str,
+ org_id: &str,
project_id: &str,
) -> Result {
let req = CreatePrincipalRequest {
id: id.into(),
kind: ProtoPrincipalKind::ServiceAccount as i32,
name: name.into(),
- org_id: None,
+ org_id: Some(org_id.into()),
project_id: Some(project_id.into()),
email: None,
metadata: Default::default(),
@@ -263,7 +304,8 @@ impl IamClient {
let resp = Self::call_with_retry("create_service_account", || {
let mut client = self.admin_client();
let req = req.clone();
- async move { client.create_principal(req).await }
+ let request = self.admin_request(req);
+ async move { client.create_principal(request).await }
})
.await?
.into_inner();
@@ -283,7 +325,8 @@ impl IamClient {
let resp = Self::call_with_retry("list_principals", || {
let mut client = self.admin_client();
let req = req.clone();
- async move { client.list_principals(req).await }
+ let request = self.admin_request(req);
+ async move { client.list_principals(request).await }
})
.await?
.into_inner();
@@ -295,6 +338,219 @@ impl IamClient {
.collect())
}
+ // ========================================================================
+ // Tenant Management APIs
+ // ========================================================================
+
+ /// Create an organization.
+ pub async fn create_organization(
+ &self,
+ id: &str,
+ name: &str,
+ description: &str,
+ ) -> Result {
+ let req = CreateOrganizationRequest {
+ id: id.into(),
+ name: name.into(),
+ description: description.into(),
+ metadata: Default::default(),
+ };
+ let resp = Self::call_with_retry("create_organization", || {
+ let mut client = self.admin_client();
+ let req = req.clone();
+ let request = self.admin_request(req);
+ async move { client.create_organization(request).await }
+ })
+ .await?
+ .into_inner();
+ Ok(ProtoOrganization::into(resp))
+ }
+
+ /// Get an organization by id.
+ pub async fn get_organization(&self, id: &str) -> Result