adding some initial SSO providers, updating publish workflow
Some checks failed
CI / Rustfmt (push) Failing after 21s
CI / Cargo Audit & Deny (push) Failing after 33s
CI / Web Blocking Checks (push) Successful in 50s
CI / Security Blocking Checks (push) Successful in 7s
CI / Web Advisory Checks (push) Successful in 33s
CI / Security Advisory Checks (push) Successful in 34s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 1s
Publish Images And Chart / Publish init-packs (push) Failing after 11s
Publish Images And Chart / Publish init-user (push) Failing after 10s
Publish Images And Chart / Publish migrations (push) Failing after 11s
Publish Images And Chart / Publish sensor (push) Failing after 10s
Publish Images And Chart / Publish web (push) Failing after 10s
Publish Images And Chart / Publish worker (push) Failing after 10s
Publish Images And Chart / Publish api (push) Failing after 7s
Publish Images And Chart / Publish executor (push) Failing after 9s
Publish Images And Chart / Publish notifier (push) Failing after 10s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
CI / Clippy (push) Successful in 18m52s
CI / Tests (push) Has been cancelled

This commit is contained in:
2026-03-20 12:37:24 -05:00
parent 57fa3bf7cf
commit 4df621c5c8
18 changed files with 1456 additions and 12 deletions

View File

@@ -70,6 +70,7 @@ jsonschema = { workspace = true }
# HTTP client
reqwest = { workspace = true }
openidconnect = "4.0"
ldap3 = "0.12"
url = { workspace = true }
# Archive/compression

479
crates/api/src/auth/ldap.rs Normal file
View File

