Implement IAM tenant registry and privileged admin surfaces

This commit is contained in:
centra 2026-03-31 01:23:16 +09:00
parent 37f5479ab8
commit b75766af0b
Signed by: centra
GPG key ID: 0C09689D20B25ACA
27 changed files with 2837 additions and 478 deletions

View file

@ -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?
}
};

View file

@ -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<Principal> for TypesPrincipal {
}
}
// ============================================================================
// Organization / Project conversions
// ============================================================================
impl From<TypesOrganization> 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<Organization> 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<TypesProject> 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<Project> 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
// ============================================================================

View file

@ -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<CredentialStore>,
principal_store: Arc<PrincipalStore>,
cipher: Aes256Gcm,
key_id: String,
admin_token: Option<String>,
}
impl IamCredentialService {
pub fn new(store: Arc<CredentialStore>, master_key: &[u8], key_id: &str) -> Result<Self, Status> {
pub fn new(
store: Arc<CredentialStore>,
principal_store: Arc<PrincipalStore>,
master_key: &[u8],
key_id: &str,
admin_token: Option<String>,
) -> Result<Self, Status> {
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::<Aes256Gcm>::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<T>(&self, request: &Request<T>) -> 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<TypesPrincipalKind, Status> {
@ -110,9 +159,22 @@ impl IamCredential for IamCredentialService {
&self,
request: Request<CreateS3CredentialRequest>,
) -> Result<Response<CreateS3CredentialResponse>, 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<GetSecretKeyRequest>,
) -> Result<Response<GetSecretKeyResponse>, 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<ListCredentialsRequest>,
) -> Result<Response<ListCredentialsResponse>, 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<RevokeCredentialRequest>,
) -> Result<Response<RevokeCredentialResponse>, 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<PrincipalStore>) {
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());
}
}

View file

@ -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]

File diff suppressed because it is too large Load diff

View file

@ -25,6 +25,7 @@ pub struct IamTokenService {
token_service: Arc<InternalTokenService>,
principal_store: Arc<PrincipalStore>,
token_store: Arc<TokenStore>,
admin_token: Option<String>,
}
impl IamTokenService {
@ -33,11 +34,13 @@ impl IamTokenService {
token_service: Arc<InternalTokenService>,
principal_store: Arc<PrincipalStore>,
token_store: Arc<TokenStore>,
admin_token: Option<String>,
) -> 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<T>(&self, request: &Request<T>) -> 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<IssueTokenRequest>,
) -> Result<Response<IssueTokenResponse>, 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);
}
}

View file

@ -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<AuthnResult> {
// 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<AuthnCredentials> {
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"
));
}
}

View file

