Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/cache/src/cacher/cleanup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ mod tests {
shutdown: gradient_core::shutdown::Shutdown::new(),
jwt_secret: gradient_core::types::SecretString::new("test-jwt-secret".to_string()),
started_at: chrono::Utc::now(),
pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()),
})
}

Expand Down Expand Up @@ -523,6 +524,7 @@ mod tests {
shutdown: gradient_core::shutdown::Shutdown::new(),
jwt_secret: gradient_core::types::SecretString::new("test-jwt-secret".to_string()),
started_at: chrono::Utc::now(),
pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()),
});

cleanup_stale_cached_nars(state).await.unwrap();
Expand Down Expand Up @@ -613,6 +615,7 @@ mod tests {
shutdown: gradient_core::shutdown::Shutdown::new(),
jwt_secret: gradient_core::types::SecretString::new("test-jwt-secret".to_string()),
started_at: chrono::Utc::now(),
pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()),
});

cleanup_orphaned_cache_files(Arc::clone(&state))
Expand Down Expand Up @@ -643,6 +646,7 @@ mod tests {
shutdown: gradient_core::shutdown::Shutdown::new(),
jwt_secret: gradient_core::types::SecretString::new("test-jwt-secret".to_string()),
started_at: chrono::Utc::now(),
pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()),
})
}

Expand Down Expand Up @@ -717,6 +721,7 @@ mod tests {
shutdown: gradient_core::shutdown::Shutdown::new(),
jwt_secret: gradient_core::types::SecretString::new("test-jwt-secret".to_string()),
started_at: chrono::Utc::now(),
pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()),
});

cleanup_stale_build_request_blobs(state).await.unwrap();
Expand Down
1 change: 1 addition & 0 deletions backend/cache/src/cacher/deep_gc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ mod tests {
shutdown: gradient_core::shutdown::Shutdown::new(),
jwt_secret: gradient_core::types::SecretString::new("test-jwt-secret".to_string()),
started_at: chrono::Utc::now(),
pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()),
})
}

Expand Down
1 change: 1 addition & 0 deletions backend/core/src/ci/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,7 @@ mod tests {
shutdown: crate::shutdown::Shutdown::new(),
jwt_secret: SecretString::new("test-jwt-secret".to_string()),
started_at: chrono::Utc::now(),
pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()),
})
}

Expand Down
1 change: 1 addition & 0 deletions backend/core/src/db/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,7 @@ mod reelect_leader_tests {
shutdown: crate::shutdown::Shutdown::new(),
jwt_secret: SecretString::new("test-jwt-secret".to_string()),
started_at: chrono::Utc::now(),
pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()),
})
}

Expand Down
12 changes: 8 additions & 4 deletions backend/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ pub async fn init_state(cli: Cli) -> Arc<ServerState> {
}
};

if let Err(e) = load_and_apply_state(
let pending_org_memberships = match load_and_apply_state(
&db,
cli.storage.state_file.as_deref(),
&cli.secrets.crypt_secret_file,
Expand All @@ -72,9 +72,12 @@ pub async fn init_state(cli: Cli) -> Arc<ServerState> {
)
.await
{
tracing::error!(error = %e, "Failed to load state configuration");
std::process::exit(1);
}
Ok(p) => Arc::new(p),
Err(e) => {
tracing::error!(error = %e, "Failed to load state configuration");
std::process::exit(1);
}
};

if cli.storage.keep_evaluations > 0 {
let max = cli.storage.keep_evaluations as i32;
Expand Down Expand Up @@ -219,5 +222,6 @@ pub async fn init_state(cli: Cli) -> Arc<ServerState> {
shutdown: Shutdown::new(),
jwt_secret,
started_at: chrono::Utc::now(),
pending_org_memberships,
})
}
220 changes: 216 additions & 4 deletions backend/core/src/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

mod provisioning;

pub use provisioning::{
PendingOrgMembership, PendingOrgMemberships, apply_pending_org_memberships,
};

