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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user