@ -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<String>,
}
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<String>) -> 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<String>,
}
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<Channel> {
@ -105,6 +127,22 @@ impl IamClient {
IamTokenClient::new(self.channel.clone())
}
fn inject_admin_token<T>(&self, request: &mut Request<T>) {
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<T>(&self, message: T) -> Request<T> {
let mut request = Request::new(message);
self.inject_admin_token(&mut request);
request
}
async fn call_with_retry<T, F, Fut>(operation: &'static str, mut op: F) -> Result<T>
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<Principal> {
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<Organization> {
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<Option<Organization>> {
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<Vec<Organization>> {
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<bool>,
) -> Result<Organization> {
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<bool> {
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<Project> {
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<Option<Project>> {
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<Vec<Project>> {
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<bool>,
) -> Result<Project> {
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<bool> {
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<String> {
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))

View file

@ -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<String> {
.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<PrincipalStore>,
binding_store: &Arc<BindingStore>,
org_store: &Arc<OrgStore>,
project_store: &Arc<ProjectStore>,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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,
));
@ -253,6 +369,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
token_store.clone(),
evaluator.clone(),
);
let credential_service =
IamCredentialService::new(credential_store, &credential_master_key, "iam-cred-master")
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<dyn std::error::Error>> {
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?;
@ -469,11 +601,7 @@ async fn create_backend(
.map_err(|e| e.into())
}
BackendKind::Postgres | BackendKind::Sqlite => {
let database_url = config
.store
.database_url
.as_deref()
.ok_or_else(|| {
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)
@ -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: {}",

View file

@ -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<String>,
}
/// 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<T> SuccessResponse<T> {
}
}
/// 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<Vec<String>>,
}
/// 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<String>,
pub project_id: Option<String>,
pub enabled: bool,
}
impl From<Principal> for UserResponse {
@ -136,48 +128,131 @@ impl From<Principal> 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<UserResponse>,
}
/// 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<String>,
pub description: Option<String>,
pub enabled: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct OrganizationResponse {
pub id: String,
pub name: String,
pub description: String,
pub enabled: bool,
}
impl From<Organization> 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<OrganizationResponse>,
}
#[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<String>,
pub description: Option<String>,
pub enabled: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct ProjectsQuery {
pub org_id: Option<String>,
}
/// 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<Project> 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<ProjectResponse>,
}
/// 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<SuccessResponse<serde_json::Value>>) {
(
StatusCode::OK,
@ -187,11 +262,63 @@ async fn health_check() -> (StatusCode, Json<SuccessResponse<serde_json::Value>>
)
}
/// POST /api/v1/auth/token - Issue token
fn require_admin(
state: &RestApiState,
headers: &HeaderMap,
) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
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, (StatusCode, Json<ErrorResponse>)> {
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<RestApiState>,
Json(req): Json<TokenRequest>,
) -> Result<Json<SuccessResponse<TokenResponse>>, (StatusCode, Json<ErrorResponse>)> {
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<RestApiState>,
Json(req): Json<VerifyRequest>,
) -> Result<Json<SuccessResponse<VerifyResponse>>, (StatusCode, Json<ErrorResponse>)> {
// 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<RestApiState>,
Json(req): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<SuccessResponse<UserResponse>>), (StatusCode, Json<ErrorResponse>)> {
// 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<RestApiState>,
) -> Result<Json<SuccessResponse<UsersResponse>>, (StatusCode, Json<ErrorResponse>)> {
// 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<UserResponse> = 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<RestApiState>,
) -> Result<Json<SuccessResponse<ProjectsResponse>>, (StatusCode, Json<ErrorResponse>)> {
// 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<String>,
State(state): State<RestApiState>,
) -> Result<Json<SuccessResponse<UserResponse>>, (StatusCode, Json<ErrorResponse>)> {
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<RestApiState>,
Json(req): Json<CreateOrganizationRequest>,
) -> Result<
(StatusCode, Json<SuccessResponse<OrganizationResponse>>),
(StatusCode, Json<ErrorResponse>),
> {
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<RestApiState>,
) -> Result<Json<SuccessResponse<OrganizationsResponse>>, (StatusCode, Json<ErrorResponse>)> {
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<String>,
State(state): State<RestApiState>,
) -> Result<Json<SuccessResponse<OrganizationResponse>>, (StatusCode, Json<ErrorResponse>)> {
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<String>,
State(state): State<RestApiState>,
Json(req): Json<UpdateOrganizationRequest>,
) -> Result<Json<SuccessResponse<OrganizationResponse>>, (StatusCode, Json<ErrorResponse>)> {
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<String>,
State(state): State<RestApiState>,
) -> Result<Json<SuccessResponse<serde_json::Value>>, (StatusCode, Json<ErrorResponse>)> {
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<RestApiState>,
headers: HeaderMap,
State(state): State<RestApiState>,
Json(req): Json<CreateProjectRequest>,
) -> Result<(StatusCode, Json<SuccessResponse<ProjectResponse>>), (StatusCode, Json<ErrorResponse>)>
{
// 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<ProjectsQuery>,
State(state): State<RestApiState>,
) -> Result<Json<SuccessResponse<ProjectsResponse>>, (StatusCode, Json<ErrorResponse>)> {
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<RestApiState>,
) -> Result<Json<SuccessResponse<ProjectResponse>>, (StatusCode, Json<ErrorResponse>)> {
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<RestApiState>,
Json(req): Json<UpdateProjectRequest>,
) -> Result<Json<SuccessResponse<ProjectResponse>>, (StatusCode, Json<ErrorResponse>)> {
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<RestApiState>,
) -> Result<Json<SuccessResponse<serde_json::Value>>, (StatusCode, Json<ErrorResponse>)> {
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,

View file

@ -94,10 +94,7 @@ impl AuthService {
}
/// Authenticate an HTTP request using headers.
pub async fn authenticate_headers(
&self,
headers: &HeaderMap,
) -> Result<TenantContext, Status> {
pub async fn authenticate_headers(&self, headers: &HeaderMap) -> Result<TenantContext, Status> {
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,11 +139,7 @@ impl AuthService {
return Ok(cached);
}
let claims = self
.iam_client
.validate_token(token)
.await
.map_err(|e| {
let claims = self.iam_client.validate_token(token).await.map_err(|e| {
warn!("Token validation failed: {}", e);
Status::unauthenticated(format!("Invalid token: {}", e))
})?;

View file

@ -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;

View file

@ -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<Backend>,
}
impl JsonStore for OrgStore {
fn backend(&self) -> &Backend {
&self.backend
}
}
impl OrgStore {
pub fn new(backend: Arc<Backend>) -> Self {
Self { backend }
}
pub async fn create(&self, org: &Organization) -> Result<u64> {
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<bool> {
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<Option<Organization>> {
Ok(self.get_json::<Organization>(self.primary_key(id).as_bytes()).await?.map(|v| v.0))
}
pub async fn get_with_version(&self, id: &str) -> Result<Option<(Organization, u64)>> {
self.get_json::<Organization>(self.primary_key(id).as_bytes()).await
}
pub async fn update(&self, org: &Organization, expected_version: u64) -> Result<u64> {
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<bool> {
self.backend.delete(self.primary_key(id).as_bytes()).await
}
pub async fn list(&self) -> Result<Vec<Organization>> {
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<bool> {
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());
}
}

View file

@ -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<Vec<Principal>> {
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<bool> {
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();

View file

@ -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<Backend>,
}
impl JsonStore for ProjectStore {
fn backend(&self) -> &Backend {
&self.backend
}
}
impl ProjectStore {
pub fn new(backend: Arc<Backend>) -> Self {
Self { backend }
}
pub async fn create(&self, project: &Project) -> Result<u64> {
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<bool> {
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<Option<Project>> {
Ok(self
.get_json::<Project>(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<Option<(Project, u64)>> {
self.get_json::<Project>(self.primary_key(org_id, id).as_bytes())
.await
}
pub async fn update(&self, project: &Project, expected_version: u64) -> Result<u64> {
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<bool> {
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<Vec<Project>> {
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<Vec<Project>> {
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<bool> {
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");
}
}

View file

@ -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),

View file

@ -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,
};

View file

@ -104,13 +104,14 @@ impl Principal {
pub fn new_service_account(
id: impl Into<String>,
name: impl Into<String>,
org_id: impl Into<String>,
project_id: impl Into<String>,
) -> 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,

View file

@ -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")));
}
}

View file

@ -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<String, String>,
pub created_at: u64,
pub updated_at: u64,
pub enabled: bool,
}
impl Organization {
pub fn new(id: impl Into<String>, name: impl Into<String>) -> 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<String>) -> 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<String, String>,
pub created_at: u64,
pub updated_at: u64,
pub enabled: bool,
}
impl Project {
pub fn new(
id: impl Into<String>,
org_id: impl Into<String>,
name: impl Into<String>,
) -> 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<String>) -> 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");
}
}

View file

@ -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<string, string> metadata = 4;
}
message GetOrganizationRequest {
string id = 1;
}
message UpdateOrganizationRequest {
string id = 1;
optional string name = 2;
optional string description = 3;
map<string, string> 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<string, string> 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<string, string> 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<string, string> 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<string, string> metadata = 5;
uint64 created_at = 6;
uint64 updated_at = 7;
bool enabled = 8;
}
message ResourceRef {
// Resource kind (e.g., "instance", "volume")
string kind = 1;

View file

@ -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(());
}

View file

@ -3,6 +3,7 @@
//! 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<Sha256>;
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 {
let mut request = TonicRequest::new(GetSecretKeyRequest {
access_key_id: access_key_id.to_string(),
})
.await
{
});
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<String> {
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<String>) -> 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,15 +468,12 @@ 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,
) {
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),
Err(e) => {
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "SignatureError", &e)
}
};
debug!(
method = %method,
@ -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<String, String> {
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,7 +621,8 @@ 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())
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));
@ -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>{}</Code>
<Message>{}</Message>
</Error>"###,
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<RevokeCredentialRequest>,
) -> Result<TonicResponse<RevokeCredentialResponse>, Status> {
Ok(TonicResponse::new(RevokeCredentialResponse { success: true }))
Ok(TonicResponse::new(RevokeCredentialResponse {
success: true,
}))
}
}
@ -880,7 +901,8 @@ 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")
let expected =
hex::decode("5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0")
.unwrap();
assert_eq!(hmac_sha256(key, data).unwrap(), expected);
}
@ -915,7 +937,10 @@ mod tests {
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);

View file

@ -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

View file

@ -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 = {

View file

@ -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}"))

View file

@ -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);