use crate::types::triggers::{ConcurrencyPolicy, TriggerType};
use entity::organization_cache::CacheSubscriptionMode;
use sea_orm::DatabaseConnection;
Expand Down Expand Up @@ -47,6 +51,20 @@ pub struct StateOrganization {
#[serde(default)]
pub github_installation_id: Option<i64>,
pub created_by: String,
/// Declarative org membership. Empty preserves the legacy behavior of
/// auto-adding `created_by` as Admin. Non-empty makes the list
/// authoritative: unmatched memberships are revoked, the implicit
/// creator-Admin assignment is skipped, and members referencing users
/// that do not yet exist are recorded as pending and applied at
/// registration / OIDC first-login.
#[serde(default)]
pub members: Vec<StateOrgMemberEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateOrgMemberEntry {
pub user: String,
pub role: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -342,6 +360,37 @@ impl StateConfiguration {
message: format!("User '{}' does not exist", org.created_by),
});
}

let declared_org_role_names: std::collections::HashSet<&str> = self
.roles
.values()
.filter(|r| r.organization == org.name)
.map(|r| r.name.as_str())
.collect();
let mut member_users_seen: std::collections::HashSet<&str> =
std::collections::HashSet::new();
for member in &org.members {
let builtin = matches!(member.role.as_str(), "Admin" | "Write" | "View");
if !builtin && !declared_org_role_names.contains(member.role.as_str()) {
errors.push(ValidationError {
field: format!("organizations.{}.members.{}.role", org.name, member.user),
message: format!(
"Role '{}' not found for organization '{}' (must be Admin/Write/View or a state-managed org role)",
member.role, org.name
),
});
}
if !member_users_seen.insert(member.user.as_str()) {
errors.push(ValidationError {
field: format!("organizations.{}.members.{}.user", org.name, member.user),
message: format!(
"Duplicate member entry for user '{}' in organization '{}'",
member.user, org.name
),
});
}
// Note: missing user is intentionally not an error (issue #94).
}
}

for project in self.projects.values() {
Expand Down Expand Up @@ -629,10 +678,10 @@ pub async fn load_and_apply_state(
crypt_secret_file: &str,
delete_state: bool,
email_enabled: bool,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<PendingOrgMemberships, Box<dyn std::error::Error>> {
let Some(path) = state_file_path else {
tracing::info!("No state file configured, skipping state management");
return Ok(());
return Ok(PendingOrgMemberships::new());
};

tracing::info!(path, "Loading state configuration");
Expand All @@ -656,7 +705,7 @@ pub async fn load_and_apply_state(

tracing::info!("State configuration validated successfully");

provisioning::apply_state_to_database(
let pending = provisioning::apply_state_to_database(
db,
&config,
crypt_secret_file,
Expand All @@ -665,7 +714,7 @@ pub async fn load_and_apply_state(
)
.await?;

Ok(())
Ok(pending)
}

#[cfg(test)]
Expand Down Expand Up @@ -1142,6 +1191,169 @@ mod tests {
);
}

#[test]
fn state_org_members_serde_round_trip() {
let json = r#"{
"organizations": {
"acme": {
"name": "acme",
"display_name": "ACME",
"private_key_file": "/dev/null",
"public": false,
"created_by": "alice",
"members": [
{ "user": "bob", "role": "Write" },
{ "user": "carol", "role": "releaser" }
]
}
}
}"#;
let cfg: StateConfiguration = serde_json::from_str(json).unwrap();
let members = &cfg.organizations["acme"].members;
assert_eq!(members.len(), 2);
assert_eq!(members[0].user, "bob");
assert_eq!(members[0].role, "Write");
assert_eq!(members[1].user, "carol");
assert_eq!(members[1].role, "releaser");
}

#[test]
fn state_org_members_default_empty() {
let json = r#"{
"organizations": {
"acme": {
"name": "acme",
"display_name": "ACME",
"private_key_file": "/dev/null",
"public": false,
"created_by": "alice"
}
}
}"#;
let cfg: StateConfiguration = serde_json::from_str(json).unwrap();
assert!(cfg.organizations["acme"].members.is_empty());
}

#[test]
fn state_org_members_validator_accepts_builtin_role() {
let json = r#"{
"users": {
"alice": { "username": "alice", "name": "Alice", "email": "a@x.io", "password_file": "/dev/null" },
"bob": { "username": "bob", "name": "Bob", "email": "b@x.io", "password_file": "/dev/null" }
},
"organizations": {
"acme": {
"name": "acme", "display_name": "ACME",
"private_key_file": "/dev/null", "public": false, "created_by": "alice",
"members": [{ "user": "bob", "role": "Write" }]
}
}
}"#;
let cfg: StateConfiguration = serde_json::from_str(json).unwrap();
let v = cfg.validate();
assert!(v.is_valid, "errors: {:?}", v.errors);
}

#[test]
fn state_org_members_validator_accepts_custom_org_role() {
let json = r#"{
"users": {
"alice": { "username": "alice", "name": "Alice", "email": "a@x.io", "password_file": "/dev/null" }
},
"organizations": {
"acme": {
"name": "acme", "display_name": "ACME",
"private_key_file": "/dev/null", "public": false, "created_by": "alice",
"members": [{ "user": "alice", "role": "releaser" }]
}
},
"roles": {
"releaser": { "name": "releaser", "organization": "acme", "permissions": ["viewOrg"] }
}
}"#;
let cfg: StateConfiguration = serde_json::from_str(json).unwrap();
let v = cfg.validate();
assert!(v.is_valid, "errors: {:?}", v.errors);
}

