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

@@ -0,0 +1,3 @@
ALTER TABLE management_tokens
ADD COLUMN permissions TEXT NULL AFTER allowed_ips;

View File

@@ -0,0 +1,3 @@
ALTER TABLE public.management_tokens
ADD COLUMN IF NOT EXISTS permissions json;

View File

@@ -0,0 +1,3 @@
ALTER TABLE management_tokens
ADD COLUMN permissions TEXT;

View File

@@ -117,6 +117,7 @@ CREATE TABLE IF NOT EXISTS management_tokens (
`token_hash` VARCHAR(255) NOT NULL,
`token_prefix` VARCHAR(64),
`allowed_ips` JSON,
`permissions` JSON,
`expires_at` BIGINT,
`last_used_at` BIGINT,
`last_used_ip` VARCHAR(255),

View File

@@ -122,6 +122,7 @@ CREATE TABLE IF NOT EXISTS public.management_tokens (
token_hash character varying(255) NOT NULL,
token_prefix character varying(64),
allowed_ips jsonb,
permissions jsonb,
expires_at bigint,
last_used_at bigint,
last_used_ip character varying(255),

View File

@@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS management_tokens (
token_hash TEXT NOT NULL,
token_prefix TEXT,
allowed_ips TEXT,
permissions TEXT,
expires_at INTEGER,
last_used_at INTEGER,
last_used_ip TEXT,

View File

@@ -499,6 +499,11 @@ name = "allowed_ips"
type = "json"
nullable = true
[[table.management_tokens.columns]]
name = "permissions"
type = "json"
nullable = true
[[table.management_tokens.columns]]
name = "expires_at"
type = "unix_seconds"

View File

@@ -7,7 +7,7 @@ use tracing::info;
// Generated by build.rs from schema/bootstrap/postgres.
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260507000000;
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260507120000;
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT

View File

@@ -292,6 +292,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
20260505000000,
20260505130000,
20260507000000,
20260507120000,
]
);
}
@@ -497,7 +498,7 @@ fn mysql_and_sqlite_migrations_do_not_use_postgres_jsonb() {
}
#[test]
fn mysql_and_sqlite_migrations_are_baseline_only_until_enabled() {
fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
let mysql_versions = super::mysql::MIGRATOR
.iter()
.filter(|migration| migration.migration_type.is_up_migration())
@@ -509,8 +510,8 @@ fn mysql_and_sqlite_migrations_are_baseline_only_until_enabled() {
.map(|migration| migration.version)
.collect::<Vec<_>>();
assert_eq!(mysql_versions, vec![20260403000000]);
assert_eq!(sqlite_versions, vec![20260403000000]);
assert_eq!(mysql_versions, vec![20260403000000, 20260507120000]);
assert_eq!(sqlite_versions, vec![20260403000000, 20260507120000]);
}
#[test]
@@ -1012,6 +1013,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260505000000,
20260505130000,
20260507000000,
20260507120000,
]
);
}

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,