diff --git a/crates/ironclaw_architecture/tests/reborn_deployment_mode_typename_ratchet.rs b/crates/ironclaw_architecture/tests/reborn_deployment_mode_typename_ratchet.rs index e9b3c1561ff..efeb007a6f5 100644 --- a/crates/ironclaw_architecture/tests/reborn_deployment_mode_typename_ratchet.rs +++ b/crates/ironclaw_architecture/tests/reborn_deployment_mode_typename_ratchet.rs @@ -22,9 +22,9 @@ //! `LocalDev*`-prefixed names, owned by the sibling ratchet; localization //! words like `Locale`/`Localization` are excluded structurally — they //! continue lowercase, so the word is not `Local`). The `LocalTriggerAccess*` -//! family (incl. its `Reborn*LocalTriggerAccess*` backends) is genuine -//! Bucket-1 debt: §4.4 folds the `local_trigger_access` module into "seed the -//! owner grant from config at boot," a policy value. The `RebornLocal*` +//! family is gone: §4.4 folded the `local_trigger_access` module into a +//! config value (the `TriggerFireAccessPolicy` on `RebornRuntimeInput`, +//! backed by config + the identity directory). The `RebornLocal*` //! composition family is Slice-B mode-as-type debt. Shrinks as those land. //! `LocalInvocationServicesResolver` awaits a rename (a design call — it //! wires host OR sandbox ports). @@ -97,17 +97,11 @@ const FROZEN_OTHER_MODE_TYPES: &[&str] = &[ "HostedMcpDiscoveryEgress", "HostedMcpDiscoveryError", "HostedMcpEndpoint", - // --- Local* (non-LocalDev): Bucket-1 DEBT — the `local_trigger_access` module - // folds to "seed owner grant from config at boot" (§4.4). Shrinks as that - // lands. - "LocalTriggerAccessBootstrap", - "LocalTriggerAccessBootstrapConfig", - "LocalTriggerAccessReconciliation", - "LocalTriggerAccessRole", - "LocalTriggerAccessSeed", - "LocalTriggerAccessSource", - "LocalTriggerAccessStatus", - "LocalTriggerAccessStore", + // --- Local* (non-LocalDev): the `local_trigger_access` module has been + // folded to a config value — fire-time trigger access is now the + // `TriggerFireAccessPolicy` on `RebornRuntimeInput`, backed by config + // (static owner) and the identity directory (SSO membership), with no + // persisted store type (§4.4). The `LocalTriggerAccess*` family is gone. // --- Local*: pending rename — its correct name is a design call (wires host // OR sandbox process ports, so "Local…" understates it). "LocalInvocationServicesResolver", @@ -116,11 +110,6 @@ const FROZEN_OTHER_MODE_TYPES: &[&str] = &[ // JUSTIFIED (Bucket-3 by meaning): "hook-local id" — an identifier local to // one hook, a genuine domain concept, not a deployment tier. "HookLocalId", - // local_trigger_access family (same Bucket-1 debt as the LocalTriggerAccess* - // prefix group above — folds into config-seeded owner grants): - "RebornFilesystemLocalTriggerAccessStore", - "RebornLibSqlLocalTriggerAccessStore", - "RebornLocalTriggerAccessStoreError", // RebornLocal* composition family — local-dev-as-type mode names in the // composition surface; shrinks with Slice B (deployment mode becomes a // `DeploymentConfig` value): diff --git a/crates/ironclaw_filesystem/CLAUDE.md b/crates/ironclaw_filesystem/CLAUDE.md index c06fdb165c4..1598d11d6b2 100644 --- a/crates/ironclaw_filesystem/CLAUDE.md +++ b/crates/ironclaw_filesystem/CLAUDE.md @@ -110,9 +110,8 @@ codified in `docs/reborn/2026-05-14-universal-fs-dispatch.md` (the new ADR). so they are not the convoy hazard `cas_update` was introduced to fix; migration to `cas_update`'s fail-closed semantics is a deferred follow-up tracked as a sibling to #5274. - - `ironclaw_conversations::filesystem_store::save_state`, - `ironclaw_runner::local_trigger_access::filesystem::deactivate_stale_record` - (via `put_record`), and `ironclaw_product_workflow::filesystem_ledger` + - `ironclaw_conversations::filesystem_store::save_state` and + `ironclaw_product_workflow::filesystem_ledger` (`begin_or_replay` / `settle` / `release` / `try_acquire_prune_lease`) are further pre-existing examples of the same lock-free retry-loop pattern, pending the same migration. diff --git a/crates/ironclaw_reborn_cli/src/commands/serve.rs b/crates/ironclaw_reborn_cli/src/commands/serve.rs index 763134858dd..db31baaeedd 100644 --- a/crates/ironclaw_reborn_cli/src/commands/serve.rs +++ b/crates/ironclaw_reborn_cli/src/commands/serve.rs @@ -13,10 +13,8 @@ use ironclaw_reborn_composition::host_api::{ AgentId, InvocationId, ProjectId, ResourceScope, SecretHandle, TenantId, UserId, }; use ironclaw_reborn_composition::{ - GoogleOAuthRouteConfig, LocalTriggerAccessReconciliation, LocalTriggerAccessRole, - LocalTriggerAccessSource, LocalTriggerAccessStore, RebornBuildInput, RebornReadiness, - RebornRuntimeIdentity, RebornRuntimeInput, RebornWebuiBundle, build_reborn_runtime, - local_trigger_access_fire_checker, + GoogleOAuthRouteConfig, RebornBuildInput, RebornReadiness, RebornRuntimeIdentity, + RebornRuntimeInput, RebornWebuiBundle, TriggerFireAccessPolicy, build_reborn_runtime, }; use ironclaw_reborn_composition::{ SlackOperatorRouteVisibility, build_slack_host_beta_runtime_mounts, @@ -33,10 +31,7 @@ use ironclaw_webui::{ use secrecy::SecretString; use crate::context::RebornCliContext; -use crate::runtime::{ - RuntimeInputOptions, open_trigger_access_store_for_profile, - resolve_google_oauth_config_from_env, -}; +use crate::runtime::{RuntimeInputOptions, resolve_google_oauth_config_from_env}; // pub(crate): reused by onboard's finale login-link print (same default host:port). pub(crate) const DEFAULT_SERVE_HOST: &str = "127.0.0.1"; @@ -380,15 +375,7 @@ impl ServeCommand { // `crate::webui_token::resolve_webui_token` already enforced the // >=32-byte floor when `token_value` was resolved above, so no // separate check is needed here. - // Sidecar DB used by the local-runtime trigger-fire access checker. It - // backs the local trigger-fire - // access store used to seed default-user and SSO-user trigger access; - // canonical identity itself lives on the runtime's scoped filesystem, - // not in this file. let profile = crate::runtime::effective_profile(boot_config, config_file.as_ref())?; - let user_store_path = ironclaw_reborn_composition::local_dev_db_path( - &crate::runtime::local_runtime_storage_root(boot_config, profile), - ); // CORS allow-origin list. Empty = fail-closed on every // cross-origin preflight; operators MUST opt in to the // specific origins the host installation actually serves. @@ -470,28 +457,18 @@ impl ServeCommand { None }; - let trigger_access_store = if trigger_poller_enabled || sso_enabled { - Some( - open_trigger_access_store_for_profile(&runtime_input, profile, &user_store_path) - .await?, - ) - } else { - None - }; - if trigger_poller_enabled { - let access_store = trigger_access_store - .as_ref() - .ok_or_else(|| anyhow!("trigger access store was not opened"))?; - runtime_input = with_local_trigger_fire_access_checker( - runtime_input, - Arc::clone(access_store), - &tenant_id, - &user_id, - &default_agent_id, - default_project_id.as_ref(), - ) - .await?; - } + // Fire-time trigger access is a config value, not a persisted store + // (arch-simplification §4.4). When the poller is on, the operator + // owner may always fire; when SSO is also on, any active tenant + // member (the users SSO login persists in the identity store) may + // too — the union the former single trigger-access store expressed. + runtime_input = runtime_input.with_trigger_fire_access_policy(trigger_fire_access_policy( + trigger_poller_enabled, + sso_enabled, + &user_id, + &default_agent_id, + default_project_id.as_ref(), + )); let runtime = build_reborn_runtime(runtime_input) .await @@ -622,10 +599,11 @@ impl ServeCommand { // Assemble the WebChat v2 auth surface (authenticator + optional // public login mount). The auth/identity module owns the - // signed-session wiring; `serve` supplies host config, the - // runtime-owned identity resolver, and the local trigger-access - // bootstrap that seeds an admitted SSO user's trigger access on - // login. + // signed-session wiring; `serve` supplies host config and the + // runtime-owned identity resolver. An admitted SSO user's trigger + // access is no longer separately seeded here — the identity + // `StoredUser` the login persists IS the membership the fire-time + // checker reads (arch-simplification §4.4). let crate::commands::webui_auth::WebuiAuthSurface { authenticator, public_mount, @@ -635,14 +613,6 @@ impl ServeCommand { tenant_id.clone(), session_signing_secret, env_authenticator, - trigger_access_store.as_ref().map(|store| { - crate::commands::webui_auth::LocalTriggerAccessBootstrapConfig { - store: Arc::clone(store), - tenant_id: tenant_id.clone(), - agent_id: default_agent_id.clone(), - project_id: default_project_id.clone(), - } - }), ) .await?; @@ -1028,32 +998,30 @@ fn canonical_host_name(host: &str) -> &str { host.split_once(':').map(|(host, _)| host).unwrap_or(host) } -async fn with_local_trigger_fire_access_checker( - runtime_input: RebornRuntimeInput, - access_store: Arc, - tenant_id: &TenantId, +/// Resolve the fire-time trigger access policy for `serve` from the enabled +/// surfaces (arch-simplification §4.4). Poller off → no authorizer. Poller on → +/// the configured operator owner may fire; with SSO also on, any active tenant +/// member may too (the union the former single trigger-access store expressed). +fn trigger_fire_access_policy( + trigger_poller_enabled: bool, + sso_enabled: bool, user_id: &UserId, default_agent_id: &AgentId, default_project_id: Option<&ProjectId>, -) -> anyhow::Result { - if !runtime_input.trigger_poller.enabled { - return Ok(runtime_input); +) -> TriggerFireAccessPolicy { + if !trigger_poller_enabled { + return TriggerFireAccessPolicy::disabled(); } - - let user_ids = [user_id.clone()]; - access_store - .reconcile_local_access(LocalTriggerAccessReconciliation { - tenant_id, - user_ids: &user_ids, - agent_id: Some(default_agent_id), - project_id: default_project_id, - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevEnvBootstrap, - }) - .await - .context("failed to reconcile local trigger-fire access")?; - Ok(runtime_input - .with_trigger_fire_access_checker(local_trigger_access_fire_checker(access_store))) + let mut policy = TriggerFireAccessPolicy::disabled().with_static_owner( + user_id.clone(), + default_agent_id.clone(), + default_project_id.cloned(), + ); + if sso_enabled { + policy = + policy.with_tenant_membership(default_agent_id.clone(), default_project_id.cloned()); + } + policy } fn resolve_webui_default_agent( @@ -1603,222 +1571,54 @@ slack_user_id = "U123" ); } - #[tokio::test] - async fn trigger_poller_disabled_does_not_wire_local_access_checker() { - struct PanicLocalTriggerAccessStore; - - #[async_trait::async_trait] - impl LocalTriggerAccessStore for PanicLocalTriggerAccessStore { - async fn seed_local_access( - &self, - _seed: ironclaw_reborn_composition::LocalTriggerAccessSeed<'_>, - ) -> Result<(), ironclaw_reborn_composition::RebornLocalTriggerAccessStoreError> - { - panic!("disabled trigger poller must not seed local access") - } - - async fn reconcile_local_access( - &self, - _reconciliation: LocalTriggerAccessReconciliation<'_>, - ) -> Result<(), ironclaw_reborn_composition::RebornLocalTriggerAccessStoreError> - { - panic!("disabled trigger poller must not reconcile local access") - } - - async fn has_active_local_access( - &self, - _tenant_id: &TenantId, - _user_id: &UserId, - _agent_id: Option<&AgentId>, - _project_id: Option<&ProjectId>, - ) -> Result - { - panic!("disabled trigger poller must not check local access") - } - } - - let dir = tempfile::tempdir().expect("tempdir"); - let tenant_id = TenantId::new("serve-trigger-disabled-tenant").expect("tenant id"); + #[test] + fn trigger_poller_disabled_yields_empty_access_policy() { let user_id = UserId::new("serve-trigger-disabled-user").expect("user id"); let agent_id = AgentId::new("serve-trigger-disabled-agent").expect("agent id"); - let runtime_input = RebornRuntimeInput::from_services(RebornBuildInput::local_dev( - "serve-trigger-owner", - dir.path().join("runtime"), - )); - let access_store: Arc = Arc::new(PanicLocalTriggerAccessStore); - - let runtime_input = with_local_trigger_fire_access_checker( - runtime_input, - access_store, - &tenant_id, - &user_id, - &agent_id, - None, - ) - .await - .expect("disabled trigger poller skips local access store"); - - assert!( - runtime_input.trigger_fire_access_checker.is_none(), - "disabled trigger poller must not wire a local access checker" + // Poller off: no fire-time authorizer, regardless of SSO. + assert_eq!( + trigger_fire_access_policy(false, false, &user_id, &agent_id, None), + TriggerFireAccessPolicy::disabled() + ); + assert_eq!( + trigger_fire_access_policy(false, true, &user_id, &agent_id, None), + TriggerFireAccessPolicy::disabled() ); } - #[tokio::test] - async fn trigger_poller_bootstrap_seeds_local_access_checker() { - let dir = tempfile::tempdir().expect("tempdir"); - let tenant_id = TenantId::new("serve-trigger-tenant").expect("tenant id"); + #[test] + fn trigger_poller_without_sso_grants_only_static_owner() { let user_id = UserId::new("serve-trigger-user").expect("user id"); - let stale_user_id = UserId::new("serve-trigger-stale").expect("stale user id"); let agent_id = AgentId::new("serve-trigger-agent").expect("agent id"); let project_id = ProjectId::new("serve-trigger-project").expect("project id"); - let user_store_path = dir.path().join("reborn-local-dev.db"); - let access_store = - ironclaw_reborn_composition::open_local_trigger_access_store(&user_store_path) - .await - .expect("open local trigger access store"); - access_store - .seed_local_access(ironclaw_reborn_composition::LocalTriggerAccessSeed { - tenant_id: &tenant_id, - user_id: &stale_user_id, - agent_id: Some(&agent_id), - project_id: Some(&project_id), - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevEnvBootstrap, - }) - .await - .expect("seed stale local trigger access"); - let runtime_input = - RebornRuntimeInput::from_services(RebornBuildInput::local_dev( - "serve-trigger-owner", - dir.path().join("runtime"), - )) - .with_trigger_poller_settings( - ironclaw_reborn_composition::TriggerPollerSettings::enabled(), - ); - - let runtime_input = with_local_trigger_fire_access_checker( - runtime_input, - access_store, - &tenant_id, - &user_id, - &agent_id, - Some(&project_id), - ) - .await - .expect("bootstrap trigger fire access checker"); - - let checker = runtime_input - .trigger_fire_access_checker - .expect("checker is wired"); - let decision = checker - .check_trigger_fire_access(ironclaw_reborn_composition::TriggerFireAccessCheck { - tenant_id: tenant_id.clone(), - creator_user_id: user_id, - agent_id: Some(agent_id.clone()), - project_id: Some(project_id.clone()), - trigger_id: ironclaw_reborn_composition::TriggerId::new(), - fire_slot: chrono::Utc::now(), - }) - .await - .expect("check trigger fire access"); - + // Poller on, SSO off: the operator owner is the sole grant. assert_eq!( - decision, - ironclaw_reborn_composition::TriggerFireAccessDecision::Allowed + trigger_fire_access_policy(true, false, &user_id, &agent_id, Some(&project_id)), + TriggerFireAccessPolicy::disabled().with_static_owner( + user_id.clone(), + agent_id.clone(), + Some(project_id.clone()), + ) ); - - let stale_decision = checker - .check_trigger_fire_access(ironclaw_reborn_composition::TriggerFireAccessCheck { - tenant_id, - creator_user_id: stale_user_id, - agent_id: Some(agent_id), - project_id: Some(project_id), - trigger_id: ironclaw_reborn_composition::TriggerId::new(), - fire_slot: chrono::Utc::now(), - }) - .await - .expect("check stale trigger fire access"); - + // No project scope is carried through exactly (not a wildcard). assert_eq!( - stale_decision, - ironclaw_reborn_composition::TriggerFireAccessDecision::Denied { - reason: "trigger creator does not have active local access for this scope" - .to_string(), - } + trigger_fire_access_policy(true, false, &user_id, &agent_id, None), + TriggerFireAccessPolicy::disabled().with_static_owner(user_id, agent_id, None) ); } - #[tokio::test] - async fn trigger_poller_bootstrap_seeds_no_project_local_access_checker() { - let dir = tempfile::tempdir().expect("tempdir"); - let tenant_id = TenantId::new("serve-trigger-no-project-tenant").expect("tenant id"); - let user_id = UserId::new("serve-trigger-no-project-user").expect("user id"); - let agent_id = AgentId::new("serve-trigger-no-project-agent").expect("agent id"); - let project_id = ProjectId::new("serve-trigger-no-project-project").expect("project id"); - let user_store_path = dir.path().join("reborn-local-dev.db"); - let access_store = - ironclaw_reborn_composition::open_local_trigger_access_store(&user_store_path) - .await - .expect("open local trigger access store"); - let runtime_input = - RebornRuntimeInput::from_services(RebornBuildInput::local_dev( - "serve-trigger-owner", - dir.path().join("runtime"), - )) - .with_trigger_poller_settings( - ironclaw_reborn_composition::TriggerPollerSettings::enabled(), - ); - - let runtime_input = with_local_trigger_fire_access_checker( - runtime_input, - access_store, - &tenant_id, - &user_id, - &agent_id, - None, - ) - .await - .expect("bootstrap trigger fire access checker"); - - let checker = runtime_input - .trigger_fire_access_checker - .expect("checker is wired"); - let decision = checker - .check_trigger_fire_access(ironclaw_reborn_composition::TriggerFireAccessCheck { - tenant_id: tenant_id.clone(), - creator_user_id: user_id.clone(), - agent_id: Some(agent_id.clone()), - project_id: None, - trigger_id: ironclaw_reborn_composition::TriggerId::new(), - fire_slot: chrono::Utc::now(), - }) - .await - .expect("check trigger fire access"); - - assert_eq!( - decision, - ironclaw_reborn_composition::TriggerFireAccessDecision::Allowed - ); - - let project_scoped_decision = checker - .check_trigger_fire_access(ironclaw_reborn_composition::TriggerFireAccessCheck { - tenant_id, - creator_user_id: user_id, - agent_id: Some(agent_id), - project_id: Some(project_id), - trigger_id: ironclaw_reborn_composition::TriggerId::new(), - fire_slot: chrono::Utc::now(), - }) - .await - .expect("check project-scoped trigger fire access"); - + #[test] + fn trigger_poller_with_sso_grants_static_owner_and_tenant_membership() { + let user_id = UserId::new("serve-trigger-user").expect("user id"); + let agent_id = AgentId::new("serve-trigger-agent").expect("agent id"); + let project_id = ProjectId::new("serve-trigger-project").expect("project id"); + // Poller on, SSO on: the union of the operator owner and any active + // tenant member (the users SSO login persists in the identity store). assert_eq!( - project_scoped_decision, - ironclaw_reborn_composition::TriggerFireAccessDecision::Denied { - reason: "trigger creator does not have active local access for this scope" - .to_string(), - } + trigger_fire_access_policy(true, true, &user_id, &agent_id, Some(&project_id)), + TriggerFireAccessPolicy::disabled() + .with_static_owner(user_id, agent_id.clone(), Some(project_id.clone())) + .with_tenant_membership(agent_id, Some(project_id)) ); } diff --git a/crates/ironclaw_reborn_cli/src/commands/user_directory.rs b/crates/ironclaw_reborn_cli/src/commands/user_directory.rs index 085f45bdb8c..b6b2fd4bfe4 100644 --- a/crates/ironclaw_reborn_cli/src/commands/user_directory.rs +++ b/crates/ironclaw_reborn_cli/src/commands/user_directory.rs @@ -20,10 +20,9 @@ use std::sync::Arc; use async_trait::async_trait; -use ironclaw_reborn_composition::host_api::{AgentId, ProjectId, TenantId, UserId}; +use ironclaw_reborn_composition::host_api::{TenantId, UserId}; use ironclaw_reborn_composition::{ - ExternalSubjectId, LocalTriggerAccessRole, LocalTriggerAccessSeed, LocalTriggerAccessSource, - LocalTriggerAccessStore, ProviderKind, RebornIdentityError, RebornIdentityResolver, + ExternalSubjectId, ProviderKind, RebornIdentityError, RebornIdentityResolver, ResolveExternalIdentity, SurfaceKind, }; use ironclaw_webui::{OAuthProviderName, OAuthUserProfile, UserDirectory, UserDirectoryError}; @@ -35,8 +34,6 @@ pub(crate) struct WebuiUserDirectory { /// Trusted host tenant the resolved OAuth identities are scoped to. /// Identity resolution and email-linking happen within this tenant. tenant_id: TenantId, - /// Local-dev SSO trigger-access seeding, when configured. - local_trigger_access: Option, /// Lowercased verified-email domains allowed to log in. Never empty /// in production — an empty list rejects every login (fail closed). allowed_email_domains: Vec, @@ -51,19 +48,10 @@ impl WebuiUserDirectory { Self { resolver, tenant_id, - local_trigger_access: None, allowed_email_domains, } } - pub(crate) fn with_local_trigger_access( - mut self, - local_trigger_access: LocalTriggerAccessBootstrap, - ) -> Self { - self.local_trigger_access = Some(local_trigger_access); - self - } - /// The verified email this profile is admitted on, if any: the first /// verified address whose domain is on the allowlist. Candidates are /// the canonical [`email`](OAuthUserProfile::email) (only when @@ -98,44 +86,6 @@ impl WebuiUserDirectory { } } -/// Local-dev trigger access seed configuration for users admitted through SSO. -pub(crate) struct LocalTriggerAccessBootstrap { - store: Arc, - tenant_id: TenantId, - agent_id: AgentId, - project_id: Option, -} - -impl LocalTriggerAccessBootstrap { - pub(crate) fn new( - store: Arc, - tenant_id: TenantId, - agent_id: AgentId, - project_id: Option, - ) -> Self { - Self { - store, - tenant_id, - agent_id, - project_id, - } - } - - async fn seed_for_user(&self, user_id: &UserId) -> Result<(), UserDirectoryError> { - self.store - .seed_local_access(LocalTriggerAccessSeed { - tenant_id: &self.tenant_id, - user_id, - agent_id: Some(&self.agent_id), - project_id: self.project_id.as_ref(), - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevSsoBootstrap, - }) - .await - .map_err(|err| UserDirectoryError::Backend(err.to_string())) - } -} - #[async_trait] impl UserDirectory for WebuiUserDirectory { async fn resolve( @@ -199,9 +149,6 @@ impl UserDirectory for WebuiUserDirectory { RebornIdentityError::UserSuspended(_) => UserDirectoryError::Unknown, other => UserDirectoryError::Backend(other.to_string()), })?; - if let Some(local_trigger_access) = &self.local_trigger_access { - local_trigger_access.seed_for_user(&user_id).await?; - } Ok(user_id) } } @@ -376,85 +323,4 @@ mod tests { .expect_err("no verified email on the allowlist must be rejected"); assert!(matches!(err, UserDirectoryError::Unknown)); } - - #[tokio::test] - async fn sso_user_directory_seeds_local_trigger_access_for_admitted_user() { - let tmp = tempfile::tempdir().expect("tempdir"); - let path = tmp.keep().join("reborn-local-dev.db"); - let access_store = ironclaw_reborn_composition::open_local_trigger_access_store(&path) - .await - .expect("open access store"); - let tenant_id = TenantId::new("sso-access-tenant").expect("tenant id"); - let agent_id = AgentId::new("sso-access-agent").expect("agent id"); - let project_id = ProjectId::new("sso-access-project").expect("project id"); - let dir = WebuiUserDirectory::new( - shared_resolver(), - tenant_id.clone(), - vec!["example.com".to_string()], - ) - .with_local_trigger_access(LocalTriggerAccessBootstrap::new( - access_store.clone(), - tenant_id.clone(), - agent_id.clone(), - Some(project_id.clone()), - )); - - let user_id = dir - .resolve(&google(), &profile(Some("alice@example.com"), true)) - .await - .expect("admitted SSO profile resolves"); - - assert!( - access_store - .has_active_local_access(&tenant_id, &user_id, Some(&agent_id), Some(&project_id)) - .await - .expect("check local access"), - "admitted SSO users get an exact local-dev trigger access row on login" - ); - } - - #[tokio::test] - async fn sso_user_directory_does_not_seed_local_trigger_access_for_unadmitted_user() { - let tmp = tempfile::tempdir().expect("tempdir"); - let path = tmp.keep().join("reborn-local-dev.db"); - let access_store = ironclaw_reborn_composition::open_local_trigger_access_store(&path) - .await - .expect("open access store"); - let tenant_id = TenantId::new("sso-access-reject-tenant").expect("tenant id"); - let agent_id = AgentId::new("sso-access-reject-agent").expect("agent id"); - let project_id = ProjectId::new("sso-access-reject-project").expect("project id"); - let dir = WebuiUserDirectory::new( - shared_resolver(), - tenant_id.clone(), - vec!["example.com".to_string()], - ) - .with_local_trigger_access(LocalTriggerAccessBootstrap::new( - access_store.clone(), - tenant_id.clone(), - agent_id.clone(), - Some(project_id.clone()), - )); - - let err = dir - .resolve(&google(), &profile(Some("mallory@evil.test"), true)) - .await - .expect_err("off-allowlist SSO profile must be rejected"); - assert!(matches!(err, UserDirectoryError::Unknown)); - - // A rejected profile fails admission before resolution, so it mints no - // user and seeds no trigger access (per-login seeding never runs). - let sentinel_user_id = UserId::new("sso-access-reject-user").expect("user id"); - assert!( - !access_store - .has_active_local_access( - &tenant_id, - &sentinel_user_id, - Some(&agent_id), - Some(&project_id) - ) - .await - .expect("check local access"), - "rejected SSO profiles must not seed local trigger access" - ); - } } diff --git a/crates/ironclaw_reborn_cli/src/commands/webui_auth.rs b/crates/ironclaw_reborn_cli/src/commands/webui_auth.rs index 275283bcc68..7ee00a0cf47 100644 --- a/crates/ironclaw_reborn_cli/src/commands/webui_auth.rs +++ b/crates/ironclaw_reborn_cli/src/commands/webui_auth.rs @@ -13,10 +13,8 @@ use std::sync::Arc; use anyhow::anyhow; -use ironclaw_reborn_composition::host_api::{AgentId, ProjectId, TenantId}; -use ironclaw_reborn_composition::{ - LocalTriggerAccessStore, PublicRouteMount, RebornIdentityResolver, -}; +use ironclaw_reborn_composition::host_api::TenantId; +use ironclaw_reborn_composition::{PublicRouteMount, RebornIdentityResolver}; use ironclaw_webui::{ CompositeAuthenticator, SessionAuthenticator, SignedSessionLoginConfig, WebuiAuthenticator, build_signed_session_login, empty_webui_v2_auth_providers_mount, signed_session_store, @@ -24,7 +22,7 @@ use ironclaw_webui::{ use secrecy::SecretString; use crate::commands::serve_sso::SsoStartupConfig; -use crate::commands::user_directory::{LocalTriggerAccessBootstrap, WebuiUserDirectory}; +use crate::commands::user_directory::WebuiUserDirectory; /// The composed WebChat v2 auth surface: the authenticator the protected /// routes verify bearers with, plus the optional public login-route mount @@ -34,19 +32,6 @@ pub(crate) struct WebuiAuthSurface { pub(crate) public_mount: Option, } -/// How to seed local-dev trigger-fire access for SSO users on login. -/// -/// Carries the already-open local trigger-access store plus the scope an -/// admitted user's access row is seeded under. `serve.rs` opens the store once -/// through the active runtime profile so SSO and trigger-poller wiring share -/// the same backend. -pub(crate) struct LocalTriggerAccessBootstrapConfig { - pub(crate) store: Arc, - pub(crate) tenant_id: TenantId, - pub(crate) agent_id: AgentId, - pub(crate) project_id: Option, -} - /// Build the auth surface from resolved startup config. /// /// With no SSO provider configured (`sso_startup` is `None`), the listener @@ -62,21 +47,18 @@ pub(crate) struct LocalTriggerAccessBootstrapConfig { /// local-runtime substrate; with SSO configured that is unrecoverable, so /// this fails closed rather than minting users against a missing store. /// -/// When `local_trigger_access` is present and SSO is configured, admitted -/// users get a local trigger-access row seeded on each login (via the -/// admission adapter). There is no startup reconciliation in this path: the -/// bootstrap only seeds, it does not enumerate or revoke. +/// An admitted SSO user's trigger-fire access is no longer seeded here: the +/// canonical `StoredUser` the resolver persists on login IS the membership the +/// runtime's fire-time checker reads (arch-simplification §4.4). pub(crate) async fn build_webui_auth_surface( sso_startup: Option, identity_resolver: Option>, tenant_id: TenantId, session_signing_secret: SecretString, env_authenticator: Arc, - local_trigger_access: Option, ) -> anyhow::Result { let Some(sso) = sso_startup else { - // No SSO providers: no public login routes, and no SSO logins to seed - // local trigger access for (bootstrap config is unused here). But the + // No SSO providers: no public login routes. But the // serve layer *always* wires the admin-API token minter, which mints // signed **session** tokens (the user-create bearer). Those validate // only through a `SessionAuthenticator` over the same signed store — @@ -111,15 +93,11 @@ pub(crate) async fn build_webui_auth_surface( ) })?; - let mut user_directory = WebuiUserDirectory::new( + let user_directory = WebuiUserDirectory::new( identity_resolver, tenant_id.clone(), sso.allowed_email_domains, ); - if let Some(config) = local_trigger_access { - user_directory = - user_directory.with_local_trigger_access(local_trigger_access_bootstrap(config)); - } let wiring = build_signed_session_login(SignedSessionLoginConfig { tenant_id, @@ -141,18 +119,6 @@ pub(crate) async fn build_webui_auth_surface( }) } -fn local_trigger_access_bootstrap( - config: LocalTriggerAccessBootstrapConfig, -) -> LocalTriggerAccessBootstrap { - let LocalTriggerAccessBootstrapConfig { - store, - tenant_id, - agent_id, - project_id, - } = config; - LocalTriggerAccessBootstrap::new(store, tenant_id, agent_id, project_id) -} - #[cfg(test)] mod tests { use super::*; @@ -217,7 +183,6 @@ mod tests { TenantId::new("tenant-host").expect("tenant"), SecretString::from("session-signing-secret".to_string()), Arc::new(RejectingAuth), - None, ) .await; @@ -236,15 +201,13 @@ mod tests { // With no SSO configured the surface still needs env-bearer access // plus signed-session bearer access for admin-created users. It also // mounts an inert public auth surface for provider discovery. The - // absent-resolver check must not fire on this path, and a bootstrap - // config is unused. + // absent-resolver check must not fire on this path. let result = build_webui_auth_surface( None, None, TenantId::new("tenant-host").expect("tenant"), SecretString::from("session-signing-secret".to_string()), Arc::new(RejectingAuth), - None, ) .await; @@ -261,17 +224,12 @@ mod tests { } #[tokio::test] - async fn sso_with_local_trigger_access_bootstrap_builds_surface() { - // SSO configured with a local-trigger-access bootstrap: the surface - // must attach the per-login seeder to the admission adapter and mount - // the public login routes — proving the bootstrap config is wired - // through, not silently dropped. - let tmp = tempfile::tempdir().expect("tempdir"); - let access_store_path = tmp.path().join("reborn-local-dev.db"); - let access_store = - ironclaw_reborn_composition::open_local_trigger_access_store(&access_store_path) - .await - .expect("open local trigger access store"); + async fn sso_configured_builds_surface_with_public_login_mount() { + // SSO configured: the surface layers the admission adapter over the + // runtime identity resolver and mounts the public login routes. Trigger + // access is no longer seeded here (arch-simplification §4.4) — the + // resolver's own `StoredUser` is the membership the fire-time checker + // reads. let sso = SsoStartupConfig { providers: vec![Arc::new(StubProvider( OAuthProviderName::new("google").expect("provider name"), @@ -288,15 +246,9 @@ mod tests { TenantId::new("sso-bootstrap-tenant").expect("tenant"), SecretString::from("operator-session-secret".to_string()), Arc::new(RejectingAuth), - Some(LocalTriggerAccessBootstrapConfig { - store: access_store, - tenant_id: TenantId::new("sso-bootstrap-tenant").expect("tenant"), - agent_id: AgentId::new("sso-bootstrap-agent").expect("agent"), - project_id: Some(ProjectId::new("sso-bootstrap-project").expect("project")), - }), ) .await - .expect("SSO surface with a bootstrap config must build"); + .expect("SSO surface must build"); assert!( surface.public_mount.is_some(), diff --git a/crates/ironclaw_reborn_cli/src/runtime/mod.rs b/crates/ironclaw_reborn_cli/src/runtime/mod.rs index f426e25bd78..b2737a91c8b 100644 --- a/crates/ironclaw_reborn_cli/src/runtime/mod.rs +++ b/crates/ironclaw_reborn_cli/src/runtime/mod.rs @@ -1,16 +1,14 @@ // arch-exempt: large_file, Google OAuth resolution hardening remains at the existing runtime config seam, plan #4088 use std::io::{IsTerminal, Write}; -use std::path::Path; use std::path::PathBuf; -use std::sync::Arc; use std::time::Duration; use std::{future::Future, thread}; use anyhow::Context; use ironclaw_reborn_composition::OAuthRedirectUri; use ironclaw_reborn_composition::SlackPersonalSetupServiceSlot; -use ironclaw_reborn_composition::host_api::UserId; -use ironclaw_reborn_composition::host_api::{AgentId, TenantId}; +use ironclaw_reborn_composition::TriggerFireAccessPolicy; +use ironclaw_reborn_composition::host_api::{AgentId, TenantId, UserId}; #[cfg(feature = "postgres")] use ironclaw_reborn_composition::hosted_single_tenant_runtime_policy; use ironclaw_reborn_composition::{ @@ -19,10 +17,6 @@ use ironclaw_reborn_composition::{ RebornRuntimeProfileOptions, TurnRunnerSettings, build_reborn_runtime, local_runtime_build_input_with_options, nearai_mcp_bootstrap_config_from_env, }; -use ironclaw_reborn_composition::{ - LocalTriggerAccessReconciliation, LocalTriggerAccessRole, LocalTriggerAccessSource, - LocalTriggerAccessStore, local_trigger_access_fire_checker, open_local_trigger_access_store, -}; use ironclaw_reborn_config::{ REBORN_PROFILE_ENV, RebornBootConfig, RebornProfile, seed_default_config_file_if_missing, }; @@ -191,7 +185,7 @@ pub(crate) fn execute( .build()?; rt.block_on(async move { let runtime_input = - with_run_local_trigger_fire_access_checker(runtime_input, &boot_config).await?; + apply_run_trigger_fire_access_policy(runtime_input, &boot_config).await?; let runtime = build_reborn_runtime(runtime_input).await?; print_runtime_banner(&boot_config); @@ -210,7 +204,7 @@ pub(crate) fn execute( Ok(()) } -async fn with_run_local_trigger_fire_access_checker( +async fn apply_run_trigger_fire_access_policy( runtime_input: RebornRuntimeInput, config: &RebornBootConfig, ) -> anyhow::Result { @@ -220,12 +214,6 @@ async fn with_run_local_trigger_fire_access_checker( } let config_file = read_config_file(config)?; - let tenant_id = TenantId::new(&runtime_input.identity.tenant_id).with_context(|| { - format!( - "[identity].tenant `{}` is invalid", - runtime_input.identity.tenant_id - ) - })?; let user_id = UserId::new(default_owner_id(config_file.as_ref())) .context("[identity].default_owner is invalid")?; let agent_id = AgentId::new(&runtime_input.identity.agent_id).with_context(|| { @@ -234,66 +222,12 @@ async fn with_run_local_trigger_fire_access_checker( runtime_input.identity.agent_id ) })?; - let profile = effective_profile(config, config_file.as_ref())?; - let user_store_path = ironclaw_reborn_composition::local_dev_db_path( - &local_runtime_storage_root(config, profile), - ); - let access_store = - open_trigger_access_store_for_profile(&runtime_input, profile, &user_store_path) - .await?; - let user_ids = [user_id]; - access_store - .reconcile_local_access(LocalTriggerAccessReconciliation { - tenant_id: &tenant_id, - user_ids: &user_ids, - agent_id: Some(&agent_id), - project_id: None, - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevRunBootstrap, - }) - .await - .context("failed to reconcile local trigger-fire access for `run`")?; - - Ok(runtime_input - .with_trigger_fire_access_checker(local_trigger_access_fire_checker(access_store))) - } -} - -pub(crate) async fn open_trigger_access_store_for_profile( - runtime_input: &RebornRuntimeInput, - profile: RebornProfile, - local_store_path: &Path, -) -> anyhow::Result> { - match profile { - RebornProfile::HostedSingleTenant => { - #[cfg(feature = "postgres")] - { - let services = runtime_input.services.as_ref().context( - "profile=hosted-single-tenant requires runtime services before trigger-fire access can be wired", - )?; - let store = services - .open_hosted_single_tenant_trigger_access_store() - .await - .context("failed to initialize hosted trigger-fire access store")?; - let store: Arc = store; - Ok(store) - } - #[cfg(not(feature = "postgres"))] - { - let _ = runtime_input; - let _ = local_store_path; - anyhow::bail!( - "profile=hosted-single-tenant requires the `postgres` feature for trigger-fire access" - ); - } - } - _ => { - let store = open_local_trigger_access_store(local_store_path) - .await - .context("failed to initialize local trigger-fire access store")?; - let store: Arc = store; - Ok(store) - } + // The `run` owner grant is a static single owner — a config value, + // built into the runtime's fire-time checker without any persisted + // trigger-access store (arch-simplification §4.4). + Ok(runtime_input.with_trigger_fire_access_policy( + TriggerFireAccessPolicy::disabled().with_static_owner(user_id, agent_id, None), + )) } } @@ -1582,16 +1516,16 @@ fn runner_settings( mod tests { use std::{collections::HashMap, sync::MutexGuard}; + use ironclaw_reborn_composition::TriggerFireAccessPolicy; use ironclaw_reborn_composition::{ CredentialRefreshSettings, RebornCompositionProfile, TurnStatus, test_support::assistant_reply_without_text_for_test, }; - use ironclaw_reborn_composition::{LocalTriggerAccessRole, LocalTriggerAccessSource}; use ironclaw_reborn_config::RebornBootConfig; use secrecy::SecretString; + use super::apply_run_trigger_fire_access_policy; use super::test_env::EnvGuard; - use super::with_run_local_trigger_fire_access_checker; use super::{ GoogleOAuthConfigState, GoogleOAuthEnvInputs, GoogleOAuthResolution, RuntimeInputCaller, RuntimeInputOptions, apply_credential_refresh_override, block_on_cli, build_runtime_input, @@ -3233,7 +3167,7 @@ enabled = true #[allow(clippy::await_holding_lock, reason = "serializes env guards")] #[tokio::test] - async fn run_trigger_poller_bootstrap_seeds_local_access_checker() { + async fn run_trigger_poller_sets_static_owner_access_policy() { let _lock = lock_runtime_env(); let (_enabled, _interval) = clear_trigger_poller_env(); @@ -3263,100 +3197,26 @@ enabled = true let runtime_input = build_runtime_input(&config, RuntimeInputCaller::Run).expect("runtime input"); - let tenant_id = ironclaw_reborn_composition::host_api::TenantId::new("run-trigger-tenant") - .expect("tenant id"); let user_id = ironclaw_reborn_composition::host_api::UserId::new("run-trigger-user") .expect("user id"); - let stale_user_id = ironclaw_reborn_composition::host_api::UserId::new("run-trigger-stale") - .expect("stale user id"); let agent_id = ironclaw_reborn_composition::host_api::AgentId::new("run-trigger-agent") .expect("agent id"); - let project_id = - ironclaw_reborn_composition::host_api::ProjectId::new("run-trigger-project") - .expect("project id"); - let user_store_path = config - .home() - .path() - .join("local-dev") - .join("reborn-local-dev.db"); - let access_store = - ironclaw_reborn_composition::open_local_trigger_access_store(&user_store_path) - .await - .expect("open local trigger access store"); - access_store - .seed_local_access(ironclaw_reborn_composition::LocalTriggerAccessSeed { - tenant_id: &tenant_id, - user_id: &stale_user_id, - agent_id: Some(&agent_id), - project_id: None, - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevRunBootstrap, - }) - .await - .expect("seed stale run trigger access"); - let runtime_input = with_run_local_trigger_fire_access_checker(runtime_input, &config) + let runtime_input = apply_run_trigger_fire_access_policy(runtime_input, &config) .await - .expect("bootstrap run trigger fire access checker"); - - let checker = runtime_input - .trigger_fire_access_checker - .expect("checker is wired"); - let allowed = checker - .check_trigger_fire_access(ironclaw_reborn_composition::TriggerFireAccessCheck { - tenant_id: tenant_id.clone(), - creator_user_id: user_id, - agent_id: Some(agent_id.clone()), - project_id: None, - trigger_id: ironclaw_reborn_composition::TriggerId::new(), - fire_slot: chrono::Utc::now(), - }) - .await - .expect("check run trigger fire access"); - assert_eq!( - allowed, - ironclaw_reborn_composition::TriggerFireAccessDecision::Allowed - ); + .expect("bootstrap run trigger fire access policy"); - let project_scoped_decision = checker - .check_trigger_fire_access(ironclaw_reborn_composition::TriggerFireAccessCheck { - tenant_id: tenant_id.clone(), - creator_user_id: ironclaw_reborn_composition::host_api::UserId::new( - "run-trigger-user", - ) - .expect("user id"), - agent_id: Some(agent_id.clone()), - project_id: Some(project_id.clone()), - trigger_id: ironclaw_reborn_composition::TriggerId::new(), - fire_slot: chrono::Utc::now(), - }) - .await - .expect("check project-scoped run trigger fire access"); + // The `run` owner grant is the configured default owner at the default + // agent scope, no project (arch-simplification §4.4). The checker's + // allow/deny behavior is covered by StaticOwnerTriggerFireChecker's + // unit tests; here we assert the run edge resolves the right policy. assert_eq!( - project_scoped_decision, - ironclaw_reborn_composition::TriggerFireAccessDecision::Denied { - reason: "trigger creator does not have active local access for this scope" - .to_string(), - } + runtime_input.trigger_fire_access, + TriggerFireAccessPolicy::disabled().with_static_owner(user_id, agent_id, None) ); - - let stale_decision = checker - .check_trigger_fire_access(ironclaw_reborn_composition::TriggerFireAccessCheck { - tenant_id, - creator_user_id: stale_user_id, - agent_id: Some(agent_id), - project_id: None, - trigger_id: ironclaw_reborn_composition::TriggerId::new(), - fire_slot: chrono::Utc::now(), - }) - .await - .expect("check stale run trigger fire access"); - assert_eq!( - stale_decision, - ironclaw_reborn_composition::TriggerFireAccessDecision::Denied { - reason: "trigger creator does not have active local access for this scope" - .to_string(), - } + assert!( + runtime_input.trigger_fire_access_checker.is_none(), + "the run path sets a policy, not an explicit checker override" ); } diff --git a/crates/ironclaw_reborn_composition/Cargo.toml b/crates/ironclaw_reborn_composition/Cargo.toml index b149a122b46..260dfe0a939 100644 --- a/crates/ironclaw_reborn_composition/Cargo.toml +++ b/crates/ironclaw_reborn_composition/Cargo.toml @@ -46,7 +46,6 @@ postgres = [ "dep:deadpool-postgres", "ironclaw_host_runtime/postgres", "ironclaw_runner/filesystem-goal-store", - "ironclaw_runner/filesystem-local-trigger-access", ] [dependencies] async-trait = "0.1" @@ -83,7 +82,7 @@ ironclaw_processes = { path = "../ironclaw_processes" } ironclaw_product_adapters = { path = "../ironclaw_product_adapters", features = ["host-auth-mint"] } ironclaw_product_adapter_registry = { path = "../ironclaw_product_adapter_registry" } ironclaw_product_workflow = { path = "../ironclaw_product_workflow" } -ironclaw_runner = { path = "../ironclaw_runner", features = ["webui-user-store"] } +ironclaw_runner = { path = "../ironclaw_runner" } ironclaw_reborn_config = { path = "../ironclaw_reborn_config" } ironclaw_reborn_identity = { path = "../ironclaw_reborn_identity" } ironclaw_reborn_event_store = { path = "../ironclaw_reborn_event_store" } diff --git a/crates/ironclaw_reborn_composition/src/input.rs b/crates/ironclaw_reborn_composition/src/input.rs index f855a7b3b9a..4e0755e180f 100644 --- a/crates/ironclaw_reborn_composition/src/input.rs +++ b/crates/ironclaw_reborn_composition/src/input.rs @@ -457,31 +457,6 @@ impl RebornBuildInput { )) } - /// Open the hosted-single-tenant trigger access store from this build - /// input's already-resolved PostgreSQL storage. - #[cfg(feature = "postgres")] - pub async fn open_hosted_single_tenant_trigger_access_store( - &self, - ) -> Result, crate::RebornLocalTriggerAccessStoreError> - { - let RebornStorageInput::HostedSingleTenantPostgres { pool, .. } = &self.storage else { - return Err(crate::RebornLocalTriggerAccessStoreError::Backend( - "hosted-single-tenant trigger access requires PostgreSQL-backed runtime storage" - .to_string(), - )); - }; - let filesystem = Arc::new(ironclaw_filesystem::PostgresRootFilesystem::new( - pool.clone(), - )); - filesystem.run_migrations().await.map_err(|error| { - crate::RebornLocalTriggerAccessStoreError::Backend(error.to_string()) - })?; - let scoped = crate::wrap_scoped(filesystem); - Ok(Arc::new( - crate::RebornFilesystemLocalTriggerAccessStore::new(scoped), - )) - } - pub fn with_local_runtime_workspace_root(mut self, workspace_root: PathBuf) -> Self { match &mut self.storage { RebornStorageInput::LocalDev { diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index b53277c6666..6584418f4c0 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -60,6 +60,7 @@ mod runtime_profile_approval_policy; mod support; #[cfg(feature = "test-support")] pub mod test_support; +mod trigger_fire_access; mod turn_run_snapshot; mod web_access; mod webui; @@ -215,7 +216,8 @@ pub use runtime_input::{ CredentialRefreshSettings, DEFAULT_TURN_RUNNER_HEARTBEAT_INTERVAL, DEFAULT_TURN_RUNNER_POLL_INTERVAL, PollSettings, RebornRuntimeIdentity, RebornRuntimeInput, TriggerFireAccessCheck, TriggerFireAccessChecker, TriggerFireAccessDecision, - TriggerFireAccessError, TriggerPollerSettings, TurnRunnerSettings, + TriggerFireAccessError, TriggerFireAccessGrant, TriggerFireAccessPolicy, TriggerPollerSettings, + TurnRunnerSettings, }; pub use runtime_input::{RebornProviderFactory, ResolvedRebornLlm}; pub use slack::slack_actor_identity::{ @@ -287,71 +289,6 @@ pub mod host_api { }; } -#[cfg(feature = "postgres")] -pub use ironclaw_runner::local_trigger_access::RebornFilesystemLocalTriggerAccessStore; -/// Reborn-owned local trigger-fire access store, re-exported so host -/// binaries reach it through this composition facade instead of taking a -/// direct `ironclaw_runner` dependency (the -/// `reborn_cli_binary_crate_stays_separate_from_v1_root` architecture -/// boundary forbids that). The store is a reborn-owned repository. Local-dev -/// callers use [`open_local_trigger_access_store`]; hosted-single-tenant -/// callers use the filesystem-backed store through the host filesystem -/// abstraction. -pub use ironclaw_runner::local_trigger_access::{ - LocalTriggerAccessReconciliation, LocalTriggerAccessRole, LocalTriggerAccessSeed, - LocalTriggerAccessSource, LocalTriggerAccessStore, RebornLibSqlLocalTriggerAccessStore, - RebornLocalTriggerAccessStoreError, -}; - -struct LocalTriggerAccessFireChecker { - store: std::sync::Arc, -} - -impl LocalTriggerAccessFireChecker { - fn new(store: std::sync::Arc) -> Self { - Self { store } - } -} - -/// Wrap a backend-neutral local trigger access store as the runtime fire-time -/// authorizer. -pub fn local_trigger_access_fire_checker( - store: std::sync::Arc, -) -> std::sync::Arc { - std::sync::Arc::new(LocalTriggerAccessFireChecker::new(store)) -} - -#[async_trait::async_trait] -impl runtime_input::TriggerFireAccessChecker for LocalTriggerAccessFireChecker { - async fn check_trigger_fire_access( - &self, - request: runtime_input::TriggerFireAccessCheck, - ) -> Result - { - self.store - .has_active_local_access( - &request.tenant_id, - &request.creator_user_id, - request.agent_id.as_ref(), - request.project_id.as_ref(), - ) - .await - .map_err(|error| runtime_input::TriggerFireAccessError::Unavailable { - reason: error.to_string(), - }) - .map(|allowed| { - if allowed { - runtime_input::TriggerFireAccessDecision::Allowed - } else { - runtime_input::TriggerFireAccessDecision::Denied { - reason: "trigger creator does not have active local access for this scope" - .to_string(), - } - } - }) - } -} - /// Canonical Reborn identity resolver vocabulary (issue #4381): the one /// boundary that maps every external identity — WebUI OAuth logins and /// external channel/product actors — to a stable `UserId` before runtime @@ -405,95 +342,6 @@ pub fn open_reborn_identity_resolver( ) } -/// Open the reborn-owned local trigger access store on the substrate DB at -/// `path`, creating the parent directory and running its idempotent -/// migrations. -/// -/// Opens a libSQL handle directly, so it needs this crate's `libsql` feature. -#[cfg(feature = "libsql")] -pub async fn open_local_trigger_access_store( - path: &std::path::Path, -) -> Result, RebornLocalTriggerAccessStoreError> -{ - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|err| RebornLocalTriggerAccessStoreError::Backend(err.to_string()))?; - } - let db = std::sync::Arc::new( - libsql::Builder::new_local(path) - .build() - .await - .map_err(|err| RebornLocalTriggerAccessStoreError::Backend(err.to_string()))?, - ); - Ok(std::sync::Arc::new( - RebornLibSqlLocalTriggerAccessStore::open(db).await?, - )) -} - -#[cfg(test)] -mod webui_user_access_checker_tests { - use super::*; - use crate::runtime_input::{TriggerFireAccessCheck, TriggerFireAccessDecision}; - use ironclaw_host_api::{AgentId, ProjectId, TenantId, UserId}; - - #[tokio::test] - async fn user_store_trigger_fire_checker_uses_exact_seeded_scope() { - let root = tempfile::tempdir().expect("tempdir"); - let store = open_local_trigger_access_store(&root.path().join("reborn-local-dev.db")) - .await - .expect("open local trigger access store"); - let tenant_id = TenantId::new("checker-tenant").expect("tenant id"); - let user_id = UserId::new("checker-user").expect("user id"); - let other_user_id = UserId::new("checker-other-user").expect("user id"); - let agent_id = AgentId::new("checker-agent").expect("agent id"); - let project_id = ProjectId::new("checker-project").expect("project id"); - - store - .seed_local_access(LocalTriggerAccessSeed { - tenant_id: &tenant_id, - user_id: &user_id, - agent_id: Some(&agent_id), - project_id: Some(&project_id), - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevEnvBootstrap, - }) - .await - .expect("seed local access"); - - let checker = local_trigger_access_fire_checker(store); - - let allowed = checker - .check_trigger_fire_access(TriggerFireAccessCheck { - tenant_id: tenant_id.clone(), - creator_user_id: user_id, - agent_id: Some(agent_id.clone()), - project_id: Some(project_id.clone()), - trigger_id: TriggerId::new(), - fire_slot: chrono::Utc::now(), - }) - .await - .expect("check access"); - assert_eq!(allowed, TriggerFireAccessDecision::Allowed); - - let denied = checker - .check_trigger_fire_access(TriggerFireAccessCheck { - tenant_id, - creator_user_id: other_user_id, - agent_id: Some(agent_id), - project_id: Some(project_id), - trigger_id: TriggerId::new(), - fire_slot: chrono::Utc::now(), - }) - .await - .expect("check access"); - assert!(matches!( - denied, - TriggerFireAccessDecision::Denied { reason } - if reason.contains("does not have active local access") - )); - } -} - /// Reborn model purpose slot names exposed for diagnostic callers. /// /// This keeps CLI diagnostics on the composition boundary instead of making diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index 1ebd8a7cd1d..6f4d3e8e9f5 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -144,8 +144,12 @@ use crate::automation::trigger_poller::{ TriggerPollerRuntimeHandle, spawn_trigger_poller, }; use crate::runtime_input::{ - PollSettings, RebornRuntimeIdentity, RebornRuntimeInput, TriggerPollerAuthorizerConfig, - TriggerPollerSettings, + PollSettings, RebornRuntimeIdentity, RebornRuntimeInput, TriggerFireAccessChecker, + TriggerFireAccessGrant, TriggerPollerAuthorizerConfig, TriggerPollerSettings, +}; +use crate::trigger_fire_access::{ + CompositeTriggerFireChecker, IdentityMembershipTriggerFireChecker, + StaticOwnerTriggerFireChecker, }; use crate::{ RebornBuildError, RebornProductAuthServices, RebornReadiness, RebornServices, @@ -3074,6 +3078,7 @@ pub async fn build_reborn_runtime( trigger_poller, credential_refresh, trigger_fire_access_checker, + trigger_fire_access, poll, identity, default_project_id, @@ -3965,16 +3970,73 @@ pub async fn build_reborn_runtime( let local_runtime = local_runtime.ok_or(RebornRuntimeError::InvalidArgument { reason: "trigger poller is not wired for production runtime launch".to_string(), })?; + // Fire-time authorizer: an explicit override wins (tests/advanced), + // otherwise build one from the deployment's `TriggerFireAccessPolicy` + // (arch-simplification §4.4 — the former `local_trigger_access` store is + // now a config value, not a per-deployment store type). Grants are + // OR-combined, preserving the union the single store expressed. + let mut grant_checkers: Vec> = Vec::new(); + for grant in trigger_fire_access.grants() { + match grant { + TriggerFireAccessGrant::StaticOwner { + owner, + agent, + project, + } => { + let checker: Arc = + Arc::new(StaticOwnerTriggerFireChecker::new( + thread_scope.tenant_id.clone(), + owner.clone(), + agent.clone(), + project.clone(), + )); + grant_checkers.push(checker); + } + TriggerFireAccessGrant::TenantMembership { agent, project } => { + // Membership is resolved against the canonical identity + // directory the SSO login path populates — the same store + // `reborn_user_directory` opens, built here from the + // runtime's own identity filesystem. + let store = ironclaw_reborn_identity::FilesystemRebornIdentityStore::new( + Arc::clone(&local_runtime.identity_filesystem), + thread_scope.tenant_id.clone(), + actor_user_id.clone(), + thread_scope.agent_id.clone(), + thread_scope.project_id.clone(), + ); + let directory: Arc = + Arc::new(store); + let checker: Arc = + Arc::new(IdentityMembershipTriggerFireChecker::new( + directory, + thread_scope.tenant_id.clone(), + agent.clone(), + project.clone(), + )); + grant_checkers.push(checker); + } + } + } + let policy_checker: Option> = if grant_checkers.len() <= 1 + { + grant_checkers.into_iter().next() + } else { + let composite: Arc = + Arc::new(CompositeTriggerFireChecker::new(grant_checkers)); + Some(composite) + }; + let effective_trigger_fire_access_checker = + trigger_fire_access_checker.clone().or(policy_checker); validate_trigger_poller_authorization( &trigger_poller, - trigger_fire_access_checker.as_ref(), + effective_trigger_fire_access_checker.as_ref(), )?; let trigger_poller_services = build_trigger_poller_services( local_runtime, Arc::clone(&planned_turn_coordinator), Arc::clone(&thread_service), trigger_poller.authorizer, - trigger_fire_access_checker.clone(), + effective_trigger_fire_access_checker.clone(), thread_scope.tenant_id.clone(), validated_identity.agent_id.clone(), ) diff --git a/crates/ironclaw_reborn_composition/src/runtime_input.rs b/crates/ironclaw_reborn_composition/src/runtime_input.rs index 82473c9ce79..2cd5ff132bb 100644 --- a/crates/ironclaw_reborn_composition/src/runtime_input.rs +++ b/crates/ironclaw_reborn_composition/src/runtime_input.rs @@ -125,6 +125,79 @@ pub trait TriggerFireAccessChecker: Send + Sync { ) -> Result; } +/// A single fire-time access grant. The granted scope is exact (`None` project +/// means "no project", never a wildcard), matching [`TriggerFireAccessCheck`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TriggerFireAccessGrant { + /// A single static owner may fire triggers for the granted scope — the + /// env-token `serve` and CLI `run` owner grant. `owner` is the + /// caller-configured owner id (formerly seeded into the trigger-access + /// store); the check is a pure comparison, no persistence. + StaticOwner { + owner: UserId, + agent: AgentId, + project: Option, + }, + /// Any active member of the host tenant may fire triggers for the granted + /// scope — the SSO/WebUI deployment. Membership is resolved at fire time + /// from the canonical identity directory (the `StoredUser` records SSO + /// login persists), so a suspended or unknown creator is denied. + TenantMembership { + agent: AgentId, + project: Option, + }, +} + +/// How fire-time trigger access is authorized for this deployment — the set of +/// grants that authorize a fire, OR-combined. +/// +/// This is the config value that replaced the former `local_trigger_access` +/// shadow store (arch-simplification §4.4): the owner grant is *data* resolved +/// at the serve/run edge, and `build_reborn_runtime` builds the matching +/// [`TriggerFireAccessChecker`] from it — no per-deployment store type. An empty +/// policy wires no authorizer (poller disabled / authorization supplied out of +/// band). A `serve` with both the operator owner and SSO carries both a +/// `StaticOwner` and a `TenantMembership` grant, preserving the union the old +/// single store expressed. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TriggerFireAccessPolicy { + grants: Vec, +} + +impl TriggerFireAccessPolicy { + /// No fire-time authorizer. + pub fn disabled() -> Self { + Self::default() + } + + /// Grant the caller-configured static owner access for the exact scope. + pub fn with_static_owner( + mut self, + owner: UserId, + agent: AgentId, + project: Option, + ) -> Self { + self.grants.push(TriggerFireAccessGrant::StaticOwner { + owner, + agent, + project, + }); + self + } + + /// Grant any active member of the host tenant access for the exact scope. + pub fn with_tenant_membership(mut self, agent: AgentId, project: Option) -> Self { + self.grants + .push(TriggerFireAccessGrant::TenantMembership { agent, project }); + self + } + + /// The declared grants, OR-combined at fire time by the build. + pub(crate) fn grants(&self) -> &[TriggerFireAccessGrant] { + &self.grants + } +} + #[derive(Clone)] pub struct ResolvedRebornLlm { provider_id: String, @@ -418,7 +491,15 @@ pub struct RebornRuntimeInput { pub tool_disclosure: Option, pub trigger_poller: TriggerPollerSettings, pub credential_refresh: CredentialRefreshSettings, + /// Explicit fire-time access checker override. Primarily a test/advanced + /// seam; production callers set [`trigger_fire_access`](Self::trigger_fire_access) + /// and let the build construct the checker. When set, it takes precedence + /// over the policy. pub trigger_fire_access_checker: Option>, + /// The deployment's fire-time access policy. `build_reborn_runtime` builds + /// the matching [`TriggerFireAccessChecker`] from this when the trigger + /// poller is enabled and no explicit checker override is supplied. + pub trigger_fire_access: TriggerFireAccessPolicy, pub poll: PollSettings, pub identity: RebornRuntimeIdentity, /// Optional project scope for runtime-owned thread I/O. Channel adapters @@ -487,6 +568,7 @@ impl RebornRuntimeInput { trigger_poller: TriggerPollerSettings::default(), credential_refresh: CredentialRefreshSettings::default(), trigger_fire_access_checker: None, + trigger_fire_access: TriggerFireAccessPolicy::default(), poll: PollSettings::default(), identity: RebornRuntimeIdentity::default(), default_project_id: None, @@ -632,6 +714,11 @@ impl RebornRuntimeInput { self } + pub fn with_trigger_fire_access_policy(mut self, policy: TriggerFireAccessPolicy) -> Self { + self.trigger_fire_access = policy; + self + } + pub fn with_poll_settings(mut self, poll: PollSettings) -> Self { self.poll = poll; self diff --git a/crates/ironclaw_reborn_composition/src/trigger_fire_access.rs b/crates/ironclaw_reborn_composition/src/trigger_fire_access.rs new file mode 100644 index 00000000000..18d58d0d268 --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/trigger_fire_access.rs @@ -0,0 +1,633 @@ +//! Fire-time trigger access checkers built from [`TriggerFireAccessPolicy`]. +//! +//! These replaced the former `ironclaw_runner::local_trigger_access` shadow +//! store (arch-simplification §4.4). Trigger-fire authorization is no longer a +//! persisted parallel access table: it is either a pure comparison against a +//! config-supplied owner ([`StaticOwnerTriggerFireChecker`]) or a membership +//! lookup against the canonical identity directory the SSO login path already +//! populates ([`IdentityMembershipTriggerFireChecker`]). The composition build +//! selects one from [`TriggerFireAccessPolicy`] when the trigger poller is +//! enabled. + +use std::sync::Arc; + +use async_trait::async_trait; +use ironclaw_host_api::{AgentId, ProjectId, TenantId, UserId}; + +use crate::runtime_input::{ + TriggerFireAccessCheck, TriggerFireAccessChecker, TriggerFireAccessDecision, + TriggerFireAccessError, +}; + +const DENY_REASON: &str = "trigger creator does not have active access for this scope"; + +/// Does the fire-time check's exact scope match the granted `(agent, project)` +/// grant? Scope is exact — `None` project means "no project", never a wildcard +/// (matches [`TriggerFireAccessCheck`] semantics). +fn scope_matches( + check: &TriggerFireAccessCheck, + agent: &AgentId, + project: &Option, +) -> bool { + check.agent_id.as_ref() == Some(agent) && &check.project_id == project +} + +fn denied() -> TriggerFireAccessDecision { + TriggerFireAccessDecision::Denied { + reason: DENY_REASON.to_string(), + } +} + +/// A single configured owner may fire triggers for one exact scope — the +/// env-token `serve` and CLI `run` owner grant. Pure comparison, no I/O. +/// +/// The `tenant_id` bound is load-bearing: the due-trigger repository is global, +/// so a fire-time check that matched only owner + scope could authorize a +/// foreign tenant's trigger whose creator id happened to equal this owner. The +/// former store keyed every row on tenant; this preserves that. +pub(crate) struct StaticOwnerTriggerFireChecker { + tenant_id: TenantId, + owner: UserId, + agent: AgentId, + project: Option, +} + +impl StaticOwnerTriggerFireChecker { + pub(crate) fn new( + tenant_id: TenantId, + owner: UserId, + agent: AgentId, + project: Option, + ) -> Self { + Self { + tenant_id, + owner, + agent, + project, + } + } +} + +#[async_trait] +impl TriggerFireAccessChecker for StaticOwnerTriggerFireChecker { + async fn check_trigger_fire_access( + &self, + request: TriggerFireAccessCheck, + ) -> Result { + let allowed = request.tenant_id == self.tenant_id + && request.creator_user_id == self.owner + && scope_matches(&request, &self.agent, &self.project); + Ok(if allowed { + TriggerFireAccessDecision::Allowed + } else { + denied() + }) + } +} + +/// Any active member of the host tenant may fire triggers for one exact scope — +/// the SSO/WebUI deployment. Membership is resolved at fire time from the +/// canonical identity directory (the `StoredUser` records SSO login persists), +/// so a suspended, wrong-tenant, or unknown creator is denied. A directory +/// backend error surfaces as retryable `Unavailable`, never a hard denial. +pub(crate) struct IdentityMembershipTriggerFireChecker { + directory: Arc, + tenant_id: TenantId, + agent: AgentId, + project: Option, +} + +impl IdentityMembershipTriggerFireChecker { + pub(crate) fn new( + directory: Arc, + tenant_id: TenantId, + agent: AgentId, + project: Option, + ) -> Self { + Self { + directory, + tenant_id, + agent, + project, + } + } +} + +#[async_trait] +impl TriggerFireAccessChecker for IdentityMembershipTriggerFireChecker { + async fn check_trigger_fire_access( + &self, + request: TriggerFireAccessCheck, + ) -> Result { + if !scope_matches(&request, &self.agent, &self.project) { + return Ok(denied()); + } + let user = self + .directory + .get_user(&request.creator_user_id) + .await + .map_err(|error| TriggerFireAccessError::Unavailable { + reason: error.to_string(), + })?; + // Active member of THIS tenant. A record with no persisted tenant is + // treated as belonging to the requested tenant (single-tenant + // back-compat, matching `RebornUserDirectory` enumeration). + let allowed = user.is_some_and(|user| { + user.status == ironclaw_reborn_identity::RebornUserStatus::Active + // `is_none_or` (stable since Rust 1.82) is within MSRV — this + // workspace is edition 2024 (Rust ≥ 1.85) and clippy enforces it + // over `map_or(true, …)`. + && user + .tenant_id + .as_ref() + .is_none_or(|tenant| tenant == &self.tenant_id) + }); + Ok(if allowed { + TriggerFireAccessDecision::Allowed + } else { + denied() + }) + } +} + +/// OR-combines several checkers: `Allowed` if any grant allows; otherwise +/// `Unavailable` if any grant's backend was unavailable (retryable, so a +/// transient identity-store fault is not a hard denial); otherwise `Denied`. +pub(crate) struct CompositeTriggerFireChecker { + checkers: Vec>, +} + +impl CompositeTriggerFireChecker { + pub(crate) fn new(checkers: Vec>) -> Self { + Self { checkers } + } +} + +#[async_trait] +impl TriggerFireAccessChecker for CompositeTriggerFireChecker { + async fn check_trigger_fire_access( + &self, + request: TriggerFireAccessCheck, + ) -> Result { + // Split so the last checker takes `request` by move — no redundant + // final clone (the common case is a single StaticOwner + SsoMembership + // pair, so this saves one clone per fire). + let Some((last, rest)) = self.checkers.split_last() else { + return Ok(denied()); + }; + let mut unavailable: Option = None; + for checker in rest { + match checker.check_trigger_fire_access(request.clone()).await { + Ok(TriggerFireAccessDecision::Allowed) => { + return Ok(TriggerFireAccessDecision::Allowed); + } + Ok(TriggerFireAccessDecision::Denied { .. }) => {} + Err(error) => unavailable = Some(error), + } + } + match last.check_trigger_fire_access(request).await { + Ok(TriggerFireAccessDecision::Allowed) => Ok(TriggerFireAccessDecision::Allowed), + Ok(TriggerFireAccessDecision::Denied { .. }) => match unavailable { + Some(error) => Err(error), + None => Ok(denied()), + }, + Err(error) => Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn check(creator: &str, agent: Option<&str>, project: Option<&str>) -> TriggerFireAccessCheck { + TriggerFireAccessCheck { + tenant_id: TenantId::new("tenant").expect("tenant"), + creator_user_id: UserId::new(creator).expect("user"), + agent_id: agent.map(|a| AgentId::new(a).expect("agent")), + project_id: project.map(|p| ProjectId::new(p).expect("project")), + trigger_id: ironclaw_triggers::TriggerId::new(), + fire_slot: chrono::Utc::now(), + } + } + + fn static_checker() -> StaticOwnerTriggerFireChecker { + StaticOwnerTriggerFireChecker::new( + TenantId::new("tenant").expect("tenant"), + UserId::new("owner").expect("user"), + AgentId::new("agent").expect("agent"), + Some(ProjectId::new("project").expect("project")), + ) + } + + #[tokio::test] + async fn static_owner_allows_exact_owner_and_scope() { + let decision = static_checker() + .check_trigger_fire_access(check("owner", Some("agent"), Some("project"))) + .await + .expect("check"); + assert_eq!(decision, TriggerFireAccessDecision::Allowed); + } + + #[tokio::test] + async fn static_owner_denies_non_owner() { + let decision = static_checker() + .check_trigger_fire_access(check("intruder", Some("agent"), Some("project"))) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + } + + #[tokio::test] + async fn static_owner_denies_scope_mismatch() { + // Right owner, wrong project scope. + let decision = static_checker() + .check_trigger_fire_access(check("owner", Some("agent"), Some("other"))) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + // Right owner, missing project where one was granted. + let decision = static_checker() + .check_trigger_fire_access(check("owner", Some("agent"), None)) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + } + + #[tokio::test] + async fn static_owner_denies_foreign_tenant() { + // The due-trigger repository is global: a foreign tenant's trigger with + // a matching owner id + scope must NOT be authorized (regression guard). + let foreign = TriggerFireAccessCheck { + tenant_id: TenantId::new("other-tenant").expect("tenant"), + creator_user_id: UserId::new("owner").expect("user"), + agent_id: Some(AgentId::new("agent").expect("agent")), + project_id: Some(ProjectId::new("project").expect("project")), + trigger_id: ironclaw_triggers::TriggerId::new(), + fire_slot: chrono::Utc::now(), + }; + let decision = static_checker() + .check_trigger_fire_access(foreign) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + } + + #[tokio::test] + async fn composite_allows_if_any_grant_allows() { + // Two static owners; only the second matches the creator. + let checkers: Vec> = vec![ + Arc::new(StaticOwnerTriggerFireChecker::new( + TenantId::new("tenant").expect("tenant"), + UserId::new("owner-a").expect("user"), + AgentId::new("agent").expect("agent"), + Some(ProjectId::new("project").expect("project")), + )), + Arc::new(StaticOwnerTriggerFireChecker::new( + TenantId::new("tenant").expect("tenant"), + UserId::new("owner-b").expect("user"), + AgentId::new("agent").expect("agent"), + Some(ProjectId::new("project").expect("project")), + )), + ]; + let composite = CompositeTriggerFireChecker::new(checkers); + let decision = composite + .check_trigger_fire_access(check("owner-b", Some("agent"), Some("project"))) + .await + .expect("check"); + assert_eq!(decision, TriggerFireAccessDecision::Allowed); + } + + #[tokio::test] + async fn composite_denies_if_no_grant_allows() { + let checkers: Vec> = + vec![Arc::new(StaticOwnerTriggerFireChecker::new( + TenantId::new("tenant").expect("tenant"), + UserId::new("owner-a").expect("user"), + AgentId::new("agent").expect("agent"), + None, + ))]; + let composite = CompositeTriggerFireChecker::new(checkers); + let decision = composite + .check_trigger_fire_access(check("stranger", Some("agent"), None)) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + } + + mod identity { + use super::*; + use ironclaw_reborn_identity::{ + RebornIdentityError, RebornUser, RebornUserDirectory, RebornUserProfileUpdate, + RebornUserRole, RebornUserStatus, + }; + + /// Directory double returning one configured user (or none), and a + /// backend-error mode for the retryable-unavailable path. + struct FakeDirectory { + user: Option, + fail: bool, + } + + impl FakeDirectory { + fn with_user(user: RebornUser) -> Self { + Self { + user: Some(user), + fail: false, + } + } + fn empty() -> Self { + Self { + user: None, + fail: false, + } + } + fn failing() -> Self { + Self { + user: None, + fail: true, + } + } + } + + fn user(status: RebornUserStatus, tenant: Option<&str>) -> RebornUser { + RebornUser { + user_id: UserId::new("member").expect("user"), + email: None, + display_name: None, + status, + role: RebornUserRole::Member, + created_at: String::new(), + updated_at: String::new(), + created_by: None, + last_login_at: None, + tenant_id: tenant.map(|t| TenantId::new(t).expect("tenant")), + metadata: Default::default(), + } + } + + #[async_trait] + impl RebornUserDirectory for FakeDirectory { + async fn list_users( + &self, + _tenant_id: &TenantId, + _status: Option, + _after: Option<&UserId>, + _limit: usize, + ) -> Result, RebornIdentityError> { + Ok(self.user.clone().into_iter().collect()) + } + async fn get_user( + &self, + _user_id: &UserId, + ) -> Result, RebornIdentityError> { + if self.fail { + return Err(RebornIdentityError::Backend("backend down".to_string())); + } + Ok(self.user.clone()) + } + async fn create_user( + &self, + _tenant_id: &TenantId, + _email: Option, + _display_name: Option, + _role: RebornUserRole, + _created_by: &UserId, + ) -> Result { + unimplemented!("not used") + } + async fn update_profile( + &self, + _user_id: &UserId, + _update: RebornUserProfileUpdate, + ) -> Result { + unimplemented!("not used") + } + async fn update_status( + &self, + _user_id: &UserId, + _status: RebornUserStatus, + ) -> Result { + unimplemented!("not used") + } + async fn update_role( + &self, + _user_id: &UserId, + _role: RebornUserRole, + ) -> Result { + unimplemented!("not used") + } + async fn record_last_login( + &self, + _user_id: &UserId, + _at: String, + ) -> Result<(), RebornIdentityError> { + unimplemented!("not used") + } + async fn delete_user( + &self, + _tenant_id: &TenantId, + _user_id: &UserId, + ) -> Result<(), RebornIdentityError> { + unimplemented!("not used") + } + async fn count_active_admins( + &self, + _tenant_id: &TenantId, + ) -> Result { + unimplemented!("not used") + } + } + + fn membership_checker(directory: FakeDirectory) -> IdentityMembershipTriggerFireChecker { + IdentityMembershipTriggerFireChecker::new( + Arc::new(directory), + TenantId::new("tenant").expect("tenant"), + AgentId::new("agent").expect("agent"), + Some(ProjectId::new("project").expect("project")), + ) + } + + #[tokio::test] + async fn active_member_of_tenant_is_allowed() { + let decision = membership_checker(FakeDirectory::with_user(user( + RebornUserStatus::Active, + Some("tenant"), + ))) + .check_trigger_fire_access(check("member", Some("agent"), Some("project"))) + .await + .expect("check"); + assert_eq!(decision, TriggerFireAccessDecision::Allowed); + } + + #[tokio::test] + async fn record_without_tenant_is_allowed_single_tenant_backcompat() { + let decision = membership_checker(FakeDirectory::with_user(user( + RebornUserStatus::Active, + None, + ))) + .check_trigger_fire_access(check("member", Some("agent"), Some("project"))) + .await + .expect("check"); + assert_eq!(decision, TriggerFireAccessDecision::Allowed); + } + + #[tokio::test] + async fn unknown_user_is_denied() { + let decision = membership_checker(FakeDirectory::empty()) + .check_trigger_fire_access(check("ghost", Some("agent"), Some("project"))) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + } + + #[tokio::test] + async fn suspended_member_is_denied() { + // The behavior the old seed-only store lacked: suspension revokes. + let decision = membership_checker(FakeDirectory::with_user(user( + RebornUserStatus::Suspended, + Some("tenant"), + ))) + .check_trigger_fire_access(check("member", Some("agent"), Some("project"))) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + } + + #[tokio::test] + async fn wrong_tenant_member_is_denied() { + let decision = membership_checker(FakeDirectory::with_user(user( + RebornUserStatus::Active, + Some("other-tenant"), + ))) + .check_trigger_fire_access(check("member", Some("agent"), Some("project"))) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + } + + #[tokio::test] + async fn scope_mismatch_is_denied_without_directory_hit() { + let decision = membership_checker(FakeDirectory::with_user(user( + RebornUserStatus::Active, + Some("tenant"), + ))) + .check_trigger_fire_access(check("member", Some("other-agent"), Some("project"))) + .await + .expect("check"); + assert!(matches!(decision, TriggerFireAccessDecision::Denied { .. })); + } + + #[tokio::test] + async fn backend_error_is_retryable_unavailable() { + let error = membership_checker(FakeDirectory::failing()) + .check_trigger_fire_access(check("member", Some("agent"), Some("project"))) + .await + .expect_err("directory error"); + assert!(matches!(error, TriggerFireAccessError::Unavailable { .. })); + } + + /// Integration coverage over the REAL identity store the SSO login path + /// populates (not the fake): a user resolved through `resolve_or_create` + /// is an allowed trigger-fire member; an unknown user is denied; and + /// suspending the user revokes access — the behavior the former + /// seed-only trigger-access store lacked. Crate-tier because the checker + /// and directory are composition-internal (`pub(crate)`), so an external + /// `tests/` integration file cannot construct them. + #[tokio::test] + async fn real_identity_store_membership_backs_fire_access() { + use ironclaw_host_api::{ + AgentId as HostAgentId, MountAlias, MountGrant, MountPermissions, MountView, + UserId as HostUserId, VirtualPath, + }; + use ironclaw_reborn_identity::{ + ExternalSubjectId, FilesystemRebornIdentityStore, ProviderKind, + RebornIdentityResolver, RebornUserDirectory, RebornUserStatus, + ResolveExternalIdentity, SurfaceKind, + }; + + let tenant = TenantId::new("real-tenant").expect("tenant"); + let root = Arc::new(ironclaw_filesystem::InMemoryBackend::default()); + let view = MountView::new(vec![MountGrant::new( + MountAlias::new("/tenant-shared").expect("alias"), + VirtualPath::new("/tenants/test/shared").expect("path"), + MountPermissions::read_write_list_delete(), + )]) + .expect("view"); + let filesystem = Arc::new(ironclaw_filesystem::ScopedFilesystem::with_fixed_view( + root, view, + )); + let store = Arc::new(FilesystemRebornIdentityStore::new( + filesystem, + tenant.clone(), + HostUserId::new("runtime-owner").expect("owner"), + HostAgentId::new("agent").expect("agent"), + None, + )); + + // Admit a user exactly as the SSO login path does. + let resolver: Arc = store.clone(); + let user_id = resolver + .resolve_or_create(ResolveExternalIdentity { + tenant_id: tenant.clone(), + surface_kind: SurfaceKind::Oauth, + provider_kind: ProviderKind::new("google").expect("provider"), + provider_instance_id: None, + external_subject_id: ExternalSubjectId::new("subject-1").expect("subject"), + email: Some("alice@example.com".to_string()), + email_verified: true, + display_name: None, + }) + .await + .expect("resolve_or_create admits the user"); + + let directory: Arc = store.clone(); + let checker = IdentityMembershipTriggerFireChecker::new( + directory.clone(), + tenant.clone(), + AgentId::new("agent").expect("agent"), + None, + ); + + let allowed = checker + .check_trigger_fire_access(TriggerFireAccessCheck { + tenant_id: tenant.clone(), + creator_user_id: user_id.clone(), + agent_id: Some(AgentId::new("agent").expect("agent")), + project_id: None, + trigger_id: ironclaw_triggers::TriggerId::new(), + fire_slot: chrono::Utc::now(), + }) + .await + .expect("check"); + assert_eq!(allowed, TriggerFireAccessDecision::Allowed); + + let unknown = checker + .check_trigger_fire_access(check("never-logged-in", Some("agent"), None)) + .await + .expect("check"); + assert!(matches!(unknown, TriggerFireAccessDecision::Denied { .. })); + + // Suspension revokes trigger-fire access (the new, stricter behavior). + directory + .update_status(&user_id, RebornUserStatus::Suspended) + .await + .expect("suspend"); + let after_suspend = checker + .check_trigger_fire_access(TriggerFireAccessCheck { + tenant_id: tenant, + creator_user_id: user_id, + agent_id: Some(AgentId::new("agent").expect("agent")), + project_id: None, + trigger_id: ironclaw_triggers::TriggerId::new(), + fire_slot: chrono::Utc::now(), + }) + .await + .expect("check"); + assert!(matches!( + after_suspend, + TriggerFireAccessDecision::Denied { .. } + )); + } + } +} diff --git a/crates/ironclaw_reborn_composition/tests/facade_factory.rs b/crates/ironclaw_reborn_composition/tests/facade_factory.rs index 52fec2f405e..836ad2bed7b 100644 --- a/crates/ironclaw_reborn_composition/tests/facade_factory.rs +++ b/crates/ironclaw_reborn_composition/tests/facade_factory.rs @@ -15,8 +15,6 @@ use chrono::Utc; use deadpool_postgres::tokio_postgres; #[cfg(feature = "libsql")] use ironclaw_auth::{OAuthClientId, OAuthRedirectUri}; -#[cfg(feature = "postgres")] -use ironclaw_host_api::{AgentId, ProjectId, TenantId}; #[cfg(any(feature = "libsql", feature = "postgres"))] use ironclaw_host_api::{ AuditMode, DeploymentMode, EffectKind, FilesystemBackendKind, NetworkMode, PackageId, @@ -36,10 +34,6 @@ use ironclaw_host_runtime::{ }; #[cfg(any(feature = "libsql", feature = "postgres"))] use ironclaw_reborn_composition::RebornRuntimeProcessBinding; -#[cfg(feature = "postgres")] -use ironclaw_reborn_composition::{ - LocalTriggerAccessRole, LocalTriggerAccessSeed, LocalTriggerAccessSource, -}; #[cfg(any(feature = "libsql", feature = "postgres"))] use ironclaw_reborn_composition::{RebornBuildError, RebornCompositionProfile, RebornServices}; use ironclaw_reborn_composition::{ @@ -51,8 +45,6 @@ use ironclaw_reborn_composition::{ RebornReadinessDiagnosticComponent, RebornReadinessDiagnosticReason, RebornReadinessDiagnosticStatus, }; -#[cfg(feature = "postgres")] -use ironclaw_reborn_config::{RebornConfigFile, StorageBackend, StorageSection}; #[cfg(any(feature = "libsql", feature = "postgres"))] use ironclaw_runner::turn_scheduler::{ SchedulerTurnRunWakeNotifier, TurnRunExecutor, TurnRunExecutorError, TurnRunScheduler, @@ -82,9 +74,6 @@ use tokio::sync::Mutex; #[cfg(feature = "libsql")] static SECRETS_MASTER_KEY_ENV_LOCK: Mutex<()> = Mutex::const_new(()); -#[cfg(feature = "postgres")] -static HOSTED_TRIGGER_ACCESS_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - #[cfg(feature = "libsql")] struct EnvVarGuard { key: &'static str, @@ -118,49 +107,6 @@ impl Drop for EnvVarGuard { } } -#[cfg(feature = "postgres")] -struct PostgresEnvVarGuard { - key: &'static str, - previous: Option, -} - -#[cfg(feature = "postgres")] -impl PostgresEnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let previous = std::env::var_os(key); - // SAFETY: tests serialize process-env mutation with - // HOSTED_TRIGGER_ACCESS_ENV_LOCK and restore the prior value on drop. - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } - - fn clear(key: &'static str) -> Self { - let previous = std::env::var_os(key); - // SAFETY: tests serialize process-env mutation with - // HOSTED_TRIGGER_ACCESS_ENV_LOCK and restore the prior value on drop. - unsafe { - std::env::remove_var(key); - } - Self { key, previous } - } -} - -#[cfg(feature = "postgres")] -impl Drop for PostgresEnvVarGuard { - fn drop(&mut self) { - // SAFETY: PostgresEnvVarGuard is only constructed while - // HOSTED_TRIGGER_ACCESS_ENV_LOCK is held by this test module. - unsafe { - match &self.previous { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } -} - #[cfg(any(feature = "libsql", feature = "postgres"))] fn test_master_key() -> SecretMaterial { SecretMaterial::from("01234567890123456789012345678901") @@ -1532,85 +1478,6 @@ async fn production_postgres_services_migrate_trigger_repository_before_runtime_ assert_eq!(count, 0); } -#[cfg(feature = "postgres")] -#[tokio::test] -async fn hosted_single_tenant_trigger_access_store_persists_across_reopen() { - let Some((_container, _pool, database_url)) = postgres_pool_or_skip().await else { - return; - }; - let _env_lock = HOSTED_TRIGGER_ACCESS_ENV_LOCK.lock().await; - let _database_url = PostgresEnvVarGuard::set("IRONCLAW_REBORN_POSTGRES_URL", &database_url); - let _secret_master_key = PostgresEnvVarGuard::set( - "IRONCLAW_REBORN_SECRET_MASTER_KEY", - "01234567890123456789012345678901", - ); - let _pool_max_size = PostgresEnvVarGuard::set("IRONCLAW_REBORN_POSTGRES_POOL_MAX_SIZE", "1"); - let _resource_governor_singleton = PostgresEnvVarGuard::set( - "IRONCLAW_REBORN_POSTGRES_RESOURCE_GOVERNOR_SINGLETON", - "true", - ); - let _allow_cleartext = - PostgresEnvVarGuard::set("IRONCLAW_REBORN_ALLOW_REMOTE_POSTGRES_CLEAR_TEXT", "true"); - let _ssl_mode = PostgresEnvVarGuard::clear("DATABASE_SSLMODE"); - let root = tempfile::tempdir().expect("runtime root"); - let config = RebornConfigFile { - storage: Some(StorageSection { - backend: Some(StorageBackend::Postgres), - pool_max_size: Some(1), - ..Default::default() - }), - ..Default::default() - }; - let tenant_id = TenantId::new("hosted-trigger-tenant").expect("tenant id"); - let user_id = UserId::new("hosted-trigger-user").expect("user id"); - let agent_id = AgentId::new("hosted-trigger-agent").expect("agent id"); - let project_id = ProjectId::new("hosted-trigger-project").expect("project id"); - - let input = RebornBuildInput::hosted_single_tenant_postgres_from_config_and_env( - RebornCompositionProfile::HostedSingleTenant, - "hosted-trigger-owner", - root.path().to_path_buf(), - Some(&config), - ) - .expect("hosted postgres build input resolves from env"); - let store = input - .open_hosted_single_tenant_trigger_access_store() - .await - .expect("open hosted trigger access store"); - store - .seed_local_access(LocalTriggerAccessSeed { - tenant_id: &tenant_id, - user_id: &user_id, - agent_id: Some(&agent_id), - project_id: Some(&project_id), - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevEnvBootstrap, - }) - .await - .expect("seed hosted trigger access"); - drop(store); - - let reopened_input = RebornBuildInput::hosted_single_tenant_postgres_from_config_and_env( - RebornCompositionProfile::HostedSingleTenant, - "hosted-trigger-owner", - root.path().to_path_buf(), - Some(&config), - ) - .expect("reopened hosted postgres build input resolves from env"); - let reopened_store = reopened_input - .open_hosted_single_tenant_trigger_access_store() - .await - .expect("reopen hosted trigger access store"); - - assert!( - reopened_store - .has_active_local_access(&tenant_id, &user_id, Some(&agent_id), Some(&project_id)) - .await - .expect("check reopened hosted trigger access"), - "hosted-single-tenant trigger access must persist through the filesystem-backed Postgres store" - ); -} - #[cfg(feature = "postgres")] #[tokio::test] async fn production_postgres_services_wire_first_party_runtime_http_egress() { diff --git a/crates/ironclaw_runner/Cargo.toml b/crates/ironclaw_runner/Cargo.toml index 8ffb58be5d2..357a07bfa71 100644 --- a/crates/ironclaw_runner/Cargo.toml +++ b/crates/ironclaw_runner/Cargo.toml @@ -22,12 +22,6 @@ libsql-secrets = [ "filesystem-goal-store", ] filesystem-goal-store = ["dep:ironclaw_filesystem"] -# Local-dev trigger-fire access store (libSQL-backed). Named for its original -# WebChat user-store home; the canonical user store has since moved to the -# filesystem identity resolver, so this feature now only gates the -# `local_trigger_access` module. -webui-user-store = ["dep:libsql"] -filesystem-local-trigger-access = ["dep:ironclaw_filesystem"] # Opt-in because the restart coverage opens real libSQL-backed turn/thread stores. # Keep separate from `libsql-secrets` so local `cargo test -p ironclaw_runner` # stays fast, while CI can exercise process-restart persistence explicitly. diff --git a/crates/ironclaw_runner/src/lib.rs b/crates/ironclaw_runner/src/lib.rs index a42508b30df..cace341bd62 100644 --- a/crates/ironclaw_runner/src/lib.rs +++ b/crates/ironclaw_runner/src/lib.rs @@ -29,11 +29,6 @@ pub mod retry_disposition; // ironclaw_reborn_composition; they classify runner-owned categories). Re-exported // at the crate root so intra-cluster `crate::FailureLane` refs resolve and // composition can re-export them through its facade for the CLI. -#[cfg(any( - feature = "webui-user-store", - feature = "filesystem-local-trigger-access" -))] -pub mod local_trigger_access; pub mod loop_driver_host; pub mod loop_exit_applier; pub mod milestone_events; diff --git a/crates/ironclaw_runner/src/local_trigger_access/filesystem.rs b/crates/ironclaw_runner/src/local_trigger_access/filesystem.rs deleted file mode 100644 index 7c1d0195e91..00000000000 --- a/crates/ironclaw_runner/src/local_trigger_access/filesystem.rs +++ /dev/null @@ -1,695 +0,0 @@ -use std::collections::BTreeSet; -use std::sync::Arc; - -use chrono::{SecondsFormat, Utc}; -use ironclaw_filesystem::{ - CasExpectation, Entry, FilesystemError, Filter, IndexKey, IndexKind, IndexName, IndexSpec, - IndexValue, Page, RecordKind, RootFilesystem, ScopedFilesystem, VersionedEntry, -}; -use ironclaw_host_api::{ - AgentId, InvocationId, ProjectId, ResourceScope, ScopedPath, TenantId, UserId, -}; -use serde::{Deserialize, Serialize}; - -use super::types::{ - LocalTriggerAccessReconciliation, LocalTriggerAccessRole, LocalTriggerAccessSeed, - LocalTriggerAccessSource, LocalTriggerAccessStatus, LocalTriggerAccessStore, - RebornLocalTriggerAccessStoreError, backend, optional_scope_key, -}; - -/// Filesystem-backed local trigger access repository. -pub struct RebornFilesystemLocalTriggerAccessStore -where - F: RootFilesystem + 'static, -{ - filesystem: Arc>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct FilesystemLocalTriggerAccessRecord { - tenant_id: String, - user_id: String, - agent_id: Option, - project_id: Option, - role: LocalTriggerAccessRole, - status: LocalTriggerAccessStatus, - source: LocalTriggerAccessSource, - created_at: String, - updated_at: String, -} - -struct FilesystemReconciliationContext<'a> { - tenant_id: &'a TenantId, - agent_id: Option<&'a AgentId>, - project_id: Option<&'a ProjectId>, - source: LocalTriggerAccessSource, - allowed: &'a BTreeSet, -} - -impl RebornFilesystemLocalTriggerAccessStore -where - F: RootFilesystem + 'static, -{ - /// Build a store over the host filesystem abstraction. The root - /// filesystem backend has already run its own migrations at composition - /// time; this store owns only JSON record shapes and scoped paths. - pub fn new(filesystem: Arc>) -> Self { - Self { filesystem } - } - - fn record_entry( - record: &FilesystemLocalTriggerAccessRecord, - ) -> Result { - let body = serde_json::to_value(record).map_err(backend)?; - let entry = Entry::record(trigger_access_record_kind()?, &body) - .map_err(backend)? - .with_indexed( - index_key_tenant_id()?, - IndexValue::Text(record.tenant_id.clone()), - ) - .with_indexed( - index_key_user_id()?, - IndexValue::Text(record.user_id.clone()), - ) - .with_indexed( - index_key_agent_id()?, - IndexValue::Text(optional_scope_key(record.agent_id.as_deref()).to_string()), - ) - .with_indexed( - index_key_project_id()?, - IndexValue::Text(optional_scope_key(record.project_id.as_deref()).to_string()), - ) - .with_indexed( - index_key_role()?, - IndexValue::Text(record.role.as_str().to_string()), - ) - .with_indexed( - index_key_status()?, - IndexValue::Text(record.status.as_str().to_string()), - ) - .with_indexed( - index_key_source()?, - IndexValue::Text(record.source.as_str().to_string()), - ); - Ok(entry) - } - - async fn read_record( - &self, - scope: &ResourceScope, - path: &ScopedPath, - ) -> Result< - Option<( - FilesystemLocalTriggerAccessRecord, - ironclaw_filesystem::RecordVersion, - )>, - RebornLocalTriggerAccessStoreError, - > { - let Some(versioned) = self.filesystem.get(scope, path).await.map_err(backend)? else { - return Ok(None); - }; - let record = serde_json::from_slice(&versioned.entry.body).map_err(backend)?; - Ok(Some((record, versioned.version))) - } - - async fn put_record( - &self, - scope: &ResourceScope, - path: &ScopedPath, - record: &FilesystemLocalTriggerAccessRecord, - cas: CasExpectation, - ) -> Result<(), FilesystemAccessPutError> { - let entry = Self::record_entry(record).map_err(FilesystemAccessPutError::Other)?; - match self.filesystem.put(scope, path, entry, cas).await { - Ok(_) => Ok(()), - Err(FilesystemError::VersionMismatch { .. }) => { - Err(FilesystemAccessPutError::VersionMismatch) - } - Err(error) => Err(FilesystemAccessPutError::Other(backend(error))), - } - } - - async fn deactivate_stale_record( - &self, - context: &FilesystemReconciliationContext<'_>, - path: &ScopedPath, - user_id: &UserId, - ) -> Result<(), RebornLocalTriggerAccessStoreError> { - let scope = tenant_shared_scope( - context.tenant_id, - user_id, - context.agent_id, - context.project_id, - ); - for _ in 0..FILESYSTEM_CAS_RETRIES { - let Some((mut record, version)) = self.read_record(&scope, path).await? else { - return Ok(()); - }; - if !record_matches_scope( - &record, - context.tenant_id, - user_id, - context.agent_id, - context.project_id, - ) || record.source != context.source - || record.status != LocalTriggerAccessStatus::Active - || context.allowed.contains(record.user_id.as_str()) - { - return Ok(()); - } - record.status = LocalTriggerAccessStatus::Inactive; - record.updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - match self - .put_record(&scope, path, &record, CasExpectation::Version(version)) - .await - { - Ok(()) => return Ok(()), - Err(FilesystemAccessPutError::VersionMismatch) => continue, - Err(FilesystemAccessPutError::Other(error)) => return Err(error), - } - } - Err(backend(format!( - "filesystem CAS retries exhausted for path {}", - path.as_str() - ))) - } - - async fn ensure_reconciliation_indexes( - &self, - scope: &ResourceScope, - users_root: &ScopedPath, - ) -> Result<(), RebornLocalTriggerAccessStoreError> { - self.ensure_exact_index(scope, users_root, index_name_source()?, index_key_source()?) - .await?; - self.ensure_exact_index(scope, users_root, index_name_status()?, index_key_status()?) - .await?; - Ok(()) - } - - async fn ensure_exact_index( - &self, - scope: &ResourceScope, - prefix: &ScopedPath, - name: IndexName, - key: IndexKey, - ) -> Result<(), RebornLocalTriggerAccessStoreError> { - let spec = IndexSpec::new(name, vec![key], IndexKind::Exact); - match self.filesystem.ensure_index(scope, prefix, &spec).await { - Ok(()) => Ok(()), - Err(FilesystemError::Unsupported { .. }) => Ok(()), - Err(error) => Err(backend(error)), - } - } - - async fn query_active_reconciliation_records( - &self, - scope: &ResourceScope, - users_root: &ScopedPath, - source: LocalTriggerAccessSource, - ) -> Result, RebornLocalTriggerAccessStoreError> { - self.ensure_reconciliation_indexes(scope, users_root) - .await?; - let filter = active_reconciliation_filter(source)?; - let mut records = Vec::new(); - let mut offset = 0; - loop { - let page = Page::new(offset, Page::MAX_LIMIT); - let entries = match self - .filesystem - .query(scope, users_root, &filter, page) - .await - { - Ok(entries) => entries, - Err(error) if is_not_found(&error) => return Ok(records), - Err(error) => return Err(backend(error)), - }; - let received = entries.len(); - for entry in entries { - records.push(deserialize_query_record(entry)?); - } - if received < Page::MAX_LIMIT as usize { - break; - } - offset = offset.saturating_add(received as u64); - } - Ok(records) - } - - /// Seed the local trigger access row used by Reborn-owned fire-time trigger - /// authorization. Existing rows are left untouched so an operator can - /// revoke or edit access without the next boot or login silently - /// re-granting it. - pub async fn seed_local_access( - &self, - seed: LocalTriggerAccessSeed<'_>, - ) -> Result<(), RebornLocalTriggerAccessStoreError> { - let scope = - tenant_shared_scope(seed.tenant_id, seed.user_id, seed.agent_id, seed.project_id); - let path = access_record_path(seed.agent_id, seed.project_id, seed.user_id)?; - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - let record = FilesystemLocalTriggerAccessRecord { - tenant_id: seed.tenant_id.as_str().to_string(), - user_id: seed.user_id.as_str().to_string(), - agent_id: seed.agent_id.map(|agent_id| agent_id.as_str().to_string()), - project_id: seed - .project_id - .map(|project_id| project_id.as_str().to_string()), - role: seed.role, - status: LocalTriggerAccessStatus::Active, - source: seed.source, - created_at: now.clone(), - updated_at: now, - }; - match self - .put_record(&scope, &path, &record, CasExpectation::Absent) - .await - { - Ok(()) => Ok(()), - Err(FilesystemAccessPutError::VersionMismatch) => Ok(()), - Err(FilesystemAccessPutError::Other(error)) => Err(error), - } - } - - /// Reconcile bootstrap-owned local trigger access rows for one exact scope. - pub async fn reconcile_local_access( - &self, - reconciliation: LocalTriggerAccessReconciliation<'_>, - ) -> Result<(), RebornLocalTriggerAccessStoreError> { - let allowed: BTreeSet = reconciliation - .user_ids - .iter() - .map(|user_id| user_id.as_str().to_string()) - .collect(); - let bootstrap_user = match reconciliation.user_ids.first() { - Some(user_id) => user_id.clone(), - None => trigger_access_bootstrap_user_id()?, - }; - let scope = tenant_shared_scope( - reconciliation.tenant_id, - &bootstrap_user, - reconciliation.agent_id, - reconciliation.project_id, - ); - let users_root = - access_scope_users_root(reconciliation.agent_id, reconciliation.project_id)?; - let context = FilesystemReconciliationContext { - tenant_id: reconciliation.tenant_id, - agent_id: reconciliation.agent_id, - project_id: reconciliation.project_id, - source: reconciliation.source, - allowed: &allowed, - }; - let records = self - .query_active_reconciliation_records(&scope, &users_root, reconciliation.source) - .await?; - for record in records { - let Ok(user_id) = UserId::new(record.user_id.clone()) else { - continue; - }; - let path = - access_record_path(reconciliation.agent_id, reconciliation.project_id, &user_id)?; - self.deactivate_stale_record(&context, &path, &user_id) - .await?; - } - - for user_id in reconciliation.user_ids { - self.seed_local_access(LocalTriggerAccessSeed { - tenant_id: reconciliation.tenant_id, - user_id, - agent_id: reconciliation.agent_id, - project_id: reconciliation.project_id, - role: reconciliation.role, - source: reconciliation.source, - }) - .await?; - } - Ok(()) - } - - /// Return whether a local trigger user has active access for the exact - /// tenant/agent/project tuple on a trigger fire request. - pub async fn has_active_local_access( - &self, - tenant_id: &TenantId, - user_id: &UserId, - agent_id: Option<&AgentId>, - project_id: Option<&ProjectId>, - ) -> Result { - let scope = tenant_shared_scope(tenant_id, user_id, agent_id, project_id); - let path = access_record_path(agent_id, project_id, user_id)?; - let Some((record, _version)) = self.read_record(&scope, &path).await? else { - return Ok(false); - }; - Ok( - record_matches_scope(&record, tenant_id, user_id, agent_id, project_id) - && record.status == LocalTriggerAccessStatus::Active, - ) - } -} - -#[derive(Debug)] -enum FilesystemAccessPutError { - VersionMismatch, - Other(RebornLocalTriggerAccessStoreError), -} - -const FILESYSTEM_CAS_RETRIES: usize = 8; -const TRIGGER_ACCESS_ROOT: &str = "/tenant-shared/reborn-trigger-access"; -const TRIGGER_ACCESS_RECORD_KIND: &str = "reborn_trigger_access"; -const TRIGGER_ACCESS_SOURCE_INDEX_NAME: &str = "reborn_trigger_access_source"; -const TRIGGER_ACCESS_STATUS_INDEX_NAME: &str = "reborn_trigger_access_status"; -const TENANT_ID_INDEX_KEY: &str = "tenant_id"; -const USER_ID_INDEX_KEY: &str = "user_id"; -const AGENT_ID_INDEX_KEY: &str = "agent_id"; -const PROJECT_ID_INDEX_KEY: &str = "project_id"; -const ROLE_INDEX_KEY: &str = "role"; -const STATUS_INDEX_KEY: &str = "status"; -const SOURCE_INDEX_KEY: &str = "source"; - -fn tenant_shared_scope( - tenant_id: &TenantId, - user_id: &UserId, - agent_id: Option<&AgentId>, - project_id: Option<&ProjectId>, -) -> ResourceScope { - ResourceScope { - tenant_id: tenant_id.clone(), - user_id: user_id.clone(), - agent_id: agent_id.cloned(), - project_id: project_id.cloned(), - mission_id: None, - thread_id: None, - invocation_id: InvocationId::new(), - } -} - -fn trigger_access_bootstrap_user_id() -> Result { - UserId::new("trigger-access-bootstrap").map_err(backend) -} - -fn access_scope_users_root( - agent_id: Option<&AgentId>, - project_id: Option<&ProjectId>, -) -> Result { - ScopedPath::new(format!( - "{}/agents/{}/projects/{}/users", - TRIGGER_ACCESS_ROOT, - optional_axis_path(agent_id.map(AgentId::as_str)), - optional_axis_path(project_id.map(ProjectId::as_str)) - )) - .map_err(backend) -} - -fn access_record_path( - agent_id: Option<&AgentId>, - project_id: Option<&ProjectId>, - user_id: &UserId, -) -> Result { - ScopedPath::new(format!( - "{}/{}.json", - access_scope_users_root(agent_id, project_id)?.as_str(), - user_id.as_str() - )) - .map_err(backend) -} - -/// `pub(crate)`: shared with `subagent::await_edge` (§4.2's identical -/// `{some/|none}` optional-axis path encoding for await-edge paths). -pub(crate) fn optional_axis_path(value: Option<&str>) -> String { - match value { - Some(value) => format!("some/{value}"), - None => "none".to_string(), - } -} - -fn record_matches_scope( - record: &FilesystemLocalTriggerAccessRecord, - tenant_id: &TenantId, - user_id: &UserId, - agent_id: Option<&AgentId>, - project_id: Option<&ProjectId>, -) -> bool { - record.tenant_id == tenant_id.as_str() - && record.user_id == user_id.as_str() - && record.agent_id.as_deref() == agent_id.map(AgentId::as_str) - && record.project_id.as_deref() == project_id.map(ProjectId::as_str) -} - -fn deserialize_query_record( - entry: VersionedEntry, -) -> Result { - serde_json::from_slice(&entry.entry.body).map_err(backend) -} - -fn active_reconciliation_filter( - source: LocalTriggerAccessSource, -) -> Result { - Ok(Filter::And(vec![ - Filter::Eq { - key: index_key_source()?, - value: IndexValue::Text(source.as_str().to_string()), - }, - Filter::Eq { - key: index_key_status()?, - value: IndexValue::Text(LocalTriggerAccessStatus::Active.as_str().to_string()), - }, - ])) -} - -fn trigger_access_record_kind() -> Result { - RecordKind::new(TRIGGER_ACCESS_RECORD_KIND).map_err(backend) -} - -fn index_name(value: &'static str) -> Result { - IndexName::new(value).map_err(backend) -} - -fn index_key(value: &'static str) -> Result { - IndexKey::new(value).map_err(backend) -} - -fn index_name_source() -> Result { - index_name(TRIGGER_ACCESS_SOURCE_INDEX_NAME) -} - -fn index_name_status() -> Result { - index_name(TRIGGER_ACCESS_STATUS_INDEX_NAME) -} - -fn index_key_tenant_id() -> Result { - index_key(TENANT_ID_INDEX_KEY) -} - -fn index_key_user_id() -> Result { - index_key(USER_ID_INDEX_KEY) -} - -fn index_key_agent_id() -> Result { - index_key(AGENT_ID_INDEX_KEY) -} - -fn index_key_project_id() -> Result { - index_key(PROJECT_ID_INDEX_KEY) -} - -fn index_key_role() -> Result { - index_key(ROLE_INDEX_KEY) -} - -fn index_key_status() -> Result { - index_key(STATUS_INDEX_KEY) -} - -fn index_key_source() -> Result { - index_key(SOURCE_INDEX_KEY) -} - -fn is_not_found(error: &FilesystemError) -> bool { - matches!(error, FilesystemError::NotFound { .. }) -} - -#[async_trait::async_trait] -impl LocalTriggerAccessStore for RebornFilesystemLocalTriggerAccessStore -where - F: RootFilesystem + 'static, -{ - async fn seed_local_access( - &self, - seed: LocalTriggerAccessSeed<'_>, - ) -> Result<(), RebornLocalTriggerAccessStoreError> { - RebornFilesystemLocalTriggerAccessStore::seed_local_access(self, seed).await - } - - async fn reconcile_local_access( - &self, - reconciliation: LocalTriggerAccessReconciliation<'_>, - ) -> Result<(), RebornLocalTriggerAccessStoreError> { - RebornFilesystemLocalTriggerAccessStore::reconcile_local_access(self, reconciliation).await - } - - async fn has_active_local_access( - &self, - tenant_id: &TenantId, - user_id: &UserId, - agent_id: Option<&AgentId>, - project_id: Option<&ProjectId>, - ) -> Result { - RebornFilesystemLocalTriggerAccessStore::has_active_local_access( - self, tenant_id, user_id, agent_id, project_id, - ) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::local_trigger_access::{LocalTriggerAccessRole, LocalTriggerAccessSource}; - use ironclaw_filesystem::{InMemoryBackend, ScopedFilesystem}; - use ironclaw_host_api::{MountAlias, MountGrant, MountPermissions, MountView, VirtualPath}; - - fn store() -> RebornFilesystemLocalTriggerAccessStore { - let root = Arc::new(InMemoryBackend::default()); - let view = MountView::new(vec![MountGrant::new( - MountAlias::new("/tenant-shared").expect("mount alias"), - VirtualPath::new("/tenants/fs-trigger/shared").expect("virtual path"), - MountPermissions::read_write_list_delete(), - )]) - .expect("mount view"); - let filesystem = Arc::new(ScopedFilesystem::with_fixed_view(root, view)); - RebornFilesystemLocalTriggerAccessStore::new(filesystem) - } - - #[tokio::test] - async fn filesystem_store_reconciles_and_checks_exact_scope() { - let store = store(); - let tenant_id = TenantId::new("fs-trigger-tenant").expect("tenant id"); - let user_id = UserId::new("fs-trigger-user").expect("user id"); - let stale_user_id = UserId::new("fs-trigger-stale.json").expect("stale user id"); - let agent_id = AgentId::new("fs-trigger-agent").expect("agent id"); - let project_id = ProjectId::new("fs-trigger-project").expect("project id"); - let other_project_id = ProjectId::new("fs-trigger-other-project").expect("project id"); - - store - .seed_local_access(LocalTriggerAccessSeed { - tenant_id: &tenant_id, - user_id: &stale_user_id, - agent_id: Some(&agent_id), - project_id: Some(&project_id), - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevEnvBootstrap, - }) - .await - .expect("seed stale local access"); - - store - .reconcile_local_access(LocalTriggerAccessReconciliation { - tenant_id: &tenant_id, - user_ids: std::slice::from_ref(&user_id), - agent_id: Some(&agent_id), - project_id: Some(&project_id), - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevEnvBootstrap, - }) - .await - .expect("reconcile local access"); - - assert!( - store - .has_active_local_access(&tenant_id, &user_id, Some(&agent_id), Some(&project_id)) - .await - .expect("check active local access"), - "the reconciled filesystem record allows the exact scope" - ); - assert!( - !store - .has_active_local_access( - &tenant_id, - &user_id, - Some(&agent_id), - Some(&other_project_id), - ) - .await - .expect("check wrong project access"), - "filesystem trigger access is exact-project, not a wildcard" - ); - assert!( - !store - .has_active_local_access( - &tenant_id, - &stale_user_id, - Some(&agent_id), - Some(&project_id), - ) - .await - .expect("check stale local access"), - "reconciliation deactivates stale filesystem records for the same source" - ); - } - - #[tokio::test] - async fn filesystem_store_reconcile_skips_invalid_indexed_user_id() { - let store = store(); - let tenant_id = TenantId::new("fs-trigger-tenant").expect("tenant id"); - let valid_user_id = UserId::new("fs-trigger-user").expect("user id"); - let agent_id = AgentId::new("fs-trigger-agent").expect("agent id"); - let project_id = ProjectId::new("fs-trigger-project").expect("project id"); - let scope = tenant_shared_scope( - &tenant_id, - &valid_user_id, - Some(&agent_id), - Some(&project_id), - ); - let users_root = - access_scope_users_root(Some(&agent_id), Some(&project_id)).expect("access users root"); - let malformed_path = ScopedPath::new(format!("{}/malformed.json", users_root.as_str())) - .expect("malformed record path"); - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - let malformed_record = FilesystemLocalTriggerAccessRecord { - tenant_id: tenant_id.as_str().to_string(), - user_id: "bad/user".to_string(), - agent_id: Some(agent_id.as_str().to_string()), - project_id: Some(project_id.as_str().to_string()), - role: LocalTriggerAccessRole::Owner, - status: LocalTriggerAccessStatus::Active, - source: LocalTriggerAccessSource::LocalDevEnvBootstrap, - created_at: now.clone(), - updated_at: now, - }; - - store - .put_record( - &scope, - &malformed_path, - &malformed_record, - CasExpectation::Absent, - ) - .await - .expect("seed malformed indexed access record"); - - store - .reconcile_local_access(LocalTriggerAccessReconciliation { - tenant_id: &tenant_id, - user_ids: std::slice::from_ref(&valid_user_id), - agent_id: Some(&agent_id), - project_id: Some(&project_id), - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevEnvBootstrap, - }) - .await - .expect("invalid indexed user id should not abort reconciliation"); - - assert!( - store - .has_active_local_access( - &tenant_id, - &valid_user_id, - Some(&agent_id), - Some(&project_id), - ) - .await - .expect("check valid local access"), - "reconciliation still seeds valid users when one indexed record is malformed" - ); - } -} diff --git a/crates/ironclaw_runner/src/local_trigger_access/libsql.rs b/crates/ironclaw_runner/src/local_trigger_access/libsql.rs deleted file mode 100644 index e8caa2c4d76..00000000000 --- a/crates/ironclaw_runner/src/local_trigger_access/libsql.rs +++ /dev/null @@ -1,611 +0,0 @@ -use std::collections::BTreeSet; -use std::sync::Arc; - -use chrono::{SecondsFormat, Utc}; -use ironclaw_host_api::{AgentId, ProjectId, TenantId, UserId}; - -use super::types::{ - LocalTriggerAccessReconciliation, LocalTriggerAccessSeed, LocalTriggerAccessStatus, - LocalTriggerAccessStore, RebornLocalTriggerAccessStoreError, backend, optional_scope_key, -}; - -/// libSQL-backed local-dev trigger access repository. -pub struct RebornLibSqlLocalTriggerAccessStore { - db: Arc, -} - -impl RebornLibSqlLocalTriggerAccessStore { - /// Open the store on an existing libSQL substrate handle and run its - /// idempotent migrations. - pub async fn open( - db: Arc, - ) -> Result { - let store = Self { db }; - store.run_migrations().await?; - Ok(store) - } - - /// A connection with a busy timeout set. This store shares the reborn - /// substrate DB file with other local-dev stores, so contended writes must - /// wait for the lock rather than fail immediately with `SQLITE_BUSY`. - async fn conn(&self) -> Result { - let conn = self.db.connect().map_err(backend)?; - conn.query("PRAGMA busy_timeout = 5000", ()) - .await - .map_err(backend)?; - Ok(conn) - } - - async fn run_migrations(&self) -> Result<(), RebornLocalTriggerAccessStoreError> { - let conn = self.conn().await?; - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS local_reborn_access (\ - tenant_id TEXT NOT NULL, \ - user_id TEXT NOT NULL, \ - agent_id TEXT NOT NULL, \ - project_id TEXT NOT NULL, \ - role TEXT NOT NULL, \ - status TEXT NOT NULL, \ - source TEXT NOT NULL, \ - created_at TEXT NOT NULL, \ - updated_at TEXT NOT NULL, \ - PRIMARY KEY (tenant_id, user_id, agent_id, project_id));", - ) - .await - .map_err(backend)?; - Ok(()) - } - - /// Seed the local-dev trigger access row used by Reborn-owned fire-time - /// trigger authorization. Existing rows are left untouched so a local - /// operator can revoke or edit access without the next boot or login - /// silently re-granting it. - pub async fn seed_local_access( - &self, - seed: LocalTriggerAccessSeed<'_>, - ) -> Result<(), RebornLocalTriggerAccessStoreError> { - let conn = self.conn().await?; - let tx = conn - .transaction_with_behavior(libsql::TransactionBehavior::Immediate) - .await - .map_err(backend)?; - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - tx.execute( - "INSERT INTO local_reborn_access \ - (tenant_id, user_id, agent_id, project_id, role, status, source, created_at, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8) \ - ON CONFLICT(tenant_id, user_id, agent_id, project_id) DO NOTHING", - libsql::params![ - seed.tenant_id.as_str(), - seed.user_id.as_str(), - optional_scope_key(seed.agent_id.map(AgentId::as_str)), - optional_scope_key(seed.project_id.map(ProjectId::as_str)), - seed.role.as_str(), - LocalTriggerAccessStatus::Active.as_str(), - seed.source.as_str(), - now.as_str(), - ], - ) - .await - .map_err(backend)?; - tx.commit().await.map_err(backend)?; - Ok(()) - } - - /// Reconcile bootstrap-owned local-dev access rows for one exact scope. - /// - /// Active rows from the same `source` and scope that are not in - /// `user_ids` are marked inactive, so local-dev boot/login admission - /// changes stop authorizing stale creators. Existing inactive rows are - /// still left untouched; marking a row inactive remains the local operator - /// revocation mechanism and the next reconciliation will not silently - /// reactivate it. - pub async fn reconcile_local_access( - &self, - reconciliation: LocalTriggerAccessReconciliation<'_>, - ) -> Result<(), RebornLocalTriggerAccessStoreError> { - let conn = self.conn().await?; - let tx = conn - .transaction_with_behavior(libsql::TransactionBehavior::Immediate) - .await - .map_err(backend)?; - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - let agent_key = optional_scope_key(reconciliation.agent_id.map(AgentId::as_str)); - let project_key = optional_scope_key(reconciliation.project_id.map(ProjectId::as_str)); - let allowed: BTreeSet<&str> = reconciliation.user_ids.iter().map(UserId::as_str).collect(); - - let mut rows = tx - .query( - "SELECT user_id \ - FROM local_reborn_access \ - WHERE tenant_id = ?1 \ - AND agent_id = ?2 \ - AND project_id = ?3 \ - AND source = ?4 \ - AND status = ?5", - libsql::params![ - reconciliation.tenant_id.as_str(), - agent_key, - project_key, - reconciliation.source.as_str(), - LocalTriggerAccessStatus::Active.as_str(), - ], - ) - .await - .map_err(backend)?; - let mut stale_user_ids = Vec::new(); - while let Some(row) = rows.next().await.map_err(backend)? { - let user_id = row.get::(0).map_err(backend)?; - if !allowed.contains(user_id.as_str()) { - stale_user_ids.push(user_id); - } - } - drop(rows); - - for user_id in stale_user_ids { - tx.execute( - "UPDATE local_reborn_access \ - SET status = ?1, updated_at = ?2 \ - WHERE tenant_id = ?3 \ - AND user_id = ?4 \ - AND agent_id = ?5 \ - AND project_id = ?6 \ - AND source = ?7 \ - AND status = ?8", - libsql::params![ - LocalTriggerAccessStatus::Inactive.as_str(), - now.as_str(), - reconciliation.tenant_id.as_str(), - user_id.as_str(), - agent_key, - project_key, - reconciliation.source.as_str(), - LocalTriggerAccessStatus::Active.as_str(), - ], - ) - .await - .map_err(backend)?; - } - - for user_id in reconciliation.user_ids { - tx.execute( - "INSERT INTO local_reborn_access \ - (tenant_id, user_id, agent_id, project_id, role, status, source, created_at, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8) \ - ON CONFLICT(tenant_id, user_id, agent_id, project_id) DO NOTHING", - libsql::params![ - reconciliation.tenant_id.as_str(), - user_id.as_str(), - agent_key, - project_key, - reconciliation.role.as_str(), - LocalTriggerAccessStatus::Active.as_str(), - reconciliation.source.as_str(), - now.as_str(), - ], - ) - .await - .map_err(backend)?; - } - - tx.commit().await.map_err(backend)?; - Ok(()) - } - - /// Return whether a local-dev user has active access for the exact - /// tenant/agent/project tuple on a trigger fire request. - pub async fn has_active_local_access( - &self, - tenant_id: &TenantId, - user_id: &UserId, - agent_id: Option<&AgentId>, - project_id: Option<&ProjectId>, - ) -> Result { - let conn = self.conn().await?; - let mut rows = conn - .query( - "SELECT 1 \ - FROM local_reborn_access \ - WHERE tenant_id = ?1 \ - AND user_id = ?2 \ - AND agent_id = ?3 \ - AND project_id = ?4 \ - AND status = ?5 \ - LIMIT 1", - libsql::params![ - tenant_id.as_str(), - user_id.as_str(), - optional_scope_key(agent_id.map(AgentId::as_str)), - optional_scope_key(project_id.map(ProjectId::as_str)), - LocalTriggerAccessStatus::Active.as_str(), - ], - ) - .await - .map_err(backend)?; - Ok(rows.next().await.map_err(backend)?.is_some()) - } -} - -#[async_trait::async_trait] -impl LocalTriggerAccessStore for RebornLibSqlLocalTriggerAccessStore { - async fn seed_local_access( - &self, - seed: LocalTriggerAccessSeed<'_>, - ) -> Result<(), RebornLocalTriggerAccessStoreError> { - RebornLibSqlLocalTriggerAccessStore::seed_local_access(self, seed).await - } - - async fn reconcile_local_access( - &self, - reconciliation: LocalTriggerAccessReconciliation<'_>, - ) -> Result<(), RebornLocalTriggerAccessStoreError> { - RebornLibSqlLocalTriggerAccessStore::reconcile_local_access(self, reconciliation).await - } - - async fn has_active_local_access( - &self, - tenant_id: &TenantId, - user_id: &UserId, - agent_id: Option<&AgentId>, - project_id: Option<&ProjectId>, - ) -> Result { - RebornLibSqlLocalTriggerAccessStore::has_active_local_access( - self, tenant_id, user_id, agent_id, project_id, - ) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::local_trigger_access::{LocalTriggerAccessRole, LocalTriggerAccessSource}; - - async fn store() -> RebornLibSqlLocalTriggerAccessStore { - let tmp = tempfile::tempdir().expect("tempdir"); - let path = tmp.keep().join("reborn-local-dev.db"); - let db = Arc::new( - libsql::Builder::new_local(&path) - .build() - .await - .expect("open libsql"), - ); - RebornLibSqlLocalTriggerAccessStore::open(db) - .await - .expect("open store") - } - - #[tokio::test] - async fn seeded_local_access_allows_exact_scope_only() { - let store = store().await; - let tenant_id = TenantId::new("local-access-tenant").expect("tenant id"); - let user_id = UserId::new("local-access-user").expect("user id"); - let other_user_id = UserId::new("local-access-other-user").expect("user id"); - let agent_id = AgentId::new("local-access-agent").expect("agent id"); - let project_id = ProjectId::new("local-access-project").expect("project id"); - let other_project_id = ProjectId::new("local-access-other-project").expect("project id"); - - store - .seed_local_access(LocalTriggerAccessSeed { - tenant_id: &tenant_id, - user_id: &user_id, - agent_id: Some(&agent_id), - project_id: Some(&project_id), - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevRunBootstrap, - }) - .await - .expect("seed local access"); - - assert!( - store - .has_active_local_access(&tenant_id, &user_id, Some(&agent_id), Some(&project_id)) - .await - .expect("check local access"), - "the seeded exact tenant/user/agent/project scope is allowed" - ); - assert!( - !store - .has_active_local_access( - &tenant_id, - &user_id, - Some(&agent_id), - Some(&other_project_id) - ) - .await - .expect("check local access"), - "a different project is not covered by the seeded row" - ); - assert!( - !store - .has_active_local_access(&tenant_id, &user_id, Some(&agent_id), None) - .await - .expect("check local access"), - "a no-project request is not covered by a project-scoped row" - ); - assert!( - !store - .has_active_local_access( - &tenant_id, - &other_user_id, - Some(&agent_id), - Some(&project_id) - ) - .await - .expect("check local access"), - "a different user is not covered by the seeded row" - ); - } - - #[tokio::test] - async fn seeded_local_access_allows_exact_no_project_scope_only() { - let store = store().await; - let tenant_id = TenantId::new("local-no-project-tenant").expect("tenant id"); - let user_id = UserId::new("local-no-project-user").expect("user id"); - let agent_id = AgentId::new("local-no-project-agent").expect("agent id"); - let project_id = ProjectId::new("local-no-project-project").expect("project id"); - - store - .seed_local_access(LocalTriggerAccessSeed { - tenant_id: &tenant_id, - user_id: &user_id, - agent_id: Some(&agent_id), - project_id: None, - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevRunBootstrap, - }) - .await - .expect("seed local access"); - - assert!( - store - .has_active_local_access(&tenant_id, &user_id, Some(&agent_id), None) - .await - .expect("check local access"), - "the seeded no-project scope is allowed" - ); - assert!( - !store - .has_active_local_access(&tenant_id, &user_id, Some(&agent_id), Some(&project_id)) - .await - .expect("check local access"), - "a no-project row is not a wildcard for project-scoped fires" - ); - } - - #[tokio::test] - async fn seed_local_access_does_not_reactivate_existing_inactive_row() { - let store = store().await; - let tenant_id = TenantId::new("local-revoked-tenant").expect("tenant id"); - let user_id = UserId::new("local-revoked-user").expect("user id"); - let agent_id = AgentId::new("local-revoked-agent").expect("agent id"); - let project_id = ProjectId::new("local-revoked-project").expect("project id"); - - store - .seed_local_access(LocalTriggerAccessSeed { - tenant_id: &tenant_id, - user_id: &user_id, - agent_id: Some(&agent_id), - project_id: Some(&project_id), - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevRunBootstrap, - }) - .await - .expect("seed local access"); - - let conn = store.conn().await.expect("conn"); - conn.execute( - "UPDATE local_reborn_access SET status = ?1 \ - WHERE tenant_id = ?2 AND user_id = ?3 AND agent_id = ?4 AND project_id = ?5", - libsql::params![ - LocalTriggerAccessStatus::Inactive.as_str(), - tenant_id.as_str(), - user_id.as_str(), - agent_id.as_str(), - project_id.as_str(), - ], - ) - .await - .expect("mark access inactive"); - - store - .seed_local_access(LocalTriggerAccessSeed { - tenant_id: &tenant_id, - user_id: &user_id, - agent_id: Some(&agent_id), - project_id: Some(&project_id), - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevRunBootstrap, - }) - .await - .expect("reseed local access"); - - assert!( - !store - .has_active_local_access(&tenant_id, &user_id, Some(&agent_id), Some(&project_id)) - .await - .expect("check local access"), - "reseed must not silently reactivate an inactive existing row" - ); - } - - #[tokio::test] - async fn reconcile_local_access_deactivates_stale_source_rows_only() { - let store = store().await; - let tenant_id = TenantId::new("local-reconcile-tenant").expect("tenant id"); - let keep_user_id = UserId::new("local-reconcile-keep").expect("user id"); - let stale_user_id = UserId::new("local-reconcile-stale").expect("user id"); - let manual_user_id = UserId::new("local-reconcile-manual").expect("user id"); - let agent_id = AgentId::new("local-reconcile-agent").expect("agent id"); - let project_id = ProjectId::new("local-reconcile-project").expect("project id"); - - for (user_id, source) in [ - ( - &keep_user_id, - LocalTriggerAccessSource::LocalDevSsoBootstrap, - ), - ( - &stale_user_id, - LocalTriggerAccessSource::LocalDevSsoBootstrap, - ), - ( - &manual_user_id, - LocalTriggerAccessSource::LocalDevEnvBootstrap, - ), - ] { - store - .seed_local_access(LocalTriggerAccessSeed { - tenant_id: &tenant_id, - user_id, - agent_id: Some(&agent_id), - project_id: Some(&project_id), - role: LocalTriggerAccessRole::Owner, - source, - }) - .await - .expect("seed local access"); - } - - store - .reconcile_local_access(LocalTriggerAccessReconciliation { - tenant_id: &tenant_id, - user_ids: std::slice::from_ref(&keep_user_id), - agent_id: Some(&agent_id), - project_id: Some(&project_id), - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevSsoBootstrap, - }) - .await - .expect("reconcile local access"); - - assert!( - store - .has_active_local_access( - &tenant_id, - &keep_user_id, - Some(&agent_id), - Some(&project_id) - ) - .await - .expect("check local access"), - "current bootstrap user remains active" - ); - assert!( - !store - .has_active_local_access( - &tenant_id, - &stale_user_id, - Some(&agent_id), - Some(&project_id) - ) - .await - .expect("check local access"), - "stale bootstrap user is deactivated" - ); - assert!( - store - .has_active_local_access( - &tenant_id, - &manual_user_id, - Some(&agent_id), - Some(&project_id) - ) - .await - .expect("check local access"), - "rows from another source are untouched" - ); - } - - #[tokio::test] - async fn reconcile_local_access_inserts_all_allowed_users() { - let store = store().await; - let tenant_id = TenantId::new("local-reconcile-many-tenant").expect("tenant id"); - let first_user_id = UserId::new("local-reconcile-many-first").expect("user id"); - let second_user_id = UserId::new("local-reconcile-many-second").expect("user id"); - let agent_id = AgentId::new("local-reconcile-many-agent").expect("agent id"); - let project_id = ProjectId::new("local-reconcile-many-project").expect("project id"); - let allowed_user_ids = [first_user_id.clone(), second_user_id.clone()]; - - store - .reconcile_local_access(LocalTriggerAccessReconciliation { - tenant_id: &tenant_id, - user_ids: &allowed_user_ids, - agent_id: Some(&agent_id), - project_id: Some(&project_id), - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevSsoBootstrap, - }) - .await - .expect("reconcile local access"); - - for user_id in [&first_user_id, &second_user_id] { - assert!( - store - .has_active_local_access( - &tenant_id, - user_id, - Some(&agent_id), - Some(&project_id) - ) - .await - .expect("check local access"), - "every admitted user from one reconciliation should be inserted" - ); - } - } - - #[tokio::test] - async fn reconcile_local_access_does_not_reactivate_inactive_allowed_user() { - let store = store().await; - let tenant_id = TenantId::new("local-reconcile-revoked-tenant").expect("tenant id"); - let user_id = UserId::new("local-reconcile-revoked-user").expect("user id"); - let agent_id = AgentId::new("local-reconcile-revoked-agent").expect("agent id"); - - store - .seed_local_access(LocalTriggerAccessSeed { - tenant_id: &tenant_id, - user_id: &user_id, - agent_id: Some(&agent_id), - project_id: None, - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevSsoBootstrap, - }) - .await - .expect("seed local access"); - - let conn = store.conn().await.expect("conn"); - conn.execute( - "UPDATE local_reborn_access SET status = ?1 \ - WHERE tenant_id = ?2 AND user_id = ?3 AND agent_id = ?4 AND project_id = ?5", - libsql::params![ - LocalTriggerAccessStatus::Inactive.as_str(), - tenant_id.as_str(), - user_id.as_str(), - agent_id.as_str(), - optional_scope_key(None), - ], - ) - .await - .expect("mark access inactive"); - - store - .reconcile_local_access(LocalTriggerAccessReconciliation { - tenant_id: &tenant_id, - user_ids: std::slice::from_ref(&user_id), - agent_id: Some(&agent_id), - project_id: None, - role: LocalTriggerAccessRole::Owner, - source: LocalTriggerAccessSource::LocalDevSsoBootstrap, - }) - .await - .expect("reconcile local access"); - - assert!( - !store - .has_active_local_access(&tenant_id, &user_id, Some(&agent_id), None) - .await - .expect("check local access"), - "reconcile must not silently reactivate an inactive existing row" - ); - } -} diff --git a/crates/ironclaw_runner/src/local_trigger_access/mod.rs b/crates/ironclaw_runner/src/local_trigger_access/mod.rs deleted file mode 100644 index 729222a0711..00000000000 --- a/crates/ironclaw_runner/src/local_trigger_access/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Local trigger-fire access store. -//! -//! This is a Reborn-owned bootstrap access store, separate from the WebChat -//! identity store. It owns only the local access records used to satisfy the -//! fire-time trigger authorization contract for local/operator-managed -//! deployments. Backends may persist those records through the host filesystem -//! abstraction or through the legacy local-dev libSQL sidecar. -//! -//! These records are not the general agent/project membership source of truth. -//! Multi-tenant runtimes must wire a real membership-backed trigger access -//! checker instead of this bootstrap store. - -#[cfg(feature = "filesystem-local-trigger-access")] -mod filesystem; -#[cfg(feature = "webui-user-store")] -mod libsql; -mod types; - -#[cfg(feature = "filesystem-local-trigger-access")] -pub use filesystem::RebornFilesystemLocalTriggerAccessStore; -#[cfg(feature = "webui-user-store")] -pub use libsql::RebornLibSqlLocalTriggerAccessStore; -pub use types::{ - LocalTriggerAccessReconciliation, LocalTriggerAccessRole, LocalTriggerAccessSeed, - LocalTriggerAccessSource, LocalTriggerAccessStore, RebornLocalTriggerAccessStoreError, -}; diff --git a/crates/ironclaw_runner/src/local_trigger_access/types.rs b/crates/ironclaw_runner/src/local_trigger_access/types.rs deleted file mode 100644 index ab7afa33273..00000000000 --- a/crates/ironclaw_runner/src/local_trigger_access/types.rs +++ /dev/null @@ -1,132 +0,0 @@ -use ironclaw_host_api::{AgentId, ProjectId, TenantId, UserId}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -/// Fixed local-dev access role persisted on trigger-fire access rows. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum LocalTriggerAccessRole { - /// Owner-level local trigger-fire access. - Owner, -} - -impl LocalTriggerAccessRole { - pub(super) fn as_str(self) -> &'static str { - match self { - Self::Owner => "owner", - } - } -} - -/// Local-dev bootstrap path that owns a trigger-fire access row. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum LocalTriggerAccessSource { - /// Environment-token `serve` bootstrap path. - LocalDevEnvBootstrap, - /// SSO-admitted WebUI user bootstrap path. - LocalDevSsoBootstrap, - /// CLI `run` default-owner bootstrap path. - LocalDevRunBootstrap, -} - -impl LocalTriggerAccessSource { - pub(super) fn as_str(self) -> &'static str { - match self { - Self::LocalDevEnvBootstrap => "local_dev_env_bootstrap", - Self::LocalDevSsoBootstrap => "local_dev_sso_bootstrap", - Self::LocalDevRunBootstrap => "local_dev_run_bootstrap", - } - } -} - -/// Fixed lifecycle state persisted on local-dev access rows. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub(super) enum LocalTriggerAccessStatus { - Active, - Inactive, -} - -impl LocalTriggerAccessStatus { - pub(super) fn as_str(self) -> &'static str { - match self { - Self::Active => "active", - Self::Inactive => "inactive", - } - } -} - -/// Failure modes of a local trigger access store. -#[derive(Debug, Error)] -pub enum RebornLocalTriggerAccessStoreError { - /// The backend (connect / migrate / query / commit) failed. - #[error("reborn local trigger access store backend failure: {0}")] - Backend(String), -} - -/// Local-dev trigger access row to seed from trusted host/operator input. -pub struct LocalTriggerAccessSeed<'a> { - /// Tenant scope for the local access row. - pub tenant_id: &'a TenantId, - /// User that is allowed to fire triggers for the exact scope. - pub user_id: &'a UserId, - /// Optional agent scope. `None` is stored as an exact no-agent scope, not - /// a wildcard. - pub agent_id: Option<&'a AgentId>, - /// Optional project scope. `None` is stored as an exact no-project scope, - /// not a wildcard. - pub project_id: Option<&'a ProjectId>, - /// Local role to persist on the access row. - pub role: LocalTriggerAccessRole, - /// Source for the host/operator seed path. - pub source: LocalTriggerAccessSource, -} - -/// Current trusted local-dev access set for one bootstrap source and exact -/// tenant/agent/project scope. -pub struct LocalTriggerAccessReconciliation<'a> { - /// Tenant scope for the local access rows. - pub tenant_id: &'a TenantId, - /// Users that should keep active access for this bootstrap source/scope. - pub user_ids: &'a [UserId], - /// Optional agent scope. `None` is an exact no-agent scope, not a wildcard. - pub agent_id: Option<&'a AgentId>, - /// Optional project scope. `None` is an exact no-project scope, not a - /// wildcard. - pub project_id: Option<&'a ProjectId>, - /// Local role for newly inserted rows. - pub role: LocalTriggerAccessRole, - /// Source for this host/operator seed path. - pub source: LocalTriggerAccessSource, -} - -/// Backend-neutral local trigger access repository contract. -#[async_trait::async_trait] -pub trait LocalTriggerAccessStore: Send + Sync { - async fn seed_local_access( - &self, - seed: LocalTriggerAccessSeed<'_>, - ) -> Result<(), RebornLocalTriggerAccessStoreError>; - - async fn reconcile_local_access( - &self, - reconciliation: LocalTriggerAccessReconciliation<'_>, - ) -> Result<(), RebornLocalTriggerAccessStoreError>; - - async fn has_active_local_access( - &self, - tenant_id: &TenantId, - user_id: &UserId, - agent_id: Option<&AgentId>, - project_id: Option<&ProjectId>, - ) -> Result; -} - -pub(super) fn backend(err: impl std::fmt::Display) -> RebornLocalTriggerAccessStoreError { - RebornLocalTriggerAccessStoreError::Backend(err.to_string()) -} - -pub(super) fn optional_scope_key(value: Option<&str>) -> &str { - value.unwrap_or("") -} diff --git a/crates/ironclaw_runner/src/subagent/await_edge/mod.rs b/crates/ironclaw_runner/src/subagent/await_edge/mod.rs index 1fea52436b6..322cf766ef9 100644 --- a/crates/ironclaw_runner/src/subagent/await_edge/mod.rs +++ b/crates/ironclaw_runner/src/subagent/await_edge/mod.rs @@ -204,14 +204,9 @@ pub(crate) fn map_await_edge_error( } /// `{some/|none}` optional-axis path encoding (§4.2), matching the -/// existing precedent at -/// `local_trigger_access::filesystem::optional_axis_path`. Duplicated here -/// (4 lines) rather than reused — that helper lives behind -/// `local_trigger_access`'s own `mod filesystem;`, which is both private and -/// feature-gated behind `filesystem-local-trigger-access`, a feature -/// unrelated to await-edge's own filesystem gate (`filesystem-goal-store`); -/// threading a cross-feature dependency for a 4-line pure function is worse -/// than the duplication. +/// `agents//projects/` scope-path convention `goal_store.rs` uses. +/// A local 4-line pure helper rather than a shared dependency — the encoding +/// is trivial and each store owns its own path layout. fn optional_axis_path(value: Option<&str>) -> String { match value { Some(value) => format!("some/{value}"), diff --git a/crates/ironclaw_runner/src/subagent/await_edge/roster.rs b/crates/ironclaw_runner/src/subagent/await_edge/roster.rs index 7c78bb5056e..d9b512ea06b 100644 --- a/crates/ironclaw_runner/src/subagent/await_edge/roster.rs +++ b/crates/ironclaw_runner/src/subagent/await_edge/roster.rs @@ -2,15 +2,15 @@ //! unclosed await-edges, discoverable without a global walk over the edge //! tree itself (edges are scope-isolated, listed only per-scope, §4.5a). //! -//! Deliberately diverges from this crate's other two scope-listing -//! precedents on purpose, not by oversight: -//! - `local_trigger_access/filesystem.rs`'s `ensure_index`/`query()` idiom -//! assumes the caller already knows the scope to look up; the roster's -//! whole job is discovering scopes the caller does *not* yet know. -//! - `goal_store.rs`/`local_trigger_access`'s nested `agents//projects/` -//! path convention breaks scope-independent enumeration at any fixed -//! `list_dir` depth (round-3 fix, §4.5) — nested markers hide behind -//! however many directory levels each scope's optional axes happen to use. +//! Deliberately diverges from this crate's other scope-listing precedents on +//! purpose, not by oversight: +//! - the usual indexed-lookup idiom assumes the caller already knows the scope +//! to look up; the roster's whole job is discovering scopes the caller does +//! *not* yet know. +//! - `goal_store.rs`'s nested `agents//projects/` path convention +//! breaks scope-independent enumeration at any fixed `list_dir` depth +//! (round-3 fix, §4.5) — nested markers hide behind however many directory +//! levels each scope's optional axes happen to use. //! //! The roster therefore flattens every scope's key into one percent-encoded, //! `__`-joined filename (round-3/round-4) and shards those flat filenames diff --git a/crates/ironclaw_runner/src/subagent/await_edge/store.rs b/crates/ironclaw_runner/src/subagent/await_edge/store.rs index 773fde93c47..1ee20857e7d 100644 --- a/crates/ironclaw_runner/src/subagent/await_edge/store.rs +++ b/crates/ironclaw_runner/src/subagent/await_edge/store.rs @@ -35,9 +35,8 @@ pub struct CloseCrashHooks<'a> { /// Thin CAS wrapper around one shared `Arc>`. Generic /// over the backend, matching every other filesystem-backed reborn store -/// (`goal_store.rs`'s `FilesystemSubagentGoalStore`, -/// `local_trigger_access::filesystem::RebornFilesystemLocalTriggerAccessStore`) -/// — never `Arc`. +/// (`goal_store.rs`'s `FilesystemSubagentGoalStore`) — never +/// `Arc`. pub struct FilesystemAwaitEdgeStore { fs: Arc>, } diff --git a/docs/plans/composition-pubuse.snapshot b/docs/plans/composition-pubuse.snapshot index 2058db1b747..74a18351e6a 100644 --- a/docs/plans/composition-pubuse.snapshot +++ b/docs/plans/composition-pubuse.snapshot @@ -126,7 +126,8 @@ pub use runtime_input::{ CredentialRefreshSettings, DEFAULT_TURN_RUNNER_HEARTBEAT_INTERVAL, DEFAULT_TURN_RUNNER_POLL_INTERVAL, PollSettings, RebornRuntimeIdentity, RebornRuntimeInput, TriggerFireAccessCheck, TriggerFireAccessChecker, TriggerFireAccessDecision, - TriggerFireAccessError, TriggerPollerSettings, TurnRunnerSettings, + TriggerFireAccessError, TriggerFireAccessGrant, TriggerFireAccessPolicy, TriggerPollerSettings, + TurnRunnerSettings, }; pub use runtime_input::{RebornProviderFactory, ResolvedRebornLlm}; pub use slack::slack_actor_identity::{ @@ -178,13 +179,6 @@ pub use webui::facade::{RebornWebuiBundle, build_webui_services}; pub use webui::route_mounts::{ ProtectedRouteMount, PublicRouteDrain, PublicRouteDrains, PublicRouteMount, }; -#[cfg(feature = "postgres")] -pub use ironclaw_runner::local_trigger_access::RebornFilesystemLocalTriggerAccessStore; -pub use ironclaw_runner::local_trigger_access::{ - LocalTriggerAccessReconciliation, LocalTriggerAccessRole, LocalTriggerAccessSeed, - LocalTriggerAccessSource, LocalTriggerAccessStore, RebornLibSqlLocalTriggerAccessStore, - RebornLocalTriggerAccessStoreError, -}; pub use ironclaw_reborn_identity::{ ExternalSubjectId, IdentityKeyError, ProviderInstanceId, ProviderKind, RebornIdentityError, RebornIdentityResolver, ResolveExternalIdentity, SurfaceKind, diff --git a/scripts/ci/package-feature-flags.sh b/scripts/ci/package-feature-flags.sh index 7cd83ba376e..5f1dbff7708 100755 --- a/scripts/ci/package-feature-flags.sh +++ b/scripts/ci/package-feature-flags.sh @@ -50,7 +50,7 @@ case "${package}" in printf '%s\n' "--features test-support,libsql" ;; ironclaw_runner) - printf '%s\n' "--features libsql-secrets,libsql-restart-tests,webui-user-store" + printf '%s\n' "--features libsql-secrets,libsql-restart-tests" ;; ironclaw_reborn_event_store) ;;