#[test]
fn state_org_members_validator_rejects_unknown_role() {
let json = r#"{
"users": {
"alice": { "username": "alice", "name": "Alice", "email": "a@x.io", "password_file": "/dev/null" }
},
"organizations": {
"acme": {
"name": "acme", "display_name": "ACME",
"private_key_file": "/dev/null", "public": false, "created_by": "alice",
"members": [{ "user": "alice", "role": "Ghost" }]
}
}
}"#;
let cfg: StateConfiguration = serde_json::from_str(json).unwrap();
let v = cfg.validate();
assert!(!v.is_valid);
assert!(
v.errors
.iter()
.any(|e| e.field == "organizations.acme.members.alice.role"),
"expected unknown-role error, got: {:?}",
v.errors
);
}

#[test]
fn state_org_members_validator_ignores_unknown_user() {
let json = r#"{
"users": {
"alice": { "username": "alice", "name": "Alice", "email": "a@x.io", "password_file": "/dev/null" }
},
"organizations": {
"acme": {
"name": "acme", "display_name": "ACME",
"private_key_file": "/dev/null", "public": false, "created_by": "alice",
"members": [{ "user": "ghost", "role": "Write" }]
}
}
}"#;
let cfg: StateConfiguration = serde_json::from_str(json).unwrap();
let v = cfg.validate();
assert!(
v.is_valid,
"missing user must not fail validation (issue #94): {:?}",
v.errors
);
}

#[test]
fn state_org_members_validator_rejects_duplicate_user() {
let json = r#"{
"users": {
"alice": { "username": "alice", "name": "Alice", "email": "a@x.io", "password_file": "/dev/null" }
},
"organizations": {
"acme": {
"name": "acme", "display_name": "ACME",
"private_key_file": "/dev/null", "public": false, "created_by": "alice",
"members": [
{ "user": "alice", "role": "Write" },
{ "user": "alice", "role": "View" }
]
}
}
}"#;
let cfg: StateConfiguration = serde_json::from_str(json).unwrap();
let v = cfg.validate();
assert!(!v.is_valid);
assert!(
v.errors
.iter()
.any(|e| e.message.contains("Duplicate member")),
"expected duplicate-member error, got: {:?}",
v.errors
);
}

#[test]
fn state_worker_rejects_unknown_organization_in_list() {
let cfg = worker_cfg(r#"["acme", "ghost"]"#);
Expand Down
Loading
Loading