diff --git a/backend/cache/src/cacher/cleanup.rs b/backend/cache/src/cacher/cleanup.rs index e6d99d8a..1af93857 100644 --- a/backend/cache/src/cacher/cleanup.rs +++ b/backend/cache/src/cacher/cleanup.rs @@ -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()), }) } @@ -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(); @@ -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)) @@ -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()), }) } @@ -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(); diff --git a/backend/cache/src/cacher/deep_gc.rs b/backend/cache/src/cacher/deep_gc.rs index 43e01e9c..b3156f6a 100644 --- a/backend/cache/src/cacher/deep_gc.rs +++ b/backend/cache/src/cacher/deep_gc.rs @@ -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()), }) } diff --git a/backend/core/src/ci/actions.rs b/backend/core/src/ci/actions.rs index dfed70cd..ff27d87e 100644 --- a/backend/core/src/ci/actions.rs +++ b/backend/core/src/ci/actions.rs @@ -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()), }) } diff --git a/backend/core/src/db/status.rs b/backend/core/src/db/status.rs index 84e8ce31..e07069cf 100644 --- a/backend/core/src/db/status.rs +++ b/backend/core/src/db/status.rs @@ -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()), }) } diff --git a/backend/core/src/lib.rs b/backend/core/src/lib.rs index 5ec95f2a..72937451 100644 --- a/backend/core/src/lib.rs +++ b/backend/core/src/lib.rs @@ -63,7 +63,7 @@ pub async fn init_state(cli: Cli) -> Arc { } }; - 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, @@ -72,9 +72,12 @@ pub async fn init_state(cli: Cli) -> Arc { ) .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; @@ -219,5 +222,6 @@ pub async fn init_state(cli: Cli) -> Arc { shutdown: Shutdown::new(), jwt_secret, started_at: chrono::Utc::now(), + pending_org_memberships, }) } diff --git a/backend/core/src/state/mod.rs b/backend/core/src/state/mod.rs index 6eacfb09..9bde8b09 100644 --- a/backend/core/src/state/mod.rs +++ b/backend/core/src/state/mod.rs @@ -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; @@ -47,6 +51,20 @@ pub struct StateOrganization { #[serde(default)] pub github_installation_id: Option, 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateOrgMemberEntry { + pub user: String, + pub role: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -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() { @@ -629,10 +678,10 @@ pub async fn load_and_apply_state( crypt_secret_file: &str, delete_state: bool, email_enabled: bool, -) -> Result<(), Box> { +) -> Result> { 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"); @@ -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, @@ -665,7 +714,7 @@ pub async fn load_and_apply_state( ) .await?; - Ok(()) + Ok(pending) } #[cfg(test)] @@ -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"]"#); diff --git a/backend/core/src/state/provisioning.rs b/backend/core/src/state/provisioning.rs index 34e0eb59..53df50d9 100644 --- a/backend/core/src/state/provisioning.rs +++ b/backend/core/src/state/provisioning.rs @@ -6,13 +6,15 @@ use super::{ StateAction, StateApiKey, StateCache, StateCacheMemberEntry, StateCacheRoleEntry, - StateConfiguration, StateFlakeInputOverride, StateIntegration, StateOrganization, StateProject, - StateRole, StateTrigger, StateUpstream, StateUser, StateWorker, + StateConfiguration, StateFlakeInputOverride, StateIntegration, StateOrganization, + StateOrgMemberEntry, StateProject, StateRole, StateTrigger, StateUpstream, StateUser, + StateWorker, }; use crate::ci::actions::encrypt_secret_with_file; use crate::ci::{ForgeType, GITHUB_APP_INTEGRATION_NAME, IntegrationKind}; use crate::types::consts::{ BASE_CACHE_ROLE_ADMIN_ID, BASE_CACHE_ROLE_VIEW_ID, BASE_CACHE_ROLE_WRITE_ID, BASE_ROLE_ADMIN_ID, + BASE_ROLE_VIEW_ID, BASE_ROLE_WRITE_ID, }; use crate::types::input::load_secret_bytes; use crate::types::triggers::TriggerConfig; @@ -31,6 +33,17 @@ use std::fs; type DynError = Box; +/// Org membership declared in state for a user who did not exist at apply +/// time. Drained per-username when the user is later registered or signs +/// in via OIDC for the first time. +#[derive(Debug, Clone)] +pub struct PendingOrgMembership { + pub organization: OrganizationId, + pub role: RoleId, +} + +pub type PendingOrgMemberships = HashMap>; + // ── Entry point ─────────────────────────────────────────────────────────────── pub(super) async fn apply_state_to_database( @@ -39,7 +52,7 @@ pub(super) async fn apply_state_to_database( crypt_secret_file: &str, delete_state: bool, email_enabled: bool, -) -> Result<(), DynError> { +) -> Result { tracing::info!("Applying state to database"); let app = StateApplicator { @@ -48,9 +61,14 @@ pub(super) async fn apply_state_to_database( email_enabled, }; + let mut pending: PendingOrgMemberships = HashMap::new(); + app.apply_users(&config.users).await?; - app.apply_organizations(&config.organizations).await?; + app.apply_organizations_without_members(&config.organizations) + .await?; app.apply_roles(&config.roles).await?; + app.apply_organization_members(&config.organizations, &mut pending) + .await?; app.apply_projects(&config.projects).await?; app.apply_integrations(&config.integrations).await?; app.apply_caches(&config.caches).await?; @@ -59,7 +77,7 @@ pub(super) async fn apply_state_to_database( app.unmark_removed_entities(config, delete_state).await?; tracing::info!("State applied successfully"); - Ok(()) + Ok(pending) } // ── Credential / lookup helpers ─────────────────────────────────────────────── @@ -283,9 +301,14 @@ impl<'a> StateApplicator<'a> { Ok(()) } - // ── apply_organizations ─────────────────────────────────────────────────── + // ── apply_organizations_without_members ─────────────────────────────────── - async fn apply_organizations( + /// Create/update the `organization` row (and seed the GitHub App + /// integration if needed). Membership reconciliation happens later in + /// `apply_organization_members`, after `apply_roles` so custom org roles + /// referenced by `members` can be resolved against rows inserted in the + /// same apply pass. + async fn apply_organizations_without_members( &self, state_orgs: &HashMap, ) -> Result<(), DynError> { @@ -362,24 +385,178 @@ impl<'a> StateApplicator<'a> { crate::ci::ensure_github_app_integrations(self.db, org_id, created_by_id).await?; } - let existing_membership = organization_user::Entity::find() - .filter(organization_user::Column::Organization.eq(org_id)) - .filter(organization_user::Column::User.eq(created_by_id)) - .one(self.db) - .await?; + } - if existing_membership.is_none() { - let membership = organization_user::ActiveModel { - id: Set(OrganizationUserId::now_v7()), - organization: Set(org_id), - user: Set(created_by_id), - role: Set(BASE_ROLE_ADMIN_ID), - }; - membership.insert(self.db).await?; + Ok(()) + } + + // ── apply_organization_members ──────────────────────────────────────────── + + /// Reconcile `organization_user` rows for every state-managed org. + /// + /// When `state_org.members` is empty, the legacy behavior applies: + /// `created_by` is added as Admin if no row exists. When `members` is + /// non-empty, the declared list is authoritative — see + /// [`StateApplicator::apply_org_members`] for the per-org logic. + async fn apply_organization_members( + &self, + state_orgs: &HashMap, + pending: &mut PendingOrgMemberships, + ) -> Result<(), DynError> { + let user_map = self.user_lookup().await?; + let org_map = self.org_lookup().await?; + + for state_org in state_orgs.values() { + let org_id = lookup_id(&org_map, &state_org.name, "Organization")?; + let created_by_id = lookup_id(&user_map, &state_org.created_by, "User")?; + + if state_org.members.is_empty() { + let existing = organization_user::Entity::find() + .filter(organization_user::Column::Organization.eq(org_id)) + .filter(organization_user::Column::User.eq(created_by_id)) + .one(self.db) + .await?; + + if existing.is_none() { + organization_user::ActiveModel { + id: Set(OrganizationUserId::now_v7()), + organization: Set(org_id), + user: Set(created_by_id), + role: Set(BASE_ROLE_ADMIN_ID), + } + .insert(self.db) + .await?; + tracing::info!( + username = %state_org.created_by, + organization = %state_org.name, + "Added admin member to organization" + ); + } + } else { + self.apply_org_members(org_id, &state_org.name, &state_org.members, pending) + .await + .map_err(|e| { + format!( + "Failed to apply members for organization '{}': {}", + state_org.name, e + ) + })?; + } + } + + Ok(()) + } + + /// Reconcile membership for a single state-managed organization whose + /// `members` list is non-empty. + /// + /// - Missing users are recorded into `pending` and skipped (issue #94); + /// they'll be applied when the user later registers or signs in via + /// OIDC. + /// - Built-in roles (`Admin`/`Write`/`View`) map to constant role IDs; + /// custom org roles resolve against `role` rows scoped to this org. + /// - Drift: existing memberships not in the declared user set are + /// deleted. State owns the membership list when explicitly declared. + async fn apply_org_members( + &self, + org_id: OrganizationId, + org_name: &str, + members: &[StateOrgMemberEntry], + pending: &mut PendingOrgMemberships, + ) -> Result<(), DynError> { + let user_map = self.user_lookup().await?; + + let custom_roles: HashMap = role::Entity::find() + .filter(role::Column::Organization.eq(org_id)) + .filter(role::Column::Managed.eq(true)) + .all(self.db) + .await? + .into_iter() + .map(|r| (r.name, r.id)) + .collect(); + + let mut declared_user_ids: HashSet = HashSet::new(); + + for member in members { + let role_id = match member.role.as_str() { + "Admin" => BASE_ROLE_ADMIN_ID, + "Write" => BASE_ROLE_WRITE_ID, + "View" => BASE_ROLE_VIEW_ID, + name => *custom_roles.get(name).ok_or_else(|| -> DynError { + format!( + "Organization '{}' member '{}' references unknown role '{}'", + org_name, member.user, name + ) + .into() + })?, + }; + + match user_map.get(&member.user).copied() { + Some(user_id) => { + declared_user_ids.insert(user_id); + let existing = organization_user::Entity::find() + .filter(organization_user::Column::Organization.eq(org_id)) + .filter(organization_user::Column::User.eq(user_id)) + .one(self.db) + .await?; + if let Some(row) = existing { + if row.role != role_id { + let mut active: organization_user::ActiveModel = row.into(); + active.role = Set(role_id); + active.update(self.db).await?; + tracing::info!( + organization = %org_name, + user = %member.user, + "Updated organization membership role" + ); + } + } else { + organization_user::ActiveModel { + id: Set(OrganizationUserId::now_v7()), + organization: Set(org_id), + user: Set(user_id), + role: Set(role_id), + } + .insert(self.db) + .await?; + tracing::info!( + organization = %org_name, + user = %member.user, + "Added organization member" + ); + } + } + None => { + tracing::info!( + organization = %org_name, + user = %member.user, + "Declared member not yet registered; deferring until user creation" + ); + pending + .entry(member.user.clone()) + .or_default() + .push(PendingOrgMembership { + organization: org_id, + role: role_id, + }); + } + } + } + + let existing = organization_user::Entity::find() + .filter(organization_user::Column::Organization.eq(org_id)) + .all(self.db) + .await?; + for row in existing { + if !declared_user_ids.contains(&row.user) { + let user_id = row.user; + organization_user::Entity::delete_by_id(row.id) + .exec(self.db) + .await?; tracing::info!( - username = %state_org.created_by, - organization = %state_org.name, - "Added admin member to organization" + organization = %org_name, + %user_id, + "Removed organization member no longer in state" ); } } @@ -1564,6 +1741,63 @@ impl<'a> StateApplicator<'a> { } } +// ── Pending-membership backfill ────────────────────────────────────────────── + +/// Apply any pending state-managed org memberships for `username` against +/// `user_id`. Idempotent: existing rows are updated to the declared role, +/// missing rows are inserted. Returns the number of memberships applied +/// (`Ok(0)` when the username has no pending entries). +/// +/// Called from the user-creation paths (`POST /user` and OIDC first-login) +/// so a member declared in state for a not-yet-registered user becomes +/// effective the instant that user joins. +pub async fn apply_pending_org_memberships( + db: &C, + pending: &PendingOrgMemberships, + username: &str, + user_id: UserId, +) -> Result { + let Some(entries) = pending.get(username) else { + return Ok(0); + }; + let mut applied = 0usize; + for entry in entries { + let existing = organization_user::Entity::find() + .filter(organization_user::Column::Organization.eq(entry.organization)) + .filter(organization_user::Column::User.eq(user_id)) + .one(db) + .await?; + match existing { + Some(row) if row.role == entry.role => {} + Some(row) => { + let mut active: organization_user::ActiveModel = row.into(); + active.role = Set(entry.role); + active.update(db).await?; + applied += 1; + } + None => { + organization_user::ActiveModel { + id: Set(OrganizationUserId::now_v7()), + organization: Set(entry.organization), + user: Set(user_id), + role: Set(entry.role), + } + .insert(db) + .await?; + applied += 1; + } + } + } + if applied > 0 { + tracing::info!( + username, + count = applied, + "Applied pending state-managed org memberships for newly-registered user" + ); + } + Ok(applied) +} + // ── Trigger sync ───────────────────────────────────────────────────────────── async fn apply_project_triggers( @@ -2316,6 +2550,22 @@ mod keep_set_tests { } } +#[cfg(test)] +mod pending_membership_tests { + use super::*; + use sea_orm::{DatabaseBackend, MockDatabase}; + + #[tokio::test] + async fn apply_pending_returns_zero_for_unknown_user() { + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); + let pending: PendingOrgMemberships = HashMap::new(); + let count = apply_pending_org_memberships(&db, &pending, "ghost", UserId::now_v7()) + .await + .unwrap(); + assert_eq!(count, 0); + } +} + #[cfg(test)] mod action_helper_tests { use super::*; diff --git a/backend/core/src/types/mod.rs b/backend/core/src/types/mod.rs index 03c26c3c..1106abe3 100644 --- a/backend/core/src/types/mod.rs +++ b/backend/core/src/types/mod.rs @@ -133,6 +133,11 @@ pub struct ServerState { /// Wall-clock time the process bootstrapped. Used to derive /// `gradient_uptime_seconds` for the metrics endpoint. pub started_at: chrono::DateTime, + /// Org memberships declared in state for users who did not exist at + /// apply time. Drained per-username when a user later registers or + /// signs in via OIDC for the first time. Empty when no state file is + /// configured or when every declared member already existed. + pub pending_org_memberships: Arc, } #[derive(Serialize, Deserialize, Debug)] diff --git a/backend/test-support/src/cache_fixture.rs b/backend/test-support/src/cache_fixture.rs index a63def03..35eaecc4 100644 --- a/backend/test-support/src/cache_fixture.rs +++ b/backend/test-support/src/cache_fixture.rs @@ -163,6 +163,7 @@ pub async fn public_cache_with_narinfo() -> Arc { shutdown: gradient_core::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()), }) } @@ -207,6 +208,7 @@ pub async fn public_cache_state() -> Arc { shutdown: gradient_core::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()), }) } @@ -256,6 +258,7 @@ pub async fn public_cache_with_nar() -> Arc { shutdown: gradient_core::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()), }); let compressed = synthetic_nar_zst().await; @@ -359,6 +362,7 @@ fn make_state( shutdown: gradient_core::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()), }) } @@ -429,6 +433,7 @@ pub async fn private_cache_state() -> Arc { shutdown: gradient_core::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()), }) } @@ -484,6 +489,7 @@ pub async fn private_cache_with_nar() -> Arc { shutdown: gradient_core::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()), }); let compressed = synthetic_nar_zst().await; diff --git a/backend/test-support/src/state.rs b/backend/test-support/src/state.rs index 4eeb1460..5309a130 100644 --- a/backend/test-support/src/state.rs +++ b/backend/test-support/src/state.rs @@ -36,6 +36,7 @@ pub fn test_state(db: DatabaseConnection) -> Arc { shutdown: gradient_core::shutdown::Shutdown::new(), jwt_secret: SecretString::new("test-jwt-secret".to_string()), started_at: chrono::Utc::now(), + pending_org_memberships: Arc::new(std::collections::HashMap::new()), }) } @@ -60,5 +61,6 @@ pub fn test_state_with_log_storage( shutdown: gradient_core::shutdown::Shutdown::new(), jwt_secret: SecretString::new("test-jwt-secret".to_string()), started_at: chrono::Utc::now(), + pending_org_memberships: Arc::new(std::collections::HashMap::new()), }) } diff --git a/backend/test-support/src/web.rs b/backend/test-support/src/web.rs index 82802197..fcb1d579 100644 --- a/backend/test-support/src/web.rs +++ b/backend/test-support/src/web.rs @@ -116,6 +116,7 @@ pub fn make_test_server_with( shutdown: gradient_core::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()), }); TestServer::new(web::create_router(state)) } diff --git a/backend/web/src/access.rs b/backend/web/src/access.rs index ffb1cc4d..3938670b 100644 --- a/backend/web/src/access.rs +++ b/backend/web/src/access.rs @@ -629,6 +629,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()), }) } diff --git a/backend/web/src/authorization/oidc.rs b/backend/web/src/authorization/oidc.rs index c322baa0..0e43e892 100644 --- a/backend/web/src/authorization/oidc.rs +++ b/backend/web/src/authorization/oidc.rs @@ -364,6 +364,21 @@ async fn create_or_update_user( .await .context("Failed to create user")?; + if let Err(e) = gradient_core::state::apply_pending_org_memberships( + &tx, + &state.pending_org_memberships, + &user.username, + user.id, + ) + .await + { + tracing::warn!( + error = %e, + username = %user.username, + "Failed to apply pending state-managed org memberships for new OIDC user" + ); + } + tx.commit() .await .context("Failed to commit OIDC user transaction")?; diff --git a/backend/web/src/endpoints/auth.rs b/backend/web/src/endpoints/auth.rs index 24b3d562..914de3bb 100644 --- a/backend/web/src/endpoints/auth.rs +++ b/backend/web/src/endpoints/auth.rs @@ -126,6 +126,21 @@ pub async fn post_basic_register( .await .map_err(|e| WebError::from_db_err(e, "User"))?; + if let Err(e) = gradient_core::state::apply_pending_org_memberships( + &state.web_db, + &state.pending_org_memberships, + &user.username, + user.id, + ) + .await + { + tracing::warn!( + error = %e, + username = %user.username, + "Failed to apply pending state-managed org memberships for new user" + ); + } + audit_record( &state.web_db, Some(user.id), diff --git a/backend/web/tests/actions.rs b/backend/web/tests/actions.rs index f9125e19..a41c9261 100644 --- a/backend/web/tests/actions.rs +++ b/backend/web/tests/actions.rs @@ -165,6 +165,7 @@ fn server_with_email( shutdown: gradient_core::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()), }); TestServer::new(create_router(state)) } diff --git a/backend/web/tests/auth_hardening.rs b/backend/web/tests/auth_hardening.rs index 3a213f78..81cc37db 100644 --- a/backend/web/tests/auth_hardening.rs +++ b/backend/web/tests/auth_hardening.rs @@ -85,6 +85,7 @@ fn server_with(web_db_setup: impl FnOnce(MockDatabase) -> MockDatabase) -> TestS shutdown: gradient_core::shutdown::Shutdown::new(), jwt_secret: SecretString::new(JWT_SECRET.to_string()), started_at: chrono::Utc::now(), + pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()), }); TestServer::new(create_router(state)) } diff --git a/backend/web/tests/auth_middleware.rs b/backend/web/tests/auth_middleware.rs index 49bd0ded..ee004bdb 100644 --- a/backend/web/tests/auth_middleware.rs +++ b/backend/web/tests/auth_middleware.rs @@ -48,6 +48,7 @@ fn server() -> TestServer { 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()), }); TestServer::new(create_router(state)) } diff --git a/backend/web/tests/body_size_limit.rs b/backend/web/tests/body_size_limit.rs index 03dbef80..6367e03d 100644 --- a/backend/web/tests/body_size_limit.rs +++ b/backend/web/tests/body_size_limit.rs @@ -45,6 +45,7 @@ fn make_state_with_limits(max_request_size: usize) -> Arc { 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()), }) } diff --git a/backend/web/tests/builds_download.rs b/backend/web/tests/builds_download.rs index cbb134ec..3c550410 100644 --- a/backend/web/tests/builds_download.rs +++ b/backend/web/tests/builds_download.rs @@ -267,6 +267,7 @@ fn listing_returns_products_from_db() { 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()), }); let router = create_router(state); @@ -345,6 +346,7 @@ fn download_streams_file_from_nar() { 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()), }); let router = create_router(state); diff --git a/backend/web/tests/cache_local_priority.rs b/backend/web/tests/cache_local_priority.rs index 54e94bda..183473bd 100644 --- a/backend/web/tests/cache_local_priority.rs +++ b/backend/web/tests/cache_local_priority.rs @@ -76,6 +76,7 @@ fn build_server(cache: entity::cache::Model, peer: &str) -> TestServer { 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()), }); let peer_addr: SocketAddr = format!("{peer}:0").parse().expect("valid peer addr"); diff --git a/backend/web/tests/commits_authorization.rs b/backend/web/tests/commits_authorization.rs index f1ee3255..61f19ec1 100644 --- a/backend/web/tests/commits_authorization.rs +++ b/backend/web/tests/commits_authorization.rs @@ -170,6 +170,7 @@ fn make_server(db: sea_orm::DatabaseConnection) -> TestServer { shutdown: gradient_core::shutdown::Shutdown::new(), jwt_secret: SecretString::new(JWT_SECRET.to_string()), started_at: chrono::Utc::now(), + pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()), }); TestServer::new(create_router(state)) } diff --git a/backend/web/tests/cross_org_follower_log_visible.rs b/backend/web/tests/cross_org_follower_log_visible.rs index 502eaed7..93be71f2 100644 --- a/backend/web/tests/cross_org_follower_log_visible.rs +++ b/backend/web/tests/cross_org_follower_log_visible.rs @@ -204,6 +204,7 @@ fn make_server(db: sea_orm::DatabaseConnection) -> TestServer { shutdown: gradient_core::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()), }); TestServer::new(create_router(state)) } diff --git a/backend/web/tests/entry_point_download.rs b/backend/web/tests/entry_point_download.rs index cd3a1a38..cf8a3005 100644 --- a/backend/web/tests/entry_point_download.rs +++ b/backend/web/tests/entry_point_download.rs @@ -167,6 +167,7 @@ fn make_state(db: sea_orm::DatabaseConnection) -> Arc { 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()), }) } diff --git a/backend/web/tests/entry_point_metrics.rs b/backend/web/tests/entry_point_metrics.rs index d5873f66..87319723 100644 --- a/backend/web/tests/entry_point_metrics.rs +++ b/backend/web/tests/entry_point_metrics.rs @@ -166,6 +166,7 @@ fn make_state(db: sea_orm::DatabaseConnection) -> Arc { 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()), }) } diff --git a/backend/web/tests/evals_artefacts.rs b/backend/web/tests/evals_artefacts.rs index 32c615eb..ab310f22 100644 --- a/backend/web/tests/evals_artefacts.rs +++ b/backend/web/tests/evals_artefacts.rs @@ -235,6 +235,7 @@ fn make_state(db: sea_orm::DatabaseConnection) -> Arc { 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()), }) } diff --git a/backend/web/tests/evaluation_builds_via.rs b/backend/web/tests/evaluation_builds_via.rs index ed930e24..2c512fa8 100644 --- a/backend/web/tests/evaluation_builds_via.rs +++ b/backend/web/tests/evaluation_builds_via.rs @@ -190,6 +190,7 @@ fn make_state(db: sea_orm::DatabaseConnection) -> Arc { 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()), }) } diff --git a/backend/web/tests/evaluation_builds_via_cross_org.rs b/backend/web/tests/evaluation_builds_via_cross_org.rs index 8b50f8aa..a708e346 100644 --- a/backend/web/tests/evaluation_builds_via_cross_org.rs +++ b/backend/web/tests/evaluation_builds_via_cross_org.rs @@ -207,6 +207,7 @@ fn make_server(db: sea_orm::DatabaseConnection) -> TestServer { shutdown: gradient_core::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()), }); TestServer::new(create_router(state)) } diff --git a/backend/web/tests/evaluations.rs b/backend/web/tests/evaluations.rs index 2a8baeaf..971d8827 100644 --- a/backend/web/tests/evaluations.rs +++ b/backend/web/tests/evaluations.rs @@ -175,6 +175,7 @@ fn make_server(db: sea_orm::DatabaseConnection) -> TestServer { shutdown: gradient_core::shutdown::Shutdown::new(), jwt_secret: SecretString::new(JWT_SECRET.to_string()), started_at: chrono::Utc::now(), + pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()), }); TestServer::new(create_router(state)) } diff --git a/backend/web/tests/forge_hooks.rs b/backend/web/tests/forge_hooks.rs index 553265b5..23d965f7 100644 --- a/backend/web/tests/forge_hooks.rs +++ b/backend/web/tests/forge_hooks.rs @@ -83,6 +83,7 @@ fn make_state( 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()), }) } diff --git a/backend/web/tests/metrics.rs b/backend/web/tests/metrics.rs index 0198e858..064fc574 100644 --- a/backend/web/tests/metrics.rs +++ b/backend/web/tests/metrics.rs @@ -62,6 +62,7 @@ fn state_with_metrics(enabled: bool, db: DatabaseConnection) -> Arc shutdown: gradient_core::shutdown::Shutdown::new(), jwt_secret: SecretString::new("test-jwt-secret".into()), started_at: chrono::Utc::now(), + pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()), }) } diff --git a/backend/web/tests/narinfo.rs b/backend/web/tests/narinfo.rs index bb6678d1..6bf13768 100644 --- a/backend/web/tests/narinfo.rs +++ b/backend/web/tests/narinfo.rs @@ -186,6 +186,7 @@ async fn narinfo_served_from_db_inner() { 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()), }); let router = create_router(state); @@ -330,6 +331,7 @@ async fn narinfo_unsigned_inner() { 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()), }); let router = create_router(state); diff --git a/backend/web/tests/oidc_errors.rs b/backend/web/tests/oidc_errors.rs index 15f8ea60..32ccc8db 100644 --- a/backend/web/tests/oidc_errors.rs +++ b/backend/web/tests/oidc_errors.rs @@ -61,6 +61,7 @@ fn server_with_broken_oidc() -> TestServer { 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()), }); TestServer::new(create_router(state)) } diff --git a/backend/web/tests/old_direct_build_gone.rs b/backend/web/tests/old_direct_build_gone.rs index 2dc29f79..76c049b6 100644 --- a/backend/web/tests/old_direct_build_gone.rs +++ b/backend/web/tests/old_direct_build_gone.rs @@ -40,6 +40,7 @@ fn make_state() -> Arc { 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()), }) } diff --git a/backend/web/tests/projects_sign_cache.rs b/backend/web/tests/projects_sign_cache.rs index 15718bdc..962bab7e 100644 --- a/backend/web/tests/projects_sign_cache.rs +++ b/backend/web/tests/projects_sign_cache.rs @@ -86,6 +86,7 @@ fn make_server(db: sea_orm::DatabaseConnection) -> TestServer { shutdown: gradient_core::shutdown::Shutdown::new(), jwt_secret: SecretString::new(JWT_SECRET.to_string()), started_at: chrono::Utc::now(), + pending_org_memberships: std::sync::Arc::new(std::collections::HashMap::new()), }); TestServer::new(create_router(state)) } diff --git a/backend/web/tests/rate_limit.rs b/backend/web/tests/rate_limit.rs index 7b4e22e0..9c76a172 100644 --- a/backend/web/tests/rate_limit.rs +++ b/backend/web/tests/rate_limit.rs @@ -39,6 +39,7 @@ fn make_state() -> Arc { 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()), }) } diff --git a/backend/web/tests/request_id.rs b/backend/web/tests/request_id.rs index a53fd2c7..c255cf20 100644 --- a/backend/web/tests/request_id.rs +++ b/backend/web/tests/request_id.rs @@ -42,6 +42,7 @@ fn make_state() -> Arc { 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()), }) } diff --git a/docs/src/tests.md b/docs/src/tests.md index c98821c4..284cc4f0 100644 --- a/docs/src/tests.md +++ b/docs/src/tests.md @@ -499,6 +499,33 @@ Backend (`cargo test -p core --lib state::tests`): `force_evaluation`; serde's default unknown-field handling drops it silently so existing deployments parse cleanly after the field's removal from the schema. +- `state_org_members_serde_round_trip` - `StateOrganization.members` + round-trips through JSON as `[{ user, role }]` entries, covering both + built-in (`Write`) and custom (`releaser`) role names. +- `state_org_members_default_empty` - omitting `members` deserialises + to an empty `Vec`, preserving the legacy creator-as-Admin behavior on + state files that predate the field (issue #94). +- `state_org_members_validator_accepts_builtin_role` - a member with + `role = "Write"` validates when the referenced user exists. +- `state_org_members_validator_accepts_custom_org_role` - a member can + reference a state-managed custom org role declared under + `state.roles` scoped to the same organization. +- `state_org_members_validator_rejects_unknown_role` - a role name + that is neither a built-in nor a declared org role yields a + validation error pinpointing + `organizations..members..role`. +- `state_org_members_validator_ignores_unknown_user` - a member + referencing a user that does not exist passes validation; the + membership is deferred and applied on registration / OIDC first-login + (issue #94 contract). +- `state_org_members_validator_rejects_duplicate_user` - two + member entries with the same `user` in one org's `members` is an + error. +- `pending_membership_tests::apply_pending_returns_zero_for_unknown_user` + (`backend/core/src/state/provisioning.rs`) - + `apply_pending_org_memberships` is a no-op when the username has no + pending entries; callable from any user-creation path without a + matching state declaration. - `keep_set_tests::keep_sets_track_inner_name_not_attrset_key` (`backend/core/src/state/provisioning.rs`) - `gradient-state.nix` exposes `name = mkOption { default = ; }` on users, diff --git a/docs/src/usage/state.md b/docs/src/usage/state.md index 31f9bbcb..751586e4 100644 --- a/docs/src/usage/state.md +++ b/docs/src/usage/state.md @@ -121,6 +121,38 @@ ssh-keygen -t ed25519 -N "" -f /run/secrets/acme-ssh-key | `public` | `false` | Visible to all users | | `github_installation_id` | `null` | GitHub App installation id to bind to this org (look it up on the App's "Install App" page on GitHub). Setting this enables outbound CI status reporting and webhook routing. When `null`, the field is left untouched on update so a webhook-recorded id survives reconciliation | | `created_by` | - | Username of creator (required) | +| `members` | `[]` | Per-org membership list. When non-empty, the list is authoritative (drift removes unlisted memberships, the implicit creator-Admin step is skipped). Empty preserves the legacy behavior. Members referencing not-yet-registered users are skipped silently and backfilled on registration / OIDC first-login | + +### Organization members + +Declare per-org membership inline: + +```nix +services.gradient.state.organizations.acme = { + display_name = "ACME Corp"; + private_key_file = "/run/secrets/acme-ssh-key"; + created_by = "alice"; + members = [ + { user = "alice"; role = "Admin"; } + { user = "bob"; role = "Write"; } + { user = "carol"; role = "releaser"; } # custom org role from state.roles + ]; +}; +``` + +When `members` is **empty** (the default), the `created_by` user is added as Admin and no other membership reconciliation happens — this is the legacy behavior. + +When `members` is **non-empty**, the list is the source of truth: + +- Built-in roles (`Admin`, `Write`, `View`) and state-managed custom org roles are both accepted. +- Members referencing **unknown users are skipped silently** at provision time. The membership is applied automatically the instant that user registers (`POST /user`) or first-logs-in via OIDC. +- Memberships no longer in the list are removed on next state apply (drift reconciliation, mirroring cache members). +- The implicit "creator becomes Admin" rule does **not** fire — list yourself explicitly if you want it. + +| Option | Default | Description | +|---|---|---| +| `members.*.user` | - | Username (required) | +| `members.*.role` | - | `Admin`/`Write`/`View` or a custom org role declared in `state.roles` for the same organization (required) | ## Projects diff --git a/nix/modules/gradient-state.nix b/nix/modules/gradient-state.nix index f5784172..67ac2031 100644 --- a/nix/modules/gradient-state.nix +++ b/nix/modules/gradient-state.nix @@ -156,6 +156,33 @@ type = types.str; description = "Username of the user who created this organization"; }; + + members = mkOption { + type = types.listOf orgMemberType; + default = []; + description = '' + Users with role assignments on this organization. When empty + (the default), legacy behavior applies: `created_by` is added + as Admin and no other membership reconciliation happens. + + When non-empty, this list is the source of truth - existing + memberships not in the list are revoked on next state apply, + and the implicit `created_by`-as-Admin assignment is skipped + (list yourself explicitly if you want that role). + + Members referencing users that do not yet exist are skipped + silently at provision time and applied automatically when the + user later registers (`POST /user`) or signs in via OIDC for + the first time. + ''; + example = literalExpression '' + [ + { user = "alice"; role = "Admin"; } + { user = "bob"; role = "Write"; } + { user = "carol"; role = "releaser"; } + ] + ''; + }; }; }); @@ -562,6 +589,28 @@ }; }; + orgMemberType = types.submodule { + options = { + user = mkOption { + type = types.str; + description = '' + Username to grant membership to. Resolved at provision time; + if the user does not yet exist, the membership is recorded as + pending and applied automatically when the user later registers + (`POST /user`) or signs in via OIDC for the first time. + ''; + }; + role = mkOption { + type = types.str; + description = '' + Role name. Either a built-in (`Admin`/`Write`/`View`) or a + custom org role declared under + `services.gradient.state.roles` for the same organization. + ''; + }; + }; + }; + cacheRoleType = types.submodule { options = { name = mkOption {