feat: improve legacy metadata and admin compatibility (#2202)

This commit is contained in:
weisd
2026-03-18 21:05:09 +08:00
committed by GitHub
parent 84077adf17
commit b9b7d86ae4
133 changed files with 11707 additions and 1945 deletions
+51 -2
View File
@@ -13,10 +13,11 @@
// limitations under the License.
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use time::OffsetDateTime;
#[derive(Debug, Serialize, Deserialize, Default)]
#[derive(Debug, Serialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum GroupStatus {
#[default]
@@ -24,6 +25,20 @@ pub enum GroupStatus {
Disabled,
}
impl<'de> Deserialize<'de> for GroupStatus {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
match value.as_str() {
"" | "enabled" => Ok(Self::Enabled),
"disabled" => Ok(Self::Disabled),
_ => Err(serde::de::Error::unknown_variant(&value, &["enabled", "disabled"])),
}
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct GroupAddRemove {
pub group: String,
@@ -40,6 +55,40 @@ pub struct GroupDesc {
pub status: String,
pub members: Vec<String>,
pub policy: String,
#[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
#[serde(
rename = "updatedAt",
skip_serializing_if = "Option::is_none",
with = "time::serde::rfc3339::option"
)]
pub updated_at: Option<OffsetDateTime>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn group_desc_updated_at_serializes_as_rfc3339() {
let now = OffsetDateTime::now_utc().replace_nanosecond(0).unwrap();
let group = GroupDesc {
name: "group-a".to_string(),
status: "enabled".to_string(),
members: vec!["user-a".to_string()],
policy: "readwrite".to_string(),
updated_at: Some(now),
};
let json = serde_json::to_string(&group).unwrap();
let decoded: GroupDesc = serde_json::from_str(&json).unwrap();
assert!(json.contains("\"updatedAt\":\""));
assert!(json.contains('T'));
assert_eq!(decoded.updated_at, Some(now));
}
#[test]
fn group_status_accepts_empty_string_as_enabled() {
let status: GroupStatus = serde_json::from_str(r#""""#).unwrap();
assert_eq!(status, GroupStatus::Enabled);
}
}
+325 -71
View File
@@ -13,6 +13,7 @@
// limitations under the License.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use serde_json::value::RawValue;
use std::collections::HashMap;
use time::OffsetDateTime;
@@ -88,7 +89,7 @@ pub struct UserInfo {
#[serde(rename = "memberOf", skip_serializing_if = "Option::is_none")]
pub member_of: Option<Vec<String>>,
#[serde(rename = "updatedAt")]
#[serde(rename = "updatedAt", with = "time::serde::rfc3339::option")]
pub updated_at: Option<OffsetDateTime>,
}
@@ -134,47 +135,61 @@ pub struct ListServiceAccountsResp {
pub accounts: Vec<ServiceAccountInfo>,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ListAccessKeysResp {
#[serde(rename = "serviceAccounts", default)]
pub service_accounts: Vec<ServiceAccountInfo>,
#[serde(rename = "stsKeys", default)]
pub sts_keys: Vec<ServiceAccountInfo>,
}
pub const ACCESS_KEY_LIST_USERS_ONLY: &str = "users-only";
pub const ACCESS_KEY_LIST_STS_ONLY: &str = "sts-only";
pub const ACCESS_KEY_LIST_SVCACC_ONLY: &str = "svcacc-only";
pub const ACCESS_KEY_LIST_ALL: &str = "all";
#[derive(Debug, Serialize, Deserialize)]
pub struct AddServiceAccountReq {
#[serde(rename = "policy", skip_serializing_if = "Option::is_none")]
pub policy: Option<String>,
#[serde(
rename = "policy",
skip_serializing_if = "Option::is_none",
default,
deserialize_with = "deserialize_optional_policy_value"
)]
pub policy: Option<Value>,
#[serde(rename = "targetUser", skip_serializing_if = "Option::is_none")]
pub target_user: Option<String>,
#[serde(rename = "accessKey")]
#[serde(rename = "accessKey", default)]
pub access_key: String,
#[serde(rename = "secretKey")]
#[serde(rename = "secretKey", default)]
pub secret_key: String,
#[serde(rename = "name")]
#[serde(rename = "name", skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(rename = "description", skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "expiration", with = "time::serde::rfc3339::option")]
#[serde(
rename = "expiration",
skip_serializing_if = "Option::is_none",
default,
with = "time::serde::rfc3339::option"
)]
pub expiration: Option<OffsetDateTime>,
#[serde(rename = "comment", skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
impl AddServiceAccountReq {
pub fn validate(&self) -> Result<(), String> {
if self.access_key.is_empty() {
return Err("accessKey is empty".to_string());
}
if self.secret_key.is_empty() {
return Err("secretKey is empty".to_string());
}
if self.name.is_none() {
return Err("name is empty".to_string());
}
// TODO: validate
Ok(())
validate_service_account_name(self.name.as_deref())?;
validate_service_account_description(self.description.as_deref().or(self.comment.as_deref()))?;
validate_service_account_expiration(self.expiration)
}
}
@@ -195,7 +210,7 @@ pub struct AddServiceAccountResp<'a> {
pub credentials: Credentials<'a>,
}
#[derive(Serialize)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct InfoServiceAccountResp {
pub parent_user: String,
@@ -213,10 +228,70 @@ pub struct InfoServiceAccountResp {
pub expiration: Option<OffsetDateTime>,
}
pub type TemporaryAccountInfoResp = InfoServiceAccountResp;
#[derive(Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct LDAPSpecificAccessKeyInfo {
#[serde(rename = "username", skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
}
impl LDAPSpecificAccessKeyInfo {
pub fn is_empty(&self) -> bool {
self.username.is_none()
}
}
#[derive(Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct OpenIDSpecificAccessKeyInfo {
#[serde(rename = "configName", skip_serializing_if = "Option::is_none")]
pub config_name: Option<String>,
#[serde(rename = "userID", skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
#[serde(rename = "userIDClaim", skip_serializing_if = "Option::is_none")]
pub user_id_claim: Option<String>,
#[serde(rename = "displayName", skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(rename = "displayNameClaim", skip_serializing_if = "Option::is_none")]
pub display_name_claim: Option<String>,
}
impl OpenIDSpecificAccessKeyInfo {
pub fn is_empty(&self) -> bool {
self.config_name.is_none()
&& self.user_id.is_none()
&& self.user_id_claim.is_none()
&& self.display_name.is_none()
&& self.display_name_claim.is_none()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct InfoAccessKeyResp {
pub access_key: String,
#[serde(flatten)]
pub info: InfoServiceAccountResp,
pub user_type: String,
pub user_provider: String,
#[serde(rename = "ldapSpecificInfo", skip_serializing_if = "LDAPSpecificAccessKeyInfo::is_empty")]
pub ldap_specific_info: LDAPSpecificAccessKeyInfo,
#[serde(
rename = "openIDSpecificInfo",
skip_serializing_if = "OpenIDSpecificAccessKeyInfo::is_empty"
)]
pub open_id_specific_info: OpenIDSpecificAccessKeyInfo,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateServiceAccountReq {
#[serde(rename = "newPolicy", skip_serializing_if = "Option::is_none")]
pub new_policy: Option<String>,
#[serde(
rename = "newPolicy",
skip_serializing_if = "Option::is_none",
default,
deserialize_with = "deserialize_optional_policy_value"
)]
pub new_policy: Option<Value>,
#[serde(rename = "newSecretKey", skip_serializing_if = "Option::is_none")]
pub new_secret_key: Option<String>,
@@ -230,18 +305,95 @@ pub struct UpdateServiceAccountReq {
#[serde(rename = "newDescription", skip_serializing_if = "Option::is_none")]
pub new_description: Option<String>,
#[serde(rename = "newExpiration", skip_serializing_if = "Option::is_none")]
#[serde(rename = "newExpiration", skip_serializing_if = "Option::is_none", default)]
#[serde(with = "time::serde::rfc3339::option")]
pub new_expiration: Option<OffsetDateTime>,
}
impl UpdateServiceAccountReq {
pub fn validate(&self) -> Result<(), String> {
// TODO: validate
Ok(())
validate_service_account_name(self.new_name.as_deref())?;
validate_service_account_description(self.new_description.as_deref())?;
validate_service_account_expiration(self.new_expiration)
}
}
fn deserialize_optional_policy_value<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
Ok(value.map(normalize_policy_value))
}
fn normalize_policy_value(value: Value) -> Value {
match value {
Value::String(policy) => serde_json::from_str(&policy).unwrap_or(Value::String(policy)),
other => other,
}
}
fn validate_service_account_name(name: Option<&str>) -> Result<(), String> {
let Some(name) = name else {
return Ok(());
};
if name.is_empty() {
return Ok(());
}
if name.len() > 32 {
return Err("name must not be longer than 32 characters".to_string());
}
let mut chars = name.chars();
let Some(first) = chars.next() else {
return Ok(());
};
if !first.is_ascii_alphabetic() {
return Err(
"name must contain only ASCII letters, digits, underscores and hyphens and must start with a letter".to_string(),
);
}
if chars.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-') {
return Err(
"name must contain only ASCII letters, digits, underscores and hyphens and must start with a letter".to_string(),
);
}
Ok(())
}
fn validate_service_account_description(description: Option<&str>) -> Result<(), String> {
let Some(description) = description else {
return Ok(());
};
if description.len() > 256 {
return Err("description must be at most 256 bytes long".to_string());
}
Ok(())
}
fn validate_service_account_expiration(expiration: Option<OffsetDateTime>) -> Result<(), String> {
let Some(expiration) = expiration else {
return Ok(());
};
if expiration.unix_timestamp() == 0 {
return Ok(());
}
if expiration < OffsetDateTime::now_utc() {
return Err("the expiration time should be in the future".to_string());
}
Ok(())
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct AccountInfo {
pub account_name: String,
@@ -423,6 +575,54 @@ pub struct IAMEntities {
pub sts_policies: Vec<HashMap<String, Vec<String>>>,
}
/// PolicyEntitiesResult - contains response to a policy entities query.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyEntitiesResult {
#[serde(rename = "timestamp", with = "time::serde::rfc3339")]
pub timestamp: time::OffsetDateTime,
#[serde(rename = "userMappings", skip_serializing_if = "Vec::is_empty")]
pub user_mappings: Vec<UserPolicyEntities>,
#[serde(rename = "groupMappings", skip_serializing_if = "Vec::is_empty")]
pub group_mappings: Vec<GroupPolicyEntities>,
#[serde(rename = "policyMappings", skip_serializing_if = "Vec::is_empty")]
pub policy_mappings: Vec<PolicyEntities>,
}
impl Default for PolicyEntitiesResult {
fn default() -> Self {
Self {
timestamp: time::OffsetDateTime::UNIX_EPOCH,
user_mappings: Vec::new(),
group_mappings: Vec::new(),
policy_mappings: Vec::new(),
}
}
}
/// UserPolicyEntities - user -> policies mapping
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct UserPolicyEntities {
pub user: String,
pub policies: Vec<String>,
#[serde(rename = "memberOfMappings", skip_serializing_if = "Vec::is_empty")]
pub member_of_mappings: Vec<GroupPolicyEntities>,
}
/// GroupPolicyEntities - group -> policies mapping
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct GroupPolicyEntities {
pub group: String,
pub policies: Vec<String>,
}
/// PolicyEntities - policy -> user+group mapping
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct PolicyEntities {
pub policy: String,
pub users: Vec<String>,
pub groups: Vec<String>,
}
/// IAMErrEntities - represents errored out IAM entries while import with error
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IAMErrEntities {
@@ -659,13 +859,14 @@ mod tests {
#[test]
fn test_add_service_account_req_validate_success() {
let req = AddServiceAccountReq {
policy: Some("ReadOnlyAccess".to_string()),
policy: Some(serde_json::json!({"Version": "2012-10-17"})),
target_user: Some("testuser".to_string()),
access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
name: Some("test-service".to_string()),
description: Some("Test service account".to_string()),
expiration: None,
comment: None,
};
let result = req.validate();
@@ -673,54 +874,82 @@ mod tests {
}
#[test]
fn test_add_service_account_req_validate_empty_access_key() {
fn test_add_service_account_req_validate_allows_generated_credentials() {
let req = AddServiceAccountReq {
policy: None,
target_user: None,
access_key: "".to_string(),
secret_key: "secret".to_string(),
name: Some("test".to_string()),
description: None,
expiration: None,
};
let result = req.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("accessKey is empty"));
}
#[test]
fn test_add_service_account_req_validate_empty_secret_key() {
let req = AddServiceAccountReq {
policy: None,
target_user: None,
access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
secret_key: "".to_string(),
name: Some("test".to_string()),
description: None,
expiration: None,
};
let result = req.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("secretKey is empty"));
}
#[test]
fn test_add_service_account_req_validate_empty_name() {
let req = AddServiceAccountReq {
policy: None,
target_user: None,
access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
secret_key: "secret".to_string(),
name: None,
description: None,
expiration: None,
comment: None,
};
assert!(req.validate().is_ok());
}
#[test]
fn test_add_service_account_req_deserializes_stringified_policy_json() {
let req: AddServiceAccountReq = serde_json::from_str(
r#"{
"policy":"{\"Version\":\"2012-10-17\",\"Statement\":[]}",
"accessKey":"AKIAIOSFODNN7EXAMPLE",
"secretKey":"secret"
}"#,
)
.unwrap();
assert_eq!(req.policy, Some(serde_json::json!({"Version":"2012-10-17","Statement":[]})));
}
#[test]
fn test_add_service_account_req_allows_missing_policy_field() {
let req: AddServiceAccountReq = serde_json::from_str(
r#"{
"accessKey":"AKIAIOSFODNN7EXAMPLE",
"secretKey":"secret"
}"#,
)
.unwrap();
assert_eq!(req.policy, None);
}
#[test]
fn test_add_service_account_req_validate_invalid_name() {
let req = AddServiceAccountReq {
policy: None,
target_user: None,
access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
secret_key: "secret".to_string(),
name: Some("1invalid".to_string()),
description: None,
expiration: None,
comment: None,
};
let result = req.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("name is empty"));
assert!(result.unwrap_err().contains("must start with a letter"));
}
#[test]
fn test_add_service_account_req_validate_rejects_long_description() {
let req = AddServiceAccountReq {
policy: None,
target_user: None,
access_key: "AKIAIOSFODNN7EXAMPLE".to_string(),
secret_key: "secret".to_string(),
name: Some("test".to_string()),
description: Some("a".repeat(257)),
expiration: None,
comment: None,
};
let result = req.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("at most 256 bytes"));
}
#[test]
@@ -793,7 +1022,7 @@ mod tests {
#[test]
fn test_update_service_account_req_validate() {
let req = UpdateServiceAccountReq {
new_policy: Some("FullAccess".to_string()),
new_policy: Some(serde_json::json!({"Version": "2012-10-17"})),
new_secret_key: Some("newsecret".to_string()),
new_status: Some("enabled".to_string()),
new_name: Some("updated-service".to_string()),
@@ -805,6 +1034,25 @@ mod tests {
assert!(result.is_ok());
}
#[test]
fn test_update_service_account_req_deserializes_stringified_policy_json() {
let req: UpdateServiceAccountReq = serde_json::from_str(
r#"{
"newPolicy":"{\"Version\":\"2012-10-17\",\"Statement\":[]}"
}"#,
)
.unwrap();
assert_eq!(req.new_policy, Some(serde_json::json!({"Version":"2012-10-17","Statement":[]})));
}
#[test]
fn test_update_service_account_req_allows_missing_policy_field() {
let req: UpdateServiceAccountReq = serde_json::from_str(r#"{}"#).unwrap();
assert_eq!(req.new_policy, None);
}
#[test]
fn test_account_info_creation() {
use crate::BackendInfo;
@@ -904,6 +1152,7 @@ mod tests {
#[test]
fn test_serialization_deserialization_roundtrip() {
let now = OffsetDateTime::now_utc().replace_nanosecond(0).unwrap();
let user_info = UserInfo {
auth_info: Some(UserAuthInfo {
auth_type: UserAuthType::Ldap,
@@ -914,16 +1163,19 @@ mod tests {
policy_name: Some("ReadOnlyAccess".to_string()),
status: AccountStatus::Enabled,
member_of: Some(vec!["group1".to_string()]),
updated_at: None,
updated_at: Some(now),
};
let json = serde_json::to_string(&user_info).unwrap();
let deserialized: UserInfo = serde_json::from_str(&json).unwrap();
assert!(json.contains("\"updatedAt\":\""));
assert!(json.contains('T'));
assert_eq!(deserialized.secret_key.unwrap(), "secret123");
assert_eq!(deserialized.policy_name.unwrap(), "ReadOnlyAccess");
assert_eq!(deserialized.status, AccountStatus::Enabled);
assert_eq!(deserialized.member_of.unwrap().len(), 1);
assert_eq!(deserialized.updated_at, Some(now));
}
#[test]
@@ -962,13 +1214,14 @@ mod tests {
fn test_edge_cases() {
// Test empty strings and edge cases
let req = AddServiceAccountReq {
policy: Some("".to_string()),
policy: Some(serde_json::Value::Null),
target_user: Some("".to_string()),
access_key: "valid_key".to_string(),
secret_key: "valid_secret".to_string(),
name: Some("valid_name".to_string()),
description: Some("".to_string()),
expiration: None,
comment: None,
};
// Should still validate successfully with empty optional strings
@@ -977,13 +1230,14 @@ mod tests {
// Test very long strings
let long_string = "a".repeat(1000);
let long_req = AddServiceAccountReq {
policy: Some(long_string.clone()),
policy: Some(serde_json::json!({"Statement": [long_string.clone()]})),
target_user: Some(long_string.clone()),
access_key: long_string.clone(),
secret_key: long_string.clone(),
name: Some(long_string.clone()),
description: Some(long_string),
name: Some("valid_name".to_string()),
description: Some("valid description".to_string()),
expiration: None,
comment: None,
};
assert!(long_req.validate().is_ok());