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> { + let req = GetOrganizationRequest { id: id.into() }; + let resp = Self::call_with_retry("get_organization", || { + let mut client = self.admin_client(); + let req = req.clone(); + let request = self.admin_request(req); + async move { client.get_organization(request).await } + }) + .await; + match resp { + Ok(r) => Ok(Some(r.into_inner().into())), + Err(Error::Internal(message)) if tonic_not_found(&message) => Ok(None), + Err(err) => Err(err), + } + } + + /// List organizations. + pub async fn list_organizations(&self) -> Result> { + let req = ListOrganizationsRequest { + include_disabled: false, + page_size: 0, + page_token: String::new(), + }; + let resp = Self::call_with_retry("list_organizations", || { + let mut client = self.admin_client(); + let req = req.clone(); + let request = self.admin_request(req); + async move { client.list_organizations(request).await } + }) + .await? + .into_inner(); + Ok(resp.organizations.into_iter().map(Into::into).collect()) + } + + /// Update an organization. + pub async fn update_organization( + &self, + id: &str, + name: Option<&str>, + description: Option<&str>, + enabled: Option, + ) -> Result { + let req = UpdateOrganizationRequest { + id: id.into(), + name: name.map(str::to_string), + description: description.map(str::to_string), + metadata: Default::default(), + enabled, + }; + let resp = Self::call_with_retry("update_organization", || { + let mut client = self.admin_client(); + let req = req.clone(); + let request = self.admin_request(req); + async move { client.update_organization(request).await } + }) + .await? + .into_inner(); + Ok(resp.into()) + } + + /// Delete an organization. + pub async fn delete_organization(&self, id: &str) -> Result { + let req = DeleteOrganizationRequest { id: id.into() }; + let resp = Self::call_with_retry("delete_organization", || { + let mut client = self.admin_client(); + let req = req.clone(); + let request = self.admin_request(req); + async move { client.delete_organization(request).await } + }) + .await? + .into_inner(); + Ok(resp.deleted) + } + + /// Create a project. + pub async fn create_project( + &self, + org_id: &str, + id: &str, + name: &str, + description: &str, + ) -> Result { + let req = CreateProjectRequest { + id: id.into(), + org_id: org_id.into(), + name: name.into(), + description: description.into(), + metadata: Default::default(), + }; + let resp = Self::call_with_retry("create_project", || { + let mut client = self.admin_client(); + let req = req.clone(); + let request = self.admin_request(req); + async move { client.create_project(request).await } + }) + .await? + .into_inner(); + Ok(ProtoProject::into(resp)) + } + + /// Get a project by org + id. + pub async fn get_project(&self, org_id: &str, id: &str) -> Result> { + let req = GetProjectRequest { + org_id: org_id.into(), + id: id.into(), + }; + let resp = Self::call_with_retry("get_project", || { + let mut client = self.admin_client(); + let req = req.clone(); + let request = self.admin_request(req); + async move { client.get_project(request).await } + }) + .await; + match resp { + Ok(r) => Ok(Some(r.into_inner().into())), + Err(Error::Internal(message)) if tonic_not_found(&message) => Ok(None), + Err(err) => Err(err), + } + } + + /// List projects, optionally filtered by organization. + pub async fn list_projects(&self, org_id: Option<&str>) -> Result> { + let req = ListProjectsRequest { + org_id: org_id.map(str::to_string), + include_disabled: false, + page_size: 0, + page_token: String::new(), + }; + let resp = Self::call_with_retry("list_projects", || { + let mut client = self.admin_client(); + let req = req.clone(); + let request = self.admin_request(req); + async move { client.list_projects(request).await } + }) + .await? + .into_inner(); + Ok(resp.projects.into_iter().map(Into::into).collect()) + } + + /// Update a project. + pub async fn update_project( + &self, + org_id: &str, + id: &str, + name: Option<&str>, + description: Option<&str>, + enabled: Option, + ) -> Result { + let req = UpdateProjectRequest { + org_id: org_id.into(), + id: id.into(), + name: name.map(str::to_string), + description: description.map(str::to_string), + metadata: Default::default(), + enabled, + }; + let resp = Self::call_with_retry("update_project", || { + let mut client = self.admin_client(); + let req = req.clone(); + let request = self.admin_request(req); + async move { client.update_project(request).await } + }) + .await? + .into_inner(); + Ok(resp.into()) + } + + /// Delete a project. + pub async fn delete_project(&self, org_id: &str, id: &str) -> Result { + let req = DeleteProjectRequest { + org_id: org_id.into(), + id: id.into(), + }; + let resp = Self::call_with_retry("delete_project", || { + let mut client = self.admin_client(); + let req = req.clone(); + let request = self.admin_request(req); + async move { client.delete_project(request).await } + }) + .await? + .into_inner(); + Ok(resp.deleted) + } + // ======================================================================== // Role Management APIs // ======================================================================== @@ -305,7 +561,8 @@ impl IamClient { let resp = Self::call_with_retry("get_role", || { let mut client = self.admin_client(); let req = req.clone(); - async move { client.get_role(req).await } + let request = self.admin_request(req); + async move { client.get_role(request).await } }) .await; match resp { @@ -326,7 +583,8 @@ impl IamClient { let resp = Self::call_with_retry("list_roles", || { let mut client = self.admin_client(); let req = req.clone(); - async move { client.list_roles(req).await } + let request = self.admin_request(req); + async move { client.list_roles(request).await } }) .await? .into_inner(); @@ -351,7 +609,8 @@ impl IamClient { let resp = Self::call_with_retry("create_role", || { let mut client = self.admin_client(); let req = req.clone(); - async move { client.create_role(req).await } + let request = self.admin_request(req); + async move { client.create_role(request).await } }) .await? .into_inner(); @@ -375,7 +634,8 @@ impl IamClient { let resp = Self::call_with_retry("create_binding", || { let mut client = self.admin_client(); let req = req.clone(); - async move { client.create_binding(req).await } + let request = self.admin_request(req); + async move { client.create_binding(request).await } }) .await? .into_inner(); @@ -390,7 +650,8 @@ impl IamClient { let resp = Self::call_with_retry("delete_binding", || { let mut client = self.admin_client(); let req = req.clone(); - async move { client.delete_binding(req).await } + let request = self.admin_request(req); + async move { client.delete_binding(request).await } }) .await? .into_inner(); @@ -414,7 +675,8 @@ impl IamClient { let resp = Self::call_with_retry("list_bindings_for_principal", || { let mut client = self.admin_client(); let req = req.clone(); - async move { client.list_bindings(req).await } + let request = self.admin_request(req); + async move { client.list_bindings(request).await } }) .await? .into_inner(); @@ -435,7 +697,8 @@ impl IamClient { let resp = Self::call_with_retry("list_bindings_for_scope", || { let mut client = self.admin_client(); let req = req.clone(); - async move { client.list_bindings(req).await } + let request = self.admin_request(req); + async move { client.list_bindings(request).await } }) .await? .into_inner(); @@ -469,7 +732,8 @@ impl IamClient { let resp = Self::call_with_retry("issue_token", || { let mut client = self.token_client(); let req = req.clone(); - async move { client.issue_token(req).await } + let request = self.admin_request(req); + async move { client.issue_token(request).await } }) .await? .into_inner(); @@ -553,6 +817,14 @@ impl IamClient { } } +fn load_admin_token_from_env() -> Option { + std::env::var("IAM_ADMIN_TOKEN") + .or_else(|_| std::env::var("PHOTON_IAM_ADMIN_TOKEN")) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + fn retry_delay(attempt: usize) -> Duration { TRANSIENT_RPC_INITIAL_BACKOFF .saturating_mul(1u32 << attempt.min(3)) diff --git a/iam/crates/iam-server/src/main.rs b/iam/crates/iam-server/src/main.rs index 338ce78..761c20f 100644 --- a/iam/crates/iam-server/src/main.rs +++ b/iam/crates/iam-server/src/main.rs @@ -27,8 +27,10 @@ use iam_api::{ use iam_authn::{InternalTokenConfig, InternalTokenService, SigningKey}; use iam_authz::{PolicyCache, PolicyCacheConfig, PolicyEvaluator}; use iam_store::{ - Backend, BackendConfig, BindingStore, CredentialStore, PrincipalStore, RoleStore, TokenStore, + Backend, BackendConfig, BindingStore, CredentialStore, GroupStore, OrgStore, PrincipalStore, + ProjectStore, RoleStore, TokenStore, }; +use iam_types::{Organization, Project, Scope}; use config::{BackendKind, ServerConfig}; @@ -62,6 +64,19 @@ fn load_admin_token() -> Option { .filter(|value| !value.is_empty()) } +fn allow_unauthenticated_admin() -> bool { + std::env::var("IAM_ALLOW_UNAUTHENTICATED_ADMIN") + .or_else(|_| std::env::var("PHOTON_IAM_ALLOW_UNAUTHENTICATED_ADMIN")) + .ok() + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "y" | "on" + ) + }) + .unwrap_or(false) +} + fn admin_token_valid(metadata: &MetadataMap, token: &str) -> bool { if let Some(value) = metadata.get("x-iam-admin-token") { if let Ok(raw) = value.to_str() { @@ -86,6 +101,102 @@ fn admin_token_valid(metadata: &MetadataMap, token: &str) -> bool { false } +async fn backfill_tenant_registry( + principal_store: &Arc, + binding_store: &Arc, + org_store: &Arc, + project_store: &Arc, +) -> Result<(), Box> { + let mut principals = Vec::new(); + for kind in [ + iam_types::PrincipalKind::User, + iam_types::PrincipalKind::ServiceAccount, + iam_types::PrincipalKind::Group, + ] { + principals.extend(principal_store.list_by_kind(&kind).await?); + } + + for principal in principals { + match (principal.org_id.as_deref(), principal.project_id.as_deref()) { + (Some(org_id), Some(project_id)) => { + let mut org = Organization::new(org_id, org_id); + let mut project = Project::new(project_id, org_id, project_id); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + org.created_at = now; + org.updated_at = now; + project.created_at = now; + project.updated_at = now; + org_store.create_if_missing(&org).await?; + project_store.create_if_missing(&project).await?; + } + (Some(org_id), None) => { + let mut org = Organization::new(org_id, org_id); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + org.created_at = now; + org.updated_at = now; + org_store.create_if_missing(&org).await?; + } + (None, Some(project_id)) => { + warn!( + principal_id = %principal.id, + project_id = %project_id, + "service account missing org_id; tenant registry backfill skipped" + ); + } + (None, None) => {} + } + } + + for binding in binding_store.list_all().await? { + match binding.scope { + Scope::System => {} + Scope::Org { id } => { + let mut org = Organization::new(&id, &id); + org.created_at = now_ts(); + org.updated_at = org.created_at; + org_store.create_if_missing(&org).await?; + } + Scope::Project { id, org_id } => { + let mut org = Organization::new(&org_id, &org_id); + let mut project = Project::new(&id, &org_id, &id); + org.created_at = now_ts(); + org.updated_at = org.created_at; + project.created_at = now_ts(); + project.updated_at = project.created_at; + org_store.create_if_missing(&org).await?; + project_store.create_if_missing(&project).await?; + } + Scope::Resource { + project_id, org_id, .. + } => { + let mut org = Organization::new(&org_id, &org_id); + let mut project = Project::new(&project_id, &org_id, &project_id); + org.created_at = now_ts(); + org.updated_at = org.created_at; + project.created_at = now_ts(); + project.updated_at = project.created_at; + org_store.create_if_missing(&org).await?; + project_store.create_if_missing(&project).await?; + } + } + } + + Ok(()) +} + +fn now_ts() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + /// IAM Server #[derive(Parser, Debug)] #[command(name = "iam-server")] @@ -195,10 +306,14 @@ async fn main() -> Result<(), Box> { let binding_store = Arc::new(BindingStore::new(backend.clone())); let credential_store = Arc::new(CredentialStore::new(backend.clone())); let token_store = Arc::new(TokenStore::new(backend.clone())); + let group_store = Arc::new(GroupStore::new(backend.clone())); + let org_store = Arc::new(OrgStore::new(backend.clone())); + let project_store = Arc::new(ProjectStore::new(backend.clone())); // Initialize builtin roles info!("Initializing builtin roles..."); role_store.init_builtin_roles().await?; + backfill_tenant_registry(&principal_store, &binding_store, &org_store, &project_store).await?; // Create policy cache let cache_config = PolicyCacheConfig { @@ -210,9 +325,10 @@ async fn main() -> Result<(), Box> { let cache = Arc::new(PolicyCache::new(cache_config)); // Create evaluator - let evaluator = Arc::new(PolicyEvaluator::new( + let evaluator = Arc::new(PolicyEvaluator::with_group_store( binding_store.clone(), role_store.clone(), + group_store.clone(), cache, )); @@ -244,15 +360,21 @@ async fn main() -> Result<(), Box> { let token_config = InternalTokenConfig::new(signing_key.clone(), &config.authn.internal_token.issuer) - .with_default_ttl(Duration::from_secs( - config.authn.internal_token.default_ttl_seconds, - )) - .with_max_ttl(Duration::from_secs( - config.authn.internal_token.max_ttl_seconds, - )); + .with_default_ttl(Duration::from_secs( + config.authn.internal_token.default_ttl_seconds, + )) + .with_max_ttl(Duration::from_secs( + config.authn.internal_token.max_ttl_seconds, + )); let token_service = Arc::new(InternalTokenService::new(token_config)); let admin_token = load_admin_token(); + if admin_token.is_none() && !allow_unauthenticated_admin() { + return Err( + "IAM admin token not configured. Set IAM_ADMIN_TOKEN or explicitly allow dev mode with IAM_ALLOW_UNAUTHENTICATED_ADMIN=true." + .into(), + ); + } let credential_master_key = std::env::var("IAM_CRED_MASTER_KEY") .ok() .map(|value| value.into_bytes()) @@ -270,6 +392,7 @@ async fn main() -> Result<(), Box> { token_service.clone(), principal_store.clone(), token_store.clone(), + admin_token.clone(), ); let gateway_auth_service = GatewayAuthServiceImpl::new( token_service.clone(), @@ -277,17 +400,25 @@ async fn main() -> Result<(), Box> { token_store.clone(), evaluator.clone(), ); - let credential_service = - IamCredentialService::new(credential_store, &credential_master_key, "iam-cred-master") - .map_err(|e| format!("Failed to initialize credential service: {}", e))?; + let credential_service = IamCredentialService::new( + credential_store, + principal_store.clone(), + &credential_master_key, + "iam-cred-master", + admin_token.clone(), + ) + .map_err(|e| format!("Failed to initialize credential service: {}", e))?; let admin_service = IamAdminService::new( principal_store.clone(), role_store.clone(), binding_store.clone(), + org_store.clone(), + project_store.clone(), + group_store.clone(), ) .with_evaluator(evaluator.clone()); let admin_interceptor = AdminTokenInterceptor { - token: admin_token.map(Arc::new), + token: admin_token.clone().map(Arc::new), }; if admin_interceptor.token.is_some() { info!("IAM admin token authentication enabled"); @@ -388,6 +519,7 @@ async fn main() -> Result<(), Box> { let rest_state = rest::RestApiState { server_addr: config.server.addr.to_string(), tls_enabled: config.server.tls.is_some(), + admin_token: admin_token.clone(), }; let rest_app = rest::build_router(rest_state); let http_listener = tokio::net::TcpListener::bind(&http_addr).await?; @@ -465,20 +597,16 @@ async fn create_backend( pd_endpoint, namespace, }) - .await - .map_err(|e| e.into()) + .await + .map_err(|e| e.into()) } BackendKind::Postgres | BackendKind::Sqlite => { - let database_url = config - .store - .database_url - .as_deref() - .ok_or_else(|| { - format!( - "database_url is required when store.backend={}", - backend_kind_name(config.store.backend) - ) - })?; + let database_url = config.store.database_url.as_deref().ok_or_else(|| { + format!( + "database_url is required when store.backend={}", + backend_kind_name(config.store.backend) + ) + })?; ensure_sql_backend_matches_url(config.store.backend, database_url)?; info!( "Using {} backend: {}", @@ -543,31 +671,29 @@ async fn register_chainfire_membership( let value = format!(r#"{{"addr":"{}","ts":{}}}"#, addr, ts); let deadline = tokio::time::Instant::now() + Duration::from_secs(120); let mut attempt = 0usize; - let mut last_error = String::new(); - - loop { + let last_error = loop { attempt += 1; - match ChainFireClient::connect(endpoint).await { + let error = match ChainFireClient::connect(endpoint).await { Ok(mut client) => match client.put_str(&key, &value).await { Ok(_) => return Ok(()), - Err(error) => last_error = format!("put failed: {}", error), + Err(error) => format!("put failed: {}", error), }, - Err(error) => last_error = format!("connect failed: {}", error), - } + Err(error) => format!("connect failed: {}", error), + }; if tokio::time::Instant::now() >= deadline { - break; + break error; } warn!( attempt, endpoint, service, - error = %last_error, + error = %error, "retrying ChainFire membership registration" ); tokio::time::sleep(Duration::from_secs(2)).await; - } + }; Err(std::io::Error::other(format!( "failed to register ChainFire membership for {} via {} after {} attempts: {}", diff --git a/iam/crates/iam-server/src/rest.rs b/iam/crates/iam-server/src/rest.rs index 298a95e..369d594 100644 --- a/iam/crates/iam-server/src/rest.rs +++ b/iam/crates/iam-server/src/rest.rs @@ -1,22 +1,13 @@ -//! REST HTTP API handlers for IAM -//! -//! Implements REST endpoints as specified in T050.S4: -//! - POST /api/v1/auth/token - Issue token -//! - POST /api/v1/auth/verify - Verify token -//! - GET /api/v1/users - List users -//! - POST /api/v1/users - Create user -//! - GET /api/v1/projects - List projects -//! - POST /api/v1/projects - Create project -//! - GET /health - Health check +//! REST HTTP API handlers for IAM. use axum::{ - extract::State, - http::StatusCode, + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, routing::{get, post}, Json, Router, }; use iam_client::client::{IamClient, IamClientConfig}; -use iam_types::{Principal, PrincipalKind, PrincipalRef, Scope}; +use iam_types::{Organization, Principal, PrincipalKind, PrincipalRef, Project, Scope}; use serde::{Deserialize, Serialize}; /// REST API state @@ -24,6 +15,7 @@ use serde::{Deserialize, Serialize}; pub struct RestApiState { pub server_addr: String, pub tls_enabled: bool, + pub admin_token: Option, } /// Standard REST error response @@ -58,6 +50,9 @@ impl ResponseMeta { fn iam_client_config(state: &RestApiState) -> IamClientConfig { let mut config = IamClientConfig::new(&state.server_addr); + if let Some(token) = state.admin_token.as_deref() { + config = config.with_admin_token(token); + } if !state.tls_enabled { config = config.without_tls(); } @@ -80,7 +75,6 @@ impl SuccessResponse { } } -/// Token issuance request #[derive(Debug, Deserialize)] pub struct TokenRequest { pub username: String, @@ -90,23 +84,20 @@ pub struct TokenRequest { } fn default_ttl() -> u64 { - 3600 // 1 hour + 3600 } -/// Token response #[derive(Debug, Serialize)] pub struct TokenResponse { pub token: String, pub expires_at: String, } -/// Token verification request #[derive(Debug, Deserialize)] pub struct VerifyRequest { pub token: String, } -/// Token verification response #[derive(Debug, Serialize)] pub struct VerifyResponse { pub valid: bool, @@ -115,19 +106,20 @@ pub struct VerifyResponse { pub roles: Option>, } -/// User creation request #[derive(Debug, Deserialize)] pub struct CreateUserRequest { pub id: String, pub name: String, } -/// User response #[derive(Debug, Serialize)] pub struct UserResponse { pub id: String, pub name: String, pub kind: String, + pub org_id: Option, + pub project_id: Option, + pub enabled: bool, } impl From for UserResponse { @@ -136,48 +128,131 @@ impl From for UserResponse { id: p.id, name: p.name, kind: format!("{:?}", p.kind), + org_id: p.org_id, + project_id: p.project_id, + enabled: p.enabled, } } } -/// Users list response #[derive(Debug, Serialize)] pub struct UsersResponse { pub users: Vec, } -/// Project creation request (placeholder) +#[derive(Debug, Deserialize)] +pub struct CreateOrganizationRequest { + pub id: String, + pub name: String, + #[serde(default)] + pub description: String, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateOrganizationRequest { + pub name: Option, + pub description: Option, + pub enabled: Option, +} + +#[derive(Debug, Serialize)] +pub struct OrganizationResponse { + pub id: String, + pub name: String, + pub description: String, + pub enabled: bool, +} + +impl From for OrganizationResponse { + fn from(org: Organization) -> Self { + Self { + id: org.id, + name: org.name, + description: org.description, + enabled: org.enabled, + } + } +} + +#[derive(Debug, Serialize)] +pub struct OrganizationsResponse { + pub organizations: Vec, +} + #[derive(Debug, Deserialize)] pub struct CreateProjectRequest { pub id: String, + pub org_id: String, pub name: String, + #[serde(default)] + pub description: String, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateProjectRequest { + pub name: Option, + pub description: Option, + pub enabled: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ProjectsQuery { + pub org_id: Option, } -/// Project response (placeholder) #[derive(Debug, Serialize)] pub struct ProjectResponse { pub id: String, + pub org_id: String, pub name: String, + pub description: String, + pub enabled: bool, +} + +impl From for ProjectResponse { + fn from(project: Project) -> Self { + Self { + id: project.id, + org_id: project.org_id, + name: project.name, + description: project.description, + enabled: project.enabled, + } + } } -/// Projects list response (placeholder) #[derive(Debug, Serialize)] pub struct ProjectsResponse { pub projects: Vec, } -/// Build the REST API router pub fn build_router(state: RestApiState) -> Router { Router::new() .route("/api/v1/auth/token", post(issue_token)) .route("/api/v1/auth/verify", post(verify_token)) .route("/api/v1/users", get(list_users).post(create_user)) + .route("/api/v1/users/:id", get(get_user)) + .route( + "/api/v1/orgs", + get(list_organizations).post(create_organization), + ) + .route( + "/api/v1/orgs/:org_id", + get(get_organization) + .patch(update_organization) + .delete(delete_organization), + ) .route("/api/v1/projects", get(list_projects).post(create_project)) + .route( + "/api/v1/orgs/:org_id/projects/:project_id", + get(get_project) + .patch(update_project) + .delete(delete_project), + ) .route("/health", get(health_check)) .with_state(state) } -/// Health check endpoint async fn health_check() -> (StatusCode, Json>) { ( StatusCode::OK, @@ -187,11 +262,63 @@ async fn health_check() -> (StatusCode, Json> ) } -/// POST /api/v1/auth/token - Issue token +fn require_admin( + state: &RestApiState, + headers: &HeaderMap, +) -> Result<(), (StatusCode, Json)> { + let Some(token) = state.admin_token.as_deref() else { + return Ok(()); + }; + + if let Some(value) = headers.get("x-iam-admin-token") { + if let Ok(raw) = value.to_str() { + if raw.trim() == token { + return Ok(()); + } + } + } + + if let Some(value) = headers.get("authorization") { + if let Ok(raw) = value.to_str() { + let raw = raw.trim(); + if let Some(rest) = raw + .strip_prefix("Bearer ") + .or_else(|| raw.strip_prefix("bearer ")) + { + if rest.trim() == token { + return Ok(()); + } + } + } + } + + Err(error_response( + StatusCode::UNAUTHORIZED, + "ADMIN_TOKEN_REQUIRED", + "missing or invalid IAM admin token", + )) +} + +async fn connect_client( + state: &RestApiState, +) -> Result)> { + IamClient::connect(iam_client_config(state)) + .await + .map_err(|e| { + error_response( + StatusCode::SERVICE_UNAVAILABLE, + "SERVICE_UNAVAILABLE", + &format!("Failed to connect: {}", e), + ) + }) +} + async fn issue_token( + headers: HeaderMap, State(state): State, Json(req): Json, ) -> Result>, (StatusCode, Json)> { + require_admin(&state, &headers)?; if !allow_insecure_rest_token_issue() { return Err(error_response( StatusCode::FORBIDDEN, @@ -206,16 +333,7 @@ async fn issue_token( ttl_seconds, } = req; - // Connect to IAM server - let config = iam_client_config(&state); - let client = IamClient::connect(config).await.map_err(|e| { - error_response( - StatusCode::SERVICE_UNAVAILABLE, - "SERVICE_UNAVAILABLE", - &format!("Failed to connect: {}", e), - ) - })?; - + let client = connect_client(&state).await?; let principal_ref = PrincipalRef::new(PrincipalKind::User, &username); let principal = match client.get_principal(&principal_ref).await.map_err(|e| { error_response( @@ -242,7 +360,6 @@ async fn issue_token( )); } - // Issue token let token = client .issue_token(&principal, vec![], Scope::System, ttl_seconds) .await @@ -275,22 +392,11 @@ fn allow_insecure_rest_token_issue() -> bool { .unwrap_or(false) } -/// POST /api/v1/auth/verify - Verify token async fn verify_token( State(state): State, Json(req): Json, ) -> Result>, (StatusCode, Json)> { - // Connect to IAM server - let config = iam_client_config(&state); - let client = IamClient::connect(config).await.map_err(|e| { - error_response( - StatusCode::SERVICE_UNAVAILABLE, - "SERVICE_UNAVAILABLE", - &format!("Failed to connect: {}", e), - ) - })?; - - // Validate token + let client = connect_client(&state).await?; let result = client.validate_token(&req.token).await; match result { @@ -309,22 +415,13 @@ async fn verify_token( } } -/// POST /api/v1/users - Create user async fn create_user( + headers: HeaderMap, State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json>), (StatusCode, Json)> { - // Connect to IAM server - let config = iam_client_config(&state); - let client = IamClient::connect(config).await.map_err(|e| { - error_response( - StatusCode::SERVICE_UNAVAILABLE, - "SERVICE_UNAVAILABLE", - &format!("Failed to connect: {}", e), - ) - })?; - - // Create user + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; let principal = client.create_user(&req.id, &req.name).await.map_err(|e| { error_response( StatusCode::INTERNAL_SERVER_ERROR, @@ -339,21 +436,12 @@ async fn create_user( )) } -/// GET /api/v1/users - List users async fn list_users( + headers: HeaderMap, State(state): State, ) -> Result>, (StatusCode, Json)> { - // Connect to IAM server - let config = iam_client_config(&state); - let client = IamClient::connect(config).await.map_err(|e| { - error_response( - StatusCode::SERVICE_UNAVAILABLE, - "SERVICE_UNAVAILABLE", - &format!("Failed to connect: {}", e), - ) - })?; - - // List users + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; let principals = client.list_users().await.map_err(|e| { error_response( StatusCode::INTERNAL_SERVER_ERROR, @@ -363,45 +451,265 @@ async fn list_users( })?; let users: Vec = principals.into_iter().map(UserResponse::from).collect(); - Ok(Json(SuccessResponse::new(UsersResponse { users }))) } -/// GET /api/v1/projects - List projects (placeholder) -async fn list_projects( - State(_state): State, -) -> Result>, (StatusCode, Json)> { - // Project management not yet implemented in IAM - // Return placeholder response - Ok(Json(SuccessResponse::new(ProjectsResponse { - projects: vec![ProjectResponse { - id: "(placeholder)".to_string(), - name: "Project management via REST not yet implemented - use gRPC IamAdminService for scope/binding management".to_string(), - }], +async fn get_user( + headers: HeaderMap, + Path(id): Path, + State(state): State, +) -> Result>, (StatusCode, Json)> { + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; + let principal = client + .get_principal(&PrincipalRef::user(id)) + .await + .map_err(|e| { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "USER_LOOKUP_FAILED", + &e.to_string(), + ) + })? + .ok_or_else(|| error_response(StatusCode::NOT_FOUND, "USER_NOT_FOUND", "user not found"))?; + Ok(Json(SuccessResponse::new(UserResponse::from(principal)))) +} + +async fn create_organization( + headers: HeaderMap, + State(state): State, + Json(req): Json, +) -> Result< + (StatusCode, Json>), + (StatusCode, Json), +> { + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; + let org = client + .create_organization(&req.id, &req.name, &req.description) + .await + .map_err(|e| { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "ORG_CREATE_FAILED", + &e.to_string(), + ) + })?; + Ok((StatusCode::CREATED, Json(SuccessResponse::new(org.into())))) +} + +async fn list_organizations( + headers: HeaderMap, + State(state): State, +) -> Result>, (StatusCode, Json)> { + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; + let organizations = client.list_organizations().await.map_err(|e| { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "ORG_LIST_FAILED", + &e.to_string(), + ) + })?; + Ok(Json(SuccessResponse::new(OrganizationsResponse { + organizations: organizations.into_iter().map(Into::into).collect(), }))) } -/// POST /api/v1/projects - Create project (placeholder) +async fn get_organization( + headers: HeaderMap, + Path(org_id): Path, + State(state): State, +) -> Result>, (StatusCode, Json)> { + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; + let organization = client + .get_organization(&org_id) + .await + .map_err(|e| { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "ORG_LOOKUP_FAILED", + &e.to_string(), + ) + })? + .ok_or_else(|| { + error_response( + StatusCode::NOT_FOUND, + "ORG_NOT_FOUND", + "organization not found", + ) + })?; + Ok(Json(SuccessResponse::new(organization.into()))) +} + +async fn update_organization( + headers: HeaderMap, + Path(org_id): Path, + State(state): State, + Json(req): Json, +) -> Result>, (StatusCode, Json)> { + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; + let organization = client + .update_organization( + &org_id, + req.name.as_deref(), + req.description.as_deref(), + req.enabled, + ) + .await + .map_err(|e| { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "ORG_UPDATE_FAILED", + &e.to_string(), + ) + })?; + Ok(Json(SuccessResponse::new(organization.into()))) +} + +async fn delete_organization( + headers: HeaderMap, + Path(org_id): Path, + State(state): State, +) -> Result>, (StatusCode, Json)> { + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; + let deleted = client.delete_organization(&org_id).await.map_err(|e| { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "ORG_DELETE_FAILED", + &e.to_string(), + ) + })?; + Ok(Json(SuccessResponse::new( + serde_json::json!({ "deleted": deleted }), + ))) +} + async fn create_project( - State(_state): State, + headers: HeaderMap, + State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json>), (StatusCode, Json)> { - // Project management not yet implemented in IAM - // Return placeholder response + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; + let project = client + .create_project(&req.org_id, &req.id, &req.name, &req.description) + .await + .map_err(|e| { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "PROJECT_CREATE_FAILED", + &e.to_string(), + ) + })?; Ok(( - StatusCode::NOT_IMPLEMENTED, - Json(SuccessResponse::new(ProjectResponse { - id: req.id, - name: format!( - "Project '{}' - management via REST not yet implemented", - req.name - ), - })), + StatusCode::CREATED, + Json(SuccessResponse::new(project.into())), )) } -/// Helper to create error response +async fn list_projects( + headers: HeaderMap, + Query(query): Query, + State(state): State, +) -> Result>, (StatusCode, Json)> { + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; + let projects = client + .list_projects(query.org_id.as_deref()) + .await + .map_err(|e| { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "PROJECT_LIST_FAILED", + &e.to_string(), + ) + })?; + Ok(Json(SuccessResponse::new(ProjectsResponse { + projects: projects.into_iter().map(Into::into).collect(), + }))) +} + +async fn get_project( + headers: HeaderMap, + Path((org_id, project_id)): Path<(String, String)>, + State(state): State, +) -> Result>, (StatusCode, Json)> { + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; + let project = client + .get_project(&org_id, &project_id) + .await + .map_err(|e| { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "PROJECT_LOOKUP_FAILED", + &e.to_string(), + ) + })? + .ok_or_else(|| { + error_response( + StatusCode::NOT_FOUND, + "PROJECT_NOT_FOUND", + "project not found", + ) + })?; + Ok(Json(SuccessResponse::new(project.into()))) +} + +async fn update_project( + headers: HeaderMap, + Path((org_id, project_id)): Path<(String, String)>, + State(state): State, + Json(req): Json, +) -> Result>, (StatusCode, Json)> { + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; + let project = client + .update_project( + &org_id, + &project_id, + req.name.as_deref(), + req.description.as_deref(), + req.enabled, + ) + .await + .map_err(|e| { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "PROJECT_UPDATE_FAILED", + &e.to_string(), + ) + })?; + Ok(Json(SuccessResponse::new(project.into()))) +} + +async fn delete_project( + headers: HeaderMap, + Path((org_id, project_id)): Path<(String, String)>, + State(state): State, +) -> Result>, (StatusCode, Json)> { + require_admin(&state, &headers)?; + let client = connect_client(&state).await?; + let deleted = client + .delete_project(&org_id, &project_id) + .await + .map_err(|e| { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "PROJECT_DELETE_FAILED", + &e.to_string(), + ) + })?; + Ok(Json(SuccessResponse::new( + serde_json::json!({ "deleted": deleted }), + ))) +} + fn error_response( status: StatusCode, code: &str, diff --git a/iam/crates/iam-service-auth/src/lib.rs b/iam/crates/iam-service-auth/src/lib.rs index 0ed8cbc..ed6adfd 100644 --- a/iam/crates/iam-service-auth/src/lib.rs +++ b/iam/crates/iam-service-auth/src/lib.rs @@ -94,10 +94,7 @@ impl AuthService { } /// Authenticate an HTTP request using headers. - pub async fn authenticate_headers( - &self, - headers: &HeaderMap, - ) -> Result { + pub async fn authenticate_headers(&self, headers: &HeaderMap) -> Result { let token = extract_token_from_headers(headers)?; self.authenticate_token(&token).await } @@ -110,11 +107,18 @@ impl AuthService { resource: &Resource, ) -> Result<(), Status> { let mut principal = match tenant.principal_kind { - PrincipalKind::User => Principal::new_user(&tenant.principal_id, &tenant.principal_name), - PrincipalKind::ServiceAccount => { - Principal::new_service_account(&tenant.principal_id, &tenant.principal_name, &tenant.project_id) + PrincipalKind::User => { + Principal::new_user(&tenant.principal_id, &tenant.principal_name) + } + PrincipalKind::ServiceAccount => Principal::new_service_account( + &tenant.principal_id, + &tenant.principal_name, + &tenant.org_id, + &tenant.project_id, + ), + PrincipalKind::Group => { + Principal::new_group(&tenant.principal_id, &tenant.principal_name) } - PrincipalKind::Group => Principal::new_group(&tenant.principal_id, &tenant.principal_name), }; principal.org_id = Some(tenant.org_id.clone()); @@ -135,14 +139,10 @@ impl AuthService { return Ok(cached); } - let claims = self - .iam_client - .validate_token(token) - .await - .map_err(|e| { - warn!("Token validation failed: {}", e); - Status::unauthenticated(format!("Invalid token: {}", e)) - })?; + let claims = self.iam_client.validate_token(token).await.map_err(|e| { + warn!("Token validation failed: {}", e); + Status::unauthenticated(format!("Invalid token: {}", e)) + })?; let org_id = claims .org_id diff --git a/iam/crates/iam-store/src/lib.rs b/iam/crates/iam-store/src/lib.rs index 47a13ea..02aeaa0 100644 --- a/iam/crates/iam-store/src/lib.rs +++ b/iam/crates/iam-store/src/lib.rs @@ -9,7 +9,9 @@ pub mod backend; pub mod binding_store; pub mod credential_store; pub mod group_store; +pub mod org_store; pub mod principal_store; +pub mod project_store; pub mod role_store; pub mod token_store; @@ -17,6 +19,8 @@ pub use backend::{Backend, BackendConfig, CasResult, KvPair, StorageBackend}; pub use binding_store::BindingStore; pub use credential_store::CredentialStore; pub use group_store::GroupStore; +pub use org_store::OrgStore; pub use principal_store::PrincipalStore; +pub use project_store::ProjectStore; pub use role_store::RoleStore; pub use token_store::TokenStore; diff --git a/iam/crates/iam-store/src/org_store.rs b/iam/crates/iam-store/src/org_store.rs new file mode 100644 index 0000000..9729696 --- /dev/null +++ b/iam/crates/iam-store/src/org_store.rs @@ -0,0 +1,109 @@ +//! Organization storage. + +use std::sync::Arc; + +use iam_types::{Error, IamError, Organization, Result}; + +use crate::backend::{Backend, CasResult, JsonStore, StorageBackend}; + +mod keys { + pub const ORGS: &str = "iam/orgs/"; +} + +pub struct OrgStore { + backend: Arc, +} + +impl JsonStore for OrgStore { + fn backend(&self) -> &Backend { + &self.backend + } +} + +impl OrgStore { + pub fn new(backend: Arc) -> Self { + Self { backend } + } + + pub async fn create(&self, org: &Organization) -> Result { + let key = self.primary_key(&org.id); + let bytes = serde_json::to_vec(org).map_err(|e| Error::Serialization(e.to_string()))?; + match self.backend.cas(key.as_bytes(), 0, &bytes).await? { + CasResult::Success(version) => Ok(version), + CasResult::Conflict { .. } => { + Err(Error::Iam(IamError::OrganizationAlreadyExists(org.id.clone()))) + } + CasResult::NotFound => Err(Error::Internal("Unexpected CAS result".into())), + } + } + + pub async fn create_if_missing(&self, org: &Organization) -> Result { + let key = self.primary_key(&org.id); + let bytes = serde_json::to_vec(org).map_err(|e| Error::Serialization(e.to_string()))?; + match self.backend.cas(key.as_bytes(), 0, &bytes).await? { + CasResult::Success(_) => Ok(true), + CasResult::Conflict { .. } => Ok(false), + CasResult::NotFound => Err(Error::Internal("Unexpected CAS result".into())), + } + } + + pub async fn get(&self, id: &str) -> Result> { + Ok(self.get_json::(self.primary_key(id).as_bytes()).await?.map(|v| v.0)) + } + + pub async fn get_with_version(&self, id: &str) -> Result> { + self.get_json::(self.primary_key(id).as_bytes()).await + } + + pub async fn update(&self, org: &Organization, expected_version: u64) -> Result { + let key = self.primary_key(&org.id); + let bytes = serde_json::to_vec(org).map_err(|e| Error::Serialization(e.to_string()))?; + match self.backend.cas(key.as_bytes(), expected_version, &bytes).await? { + CasResult::Success(version) => Ok(version), + CasResult::Conflict { expected, actual } => { + Err(Error::Storage(iam_types::StorageError::CasConflict { expected, actual })) + } + CasResult::NotFound => Err(Error::Iam(IamError::OrganizationNotFound(org.id.clone()))), + } + } + + pub async fn delete(&self, id: &str) -> Result { + self.backend.delete(self.primary_key(id).as_bytes()).await + } + + pub async fn list(&self) -> Result> { + let pairs = self.backend.scan_prefix(keys::ORGS.as_bytes(), 10_000).await?; + let mut orgs = Vec::new(); + for pair in pairs { + let org: Organization = serde_json::from_slice(&pair.value) + .map_err(|e| Error::Serialization(e.to_string()))?; + orgs.push(org); + } + Ok(orgs) + } + + pub async fn exists(&self, id: &str) -> Result { + Ok(self.backend.get(self.primary_key(id).as_bytes()).await?.is_some()) + } + + fn primary_key(&self, id: &str) -> String { + format!("{}{id}", keys::ORGS) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn crud_roundtrip() { + let backend = Arc::new(Backend::memory()); + let store = OrgStore::new(backend); + let org = Organization::new("org-1", "Org 1"); + store.create(&org).await.unwrap(); + let fetched = store.get("org-1").await.unwrap().unwrap(); + assert_eq!(fetched.name, "Org 1"); + assert!(store.delete("org-1").await.unwrap()); + assert!(store.get("org-1").await.unwrap().is_none()); + } +} diff --git a/iam/crates/iam-store/src/principal_store.rs b/iam/crates/iam-store/src/principal_store.rs index 3f1f8f6..4d170d3 100644 --- a/iam/crates/iam-store/src/principal_store.rs +++ b/iam/crates/iam-store/src/principal_store.rs @@ -19,9 +19,13 @@ mod keys { pub const BY_ORG: &str = "iam/principals/by-org/"; /// Secondary index: by project (for service accounts) - /// Format: iam/principals/by-project/{project_id}/{id} + /// Format: iam/principals/by-project/{project_id}/{org_id}/{id} pub const BY_PROJECT: &str = "iam/principals/by-project/"; + /// Secondary index: by tenant (for service accounts) + /// Format: iam/principals/by-tenant/{org_id}/{project_id}/{id} + pub const BY_TENANT: &str = "iam/principals/by-tenant/"; + /// Secondary index: by email /// Format: iam/principals/by-email/{email} pub const BY_EMAIL: &str = "iam/principals/by-email/"; @@ -213,6 +217,23 @@ impl PrincipalStore { Ok(principals) } + /// List service accounts by organization + project. + pub async fn list_by_tenant(&self, org_id: &str, project_id: &str) -> Result> { + let prefix = format!("{}{}/{}/", keys::BY_TENANT, org_id, project_id); + let pairs = self.backend.scan_prefix(prefix.as_bytes(), 10000).await?; + + let mut principals = Vec::new(); + for pair in pairs { + let principal_ref: PrincipalRef = serde_json::from_slice(&pair.value) + .map_err(|e| Error::Serialization(e.to_string()))?; + if let Some(principal) = self.get(&principal_ref).await? { + principals.push(principal); + } + } + + Ok(principals) + } + /// Check if a principal exists pub async fn exists(&self, principal_ref: &PrincipalRef) -> Result { let key = self.make_primary_key(&principal_ref.kind, &principal_ref.id); @@ -251,9 +272,24 @@ impl PrincipalStore { } // Project index (for service accounts) - if let Some(project_id) = &principal.project_id { - let key = format!("{}{}/{}", keys::BY_PROJECT, project_id, principal.id); + if let (Some(org_id), Some(project_id)) = (&principal.org_id, &principal.project_id) { + let key = format!( + "{}{}/{}/{}", + keys::BY_PROJECT, + project_id, + org_id, + principal.id + ); self.backend.put(key.as_bytes(), &ref_bytes).await?; + + let tenant_key = format!( + "{}{}/{}/{}", + keys::BY_TENANT, + org_id, + project_id, + principal.id + ); + self.backend.put(tenant_key.as_bytes(), &ref_bytes).await?; } // Email index @@ -289,9 +325,24 @@ impl PrincipalStore { } // Project index - if let Some(project_id) = &principal.project_id { - let key = format!("{}{}/{}", keys::BY_PROJECT, project_id, principal.id); + if let (Some(org_id), Some(project_id)) = (&principal.org_id, &principal.project_id) { + let key = format!( + "{}{}/{}/{}", + keys::BY_PROJECT, + project_id, + org_id, + principal.id + ); self.backend.delete(key.as_bytes()).await?; + + let tenant_key = format!( + "{}{}/{}/{}", + keys::BY_TENANT, + org_id, + project_id, + principal.id + ); + self.backend.delete(tenant_key.as_bytes()).await?; } // Email index @@ -356,7 +407,8 @@ mod tests { async fn test_service_account() { let store = PrincipalStore::new(test_backend()); - let sa = Principal::new_service_account("compute-agent", "Compute Agent", "proj-1"); + let sa = + Principal::new_service_account("compute-agent", "Compute Agent", "org-1", "proj-1"); store.create(&sa).await.unwrap(); @@ -364,6 +416,10 @@ mod tests { let sas = store.list_by_project("proj-1").await.unwrap(); assert_eq!(sas.len(), 1); assert_eq!(sas[0].id, "compute-agent"); + + let tenant = store.list_by_tenant("org-1", "proj-1").await.unwrap(); + assert_eq!(tenant.len(), 1); + assert_eq!(tenant[0].id, "compute-agent"); } #[tokio::test] @@ -382,14 +438,15 @@ mod tests { store.update(&principal, version).await.unwrap(); // Old indexes should be cleared - assert!(store.get_by_email("alice@example.com").await.unwrap().is_none()); + assert!(store + .get_by_email("alice@example.com") + .await + .unwrap() + .is_none()); assert!(store.list_by_org("org-1").await.unwrap().is_empty()); // New indexes should exist - let fetched = store - .get_by_email("alice+new@example.com") - .await - .unwrap(); + let fetched = store.get_by_email("alice+new@example.com").await.unwrap(); assert!(fetched.is_some()); assert_eq!(fetched.unwrap().id, "alice"); let org2 = store.list_by_org("org-2").await.unwrap(); @@ -409,7 +466,9 @@ mod tests { .await .unwrap(); store - .create(&Principal::new_service_account("sa1", "SA 1", "proj-1")) + .create(&Principal::new_service_account( + "sa1", "SA 1", "org-1", "proj-1", + )) .await .unwrap(); diff --git a/iam/crates/iam-store/src/project_store.rs b/iam/crates/iam-store/src/project_store.rs new file mode 100644 index 0000000..c7ffe98 --- /dev/null +++ b/iam/crates/iam-store/src/project_store.rs @@ -0,0 +1,174 @@ +//! Project storage. + +use std::sync::Arc; + +use iam_types::{Error, IamError, Project, Result}; + +use crate::backend::{Backend, CasResult, JsonStore, StorageBackend}; + +mod keys { + pub const PROJECTS: &str = "iam/projects/"; + pub const BY_ORG: &str = "iam/projects/by-org/"; +} + +pub struct ProjectStore { + backend: Arc, +} + +impl JsonStore for ProjectStore { + fn backend(&self) -> &Backend { + &self.backend + } +} + +impl ProjectStore { + pub fn new(backend: Arc) -> Self { + Self { backend } + } + + pub async fn create(&self, project: &Project) -> Result { + let key = self.primary_key(&project.org_id, &project.id); + let bytes = + serde_json::to_vec(project).map_err(|e| Error::Serialization(e.to_string()))?; + match self.backend.cas(key.as_bytes(), 0, &bytes).await? { + CasResult::Success(version) => { + self.create_indexes(project).await?; + Ok(version) + } + CasResult::Conflict { .. } => Err(Error::Iam(IamError::ProjectAlreadyExists( + project.key(), + ))), + CasResult::NotFound => Err(Error::Internal("Unexpected CAS result".into())), + } + } + + pub async fn create_if_missing(&self, project: &Project) -> Result { + let key = self.primary_key(&project.org_id, &project.id); + let bytes = + serde_json::to_vec(project).map_err(|e| Error::Serialization(e.to_string()))?; + match self.backend.cas(key.as_bytes(), 0, &bytes).await? { + CasResult::Success(_) => { + self.create_indexes(project).await?; + Ok(true) + } + CasResult::Conflict { .. } => Ok(false), + CasResult::NotFound => Err(Error::Internal("Unexpected CAS result".into())), + } + } + + pub async fn get(&self, org_id: &str, id: &str) -> Result> { + Ok(self + .get_json::(self.primary_key(org_id, id).as_bytes()) + .await? + .map(|v| v.0)) + } + + pub async fn get_with_version(&self, org_id: &str, id: &str) -> Result> { + self.get_json::(self.primary_key(org_id, id).as_bytes()) + .await + } + + pub async fn update(&self, project: &Project, expected_version: u64) -> Result { + let key = self.primary_key(&project.org_id, &project.id); + let bytes = + serde_json::to_vec(project).map_err(|e| Error::Serialization(e.to_string()))?; + match self.backend.cas(key.as_bytes(), expected_version, &bytes).await? { + CasResult::Success(version) => { + self.create_indexes(project).await?; + Ok(version) + } + CasResult::Conflict { expected, actual } => { + Err(Error::Storage(iam_types::StorageError::CasConflict { expected, actual })) + } + CasResult::NotFound => Err(Error::Iam(IamError::ProjectNotFound(project.key()))), + } + } + + pub async fn delete(&self, org_id: &str, id: &str) -> Result { + if let Some(project) = self.get(org_id, id).await? { + let deleted = self.backend.delete(self.primary_key(org_id, id).as_bytes()).await?; + if deleted { + self.delete_indexes(&project).await?; + } + Ok(deleted) + } else { + Ok(false) + } + } + + pub async fn list(&self) -> Result> { + let pairs = self.backend.scan_prefix(keys::PROJECTS.as_bytes(), 10_000).await?; + let mut projects = Vec::new(); + for pair in pairs { + if String::from_utf8_lossy(&pair.key).starts_with(keys::BY_ORG) { + continue; + } + let project: Project = serde_json::from_slice(&pair.value) + .map_err(|e| Error::Serialization(e.to_string()))?; + projects.push(project); + } + Ok(projects) + } + + pub async fn list_by_org(&self, org_id: &str) -> Result> { + let prefix = format!("{}{}/", keys::BY_ORG, org_id); + let pairs = self.backend.scan_prefix(prefix.as_bytes(), 10_000).await?; + let mut projects = Vec::new(); + for pair in pairs { + let project_id = String::from_utf8_lossy(&pair.value).to_string(); + if let Some(project) = self.get(org_id, &project_id).await? { + projects.push(project); + } + } + Ok(projects) + } + + pub async fn exists(&self, org_id: &str, id: &str) -> Result { + Ok(self + .backend + .get(self.primary_key(org_id, id).as_bytes()) + .await? + .is_some()) + } + + fn primary_key(&self, org_id: &str, id: &str) -> String { + format!("{}{}/{}", keys::PROJECTS, org_id, id) + } + + async fn create_indexes(&self, project: &Project) -> Result<()> { + let key = format!("{}{}/{}", keys::BY_ORG, project.org_id, project.id); + self.backend.put(key.as_bytes(), project.id.as_bytes()).await?; + Ok(()) + } + + async fn delete_indexes(&self, project: &Project) -> Result<()> { + let key = format!("{}{}/{}", keys::BY_ORG, project.org_id, project.id); + self.backend.delete(key.as_bytes()).await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn list_by_org_is_isolated() { + let backend = Arc::new(Backend::memory()); + let store = ProjectStore::new(backend); + store + .create(&Project::new("proj-1", "org-1", "Project 1")) + .await + .unwrap(); + store + .create(&Project::new("proj-1", "org-2", "Project 1")) + .await + .unwrap(); + let org1 = store.list_by_org("org-1").await.unwrap(); + let org2 = store.list_by_org("org-2").await.unwrap(); + assert_eq!(org1.len(), 1); + assert_eq!(org2.len(), 1); + assert_eq!(org1[0].org_id, "org-1"); + assert_eq!(org2[0].org_id, "org-2"); + } +} diff --git a/iam/crates/iam-types/src/error.rs b/iam/crates/iam-types/src/error.rs index 431f5f9..682ec3c 100644 --- a/iam/crates/iam-types/src/error.rs +++ b/iam/crates/iam-types/src/error.rs @@ -48,6 +48,14 @@ pub enum IamError { #[error("Role not found: {0}")] RoleNotFound(String), + /// Organization not found + #[error("Organization not found: {0}")] + OrganizationNotFound(String), + + /// Project not found + #[error("Project not found: {0}")] + ProjectNotFound(String), + /// Binding not found #[error("Binding not found: {0}")] BindingNotFound(String), @@ -84,6 +92,14 @@ pub enum IamError { #[error("Role already exists: {0}")] RoleAlreadyExists(String), + /// Organization already exists + #[error("Organization already exists: {0}")] + OrganizationAlreadyExists(String), + + /// Project already exists + #[error("Project already exists: {0}")] + ProjectAlreadyExists(String), + /// Cannot modify builtin role #[error("Cannot modify builtin role: {0}")] CannotModifyBuiltinRole(String), diff --git a/iam/crates/iam-types/src/lib.rs b/iam/crates/iam-types/src/lib.rs index ba1dda4..c7376ed 100644 --- a/iam/crates/iam-types/src/lib.rs +++ b/iam/crates/iam-types/src/lib.rs @@ -17,6 +17,7 @@ pub mod principal; pub mod resource; pub mod role; pub mod scope; +pub mod tenant; pub mod token; pub use condition::{Condition, ConditionExpr}; @@ -27,6 +28,7 @@ pub use principal::{Principal, PrincipalKind, PrincipalRef}; pub use resource::{Resource, ResourceRef}; pub use role::{builtin as builtin_roles, Permission, Role}; pub use scope::Scope; +pub use tenant::{Organization, Project}; pub use token::{ AuthMethod, InternalTokenClaims, JwtClaims, TokenMetadata, TokenType, TokenValidationError, }; diff --git a/iam/crates/iam-types/src/principal.rs b/iam/crates/iam-types/src/principal.rs index ba47c58..4c86bca 100644 --- a/iam/crates/iam-types/src/principal.rs +++ b/iam/crates/iam-types/src/principal.rs @@ -104,13 +104,14 @@ impl Principal { pub fn new_service_account( id: impl Into, name: impl Into, + org_id: impl Into, project_id: impl Into, ) -> Self { Self { id: id.into(), kind: PrincipalKind::ServiceAccount, name: name.into(), - org_id: None, + org_id: Some(org_id.into()), project_id: Some(project_id.into()), email: None, oidc_sub: None, diff --git a/iam/crates/iam-types/src/scope.rs b/iam/crates/iam-types/src/scope.rs index 4acf7b0..10f65d9 100644 --- a/iam/crates/iam-types/src/scope.rs +++ b/iam/crates/iam-types/src/scope.rs @@ -155,6 +155,63 @@ impl Scope { } } + /// Check whether this scope can be applied to another scope. + /// + /// Unlike `contains`, this matcher understands `*` wildcard segments. + /// It is intended for validating role applicability at bind time. + pub fn applies_to(&self, other: &Scope) -> bool { + match (self, other) { + (Scope::System, _) => true, + (Scope::Org { id }, Scope::Org { id: other_id }) => segment_matches(id, other_id), + ( + Scope::Org { id }, + Scope::Project { + org_id: other_org_id, + .. + }, + ) => segment_matches(id, other_org_id), + ( + Scope::Org { id }, + Scope::Resource { + org_id: other_org_id, + .. + }, + ) => segment_matches(id, other_org_id), + ( + Scope::Project { id, org_id }, + Scope::Project { + id: other_id, + org_id: other_org_id, + }, + ) => segment_matches(org_id, other_org_id) && segment_matches(id, other_id), + ( + Scope::Project { id, org_id }, + Scope::Resource { + project_id: other_project_id, + org_id: other_org_id, + .. + }, + ) => segment_matches(org_id, other_org_id) && segment_matches(id, other_project_id), + ( + Scope::Resource { + id, + project_id, + org_id, + }, + Scope::Resource { + id: other_id, + project_id: other_project_id, + org_id: other_org_id, + }, + ) => { + segment_matches(org_id, other_org_id) + && segment_matches(project_id, other_project_id) + && segment_matches(id, other_id) + } + _ => false, + } + } + /// Get the parent scope, if any /// /// - System has no parent @@ -276,6 +333,10 @@ impl Scope { } } +fn segment_matches(pattern: &str, value: &str) -> bool { + pattern == "*" || pattern == value +} + impl fmt::Display for Scope { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -490,4 +551,16 @@ mod tests { Some("proj1") ); } + + #[test] + fn test_scope_applies_to_with_wildcards() { + assert!(Scope::System.applies_to(&Scope::project("proj-1", "org-1"))); + assert!(Scope::org("*").applies_to(&Scope::project("proj-1", "org-1"))); + assert!(Scope::org("org-1").applies_to(&Scope::resource("res-1", "proj-1", "org-1"))); + assert!(Scope::project("*", "*").applies_to(&Scope::project("proj-1", "org-1"))); + assert!(Scope::project("proj-1", "org-1") + .applies_to(&Scope::resource("res-1", "proj-1", "org-1"))); + assert!(!Scope::project("proj-2", "org-1").applies_to(&Scope::project("proj-1", "org-1"))); + assert!(!Scope::org("org-2").applies_to(&Scope::project("proj-1", "org-1"))); + } } diff --git a/iam/crates/iam-types/src/tenant.rs b/iam/crates/iam-types/src/tenant.rs new file mode 100644 index 0000000..5718a77 --- /dev/null +++ b/iam/crates/iam-types/src/tenant.rs @@ -0,0 +1,93 @@ +//! Tenant registry types for IAM. +//! +//! Organizations and projects are the authoritative tenant entities for IAM. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// An organization tracked by IAM. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Organization { + pub id: String, + pub name: String, + pub description: String, + pub metadata: HashMap, + pub created_at: u64, + pub updated_at: u64, + pub enabled: bool, +} + +impl Organization { + pub fn new(id: impl Into, name: impl Into) -> Self { + Self { + id: id.into(), + name: name.into(), + description: String::new(), + metadata: HashMap::new(), + created_at: 0, + updated_at: 0, + enabled: true, + } + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } +} + +/// A project belonging to an organization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Project { + pub id: String, + pub org_id: String, + pub name: String, + pub description: String, + pub metadata: HashMap, + pub created_at: u64, + pub updated_at: u64, + pub enabled: bool, +} + +impl Project { + pub fn new( + id: impl Into, + org_id: impl Into, + name: impl Into, + ) -> Self { + Self { + id: id.into(), + org_id: org_id.into(), + name: name.into(), + description: String::new(), + metadata: HashMap::new(), + created_at: 0, + updated_at: 0, + enabled: true, + } + } + + pub fn tenant_key(org_id: &str, project_id: &str) -> String { + format!("{org_id}/{project_id}") + } + + pub fn key(&self) -> String { + Self::tenant_key(&self.org_id, &self.id) + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_key_is_composite() { + let project = Project::new("proj-1", "org-1", "Project 1"); + assert_eq!(project.key(), "org-1/proj-1"); + } +} diff --git a/iam/proto/iam.proto b/iam/proto/iam.proto index 200e12e..42f3089 100644 --- a/iam/proto/iam.proto +++ b/iam/proto/iam.proto @@ -255,6 +255,20 @@ service IamAdmin { rpc DeletePrincipal(DeletePrincipalRequest) returns (DeletePrincipalResponse); rpc ListPrincipals(ListPrincipalsRequest) returns (ListPrincipalsResponse); + // Organization management + rpc CreateOrganization(CreateOrganizationRequest) returns (Organization); + rpc GetOrganization(GetOrganizationRequest) returns (Organization); + rpc UpdateOrganization(UpdateOrganizationRequest) returns (Organization); + rpc DeleteOrganization(DeleteOrganizationRequest) returns (DeleteOrganizationResponse); + rpc ListOrganizations(ListOrganizationsRequest) returns (ListOrganizationsResponse); + + // Project management + rpc CreateProject(CreateProjectRequest) returns (Project); + rpc GetProject(GetProjectRequest) returns (Project); + rpc UpdateProject(UpdateProjectRequest) returns (Project); + rpc DeleteProject(DeleteProjectRequest) returns (DeleteProjectResponse); + rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse); + // Role management rpc CreateRole(CreateRoleRequest) returns (Role); rpc GetRole(GetRoleRequest) returns (Role); @@ -268,6 +282,12 @@ service IamAdmin { rpc UpdateBinding(UpdateBindingRequest) returns (PolicyBinding); rpc DeleteBinding(DeleteBindingRequest) returns (DeleteBindingResponse); rpc ListBindings(ListBindingsRequest) returns (ListBindingsResponse); + + // Group membership management + rpc AddGroupMember(AddGroupMemberRequest) returns (AddGroupMemberResponse); + rpc RemoveGroupMember(RemoveGroupMemberRequest) returns (RemoveGroupMemberResponse); + rpc ListGroupMembers(ListGroupMembersRequest) returns (ListGroupMembersResponse); + rpc ListPrincipalGroups(ListPrincipalGroupsRequest) returns (ListPrincipalGroupsResponse); } // ---------------------------------------------------------------------------- @@ -340,6 +360,95 @@ message ListPrincipalsResponse { string next_page_token = 2; } +// ---------------------------------------------------------------------------- +// Organization Messages +// ---------------------------------------------------------------------------- + +message CreateOrganizationRequest { + string id = 1; + string name = 2; + string description = 3; + map metadata = 4; +} + +message GetOrganizationRequest { + string id = 1; +} + +message UpdateOrganizationRequest { + string id = 1; + optional string name = 2; + optional string description = 3; + map metadata = 4; + optional bool enabled = 5; +} + +message DeleteOrganizationRequest { + string id = 1; +} + +message DeleteOrganizationResponse { + bool deleted = 1; +} + +message ListOrganizationsRequest { + bool include_disabled = 1; + int32 page_size = 2; + string page_token = 3; +} + +message ListOrganizationsResponse { + repeated Organization organizations = 1; + string next_page_token = 2; +} + +// ---------------------------------------------------------------------------- +// Project Messages +// ---------------------------------------------------------------------------- + +message CreateProjectRequest { + string id = 1; + string org_id = 2; + string name = 3; + string description = 4; + map metadata = 5; +} + +message GetProjectRequest { + string org_id = 1; + string id = 2; +} + +message UpdateProjectRequest { + string org_id = 1; + string id = 2; + optional string name = 3; + optional string description = 4; + map metadata = 5; + optional bool enabled = 6; +} + +message DeleteProjectRequest { + string org_id = 1; + string id = 2; +} + +message DeleteProjectResponse { + bool deleted = 1; +} + +message ListProjectsRequest { + optional string org_id = 1; + bool include_disabled = 2; + int32 page_size = 3; + string page_token = 4; +} + +message ListProjectsResponse { + repeated Project projects = 1; + string next_page_token = 2; +} + // ---------------------------------------------------------------------------- // Role Messages // ---------------------------------------------------------------------------- @@ -466,6 +575,50 @@ message ListBindingsResponse { string next_page_token = 2; } +// ---------------------------------------------------------------------------- +// Group Membership Messages +// ---------------------------------------------------------------------------- + +message AddGroupMemberRequest { + string group_id = 1; + PrincipalRef principal = 2; +} + +message AddGroupMemberResponse { + bool added = 1; +} + +message RemoveGroupMemberRequest { + string group_id = 1; + PrincipalRef principal = 2; +} + +message RemoveGroupMemberResponse { + bool removed = 1; +} + +message ListGroupMembersRequest { + string group_id = 1; + int32 page_size = 2; + string page_token = 3; +} + +message ListGroupMembersResponse { + repeated Principal members = 1; + string next_page_token = 2; +} + +message ListPrincipalGroupsRequest { + PrincipalRef principal = 1; + int32 page_size = 2; + string page_token = 3; +} + +message ListPrincipalGroupsResponse { + repeated Principal groups = 1; + string next_page_token = 2; +} + // ============================================================================ // Common Types // ============================================================================ @@ -497,6 +650,27 @@ message Principal { bool enabled = 12; } +message Organization { + string id = 1; + string name = 2; + string description = 3; + map metadata = 4; + uint64 created_at = 5; + uint64 updated_at = 6; + bool enabled = 7; +} + +message Project { + string id = 1; + string org_id = 2; + string name = 3; + string description = 4; + map metadata = 5; + uint64 created_at = 6; + uint64 updated_at = 7; + bool enabled = 8; +} + message ResourceRef { // Resource kind (e.g., "instance", "volume") string kind = 1; diff --git a/k8shost/crates/k8shost-server/src/auth.rs b/k8shost/crates/k8shost-server/src/auth.rs index a6cb309..400400c 100644 --- a/k8shost/crates/k8shost-server/src/auth.rs +++ b/k8shost/crates/k8shost-server/src/auth.rs @@ -5,12 +5,14 @@ use std::sync::Arc; use anyhow::Result; use iam_client::client::IamClientConfig; use iam_client::IamClient; -use iam_types::{PolicyBinding, Principal, PrincipalRef, Scope}; pub use iam_service_auth::AuthService; +use iam_types::{PolicyBinding, Principal, PrincipalRef, Scope}; use tonic::metadata::MetadataValue; use tonic::{Request, Status}; -pub use iam_service_auth::{get_tenant_context, resolve_tenant_ids_from_context, resource_for_tenant}; +pub use iam_service_auth::{ + get_tenant_context, resolve_tenant_ids_from_context, resource_for_tenant, +}; /// gRPC interceptor that authenticates requests and injects tenant context. pub async fn auth_interceptor( @@ -45,16 +47,23 @@ pub async fn issue_controller_token( let principal_ref = PrincipalRef::service_account(principal_id); let principal = match client.get_principal(&principal_ref).await? { Some(existing) => existing, - None => client - .create_service_account(principal_id, principal_id, project_id) - .await?, + None => { + client + .create_service_account(principal_id, principal_id, org_id, project_id) + .await? + } }; ensure_project_admin_binding(&client, &principal, org_id, project_id).await?; let scope = Scope::project(project_id, org_id); client - .issue_token(&principal, vec!["roles/ProjectAdmin".to_string()], scope, 3600) + .issue_token( + &principal, + vec!["roles/ProjectAdmin".to_string()], + scope, + 3600, + ) .await .map_err(Into::into) } @@ -70,9 +79,9 @@ async fn ensure_project_admin_binding( .list_bindings_for_principal(&principal.to_ref()) .await?; - let already_bound = bindings.iter().any(|binding| { - binding.role_ref == "roles/ProjectAdmin" && binding.scope == scope - }); + let already_bound = bindings + .iter() + .any(|binding| binding.role_ref == "roles/ProjectAdmin" && binding.scope == scope); if already_bound { return Ok(()); } diff --git a/lightningstor/crates/lightningstor-server/src/s3/auth.rs b/lightningstor/crates/lightningstor-server/src/s3/auth.rs index 67cee5b..4552ebc 100644 --- a/lightningstor/crates/lightningstor-server/src/s3/auth.rs +++ b/lightningstor/crates/lightningstor-server/src/s3/auth.rs @@ -1,8 +1,9 @@ //! AWS Signature Version 4 authentication for S3 API -//! +//! //! Implements simplified SigV4 authentication compatible with AWS S3 SDKs and aws-cli. //! Integrates with IAM for access key validation. +use crate::tenant::TenantContext; use axum::{ body::{Body, Bytes}, extract::Request, @@ -10,17 +11,16 @@ use axum::{ middleware::Next, response::{IntoResponse, Response}, }; -use crate::tenant::TenantContext; use hmac::{Hmac, Mac}; use iam_api::proto::{iam_credential_client::IamCredentialClient, GetSecretKeyRequest}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Arc; +use std::time::{Duration as StdDuration, Instant}; use tokio::sync::{Mutex, RwLock}; -use tonic::transport::Channel; +use tonic::{transport::Channel, Request as TonicRequest}; use tracing::{debug, warn}; use url::form_urlencoded; -use std::time::{Duration as StdDuration, Instant}; type HmacSha256 = Hmac; const DEFAULT_MAX_AUTH_BODY_BYTES: usize = 1024 * 1024 * 1024; @@ -124,13 +124,17 @@ impl IamClient { if let Ok(creds_str) = std::env::var("S3_CREDENTIALS") { for pair in creds_str.split(',') { if let Some((access_key, secret_key)) = pair.split_once(':') { - credentials.insert(access_key.trim().to_string(), secret_key.trim().to_string()); + credentials + .insert(access_key.trim().to_string(), secret_key.trim().to_string()); } else { warn!("Invalid S3_CREDENTIALS format for pair: {}", pair); } } if !credentials.is_empty() { - debug!("Loaded {} S3 credential(s) from S3_CREDENTIALS", credentials.len()); + debug!( + "Loaded {} S3 credential(s) from S3_CREDENTIALS", + credentials.len() + ); } } @@ -264,12 +268,15 @@ impl IamClient { for attempt in 0..2 { let grpc_channel = Self::grpc_channel(endpoint, channel).await?; let mut client = IamCredentialClient::new(grpc_channel); - match client - .get_secret_key(GetSecretKeyRequest { - access_key_id: access_key_id.to_string(), - }) - .await - { + let mut request = TonicRequest::new(GetSecretKeyRequest { + access_key_id: access_key_id.to_string(), + }); + if let Some(token) = iam_admin_token() { + if let Ok(value) = token.parse() { + request.metadata_mut().insert("x-iam-admin-token", value); + } + } + match client.get_secret_key(request).await { Ok(response) => return Ok(response), Err(status) if attempt == 0 @@ -300,6 +307,14 @@ fn normalize_iam_endpoint(endpoint: &str) -> String { } } +fn iam_admin_token() -> Option { + std::env::var("IAM_ADMIN_TOKEN") + .or_else(|_| std::env::var("PHOTON_IAM_ADMIN_TOKEN")) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + impl AuthState { /// Create new auth state with IAM integration pub fn new(iam_endpoint: Option) -> Self { @@ -311,8 +326,7 @@ impl AuthState { .unwrap_or_else(|_| "true".to_string()) .parse() .unwrap_or(true), - aws_region: std::env::var("AWS_REGION") - .unwrap_or_else(|_| "us-east-1".to_string()), // Default S3 region + aws_region: std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string()), // Default S3 region aws_service: "s3".to_string(), } } @@ -431,7 +445,13 @@ pub async fn sigv4_auth_middleware( let (parts, body) = request.into_parts(); let body_bytes = match axum::body::to_bytes(body, max_body_bytes).await { Ok(b) => b, - Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, "InternalError", &e.to_string()), + Err(e) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalError", + &e.to_string(), + ) + } }; request = Request::from_parts(parts, Body::from(body_bytes.clone())); @@ -448,16 +468,13 @@ pub async fn sigv4_auth_middleware( Bytes::new() }; - let (canonical_request, hashed_payload) = match build_canonical_request( - &method, - &uri, - &headers, - &body_bytes, - &signed_headers_str, - ) { - Ok(val) => val, - Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, "SignatureError", &e), - }; + let (canonical_request, hashed_payload) = + match build_canonical_request(&method, &uri, &headers, &body_bytes, &signed_headers_str) { + Ok(val) => val, + Err(e) => { + return error_response(StatusCode::INTERNAL_SERVER_ERROR, "SignatureError", &e) + } + }; debug!( method = %method, uri = %uri, @@ -468,11 +485,7 @@ pub async fn sigv4_auth_middleware( "SigV4 Canonical Request generated" ); - let string_to_sign = build_string_to_sign( - amz_date, - &credential_scope, - &canonical_request, - ); + let string_to_sign = build_string_to_sign(amz_date, &credential_scope, &canonical_request); debug!( amz_date = %amz_date, credential_scope = %credential_scope, @@ -559,7 +572,12 @@ fn parse_auth_header(auth_header: &str) -> Result<(String, String, String, Strin .get("Signature") .ok_or("Signature not found in Authorization header")?; - Ok((access_key_id.to_string(), full_credential_scope, signed_headers.to_string(), signature.to_string())) + Ok(( + access_key_id.to_string(), + full_credential_scope, + signed_headers.to_string(), + signature.to_string(), + )) } /// Compute the full AWS Signature Version 4. @@ -576,19 +594,10 @@ fn compute_sigv4_signature( aws_region: &str, aws_service: &str, ) -> Result { - let (canonical_request, _hashed_payload) = build_canonical_request( - method, - uri, - headers, - body_bytes, - signed_headers_str, - )?; + let (canonical_request, _hashed_payload) = + build_canonical_request(method, uri, headers, body_bytes, signed_headers_str)?; - let string_to_sign = build_string_to_sign( - amz_date, - credential_scope, - &canonical_request, - ); + let string_to_sign = build_string_to_sign(amz_date, credential_scope, &canonical_request); let signing_key = get_signing_key(secret_key, amz_date, aws_region, aws_service)?; @@ -612,9 +621,10 @@ fn build_canonical_request( // Canonical Query String let canonical_query_string = if uri_parts.len() > 1 { - let mut query_params: Vec<(String, String)> = form_urlencoded::parse(uri_parts[1].as_bytes()) - .into_owned() - .collect(); + let mut query_params: Vec<(String, String)> = + form_urlencoded::parse(uri_parts[1].as_bytes()) + .into_owned() + .collect(); query_params.sort_by(|(k1, _), (k2, _)| k1.cmp(k2)); query_params .into_iter() @@ -638,10 +648,17 @@ fn build_canonical_request( let value_str = header_value .to_str() .map_err(|_| format!("Invalid header value for {}", header_name))?; - canonical_headers.push_str(&format!("{}:{} -", header_name, value_str.trim())); + canonical_headers.push_str(&format!( + "{}:{} +", + header_name, + value_str.trim() + )); } else { - return Err(format!("Signed header '{}' not found in request", header_name)); + return Err(format!( + "Signed header '{}' not found in request", + header_name + )); } } @@ -756,8 +773,7 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response { {} {} "###, - code, - message + code, message ); Response::builder() @@ -780,11 +796,14 @@ mod tests { }; use std::collections::HashMap; use std::net::SocketAddr; - use std::sync::{atomic::{AtomicUsize, Ordering}, Mutex}; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, + }; use tokio::net::TcpListener; use tokio::time::{sleep, Duration}; - use tonic::{Request as TonicRequest, Response as TonicResponse, Status}; use tonic::transport::Server; + use tonic::{Request as TonicRequest, Response as TonicResponse, Status}; static ENV_LOCK: Mutex<()> = Mutex::new(()); @@ -835,7 +854,9 @@ mod tests { &self, _request: TonicRequest, ) -> Result, Status> { - Ok(TonicResponse::new(RevokeCredentialResponse { success: true })) + Ok(TonicResponse::new(RevokeCredentialResponse { + success: true, + })) } } @@ -880,8 +901,9 @@ mod tests { let key = b"key"; let data = "data"; // Verified with: echo -n "data" | openssl dgst -sha256 -mac hmac -macopt key:"key" - let expected = hex::decode("5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0") - .unwrap(); + let expected = + hex::decode("5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0") + .unwrap(); assert_eq!(hmac_sha256(key, data).unwrap(), expected); } @@ -911,11 +933,14 @@ mod tests { assert_eq!(url_encode_path("/foo bar/baz"), "/foo%20bar/baz"); assert_eq!(url_encode_path("/"), "/"); assert_eq!(url_encode_path(""), "/"); // Empty path should be normalized to / - // Test special characters that should be encoded + // Test special characters that should be encoded assert_eq!(url_encode_path("/my+bucket"), "/my%2Bbucket"); assert_eq!(url_encode_path("/my=bucket"), "/my%3Dbucket"); // Test unreserved characters that should NOT be encoded - assert_eq!(url_encode_path("/my-bucket_test.file~123"), "/my-bucket_test.file~123"); + assert_eq!( + url_encode_path("/my-bucket_test.file~123"), + "/my-bucket_test.file~123" + ); } #[tokio::test] @@ -930,8 +955,7 @@ mod tests { let signed_headers = "content-type;host;x-amz-date"; let (canonical_request, hashed_payload) = - build_canonical_request(method, uri, &headers, &body, signed_headers) - .unwrap(); + build_canonical_request(method, uri, &headers, &body, signed_headers).unwrap(); // Body hash verified with: echo -n "some_body" | sha256sum let expected_body_hash = "fed42376ceefa4bb65ead687ec9738f6b2329fd78870aaf797bd7194da4228d3"; @@ -950,13 +974,15 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert("host", HeaderValue::from_static("example.com")); headers.insert("x-amz-date", HeaderValue::from_static("20231201T000000Z")); - headers.insert("x-amz-content-sha256", HeaderValue::from_static("signed-payload-hash")); + headers.insert( + "x-amz-content-sha256", + HeaderValue::from_static("signed-payload-hash"), + ); let body = Bytes::from("different-body"); let signed_headers = "host;x-amz-content-sha256;x-amz-date"; let (canonical_request, hashed_payload) = - build_canonical_request(method, uri, &headers, &body, signed_headers) - .unwrap(); + build_canonical_request(method, uri, &headers, &body, signed_headers).unwrap(); assert!(canonical_request.ends_with("\nsigned-payload-hash")); assert_eq!(hashed_payload, "signed-payload-hash"); @@ -980,9 +1006,7 @@ mod tests { let expected_string_to_sign = format!( "AWS4-HMAC-SHA256\n{}\n{}\n{}", - amz_date, - credential_scope, - hashed_canonical_request + amz_date, credential_scope, hashed_canonical_request ); assert_eq!(string_to_sign, expected_string_to_sign); } @@ -1015,7 +1039,10 @@ mod tests { let credentials = client.env_credentials().unwrap(); assert_eq!(credentials.len(), 1); - assert_eq!(credentials.get("test_key"), Some(&"test_secret".to_string())); + assert_eq!( + credentials.get("test_key"), + Some(&"test_secret".to_string()) + ); std::env::remove_var("S3_ACCESS_KEY_ID"); std::env::remove_var("S3_SECRET_KEY"); @@ -1071,7 +1098,10 @@ mod tests { let signed_headers = "host;x-amz-date"; let mut headers = HeaderMap::new(); - headers.insert("host", HeaderValue::from_static("examplebucket.s3.amazonaws.com")); + headers.insert( + "host", + HeaderValue::from_static("examplebucket.s3.amazonaws.com"), + ); headers.insert("x-amz-date", HeaderValue::from_static("20150830T123600Z")); let body = Bytes::new(); // Empty body for GET @@ -1191,7 +1221,10 @@ mod tests { .unwrap(); // Signatures MUST be different - assert_ne!(sig1, sig2, "Signatures should differ with different secret keys"); + assert_ne!( + sig1, sig2, + "Signatures should differ with different secret keys" + ); } #[test] @@ -1341,7 +1374,10 @@ mod tests { .unwrap(); // Signatures MUST be different - assert_ne!(sig1, sig2, "Signatures should differ with different header values"); + assert_ne!( + sig1, sig2, + "Signatures should differ with different header values" + ); } #[test] @@ -1389,7 +1425,10 @@ mod tests { .unwrap(); // Signatures MUST be different - assert_ne!(sig1, sig2, "Signatures should differ with different query parameters"); + assert_ne!( + sig1, sig2, + "Signatures should differ with different query parameters" + ); } #[test] @@ -1404,7 +1443,10 @@ mod tests { let credentials = client.env_credentials().unwrap(); // Known key should be found in credentials map - assert_eq!(credentials.get("known_key"), Some(&"known_secret".to_string())); + assert_eq!( + credentials.get("known_key"), + Some(&"known_secret".to_string()) + ); // Unknown key should not be found assert_eq!(credentials.get("unknown_key"), None); diff --git a/nix/modules/first-boot-automation.nix b/nix/modules/first-boot-automation.nix index 776b4b8..f1dccf6 100644 --- a/nix/modules/first-boot-automation.nix +++ b/nix/modules/first-boot-automation.nix @@ -250,7 +250,7 @@ in iamPort = lib.mkOption { type = lib.types.port; - default = 8080; + default = config.services.iam.httpPort; description = "IAM API port"; }; }; @@ -374,7 +374,12 @@ in # Check if admin user exists log "INFO" "Checking for existing admin user" - HTTP_CODE=$(${pkgs.curl}/bin/curl -s -o /dev/null -w "%{http_code}" "http://localhost:${toString cfg.iamPort}/api/users/admin" 2>/dev/null || echo "000") + ADMIN_HEADER=() + ${lib.optionalString (config.services.iam.adminToken != null) '' + ADMIN_HEADER=(-H "x-iam-admin-token: ${config.services.iam.adminToken}") + ''} + + HTTP_CODE=$(${pkgs.curl}/bin/curl -s -o /dev/null -w "%{http_code}" "''${ADMIN_HEADER[@]}" "http://localhost:${toString cfg.iamPort}/api/v1/users/admin" 2>/dev/null || echo "000") if [ "$HTTP_CODE" = "200" ]; then log "INFO" "Admin user already exists" @@ -383,8 +388,22 @@ in exit 0 fi - # TODO: Create admin user (requires IAM API implementation) - log "WARN" "Admin user creation not yet implemented (waiting for IAM API)" + log "INFO" "Creating bootstrap admin user" + + RESPONSE_FILE=$(mktemp) + HTTP_CODE=$(${pkgs.curl}/bin/curl -s -w "%{http_code}" -o "$RESPONSE_FILE" \ + -X POST "http://localhost:${toString cfg.iamPort}/api/v1/users" \ + "''${ADMIN_HEADER[@]}" \ + -H "Content-Type: application/json" \ + -d '{"id":"admin","name":"Bootstrap Admin"}' 2>/dev/null || echo "000") + + RESPONSE_BODY=$(cat "$RESPONSE_FILE" 2>/dev/null || echo "") + rm -f "$RESPONSE_FILE" + + if [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "409" ]; then + log "ERROR" "Failed to create admin user: HTTP $HTTP_CODE, response: $RESPONSE_BODY" + exit 1 + fi # Mark as initialized for now mkdir -p /var/lib/first-boot-automation diff --git a/nix/modules/iam.nix b/nix/modules/iam.nix index c2251b4..7d3f44d 100644 --- a/nix/modules/iam.nix +++ b/nix/modules/iam.nix @@ -81,6 +81,12 @@ in description = "Data directory for iam"; }; + adminToken = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Admin token injected as IAM_ADMIN_TOKEN for privileged IAM APIs."; + }; + settings = lib.mkOption { type = lib.types.attrs; default = {}; @@ -127,6 +133,9 @@ in (lib.mkIf (cfg.storeBackend == "memory") { IAM_ALLOW_MEMORY_BACKEND = "1"; }) + (lib.mkIf (cfg.adminToken != null) { + IAM_ADMIN_TOKEN = cfg.adminToken; + }) ]; serviceConfig = { diff --git a/plasmavmc/crates/plasmavmc-server/src/artifact_store.rs b/plasmavmc/crates/plasmavmc-server/src/artifact_store.rs index 1504105..33a2c0a 100644 --- a/plasmavmc/crates/plasmavmc-server/src/artifact_store.rs +++ b/plasmavmc/crates/plasmavmc-server/src/artifact_store.rs @@ -783,7 +783,7 @@ impl ArtifactStore { Some(principal) => principal, None => self .iam_client - .create_service_account(&principal_id, &principal_id, project_id) + .create_service_account(&principal_id, &principal_id, org_id, project_id) .await .map_err(|e| { Status::unavailable(format!("failed to create service account: {e}")) diff --git a/plasmavmc/crates/plasmavmc-server/src/vm_service.rs b/plasmavmc/crates/plasmavmc-server/src/vm_service.rs index 137d582..a8d7b13 100644 --- a/plasmavmc/crates/plasmavmc-server/src/vm_service.rs +++ b/plasmavmc/crates/plasmavmc-server/src/vm_service.rs @@ -7,13 +7,16 @@ use crate::volume_manager::VolumeManager; use crate::watcher::StateSink; use creditservice_client::{Client as CreditServiceClient, ResourceType as CreditResourceType}; use dashmap::DashMap; -use iam_client::IamClient; use iam_client::client::IamClientConfig; +use iam_client::IamClient; use iam_service_auth::{ - AuthService, get_tenant_context, resolve_tenant_ids_from_context, resource_for_tenant, + get_tenant_context, resolve_tenant_ids_from_context, resource_for_tenant, AuthService, }; use iam_types::{PolicyBinding, PrincipalRef, Scope}; use plasmavmc_api::proto::{ + disk_source::Source as ProtoDiskSourceKind, image_service_server::ImageService, + node_service_server::NodeService, vm_service_client::VmServiceClient, + vm_service_server::VmService, volume_service_server::VolumeService, Architecture as ProtoArchitecture, AttachDiskRequest, AttachNicRequest, CephRbdBacking, CordonNodeRequest, CreateImageRequest, CreateVmRequest, CreateVolumeRequest, DeleteImageRequest, DeleteVmRequest, DeleteVolumeRequest, DetachDiskRequest, DetachNicRequest, @@ -31,9 +34,6 @@ use plasmavmc_api::proto::{ VmState as ProtoVmState, VmStatus as ProtoVmStatus, Volume as ProtoVolume, VolumeBacking as ProtoVolumeBacking, VolumeDriverKind as ProtoVolumeDriverKind, VolumeFormat as ProtoVolumeFormat, VolumeStatus as ProtoVolumeStatus, WatchVmRequest, - disk_source::Source as ProtoDiskSourceKind, image_service_server::ImageService, - node_service_server::NodeService, vm_service_client::VmServiceClient, - vm_service_server::VmService, volume_service_server::VolumeService, }; use plasmavmc_hypervisor::HypervisorRegistry; use plasmavmc_types::{ @@ -374,7 +374,7 @@ impl VmServiceImpl { Some(principal) => principal, None => self .iam_client - .create_service_account(&principal_id, &principal_id, project_id) + .create_service_account(&principal_id, &principal_id, org_id, project_id) .await .map_err(|e| { Status::unavailable(format!("IAM service account create failed: {e}")) @@ -1480,7 +1480,9 @@ impl VmServiceImpl { return Ok(()); }; - let auth_token = self.issue_internal_token(&vm.org_id, &vm.project_id).await?; + let auth_token = self + .issue_internal_token(&vm.org_id, &vm.project_id) + .await?; let mut client = PrismNETClient::new(endpoint.clone(), auth_token).await?; for net_spec in &mut vm.spec.network { @@ -1532,7 +1534,9 @@ impl VmServiceImpl { return Ok(()); }; - let auth_token = self.issue_internal_token(&vm.org_id, &vm.project_id).await?; + let auth_token = self + .issue_internal_token(&vm.org_id, &vm.project_id) + .await?; let mut client = PrismNETClient::new(endpoint.clone(), auth_token).await?; for net_spec in &vm.spec.network { @@ -1936,7 +1940,10 @@ mod tests { PRISMNET_VM_DEVICE_TYPE, prismnet_api::proto::DeviceType::Vm as i32 ); - assert_ne!(PRISMNET_VM_DEVICE_TYPE, prismnet_api::proto::DeviceType::None as i32); + assert_ne!( + PRISMNET_VM_DEVICE_TYPE, + prismnet_api::proto::DeviceType::None as i32 + ); } } @@ -2130,7 +2137,10 @@ impl VmService for VmServiceImpl { { let mut client = credit_svc.write().await; if let Err(release_err) = client - .release_reservation(res_id, format!("VM volume preparation failed: {}", error)) + .release_reservation( + res_id, + format!("VM volume preparation failed: {}", error), + ) .await { tracing::warn!("Failed to release reservation {}: {}", res_id, release_err); @@ -2185,7 +2195,10 @@ impl VmService for VmServiceImpl { { let mut client = credit_svc.write().await; if let Err(release_err) = client - .release_reservation(res_id, format!("VM status failed after creation: {}", error)) + .release_reservation( + res_id, + format!("VM status failed after creation: {}", error), + ) .await { tracing::warn!("Failed to release reservation {}: {}", res_id, release_err);