Implement IAM tenant registry and privileged admin surfaces
This commit is contained in:
parent
37f5479ab8
commit
b75766af0b
27 changed files with 2837 additions and 478 deletions
|
|
@ -30,7 +30,7 @@ pub async fn issue_controller_token(
|
||||||
Some(existing) => existing,
|
Some(existing) => existing,
|
||||||
None => {
|
None => {
|
||||||
client
|
client
|
||||||
.create_service_account(principal_id, principal_id, project_id)
|
.create_service_account(principal_id, principal_id, org_id, project_id)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,15 @@
|
||||||
|
|
||||||
use iam_types::{
|
use iam_types::{
|
||||||
Condition as TypesCondition, ConditionExpr as TypesConditionExpr,
|
Condition as TypesCondition, ConditionExpr as TypesConditionExpr,
|
||||||
Permission as TypesPermission, PolicyBinding as TypesBinding, Principal as TypesPrincipal,
|
Organization as TypesOrganization, Permission as TypesPermission,
|
||||||
PrincipalKind as TypesPrincipalKind, PrincipalRef as TypesPrincipalRef, Role as TypesRole,
|
PolicyBinding as TypesBinding, Principal as TypesPrincipal,
|
||||||
Scope as TypesScope,
|
PrincipalKind as TypesPrincipalKind, PrincipalRef as TypesPrincipalRef,
|
||||||
|
Project as TypesProject, Role as TypesRole, Scope as TypesScope,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::proto::{
|
use crate::proto::{
|
||||||
self, condition_expr, scope, Condition, ConditionExpr, Permission, PolicyBinding, Principal,
|
self, condition_expr, scope, Condition, ConditionExpr, Organization, Permission, PolicyBinding,
|
||||||
PrincipalKind, PrincipalRef, Role, Scope,
|
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
|
// Scope conversions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,23 @@ use std::sync::Arc;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit, Nonce};
|
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 base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
use rand_core::{OsRng, RngCore};
|
use rand_core::{OsRng, RngCore};
|
||||||
use tonic::{Request, Response, Status};
|
use tonic::{Request, Response, Status};
|
||||||
|
|
||||||
use iam_store::CredentialStore;
|
use iam_store::{CredentialStore, PrincipalStore};
|
||||||
use iam_types::{Argon2Params, CredentialRecord, PrincipalKind as TypesPrincipalKind};
|
use iam_types::{
|
||||||
|
Argon2Params, CredentialRecord, PrincipalKind as TypesPrincipalKind, PrincipalRef,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::proto::{
|
use crate::proto::{
|
||||||
iam_credential_server::IamCredential, CreateS3CredentialRequest,
|
iam_credential_server::IamCredential, CreateS3CredentialRequest, CreateS3CredentialResponse,
|
||||||
CreateS3CredentialResponse, Credential, GetSecretKeyRequest, GetSecretKeyResponse,
|
Credential, GetSecretKeyRequest, GetSecretKeyResponse, ListCredentialsRequest,
|
||||||
ListCredentialsRequest, ListCredentialsResponse, PrincipalKind, RevokeCredentialRequest,
|
ListCredentialsResponse, PrincipalKind, RevokeCredentialRequest, RevokeCredentialResponse,
|
||||||
RevokeCredentialResponse,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fn now_ts() -> u64 {
|
fn now_ts() -> u64 {
|
||||||
|
|
@ -26,12 +30,20 @@ fn now_ts() -> u64 {
|
||||||
|
|
||||||
pub struct IamCredentialService {
|
pub struct IamCredentialService {
|
||||||
store: Arc<CredentialStore>,
|
store: Arc<CredentialStore>,
|
||||||
|
principal_store: Arc<PrincipalStore>,
|
||||||
cipher: Aes256Gcm,
|
cipher: Aes256Gcm,
|
||||||
key_id: String,
|
key_id: String,
|
||||||
|
admin_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IamCredentialService {
|
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 {
|
if master_key.len() != 32 {
|
||||||
return Err(Status::failed_precondition(
|
return Err(Status::failed_precondition(
|
||||||
"IAM_CRED_MASTER_KEY must be 32 bytes",
|
"IAM_CRED_MASTER_KEY must be 32 bytes",
|
||||||
|
|
@ -40,8 +52,10 @@ impl IamCredentialService {
|
||||||
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(master_key));
|
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(master_key));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
store,
|
store,
|
||||||
|
principal_store,
|
||||||
cipher,
|
cipher,
|
||||||
key_id: key_id.to_string(),
|
key_id: key_id.to_string(),
|
||||||
|
admin_token,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,6 +107,41 @@ impl IamCredentialService {
|
||||||
.decrypt(nonce, ct)
|
.decrypt(nonce, ct)
|
||||||
.map_err(|e| Status::internal(format!("decrypt failed: {}", e)))
|
.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> {
|
fn map_principal_kind(kind: i32) -> Result<TypesPrincipalKind, Status> {
|
||||||
|
|
@ -110,9 +159,22 @@ impl IamCredential for IamCredentialService {
|
||||||
&self,
|
&self,
|
||||||
request: Request<CreateS3CredentialRequest>,
|
request: Request<CreateS3CredentialRequest>,
|
||||||
) -> Result<Response<CreateS3CredentialResponse>, Status> {
|
) -> Result<Response<CreateS3CredentialResponse>, Status> {
|
||||||
|
self.require_admin_token(&request)?;
|
||||||
let req = request.into_inner();
|
let req = request.into_inner();
|
||||||
let now = now_ts();
|
let now = now_ts();
|
||||||
let principal_kind = map_principal_kind(req.principal_kind)?;
|
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 (secret_b64, raw_secret) = Self::generate_secret();
|
||||||
let (hash, kdf) = Self::hash_secret(&raw_secret);
|
let (hash, kdf) = Self::hash_secret(&raw_secret);
|
||||||
let secret_enc = self.encrypt_secret(&raw_secret)?;
|
let secret_enc = self.encrypt_secret(&raw_secret)?;
|
||||||
|
|
@ -156,6 +218,7 @@ impl IamCredential for IamCredentialService {
|
||||||
&self,
|
&self,
|
||||||
request: Request<GetSecretKeyRequest>,
|
request: Request<GetSecretKeyRequest>,
|
||||||
) -> Result<Response<GetSecretKeyResponse>, Status> {
|
) -> Result<Response<GetSecretKeyResponse>, Status> {
|
||||||
|
self.require_admin_token(&request)?;
|
||||||
let req = request.into_inner();
|
let req = request.into_inner();
|
||||||
let record = match self.store.get(&req.access_key_id).await {
|
let record = match self.store.get(&req.access_key_id).await {
|
||||||
Ok(Some((rec, _))) => rec,
|
Ok(Some((rec, _))) => rec,
|
||||||
|
|
@ -195,6 +258,7 @@ impl IamCredential for IamCredentialService {
|
||||||
&self,
|
&self,
|
||||||
request: Request<ListCredentialsRequest>,
|
request: Request<ListCredentialsRequest>,
|
||||||
) -> Result<Response<ListCredentialsResponse>, Status> {
|
) -> Result<Response<ListCredentialsResponse>, Status> {
|
||||||
|
self.require_admin_token(&request)?;
|
||||||
let req = request.into_inner();
|
let req = request.into_inner();
|
||||||
let items = self
|
let items = self
|
||||||
.store
|
.store
|
||||||
|
|
@ -219,13 +283,16 @@ impl IamCredential for IamCredentialService {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(Response::new(ListCredentialsResponse { credentials: creds }))
|
Ok(Response::new(ListCredentialsResponse {
|
||||||
|
credentials: creds,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn revoke_credential(
|
async fn revoke_credential(
|
||||||
&self,
|
&self,
|
||||||
request: Request<RevokeCredentialRequest>,
|
request: Request<RevokeCredentialRequest>,
|
||||||
) -> Result<Response<RevokeCredentialResponse>, Status> {
|
) -> Result<Response<RevokeCredentialResponse>, Status> {
|
||||||
|
self.require_admin_token(&request)?;
|
||||||
let req = request.into_inner();
|
let req = request.into_inner();
|
||||||
let revoked = self
|
let revoked = self
|
||||||
.store
|
.store
|
||||||
|
|
@ -241,17 +308,41 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use iam_store::Backend;
|
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 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];
|
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]
|
#[tokio::test]
|
||||||
async fn create_and_get_roundtrip() {
|
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
|
let create = svc
|
||||||
.create_s3_credential(Request::new(CreateS3CredentialRequest {
|
.create_s3_credential(Request::new(CreateS3CredentialRequest {
|
||||||
principal_id: "p1".into(),
|
principal_id: "p1".into(),
|
||||||
|
|
@ -284,7 +375,9 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_filters_by_principal() {
|
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
|
let a = svc
|
||||||
.create_s3_credential(Request::new(CreateS3CredentialRequest {
|
.create_s3_credential(Request::new(CreateS3CredentialRequest {
|
||||||
principal_id: "pA".into(),
|
principal_id: "pA".into(),
|
||||||
|
|
@ -322,7 +415,8 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn revoke_blocks_get() {
|
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
|
let created = svc
|
||||||
.create_s3_credential(Request::new(CreateS3CredentialRequest {
|
.create_s3_credential(Request::new(CreateS3CredentialRequest {
|
||||||
principal_id: "p1".into(),
|
principal_id: "p1".into(),
|
||||||
|
|
@ -365,7 +459,8 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn expired_key_is_denied() {
|
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
|
// Manually insert an expired record
|
||||||
let expired = CredentialRecord {
|
let expired = CredentialRecord {
|
||||||
access_key_id: "expired-ak".into(),
|
access_key_id: "expired-ak".into(),
|
||||||
|
|
@ -401,8 +496,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn master_key_length_enforced() {
|
fn master_key_length_enforced() {
|
||||||
let backend = Arc::new(Backend::memory());
|
let backend = Arc::new(Backend::memory());
|
||||||
let store = Arc::new(CredentialStore::new(backend));
|
let store = Arc::new(CredentialStore::new(backend.clone()));
|
||||||
let bad = IamCredentialService::new(store.clone(), &[0u8; 16], "k");
|
let principal_store = Arc::new(PrincipalStore::new(backend));
|
||||||
|
let bad = IamCredentialService::new(store.clone(), principal_store, &[0u8; 16], "k", None);
|
||||||
assert!(bad.is_err());
|
assert!(bad.is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use apigateway_api::proto::{AuthorizeRequest, AuthorizeResponse, Subject};
|
use apigateway_api::proto::{AuthorizeRequest, AuthorizeResponse, Subject};
|
||||||
use apigateway_api::GatewayAuthService;
|
use apigateway_api::GatewayAuthService;
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
use iam_authz::{AuthzContext, AuthzDecision, AuthzRequest, PolicyEvaluator};
|
|
||||||
use iam_authn::InternalTokenService;
|
use iam_authn::InternalTokenService;
|
||||||
|
use iam_authz::{AuthzContext, AuthzDecision, AuthzRequest, PolicyEvaluator};
|
||||||
use iam_store::{PrincipalStore, TokenStore};
|
use iam_store::{PrincipalStore, TokenStore};
|
||||||
use iam_types::{InternalTokenClaims, Principal, PrincipalRef, Resource};
|
use iam_types::{InternalTokenClaims, Principal, PrincipalRef, Resource};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
@ -87,7 +87,10 @@ impl GatewayAuthService for GatewayAuthServiceImpl {
|
||||||
Err(err) => return Ok(Response::new(deny_response(err.to_string()))),
|
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)));
|
return Ok(Response::new(deny_response(reason)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,7 +111,10 @@ impl GatewayAuthService for GatewayAuthServiceImpl {
|
||||||
}
|
}
|
||||||
|
|
||||||
let (action, resource, context, org_id, project_id) =
|
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 =
|
let authz_request =
|
||||||
AuthzRequest::new(principal.clone(), action, resource).with_context(context);
|
AuthzRequest::new(principal.clone(), action, resource).with_context(context);
|
||||||
let decision = self
|
let decision = self
|
||||||
|
|
@ -181,9 +187,9 @@ fn build_authz_request(
|
||||||
req: &AuthorizeRequest,
|
req: &AuthorizeRequest,
|
||||||
claims: &InternalTokenClaims,
|
claims: &InternalTokenClaims,
|
||||||
principal: &Principal,
|
principal: &Principal,
|
||||||
) -> (String, Resource, AuthzContext, String, String) {
|
) -> Result<(String, Resource, AuthzContext, String, String), String> {
|
||||||
let action = action_for_request(req);
|
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(
|
let mut resource = Resource::new(
|
||||||
"gateway_route",
|
"gateway_route",
|
||||||
resource_id_for_request(req),
|
resource_id_for_request(req),
|
||||||
|
|
@ -212,7 +218,7 @@ fn build_authz_request(
|
||||||
context = context.with_source_ip(ip);
|
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 {
|
fn action_for_request(req: &AuthorizeRequest) -> String {
|
||||||
|
|
@ -265,7 +271,7 @@ fn resolve_org_project(
|
||||||
req: &AuthorizeRequest,
|
req: &AuthorizeRequest,
|
||||||
claims: &InternalTokenClaims,
|
claims: &InternalTokenClaims,
|
||||||
principal: &Principal,
|
principal: &Principal,
|
||||||
) -> (String, String) {
|
) -> Result<(String, String), String> {
|
||||||
let allow_header_override = allow_header_tenant_override();
|
let allow_header_override = allow_header_tenant_override();
|
||||||
let org_id = claims
|
let org_id = claims
|
||||||
.org_id
|
.org_id
|
||||||
|
|
@ -279,7 +285,7 @@ fn resolve_org_project(
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "system".to_string());
|
.ok_or_else(|| "tenant resolution failed: missing org_id".to_string())?;
|
||||||
|
|
||||||
let project_id = claims
|
let project_id = claims
|
||||||
.project_id
|
.project_id
|
||||||
|
|
@ -293,9 +299,9 @@ fn resolve_org_project(
|
||||||
None
|
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 {
|
fn allow_header_tenant_override() -> bool {
|
||||||
|
|
@ -383,7 +389,14 @@ mod tests {
|
||||||
token_store.clone(),
|
token_store.clone(),
|
||||||
evaluator,
|
evaluator,
|
||||||
);
|
);
|
||||||
(service, token_service, role_store, binding_store, token_store, principal)
|
(
|
||||||
|
service,
|
||||||
|
token_service,
|
||||||
|
role_store,
|
||||||
|
binding_store,
|
||||||
|
token_store,
|
||||||
|
principal,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -25,6 +25,7 @@ pub struct IamTokenService {
|
||||||
token_service: Arc<InternalTokenService>,
|
token_service: Arc<InternalTokenService>,
|
||||||
principal_store: Arc<PrincipalStore>,
|
principal_store: Arc<PrincipalStore>,
|
||||||
token_store: Arc<TokenStore>,
|
token_store: Arc<TokenStore>,
|
||||||
|
admin_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IamTokenService {
|
impl IamTokenService {
|
||||||
|
|
@ -33,11 +34,13 @@ impl IamTokenService {
|
||||||
token_service: Arc<InternalTokenService>,
|
token_service: Arc<InternalTokenService>,
|
||||||
principal_store: Arc<PrincipalStore>,
|
principal_store: Arc<PrincipalStore>,
|
||||||
token_store: Arc<TokenStore>,
|
token_store: Arc<TokenStore>,
|
||||||
|
admin_token: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
token_service,
|
token_service,
|
||||||
principal_store,
|
principal_store,
|
||||||
token_store,
|
token_store,
|
||||||
|
admin_token,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,6 +120,110 @@ impl IamTokenService {
|
||||||
}
|
}
|
||||||
Ok(token_id)
|
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]
|
#[tonic::async_trait]
|
||||||
|
|
@ -125,6 +232,7 @@ impl IamToken for IamTokenService {
|
||||||
&self,
|
&self,
|
||||||
request: Request<IssueTokenRequest>,
|
request: Request<IssueTokenRequest>,
|
||||||
) -> Result<Response<IssueTokenResponse>, Status> {
|
) -> Result<Response<IssueTokenResponse>, Status> {
|
||||||
|
self.require_admin_token(&request)?;
|
||||||
let req = request.into_inner();
|
let req = request.into_inner();
|
||||||
|
|
||||||
// Get principal kind
|
// Get principal kind
|
||||||
|
|
@ -149,6 +257,7 @@ impl IamToken for IamTokenService {
|
||||||
|
|
||||||
// Convert scope
|
// Convert scope
|
||||||
let scope = Self::convert_scope(&req.scope);
|
let scope = Self::convert_scope(&req.scope);
|
||||||
|
Self::validate_scope_for_principal(&principal, &scope)?;
|
||||||
|
|
||||||
// Determine TTL
|
// Determine TTL
|
||||||
let ttl = if req.ttl_seconds > 0 {
|
let ttl = if req.ttl_seconds > 0 {
|
||||||
|
|
@ -395,7 +504,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_issue_token_principal_not_found() {
|
async fn test_issue_token_principal_not_found() {
|
||||||
let (token_service, principal_store, token_store) = test_setup();
|
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 {
|
let req = IssueTokenRequest {
|
||||||
principal_id: "nonexistent".into(),
|
principal_id: "nonexistent".into(),
|
||||||
|
|
@ -412,8 +521,12 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_revoke_and_validate_blocklist() {
|
async fn test_revoke_and_validate_blocklist() {
|
||||||
let (token_service, principal_store, token_store) = test_setup();
|
let (token_service, principal_store, token_store) = test_setup();
|
||||||
let service =
|
let service = IamTokenService::new(
|
||||||
IamTokenService::new(token_service, principal_store.clone(), token_store.clone());
|
token_service,
|
||||||
|
principal_store.clone(),
|
||||||
|
token_store.clone(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
// create principal
|
// create principal
|
||||||
let principal = Principal::new_user("alice", "Alice");
|
let principal = Principal::new_user("alice", "Alice");
|
||||||
|
|
@ -468,8 +581,12 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_validate_token_principal_disabled() {
|
async fn test_validate_token_principal_disabled() {
|
||||||
let (token_service, principal_store, token_store) = test_setup();
|
let (token_service, principal_store, token_store) = test_setup();
|
||||||
let service =
|
let service = IamTokenService::new(
|
||||||
IamTokenService::new(token_service, principal_store.clone(), token_store.clone());
|
token_service,
|
||||||
|
principal_store.clone(),
|
||||||
|
token_store.clone(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
let principal = Principal::new_user("alice", "Alice");
|
let principal = Principal::new_user("alice", "Alice");
|
||||||
principal_store.create(&principal).await.unwrap();
|
principal_store.create(&principal).await.unwrap();
|
||||||
|
|
@ -505,4 +622,31 @@ mod tests {
|
||||||
assert!(!valid_resp.valid);
|
assert!(!valid_resp.valid);
|
||||||
assert!(valid_resp.reason.contains("disabled"));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,6 @@ pub enum AuthnCredentials {
|
||||||
BearerToken(String),
|
BearerToken(String),
|
||||||
/// mTLS certificate info
|
/// mTLS certificate info
|
||||||
Certificate(CertificateInfo),
|
Certificate(CertificateInfo),
|
||||||
/// API key
|
|
||||||
ApiKey(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authentication provider trait
|
/// Authentication provider trait
|
||||||
|
|
@ -153,19 +151,6 @@ impl CombinedAuthProvider {
|
||||||
internal_claims: None,
|
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 {
|
impl Default for CombinedAuthProvider {
|
||||||
|
|
@ -180,7 +165,6 @@ impl AuthnProvider for CombinedAuthProvider {
|
||||||
match credentials {
|
match credentials {
|
||||||
AuthnCredentials::BearerToken(token) => self.authenticate_bearer(token).await,
|
AuthnCredentials::BearerToken(token) => self.authenticate_bearer(token).await,
|
||||||
AuthnCredentials::Certificate(cert_info) => self.authenticate_certificate(cert_info),
|
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") {
|
if scheme.eq_ignore_ascii_case("bearer") {
|
||||||
return Some(AuthnCredentials::BearerToken(token.to_string()));
|
return Some(AuthnCredentials::BearerToken(token.to_string()));
|
||||||
}
|
}
|
||||||
if scheme.eq_ignore_ascii_case("apikey") {
|
|
||||||
return Some(AuthnCredentials::ApiKey(token.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
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]
|
#[test]
|
||||||
fn test_extract_bearer_token_case_insensitive() {
|
fn test_extract_bearer_token_case_insensitive() {
|
||||||
let creds = extract_credentials_from_headers(Some("bearer abc123"), None);
|
let creds = extract_credentials_from_headers(Some("bearer abc123"), None);
|
||||||
|
|
@ -314,14 +284,4 @@ mod tests {
|
||||||
Some(AuthnCredentials::BearerToken(t)) if t == "abc123"
|
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"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,21 @@ use std::time::Duration;
|
||||||
use iam_api::proto::{
|
use iam_api::proto::{
|
||||||
iam_admin_client::IamAdminClient, iam_authz_client::IamAuthzClient,
|
iam_admin_client::IamAdminClient, iam_authz_client::IamAuthzClient,
|
||||||
iam_token_client::IamTokenClient, AuthorizeRequest, AuthzContext, CreateBindingRequest,
|
iam_token_client::IamTokenClient, AuthorizeRequest, AuthzContext, CreateBindingRequest,
|
||||||
CreatePrincipalRequest, CreateRoleRequest, DeleteBindingRequest, GetPrincipalRequest,
|
CreateOrganizationRequest, CreatePrincipalRequest, CreateProjectRequest, CreateRoleRequest,
|
||||||
GetRoleRequest, IssueTokenRequest, ListBindingsRequest, ListPrincipalsRequest,
|
DeleteBindingRequest, DeleteOrganizationRequest, DeleteProjectRequest, GetOrganizationRequest,
|
||||||
ListRolesRequest, Principal as ProtoPrincipal, PrincipalKind as ProtoPrincipalKind,
|
GetPrincipalRequest, GetProjectRequest, GetRoleRequest, IssueTokenRequest, ListBindingsRequest,
|
||||||
PrincipalRef as ProtoPrincipalRef, ResourceRef as ProtoResourceRef, RevokeTokenRequest,
|
ListOrganizationsRequest, ListPrincipalsRequest, ListProjectsRequest, ListRolesRequest,
|
||||||
Scope as ProtoScope, ValidateTokenRequest,
|
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::{
|
use iam_types::{
|
||||||
AuthMethod, Error, IamError, InternalTokenClaims, PolicyBinding, Principal,
|
AuthMethod, Error, IamError, InternalTokenClaims, Organization, PolicyBinding, Principal,
|
||||||
PrincipalKind as TypesPrincipalKind, PrincipalRef, Resource, Result, Role, Scope,
|
PrincipalKind as TypesPrincipalKind, PrincipalRef, Project, Resource, Result, Role, Scope,
|
||||||
};
|
};
|
||||||
use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
|
use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
|
||||||
|
use tonic::{metadata::MetadataValue, Request};
|
||||||
|
|
||||||
const TRANSIENT_RPC_RETRY_ATTEMPTS: usize = 3;
|
const TRANSIENT_RPC_RETRY_ATTEMPTS: usize = 3;
|
||||||
const TRANSIENT_RPC_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
|
const TRANSIENT_RPC_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
|
||||||
|
|
@ -33,6 +37,8 @@ pub struct IamClientConfig {
|
||||||
pub timeout_ms: u64,
|
pub timeout_ms: u64,
|
||||||
/// Enable TLS
|
/// Enable TLS
|
||||||
pub tls: bool,
|
pub tls: bool,
|
||||||
|
/// Optional admin token for privileged APIs
|
||||||
|
pub admin_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IamClientConfig {
|
impl IamClientConfig {
|
||||||
|
|
@ -42,6 +48,7 @@ impl IamClientConfig {
|
||||||
endpoint: endpoint.into(),
|
endpoint: endpoint.into(),
|
||||||
timeout_ms: 5000,
|
timeout_ms: 5000,
|
||||||
tls: true,
|
tls: true,
|
||||||
|
admin_token: load_admin_token_from_env(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,11 +63,23 @@ impl IamClientConfig {
|
||||||
self.tls = false;
|
self.tls = false;
|
||||||
self
|
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
|
/// IAM client
|
||||||
pub struct IamClient {
|
pub struct IamClient {
|
||||||
channel: Channel,
|
channel: Channel,
|
||||||
|
admin_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IamClient {
|
impl IamClient {
|
||||||
|
|
@ -90,7 +109,10 @@ impl IamClient {
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error::Internal(e.to_string()))?;
|
.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> {
|
fn authz_client(&self) -> IamAuthzClient<Channel> {
|
||||||
|
|
@ -105,6 +127,22 @@ impl IamClient {
|
||||||
IamTokenClient::new(self.channel.clone())
|
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>
|
async fn call_with_retry<T, F, Fut>(operation: &'static str, mut op: F) -> Result<T>
|
||||||
where
|
where
|
||||||
F: FnMut() -> Fut,
|
F: FnMut() -> Fut,
|
||||||
|
|
@ -219,7 +257,8 @@ impl IamClient {
|
||||||
let resp = Self::call_with_retry("create_principal", || {
|
let resp = Self::call_with_retry("create_principal", || {
|
||||||
let mut client = self.admin_client();
|
let mut client = self.admin_client();
|
||||||
let req = req.clone();
|
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?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
@ -234,7 +273,8 @@ impl IamClient {
|
||||||
let resp = Self::call_with_retry("get_principal", || {
|
let resp = Self::call_with_retry("get_principal", || {
|
||||||
let mut client = self.admin_client();
|
let mut client = self.admin_client();
|
||||||
let req = req.clone();
|
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;
|
.await;
|
||||||
match resp {
|
match resp {
|
||||||
|
|
@ -249,13 +289,14 @@ impl IamClient {
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
org_id: &str,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
) -> Result<Principal> {
|
) -> Result<Principal> {
|
||||||
let req = CreatePrincipalRequest {
|
let req = CreatePrincipalRequest {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
kind: ProtoPrincipalKind::ServiceAccount as i32,
|
kind: ProtoPrincipalKind::ServiceAccount as i32,
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
org_id: None,
|
org_id: Some(org_id.into()),
|
||||||
project_id: Some(project_id.into()),
|
project_id: Some(project_id.into()),
|
||||||
email: None,
|
email: None,
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
|
|
@ -263,7 +304,8 @@ impl IamClient {
|
||||||
let resp = Self::call_with_retry("create_service_account", || {
|
let resp = Self::call_with_retry("create_service_account", || {
|
||||||
let mut client = self.admin_client();
|
let mut client = self.admin_client();
|
||||||
let req = req.clone();
|
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?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
@ -283,7 +325,8 @@ impl IamClient {
|
||||||
let resp = Self::call_with_retry("list_principals", || {
|
let resp = Self::call_with_retry("list_principals", || {
|
||||||
let mut client = self.admin_client();
|
let mut client = self.admin_client();
|
||||||
let req = req.clone();
|
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?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
@ -295,6 +338,219 @@ impl IamClient {
|
||||||
.collect())
|
.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
|
// Role Management APIs
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
@ -305,7 +561,8 @@ impl IamClient {
|
||||||
let resp = Self::call_with_retry("get_role", || {
|
let resp = Self::call_with_retry("get_role", || {
|
||||||
let mut client = self.admin_client();
|
let mut client = self.admin_client();
|
||||||
let req = req.clone();
|
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;
|
.await;
|
||||||
match resp {
|
match resp {
|
||||||
|
|
@ -326,7 +583,8 @@ impl IamClient {
|
||||||
let resp = Self::call_with_retry("list_roles", || {
|
let resp = Self::call_with_retry("list_roles", || {
|
||||||
let mut client = self.admin_client();
|
let mut client = self.admin_client();
|
||||||
let req = req.clone();
|
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?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
@ -351,7 +609,8 @@ impl IamClient {
|
||||||
let resp = Self::call_with_retry("create_role", || {
|
let resp = Self::call_with_retry("create_role", || {
|
||||||
let mut client = self.admin_client();
|
let mut client = self.admin_client();
|
||||||
let req = req.clone();
|
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?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
@ -375,7 +634,8 @@ impl IamClient {
|
||||||
let resp = Self::call_with_retry("create_binding", || {
|
let resp = Self::call_with_retry("create_binding", || {
|
||||||
let mut client = self.admin_client();
|
let mut client = self.admin_client();
|
||||||
let req = req.clone();
|
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?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
@ -390,7 +650,8 @@ impl IamClient {
|
||||||
let resp = Self::call_with_retry("delete_binding", || {
|
let resp = Self::call_with_retry("delete_binding", || {
|
||||||
let mut client = self.admin_client();
|
let mut client = self.admin_client();
|
||||||
let req = req.clone();
|
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?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
@ -414,7 +675,8 @@ impl IamClient {
|
||||||
let resp = Self::call_with_retry("list_bindings_for_principal", || {
|
let resp = Self::call_with_retry("list_bindings_for_principal", || {
|
||||||
let mut client = self.admin_client();
|
let mut client = self.admin_client();
|
||||||
let req = req.clone();
|
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?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
@ -435,7 +697,8 @@ impl IamClient {
|
||||||
let resp = Self::call_with_retry("list_bindings_for_scope", || {
|
let resp = Self::call_with_retry("list_bindings_for_scope", || {
|
||||||
let mut client = self.admin_client();
|
let mut client = self.admin_client();
|
||||||
let req = req.clone();
|
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?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
@ -469,7 +732,8 @@ impl IamClient {
|
||||||
let resp = Self::call_with_retry("issue_token", || {
|
let resp = Self::call_with_retry("issue_token", || {
|
||||||
let mut client = self.token_client();
|
let mut client = self.token_client();
|
||||||
let req = req.clone();
|
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?
|
.await?
|
||||||
.into_inner();
|
.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 {
|
fn retry_delay(attempt: usize) -> Duration {
|
||||||
TRANSIENT_RPC_INITIAL_BACKOFF
|
TRANSIENT_RPC_INITIAL_BACKOFF
|
||||||
.saturating_mul(1u32 << attempt.min(3))
|
.saturating_mul(1u32 << attempt.min(3))
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,10 @@ use iam_api::{
|
||||||
use iam_authn::{InternalTokenConfig, InternalTokenService, SigningKey};
|
use iam_authn::{InternalTokenConfig, InternalTokenService, SigningKey};
|
||||||
use iam_authz::{PolicyCache, PolicyCacheConfig, PolicyEvaluator};
|
use iam_authz::{PolicyCache, PolicyCacheConfig, PolicyEvaluator};
|
||||||
use iam_store::{
|
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};
|
use config::{BackendKind, ServerConfig};
|
||||||
|
|
||||||
|
|
@ -62,6 +64,19 @@ fn load_admin_token() -> Option<String> {
|
||||||
.filter(|value| !value.is_empty())
|
.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 {
|
fn admin_token_valid(metadata: &MetadataMap, token: &str) -> bool {
|
||||||
if let Some(value) = metadata.get("x-iam-admin-token") {
|
if let Some(value) = metadata.get("x-iam-admin-token") {
|
||||||
if let Ok(raw) = value.to_str() {
|
if let Ok(raw) = value.to_str() {
|
||||||
|
|
@ -86,6 +101,102 @@ fn admin_token_valid(metadata: &MetadataMap, token: &str) -> bool {
|
||||||
false
|
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
|
/// IAM Server
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "iam-server")]
|
#[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 binding_store = Arc::new(BindingStore::new(backend.clone()));
|
||||||
let credential_store = Arc::new(CredentialStore::new(backend.clone()));
|
let credential_store = Arc::new(CredentialStore::new(backend.clone()));
|
||||||
let token_store = Arc::new(TokenStore::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
|
// Initialize builtin roles
|
||||||
info!("Initializing builtin roles...");
|
info!("Initializing builtin roles...");
|
||||||
role_store.init_builtin_roles().await?;
|
role_store.init_builtin_roles().await?;
|
||||||
|
backfill_tenant_registry(&principal_store, &binding_store, &org_store, &project_store).await?;
|
||||||
|
|
||||||
// Create policy cache
|
// Create policy cache
|
||||||
let cache_config = PolicyCacheConfig {
|
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));
|
let cache = Arc::new(PolicyCache::new(cache_config));
|
||||||
|
|
||||||
// Create evaluator
|
// Create evaluator
|
||||||
let evaluator = Arc::new(PolicyEvaluator::new(
|
let evaluator = Arc::new(PolicyEvaluator::with_group_store(
|
||||||
binding_store.clone(),
|
binding_store.clone(),
|
||||||
role_store.clone(),
|
role_store.clone(),
|
||||||
|
group_store.clone(),
|
||||||
cache,
|
cache,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -244,15 +360,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
let token_config =
|
let token_config =
|
||||||
InternalTokenConfig::new(signing_key.clone(), &config.authn.internal_token.issuer)
|
InternalTokenConfig::new(signing_key.clone(), &config.authn.internal_token.issuer)
|
||||||
.with_default_ttl(Duration::from_secs(
|
.with_default_ttl(Duration::from_secs(
|
||||||
config.authn.internal_token.default_ttl_seconds,
|
config.authn.internal_token.default_ttl_seconds,
|
||||||
))
|
))
|
||||||
.with_max_ttl(Duration::from_secs(
|
.with_max_ttl(Duration::from_secs(
|
||||||
config.authn.internal_token.max_ttl_seconds,
|
config.authn.internal_token.max_ttl_seconds,
|
||||||
));
|
));
|
||||||
|
|
||||||
let token_service = Arc::new(InternalTokenService::new(token_config));
|
let token_service = Arc::new(InternalTokenService::new(token_config));
|
||||||
let admin_token = load_admin_token();
|
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")
|
let credential_master_key = std::env::var("IAM_CRED_MASTER_KEY")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|value| value.into_bytes())
|
.map(|value| value.into_bytes())
|
||||||
|
|
@ -270,6 +392,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
token_service.clone(),
|
token_service.clone(),
|
||||||
principal_store.clone(),
|
principal_store.clone(),
|
||||||
token_store.clone(),
|
token_store.clone(),
|
||||||
|
admin_token.clone(),
|
||||||
);
|
);
|
||||||
let gateway_auth_service = GatewayAuthServiceImpl::new(
|
let gateway_auth_service = GatewayAuthServiceImpl::new(
|
||||||
token_service.clone(),
|
token_service.clone(),
|
||||||
|
|
@ -277,17 +400,25 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
token_store.clone(),
|
token_store.clone(),
|
||||||
evaluator.clone(),
|
evaluator.clone(),
|
||||||
);
|
);
|
||||||
let credential_service =
|
let credential_service = IamCredentialService::new(
|
||||||
IamCredentialService::new(credential_store, &credential_master_key, "iam-cred-master")
|
credential_store,
|
||||||
.map_err(|e| format!("Failed to initialize credential service: {}", e))?;
|
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(
|
let admin_service = IamAdminService::new(
|
||||||
principal_store.clone(),
|
principal_store.clone(),
|
||||||
role_store.clone(),
|
role_store.clone(),
|
||||||
binding_store.clone(),
|
binding_store.clone(),
|
||||||
|
org_store.clone(),
|
||||||
|
project_store.clone(),
|
||||||
|
group_store.clone(),
|
||||||
)
|
)
|
||||||
.with_evaluator(evaluator.clone());
|
.with_evaluator(evaluator.clone());
|
||||||
let admin_interceptor = AdminTokenInterceptor {
|
let admin_interceptor = AdminTokenInterceptor {
|
||||||
token: admin_token.map(Arc::new),
|
token: admin_token.clone().map(Arc::new),
|
||||||
};
|
};
|
||||||
if admin_interceptor.token.is_some() {
|
if admin_interceptor.token.is_some() {
|
||||||
info!("IAM admin token authentication enabled");
|
info!("IAM admin token authentication enabled");
|
||||||
|
|
@ -388,6 +519,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let rest_state = rest::RestApiState {
|
let rest_state = rest::RestApiState {
|
||||||
server_addr: config.server.addr.to_string(),
|
server_addr: config.server.addr.to_string(),
|
||||||
tls_enabled: config.server.tls.is_some(),
|
tls_enabled: config.server.tls.is_some(),
|
||||||
|
admin_token: admin_token.clone(),
|
||||||
};
|
};
|
||||||
let rest_app = rest::build_router(rest_state);
|
let rest_app = rest::build_router(rest_state);
|
||||||
let http_listener = tokio::net::TcpListener::bind(&http_addr).await?;
|
let http_listener = tokio::net::TcpListener::bind(&http_addr).await?;
|
||||||
|
|
@ -465,20 +597,16 @@ async fn create_backend(
|
||||||
pd_endpoint,
|
pd_endpoint,
|
||||||
namespace,
|
namespace,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.into())
|
.map_err(|e| e.into())
|
||||||
}
|
}
|
||||||
BackendKind::Postgres | BackendKind::Sqlite => {
|
BackendKind::Postgres | BackendKind::Sqlite => {
|
||||||
let database_url = config
|
let database_url = config.store.database_url.as_deref().ok_or_else(|| {
|
||||||
.store
|
format!(
|
||||||
.database_url
|
"database_url is required when store.backend={}",
|
||||||
.as_deref()
|
backend_kind_name(config.store.backend)
|
||||||
.ok_or_else(|| {
|
)
|
||||||
format!(
|
})?;
|
||||||
"database_url is required when store.backend={}",
|
|
||||||
backend_kind_name(config.store.backend)
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
ensure_sql_backend_matches_url(config.store.backend, database_url)?;
|
ensure_sql_backend_matches_url(config.store.backend, database_url)?;
|
||||||
info!(
|
info!(
|
||||||
"Using {} backend: {}",
|
"Using {} backend: {}",
|
||||||
|
|
@ -543,31 +671,29 @@ async fn register_chainfire_membership(
|
||||||
let value = format!(r#"{{"addr":"{}","ts":{}}}"#, addr, ts);
|
let value = format!(r#"{{"addr":"{}","ts":{}}}"#, addr, ts);
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(120);
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(120);
|
||||||
let mut attempt = 0usize;
|
let mut attempt = 0usize;
|
||||||
let mut last_error = String::new();
|
let last_error = loop {
|
||||||
|
|
||||||
loop {
|
|
||||||
attempt += 1;
|
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(mut client) => match client.put_str(&key, &value).await {
|
||||||
Ok(_) => return Ok(()),
|
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 {
|
if tokio::time::Instant::now() >= deadline {
|
||||||
break;
|
break error;
|
||||||
}
|
}
|
||||||
|
|
||||||
warn!(
|
warn!(
|
||||||
attempt,
|
attempt,
|
||||||
endpoint,
|
endpoint,
|
||||||
service,
|
service,
|
||||||
error = %last_error,
|
error = %error,
|
||||||
"retrying ChainFire membership registration"
|
"retrying ChainFire membership registration"
|
||||||
);
|
);
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
}
|
};
|
||||||
|
|
||||||
Err(std::io::Error::other(format!(
|
Err(std::io::Error::other(format!(
|
||||||
"failed to register ChainFire membership for {} via {} after {} attempts: {}",
|
"failed to register ChainFire membership for {} via {} after {} attempts: {}",
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,13 @@
|
||||||
//! REST HTTP API handlers for IAM
|
//! 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
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::{HeaderMap, StatusCode},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use iam_client::client::{IamClient, IamClientConfig};
|
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};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// REST API state
|
/// REST API state
|
||||||
|
|
@ -24,6 +15,7 @@ use serde::{Deserialize, Serialize};
|
||||||
pub struct RestApiState {
|
pub struct RestApiState {
|
||||||
pub server_addr: String,
|
pub server_addr: String,
|
||||||
pub tls_enabled: bool,
|
pub tls_enabled: bool,
|
||||||
|
pub admin_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Standard REST error response
|
/// Standard REST error response
|
||||||
|
|
@ -58,6 +50,9 @@ impl ResponseMeta {
|
||||||
|
|
||||||
fn iam_client_config(state: &RestApiState) -> IamClientConfig {
|
fn iam_client_config(state: &RestApiState) -> IamClientConfig {
|
||||||
let mut config = IamClientConfig::new(&state.server_addr);
|
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 {
|
if !state.tls_enabled {
|
||||||
config = config.without_tls();
|
config = config.without_tls();
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +75,6 @@ impl<T> SuccessResponse<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Token issuance request
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct TokenRequest {
|
pub struct TokenRequest {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|
@ -90,23 +84,20 @@ pub struct TokenRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_ttl() -> u64 {
|
fn default_ttl() -> u64 {
|
||||||
3600 // 1 hour
|
3600
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Token response
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct TokenResponse {
|
pub struct TokenResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub expires_at: String,
|
pub expires_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Token verification request
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct VerifyRequest {
|
pub struct VerifyRequest {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Token verification response
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct VerifyResponse {
|
pub struct VerifyResponse {
|
||||||
pub valid: bool,
|
pub valid: bool,
|
||||||
|
|
@ -115,19 +106,20 @@ pub struct VerifyResponse {
|
||||||
pub roles: Option<Vec<String>>,
|
pub roles: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User creation request
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateUserRequest {
|
pub struct CreateUserRequest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User response
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct UserResponse {
|
pub struct UserResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
pub org_id: Option<String>,
|
||||||
|
pub project_id: Option<String>,
|
||||||
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Principal> for UserResponse {
|
impl From<Principal> for UserResponse {
|
||||||
|
|
@ -136,48 +128,131 @@ impl From<Principal> for UserResponse {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
kind: format!("{:?}", p.kind),
|
kind: format!("{:?}", p.kind),
|
||||||
|
org_id: p.org_id,
|
||||||
|
project_id: p.project_id,
|
||||||
|
enabled: p.enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Users list response
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct UsersResponse {
|
pub struct UsersResponse {
|
||||||
pub users: Vec<UserResponse>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateProjectRequest {
|
pub struct CreateProjectRequest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub org_id: String,
|
||||||
pub name: 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)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ProjectResponse {
|
pub struct ProjectResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub org_id: String,
|
||||||
pub name: 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)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ProjectsResponse {
|
pub struct ProjectsResponse {
|
||||||
pub projects: Vec<ProjectResponse>,
|
pub projects: Vec<ProjectResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the REST API router
|
|
||||||
pub fn build_router(state: RestApiState) -> Router {
|
pub fn build_router(state: RestApiState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/v1/auth/token", post(issue_token))
|
.route("/api/v1/auth/token", post(issue_token))
|
||||||
.route("/api/v1/auth/verify", post(verify_token))
|
.route("/api/v1/auth/verify", post(verify_token))
|
||||||
.route("/api/v1/users", get(list_users).post(create_user))
|
.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/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))
|
.route("/health", get(health_check))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Health check endpoint
|
|
||||||
async fn health_check() -> (StatusCode, Json<SuccessResponse<serde_json::Value>>) {
|
async fn health_check() -> (StatusCode, Json<SuccessResponse<serde_json::Value>>) {
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
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(
|
async fn issue_token(
|
||||||
|
headers: HeaderMap,
|
||||||
State(state): State<RestApiState>,
|
State(state): State<RestApiState>,
|
||||||
Json(req): Json<TokenRequest>,
|
Json(req): Json<TokenRequest>,
|
||||||
) -> Result<Json<SuccessResponse<TokenResponse>>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse<TokenResponse>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
require_admin(&state, &headers)?;
|
||||||
if !allow_insecure_rest_token_issue() {
|
if !allow_insecure_rest_token_issue() {
|
||||||
return Err(error_response(
|
return Err(error_response(
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
|
|
@ -206,16 +333,7 @@ async fn issue_token(
|
||||||
ttl_seconds,
|
ttl_seconds,
|
||||||
} = req;
|
} = req;
|
||||||
|
|
||||||
// Connect to IAM server
|
let client = connect_client(&state).await?;
|
||||||
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 principal_ref = PrincipalRef::new(PrincipalKind::User, &username);
|
let principal_ref = PrincipalRef::new(PrincipalKind::User, &username);
|
||||||
let principal = match client.get_principal(&principal_ref).await.map_err(|e| {
|
let principal = match client.get_principal(&principal_ref).await.map_err(|e| {
|
||||||
error_response(
|
error_response(
|
||||||
|
|
@ -242,7 +360,6 @@ async fn issue_token(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue token
|
|
||||||
let token = client
|
let token = client
|
||||||
.issue_token(&principal, vec![], Scope::System, ttl_seconds)
|
.issue_token(&principal, vec![], Scope::System, ttl_seconds)
|
||||||
.await
|
.await
|
||||||
|
|
@ -275,22 +392,11 @@ fn allow_insecure_rest_token_issue() -> bool {
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/v1/auth/verify - Verify token
|
|
||||||
async fn verify_token(
|
async fn verify_token(
|
||||||
State(state): State<RestApiState>,
|
State(state): State<RestApiState>,
|
||||||
Json(req): Json<VerifyRequest>,
|
Json(req): Json<VerifyRequest>,
|
||||||
) -> Result<Json<SuccessResponse<VerifyResponse>>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse<VerifyResponse>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
// Connect to IAM server
|
let client = connect_client(&state).await?;
|
||||||
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 result = client.validate_token(&req.token).await;
|
let result = client.validate_token(&req.token).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|
@ -309,22 +415,13 @@ async fn verify_token(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/v1/users - Create user
|
|
||||||
async fn create_user(
|
async fn create_user(
|
||||||
|
headers: HeaderMap,
|
||||||
State(state): State<RestApiState>,
|
State(state): State<RestApiState>,
|
||||||
Json(req): Json<CreateUserRequest>,
|
Json(req): Json<CreateUserRequest>,
|
||||||
) -> Result<(StatusCode, Json<SuccessResponse<UserResponse>>), (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<(StatusCode, Json<SuccessResponse<UserResponse>>), (StatusCode, Json<ErrorResponse>)> {
|
||||||
// Connect to IAM server
|
require_admin(&state, &headers)?;
|
||||||
let config = iam_client_config(&state);
|
let client = connect_client(&state).await?;
|
||||||
let client = IamClient::connect(config).await.map_err(|e| {
|
|
||||||
error_response(
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"SERVICE_UNAVAILABLE",
|
|
||||||
&format!("Failed to connect: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Create user
|
|
||||||
let principal = client.create_user(&req.id, &req.name).await.map_err(|e| {
|
let principal = client.create_user(&req.id, &req.name).await.map_err(|e| {
|
||||||
error_response(
|
error_response(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
|
@ -339,21 +436,12 @@ async fn create_user(
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/v1/users - List users
|
|
||||||
async fn list_users(
|
async fn list_users(
|
||||||
|
headers: HeaderMap,
|
||||||
State(state): State<RestApiState>,
|
State(state): State<RestApiState>,
|
||||||
) -> Result<Json<SuccessResponse<UsersResponse>>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse<UsersResponse>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
// Connect to IAM server
|
require_admin(&state, &headers)?;
|
||||||
let config = iam_client_config(&state);
|
let client = connect_client(&state).await?;
|
||||||
let client = IamClient::connect(config).await.map_err(|e| {
|
|
||||||
error_response(
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"SERVICE_UNAVAILABLE",
|
|
||||||
&format!("Failed to connect: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// List users
|
|
||||||
let principals = client.list_users().await.map_err(|e| {
|
let principals = client.list_users().await.map_err(|e| {
|
||||||
error_response(
|
error_response(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
|
@ -363,45 +451,265 @@ async fn list_users(
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let users: Vec<UserResponse> = principals.into_iter().map(UserResponse::from).collect();
|
let users: Vec<UserResponse> = principals.into_iter().map(UserResponse::from).collect();
|
||||||
|
|
||||||
Ok(Json(SuccessResponse::new(UsersResponse { users })))
|
Ok(Json(SuccessResponse::new(UsersResponse { users })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/v1/projects - List projects (placeholder)
|
async fn get_user(
|
||||||
async fn list_projects(
|
headers: HeaderMap,
|
||||||
State(_state): State<RestApiState>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse<ProjectsResponse>>, (StatusCode, Json<ErrorResponse>)> {
|
State(state): State<RestApiState>,
|
||||||
// Project management not yet implemented in IAM
|
) -> Result<Json<SuccessResponse<UserResponse>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
// Return placeholder response
|
require_admin(&state, &headers)?;
|
||||||
Ok(Json(SuccessResponse::new(ProjectsResponse {
|
let client = connect_client(&state).await?;
|
||||||
projects: vec![ProjectResponse {
|
let principal = client
|
||||||
id: "(placeholder)".to_string(),
|
.get_principal(&PrincipalRef::user(id))
|
||||||
name: "Project management via REST not yet implemented - use gRPC IamAdminService for scope/binding management".to_string(),
|
.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(
|
async fn create_project(
|
||||||
State(_state): State<RestApiState>,
|
headers: HeaderMap,
|
||||||
|
State(state): State<RestApiState>,
|
||||||
Json(req): Json<CreateProjectRequest>,
|
Json(req): Json<CreateProjectRequest>,
|
||||||
) -> Result<(StatusCode, Json<SuccessResponse<ProjectResponse>>), (StatusCode, Json<ErrorResponse>)>
|
) -> Result<(StatusCode, Json<SuccessResponse<ProjectResponse>>), (StatusCode, Json<ErrorResponse>)>
|
||||||
{
|
{
|
||||||
// Project management not yet implemented in IAM
|
require_admin(&state, &headers)?;
|
||||||
// Return placeholder response
|
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((
|
Ok((
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::CREATED,
|
||||||
Json(SuccessResponse::new(ProjectResponse {
|
Json(SuccessResponse::new(project.into())),
|
||||||
id: req.id,
|
|
||||||
name: format!(
|
|
||||||
"Project '{}' - management via REST not yet implemented",
|
|
||||||
req.name
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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(
|
fn error_response(
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
code: &str,
|
code: &str,
|
||||||
|
|
|
||||||
|
|
@ -94,10 +94,7 @@ impl AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate an HTTP request using headers.
|
/// Authenticate an HTTP request using headers.
|
||||||
pub async fn authenticate_headers(
|
pub async fn authenticate_headers(&self, headers: &HeaderMap) -> Result<TenantContext, Status> {
|
||||||
&self,
|
|
||||||
headers: &HeaderMap,
|
|
||||||
) -> Result<TenantContext, Status> {
|
|
||||||
let token = extract_token_from_headers(headers)?;
|
let token = extract_token_from_headers(headers)?;
|
||||||
self.authenticate_token(&token).await
|
self.authenticate_token(&token).await
|
||||||
}
|
}
|
||||||
|
|
@ -110,11 +107,18 @@ impl AuthService {
|
||||||
resource: &Resource,
|
resource: &Resource,
|
||||||
) -> Result<(), Status> {
|
) -> Result<(), Status> {
|
||||||
let mut principal = match tenant.principal_kind {
|
let mut principal = match tenant.principal_kind {
|
||||||
PrincipalKind::User => Principal::new_user(&tenant.principal_id, &tenant.principal_name),
|
PrincipalKind::User => {
|
||||||
PrincipalKind::ServiceAccount => {
|
Principal::new_user(&tenant.principal_id, &tenant.principal_name)
|
||||||
Principal::new_service_account(&tenant.principal_id, &tenant.principal_name, &tenant.project_id)
|
}
|
||||||
|
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());
|
principal.org_id = Some(tenant.org_id.clone());
|
||||||
|
|
@ -135,14 +139,10 @@ impl AuthService {
|
||||||
return Ok(cached);
|
return Ok(cached);
|
||||||
}
|
}
|
||||||
|
|
||||||
let claims = self
|
let claims = self.iam_client.validate_token(token).await.map_err(|e| {
|
||||||
.iam_client
|
warn!("Token validation failed: {}", e);
|
||||||
.validate_token(token)
|
Status::unauthenticated(format!("Invalid token: {}", e))
|
||||||
.await
|
})?;
|
||||||
.map_err(|e| {
|
|
||||||
warn!("Token validation failed: {}", e);
|
|
||||||
Status::unauthenticated(format!("Invalid token: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let org_id = claims
|
let org_id = claims
|
||||||
.org_id
|
.org_id
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ pub mod backend;
|
||||||
pub mod binding_store;
|
pub mod binding_store;
|
||||||
pub mod credential_store;
|
pub mod credential_store;
|
||||||
pub mod group_store;
|
pub mod group_store;
|
||||||
|
pub mod org_store;
|
||||||
pub mod principal_store;
|
pub mod principal_store;
|
||||||
|
pub mod project_store;
|
||||||
pub mod role_store;
|
pub mod role_store;
|
||||||
pub mod token_store;
|
pub mod token_store;
|
||||||
|
|
||||||
|
|
@ -17,6 +19,8 @@ pub use backend::{Backend, BackendConfig, CasResult, KvPair, StorageBackend};
|
||||||
pub use binding_store::BindingStore;
|
pub use binding_store::BindingStore;
|
||||||
pub use credential_store::CredentialStore;
|
pub use credential_store::CredentialStore;
|
||||||
pub use group_store::GroupStore;
|
pub use group_store::GroupStore;
|
||||||
|
pub use org_store::OrgStore;
|
||||||
pub use principal_store::PrincipalStore;
|
pub use principal_store::PrincipalStore;
|
||||||
|
pub use project_store::ProjectStore;
|
||||||
pub use role_store::RoleStore;
|
pub use role_store::RoleStore;
|
||||||
pub use token_store::TokenStore;
|
pub use token_store::TokenStore;
|
||||||
|
|
|
||||||
109
iam/crates/iam-store/src/org_store.rs
Normal file
109
iam/crates/iam-store/src/org_store.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,9 +19,13 @@ mod keys {
|
||||||
pub const BY_ORG: &str = "iam/principals/by-org/";
|
pub const BY_ORG: &str = "iam/principals/by-org/";
|
||||||
|
|
||||||
/// Secondary index: by project (for service accounts)
|
/// 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/";
|
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
|
/// Secondary index: by email
|
||||||
/// Format: iam/principals/by-email/{email}
|
/// Format: iam/principals/by-email/{email}
|
||||||
pub const BY_EMAIL: &str = "iam/principals/by-email/";
|
pub const BY_EMAIL: &str = "iam/principals/by-email/";
|
||||||
|
|
@ -213,6 +217,23 @@ impl PrincipalStore {
|
||||||
Ok(principals)
|
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
|
/// Check if a principal exists
|
||||||
pub async fn exists(&self, principal_ref: &PrincipalRef) -> Result<bool> {
|
pub async fn exists(&self, principal_ref: &PrincipalRef) -> Result<bool> {
|
||||||
let key = self.make_primary_key(&principal_ref.kind, &principal_ref.id);
|
let key = self.make_primary_key(&principal_ref.kind, &principal_ref.id);
|
||||||
|
|
@ -251,9 +272,24 @@ impl PrincipalStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project index (for service accounts)
|
// Project index (for service accounts)
|
||||||
if let Some(project_id) = &principal.project_id {
|
if let (Some(org_id), Some(project_id)) = (&principal.org_id, &principal.project_id) {
|
||||||
let key = format!("{}{}/{}", keys::BY_PROJECT, project_id, principal.id);
|
let key = format!(
|
||||||
|
"{}{}/{}/{}",
|
||||||
|
keys::BY_PROJECT,
|
||||||
|
project_id,
|
||||||
|
org_id,
|
||||||
|
principal.id
|
||||||
|
);
|
||||||
self.backend.put(key.as_bytes(), &ref_bytes).await?;
|
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
|
// Email index
|
||||||
|
|
@ -289,9 +325,24 @@ impl PrincipalStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project index
|
// Project index
|
||||||
if let Some(project_id) = &principal.project_id {
|
if let (Some(org_id), Some(project_id)) = (&principal.org_id, &principal.project_id) {
|
||||||
let key = format!("{}{}/{}", keys::BY_PROJECT, project_id, principal.id);
|
let key = format!(
|
||||||
|
"{}{}/{}/{}",
|
||||||
|
keys::BY_PROJECT,
|
||||||
|
project_id,
|
||||||
|
org_id,
|
||||||
|
principal.id
|
||||||
|
);
|
||||||
self.backend.delete(key.as_bytes()).await?;
|
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
|
// Email index
|
||||||
|
|
@ -356,7 +407,8 @@ mod tests {
|
||||||
async fn test_service_account() {
|
async fn test_service_account() {
|
||||||
let store = PrincipalStore::new(test_backend());
|
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();
|
store.create(&sa).await.unwrap();
|
||||||
|
|
||||||
|
|
@ -364,6 +416,10 @@ mod tests {
|
||||||
let sas = store.list_by_project("proj-1").await.unwrap();
|
let sas = store.list_by_project("proj-1").await.unwrap();
|
||||||
assert_eq!(sas.len(), 1);
|
assert_eq!(sas.len(), 1);
|
||||||
assert_eq!(sas[0].id, "compute-agent");
|
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]
|
#[tokio::test]
|
||||||
|
|
@ -382,14 +438,15 @@ mod tests {
|
||||||
store.update(&principal, version).await.unwrap();
|
store.update(&principal, version).await.unwrap();
|
||||||
|
|
||||||
// Old indexes should be cleared
|
// 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());
|
assert!(store.list_by_org("org-1").await.unwrap().is_empty());
|
||||||
|
|
||||||
// New indexes should exist
|
// New indexes should exist
|
||||||
let fetched = store
|
let fetched = store.get_by_email("alice+new@example.com").await.unwrap();
|
||||||
.get_by_email("alice+new@example.com")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(fetched.is_some());
|
assert!(fetched.is_some());
|
||||||
assert_eq!(fetched.unwrap().id, "alice");
|
assert_eq!(fetched.unwrap().id, "alice");
|
||||||
let org2 = store.list_by_org("org-2").await.unwrap();
|
let org2 = store.list_by_org("org-2").await.unwrap();
|
||||||
|
|
@ -409,7 +466,9 @@ mod tests {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
store
|
||||||
.create(&Principal::new_service_account("sa1", "SA 1", "proj-1"))
|
.create(&Principal::new_service_account(
|
||||||
|
"sa1", "SA 1", "org-1", "proj-1",
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
|
||||||
174
iam/crates/iam-store/src/project_store.rs
Normal file
174
iam/crates/iam-store/src/project_store.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,14 @@ pub enum IamError {
|
||||||
#[error("Role not found: {0}")]
|
#[error("Role not found: {0}")]
|
||||||
RoleNotFound(String),
|
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
|
/// Binding not found
|
||||||
#[error("Binding not found: {0}")]
|
#[error("Binding not found: {0}")]
|
||||||
BindingNotFound(String),
|
BindingNotFound(String),
|
||||||
|
|
@ -84,6 +92,14 @@ pub enum IamError {
|
||||||
#[error("Role already exists: {0}")]
|
#[error("Role already exists: {0}")]
|
||||||
RoleAlreadyExists(String),
|
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
|
/// Cannot modify builtin role
|
||||||
#[error("Cannot modify builtin role: {0}")]
|
#[error("Cannot modify builtin role: {0}")]
|
||||||
CannotModifyBuiltinRole(String),
|
CannotModifyBuiltinRole(String),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ pub mod principal;
|
||||||
pub mod resource;
|
pub mod resource;
|
||||||
pub mod role;
|
pub mod role;
|
||||||
pub mod scope;
|
pub mod scope;
|
||||||
|
pub mod tenant;
|
||||||
pub mod token;
|
pub mod token;
|
||||||
|
|
||||||
pub use condition::{Condition, ConditionExpr};
|
pub use condition::{Condition, ConditionExpr};
|
||||||
|
|
@ -27,6 +28,7 @@ pub use principal::{Principal, PrincipalKind, PrincipalRef};
|
||||||
pub use resource::{Resource, ResourceRef};
|
pub use resource::{Resource, ResourceRef};
|
||||||
pub use role::{builtin as builtin_roles, Permission, Role};
|
pub use role::{builtin as builtin_roles, Permission, Role};
|
||||||
pub use scope::Scope;
|
pub use scope::Scope;
|
||||||
|
pub use tenant::{Organization, Project};
|
||||||
pub use token::{
|
pub use token::{
|
||||||
AuthMethod, InternalTokenClaims, JwtClaims, TokenMetadata, TokenType, TokenValidationError,
|
AuthMethod, InternalTokenClaims, JwtClaims, TokenMetadata, TokenType, TokenValidationError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -104,13 +104,14 @@ impl Principal {
|
||||||
pub fn new_service_account(
|
pub fn new_service_account(
|
||||||
id: impl Into<String>,
|
id: impl Into<String>,
|
||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
|
org_id: impl Into<String>,
|
||||||
project_id: impl Into<String>,
|
project_id: impl Into<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
kind: PrincipalKind::ServiceAccount,
|
kind: PrincipalKind::ServiceAccount,
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
org_id: None,
|
org_id: Some(org_id.into()),
|
||||||
project_id: Some(project_id.into()),
|
project_id: Some(project_id.into()),
|
||||||
email: None,
|
email: None,
|
||||||
oidc_sub: None,
|
oidc_sub: None,
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// Get the parent scope, if any
|
||||||
///
|
///
|
||||||
/// - System has no parent
|
/// - 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 {
|
impl fmt::Display for Scope {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
|
|
@ -490,4 +551,16 @@ mod tests {
|
||||||
Some("proj1")
|
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")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
93
iam/crates/iam-types/src/tenant.rs
Normal file
93
iam/crates/iam-types/src/tenant.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -255,6 +255,20 @@ service IamAdmin {
|
||||||
rpc DeletePrincipal(DeletePrincipalRequest) returns (DeletePrincipalResponse);
|
rpc DeletePrincipal(DeletePrincipalRequest) returns (DeletePrincipalResponse);
|
||||||
rpc ListPrincipals(ListPrincipalsRequest) returns (ListPrincipalsResponse);
|
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
|
// Role management
|
||||||
rpc CreateRole(CreateRoleRequest) returns (Role);
|
rpc CreateRole(CreateRoleRequest) returns (Role);
|
||||||
rpc GetRole(GetRoleRequest) returns (Role);
|
rpc GetRole(GetRoleRequest) returns (Role);
|
||||||
|
|
@ -268,6 +282,12 @@ service IamAdmin {
|
||||||
rpc UpdateBinding(UpdateBindingRequest) returns (PolicyBinding);
|
rpc UpdateBinding(UpdateBindingRequest) returns (PolicyBinding);
|
||||||
rpc DeleteBinding(DeleteBindingRequest) returns (DeleteBindingResponse);
|
rpc DeleteBinding(DeleteBindingRequest) returns (DeleteBindingResponse);
|
||||||
rpc ListBindings(ListBindingsRequest) returns (ListBindingsResponse);
|
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;
|
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
|
// Role Messages
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
@ -466,6 +575,50 @@ message ListBindingsResponse {
|
||||||
string next_page_token = 2;
|
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
|
// Common Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -497,6 +650,27 @@ message Principal {
|
||||||
bool enabled = 12;
|
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 {
|
message ResourceRef {
|
||||||
// Resource kind (e.g., "instance", "volume")
|
// Resource kind (e.g., "instance", "volume")
|
||||||
string kind = 1;
|
string kind = 1;
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ use std::sync::Arc;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use iam_client::client::IamClientConfig;
|
use iam_client::client::IamClientConfig;
|
||||||
use iam_client::IamClient;
|
use iam_client::IamClient;
|
||||||
use iam_types::{PolicyBinding, Principal, PrincipalRef, Scope};
|
|
||||||
pub use iam_service_auth::AuthService;
|
pub use iam_service_auth::AuthService;
|
||||||
|
use iam_types::{PolicyBinding, Principal, PrincipalRef, Scope};
|
||||||
use tonic::metadata::MetadataValue;
|
use tonic::metadata::MetadataValue;
|
||||||
use tonic::{Request, Status};
|
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.
|
/// gRPC interceptor that authenticates requests and injects tenant context.
|
||||||
pub async fn auth_interceptor(
|
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_ref = PrincipalRef::service_account(principal_id);
|
||||||
let principal = match client.get_principal(&principal_ref).await? {
|
let principal = match client.get_principal(&principal_ref).await? {
|
||||||
Some(existing) => existing,
|
Some(existing) => existing,
|
||||||
None => client
|
None => {
|
||||||
.create_service_account(principal_id, principal_id, project_id)
|
client
|
||||||
.await?,
|
.create_service_account(principal_id, principal_id, org_id, project_id)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ensure_project_admin_binding(&client, &principal, org_id, project_id).await?;
|
ensure_project_admin_binding(&client, &principal, org_id, project_id).await?;
|
||||||
|
|
||||||
let scope = Scope::project(project_id, org_id);
|
let scope = Scope::project(project_id, org_id);
|
||||||
client
|
client
|
||||||
.issue_token(&principal, vec!["roles/ProjectAdmin".to_string()], scope, 3600)
|
.issue_token(
|
||||||
|
&principal,
|
||||||
|
vec!["roles/ProjectAdmin".to_string()],
|
||||||
|
scope,
|
||||||
|
3600,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
@ -70,9 +79,9 @@ async fn ensure_project_admin_binding(
|
||||||
.list_bindings_for_principal(&principal.to_ref())
|
.list_bindings_for_principal(&principal.to_ref())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let already_bound = bindings.iter().any(|binding| {
|
let already_bound = bindings
|
||||||
binding.role_ref == "roles/ProjectAdmin" && binding.scope == scope
|
.iter()
|
||||||
});
|
.any(|binding| binding.role_ref == "roles/ProjectAdmin" && binding.scope == scope);
|
||||||
if already_bound {
|
if already_bound {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
//! AWS Signature Version 4 authentication for S3 API
|
//! AWS Signature Version 4 authentication for S3 API
|
||||||
//!
|
//!
|
||||||
//! Implements simplified SigV4 authentication compatible with AWS S3 SDKs and aws-cli.
|
//! Implements simplified SigV4 authentication compatible with AWS S3 SDKs and aws-cli.
|
||||||
//! Integrates with IAM for access key validation.
|
//! Integrates with IAM for access key validation.
|
||||||
|
|
||||||
|
use crate::tenant::TenantContext;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::{Body, Bytes},
|
body::{Body, Bytes},
|
||||||
extract::Request,
|
extract::Request,
|
||||||
|
|
@ -10,17 +11,16 @@ use axum::{
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use crate::tenant::TenantContext;
|
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use iam_api::proto::{iam_credential_client::IamCredentialClient, GetSecretKeyRequest};
|
use iam_api::proto::{iam_credential_client::IamCredentialClient, GetSecretKeyRequest};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration as StdDuration, Instant};
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
use tonic::transport::Channel;
|
use tonic::{transport::Channel, Request as TonicRequest};
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
use std::time::{Duration as StdDuration, Instant};
|
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
const DEFAULT_MAX_AUTH_BODY_BYTES: usize = 1024 * 1024 * 1024;
|
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") {
|
if let Ok(creds_str) = std::env::var("S3_CREDENTIALS") {
|
||||||
for pair in creds_str.split(',') {
|
for pair in creds_str.split(',') {
|
||||||
if let Some((access_key, secret_key)) = pair.split_once(':') {
|
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 {
|
} else {
|
||||||
warn!("Invalid S3_CREDENTIALS format for pair: {}", pair);
|
warn!("Invalid S3_CREDENTIALS format for pair: {}", pair);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !credentials.is_empty() {
|
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 {
|
for attempt in 0..2 {
|
||||||
let grpc_channel = Self::grpc_channel(endpoint, channel).await?;
|
let grpc_channel = Self::grpc_channel(endpoint, channel).await?;
|
||||||
let mut client = IamCredentialClient::new(grpc_channel);
|
let mut client = IamCredentialClient::new(grpc_channel);
|
||||||
match client
|
let mut request = TonicRequest::new(GetSecretKeyRequest {
|
||||||
.get_secret_key(GetSecretKeyRequest {
|
access_key_id: access_key_id.to_string(),
|
||||||
access_key_id: access_key_id.to_string(),
|
});
|
||||||
})
|
if let Some(token) = iam_admin_token() {
|
||||||
.await
|
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),
|
Ok(response) => return Ok(response),
|
||||||
Err(status)
|
Err(status)
|
||||||
if attempt == 0
|
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 {
|
impl AuthState {
|
||||||
/// Create new auth state with IAM integration
|
/// Create new auth state with IAM integration
|
||||||
pub fn new(iam_endpoint: Option<String>) -> Self {
|
pub fn new(iam_endpoint: Option<String>) -> Self {
|
||||||
|
|
@ -311,8 +326,7 @@ impl AuthState {
|
||||||
.unwrap_or_else(|_| "true".to_string())
|
.unwrap_or_else(|_| "true".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap_or(true),
|
.unwrap_or(true),
|
||||||
aws_region: std::env::var("AWS_REGION")
|
aws_region: std::env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string()), // Default S3 region
|
||||||
.unwrap_or_else(|_| "us-east-1".to_string()), // Default S3 region
|
|
||||||
aws_service: "s3".to_string(),
|
aws_service: "s3".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -431,7 +445,13 @@ pub async fn sigv4_auth_middleware(
|
||||||
let (parts, body) = request.into_parts();
|
let (parts, body) = request.into_parts();
|
||||||
let body_bytes = match axum::body::to_bytes(body, max_body_bytes).await {
|
let body_bytes = match axum::body::to_bytes(body, max_body_bytes).await {
|
||||||
Ok(b) => b,
|
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()));
|
request = Request::from_parts(parts, Body::from(body_bytes.clone()));
|
||||||
|
|
@ -448,16 +468,13 @@ pub async fn sigv4_auth_middleware(
|
||||||
Bytes::new()
|
Bytes::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
let (canonical_request, hashed_payload) = match build_canonical_request(
|
let (canonical_request, hashed_payload) =
|
||||||
&method,
|
match build_canonical_request(&method, &uri, &headers, &body_bytes, &signed_headers_str) {
|
||||||
&uri,
|
Ok(val) => val,
|
||||||
&headers,
|
Err(e) => {
|
||||||
&body_bytes,
|
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "SignatureError", &e)
|
||||||
&signed_headers_str,
|
}
|
||||||
) {
|
};
|
||||||
Ok(val) => val,
|
|
||||||
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, "SignatureError", &e),
|
|
||||||
};
|
|
||||||
debug!(
|
debug!(
|
||||||
method = %method,
|
method = %method,
|
||||||
uri = %uri,
|
uri = %uri,
|
||||||
|
|
@ -468,11 +485,7 @@ pub async fn sigv4_auth_middleware(
|
||||||
"SigV4 Canonical Request generated"
|
"SigV4 Canonical Request generated"
|
||||||
);
|
);
|
||||||
|
|
||||||
let string_to_sign = build_string_to_sign(
|
let string_to_sign = build_string_to_sign(amz_date, &credential_scope, &canonical_request);
|
||||||
amz_date,
|
|
||||||
&credential_scope,
|
|
||||||
&canonical_request,
|
|
||||||
);
|
|
||||||
debug!(
|
debug!(
|
||||||
amz_date = %amz_date,
|
amz_date = %amz_date,
|
||||||
credential_scope = %credential_scope,
|
credential_scope = %credential_scope,
|
||||||
|
|
@ -559,7 +572,12 @@ fn parse_auth_header(auth_header: &str) -> Result<(String, String, String, Strin
|
||||||
.get("Signature")
|
.get("Signature")
|
||||||
.ok_or("Signature not found in Authorization header")?;
|
.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.
|
/// Compute the full AWS Signature Version 4.
|
||||||
|
|
@ -576,19 +594,10 @@ fn compute_sigv4_signature(
|
||||||
aws_region: &str,
|
aws_region: &str,
|
||||||
aws_service: &str,
|
aws_service: &str,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let (canonical_request, _hashed_payload) = build_canonical_request(
|
let (canonical_request, _hashed_payload) =
|
||||||
method,
|
build_canonical_request(method, uri, headers, body_bytes, signed_headers_str)?;
|
||||||
uri,
|
|
||||||
headers,
|
|
||||||
body_bytes,
|
|
||||||
signed_headers_str,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let string_to_sign = build_string_to_sign(
|
let string_to_sign = build_string_to_sign(amz_date, credential_scope, &canonical_request);
|
||||||
amz_date,
|
|
||||||
credential_scope,
|
|
||||||
&canonical_request,
|
|
||||||
);
|
|
||||||
|
|
||||||
let signing_key = get_signing_key(secret_key, amz_date, aws_region, aws_service)?;
|
let signing_key = get_signing_key(secret_key, amz_date, aws_region, aws_service)?;
|
||||||
|
|
||||||
|
|
@ -612,9 +621,10 @@ fn build_canonical_request(
|
||||||
|
|
||||||
// Canonical Query String
|
// Canonical Query String
|
||||||
let canonical_query_string = if uri_parts.len() > 1 {
|
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)> =
|
||||||
.into_owned()
|
form_urlencoded::parse(uri_parts[1].as_bytes())
|
||||||
.collect();
|
.into_owned()
|
||||||
|
.collect();
|
||||||
query_params.sort_by(|(k1, _), (k2, _)| k1.cmp(k2));
|
query_params.sort_by(|(k1, _), (k2, _)| k1.cmp(k2));
|
||||||
query_params
|
query_params
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -638,10 +648,17 @@ fn build_canonical_request(
|
||||||
let value_str = header_value
|
let value_str = header_value
|
||||||
.to_str()
|
.to_str()
|
||||||
.map_err(|_| format!("Invalid header value for {}", header_name))?;
|
.map_err(|_| format!("Invalid header value for {}", header_name))?;
|
||||||
canonical_headers.push_str(&format!("{}:{}
|
canonical_headers.push_str(&format!(
|
||||||
", header_name, value_str.trim()));
|
"{}:{}
|
||||||
|
",
|
||||||
|
header_name,
|
||||||
|
value_str.trim()
|
||||||
|
));
|
||||||
} else {
|
} 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>
|
<Code>{}</Code>
|
||||||
<Message>{}</Message>
|
<Message>{}</Message>
|
||||||
</Error>"###,
|
</Error>"###,
|
||||||
code,
|
code, message
|
||||||
message
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
|
|
@ -780,11 +796,14 @@ mod tests {
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::{atomic::{AtomicUsize, Ordering}, Mutex};
|
use std::sync::{
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
Mutex,
|
||||||
|
};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::time::{sleep, Duration};
|
use tokio::time::{sleep, Duration};
|
||||||
use tonic::{Request as TonicRequest, Response as TonicResponse, Status};
|
|
||||||
use tonic::transport::Server;
|
use tonic::transport::Server;
|
||||||
|
use tonic::{Request as TonicRequest, Response as TonicResponse, Status};
|
||||||
|
|
||||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
|
@ -835,7 +854,9 @@ mod tests {
|
||||||
&self,
|
&self,
|
||||||
_request: TonicRequest<RevokeCredentialRequest>,
|
_request: TonicRequest<RevokeCredentialRequest>,
|
||||||
) -> Result<TonicResponse<RevokeCredentialResponse>, Status> {
|
) -> Result<TonicResponse<RevokeCredentialResponse>, Status> {
|
||||||
Ok(TonicResponse::new(RevokeCredentialResponse { success: true }))
|
Ok(TonicResponse::new(RevokeCredentialResponse {
|
||||||
|
success: true,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -880,8 +901,9 @@ mod tests {
|
||||||
let key = b"key";
|
let key = b"key";
|
||||||
let data = "data";
|
let data = "data";
|
||||||
// Verified with: echo -n "data" | openssl dgst -sha256 -mac hmac -macopt key:"key"
|
// Verified with: echo -n "data" | openssl dgst -sha256 -mac hmac -macopt key:"key"
|
||||||
let expected = hex::decode("5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0")
|
let expected =
|
||||||
.unwrap();
|
hex::decode("5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0")
|
||||||
|
.unwrap();
|
||||||
assert_eq!(hmac_sha256(key, data).unwrap(), expected);
|
assert_eq!(hmac_sha256(key, data).unwrap(), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -911,11 +933,14 @@ mod tests {
|
||||||
assert_eq!(url_encode_path("/foo bar/baz"), "/foo%20bar/baz");
|
assert_eq!(url_encode_path("/foo bar/baz"), "/foo%20bar/baz");
|
||||||
assert_eq!(url_encode_path("/"), "/");
|
assert_eq!(url_encode_path("/"), "/");
|
||||||
assert_eq!(url_encode_path(""), "/"); // Empty path should be normalized to /
|
assert_eq!(url_encode_path(""), "/"); // Empty path should be normalized to /
|
||||||
// Test special characters that should be encoded
|
// Test special characters that should be encoded
|
||||||
assert_eq!(url_encode_path("/my+bucket"), "/my%2Bbucket");
|
assert_eq!(url_encode_path("/my+bucket"), "/my%2Bbucket");
|
||||||
assert_eq!(url_encode_path("/my=bucket"), "/my%3Dbucket");
|
assert_eq!(url_encode_path("/my=bucket"), "/my%3Dbucket");
|
||||||
// Test unreserved characters that should NOT be encoded
|
// 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]
|
#[tokio::test]
|
||||||
|
|
@ -930,8 +955,7 @@ mod tests {
|
||||||
let signed_headers = "content-type;host;x-amz-date";
|
let signed_headers = "content-type;host;x-amz-date";
|
||||||
|
|
||||||
let (canonical_request, hashed_payload) =
|
let (canonical_request, hashed_payload) =
|
||||||
build_canonical_request(method, uri, &headers, &body, signed_headers)
|
build_canonical_request(method, uri, &headers, &body, signed_headers).unwrap();
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Body hash verified with: echo -n "some_body" | sha256sum
|
// Body hash verified with: echo -n "some_body" | sha256sum
|
||||||
let expected_body_hash = "fed42376ceefa4bb65ead687ec9738f6b2329fd78870aaf797bd7194da4228d3";
|
let expected_body_hash = "fed42376ceefa4bb65ead687ec9738f6b2329fd78870aaf797bd7194da4228d3";
|
||||||
|
|
@ -950,13 +974,15 @@ mod tests {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert("host", HeaderValue::from_static("example.com"));
|
headers.insert("host", HeaderValue::from_static("example.com"));
|
||||||
headers.insert("x-amz-date", HeaderValue::from_static("20231201T000000Z"));
|
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 body = Bytes::from("different-body");
|
||||||
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
|
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
|
||||||
|
|
||||||
let (canonical_request, hashed_payload) =
|
let (canonical_request, hashed_payload) =
|
||||||
build_canonical_request(method, uri, &headers, &body, signed_headers)
|
build_canonical_request(method, uri, &headers, &body, signed_headers).unwrap();
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(canonical_request.ends_with("\nsigned-payload-hash"));
|
assert!(canonical_request.ends_with("\nsigned-payload-hash"));
|
||||||
assert_eq!(hashed_payload, "signed-payload-hash");
|
assert_eq!(hashed_payload, "signed-payload-hash");
|
||||||
|
|
@ -980,9 +1006,7 @@ mod tests {
|
||||||
|
|
||||||
let expected_string_to_sign = format!(
|
let expected_string_to_sign = format!(
|
||||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||||
amz_date,
|
amz_date, credential_scope, hashed_canonical_request
|
||||||
credential_scope,
|
|
||||||
hashed_canonical_request
|
|
||||||
);
|
);
|
||||||
assert_eq!(string_to_sign, expected_string_to_sign);
|
assert_eq!(string_to_sign, expected_string_to_sign);
|
||||||
}
|
}
|
||||||
|
|
@ -1015,7 +1039,10 @@ mod tests {
|
||||||
let credentials = client.env_credentials().unwrap();
|
let credentials = client.env_credentials().unwrap();
|
||||||
|
|
||||||
assert_eq!(credentials.len(), 1);
|
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_ACCESS_KEY_ID");
|
||||||
std::env::remove_var("S3_SECRET_KEY");
|
std::env::remove_var("S3_SECRET_KEY");
|
||||||
|
|
@ -1071,7 +1098,10 @@ mod tests {
|
||||||
let signed_headers = "host;x-amz-date";
|
let signed_headers = "host;x-amz-date";
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
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"));
|
headers.insert("x-amz-date", HeaderValue::from_static("20150830T123600Z"));
|
||||||
|
|
||||||
let body = Bytes::new(); // Empty body for GET
|
let body = Bytes::new(); // Empty body for GET
|
||||||
|
|
@ -1191,7 +1221,10 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Signatures MUST be different
|
// 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]
|
#[test]
|
||||||
|
|
@ -1341,7 +1374,10 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Signatures MUST be different
|
// 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]
|
#[test]
|
||||||
|
|
@ -1389,7 +1425,10 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Signatures MUST be different
|
// 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]
|
#[test]
|
||||||
|
|
@ -1404,7 +1443,10 @@ mod tests {
|
||||||
let credentials = client.env_credentials().unwrap();
|
let credentials = client.env_credentials().unwrap();
|
||||||
|
|
||||||
// Known key should be found in credentials map
|
// 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
|
// Unknown key should not be found
|
||||||
assert_eq!(credentials.get("unknown_key"), None);
|
assert_eq!(credentials.get("unknown_key"), None);
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@ in
|
||||||
|
|
||||||
iamPort = lib.mkOption {
|
iamPort = lib.mkOption {
|
||||||
type = lib.types.port;
|
type = lib.types.port;
|
||||||
default = 8080;
|
default = config.services.iam.httpPort;
|
||||||
description = "IAM API port";
|
description = "IAM API port";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -374,7 +374,12 @@ in
|
||||||
# Check if admin user exists
|
# Check if admin user exists
|
||||||
log "INFO" "Checking for existing admin user"
|
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
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
log "INFO" "Admin user already exists"
|
log "INFO" "Admin user already exists"
|
||||||
|
|
@ -383,8 +388,22 @@ in
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# TODO: Create admin user (requires IAM API implementation)
|
log "INFO" "Creating bootstrap admin user"
|
||||||
log "WARN" "Admin user creation not yet implemented (waiting for IAM API)"
|
|
||||||
|
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
|
# Mark as initialized for now
|
||||||
mkdir -p /var/lib/first-boot-automation
|
mkdir -p /var/lib/first-boot-automation
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,12 @@ in
|
||||||
description = "Data directory for iam";
|
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 {
|
settings = lib.mkOption {
|
||||||
type = lib.types.attrs;
|
type = lib.types.attrs;
|
||||||
default = {};
|
default = {};
|
||||||
|
|
@ -127,6 +133,9 @@ in
|
||||||
(lib.mkIf (cfg.storeBackend == "memory") {
|
(lib.mkIf (cfg.storeBackend == "memory") {
|
||||||
IAM_ALLOW_MEMORY_BACKEND = "1";
|
IAM_ALLOW_MEMORY_BACKEND = "1";
|
||||||
})
|
})
|
||||||
|
(lib.mkIf (cfg.adminToken != null) {
|
||||||
|
IAM_ADMIN_TOKEN = cfg.adminToken;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
|
|
|
||||||
|
|
@ -783,7 +783,7 @@ impl ArtifactStore {
|
||||||
Some(principal) => principal,
|
Some(principal) => principal,
|
||||||
None => self
|
None => self
|
||||||
.iam_client
|
.iam_client
|
||||||
.create_service_account(&principal_id, &principal_id, project_id)
|
.create_service_account(&principal_id, &principal_id, org_id, project_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
Status::unavailable(format!("failed to create service account: {e}"))
|
Status::unavailable(format!("failed to create service account: {e}"))
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,16 @@ use crate::volume_manager::VolumeManager;
|
||||||
use crate::watcher::StateSink;
|
use crate::watcher::StateSink;
|
||||||
use creditservice_client::{Client as CreditServiceClient, ResourceType as CreditResourceType};
|
use creditservice_client::{Client as CreditServiceClient, ResourceType as CreditResourceType};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use iam_client::IamClient;
|
|
||||||
use iam_client::client::IamClientConfig;
|
use iam_client::client::IamClientConfig;
|
||||||
|
use iam_client::IamClient;
|
||||||
use iam_service_auth::{
|
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 iam_types::{PolicyBinding, PrincipalRef, Scope};
|
||||||
use plasmavmc_api::proto::{
|
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,
|
Architecture as ProtoArchitecture, AttachDiskRequest, AttachNicRequest, CephRbdBacking,
|
||||||
CordonNodeRequest, CreateImageRequest, CreateVmRequest, CreateVolumeRequest,
|
CordonNodeRequest, CreateImageRequest, CreateVmRequest, CreateVolumeRequest,
|
||||||
DeleteImageRequest, DeleteVmRequest, DeleteVolumeRequest, DetachDiskRequest, DetachNicRequest,
|
DeleteImageRequest, DeleteVmRequest, DeleteVolumeRequest, DetachDiskRequest, DetachNicRequest,
|
||||||
|
|
@ -31,9 +34,6 @@ use plasmavmc_api::proto::{
|
||||||
VmState as ProtoVmState, VmStatus as ProtoVmStatus, Volume as ProtoVolume,
|
VmState as ProtoVmState, VmStatus as ProtoVmStatus, Volume as ProtoVolume,
|
||||||
VolumeBacking as ProtoVolumeBacking, VolumeDriverKind as ProtoVolumeDriverKind,
|
VolumeBacking as ProtoVolumeBacking, VolumeDriverKind as ProtoVolumeDriverKind,
|
||||||
VolumeFormat as ProtoVolumeFormat, VolumeStatus as ProtoVolumeStatus, WatchVmRequest,
|
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_hypervisor::HypervisorRegistry;
|
||||||
use plasmavmc_types::{
|
use plasmavmc_types::{
|
||||||
|
|
@ -374,7 +374,7 @@ impl VmServiceImpl {
|
||||||
Some(principal) => principal,
|
Some(principal) => principal,
|
||||||
None => self
|
None => self
|
||||||
.iam_client
|
.iam_client
|
||||||
.create_service_account(&principal_id, &principal_id, project_id)
|
.create_service_account(&principal_id, &principal_id, org_id, project_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
Status::unavailable(format!("IAM service account create failed: {e}"))
|
Status::unavailable(format!("IAM service account create failed: {e}"))
|
||||||
|
|
@ -1480,7 +1480,9 @@ impl VmServiceImpl {
|
||||||
return Ok(());
|
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?;
|
let mut client = PrismNETClient::new(endpoint.clone(), auth_token).await?;
|
||||||
|
|
||||||
for net_spec in &mut vm.spec.network {
|
for net_spec in &mut vm.spec.network {
|
||||||
|
|
@ -1532,7 +1534,9 @@ impl VmServiceImpl {
|
||||||
return Ok(());
|
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?;
|
let mut client = PrismNETClient::new(endpoint.clone(), auth_token).await?;
|
||||||
|
|
||||||
for net_spec in &vm.spec.network {
|
for net_spec in &vm.spec.network {
|
||||||
|
|
@ -1936,7 +1940,10 @@ mod tests {
|
||||||
PRISMNET_VM_DEVICE_TYPE,
|
PRISMNET_VM_DEVICE_TYPE,
|
||||||
prismnet_api::proto::DeviceType::Vm as i32
|
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;
|
let mut client = credit_svc.write().await;
|
||||||
if let Err(release_err) = client
|
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
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("Failed to release reservation {}: {}", res_id, release_err);
|
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;
|
let mut client = credit_svc.write().await;
|
||||||
if let Err(release_err) = client
|
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
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("Failed to release reservation {}: {}", res_id, release_err);
|
tracing::warn!("Failed to release reservation {}: {}", res_id, release_err);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue