use crank_core::{ AuthConfig, AuthKind, AuthProfile, AuthProfileId, Secret, SecretId, SecretStatus, UserId, WorkspaceId, }; use crank_registry::{ CreateSecretRequest, RegistryError, RotateSecretRequest, SaveAuthProfileRequest, }; use serde_json::json; use time::OffsetDateTime; use tracing::{info, instrument}; use crate::{ error::ApiError, service::{ AdminAuditContext, AdminService, AuthProfilePayload, CredentialAuditRecord, RotateSecretPayload, SecretPayload, new_prefixed_id, }, }; impl AdminService { #[instrument(skip(self))] pub async fn list_auth_profiles( &self, workspace_id: &WorkspaceId, ) -> Result, ApiError> { self.ensure_workspace_exists(workspace_id).await?; Ok(self.registry.list_auth_profiles(workspace_id).await?) } #[instrument(skip(self))] pub async fn list_secrets(&self, workspace_id: &WorkspaceId) -> Result, ApiError> { self.ensure_workspace_exists(workspace_id).await?; Ok(self .registry .list_secrets(workspace_id) .await? .into_iter() .map(|record| record.secret) .collect()) } #[instrument(skip(self))] pub async fn get_secret( &self, workspace_id: &WorkspaceId, secret_id: &SecretId, ) -> Result { self.ensure_workspace_exists(workspace_id).await?; self.registry .get_secret(workspace_id, secret_id) .await? .map(|record| record.secret) .ok_or_else(|| { ApiError::not_found_with_context( format!("secret {} was not found", secret_id.as_str()), json!({ "secret_id": secret_id.as_str() }), ) }) } #[instrument(skip(self, created_by, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))] pub async fn create_secret( &self, workspace_id: &WorkspaceId, created_by: Option<&UserId>, payload: SecretPayload, audit_context: Option<&AdminAuditContext>, ) -> Result { if let Err(error) = self.ensure_workspace_exists(workspace_id).await { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.create_failed", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: "pending", credential_type: "secret", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } if let Err(error) = validate_secret_payload(&payload) { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.create_failed", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: "pending", credential_type: "secret", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } let now = OffsetDateTime::now_utc(); let secret = Secret { id: SecretId::new(new_prefixed_id("secret")), workspace_id: workspace_id.clone(), name: payload.name.trim().to_owned(), kind: payload.kind, status: SecretStatus::Active, current_version: 1, created_at: now, updated_at: now, last_used_at: None, }; let ciphertext = self .secret_crypto .encrypt(&payload.value) .map_err(|error| ApiError::internal(error.to_string())); let ciphertext = match ciphertext { Ok(ciphertext) => ciphertext, Err(error) => { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.create_failed", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret.id.as_str(), credential_type: "secret", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } }; if let Err(error) = self .registry .create_secret(CreateSecretRequest { secret: &secret, ciphertext: &ciphertext, key_version: self.secret_crypto.key_version(), master_key_epoch: self.secret_crypto.master_key_epoch(), created_by, }) .await { let error = ApiError::from(error); self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.create_failed", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret.id.as_str(), credential_type: "secret", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } info!( name: "admin.secret.created", secret_id = %secret.id.as_str(), "secret created" ); self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.created", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret.id.as_str(), credential_type: "secret", outcome: "success", reason: "credential_created", }, ) .await; Ok(secret) } #[instrument(skip(self, created_by, payload, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))] pub async fn rotate_secret( &self, workspace_id: &WorkspaceId, secret_id: &SecretId, created_by: Option<&UserId>, payload: RotateSecretPayload, audit_context: Option<&AdminAuditContext>, ) -> Result { if let Err(error) = self.ensure_workspace_exists(workspace_id).await { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.rotate_failed", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret_id.as_str(), credential_type: "secret", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } let existing = match self.get_secret(workspace_id, secret_id).await { Ok(secret) => secret, Err(error) => { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.rotate_failed", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret_id.as_str(), credential_type: "secret", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } }; if let Err(error) = validate_secret_value(existing.kind, &payload.value) { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.rotate_failed", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret_id.as_str(), credential_type: "secret", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } let now = OffsetDateTime::now_utc(); let ciphertext = self .secret_crypto .encrypt(&payload.value) .map_err(|error| ApiError::internal(error.to_string())); let ciphertext = match ciphertext { Ok(ciphertext) => ciphertext, Err(error) => { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.rotate_failed", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret_id.as_str(), credential_type: "secret", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } }; let version = match self .registry .rotate_secret(RotateSecretRequest { workspace_id, secret_id, ciphertext: &ciphertext, key_version: self.secret_crypto.key_version(), master_key_epoch: self.secret_crypto.master_key_epoch(), created_at: &now, updated_at: &now, created_by, }) .await { Ok(version) => version, Err(error) => { let error = ApiError::from(error); self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.rotate_failed", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret_id.as_str(), credential_type: "secret", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } }; info!( name: "admin.secret.rotated", secret_id = %secret_id.as_str(), "secret rotated" ); self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.rotated", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret_id.as_str(), credential_type: "secret", outcome: "success", reason: "credential_rotated", }, ) .await; Ok(Secret { current_version: version.secret_version.version, updated_at: now, ..existing }) } #[instrument(skip(self, audit_context), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))] pub async fn delete_secret( &self, workspace_id: &WorkspaceId, secret_id: &SecretId, audit_context: Option<&AdminAuditContext>, ) -> Result<(), ApiError> { if let Err(error) = self.ensure_workspace_exists(workspace_id).await { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.delete_failed", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret_id.as_str(), credential_type: "secret", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } let referencing_profiles = match self .registry .list_auth_profiles_referencing_secret(workspace_id, secret_id) .await { Ok(profiles) => profiles, Err(error) => { let error = ApiError::from(error); self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.delete_failed", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret_id.as_str(), credential_type: "secret", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } }; if let Some(profile) = referencing_profiles.into_iter().next() { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.delete_denied", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret_id.as_str(), credential_type: "secret", outcome: "denied", reason: "secret_referenced_by_auth_profile", }, ) .await; return Err(RegistryError::SecretReferencedByAuthProfile { secret_id: secret_id.as_str().to_owned(), auth_profile_id: profile.id.as_str().to_owned(), } .into()); } if let Err(error) = self.registry.delete_secret(workspace_id, secret_id).await { let error = ApiError::from(error); self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.delete_failed", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret_id.as_str(), credential_type: "secret", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } info!( name: "admin.secret.deleted", secret_id = %secret_id.as_str(), "secret deleted" ); self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.secret.deleted", target_kind: crank_core::AuditTargetKind::Secret, workspace_id, target_id: secret_id.as_str(), credential_type: "secret", outcome: "success", reason: "credential_deleted", }, ) .await; Ok(()) } #[instrument(skip(self))] pub async fn get_auth_profile( &self, workspace_id: &WorkspaceId, auth_profile_id: &AuthProfileId, ) -> Result { self.registry .get_auth_profile(workspace_id, auth_profile_id) .await? .ok_or_else(|| { ApiError::not_found_with_context( format!("auth profile {} was not found", auth_profile_id.as_str()), json!({ "auth_profile_id": auth_profile_id.as_str() }), ) }) } #[instrument(skip(self, payload, audit_context), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))] pub async fn create_auth_profile( &self, workspace_id: &WorkspaceId, payload: AuthProfilePayload, audit_context: Option<&AdminAuditContext>, ) -> Result { if let Err(error) = validate_auth_profile_payload(&payload) { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.auth_profile.create_failed", target_kind: crank_core::AuditTargetKind::AuthProfile, workspace_id, target_id: "pending", credential_type: "auth_profile", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } if let Err(error) = self.ensure_workspace_exists(workspace_id).await { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.auth_profile.create_failed", target_kind: crank_core::AuditTargetKind::AuthProfile, workspace_id, target_id: "pending", credential_type: "auth_profile", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } if let Err(error) = self .validate_auth_profile_secret_ids(workspace_id, &payload.config) .await { self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.auth_profile.create_failed", target_kind: crank_core::AuditTargetKind::AuthProfile, workspace_id, target_id: "pending", credential_type: "auth_profile", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } let now = OffsetDateTime::now_utc(); let profile = AuthProfile { id: AuthProfileId::new(new_prefixed_id("auth")), workspace_id: workspace_id.clone(), name: payload.name.trim().to_owned(), kind: payload.kind, config: payload.config, created_at: now, updated_at: now, }; if let Err(error) = self .registry .save_auth_profile(SaveAuthProfileRequest { workspace_id, profile: &profile, }) .await { let error = ApiError::from(error); self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.auth_profile.create_failed", target_kind: crank_core::AuditTargetKind::AuthProfile, workspace_id, target_id: profile.id.as_str(), credential_type: "auth_profile", outcome: "failure", reason: error.code(), }, ) .await; return Err(error); } info!( name: "admin.auth_profile.created", auth_profile_id = %profile.id.as_str(), "auth profile created" ); self.record_credential_audit( audit_context, CredentialAuditRecord { action: "credential.auth_profile.created", target_kind: crank_core::AuditTargetKind::AuthProfile, workspace_id, target_id: profile.id.as_str(), credential_type: "auth_profile", outcome: "success", reason: "credential_created", }, ) .await; Ok(profile) } async fn validate_auth_profile_secret_ids( &self, workspace_id: &WorkspaceId, config: &AuthConfig, ) -> Result<(), ApiError> { for secret_id in config.secret_ids() { let secret = self.get_secret(workspace_id, secret_id).await?; if secret.status != SecretStatus::Active { return Err(RegistryError::SecretInactive { secret_id: secret_id.as_str().to_owned(), } .into()); } } Ok(()) } } fn validate_auth_profile_payload(payload: &AuthProfilePayload) -> Result<(), ApiError> { const MAX_PROFILE_NAME_CHARS: usize = 128; let profile_name = payload.name.trim(); if profile_name.is_empty() || profile_name.chars().count() > MAX_PROFILE_NAME_CHARS { return Err(ApiError::validation( "auth profile name must contain 1 to 128 characters", )); } if profile_name.chars().any(char::is_control) { return Err(ApiError::validation( "auth profile name contains unsupported characters", )); } validate_auth_profile_kind(payload.kind, &payload.config)?; match &payload.config { AuthConfig::Bearer(config) => validate_auth_header_name(&config.header_name)?, AuthConfig::Basic(_) => {} AuthConfig::ApiKeyHeader(config) => validate_auth_header_name(&config.header_name)?, AuthConfig::ApiKeyQuery(config) => validate_auth_query_name(&config.param_name)?, } Ok(()) } fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), ApiError> { let is_match = matches!( (kind, config), (AuthKind::Bearer, AuthConfig::Bearer(_)) | (AuthKind::Basic, AuthConfig::Basic(_)) | (AuthKind::ApiKeyHeader, AuthConfig::ApiKeyHeader(_)) | (AuthKind::ApiKeyQuery, AuthConfig::ApiKeyQuery(_)) ); if is_match { return Ok(()); } Err(ApiError::validation("auth kind and config must match")) } fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> { const MAX_SECRET_NAME_CHARS: usize = 128; let name = payload.name.trim(); if name.is_empty() || name.chars().count() > MAX_SECRET_NAME_CHARS { return Err(ApiError::validation( "secret name must contain 1 to 128 characters", )); } if name.chars().any(char::is_control) { return Err(ApiError::validation( "secret name contains unsupported characters", )); } validate_secret_value(payload.kind, &payload.value) } fn validate_secret_value( kind: crank_core::SecretKind, value: &serde_json::Value, ) -> Result<(), ApiError> { const MAX_SECRET_VALUE_BYTES: usize = 65_536; const MAX_SECRET_STRING_BYTES: usize = 16_384; if value.is_null() { return Err(ApiError::validation("secret value must not be null")); } let encoded = serde_json::to_vec(value) .map_err(|_| ApiError::validation("secret value must be valid JSON"))?; if encoded.len() > MAX_SECRET_VALUE_BYTES { return Err(ApiError::validation("secret value is too large")); } match kind { crank_core::SecretKind::Token | crank_core::SecretKind::Header => { if value.is_string() { validate_non_empty_secret_string(value, "secret value", MAX_SECRET_STRING_BYTES)?; } else { let object = value.as_object().ok_or_else(|| { ApiError::validation( "token/header secret must be a string or a single token/value field", ) })?; let field = object .get("token") .or_else(|| object.get("value")) .filter(|_| object.len() == 1) .ok_or_else(|| { ApiError::validation("token/header secret must contain only token or value") })?; validate_non_empty_secret_string(field, "secret value", MAX_SECRET_STRING_BYTES)?; } } crank_core::SecretKind::UsernamePassword => { let object = value.as_object().ok_or_else(|| { ApiError::validation("username_password secret must contain username and password") })?; if object.len() != 2 || !object.contains_key("username") || !object.contains_key("password") { return Err(ApiError::validation( "username_password secret must contain only username and password", )); } validate_non_empty_secret_string( &object["username"], "secret username", MAX_SECRET_STRING_BYTES, )?; validate_non_empty_secret_string( &object["password"], "secret password", MAX_SECRET_STRING_BYTES, )?; } crank_core::SecretKind::Generic => {} } Ok(()) } fn validate_non_empty_secret_string( value: &serde_json::Value, field: &str, max_bytes: usize, ) -> Result<(), ApiError> { let value = value .as_str() .ok_or_else(|| ApiError::validation(format!("{field} must be a string")))?; if value.is_empty() { return Err(ApiError::validation(format!("{field} must not be empty"))); } if value.len() > max_bytes { return Err(ApiError::validation(format!("{field} is too large"))); } Ok(()) } fn validate_auth_header_name(value: &str) -> Result<(), ApiError> { const MAX_HEADER_NAME_BYTES: usize = 128; if value.is_empty() || value.len() > MAX_HEADER_NAME_BYTES || !value.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!( byte, b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~' ) }) { return Err(ApiError::validation("auth header name is invalid")); } Ok(()) } fn validate_auth_query_name(value: &str) -> Result<(), ApiError> { const MAX_QUERY_NAME_BYTES: usize = 128; if value.is_empty() || value.len() > MAX_QUERY_NAME_BYTES || !value.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'[' | b']') }) { return Err(ApiError::validation("auth query parameter name is invalid")); } Ok(()) }