photoncloud-monorepo/iam/crates/iam-api/src/credential_service.rs

575 lines
20 KiB
Rust

use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit, Nonce};
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use rand_core::{OsRng, RngCore};
use tonic::{Request, Response, Status};
use iam_store::{CredentialStore, PrincipalStore};
use iam_types::{
Argon2Params, CredentialRecord, PrincipalKind as TypesPrincipalKind, PrincipalRef,
};
use crate::proto::{
iam_credential_server::IamCredential, CreateS3CredentialRequest, CreateS3CredentialResponse,
Credential, GetSecretKeyRequest, GetSecretKeyResponse, ListCredentialsRequest,
ListCredentialsResponse, PrincipalKind, RevokeCredentialRequest, RevokeCredentialResponse,
};
fn now_ts() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub struct IamCredentialService {
store: Arc<CredentialStore>,
principal_store: Arc<PrincipalStore>,
cipher: Aes256Gcm,
key_id: String,
admin_token: Option<String>,
}
impl IamCredentialService {
pub fn new(
store: Arc<CredentialStore>,
principal_store: Arc<PrincipalStore>,
master_key: &[u8],
key_id: &str,
admin_token: Option<String>,
) -> Result<Self, Status> {
if master_key.len() != 32 {
return Err(Status::failed_precondition(
"IAM_CRED_MASTER_KEY must be 32 bytes",
));
}
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(master_key));
Ok(Self {
store,
principal_store,
cipher,
key_id: key_id.to_string(),
admin_token,
})
}
fn generate_secret() -> (String, Vec<u8>) {
let raw = uuid::Uuid::new_v4().as_bytes().to_vec();
let secret_b64 = STANDARD.encode(&raw);
(secret_b64, raw)
}
fn hash_secret(raw: &[u8]) -> (String, Argon2Params) {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(raw, &salt)
.expect("argon2 hash")
.to_string();
let params = Argon2Params {
m_cost_kib: argon2.params().m_cost(),
t_cost: argon2.params().t_cost(),
p_cost: argon2.params().p_cost(),
salt_b64: salt.to_string(),
};
(hash, params)
}
fn encrypt_secret(&self, raw: &[u8]) -> Result<String, Status> {
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = self
.cipher
.encrypt(nonce, raw)
.map_err(|e| Status::internal(format!("encrypt secret: {}", e)))?;
let mut combined = nonce_bytes.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(STANDARD.encode(combined))
}
fn decrypt_secret(&self, enc_b64: &str) -> Result<Vec<u8>, Status> {
let data = STANDARD
.decode(enc_b64)
.map_err(|e| Status::internal(format!("invalid b64: {}", e)))?;
if data.len() < 12 {
return Err(Status::internal("ciphertext too short"));
}
let (nonce_bytes, ct) = data.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
self.cipher
.decrypt(nonce, ct)
.map_err(|e| Status::internal(format!("decrypt failed: {}", e)))
}
fn admin_token_valid(metadata: &tonic::metadata::MetadataMap, token: &str) -> bool {
if let Some(value) = metadata.get("x-iam-admin-token") {
if let Ok(raw) = value.to_str() {
if raw.trim() == token {
return true;
}
}
}
if let Some(value) = metadata.get("authorization") {
if let Ok(raw) = value.to_str() {
let raw = raw.trim();
if let Some(rest) = raw.strip_prefix("Bearer ") {
return rest.trim() == token;
}
if let Some(rest) = raw.strip_prefix("bearer ") {
return rest.trim() == token;
}
}
}
false
}
fn require_admin_token<T>(&self, request: &Request<T>) -> Result<(), Status> {
if let Some(token) = self.admin_token.as_deref() {
if !Self::admin_token_valid(request.metadata(), token) {
return Err(Status::unauthenticated(
"missing or invalid IAM admin token",
));
}
}
Ok(())
}
}
fn map_principal_kind(kind: i32) -> Result<TypesPrincipalKind, Status> {
match PrincipalKind::try_from(kind).unwrap_or(PrincipalKind::Unspecified) {
PrincipalKind::User => Ok(TypesPrincipalKind::User),
PrincipalKind::ServiceAccount => Ok(TypesPrincipalKind::ServiceAccount),
PrincipalKind::Group => Ok(TypesPrincipalKind::Group),
PrincipalKind::Unspecified => Err(Status::invalid_argument("principal_kind is required")),
}
}
#[tonic::async_trait]
impl IamCredential for IamCredentialService {
async fn create_s3_credential(
&self,
request: Request<CreateS3CredentialRequest>,
) -> Result<Response<CreateS3CredentialResponse>, Status> {
self.require_admin_token(&request)?;
let req = request.into_inner();
let now = now_ts();
let principal_kind = map_principal_kind(req.principal_kind)?;
let principal_ref = PrincipalRef::new(principal_kind.clone(), &req.principal_id);
let principal = self
.principal_store
.get(&principal_ref)
.await
.map_err(|e| Status::internal(format!("principal lookup failed: {}", e)))?
.ok_or_else(|| Status::not_found("principal not found"))?;
if principal.org_id != req.org_id || principal.project_id != req.project_id {
return Err(Status::invalid_argument(
"credential tenant does not match principal tenant",
));
}
let (secret_b64, raw_secret) = Self::generate_secret();
let (hash, kdf) = Self::hash_secret(&raw_secret);
let secret_enc = self.encrypt_secret(&raw_secret)?;
let access_key_id = format!("ak_{}", uuid::Uuid::new_v4());
let record = CredentialRecord {
access_key_id: access_key_id.clone(),
principal_id: req.principal_id.clone(),
principal_kind,
org_id: req.org_id.clone(),
project_id: req.project_id.clone(),
created_at: now,
expires_at: req.expires_at,
revoked: false,
description: if req.description.is_empty() {
None
} else {
Some(req.description)
},
secret_hash: hash,
secret_enc,
key_id: self.key_id.clone(),
version: 1,
kdf,
};
self.store
.put(&record)
.await
.map_err(|e| Status::internal(format!("store credential: {}", e)))?;
Ok(Response::new(CreateS3CredentialResponse {
access_key_id,
secret_key: secret_b64,
created_at: now,
expires_at: req.expires_at,
}))
}
async fn get_secret_key(
&self,
request: Request<GetSecretKeyRequest>,
) -> Result<Response<GetSecretKeyResponse>, Status> {
self.require_admin_token(&request)?;
let req = request.into_inner();
let record = match self.store.get(&req.access_key_id).await {
Ok(Some((rec, _))) => rec,
Ok(None) => return Err(Status::not_found("access key not found")),
Err(e) => {
return Err(Status::internal(format!(
"failed to load credential: {}",
e
)))
}
};
if record.revoked {
return Err(Status::permission_denied("access key revoked"));
}
if let Some(exp) = record.expires_at {
if now_ts() > exp {
return Err(Status::permission_denied("access key expired"));
}
}
let secret = self.decrypt_secret(&record.secret_enc)?;
Ok(Response::new(GetSecretKeyResponse {
secret_key: STANDARD.encode(secret),
principal_id: record.principal_id,
expires_at: record.expires_at,
org_id: record.org_id,
project_id: record.project_id,
principal_kind: match record.principal_kind {
TypesPrincipalKind::User => PrincipalKind::User as i32,
TypesPrincipalKind::ServiceAccount => PrincipalKind::ServiceAccount as i32,
TypesPrincipalKind::Group => PrincipalKind::Group as i32,
},
}))
}
async fn list_credentials(
&self,
request: Request<ListCredentialsRequest>,
) -> Result<Response<ListCredentialsResponse>, Status> {
self.require_admin_token(&request)?;
let req = request.into_inner();
let items = self
.store
.list_for_principal(&req.principal_id, 1000)
.await
.map_err(|e| Status::internal(format!("list credentials: {}", e)))?;
let creds: Vec<Credential> = items
.into_iter()
.map(|c| Credential {
access_key_id: c.access_key_id,
principal_id: c.principal_id,
created_at: c.created_at,
expires_at: c.expires_at,
revoked: c.revoked,
description: c.description.unwrap_or_default(),
org_id: c.org_id,
project_id: c.project_id,
principal_kind: match c.principal_kind {
TypesPrincipalKind::User => PrincipalKind::User as i32,
TypesPrincipalKind::ServiceAccount => PrincipalKind::ServiceAccount as i32,
TypesPrincipalKind::Group => PrincipalKind::Group as i32,
},
})
.collect();
Ok(Response::new(ListCredentialsResponse {
credentials: creds,
}))
}
async fn revoke_credential(
&self,
request: Request<RevokeCredentialRequest>,
) -> Result<Response<RevokeCredentialResponse>, Status> {
self.require_admin_token(&request)?;
let req = request.into_inner();
let revoked = self
.store
.revoke(&req.access_key_id)
.await
.map_err(|e| Status::internal(format!("revoke: {}", e)))?;
Ok(Response::new(RevokeCredentialResponse { success: revoked }))
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::STANDARD;
use iam_store::Backend;
use iam_types::Principal;
fn test_service() -> (IamCredentialService, Arc<PrincipalStore>) {
let backend = Arc::new(Backend::memory());
let store = Arc::new(CredentialStore::new(backend.clone()));
let principal_store = Arc::new(PrincipalStore::new(backend));
let master_key = [0x42u8; 32];
(
IamCredentialService::new(
store,
principal_store.clone(),
&master_key,
"test-key",
None,
)
.unwrap(),
principal_store,
)
}
async fn seed_service_account(
principal_store: &PrincipalStore,
principal_id: &str,
org_id: &str,
project_id: &str,
) {
let principal =
Principal::new_service_account(principal_id, principal_id, org_id, project_id);
principal_store.create(&principal).await.unwrap();
}
#[tokio::test]
async fn create_and_get_roundtrip() {
let (svc, principal_store) = test_service();
seed_service_account(&principal_store, "p1", "org-a", "project-a").await;
let create = svc
.create_s3_credential(Request::new(CreateS3CredentialRequest {
principal_id: "p1".into(),
description: "".into(),
expires_at: None,
org_id: Some("org-a".into()),
project_id: Some("project-a".into()),
principal_kind: PrincipalKind::ServiceAccount as i32,
}))
.await
.unwrap()
.into_inner();
let get = svc
.get_secret_key(Request::new(GetSecretKeyRequest {
access_key_id: create.access_key_id.clone(),
}))
.await
.unwrap()
.into_inner();
let orig = STANDARD.decode(create.secret_key).unwrap();
let fetched = STANDARD.decode(get.secret_key).unwrap();
assert_eq!(orig, fetched);
assert_eq!(get.principal_id, "p1");
assert_eq!(get.org_id.as_deref(), Some("org-a"));
assert_eq!(get.project_id.as_deref(), Some("project-a"));
assert_eq!(get.principal_kind, PrincipalKind::ServiceAccount as i32);
}
#[tokio::test]
async fn list_filters_by_principal() {
let (svc, principal_store) = test_service();
seed_service_account(&principal_store, "pA", "org-a", "project-a").await;
seed_service_account(&principal_store, "pB", "org-b", "project-b").await;
let a = svc
.create_s3_credential(Request::new(CreateS3CredentialRequest {
principal_id: "pA".into(),
description: "".into(),
expires_at: None,
org_id: Some("org-a".into()),
project_id: Some("project-a".into()),
principal_kind: PrincipalKind::ServiceAccount as i32,
}))
.await
.unwrap()
.into_inner();
let _b = svc
.create_s3_credential(Request::new(CreateS3CredentialRequest {
principal_id: "pB".into(),
description: "".into(),
expires_at: None,
org_id: Some("org-b".into()),
project_id: Some("project-b".into()),
principal_kind: PrincipalKind::ServiceAccount as i32,
}))
.await
.unwrap();
let list_a = svc
.list_credentials(Request::new(ListCredentialsRequest {
principal_id: "pA".into(),
}))
.await
.unwrap()
.into_inner();
assert_eq!(list_a.credentials.len(), 1);
assert_eq!(list_a.credentials[0].access_key_id, a.access_key_id);
}
#[tokio::test]
async fn revoke_blocks_get() {
let (svc, principal_store) = test_service();
seed_service_account(&principal_store, "p1", "org-a", "project-a").await;
let created = svc
.create_s3_credential(Request::new(CreateS3CredentialRequest {
principal_id: "p1".into(),
description: "".into(),
expires_at: None,
org_id: Some("org-a".into()),
project_id: Some("project-a".into()),
principal_kind: PrincipalKind::ServiceAccount as i32,
}))
.await
.unwrap()
.into_inner();
let revoke1 = svc
.revoke_credential(Request::new(RevokeCredentialRequest {
access_key_id: created.access_key_id.clone(),
}))
.await
.unwrap()
.into_inner();
assert!(revoke1.success);
let revoke2 = svc
.revoke_credential(Request::new(RevokeCredentialRequest {
access_key_id: created.access_key_id.clone(),
}))
.await
.unwrap()
.into_inner();
assert!(!revoke2.success);
let err = svc
.get_secret_key(Request::new(GetSecretKeyRequest {
access_key_id: created.access_key_id,
}))
.await
.unwrap_err();
assert_eq!(err.code(), Status::permission_denied("").code());
}
#[tokio::test]
async fn credential_rotation_cutover_keeps_new_key_live() {
let (svc, principal_store) = test_service();
seed_service_account(&principal_store, "p1", "org-a", "project-a").await;
let old_credential = svc
.create_s3_credential(Request::new(CreateS3CredentialRequest {
principal_id: "p1".into(),
description: "old".into(),
expires_at: None,
org_id: Some("org-a".into()),
project_id: Some("project-a".into()),
principal_kind: PrincipalKind::ServiceAccount as i32,
}))
.await
.unwrap()
.into_inner();
let new_credential = svc
.create_s3_credential(Request::new(CreateS3CredentialRequest {
principal_id: "p1".into(),
description: "new".into(),
expires_at: None,
org_id: Some("org-a".into()),
project_id: Some("project-a".into()),
principal_kind: PrincipalKind::ServiceAccount as i32,
}))
.await
.unwrap()
.into_inner();
assert_ne!(old_credential.access_key_id, new_credential.access_key_id);
let listed = svc
.list_credentials(Request::new(ListCredentialsRequest {
principal_id: "p1".into(),
}))
.await
.unwrap()
.into_inner();
assert_eq!(listed.credentials.len(), 2);
let revoke_old = svc
.revoke_credential(Request::new(RevokeCredentialRequest {
access_key_id: old_credential.access_key_id.clone(),
}))
.await
.unwrap()
.into_inner();
assert!(revoke_old.success);
let old_err = svc
.get_secret_key(Request::new(GetSecretKeyRequest {
access_key_id: old_credential.access_key_id,
}))
.await
.unwrap_err();
assert_eq!(old_err.code(), Status::permission_denied("").code());
let new_secret = svc
.get_secret_key(Request::new(GetSecretKeyRequest {
access_key_id: new_credential.access_key_id,
}))
.await
.unwrap()
.into_inner();
assert_eq!(new_secret.principal_id, "p1");
assert_eq!(new_secret.org_id.as_deref(), Some("org-a"));
assert_eq!(new_secret.project_id.as_deref(), Some("project-a"));
}
#[tokio::test]
async fn expired_key_is_denied() {
let (svc, principal_store) = test_service();
seed_service_account(&principal_store, "p1", "org-a", "project-a").await;
// Manually insert an expired record
let expired = CredentialRecord {
access_key_id: "expired-ak".into(),
principal_id: "p1".into(),
principal_kind: TypesPrincipalKind::ServiceAccount,
org_id: Some("org-a".into()),
project_id: Some("project-a".into()),
created_at: now_ts(),
expires_at: Some(now_ts() - 10),
revoked: false,
description: None,
secret_hash: "hash".into(),
secret_enc: STANDARD.encode(b"dead"),
key_id: "k".into(),
version: 1,
kdf: Argon2Params {
m_cost_kib: 19456,
t_cost: 2,
p_cost: 1,
salt_b64: "c2FsdA==".into(),
},
};
svc.store.put(&expired).await.unwrap();
let err = svc
.get_secret_key(Request::new(GetSecretKeyRequest {
access_key_id: "expired-ak".into(),
}))
.await
.unwrap_err();
assert_eq!(err.code(), Status::permission_denied("").code());
}
#[test]
fn master_key_length_enforced() {
let backend = Arc::new(Backend::memory());
let store = Arc::new(CredentialStore::new(backend.clone()));
let principal_store = Arc::new(PrincipalStore::new(backend));
let bad = IamCredentialService::new(store.clone(), principal_store, &[0u8; 16], "k", None);
assert!(bad.is_err());
}
}