mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -3,7 +3,7 @@ use sqlx::{postgres::PgRow, PgPool, Postgres, QueryBuilder, Row};
|
||||
|
||||
use aether_data_contracts::repository::auth_modules::*;
|
||||
use aether_data_contracts::DataLayerError;
|
||||
use aether_data_query::{push_eq, push_limit, WhereClause};
|
||||
use aether_data_query::{push_eq, WhereClause};
|
||||
|
||||
use crate::error::SqlxResultExt;
|
||||
|
||||
@@ -34,7 +34,37 @@ SELECT
|
||||
FROM ldap_configs
|
||||
"#;
|
||||
|
||||
const UPDATE_LDAP_CONFIG_SQL: &str = r#"
|
||||
const UPDATE_LDAP_CONFIG_PRESERVE_PASSWORD_SQL: &str = r#"
|
||||
UPDATE ldap_configs
|
||||
SET
|
||||
server_url = $1,
|
||||
bind_dn = $2,
|
||||
base_dn = $3,
|
||||
user_search_filter = $4,
|
||||
username_attr = $5,
|
||||
email_attr = $6,
|
||||
display_name_attr = $7,
|
||||
is_enabled = $8,
|
||||
is_exclusive = $9,
|
||||
use_starttls = $10,
|
||||
connect_timeout = $11,
|
||||
updated_at = NOW()
|
||||
WHERE singleton_key = 1
|
||||
AND server_url IS NOT DISTINCT FROM $12
|
||||
AND bind_dn IS NOT DISTINCT FROM $13
|
||||
AND bind_password_encrypted IS NOT DISTINCT FROM $14
|
||||
AND base_dn IS NOT DISTINCT FROM $15
|
||||
AND user_search_filter IS NOT DISTINCT FROM $16
|
||||
AND username_attr IS NOT DISTINCT FROM $17
|
||||
AND email_attr IS NOT DISTINCT FROM $18
|
||||
AND display_name_attr IS NOT DISTINCT FROM $19
|
||||
AND is_enabled IS NOT DISTINCT FROM $20
|
||||
AND is_exclusive IS NOT DISTINCT FROM $21
|
||||
AND use_starttls IS NOT DISTINCT FROM $22
|
||||
AND connect_timeout IS NOT DISTINCT FROM $23
|
||||
"#;
|
||||
|
||||
const UPDATE_LDAP_CONFIG_REPLACE_PASSWORD_SQL: &str = r#"
|
||||
UPDATE ldap_configs
|
||||
SET
|
||||
server_url = $1,
|
||||
@@ -50,29 +80,24 @@ SET
|
||||
use_starttls = $11,
|
||||
connect_timeout = $12,
|
||||
updated_at = NOW()
|
||||
WHERE id = (
|
||||
SELECT id
|
||||
FROM ldap_configs
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING
|
||||
server_url,
|
||||
bind_dn,
|
||||
bind_password_encrypted,
|
||||
base_dn,
|
||||
user_search_filter,
|
||||
username_attr,
|
||||
email_attr,
|
||||
display_name_attr,
|
||||
is_enabled,
|
||||
is_exclusive,
|
||||
use_starttls,
|
||||
connect_timeout
|
||||
WHERE singleton_key = 1
|
||||
AND server_url IS NOT DISTINCT FROM $13
|
||||
AND bind_dn IS NOT DISTINCT FROM $14
|
||||
AND bind_password_encrypted IS NOT DISTINCT FROM $15
|
||||
AND base_dn IS NOT DISTINCT FROM $16
|
||||
AND user_search_filter IS NOT DISTINCT FROM $17
|
||||
AND username_attr IS NOT DISTINCT FROM $18
|
||||
AND email_attr IS NOT DISTINCT FROM $19
|
||||
AND display_name_attr IS NOT DISTINCT FROM $20
|
||||
AND is_enabled IS NOT DISTINCT FROM $21
|
||||
AND is_exclusive IS NOT DISTINCT FROM $22
|
||||
AND use_starttls IS NOT DISTINCT FROM $23
|
||||
AND connect_timeout IS NOT DISTINCT FROM $24
|
||||
"#;
|
||||
|
||||
const INSERT_LDAP_CONFIG_SQL: &str = r#"
|
||||
INSERT INTO ldap_configs (
|
||||
singleton_key,
|
||||
server_url,
|
||||
bind_dn,
|
||||
bind_password_encrypted,
|
||||
@@ -89,6 +114,7 @@ INSERT INTO ldap_configs (
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
1,
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
@@ -104,19 +130,6 @@ VALUES (
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
RETURNING
|
||||
server_url,
|
||||
bind_dn,
|
||||
bind_password_encrypted,
|
||||
base_dn,
|
||||
user_search_filter,
|
||||
username_attr,
|
||||
email_attr,
|
||||
display_name_attr,
|
||||
is_enabled,
|
||||
is_exclusive,
|
||||
use_starttls,
|
||||
connect_timeout
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -154,8 +167,7 @@ async fn list_enabled_oauth_providers(
|
||||
|
||||
async fn get_ldap_config(pool: &PgPool) -> Result<Option<StoredLdapModuleConfig>, DataLayerError> {
|
||||
let mut builder = QueryBuilder::<Postgres>::new(LDAP_CONFIG_COLUMNS);
|
||||
builder.push(" ORDER BY id ASC");
|
||||
push_limit(&mut builder, 1);
|
||||
builder.push(" WHERE singleton_key = 1");
|
||||
let row = builder
|
||||
.build()
|
||||
.fetch_optional(pool)
|
||||
@@ -192,48 +204,203 @@ impl AuthModuleReadRepository for SqlxAuthModuleRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl AuthModuleWriteRepository for SqlxAuthModuleRepository {
|
||||
async fn upsert_ldap_config(
|
||||
async fn compare_and_swap_ldap_config(
|
||||
&self,
|
||||
config: &StoredLdapModuleConfig,
|
||||
) -> Result<Option<StoredLdapModuleConfig>, DataLayerError> {
|
||||
let updated = sqlx::query(UPDATE_LDAP_CONFIG_SQL)
|
||||
.bind(&config.server_url)
|
||||
.bind(&config.bind_dn)
|
||||
.bind(config.bind_password_encrypted.as_deref())
|
||||
.bind(&config.base_dn)
|
||||
.bind(config.user_search_filter.as_deref())
|
||||
.bind(config.username_attr.as_deref())
|
||||
.bind(config.email_attr.as_deref())
|
||||
.bind(config.display_name_attr.as_deref())
|
||||
.bind(config.is_enabled)
|
||||
.bind(config.is_exclusive)
|
||||
.bind(config.use_starttls)
|
||||
.bind(config.connect_timeout)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
if let Some(row) = updated.as_ref() {
|
||||
return map_ldap_row(row).map(Some);
|
||||
}
|
||||
expected: Option<&StoredLdapModuleConfig>,
|
||||
replacement: &StoredLdapModuleConfig,
|
||||
bind_password_update: &LdapBindPasswordUpdate,
|
||||
) -> Result<CompareAndSwapLdapConfigResult, DataLayerError> {
|
||||
let persisted =
|
||||
ldap_config_after_password_update(expected, replacement, bind_password_update)?;
|
||||
|
||||
let inserted = sqlx::query(INSERT_LDAP_CONFIG_SQL)
|
||||
.bind(&config.server_url)
|
||||
.bind(&config.bind_dn)
|
||||
.bind(config.bind_password_encrypted.as_deref())
|
||||
.bind(&config.base_dn)
|
||||
.bind(config.user_search_filter.as_deref())
|
||||
.bind(config.username_attr.as_deref())
|
||||
.bind(config.email_attr.as_deref())
|
||||
.bind(config.display_name_attr.as_deref())
|
||||
.bind(config.is_enabled)
|
||||
.bind(config.is_exclusive)
|
||||
.bind(config.use_starttls)
|
||||
.bind(config.connect_timeout)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
inserted.as_ref().map(map_ldap_row).transpose()
|
||||
let Some(expected) = expected else {
|
||||
let insert = sqlx::query(INSERT_LDAP_CONFIG_SQL)
|
||||
.bind(&persisted.server_url)
|
||||
.bind(&persisted.bind_dn)
|
||||
.bind(persisted.bind_password_encrypted.as_deref())
|
||||
.bind(&persisted.base_dn)
|
||||
.bind(persisted.user_search_filter.as_deref())
|
||||
.bind(persisted.username_attr.as_deref())
|
||||
.bind(persisted.email_attr.as_deref())
|
||||
.bind(persisted.display_name_attr.as_deref())
|
||||
.bind(persisted.is_enabled)
|
||||
.bind(persisted.is_exclusive)
|
||||
.bind(persisted.use_starttls)
|
||||
.bind(persisted.connect_timeout)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
return match insert {
|
||||
Ok(result) if result.rows_affected() == 1 => {
|
||||
Ok(CompareAndSwapLdapConfigResult::Applied(persisted))
|
||||
}
|
||||
Ok(_) => Ok(CompareAndSwapLdapConfigResult::Conflict),
|
||||
Err(error)
|
||||
if error
|
||||
.as_database_error()
|
||||
.is_some_and(|error| error.is_unique_violation()) =>
|
||||
{
|
||||
Ok(CompareAndSwapLdapConfigResult::Conflict)
|
||||
}
|
||||
Err(error) => Err(crate::error::postgres_error(error)),
|
||||
};
|
||||
};
|
||||
|
||||
let rows_affected = match bind_password_update {
|
||||
LdapBindPasswordUpdate::Preserve => {
|
||||
sqlx::query(UPDATE_LDAP_CONFIG_PRESERVE_PASSWORD_SQL)
|
||||
.bind(&replacement.server_url)
|
||||
.bind(&replacement.bind_dn)
|
||||
.bind(&replacement.base_dn)
|
||||
.bind(replacement.user_search_filter.as_deref())
|
||||
.bind(replacement.username_attr.as_deref())
|
||||
.bind(replacement.email_attr.as_deref())
|
||||
.bind(replacement.display_name_attr.as_deref())
|
||||
.bind(replacement.is_enabled)
|
||||
.bind(replacement.is_exclusive)
|
||||
.bind(replacement.use_starttls)
|
||||
.bind(replacement.connect_timeout)
|
||||
.bind(&expected.server_url)
|
||||
.bind(&expected.bind_dn)
|
||||
.bind(expected.bind_password_encrypted.as_deref())
|
||||
.bind(&expected.base_dn)
|
||||
.bind(expected.user_search_filter.as_deref())
|
||||
.bind(expected.username_attr.as_deref())
|
||||
.bind(expected.email_attr.as_deref())
|
||||
.bind(expected.display_name_attr.as_deref())
|
||||
.bind(expected.is_enabled)
|
||||
.bind(expected.is_exclusive)
|
||||
.bind(expected.use_starttls)
|
||||
.bind(expected.connect_timeout)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.rows_affected()
|
||||
}
|
||||
LdapBindPasswordUpdate::Set(_) | LdapBindPasswordUpdate::Clear => {
|
||||
sqlx::query(UPDATE_LDAP_CONFIG_REPLACE_PASSWORD_SQL)
|
||||
.bind(&replacement.server_url)
|
||||
.bind(&replacement.bind_dn)
|
||||
.bind(persisted.bind_password_encrypted.as_deref())
|
||||
.bind(&replacement.base_dn)
|
||||
.bind(replacement.user_search_filter.as_deref())
|
||||
.bind(replacement.username_attr.as_deref())
|
||||
.bind(replacement.email_attr.as_deref())
|
||||
.bind(replacement.display_name_attr.as_deref())
|
||||
.bind(replacement.is_enabled)
|
||||
.bind(replacement.is_exclusive)
|
||||
.bind(replacement.use_starttls)
|
||||
.bind(replacement.connect_timeout)
|
||||
.bind(&expected.server_url)
|
||||
.bind(&expected.bind_dn)
|
||||
.bind(expected.bind_password_encrypted.as_deref())
|
||||
.bind(&expected.base_dn)
|
||||
.bind(expected.user_search_filter.as_deref())
|
||||
.bind(expected.username_attr.as_deref())
|
||||
.bind(expected.email_attr.as_deref())
|
||||
.bind(expected.display_name_attr.as_deref())
|
||||
.bind(expected.is_enabled)
|
||||
.bind(expected.is_exclusive)
|
||||
.bind(expected.use_starttls)
|
||||
.bind(expected.connect_timeout)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.rows_affected()
|
||||
}
|
||||
};
|
||||
if rows_affected == 1 {
|
||||
Ok(CompareAndSwapLdapConfigResult::Applied(persisted))
|
||||
} else {
|
||||
Ok(CompareAndSwapLdapConfigResult::Conflict)
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_ldap_config_if_matches(
|
||||
&self,
|
||||
expected: &StoredLdapModuleConfig,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
let rows_affected = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM ldap_configs
|
||||
WHERE singleton_key = 1
|
||||
AND server_url IS NOT DISTINCT FROM $1
|
||||
AND bind_dn IS NOT DISTINCT FROM $2
|
||||
AND bind_password_encrypted IS NOT DISTINCT FROM $3
|
||||
AND base_dn IS NOT DISTINCT FROM $4
|
||||
AND user_search_filter IS NOT DISTINCT FROM $5
|
||||
AND username_attr IS NOT DISTINCT FROM $6
|
||||
AND email_attr IS NOT DISTINCT FROM $7
|
||||
AND display_name_attr IS NOT DISTINCT FROM $8
|
||||
AND is_enabled IS NOT DISTINCT FROM $9
|
||||
AND is_exclusive IS NOT DISTINCT FROM $10
|
||||
AND use_starttls IS NOT DISTINCT FROM $11
|
||||
AND connect_timeout IS NOT DISTINCT FROM $12
|
||||
"#,
|
||||
)
|
||||
.bind(&expected.server_url)
|
||||
.bind(&expected.bind_dn)
|
||||
.bind(expected.bind_password_encrypted.as_deref())
|
||||
.bind(&expected.base_dn)
|
||||
.bind(expected.user_search_filter.as_deref())
|
||||
.bind(expected.username_attr.as_deref())
|
||||
.bind(expected.email_attr.as_deref())
|
||||
.bind(expected.display_name_attr.as_deref())
|
||||
.bind(expected.is_enabled)
|
||||
.bind(expected.is_exclusive)
|
||||
.bind(expected.use_starttls)
|
||||
.bind(expected.connect_timeout)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.rows_affected();
|
||||
Ok(rows_affected == 1)
|
||||
}
|
||||
|
||||
async fn compare_and_swap_ldap_bind_password(
|
||||
&self,
|
||||
expected: &str,
|
||||
replacement: &str,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
let rows_affected = sqlx::query(
|
||||
r#"
|
||||
UPDATE ldap_configs
|
||||
SET bind_password_encrypted = $1, updated_at = NOW()
|
||||
WHERE singleton_key = 1
|
||||
AND bind_password_encrypted = $2
|
||||
"#,
|
||||
)
|
||||
.bind(replacement)
|
||||
.bind(expected)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.rows_affected();
|
||||
Ok(rows_affected == 1)
|
||||
}
|
||||
}
|
||||
|
||||
fn ldap_config_after_password_update(
|
||||
expected: Option<&StoredLdapModuleConfig>,
|
||||
replacement: &StoredLdapModuleConfig,
|
||||
bind_password_update: &LdapBindPasswordUpdate,
|
||||
) -> Result<StoredLdapModuleConfig, DataLayerError> {
|
||||
let bind_password_encrypted = match bind_password_update {
|
||||
LdapBindPasswordUpdate::Preserve => expected
|
||||
.ok_or_else(|| {
|
||||
DataLayerError::InvalidConfiguration(
|
||||
"LDAP bind password cannot be preserved while creating the singleton"
|
||||
.to_string(),
|
||||
)
|
||||
})?
|
||||
.bind_password_encrypted
|
||||
.clone(),
|
||||
LdapBindPasswordUpdate::Set(ciphertext) => Some(ciphertext.clone()),
|
||||
LdapBindPasswordUpdate::Clear => None,
|
||||
};
|
||||
Ok(StoredLdapModuleConfig {
|
||||
bind_password_encrypted,
|
||||
..replacement.clone()
|
||||
})
|
||||
}
|
||||
|
||||
fn map_oauth_row(row: &PgRow) -> Result<StoredOAuthProviderModuleConfig, DataLayerError> {
|
||||
|
||||
Reference in New Issue
Block a user