@@ -0,0 +1,479 @@
//! LDAP authentication helpers for username/password login.
use attune_common::{
config::LdapConfig,
repositories::{
identity::{CreateIdentityInput, IdentityRepository, UpdateIdentityInput},
Create, Update,
},
};
use ldap3::{dn_escape, ldap_escape, Ldap, LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::{
auth::jwt::{generate_access_token, generate_refresh_token},
dto::TokenResponse,
middleware::error::ApiError,
state::SharedState,
};
/// Claims extracted from the LDAP directory for an authenticated user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LdapUserClaims {
/// The LDAP server URL the user was authenticated against.
pub server_url: String,
/// The user's full distinguished name.
pub dn: String,
/// Login attribute value (uid, sAMAccountName, etc.).
pub login: Option<String>,
/// Email address.
pub email: Option<String>,
/// Display name (cn).
pub display_name: Option<String>,
/// Group memberships (memberOf values).
pub groups: Vec<String>,
}
/// The result of a successful LDAP authentication.
#[derive(Debug, Clone)]
pub struct LdapAuthenticatedIdentity {
pub token_response: TokenResponse,
}
/// Authenticate a user against the configured LDAP directory.
///
/// This performs a bind (either direct or search+bind) to verify
/// the user's credentials, then fetches their attributes and upserts
/// the identity in the database.
pub async fn authenticate(
state: &SharedState,
login: &str,
password: &str,
) -> Result<LdapAuthenticatedIdentity, ApiError> {
let ldap_config = ldap_config(state)?;
// Connect and authenticate
let claims = if ldap_config.bind_dn_template.is_some() {
direct_bind(&ldap_config, login, password).await?
} else {
search_and_bind(&ldap_config, login, password).await?
};
// Upsert identity in DB and issue JWT tokens
let identity = upsert_identity(state, &claims).await?;
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
let token_response = TokenResponse::new(
access_token,
refresh_token,
state.jwt_config.access_token_expiration,
)
.with_user(
identity.id,
identity.login.clone(),
identity.display_name.clone(),
);
Ok(LdapAuthenticatedIdentity { token_response })
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
fn ldap_config(state: &SharedState) -> Result<LdapConfig, ApiError> {
let config = state
.config
.security
.ldap
.clone()
.filter(|ldap| ldap.enabled)
.ok_or_else(|| {
ApiError::NotImplemented("LDAP authentication is not configured".to_string())
})?;
// Reject partial service-account configuration: having exactly one of
// search_bind_dn / search_bind_password is almost certainly a config
// error and would silently fall back to anonymous search, which is a
// very different security posture than the admin intended.
let has_dn = config.search_bind_dn.is_some();
let has_pw = config.search_bind_password.is_some();
if has_dn != has_pw {
let missing = if has_dn {
"search_bind_password"
} else {
"search_bind_dn"
};
return Err(ApiError::InternalServerError(format!(
"LDAP misconfiguration: search_bind_dn and search_bind_password must both be set \
or both be omitted (missing {missing})"
)));
}
Ok(config)
}
/// Build an `LdapConnSettings` from the config.
fn conn_settings(config: &LdapConfig) -> LdapConnSettings {
let mut settings = LdapConnSettings::new();
if config.starttls {
settings = settings.set_starttls(true);
}
if config.danger_skip_tls_verify {
settings = settings.set_no_tls_verify(true);
}
settings
}
/// Open a new LDAP connection.
async fn connect(config: &LdapConfig) -> Result<Ldap, ApiError> {
let settings = conn_settings(config);
let (conn, ldap) = LdapConnAsync::with_settings(settings, &config.url)
.await
.map_err(|err| {
ApiError::InternalServerError(format!("Failed to connect to LDAP server: {err}"))
})?;
// Drive the connection in the background
ldap3::drive!(conn);
Ok(ldap)
}
/// Direct-bind authentication: construct the DN from the template and bind.
async fn direct_bind(
config: &LdapConfig,
login: &str,
password: &str,
) -> Result<LdapUserClaims, ApiError> {
let template = config.bind_dn_template.as_deref().unwrap_or_default();
// Escape the login value for safe interpolation into a Distinguished Name
// (RFC 4514). Without this, characters like `,`, `+`, `"`, `\`, `<`, `>`,
// `;`, `=`, NUL, `#` (leading), or space (leading/trailing) in the username
// would alter the DN structure.
let escaped_login = dn_escape(login);
let bind_dn = template.replace("{login}", &escaped_login);
let mut ldap = connect(config).await?;
// Bind as the user
let result = ldap
.simple_bind(&bind_dn, password)
.await
.map_err(|err| ApiError::InternalServerError(format!("LDAP bind failed: {err}")))?;
if result.rc != 0 {
let _ = ldap.unbind().await;
return Err(ApiError::Unauthorized(
"Invalid LDAP credentials".to_string(),
));
}
// Fetch user attributes
let claims = fetch_user_attributes(config, &mut ldap, &bind_dn).await?;
let _ = ldap.unbind().await;
Ok(claims)
}
/// Search-and-bind authentication:
/// 1. Bind as the service account (or anonymous)
/// 2. Search for the user entry (must match exactly one)
/// 3. Re-bind as the user with their DN + password
async fn search_and_bind(
config: &LdapConfig,
login: &str,
password: &str,
) -> Result<LdapUserClaims, ApiError> {
let search_base = config.user_search_base.as_deref().ok_or_else(|| {
ApiError::InternalServerError(
"LDAP user_search_base is required when bind_dn_template is not set".to_string(),
)
})?;
let mut ldap = connect(config).await?;
// Step 1: Bind as service account or anonymous.
// Partial config (only one of dn/password) is already rejected by
// ldap_config(), so this match is exhaustive over valid states.
if let (Some(bind_dn), Some(bind_pw)) = (
config.search_bind_dn.as_deref(),
config.search_bind_password.as_deref(),
) {
let result = ldap.simple_bind(bind_dn, bind_pw).await.map_err(|err| {
ApiError::InternalServerError(format!("LDAP service bind failed: {err}"))
})?;
if result.rc != 0 {
let _ = ldap.unbind().await;
return Err(ApiError::InternalServerError(
"LDAP service account bind failed — check search_bind_dn and search_bind_password"
.to_string(),
));
}
}
// If no service account, we proceed with an anonymous connection (already connected)
// Step 2: Search for the user.
// Escape the login value for safe interpolation into an LDAP search filter
// (RFC 4515). Without this, characters like `(`, `)`, `*`, `\`, and NUL in
// the username could broaden the filter, match unintended entries, or break
// the search entirely.
let escaped_login = ldap_escape(login);
let filter = config.user_filter.replace("{login}", &escaped_login);
let attrs = vec![
config.login_attr.as_str(),
config.email_attr.as_str(),
config.display_name_attr.as_str(),
config.group_attr.as_str(),
"dn",
];
let (results, _result) = ldap
.search(search_base, Scope::Subtree, &filter, attrs)
.await
.map_err(|err| ApiError::InternalServerError(format!("LDAP user search failed: {err}")))?
.success()
.map_err(|err| ApiError::InternalServerError(format!("LDAP search error: {err}")))?;
// The search must return exactly one entry. Zero means the user was not
// found; more than one means the filter or directory layout is ambiguous
// and we must not guess which identity to authenticate.
let result_count = results.len();
if result_count == 0 {
let _ = ldap.unbind().await;
return Err(ApiError::Unauthorized(
"Invalid LDAP credentials".to_string(),
));
}
if result_count > 1 {
let _ = ldap.unbind().await;
return Err(ApiError::InternalServerError(format!(
"LDAP user search returned {result_count} entries (expected exactly 1) — \
tighten the user_filter or user_search_base to ensure uniqueness"
)));
}
// SAFETY: result_count == 1 guaranteed by the checks above.
let entry = results
.into_iter()
.next()
.expect("checked result_count == 1");
let search_entry = SearchEntry::construct(entry);
let user_dn = search_entry.dn.clone();
// Step 3: Re-bind as the user
let result = ldap
.simple_bind(&user_dn, password)
.await
.map_err(|err| ApiError::InternalServerError(format!("LDAP user bind failed: {err}")))?;
if result.rc != 0 {
let _ = ldap.unbind().await;
return Err(ApiError::Unauthorized(
"Invalid LDAP credentials".to_string(),
));
}
let claims = extract_claims(config, &search_entry);
let _ = ldap.unbind().await;
Ok(claims)
}
/// Fetch the user's LDAP attributes after a successful bind.
async fn fetch_user_attributes(
config: &LdapConfig,
ldap: &mut Ldap,
user_dn: &str,
) -> Result<LdapUserClaims, ApiError> {
let attrs = vec![
config.login_attr.as_str(),
config.email_attr.as_str(),
config.display_name_attr.as_str(),
config.group_attr.as_str(),
];
let (results, _result) = ldap
.search(user_dn, Scope::Base, "(objectClass=*)", attrs)
.await
.map_err(|err| {
ApiError::InternalServerError(format!(
"LDAP attribute fetch failed for DN {user_dn}: {err}"
))
})?
.success()
.map_err(|err| {
ApiError::InternalServerError(format!("LDAP attribute search error: {err}"))
})?;
let entry = results.into_iter().next().ok_or_else(|| {
ApiError::InternalServerError(format!("LDAP entry not found for DN: {user_dn}"))
})?;
let search_entry = SearchEntry::construct(entry);
Ok(extract_claims(config, &search_entry))
}
/// Extract user claims from an LDAP search entry.
fn extract_claims(config: &LdapConfig, entry: &SearchEntry) -> LdapUserClaims {
let first_attr =
|name: &str| -> Option<String> { entry.attrs.get(name).and_then(|v| v.first()).cloned() };
let groups = entry
.attrs
.get(&config.group_attr)
.cloned()
.unwrap_or_default();
LdapUserClaims {
server_url: config.url.clone(),
dn: entry.dn.clone(),
login: first_attr(&config.login_attr),
email: first_attr(&config.email_attr),
display_name: first_attr(&config.display_name_attr),
groups,
}
}
/// Upsert an identity row for the LDAP-authenticated user.
async fn upsert_identity(
state: &SharedState,
claims: &LdapUserClaims,
) -> Result<attune_common::models::identity::Identity, ApiError> {
let existing =
IdentityRepository::find_by_ldap_dn(&state.db, &claims.server_url, &claims.dn).await?;
let desired_login = derive_login(claims);
let display_name = claims.display_name.clone();
let attributes = json!({ "ldap": claims });
match existing {
Some(identity) => {
let updated = UpdateIdentityInput {
display_name,
password_hash: None,
attributes: Some(attributes),
};
IdentityRepository::update(&state.db, identity.id, updated)
.await
.map_err(Into::into)
}
None => {
// Avoid login collisions
let login = match IdentityRepository::find_by_login(&state.db, &desired_login).await? {
Some(_) => fallback_dn_login(claims),
None => desired_login,
};
IdentityRepository::create(
&state.db,
CreateIdentityInput {
login,
display_name,
password_hash: None,
attributes,
},
)
.await
.map_err(Into::into)
}
}
}
/// Derive the login name from LDAP claims.
fn derive_login(claims: &LdapUserClaims) -> String {
claims
.login
.clone()
.or_else(|| claims.email.clone())
.unwrap_or_else(|| fallback_dn_login(claims))
}
/// Generate a deterministic fallback login from the LDAP server URL + DN.
fn fallback_dn_login(claims: &LdapUserClaims) -> String {
let mut hasher = Sha256::new();
hasher.update(claims.server_url.as_bytes());
hasher.update(b":");
hasher.update(claims.dn.as_bytes());
let digest = hex::encode(hasher.finalize());
format!("ldap:{}", &digest[..24])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn direct_bind_dn_escapes_special_characters() {
// Simulate what direct_bind does with the template
let template = "uid={login},ou=users,dc=example,dc=com";
let malicious_login = "admin,ou=admins,dc=evil,dc=com";
let escaped = dn_escape(malicious_login);
let bind_dn = template.replace("{login}", &escaped);
// The commas in the login value must be escaped so they don't
// introduce additional RDN components.
assert!(
bind_dn.contains("\\2c"),
"commas in login must be escaped in DN: {bind_dn}"
);
assert!(
bind_dn.starts_with("uid=admin\\2cou\\3dadmins\\2cdc\\3devil\\2cdc\\3dcom,ou=users"),
"DN structure must be preserved: {bind_dn}"
);
}
#[test]
fn search_filter_escapes_special_characters() {
let filter_template = "(uid={login})";
let malicious_login = "admin)(|(uid=*))";
let escaped = ldap_escape(malicious_login);
let filter = filter_template.replace("{login}", &escaped);
// The parentheses and asterisk must be escaped so they don't
// alter the filter structure.
assert!(
!filter.contains(")("),
"parentheses in login must be escaped in filter: {filter}"
);
assert!(
filter.contains("\\28"),
"open-paren must be hex-escaped: {filter}"
);
assert!(
filter.contains("\\29"),
"close-paren must be hex-escaped: {filter}"
);
assert!(
filter.contains("\\2a"),
"asterisk must be hex-escaped: {filter}"
);
}
#[test]
fn dn_escape_preserves_safe_usernames() {
let safe = "jdoe";
let escaped = dn_escape(safe);
assert_eq!(escaped.as_ref(), "jdoe");
}
#[test]
fn filter_escape_preserves_safe_usernames() {
let safe = "jdoe";
let escaped = ldap_escape(safe);
assert_eq!(escaped.as_ref(), "jdoe");
}
#[test]
fn fallback_dn_login_is_deterministic() {
let claims = LdapUserClaims {
server_url: "ldap://ldap.example.com".to_string(),
dn: "uid=test,ou=users,dc=example,dc=com".to_string(),
login: None,
email: None,
display_name: None,
groups: vec![],
};
let a = fallback_dn_login(&claims);
let b = fallback_dn_login(&claims);
assert_eq!(a, b);
assert!(a.starts_with("ldap:"));
assert_eq!(a.len(), "ldap:".len() + 24);
}
}

View File

@@ -1,6 +1,7 @@
//! Authentication and authorization module
pub mod jwt;
pub mod ldap;
pub mod middleware;
pub mod oidc;
pub mod password;

View File

@@ -172,6 +172,26 @@ pub struct AuthSettingsResponse {
#[schema(example = "https://auth.example.com/assets/logo.svg")]
pub oidc_provider_icon_url: Option<String>,
/// Whether LDAP login is configured and enabled.
#[schema(example = false)]
pub ldap_enabled: bool,
/// Whether LDAP login should be shown by default.
#[schema(example = false)]
pub ldap_visible_by_default: bool,
/// Provider name for `?auth=<provider>`.
#[schema(example = "ldap")]
pub ldap_provider_name: Option<String>,
/// User-facing provider label for the login button.
#[schema(example = "Company LDAP")]
pub ldap_provider_label: Option<String>,
/// Optional icon URL shown beside the provider label.
#[schema(example = "https://ldap.example.com/assets/logo.svg")]
pub ldap_provider_icon_url: Option<String>,
/// Whether unauthenticated self-service registration is allowed.
#[schema(example = false)]
pub self_registration_enabled: bool,

View File

@@ -70,6 +70,7 @@ use crate::dto::{
// Authentication
crate::routes::auth::auth_settings,
crate::routes::auth::login,
crate::routes::auth::ldap_login,
crate::routes::auth::register,
crate::routes::auth::refresh_token,
crate::routes::auth::get_current_user,
@@ -239,6 +240,7 @@ use crate::dto::{
// Auth DTOs
LoginRequest,
crate::routes::auth::LdapLoginRequest,
RegisterRequest,
RefreshTokenRequest,
ChangePasswordRequest,
@@ -453,4 +455,43 @@ mod tests {
println!("Total API paths: {}", path_count);
println!("Total API operations: {}", operation_count);
}
#[test]
fn test_auth_endpoints_registered() {
let doc = ApiDoc::openapi();
let expected_auth_paths = vec![
"/auth/settings",
"/auth/login",
"/auth/ldap/login",
"/auth/register",
"/auth/refresh",
"/auth/me",
"/auth/change-password",
];
for path in &expected_auth_paths {
assert!(
doc.paths.paths.contains_key(*path),
"Expected auth endpoint {} to be registered in OpenAPI spec, but it was missing. \
Registered paths: {:?}",
path,
doc.paths.paths.keys().collect::<Vec<_>>()
);
}
}
#[test]
fn test_ldap_login_request_schema_registered() {
let doc = ApiDoc::openapi();
let components = doc.components.as_ref().expect("components should exist");
assert!(
components.schemas.contains_key("LdapLoginRequest"),
"Expected LdapLoginRequest schema to be registered in OpenAPI components. \
Registered schemas: {:?}",
components.schemas.keys().collect::<Vec<_>>()
);
}
}

View File

@@ -74,6 +74,7 @@ pub fn routes() -> Router<SharedState> {
.route("/login", post(login))
.route("/oidc/login", get(oidc_login))
.route("/callback", get(oidc_callback))
.route("/ldap/login", post(ldap_login))
.route("/logout", get(logout))
.route("/register", post(register))
.route("/refresh", post(refresh_token))
@@ -104,6 +105,13 @@ pub async fn auth_settings(
.as_ref()
.filter(|oidc| oidc.enabled);
let ldap = state
.config
.security
.ldap
.as_ref()
.filter(|ldap| ldap.enabled);
let response = AuthSettingsResponse {
authentication_enabled: state.config.security.enable_auth,
local_password_enabled: state.config.security.enable_auth,
@@ -112,9 +120,21 @@ pub async fn auth_settings(
oidc_enabled: oidc.is_some(),
oidc_visible_by_default: oidc.is_some() && state.config.security.login_page.show_oidc_login,
oidc_provider_name: oidc.map(|oidc| oidc.provider_name.clone()),
oidc_provider_label: oidc
.map(|oidc| oidc.provider_label.clone().unwrap_or_else(|| oidc.provider_name.clone())),
oidc_provider_label: oidc.map(|oidc| {
oidc.provider_label
.clone()
.unwrap_or_else(|| oidc.provider_name.clone())
}),
oidc_provider_icon_url: oidc.and_then(|oidc| oidc.provider_icon_url.clone()),
ldap_enabled: ldap.is_some(),
ldap_visible_by_default: ldap.is_some() && state.config.security.login_page.show_ldap_login,
ldap_provider_name: ldap.map(|ldap| ldap.provider_name.clone()),
ldap_provider_label: ldap.map(|ldap| {
ldap.provider_label
.clone()
.unwrap_or_else(|| ldap.provider_name.clone())
}),
ldap_provider_icon_url: ldap.and_then(|ldap| ldap.provider_icon_url.clone()),
self_registration_enabled: state.config.security.allow_self_registration,
};
@@ -369,6 +389,17 @@ pub async fn get_current_user(
Ok(Json(ApiResponse::new(response)))
}
/// Request body for LDAP login.
#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
pub struct LdapLoginRequest {
/// User login name (uid, sAMAccountName, etc.)
#[validate(length(min = 1, max = 255))]
pub login: String,
/// User password
#[validate(length(min = 1, max = 512))]
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct OidcLoginParams {
pub redirect_to: Option<String>,
@@ -401,6 +432,34 @@ pub async fn oidc_callback(
)
}
/// Authenticate via LDAP directory.
///
/// POST /auth/ldap/login
#[utoipa::path(
post,
path = "/auth/ldap/login",
tag = "auth",
request_body = LdapLoginRequest,
responses(
(status = 200, description = "Successfully authenticated via LDAP", body = inline(ApiResponse<TokenResponse>)),
(status = 401, description = "Invalid LDAP credentials"),
(status = 501, description = "LDAP not configured")
)
)]
pub async fn ldap_login(
State(state): State<SharedState>,
Json(payload): Json<LdapLoginRequest>,
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
payload
.validate()
.map_err(|e| ApiError::ValidationError(format!("Invalid LDAP login request: {e}")))?;
let authenticated =
crate::auth::ldap::authenticate(&state, &payload.login, &payload.password).await?;
Ok(Json(ApiResponse::new(authenticated.token_response)))
}
/// Logout the current browser session and optionally redirect through the provider logout flow.
pub async fn logout(
State(state): State<SharedState>,

View File

@@ -305,6 +305,126 @@ async fn test_login_nonexistent_user() {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
// ── LDAP auth tests ──────────────────────────────────────────────────
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_ldap_login_returns_501_when_not_configured() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.post(
"/auth/ldap/login",
json!({
"login": "jdoe",
"password": "secret"
}),
None,
)
.await
.expect("Failed to make request");
// LDAP is not configured in config.test.yaml, so the endpoint
// should return 501 Not Implemented.
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_ldap_login_validates_empty_login() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.post(
"/auth/ldap/login",
json!({
"login": "",
"password": "secret"
}),
None,
)
.await
.expect("Failed to make request");
// Validation should fail before we even check LDAP config
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_ldap_login_validates_empty_password() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.post(
"/auth/ldap/login",
json!({
"login": "jdoe",
"password": ""
}),
None,
)
.await
.expect("Failed to make request");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_ldap_login_validates_missing_fields() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.post("/auth/ldap/login", json!({}), None)
.await
.expect("Failed to make request");
// Missing required fields should return 422
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
// ── auth/settings LDAP field tests ──────────────────────────────────
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_auth_settings_includes_ldap_fields_disabled() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let response = ctx
.get("/auth/settings", None)
.await
.expect("Failed to make request");
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
// LDAP is not configured in config.test.yaml, so these should all
// reflect the disabled state.
assert_eq!(body["data"]["ldap_enabled"], false);
assert_eq!(body["data"]["ldap_visible_by_default"], false);
assert!(body["data"]["ldap_provider_name"].is_null());
assert!(body["data"]["ldap_provider_label"].is_null());
assert!(body["data"]["ldap_provider_icon_url"].is_null());
// Existing fields should still be present
assert!(body["data"]["authentication_enabled"].is_boolean());
assert!(body["data"]["local_password_enabled"].is_boolean());
assert!(body["data"]["oidc_enabled"].is_boolean());
assert!(body["data"]["self_registration_enabled"].is_boolean());
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_get_current_user() {

View File

@@ -307,6 +307,10 @@ pub struct SecurityConfig {
/// Optional OpenID Connect configuration for browser login.
#[serde(default)]
pub oidc: Option<OidcConfig>,
/// Optional LDAP configuration for username/password login against a directory.
#[serde(default)]
pub ldap: Option<LdapConfig>,
}
fn default_jwt_access_expiration() -> u64 {
@@ -327,6 +331,10 @@ pub struct LoginPageConfig {
/// Show the OIDC/SSO option by default when configured.
#[serde(default = "default_true")]
pub show_oidc_login: bool,
/// Show the LDAP option by default when configured.
#[serde(default = "default_true")]
pub show_ldap_login: bool,
}
impl Default for LoginPageConfig {
@@ -334,6 +342,7 @@ impl Default for LoginPageConfig {
Self {
show_local_login: true,
show_oidc_login: true,
show_ldap_login: true,
}
}
}
@@ -379,6 +388,95 @@ fn default_oidc_provider_name() -> String {
"oidc".to_string()
}
/// LDAP authentication configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LdapConfig {
/// Enable LDAP login flow.
#[serde(default)]
pub enabled: bool,
/// LDAP server URL (e.g., "ldap://ldap.example.com:389" or "ldaps://ldap.example.com:636").
pub url: String,
/// Bind DN template. Use `{login}` as placeholder for the user-supplied login.
/// Example: "uid={login},ou=users,dc=example,dc=com"
/// If not set, an anonymous bind is attempted first to search for the user.
pub bind_dn_template: Option<String>,
/// Base DN for user searches when bind_dn_template is not set.
/// Example: "ou=users,dc=example,dc=com"
pub user_search_base: Option<String>,
/// LDAP search filter template. Use `{login}` as placeholder.
/// Default: "(uid={login})"
#[serde(default = "default_ldap_user_filter")]
pub user_filter: String,
/// DN of a service account used to search for users (required when using search-based auth).
pub search_bind_dn: Option<String>,
/// Password for the search service account.
pub search_bind_password: Option<String>,
/// LDAP attribute to use as the login name. Default: "uid"
#[serde(default = "default_ldap_login_attr")]
pub login_attr: String,
/// LDAP attribute to use as the email. Default: "mail"
#[serde(default = "default_ldap_email_attr")]
pub email_attr: String,
/// LDAP attribute to use as the display name. Default: "cn"
#[serde(default = "default_ldap_display_name_attr")]
pub display_name_attr: String,
/// LDAP attribute that contains group membership. Default: "memberOf"
#[serde(default = "default_ldap_group_attr")]
pub group_attr: String,
/// Whether to use STARTTLS. Default: false
#[serde(default)]
pub starttls: bool,
/// Whether to skip TLS certificate verification (insecure!). Default: false
#[serde(default)]
pub danger_skip_tls_verify: bool,
/// Provider name used in login-page overrides such as `?auth=<provider_name>`.
#[serde(default = "default_ldap_provider_name")]
pub provider_name: String,
/// User-facing provider label shown on the login page.
pub provider_label: Option<String>,
/// Optional icon URL shown beside the provider label on the login page.
pub provider_icon_url: Option<String>,
}
fn default_ldap_provider_name() -> String {
"ldap".to_string()
}
fn default_ldap_user_filter() -> String {
"(uid={login})".to_string()
}
fn default_ldap_login_attr() -> String {
"uid".to_string()
}
fn default_ldap_email_attr() -> String {
"mail".to_string()
}
fn default_ldap_display_name_attr() -> String {
"cn".to_string()
}
fn default_ldap_group_attr() -> String {
"memberOf".to_string()
}
/// Worker configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerConfig {
@@ -753,6 +851,7 @@ impl Default for SecurityConfig {
allow_self_registration: false,
login_page: LoginPageConfig::default(),
oidc: None,
ldap: None,
}
}
}
@@ -1035,6 +1134,7 @@ mod tests {
allow_self_registration: false,
login_page: LoginPageConfig::default(),
oidc: None,
ldap: None,
},
worker: None,
sensor: None,
@@ -1057,4 +1157,102 @@ mod tests {
config.security.jwt_secret = None;
assert!(config.validate().is_err());
}
#[test]
fn test_ldap_config_defaults() {
let yaml = r#"
enabled: true
url: "ldap://localhost:389"
client_id: "test"
"#;
let cfg: LdapConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert!(cfg.enabled);
assert_eq!(cfg.url, "ldap://localhost:389");
assert_eq!(cfg.user_filter, "(uid={login})");
assert_eq!(cfg.login_attr, "uid");
assert_eq!(cfg.email_attr, "mail");
assert_eq!(cfg.display_name_attr, "cn");
assert_eq!(cfg.group_attr, "memberOf");
assert_eq!(cfg.provider_name, "ldap");
assert!(!cfg.starttls);
assert!(!cfg.danger_skip_tls_verify);
assert!(cfg.bind_dn_template.is_none());
assert!(cfg.user_search_base.is_none());
assert!(cfg.search_bind_dn.is_none());
assert!(cfg.search_bind_password.is_none());
assert!(cfg.provider_label.is_none());
assert!(cfg.provider_icon_url.is_none());
}
#[test]
fn test_ldap_config_full_deserialization() {
let yaml = r#"
enabled: true
url: "ldaps://ldap.corp.com:636"
bind_dn_template: "uid={login},ou=people,dc=corp,dc=com"
user_search_base: "ou=people,dc=corp,dc=com"
user_filter: "(sAMAccountName={login})"
search_bind_dn: "cn=svc,dc=corp,dc=com"
search_bind_password: "secret"
login_attr: "sAMAccountName"
email_attr: "userPrincipalName"
display_name_attr: "displayName"
group_attr: "memberOf"
starttls: true
danger_skip_tls_verify: true
provider_name: "corpldap"
provider_label: "Corporate Directory"
provider_icon_url: "https://corp.com/icon.svg"
"#;
let cfg: LdapConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert!(cfg.enabled);
assert_eq!(cfg.url, "ldaps://ldap.corp.com:636");
assert_eq!(
cfg.bind_dn_template.as_deref(),
Some("uid={login},ou=people,dc=corp,dc=com")
);
assert_eq!(
cfg.user_search_base.as_deref(),
Some("ou=people,dc=corp,dc=com")
);
assert_eq!(cfg.user_filter, "(sAMAccountName={login})");
assert_eq!(cfg.search_bind_dn.as_deref(), Some("cn=svc,dc=corp,dc=com"));
assert_eq!(cfg.search_bind_password.as_deref(), Some("secret"));
assert_eq!(cfg.login_attr, "sAMAccountName");
assert_eq!(cfg.email_attr, "userPrincipalName");
assert_eq!(cfg.display_name_attr, "displayName");
assert_eq!(cfg.group_attr, "memberOf");
assert!(cfg.starttls);
assert!(cfg.danger_skip_tls_verify);
assert_eq!(cfg.provider_name, "corpldap");
assert_eq!(cfg.provider_label.as_deref(), Some("Corporate Directory"));
assert_eq!(
cfg.provider_icon_url.as_deref(),
Some("https://corp.com/icon.svg")
);
}
#[test]
fn test_security_config_ldap_none_by_default() {
let yaml = r#"jwt_secret: "s""#;
let cfg: SecurityConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert!(cfg.ldap.is_none());
}
#[test]
fn test_login_page_show_ldap_default_true() {
let cfg: LoginPageConfig = serde_yaml_ng::from_str("{}").unwrap();
assert!(cfg.show_ldap_login);
}
#[test]
fn test_login_page_show_ldap_explicit_false() {
let cfg: LoginPageConfig = serde_yaml_ng::from_str("show_ldap_login: false").unwrap();
assert!(!cfg.show_ldap_login);
}
}

View File

@@ -180,6 +180,27 @@ impl IdentityRepository {
.await
.map_err(Into::into)
}
pub async fn find_by_ldap_dn<'e, E>(
executor: E,
server_url: &str,
dn: &str,
) -> Result<Option<Identity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, Identity>(
"SELECT id, login, display_name, password_hash, attributes, created, updated
FROM identity
WHERE attributes->'ldap'->>'server_url' = $1
AND attributes->'ldap'->>'dn' = $2",
)
.bind(server_url)
.bind(dn)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
}
// Permission Set Repository

View File

@@ -479,3 +479,173 @@ async fn test_identity_login_case_sensitive() {
.unwrap();
assert_eq!(found_upper.id, identity2.id);
}
// ── LDAP-specific tests ──────────────────────────────────────────────────────
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_find_by_ldap_dn_found() {
let pool = create_test_pool().await.unwrap();
let login = unique_pack_ref("ldap_found");
let server_url = "ldap://ldap.example.com";
let dn = "uid=jdoe,ou=users,dc=example,dc=com";
let input = CreateIdentityInput {
login: login.clone(),
display_name: Some("LDAP User".to_string()),
attributes: json!({
"ldap": {
"server_url": server_url,
"dn": dn,
"login": "jdoe",
"email": "jdoe@example.com"
}
}),
password_hash: None,
};
let created = IdentityRepository::create(&pool, input).await.unwrap();
let found = IdentityRepository::find_by_ldap_dn(&pool, server_url, dn)
.await
.unwrap()
.expect("LDAP identity not found");
assert_eq!(found.id, created.id);
assert_eq!(found.login, login);
assert_eq!(found.attributes["ldap"]["server_url"], server_url);
assert_eq!(found.attributes["ldap"]["dn"], dn);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_find_by_ldap_dn_not_found() {
let pool = create_test_pool().await.unwrap();
let found = IdentityRepository::find_by_ldap_dn(
&pool,
"ldap://nonexistent.example.com",
"uid=nobody,ou=users,dc=example,dc=com",
)
.await
.unwrap();
assert!(found.is_none());
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_find_by_ldap_dn_wrong_server() {
let pool = create_test_pool().await.unwrap();
let dn = "uid=jdoe,ou=users,dc=example,dc=com";
let input = CreateIdentityInput {
login: unique_pack_ref("ldap_wrong_srv"),
display_name: Some("Server A User".to_string()),
attributes: json!({
"ldap": {
"server_url": "ldap://server-a.example.com",
"dn": dn,
"login": "jdoe"
}
}),
password_hash: None,
};
IdentityRepository::create(&pool, input).await.unwrap();
// Search with same DN but different server — composite key must match both
let found = IdentityRepository::find_by_ldap_dn(&pool, "ldap://server-b.example.com", dn)
.await
.unwrap();
assert!(found.is_none());
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_find_by_ldap_dn_multiple_identities_different_servers() {
let pool = create_test_pool().await.unwrap();
let dn = "uid=shared,ou=users,dc=example,dc=com";
let server_a = "ldap://multi-a.example.com";
let server_b = "ldap://multi-b.example.com";
let input_a = CreateIdentityInput {
login: unique_pack_ref("ldap_multi_a"),
display_name: Some("User on Server A".to_string()),
attributes: json!({
"ldap": {
"server_url": server_a,
"dn": dn,
"login": "shared_a"
}
}),
password_hash: None,
};
let identity_a = IdentityRepository::create(&pool, input_a).await.unwrap();
let input_b = CreateIdentityInput {
login: unique_pack_ref("ldap_multi_b"),
display_name: Some("User on Server B".to_string()),
attributes: json!({
"ldap": {
"server_url": server_b,
"dn": dn,
"login": "shared_b"
}
}),
password_hash: None,
};
let identity_b = IdentityRepository::create(&pool, input_b).await.unwrap();
// Query server A — should return identity_a
let found_a = IdentityRepository::find_by_ldap_dn(&pool, server_a, dn)
.await
.unwrap()
.expect("Identity for server A not found");
assert_eq!(found_a.id, identity_a.id);
assert_eq!(found_a.attributes["ldap"]["server_url"], server_a);
// Query server B — should return identity_b
let found_b = IdentityRepository::find_by_ldap_dn(&pool, server_b, dn)
.await
.unwrap()
.expect("Identity for server B not found");
assert_eq!(found_b.id, identity_b.id);
assert_eq!(found_b.attributes["ldap"]["server_url"], server_b);
// Confirm they are distinct identities
assert_ne!(found_a.id, found_b.id);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_find_by_ldap_dn_ignores_oidc_attributes() {
let pool = create_test_pool().await.unwrap();
// Create an identity with OIDC attributes (no "ldap" key)
let input = CreateIdentityInput {
login: unique_pack_ref("ldap_oidc"),
display_name: Some("OIDC User".to_string()),
attributes: json!({
"oidc": {
"issuer": "https://auth.example.com",
"subject": "abc123",
"email": "oidc@example.com"
}
}),
password_hash: None,
};
IdentityRepository::create(&pool, input).await.unwrap();
// Searching by LDAP DN should not match OIDC-only identities
let found = IdentityRepository::find_by_ldap_dn(&pool, "https://auth.example.com", "abc123")
.await
.unwrap();
assert!(found.is_none());
}