Merge remote-tracking branch 'origin/pr/399' into aether-rust-pioneer

# Conflicts:
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
This commit is contained in:
fawney19
2026-05-08 02:52:13 +08:00
34 changed files with 2212 additions and 100 deletions

View File

@@ -152,6 +152,7 @@ impl ManagementTokenWriteRepository for InMemoryManagementTokenRepository {
record.token_prefix.clone(),
record.allowed_ips.clone(),
)
.with_permissions(record.permissions.clone())
.with_runtime_fields(record.expires_at_unix_secs, None, None, 0, record.is_active)
.with_timestamps(now, now);
items.push(StoredManagementTokenWithUser::new(
@@ -205,6 +206,10 @@ impl ManagementTokenWriteRepository for InMemoryManagementTokenRepository {
items[index].token.allowed_ips = Some(allowed_ips.clone());
}
if let Some(permissions) = &record.permissions {
items[index].token.permissions = Some(permissions.clone());
}
if record.clear_expires_at {
items[index].token.expires_at_unix_secs = None;
} else if let Some(expires_at_unix_secs) = record.expires_at_unix_secs {
@@ -371,12 +376,17 @@ mod tests {
name: "created".to_string(),
description: Some("created token".to_string()),
allowed_ips: Some(serde_json::json!(["127.0.0.1"])),
permissions: Some(serde_json::json!(["admin:usage:read"])),
expires_at_unix_secs: Some(1_800_000_000),
is_active: true,
})
.await
.expect("create should succeed");
assert_eq!(created.name, "created");
assert_eq!(
created.permissions,
Some(serde_json::json!(["admin:usage:read"]))
);
let updated = repository
.update_management_token(&UpdateManagementTokenRecord {
@@ -386,6 +396,7 @@ mod tests {
clear_description: true,
allowed_ips: Some(serde_json::json!(["10.0.0.1"])),
clear_allowed_ips: false,
permissions: Some(serde_json::json!(["admin:usage:read", "admin:usage:write"])),
expires_at_unix_secs: None,
clear_expires_at: true,
is_active: Some(false),
@@ -396,6 +407,10 @@ mod tests {
assert_eq!(updated.name, "renamed");
assert_eq!(updated.description, None);
assert_eq!(updated.allowed_ips, Some(serde_json::json!(["10.0.0.1"])));
assert_eq!(
updated.permissions,
Some(serde_json::json!(["admin:usage:read", "admin:usage:write"]))
);
assert_eq!(updated.expires_at_unix_secs, None);
assert!(!updated.is_active);
@@ -416,6 +431,10 @@ mod tests {
.expect("lookup by hash should succeed")
.expect("token should exist");
assert_eq!(by_hash.token.id, "token-3");
assert_eq!(
by_hash.token.permissions,
Some(serde_json::json!(["admin:usage:read", "admin:usage:write"]))
);
let used = repository
.record_management_token_usage("token-3", Some("127.0.0.1"))

View File

@@ -42,6 +42,7 @@ SELECT
description,
token_prefix,
allowed_ips,
permissions,
expires_at AS expires_at_unix_secs,
last_used_at AS last_used_at_unix_secs,
last_used_ip,
@@ -62,6 +63,7 @@ SELECT
mt.description,
mt.token_prefix,
mt.allowed_ips,
mt.permissions,
mt.expires_at AS expires_at_unix_secs,
mt.last_used_at AS last_used_at_unix_secs,
mt.last_used_ip,
@@ -96,6 +98,7 @@ SELECT
mt.description,
mt.token_prefix,
mt.allowed_ips,
mt.permissions,
mt.expires_at AS expires_at_unix_secs,
mt.last_used_at AS last_used_at_unix_secs,
mt.last_used_ip,
@@ -121,6 +124,7 @@ SELECT
mt.description,
mt.token_prefix,
mt.allowed_ips,
mt.permissions,
mt.expires_at AS expires_at_unix_secs,
mt.last_used_at AS last_used_at_unix_secs,
mt.last_used_ip,
@@ -211,8 +215,8 @@ impl ManagementTokenWriteRepository for MysqlManagementTokenRepository {
r#"
INSERT INTO management_tokens (
id, user_id, token_hash, token_prefix, name, description, allowed_ips,
expires_at, is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
permissions, expires_at, is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&record.id)
@@ -222,6 +226,7 @@ INSERT INTO management_tokens (
.bind(&record.name)
.bind(record.description.as_deref())
.bind(json_to_string(record.allowed_ips.as_ref())?)
.bind(json_to_string(record.permissions.as_ref())?)
.bind(
record
.expires_at_unix_secs
@@ -262,6 +267,7 @@ INSERT INTO management_tokens (
} else {
record.allowed_ips.as_ref().or(current.allowed_ips.as_ref())
};
let permissions = record.permissions.as_ref().or(current.permissions.as_ref());
let expires_at = if record.clear_expires_at {
None
} else {
@@ -276,6 +282,7 @@ UPDATE management_tokens
SET name = ?,
description = ?,
allowed_ips = ?,
permissions = ?,
expires_at = ?,
is_active = ?,
updated_at = ?
@@ -285,6 +292,7 @@ WHERE id = ?
.bind(name)
.bind(description)
.bind(json_to_string(allowed_ips)?)
.bind(json_to_string(permissions)?)
.bind(expires_at.and_then(|value| i64::try_from(value).ok()))
.bind(is_active)
.bind(now as i64)
@@ -438,6 +446,7 @@ fn map_token_row(row: &MySqlRow) -> Result<StoredManagementToken, DataLayerError
row.try_get("token_prefix").map_sql_err()?,
json_from_string(row.try_get("allowed_ips").map_sql_err()?)?,
)
.with_permissions(json_from_string(row.try_get("permissions").map_sql_err()?)?)
.with_runtime_fields(
optional_unix_secs(row.try_get("expires_at_unix_secs").map_sql_err()?),
optional_unix_secs(row.try_get("last_used_at_unix_secs").map_sql_err()?),

View File

@@ -18,6 +18,7 @@ SELECT
mt.description,
mt.token_prefix,
mt.allowed_ips,
mt.permissions,
EXTRACT(EPOCH FROM mt.expires_at)::bigint AS expires_at_unix_secs,
EXTRACT(EPOCH FROM mt.last_used_at)::bigint AS last_used_at_unix_secs,
mt.last_used_ip,
@@ -53,6 +54,7 @@ SELECT
mt.description,
mt.token_prefix,
mt.allowed_ips,
mt.permissions,
EXTRACT(EPOCH FROM mt.expires_at)::bigint AS expires_at_unix_secs,
EXTRACT(EPOCH FROM mt.last_used_at)::bigint AS last_used_at_unix_secs,
mt.last_used_ip,
@@ -78,6 +80,7 @@ SELECT
mt.description,
mt.token_prefix,
mt.allowed_ips,
mt.permissions,
EXTRACT(EPOCH FROM mt.expires_at)::bigint AS expires_at_unix_secs,
EXTRACT(EPOCH FROM mt.last_used_at)::bigint AS last_used_at_unix_secs,
mt.last_used_ip,
@@ -109,6 +112,7 @@ INSERT INTO management_tokens (
name,
description,
allowed_ips,
permissions,
expires_at,
is_active
)
@@ -120,11 +124,12 @@ VALUES (
$5,
$6,
$7,
$8,
CASE
WHEN $8::bigint IS NULL THEN NULL
ELSE to_timestamp($8::double precision)
WHEN $9::bigint IS NULL THEN NULL
ELSE to_timestamp($9::double precision)
END,
$9
$10
)
RETURNING
id,
@@ -133,6 +138,7 @@ RETURNING
description,
token_prefix,
allowed_ips,
permissions,
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
last_used_ip,
@@ -155,12 +161,13 @@ SET name = COALESCE($2, name),
WHEN $6::json IS NULL THEN allowed_ips
ELSE $6
END,
permissions = COALESCE($7::json, permissions),
expires_at = CASE
WHEN $7 THEN NULL
WHEN $8::bigint IS NULL THEN expires_at
ELSE to_timestamp($8::double precision)
WHEN $8 THEN NULL
WHEN $9::bigint IS NULL THEN expires_at
ELSE to_timestamp($9::double precision)
END,
is_active = COALESCE($9, is_active),
is_active = COALESCE($10, is_active),
updated_at = NOW()
WHERE id = $1
RETURNING
@@ -170,6 +177,7 @@ RETURNING
description,
token_prefix,
allowed_ips,
permissions,
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
last_used_ip,
@@ -191,6 +199,7 @@ RETURNING
description,
token_prefix,
allowed_ips,
permissions,
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
last_used_ip,
@@ -213,6 +222,7 @@ RETURNING
description,
token_prefix,
allowed_ips,
permissions,
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
last_used_ip,
@@ -236,6 +246,7 @@ RETURNING
description,
token_prefix,
allowed_ips,
permissions,
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
last_used_ip,
@@ -327,6 +338,7 @@ impl ManagementTokenWriteRepository for SqlxManagementTokenRepository {
.bind(&record.name)
.bind(record.description.as_deref())
.bind(record.allowed_ips.as_ref())
.bind(record.permissions.as_ref())
.bind(
record
.expires_at_unix_secs
@@ -351,6 +363,7 @@ impl ManagementTokenWriteRepository for SqlxManagementTokenRepository {
.bind(record.description.as_deref())
.bind(record.clear_allowed_ips)
.bind(record.allowed_ips.as_ref())
.bind(record.permissions.as_ref())
.bind(record.clear_expires_at)
.bind(
record
@@ -458,6 +471,7 @@ fn map_token_row(row: &PgRow) -> Result<StoredManagementToken, DataLayerError> {
row.try_get("token_prefix").map_postgres_err()?,
row.try_get("allowed_ips").map_postgres_err()?,
)
.with_permissions(row.try_get("permissions").map_postgres_err()?)
.with_runtime_fields(
optional_unix_secs(row.try_get("expires_at_unix_secs").map_postgres_err()?),
optional_unix_secs(row.try_get("last_used_at_unix_secs").map_postgres_err()?),

View File

@@ -41,6 +41,7 @@ SELECT
description,
token_prefix,
allowed_ips,
permissions,
expires_at AS expires_at_unix_secs,
last_used_at AS last_used_at_unix_secs,
last_used_ip,
@@ -61,6 +62,7 @@ SELECT
mt.description,
mt.token_prefix,
mt.allowed_ips,
mt.permissions,
mt.expires_at AS expires_at_unix_secs,
mt.last_used_at AS last_used_at_unix_secs,
mt.last_used_ip,
@@ -95,6 +97,7 @@ SELECT
mt.description,
mt.token_prefix,
mt.allowed_ips,
mt.permissions,
mt.expires_at AS expires_at_unix_secs,
mt.last_used_at AS last_used_at_unix_secs,
mt.last_used_ip,
@@ -120,6 +123,7 @@ SELECT
mt.description,
mt.token_prefix,
mt.allowed_ips,
mt.permissions,
mt.expires_at AS expires_at_unix_secs,
mt.last_used_at AS last_used_at_unix_secs,
mt.last_used_ip,
@@ -210,8 +214,8 @@ impl ManagementTokenWriteRepository for SqliteManagementTokenRepository {
r#"
INSERT INTO management_tokens (
id, user_id, token_hash, token_prefix, name, description, allowed_ips,
expires_at, is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
permissions, expires_at, is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&record.id)
@@ -221,6 +225,7 @@ INSERT INTO management_tokens (
.bind(&record.name)
.bind(record.description.as_deref())
.bind(json_to_string(record.allowed_ips.as_ref())?)
.bind(json_to_string(record.permissions.as_ref())?)
.bind(
record
.expires_at_unix_secs
@@ -261,6 +266,7 @@ INSERT INTO management_tokens (
} else {
record.allowed_ips.as_ref().or(current.allowed_ips.as_ref())
};
let permissions = record.permissions.as_ref().or(current.permissions.as_ref());
let expires_at = if record.clear_expires_at {
None
} else {
@@ -275,6 +281,7 @@ UPDATE management_tokens
SET name = ?,
description = ?,
allowed_ips = ?,
permissions = ?,
expires_at = ?,
is_active = ?,
updated_at = ?
@@ -284,6 +291,7 @@ WHERE id = ?
.bind(name)
.bind(description)
.bind(json_to_string(allowed_ips)?)
.bind(json_to_string(permissions)?)
.bind(expires_at.and_then(|value| i64::try_from(value).ok()))
.bind(is_active)
.bind(now as i64)
@@ -435,6 +443,7 @@ fn map_token_row(row: &SqliteRow) -> Result<StoredManagementToken, DataLayerErro
row.try_get("token_prefix").map_sql_err()?,
json_from_string(row.try_get("allowed_ips").map_sql_err()?)?,
)
.with_permissions(json_from_string(row.try_get("permissions").map_sql_err()?)?)
.with_runtime_fields(
optional_unix_secs(row.try_get("expires_at_unix_secs").map_sql_err()?),
optional_unix_secs(row.try_get("last_used_at_unix_secs").map_sql_err()?),
@@ -516,12 +525,17 @@ VALUES ('user-1', 'user-1@example.com', 'user-1', 'admin', 1, 1, 1)
name: "primary".to_string(),
description: Some("primary token".to_string()),
allowed_ips: Some(serde_json::json!(["127.0.0.1"])),
permissions: Some(serde_json::json!(["admin:usage:read"])),
expires_at_unix_secs: Some(1_800_000_000),
is_active: true,
})
.await
.expect("token should create");
assert_eq!(created.name, "primary");
assert_eq!(
created.permissions,
Some(serde_json::json!(["admin:usage:read"]))
);
let page = repository
.list_management_tokens(&ManagementTokenListQuery {
@@ -550,6 +564,7 @@ VALUES ('user-1', 'user-1@example.com', 'user-1', 'admin', 1, 1, 1)
clear_description: true,
allowed_ips: Some(serde_json::json!(["10.0.0.1"])),
clear_allowed_ips: false,
permissions: Some(serde_json::json!(["admin:usage:read", "admin:usage:write"])),
expires_at_unix_secs: None,
clear_expires_at: true,
is_active: Some(false),
@@ -560,6 +575,10 @@ VALUES ('user-1', 'user-1@example.com', 'user-1', 'admin', 1, 1, 1)
assert_eq!(updated.name, "renamed");
assert!(!updated.is_active);
assert_eq!(updated.description, None);
assert_eq!(
updated.permissions,
Some(serde_json::json!(["admin:usage:read", "admin:usage:write"]))
);
assert_eq!(updated.expires_at_unix_secs, None);
let toggled = repository

View File

@@ -47,6 +47,7 @@ pub struct StoredManagementToken {
pub description: Option<String>,
pub token_prefix: Option<String>,
pub allowed_ips: Option<serde_json::Value>,
pub permissions: Option<serde_json::Value>,
pub expires_at_unix_secs: Option<u64>,
pub last_used_at_unix_secs: Option<u64>,
pub last_used_ip: Option<String>,
@@ -80,6 +81,7 @@ impl StoredManagementToken {
description: None,
token_prefix: None,
allowed_ips: None,
permissions: None,
expires_at_unix_secs: None,
last_used_at_unix_secs: None,
last_used_ip: None,
@@ -102,6 +104,11 @@ impl StoredManagementToken {
self
}
pub fn with_permissions(mut self, permissions: Option<serde_json::Value>) -> Self {
self.permissions = permissions;
self
}
pub fn with_runtime_fields(
mut self,
expires_at_unix_secs: Option<u64>,
@@ -166,6 +173,7 @@ pub struct CreateManagementTokenRecord {
pub name: String,
pub description: Option<String>,
pub allowed_ips: Option<serde_json::Value>,
pub permissions: Option<serde_json::Value>,
pub expires_at_unix_secs: Option<u64>,
pub is_active: bool,
}
@@ -214,6 +222,7 @@ impl CreateManagementTokenRecord {
));
}
}
validate_management_token_permissions(self.permissions.as_ref())?;
Ok(())
}
}
@@ -226,6 +235,7 @@ pub struct UpdateManagementTokenRecord {
pub clear_description: bool,
pub allowed_ips: Option<serde_json::Value>,
pub clear_allowed_ips: bool,
pub permissions: Option<serde_json::Value>,
pub expires_at_unix_secs: Option<u64>,
pub clear_expires_at: bool,
pub is_active: Option<bool>,
@@ -262,10 +272,35 @@ impl UpdateManagementTokenRecord {
));
}
}
validate_management_token_permissions(self.permissions.as_ref())?;
Ok(())
}
}
fn validate_management_token_permissions(
permissions: Option<&serde_json::Value>,
) -> Result<(), crate::DataLayerError> {
let Some(permissions) = permissions else {
return Ok(());
};
let Some(items) = permissions.as_array() else {
return Err(crate::DataLayerError::InvalidInput(
"permissions must be an array".to_string(),
));
};
if items.is_empty() {
return Err(crate::DataLayerError::InvalidInput(
"permissions must not be empty".to_string(),
));
}
if items.iter().any(|value| value.as_str().is_none()) {
return Err(crate::DataLayerError::InvalidInput(
"permissions must contain only strings".to_string(),
));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RegenerateManagementTokenSecret {
pub token_id: String,