773 lines
27 KiB
Rust
773 lines
27 KiB
Rust
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<Vec<AuthProfile>, 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<Vec<Secret>, 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<Secret, ApiError> {
|
|
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<Secret, ApiError> {
|
|
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<Secret, ApiError> {
|
|
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<AuthProfile, ApiError> {
|
|
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<AuthProfile, ApiError> {
|
|
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(())
|
|
}
|