From e82880272e5d37ee5d08b568fec9bc39a0b58f01 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Tue, 6 Oct 2026 12:59:39 -0600 Subject: [PATCH 1/4] feat(tenancy): complete tenant onboarding and invitation flows --- CHANGELOG.md | 32 ++ Cargo.lock | 12 +- crates/schema-forge-acton/Cargo.toml | 2 +- crates/schema-forge-acton/src/access.rs | 3 + crates/schema-forge-acton/src/actor.rs | 15 +- .../schema-forge-acton/src/authz/namespace.rs | 3 + .../src/cedar/policy_gen.rs | 1 + .../src/cedar/schema_gen.rs | 10 + crates/schema-forge-acton/src/config.rs | 6 + crates/schema-forge-acton/src/email.rs | 107 ++++++- crates/schema-forge-acton/src/error.rs | 23 +- crates/schema-forge-acton/src/events.rs | 13 +- crates/schema-forge-acton/src/lib.rs | 1 + crates/schema-forge-acton/src/messages.rs | 4 + .../src/middleware/tenant_scope.rs | 41 +++ crates/schema-forge-acton/src/routes/auth.rs | 31 +- .../src/routes/create_intents.rs | 6 +- .../src/routes/creator_membership.rs | 95 ++++++ .../schema-forge-acton/src/routes/entities.rs | 7 +- .../schema-forge-acton/src/routes/events.rs | 35 +- .../schema-forge-acton/src/routes/invites.rs | 107 ++++--- crates/schema-forge-acton/src/routes/mod.rs | 1 + crates/schema-forge-acton/src/routes/oauth.rs | 25 ++ crates/schema-forge-acton/src/routes/users.rs | 54 +++- crates/schema-forge-acton/src/state.rs | 99 ++++++ .../schema-forge-acton/src/tenancy_config.rs | 218 +++++++++++++ .../tests/creator_membership.rs | 292 +++++++++++++++++ .../schema-forge-acton/tests/entity_events.rs | 97 +++++- .../schema-forge-acton/tests/invitations.rs | 302 ++++++++++++++++++ .../schema-forge-acton/tests/oauth_login.rs | 82 ++++- crates/schema-forge-backend/Cargo.toml | 2 +- .../src/entity_auth_store.rs | 120 +++++-- crates/schema-forge-backend/src/lib.rs | 3 + crates/schema-forge-backend/src/onboarding.rs | 87 +++++ crates/schema-forge-backend/src/traits.rs | 105 ++++++ crates/schema-forge-backend/src/user_store.rs | 19 ++ .../tests/support/creator_membership.rs | 154 +++++++++ crates/schema-forge-cli/Cargo.toml | 4 +- crates/schema-forge-cli/src/commands/serve.rs | 52 ++- .../templates/site/src/App.tsx.jinja | 4 +- .../site/src/generated/invites.ts.jinja | 11 +- .../templates/site/src/pages/invite.tsx.jinja | 35 +- .../site_e2e/playwright/tests/invites.spec.ts | 33 +- crates/schema-forge-mssql/Cargo.toml | 4 +- crates/schema-forge-mssql/src/backend.rs | 32 ++ crates/schema-forge-mssql/tests/sql_server.rs | 4 + crates/schema-forge-postgres/Cargo.toml | 2 +- crates/schema-forge-postgres/src/backend.rs | 53 +++ .../src/create_intent.rs | 37 ++- .../tests/create_intent.rs | 103 ++++++ .../tests/data_correctness.rs | 10 + crates/schema-forge-surrealdb/Cargo.toml | 2 +- crates/schema-forge-surrealdb/src/backend.rs | 48 +++ .../tests/creator_membership.rs | 11 + docs/invitations-reference.md | 173 ++++------ docs/oauth-login.md | 11 +- docs/tenant-isolation.md | 36 +++ 57 files changed, 2654 insertions(+), 225 deletions(-) create mode 100644 crates/schema-forge-acton/src/routes/creator_membership.rs create mode 100644 crates/schema-forge-acton/src/tenancy_config.rs create mode 100644 crates/schema-forge-acton/tests/creator_membership.rs create mode 100644 crates/schema-forge-acton/tests/invitations.rs create mode 100644 crates/schema-forge-backend/src/onboarding.rs create mode 100644 crates/schema-forge-backend/tests/support/creator_membership.rs create mode 100644 crates/schema-forge-surrealdb/tests/creator_membership.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 68fced31..72563bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ is pre-1.0; breaking changes bump the **minor** version per ## [Unreleased] +## [0.49.0] - 2026-10-06 + +### Added + +- Optional `[schema_forge.tenancy] creator_role` commits a tenant root and its + creator's membership atomically on PostgreSQL, SurrealDB, and SQL Server. + PostgreSQL create receipts include the membership in the same commit and + preserve it when reconciling a request. +- Scoped membership roles enter signed login and refresh claims and apply only + within the selected tenant. Creator grants do not change global account roles. + Entity streams recheck scoped roles and close when access is revoked. +- Independent Cedar `InviteUser` permission lets tenant owners invite without + direct user creation or listing permission. The existing role-rank and tenant + delegation restrictions still apply. The generated invitation form supports + these owners and manual sharing of acceptance links. +- `[schema_forge.email] delivery = "link"` returns an acceptance URL without + SMTP. SMTP failures after persistence return 502 `invite_delivery_failed` + with `invite_id` and `accept_url`, so clients can recover the existing invite. +- Configured `schema_forge.tenancy.default_tenant` assigns new open OAuth + signups to an existing tenant root before login completes. Signed invitations + retain their configured tenant rather than using this default. + +### Changed + +- Invitations require an explicit `InviteUser` policy. Existing non-platform + inviters granted only `CreateUser` must add this permission; platform + administrators retain their existing access. +- Startup rejects enabled open OAuth signup with tenancy unless a valid, + existing default tenant is configured. `creator_role` alone cannot provide + the membership needed for a first login. See the tenancy and invitations + references for configuration and migration examples. + ## [0.48.0] - 2026-10-05 ### Added diff --git a/Cargo.lock b/Cargo.lock index 69857b28..549e6d5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7678,7 +7678,7 @@ dependencies = [ [[package]] name = "schema-forge-acton" -version = "0.46.0" +version = "0.47.0" dependencies = [ "acton-service", "arc-swap", @@ -7735,7 +7735,7 @@ dependencies = [ [[package]] name = "schema-forge-backend" -version = "0.20.0" +version = "0.21.0" dependencies = [ "acton-service", "argon2", @@ -7766,7 +7766,7 @@ dependencies = [ [[package]] name = "schema-forge-cli" -version = "0.48.0" +version = "0.49.0" dependencies = [ "acton-service", "assert_cmd", @@ -7835,7 +7835,7 @@ dependencies = [ [[package]] name = "schema-forge-mssql" -version = "0.7.0" +version = "0.8.0" dependencies = [ "acton-service", "bb8", @@ -7851,7 +7851,7 @@ dependencies = [ [[package]] name = "schema-forge-postgres" -version = "0.15.0" +version = "0.16.0" dependencies = [ "arc-swap", "argon2", @@ -7889,7 +7889,7 @@ dependencies = [ [[package]] name = "schema-forge-surrealdb" -version = "0.15.0" +version = "0.16.0" dependencies = [ "chrono", "schema-forge-backend", diff --git a/crates/schema-forge-acton/Cargo.toml b/crates/schema-forge-acton/Cargo.toml index 81e29213..20b2517f 100644 --- a/crates/schema-forge-acton/Cargo.toml +++ b/crates/schema-forge-acton/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-acton" -version = "0.46.0" +version = "0.47.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index 493c6f6a..3c9814b3 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -31,6 +31,8 @@ pub enum AccessAction { List, /// Creating new entities (POST). Create, + /// Inviting a user, independently of direct account creation. + Invite, /// Updating existing entities (PUT/PATCH). Update, /// Backwards-compatible alias accepted by the schema-level check; maps @@ -54,6 +56,7 @@ impl AccessAction { Self::Read => ActionVerb::Read, Self::List => ActionVerb::List, Self::Create | Self::Write => ActionVerb::Create, + Self::Invite => ActionVerb::Invite, Self::Update => ActionVerb::Update, Self::Delete => ActionVerb::Delete, Self::Export => ActionVerb::Export, diff --git a/crates/schema-forge-acton/src/actor.rs b/crates/schema-forge-acton/src/actor.rs index cb82e535..d6a02614 100644 --- a/crates/schema-forge-acton/src/actor.rs +++ b/crates/schema-forge-acton/src/actor.rs @@ -452,7 +452,14 @@ fn configure_backend_operations(actor: &mut ManagedActor) { let request = ctx.message().clone(); Reply::pending(async move { let result = match backend { - Some(backend) => backend.create_intent(&request.request).await, + Some(backend) => match &request.membership { + Some(membership) => { + backend + .create_intent_with_membership(&request.request, membership) + .await + } + None => backend.create_intent(&request.request).await, + }, None => Err(schema_forge_backend::create_intent::CreateIntentError::Unsupported), }; request.reply.send(result).await; @@ -462,10 +469,14 @@ fn configure_backend_operations(actor: &mut ManagedActor) { actor.act_on::(|actor, ctx| { let backend = actor.model.backend.clone(); let entity = ctx.message().entity.clone(); + let membership = ctx.message().membership.clone(); let reply = ctx.message().reply.clone(); Reply::pending(async move { let result = match backend { - Some(b) => b.create(&entity).await, + Some(b) => match &membership { + Some(membership) => b.create_with_membership(&entity, membership).await, + None => b.create(&entity).await, + }, None => { warn!("CreateEntity received but no backend is configured"); Err(no_backend_error()) diff --git a/crates/schema-forge-acton/src/authz/namespace.rs b/crates/schema-forge-acton/src/authz/namespace.rs index e4e2c6ca..8b3f06f8 100644 --- a/crates/schema-forge-acton/src/authz/namespace.rs +++ b/crates/schema-forge-acton/src/authz/namespace.rs @@ -45,6 +45,8 @@ pub enum ActionVerb { List, /// Create a new entity. Create, + /// Invite a user without granting direct account creation. + Invite, /// Update an existing entity. Update, /// Delete an existing entity. @@ -64,6 +66,7 @@ impl ActionVerb { Self::Read => "Read", Self::List => "List", Self::Create => "Create", + Self::Invite => "Invite", Self::Update => "Update", Self::Delete => "Delete", Self::Export => "Export", diff --git a/crates/schema-forge-acton/src/cedar/policy_gen.rs b/crates/schema-forge-acton/src/cedar/policy_gen.rs index 1ead299f..42c451c2 100644 --- a/crates/schema-forge-acton/src/cedar/policy_gen.rs +++ b/crates/schema-forge-acton/src/cedar/policy_gen.rs @@ -142,6 +142,7 @@ forbid ( Action::"ReadUser", Action::"ListUser", Action::"CreateUser", + Action::"InviteUser", Action::"UpdateUser", Action::"DeleteUser" ], diff --git a/crates/schema-forge-acton/src/cedar/schema_gen.rs b/crates/schema-forge-acton/src/cedar/schema_gen.rs index 3ba83426..77f2d0f5 100644 --- a/crates/schema-forge-acton/src/cedar/schema_gen.rs +++ b/crates/schema-forge-acton/src/cedar/schema_gen.rs @@ -185,6 +185,16 @@ fn write_schema_actions(out: &mut String, schema: &SchemaDefinition) -> Result<( context: {{ resource_is_placeholder: Bool }}, }};\n" )?; + if name == "User" { + writeln!( + out, + "action InviteUser appliesTo {{ + principal: [Forge::Principal], + resource: [User], + context: {{ resource_is_placeholder: Bool }}, +}};\n" + )?; + } Ok(()) } diff --git a/crates/schema-forge-acton/src/config.rs b/crates/schema-forge-acton/src/config.rs index e0982518..757118d0 100644 --- a/crates/schema-forge-acton/src/config.rs +++ b/crates/schema-forge-acton/src/config.rs @@ -77,6 +77,10 @@ pub struct SchemaForgeSettings { #[serde(default)] pub auth: crate::oauth_config::AuthSettings, + /// Tenant creator and open-signup membership grants. + #[serde(default)] + pub tenancy: crate::tenancy_config::TenancySettings, + /// Signed-schema enforcement. The CLI builds a /// [`schema_forge_signing::VerifyPolicy`] from this section before /// loading any `.schema` file, so on-disk tampering and @@ -188,6 +192,7 @@ impl Default for SchemaForgeSettings { email: crate::email::EmailConfig::default(), authz: AuthzConfig::default(), auth: crate::oauth_config::AuthSettings::default(), + tenancy: crate::tenancy_config::TenancySettings::default(), signing: SigningConfig::default(), client: ClientConfig::default(), site: SiteBrandingConfig::default(), @@ -221,6 +226,7 @@ mod tests { email: crate::email::EmailConfig::default(), authz: AuthzConfig::default(), auth: crate::oauth_config::AuthSettings::default(), + tenancy: crate::tenancy_config::TenancySettings::default(), signing: SigningConfig::default(), client: ClientConfig::default(), site: SiteBrandingConfig::default(), diff --git a/crates/schema-forge-acton/src/email.rs b/crates/schema-forge-acton/src/email.rs index 5bd06580..0df0c2af 100644 --- a/crates/schema-forge-acton/src/email.rs +++ b/crates/schema-forge-acton/src/email.rs @@ -26,13 +26,17 @@ use std::sync::Mutex; /// `[schema_forge.email]` section of `config.toml`. /// -/// Disabled by default: a deployment that never sends mail carries no SMTP -/// configuration and the invite endpoints that require delivery fail closed -/// with [`EmailError::NotConfigured`] rather than silently dropping messages. +/// SMTP is disabled by default. Link delivery needs only a public base URL; +/// SMTP mode returns [`EmailError::NotConfigured`] when the transport is +/// disabled, allowing the endpoint to return the stored invitation link. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EmailConfig { - /// Master switch. When `false`, no transport is constructed and any flow - /// that needs to send mail is refused with a clear error. + /// Invitation delivery mode. Link mode bypasses SMTP regardless of enabled. + #[serde(default)] + pub delivery: EmailDelivery, + + /// SMTP switch. Link delivery does not construct or use SMTP, regardless + /// of this value. Disabled SMTP reports a recoverable delivery failure. #[serde(default)] pub enabled: bool, @@ -74,6 +78,7 @@ pub struct EmailConfig { impl Default for EmailConfig { fn default() -> Self { Self { + delivery: EmailDelivery::default(), enabled: false, host: None, port: default_smtp_port(), @@ -86,6 +91,49 @@ impl Default for EmailConfig { } } +impl EmailConfig { + /// Validate link delivery's shareable public URL. SMTP retains its existing + /// relative-link fallback when no public URL is configured. + pub fn validate_link_delivery(&self) -> Result<(), EmailError> { + if self.delivery != EmailDelivery::Link { + return Ok(()); + } + let invalid = || { + EmailError::InvalidConfig( + "link delivery requires an absolute http(s) public_base_url without credentials, query, or fragment".into(), + ) + }; + let raw = self.public_base_url.as_deref().ok_or_else(invalid)?; + let url = reqwest::Url::parse(raw).map_err(|_| invalid())?; + let explicit_origin = raw.split_once("://").is_some_and(|(scheme, rest)| { + scheme.eq_ignore_ascii_case(url.scheme()) && !rest.starts_with('/') + }); + if !explicit_origin + || raw.trim() != raw + || !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(invalid()); + } + Ok(()) + } +} + +/// How an invitation reaches its recipient. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum EmailDelivery { + /// Send via the configured SMTP transport. + #[default] + Smtp, + /// Return a shareable accept link without sending email. + Link, +} + fn default_smtp_port() -> u16 { 465 } @@ -257,7 +305,10 @@ impl InMemoryEmailSender { /// Snapshot of every message handed to [`EmailSender::send`] so far. pub fn sent(&self) -> Vec { - self.sent.lock().expect("email recorder mutex poisoned").clone() + self.sent + .lock() + .expect("email recorder mutex poisoned") + .clone() } } @@ -310,6 +361,45 @@ impl EmailSender for DisabledEmailSender { mod tests { use super::*; + #[test] + fn link_delivery_requires_shareable_absolute_url() { + for base in [ + None, + Some("/local"), + Some("https:example.gov"), + Some("https:///example.gov"), + Some(" https://example.gov"), + Some("ftp://example.gov"), + Some("https://u:p@example.gov"), + Some("https://example.gov?query=1"), + Some("https://example.gov#fragment"), + ] { + let config = EmailConfig { + delivery: EmailDelivery::Link, + public_base_url: base.map(str::to_string), + ..Default::default() + }; + assert!(config.validate_link_delivery().is_err(), "{base:?}"); + } + let config = EmailConfig { + delivery: EmailDelivery::Link, + public_base_url: Some("https://example.gov/app/".into()), + ..Default::default() + }; + assert!(!config.enabled); + assert!(config.validate_link_delivery().is_ok()); + assert!(EmailConfig::default().validate_link_delivery().is_ok()); + } + + #[test] + fn delivery_deserialization_is_explicit_and_defaults_to_smtp() { + let default: EmailConfig = toml::from_str("").unwrap(); + assert_eq!(default.delivery, EmailDelivery::Smtp); + let link: EmailConfig = toml::from_str("delivery = 'link'").unwrap(); + assert_eq!(link.delivery, EmailDelivery::Link); + assert!(toml::from_str::("delivery = 'unknown'").is_err()); + } + #[test] fn config_defaults_to_disabled_smtps() { let cfg = EmailConfig::default(); @@ -346,7 +436,10 @@ mod tests { assert_eq!(email.host.as_deref(), Some("mail.govcraft.ai")); assert_eq!(email.tls, EmailTls::Implicit); assert!(email.password.is_none()); - assert_eq!(email.public_base_url.as_deref(), Some("https://app.agency.gov")); + assert_eq!( + email.public_base_url.as_deref(), + Some("https://app.agency.gov") + ); } #[test] diff --git a/crates/schema-forge-acton/src/error.rs b/crates/schema-forge-acton/src/error.rs index 89e0b1c1..2af54e89 100644 --- a/crates/schema-forge-acton/src/error.rs +++ b/crates/schema-forge-acton/src/error.rs @@ -62,6 +62,11 @@ pub enum ForgeError { HookAborted { reason: String }, /// A required lifecycle hook timed out or was unreachable. Maps to 503. HookUnavailable { message: String }, + /// Invitation persisted, but SMTP delivery failed. Maps to 502. + InviteDeliveryFailed { + invite_id: String, + accept_url: String, + }, /// Internal error. Maps to 500. Internal { message: String }, } @@ -133,6 +138,9 @@ impl fmt::Display for ForgeError { Self::HookUnavailable { message } => { write!(f, "required hook unavailable: {message}") } + Self::InviteDeliveryFailed { .. } => { + write!(f, "Invitation created, but email delivery failed. Share the accept link to complete onboarding.") + } Self::Internal { message } => { write!(f, "internal error: {message}") } @@ -157,7 +165,9 @@ impl ForgeError { | Self::InvalidQuery { .. } => StatusCode::BAD_REQUEST, Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED, Self::Forbidden { .. } => StatusCode::FORBIDDEN, - Self::BackendUnavailable { .. } => StatusCode::BAD_GATEWAY, + Self::BackendUnavailable { .. } | Self::InviteDeliveryFailed { .. } => { + StatusCode::BAD_GATEWAY + } Self::ExportDeferred { .. } => StatusCode::UNPROCESSABLE_ENTITY, Self::ExportTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS, @@ -188,6 +198,7 @@ impl ForgeError { Self::RateLimited { .. } => "rate_limited", Self::HookAborted { .. } => "hook_aborted", Self::HookUnavailable { .. } => "hook_unavailable", + Self::InviteDeliveryFailed { .. } => "invite_delivery_failed", Self::Internal { .. } => "internal_error", } } @@ -210,6 +221,16 @@ impl IntoResponse for ForgeError { fn into_response(self) -> Response { let status = self.status_code(); let body = match &self { + Self::InviteDeliveryFailed { + invite_id, + accept_url, + } => serde_json::json!({ + "error": self.error_kind(), + "message": self.client_message(), + "invite_id": invite_id, + "accept_url": accept_url, + "delivery": "failed", + }), Self::Conflict { reason, message } => serde_json::json!({ "error": "conflict", "reason": reason, diff --git a/crates/schema-forge-acton/src/events.rs b/crates/schema-forge-acton/src/events.rs index 6a9017c1..3f1c553b 100644 --- a/crates/schema-forge-acton/src/events.rs +++ b/crates/schema-forge-acton/src/events.rs @@ -296,7 +296,10 @@ pub(crate) fn configure_actor(actor: &mut ManagedActor) { write_handler!( CreateEntity, |b: Arc, m: CreateEntity| async move { - b.create(&m.entity).await + match &m.membership { + Some(membership) => b.create_with_membership(&m.entity, membership).await, + None => b.create(&m.entity).await, + } }, |v: &Entity, _: &CreateEntity, _: &Notification| Some(v.clone()), "created" @@ -338,7 +341,13 @@ pub(crate) fn configure_actor(actor: &mut ManagedActor) { write_handler!( ProcessCreateIntent, |b: Arc, m: ProcessCreateIntent| async move { - b.create_intent(&m.request).await + match &m.membership { + Some(membership) => { + b.create_intent_with_membership(&m.request, membership) + .await + } + None => b.create_intent(&m.request).await, + } }, |v: &schema_forge_backend::create_intent::CreateIntentReceipt, m: &ProcessCreateIntent, diff --git a/crates/schema-forge-acton/src/lib.rs b/crates/schema-forge-acton/src/lib.rs index cf77eaac..47f1a426 100644 --- a/crates/schema-forge-acton/src/lib.rs +++ b/crates/schema-forge-acton/src/lib.rs @@ -27,6 +27,7 @@ pub mod shared_auth; pub mod state; pub mod storage; pub mod system; +pub mod tenancy_config; pub mod webhook; pub use access::{PLATFORM_ADMIN_ROLE, PUBLIC_ROLE}; diff --git a/crates/schema-forge-acton/src/messages.rs b/crates/schema-forge-acton/src/messages.rs index cef2b3e2..c65b1870 100644 --- a/crates/schema-forge-acton/src/messages.rs +++ b/crates/schema-forge-acton/src/messages.rs @@ -205,6 +205,8 @@ pub struct UpdateTenantConfig { #[derive(Clone, Debug)] pub struct CreateEntity { pub entity: Entity, + /// Optional membership committed in the same transaction as the root. + pub membership: Option, pub reply: ReplyChannel>, } @@ -387,6 +389,8 @@ pub struct DeleteEntityIf { #[derive(Clone, Debug)] pub struct ProcessCreateIntent { pub request: schema_forge_backend::create_intent::CreateIntentRequest, + /// Optional membership committed with the entity and receipt. + pub membership: Option, pub reply: ReplyChannel< Result< schema_forge_backend::create_intent::CreateIntentReceipt, diff --git a/crates/schema-forge-acton/src/middleware/tenant_scope.rs b/crates/schema-forge-acton/src/middleware/tenant_scope.rs index 133b1cbe..aa04b13c 100644 --- a/crates/schema-forge-acton/src/middleware/tenant_scope.rs +++ b/crates/schema-forge-acton/src/middleware/tenant_scope.rs @@ -146,6 +146,7 @@ where }; let mut new_claims = claims; + add_active_membership_role(&mut new_claims, active); match serde_json::to_value(&effective) { Ok(v) => { new_claims.custom.insert("tenant_chain".to_string(), v); @@ -247,6 +248,25 @@ fn select_active_tenant<'a, B>( .ok_or(TenantScopeRefusal::NotInMemberships) } +/// Add only the selected membership's role, without changing global account roles. +/// The reserved platform administrator role cannot originate in a membership. +pub(crate) fn add_active_membership_role(claims: &mut Claims, active: &TenantRef) { + let roles: Vec = + claims.custom_claim_as("tenant_roles").unwrap_or_default(); + for membership in roles + .iter() + .filter(|membership| membership.tenant == *active) + { + let role = &membership.role; + if !role.is_empty() + && role != PLATFORM_ADMIN_ROLE + && !claims.roles.iter().any(|existing| existing == role) + { + claims.roles.push(role.clone()); + } + } +} + /// Parse a `:` header value. /// /// Returns `None` for any malformed input. The entity_id half may itself @@ -404,6 +424,27 @@ mod tests { use axum::body::Body; use axum::http::Request; + #[test] + fn membership_roles_only_apply_to_the_selected_tenant_and_never_platform_admin() { + let mut claims: Claims = serde_json::from_value(serde_json::json!({ + "sub":"alice", "roles":["member"], "perms":[], "exp":9999999999_u64, + "tenant_roles":[ + {"tenant":{"schema":"Organization","entity_id":"a"},"role":"owner"}, + {"tenant":{"schema":"Organization","entity_id":"b"},"role":"admin"}, + {"tenant":{"schema":"Organization","entity_id":"a"},"role":"platform_admin"} + ] + })) + .unwrap(); + add_active_membership_role(&mut claims, &membership("Organization", "a")); + assert_eq!(claims.roles, vec!["member", "owner"]); + add_active_membership_role(&mut claims, &membership("Organization", "a")); + assert_eq!(claims.roles, vec!["member", "owner"]); + let mut other = claims.clone(); + other.roles = vec!["member".into()]; + add_active_membership_role(&mut other, &membership("Organization", "b")); + assert_eq!(other.roles, vec!["member", "admin"]); + } + fn membership(schema: &str, id: &str) -> TenantRef { TenantRef { schema: schema.to_string(), diff --git a/crates/schema-forge-acton/src/routes/auth.rs b/crates/schema-forge-acton/src/routes/auth.rs index 579edd17..58264a99 100644 --- a/crates/schema-forge-acton/src/routes/auth.rs +++ b/crates/schema-forge-acton/src/routes/auth.rs @@ -281,7 +281,7 @@ impl LoginContext<'_> { { return Err(refusal.into_response()); } - let claims = match build_login_claims( + let mut claims = match build_login_claims( &user.username, &user.roles, user_entity.as_ref(), @@ -298,6 +298,14 @@ impl LoginContext<'_> { ))) } }; + let tenant_roles = self + .auth_store + .list_tenant_roles(&user.username) + .await + .map_err(|error| internal_error_response(format!("auth store error: {error}")))?; + project_tenant_roles(&mut claims, &tenant_roles).map_err(|error| { + internal_error_response(format!("failed to build tenant role claims: {error}")) + })?; let token = self .generator .generate_token_with_expiry(&claims, LOGIN_TOKEN_LIFETIME) @@ -422,7 +430,7 @@ pub async fn refresh( return refusal.into_response(); } - let next_claims = match build_login_claims( + let mut next_claims = match build_login_claims( &user.username, &user.roles, user_entity.as_ref(), @@ -434,6 +442,14 @@ pub async fn refresh( Err(e) => return internal_error_response(format!("failed to build claims: {e}")), }; + let tenant_roles = match auth_store.list_tenant_roles(&user.username).await { + Ok(roles) => roles, + Err(error) => return internal_error_response(format!("auth store error: {error}")), + }; + if let Err(error) = project_tenant_roles(&mut next_claims, &tenant_roles) { + return internal_error_response(format!("failed to build tenant role claims: {error}")); + } + let token = match generator.generate_token_with_expiry(&next_claims, LOGIN_TOKEN_LIFETIME) { Ok(t) => t, Err(e) => return internal_error_response(format!("failed to generate token: {e}")), @@ -598,6 +614,17 @@ async fn emit_token_refresh( // Pure helpers (unit-testable) // --------------------------------------------------------------------------- +fn project_tenant_roles( + claims: &mut Claims, + roles: &[schema_forge_backend::user_store::TenantRole], +) -> Result<(), serde_json::Error> { + // Reserve this signed claim for durable membership roles, even when empty. + claims + .custom + .insert("tenant_roles".into(), serde_json::to_value(roles)?); + Ok(()) +} + /// Build the PASETO claims for a successful login. /// /// Pure function: no I/O, no state. Sets the standard subject/username/role diff --git a/crates/schema-forge-acton/src/routes/create_intents.rs b/crates/schema-forge-acton/src/routes/create_intents.rs index ff66b8a8..52daa9f7 100644 --- a/crates/schema-forge-acton/src/routes/create_intents.rs +++ b/crates/schema-forge-acton/src/routes/create_intents.rs @@ -89,12 +89,13 @@ pub(super) async fn process( state: &AppState, request: CreateIntentRequest, ) -> Result { - process_with_claims(state, request, None).await + process_with_claims(state, request, None, None).await } async fn process_with_claims( state: &AppState, request: CreateIntentRequest, claims: Option<&Claims>, + membership: Option, ) -> Result { let forge = state .actor::() @@ -105,6 +106,7 @@ async fn process_with_claims( &forge, ProcessCreateIntent { request, + membership, reply: ReplyChannel::new(tx), }, ¬ification, @@ -341,6 +343,7 @@ pub(super) async fn commit( intent: IntentCommit, entity: Entity, claims: Option<&Claims>, + membership: Option, ) -> Result { process_with_claims( state, @@ -351,6 +354,7 @@ pub(super) async fn commit( entity, }, claims, + membership, ) .await } diff --git a/crates/schema-forge-acton/src/routes/creator_membership.rs b/crates/schema-forge-acton/src/routes/creator_membership.rs new file mode 100644 index 00000000..099eb9a4 --- /dev/null +++ b/crates/schema-forge-acton/src/routes/creator_membership.rs @@ -0,0 +1,95 @@ +//! Prepare the server-owned membership before the supervised atomic create. +use std::collections::BTreeMap; + +use acton_service::{middleware::Claims, prelude::ActorHandleInterface, state::AppState}; +use schema_forge_backend::Entity; +use schema_forge_core::{ + query::{FieldPath, Filter, Query}, + types::{DynamicValue, SchemaDefinition, SchemaName}, +}; +use tokio::sync::oneshot; + +use crate::{ + actor::ForgeActor, + config::SchemaForgeConfig, + error::ForgeError, + messages::{GetSchema, QueryEntities, ReplyChannel}, +}; + +pub(super) async fn prepare( + state: &AppState, + schema: &SchemaDefinition, + root: &Entity, + claims: Option<&Claims>, +) -> Result, ForgeError> { + let Some(role) = state + .config() + .custom + .schema_forge + .tenancy + .creator_role + .as_deref() + .filter(|_| crate::access::is_tenant_root(schema)) + else { + return Ok(None); + }; + let claims = claims.ok_or_else(|| ForgeError::Unauthorized { + message: "tenant root creator membership requires an authenticated User".into(), + })?; + let forge = state + .actor::() + .ok_or_else(|| ForgeError::Internal { + message: "ForgeActor not registered".into(), + })?; + let (tx, rx) = oneshot::channel(); + forge + .send(GetSchema { + name: "User".into(), + reply: ReplyChannel::new(tx), + }) + .await; + let user_schema = + super::entities::ask_forge(rx) + .await? + .ok_or_else(|| ForgeError::Internal { + message: "User schema is required for creator membership".into(), + })?; + let username = crate::authz::adapters::user_id_from_sub(&claims.sub); + let query = Query::new(user_schema.id) + .with_filter(Filter::eq( + FieldPath::single("email"), + DynamicValue::Text(username.into()), + )) + .with_limit(2); + let (tx, rx) = oneshot::channel(); + forge + .send(QueryEntities { + query, + reply: ReplyChannel::new(tx), + }) + .await; + let users = super::entities::ask_forge(rx) + .await? + .map_err(ForgeError::from)?; + let [user] = users.entities.as_slice() else { + return Err(ForgeError::Unauthorized { + message: "tenant root creator must resolve to exactly one durable User".into(), + }); + }; + let membership_schema = + SchemaName::new("TenantMembership").map_err(|e| ForgeError::Internal { + message: e.to_string(), + })?; + Ok(Some(Entity::new( + membership_schema, + BTreeMap::from([ + ("user".into(), DynamicValue::Ref(user.id.clone())), + ( + "tenant_type".into(), + DynamicValue::Text(root.schema.to_string()), + ), + ("tenant_id".into(), DynamicValue::Text(root.id.to_string())), + ("role".into(), DynamicValue::Text(role.into())), + ]), + ))) +} diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index c7914be3..27e0fbd9 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -131,7 +131,7 @@ const ACTOR_TIMEOUT: Duration = Duration::from_secs(5); /// /// Wraps the common pattern of awaiting a `oneshot::Receiver` with a deadline /// and mapping both timeout and channel-dropped errors to `ForgeError::Internal`. -async fn ask_forge(rx: oneshot::Receiver) -> Result { +pub(super) async fn ask_forge(rx: oneshot::Receiver) -> Result { tokio::time::timeout(ACTOR_TIMEOUT, rx) .await .map_err(|_| ForgeError::Internal { @@ -2995,10 +2995,12 @@ pub async fn create_entity( ActionVerb::Create, )?; + let membership = super::creator_membership::prepare(&state, &schema_def, &entity, claims.as_ref()).await?; + if let Some(intent) = intent { let changed_fields: Vec<_> = entity.fields.keys().cloned().collect(); let receipt = - super::create_intents::commit(&state, intent, entity, claims.as_ref()).await?; + super::create_intents::commit(&state, intent, entity, claims.as_ref(), membership).await?; if receipt.created { super::audit::log_forge_event(&state, claims.as_ref(), &headers, "forge.entity.created", acton_service::audit::AuditSeverity::Informational, serde_json::json!({"schema":schema,"intent_id":receipt.id.as_str(),"changed_fields":changed_fields,"entity_id":receipt.entity_id.as_ref().map(|id|id.as_str())})).await; } @@ -3012,6 +3014,7 @@ pub async fn create_entity( &forge, CreateEntity { entity, + membership, reply: ReplyChannel::new(tx), }, ¬ification, diff --git a/crates/schema-forge-acton/src/routes/events.rs b/crates/schema-forge-acton/src/routes/events.rs index 1a0c14e0..cd8e2aa7 100644 --- a/crates/schema-forge-acton/src/routes/events.rs +++ b/crates/schema-forge-acton/src/routes/events.rs @@ -114,7 +114,40 @@ impl StreamState { return false; } match self.runtime.auth_store.get_user(&self.claims.sub).await { - Ok(Some(user)) if user.active && user.roles == self.claims.roles => {} + Ok(Some(user)) if user.active => { + let mut expected = self.claims.clone(); + expected.roles = user.roles; + if let Some(active) = self.effective_chain.last() { + let Ok(roles) = self + .runtime + .auth_store + .list_tenant_roles(&self.claims.sub) + .await + else { + return false; + }; + let Ok(value) = serde_json::to_value(roles) else { + return false; + }; + expected.custom.insert("tenant_roles".into(), value); + crate::middleware::tenant_scope::add_active_membership_role( + &mut expected, + active, + ); + } + if expected + .roles + .iter() + .collect::>() + != self + .claims + .roles + .iter() + .collect::>() + { + return false; + } + } Ok(None) if !self.local_account && self.effective_chain.is_empty() => return true, _ => return false, } diff --git a/crates/schema-forge-acton/src/routes/invites.rs b/crates/schema-forge-acton/src/routes/invites.rs index d880d075..6815db80 100644 --- a/crates/schema-forge-acton/src/routes/invites.rs +++ b/crates/schema-forge-acton/src/routes/invites.rs @@ -4,10 +4,9 @@ //! //! - `POST /auth/invites` (authenticated) — an operator invites an address //! into the deployment, optionally scoping the invitee to a tenant and a -//! role. The same privilege guards that gate `POST /users` run here, *at -//! invite time*, so an invite can never grant access the inviter could not -//! grant directly. The minted invitation is persisted and an accept link is -//! emailed to the invitee. +//! role. Independent `InviteUser` permission and role-grant guards run at +//! invite time. The minted invitation is persisted and an accept link is +//! emailed or returned for manual sharing, depending on delivery mode. //! //! - `POST /auth/invites/accept` (public) — the invitee presents the opaque //! `invite_id` from their email plus a chosen password. The server @@ -53,11 +52,11 @@ use serde::{Deserialize, Serialize}; use tokio::sync::oneshot; use tracing::instrument; -use crate::access::{check_schema_access, AccessAction, OptionalClaims}; +use crate::access::OptionalClaims; use crate::authz::engine::authorize; use crate::authz::namespace::ActionVerb; use crate::config::SchemaForgeConfig; -use crate::email::{EmailMessage, EmailSender}; +use crate::email::{EmailDelivery, EmailMessage, EmailSender}; use crate::error::ForgeError; use crate::invite::{mint_invite_token, verify_invite_token, InviteTokenParams}; use crate::routes::users::{ @@ -108,6 +107,10 @@ pub struct CreateInviteResponse { pub email: String, /// ISO-8601 UTC expiry. pub expires_at: String, + /// Transport used, explicitly distinguishing a link from an emailed invite. + pub delivery: EmailDelivery, + /// Shareable link to the persisted invitation. + pub accept_url: String, } /// Request body for `POST /auth/invites/accept`. @@ -247,12 +250,11 @@ fn validate_invite_tenant( /// `POST /auth/invites` — issue an invitation. /// -/// Authorization mirrors `POST /users` exactly: schema-level `CreateUser` -/// access via [`check_schema_access`], the `platform_admin`-grant guard via -/// [`caller_can_grant_roles`], and the Cedar `role_rank` guard against a -/// synthetic `User` carrying the proposed role. Running these here means an -/// invitation can never confer access the inviter lacks the authority to -/// grant directly. +/// Authorization evaluates `InviteUser` against a proposed `User` carrying +/// the granted role rank and target `_tenant`. The concrete check supports +/// target-specific policies without a placeholder preflight denying them. +/// `CreateUser` never grants invitation permission. The explicit platform +/// administrator grant restriction and Cedar role-rank forbid still apply. #[instrument(skip_all)] pub async fn create_invite( State(state): State>, @@ -267,13 +269,6 @@ pub async fn create_invite( let user_schema = fetch_user_schema(&state).await?; let policy_store = fetch_policy_store(&state).await?; - check_schema_access( - &policy_store, - &user_schema, - Some(claims), - AccessAction::Create, - )?; - validate_email(&body.email)?; let forge = state .actor::() @@ -313,11 +308,17 @@ pub async fn create_invite( active: true, role_rank: 0, }; - let proposed_entity = forge_user_to_user_entity(&proposed, policy_store.as_ref()); + let mut proposed_entity = forge_user_to_user_entity(&proposed, policy_store.as_ref()); + if let Some(tenant_id) = &body.tenant_id { + proposed_entity.fields.insert( + "_tenant".into(), + schema_forge_core::types::DynamicValue::Text(tenant_id.clone()), + ); + } let decision = authorize( &policy_store, Some(claims), - ActionVerb::Create, + ActionVerb::Invite, &user_schema, Some(&proposed_entity), ) @@ -334,14 +335,12 @@ pub async fn create_invite( Some(serde_json::json!({ "action": "create_invite", "proposed_roles": proposed_roles, - "reason": "role_rank_guard", + "reason": "invite_policy_denied", })), ) .await; return Err(ForgeError::Forbidden { - message: format!( - "inviting a user with roles {proposed_roles:?} would exceed caller's role_rank" - ), + message: format!("access denied for InviteUser with roles {proposed_roles:?}"), }); } @@ -352,6 +351,12 @@ pub async fn create_invite( }); } + let email_config = &state.config().custom.schema_forge.email; + email_config + .validate_link_delivery() + .map_err(|error| ForgeError::Internal { + message: error.to_string(), + })?; let minted = mint_invite_token( &generator, &InviteTokenParams { @@ -381,32 +386,41 @@ pub async fn create_invite( }) .await?; - // Deliver the accept link. Fail closed: a delivery failure surfaces as a - // 5xx so the operator knows the invite did not reach the invitee. The row - // stays `Pending`, so the invite can be re-sent once SMTP is healthy. - let accept_url = build_accept_url(email_sender.public_base_url(), &invitation.jti); + // Persisted links can always be shared. Link mode skips the transport; + // an SMTP failure returns the same reference with an explicit failed + // delivery outcome, leaving the invitation Pending. + let accept_url = build_accept_url( + email_config + .public_base_url + .as_deref() + .or_else(|| email_sender.public_base_url()), + &invitation.jti, + ); let project_name = &state.config().custom.schema_forge.project_name; let message = EmailMessage { to: body.email.clone(), subject: invite_email_subject(project_name), body_text: invite_email_body(project_name, &accept_url), }; - if let Err(e) = email_sender.send(message).await { - audit_user( - &state, - "forge.invite.send_failed", - AuditSeverity::Error, - &claims.sub, - &body.email, - Some(serde_json::json!({ - "invite_id": invitation.jti, - "error": e.to_string(), - })), - ) - .await; - return Err(ForgeError::Internal { - message: format!("invitation stored but email delivery failed: {e}"), - }); + if email_config.delivery == EmailDelivery::Smtp { + if let Err(e) = email_sender.send(message).await { + audit_user( + &state, + "forge.invite.send_failed", + AuditSeverity::Error, + &claims.sub, + &body.email, + Some(serde_json::json!({ + "invite_id": invitation.jti, + "error": e.to_string(), + })), + ) + .await; + return Err(ForgeError::InviteDeliveryFailed { + invite_id: invitation.jti, + accept_url, + }); + } } audit_user( @@ -420,6 +434,7 @@ pub async fn create_invite( "tenant_type": body.tenant_type, "tenant_id": body.tenant_id, "role": body.role, + "delivery": email_config.delivery, })), ) .await; @@ -430,6 +445,8 @@ pub async fn create_invite( invite_id: invitation.jti, email: body.email, expires_at: minted.expires_at.to_rfc3339(), + delivery: email_config.delivery, + accept_url, }), )) } diff --git a/crates/schema-forge-acton/src/routes/mod.rs b/crates/schema-forge-acton/src/routes/mod.rs index b9e3c9f7..2b2bb801 100644 --- a/crates/schema-forge-acton/src/routes/mod.rs +++ b/crates/schema-forge-acton/src/routes/mod.rs @@ -1,6 +1,7 @@ pub mod audit; pub mod auth; pub mod create_intents; +mod creator_membership; pub mod entities; #[cfg(feature = "sse")] pub mod events; diff --git a/crates/schema-forge-acton/src/routes/oauth.rs b/crates/schema-forge-acton/src/routes/oauth.rs index 8b739c5c..7023cb20 100644 --- a/crates/schema-forge-acton/src/routes/oauth.rs +++ b/crates/schema-forge-acton/src/routes/oauth.rs @@ -44,6 +44,7 @@ use crate::{ invite::verify_invite_token, oauth_config::{OAuthSettings, SignupPolicy}, state::DynAuthStore, + tenancy_config::DefaultTenant, }; const STATE_TTL_SECS: u64 = 600; @@ -80,6 +81,7 @@ pub struct OAuthRuntime { states: Arc, login_codes: Arc, provisioning: Mutex<()>, + default_tenant: Option, } impl OAuthRuntime { @@ -110,6 +112,7 @@ impl OAuthRuntime { states: Arc::new(MemoryOAuthStateManager::new(STATE_TTL_SECS)), login_codes: Arc::new(MemoryOAuthStateManager::new(LOGIN_CODE_TTL_SECS)), provisioning: Mutex::new(()), + default_tenant: None, }) } @@ -137,9 +140,17 @@ impl OAuthRuntime { states, login_codes, provisioning: Mutex::new(()), + default_tenant: None, }) } + /// Set the default root validated by the embedding application's startup. + /// Signed invitation grants take precedence over this signup target. + pub fn with_default_tenant(mut self, default_tenant: Option) -> Self { + self.default_tenant = default_tenant; + self + } + async fn provider<'a>( &'a self, name: &str, @@ -459,6 +470,20 @@ async fn provision_account( .map_err(store_error)?; } } + if invite.is_none() { + if let Some(default) = &runtime.default_tenant { + services + .auth_store + .add_tenant_membership( + email, + default.schema.as_str(), + default.id.as_str(), + Some(&default.role), + ) + .await + .map_err(store_error)?; + } + } services .auth_store .link_identity(email, identity, email) diff --git a/crates/schema-forge-acton/src/routes/users.rs b/crates/schema-forge-acton/src/routes/users.rs index beee0e62..25e96f5c 100644 --- a/crates/schema-forge-acton/src/routes/users.rs +++ b/crates/schema-forge-acton/src/routes/users.rs @@ -486,7 +486,16 @@ pub async fn list_roles( let user_schema = fetch_user_schema(&state).await?; let policy_store = fetch_policy_store(&state).await?; - check_schema_access(&policy_store, &user_schema, Some(claims), AccessAction::List)?; + let can_list_users = match check_schema_access( + &policy_store, + &user_schema, + Some(claims), + AccessAction::List, + ) { + Ok(()) => true, + Err(ForgeError::Forbidden { .. }) => false, + Err(error) => return Err(error), + }; let snapshot = policy_store.current(); let caller_is_platform_admin = claims.has_role(PLATFORM_ADMIN_ROLE); @@ -500,6 +509,49 @@ pub async fn list_roles( rank: snapshot.role_ranks.get(name).unwrap_or(0), }) .collect(); + if !can_list_users { + // Invitation-only callers see exactly the role grants Cedar allows + // for their active tenant, without receiving general User access. + let tenant_chain = claims + .custom_claim_as::>("tenant_chain") + .unwrap_or_default(); + let mut allowed = Vec::new(); + for row in rows { + let proposed = ForgeUser { + username: "_invitation_role_option".into(), + roles: vec![row.name.clone()], + display_name: None, + active: true, + role_rank: row.rank, + }; + let mut entity = forge_user_to_user_entity(&proposed, policy_store.as_ref()); + if let Some(tenant) = tenant_chain.last() { + entity.fields.insert( + "_tenant".into(), + DynamicValue::Text(tenant.entity_id.clone()), + ); + } + let decision = authorize( + &policy_store, + Some(claims), + ActionVerb::Invite, + &user_schema, + Some(&entity), + ) + .map_err(|error| ForgeError::Internal { + message: format!("authz engine error while listing invitation roles: {error}"), + })?; + if decision.is_allow() { + allowed.push(row); + } + } + if allowed.is_empty() { + return Err(ForgeError::Forbidden { + message: "access denied to user and invitation role options".into(), + }); + } + rows = allowed; + } rows.sort_by(|a, b| a.rank.cmp(&b.rank).then_with(|| a.name.cmp(&b.name))); let count = rows.len(); diff --git a/crates/schema-forge-acton/src/state.rs b/crates/schema-forge-acton/src/state.rs index 7bffa5dc..09565527 100644 --- a/crates/schema-forge-acton/src/state.rs +++ b/crates/schema-forge-acton/src/state.rs @@ -209,6 +209,39 @@ pub trait DynEntityStore: Send + Sync { Box::pin(async { Err(schema_forge_backend::create_intent::CreateIntentError::Unsupported) }) } + /// Atomically create a tenant root and its creator membership. + fn create_with_membership<'a>( + &'a self, + _entity: &'a Entity, + _membership: &'a Entity, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async { + Err(BackendError::QueryError { + message: "backend does not support atomic creator membership".into(), + }) + }) + } + + /// Atomically commit a create receipt together with creator membership. + fn create_intent_with_membership<'a>( + &'a self, + _request: &'a schema_forge_backend::create_intent::CreateIntentRequest, + _membership: &'a Entity, + ) -> Pin< + Box< + dyn Future< + Output = Result< + schema_forge_backend::create_intent::CreateIntentReceipt, + schema_forge_backend::create_intent::CreateIntentError, + >, + > + Send + + Sync + + 'a, + >, + > { + Box::pin(async { Err(schema_forge_backend::create_intent::CreateIntentError::Unsupported) }) + } + /// Read a record and its storage revision, if supported. fn get_versioned<'a>( &'a self, @@ -352,6 +385,37 @@ impl DynEntityStore for T { Box::pin(SyncFuture::new(EntityStore::create_intent(self, request))) } + fn create_with_membership<'a>( + &'a self, + entity: &'a Entity, + membership: &'a Entity, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(SyncFuture::new(EntityStore::create_with_membership( + self, entity, membership, + ))) + } + + fn create_intent_with_membership<'a>( + &'a self, + request: &'a schema_forge_backend::create_intent::CreateIntentRequest, + membership: &'a Entity, + ) -> Pin< + Box< + dyn Future< + Output = Result< + schema_forge_backend::create_intent::CreateIntentReceipt, + schema_forge_backend::create_intent::CreateIntentError, + >, + > + Send + + Sync + + 'a, + >, + > { + Box::pin(SyncFuture::new(EntityStore::create_intent_with_membership( + self, request, membership, + ))) + } + fn get_versioned<'a>( &'a self, schema: &'a SchemaName, @@ -618,6 +682,24 @@ pub trait DynAuthStore: Send + Sync { username: &'a str, ) -> Pin, BackendError>> + Send + 'a>>; + /// Read scoped membership roles for signed login claims. + fn list_tenant_roles<'a>( + &'a self, + _username: &'a str, + ) -> Pin< + Box< + dyn Future< + Output = Result< + Vec, + BackendError, + >, + > + Send + + 'a, + >, + > { + Box::pin(async { Ok(Vec::new()) }) + } + /// Grant the user a tenant membership. See /// [`schema_forge_backend::user_store::AuthStore::add_tenant_membership`]. fn add_tenant_membership<'a>( @@ -774,6 +856,23 @@ impl DynAuthStore for T { Box::pin(AuthStore::list_tenant_memberships(self, username)) } + fn list_tenant_roles<'a>( + &'a self, + username: &'a str, + ) -> Pin< + Box< + dyn Future< + Output = Result< + Vec, + BackendError, + >, + > + Send + + 'a, + >, + > { + Box::pin(AuthStore::list_tenant_roles(self, username)) + } + fn add_tenant_membership<'a>( &'a self, username: &'a str, diff --git a/crates/schema-forge-acton/src/tenancy_config.rs b/crates/schema-forge-acton/src/tenancy_config.rs new file mode 100644 index 00000000..ae33d6b0 --- /dev/null +++ b/crates/schema-forge-acton/src/tenancy_config.rs @@ -0,0 +1,218 @@ +//! Operator-defined tenant onboarding grants. +use std::fmt; + +use schema_forge_backend::tenant::TenantConfig; +use schema_forge_core::types::{EntityId, SchemaName}; +use serde::{Deserialize, Serialize}; + +use crate::{ + authz::{role_ranks::PLATFORM_ADMIN_ROLE, RoleRanks}, + oauth_config::{OAuthSettings, SignupPolicy}, +}; + +/// `[schema_forge.tenancy]` onboarding settings. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct TenancySettings { + /// Role granted to an authenticated creator of a tenant root. + pub creator_role: Option, + /// Existing root tenant receiving new, non-invited OAuth accounts. + pub default_tenant: Option, +} + +/// An existing tenant root and the scoped membership role assigned at signup. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DefaultTenant { + /// Configured root schema name. + pub schema: SchemaName, + /// Existing root entity TypeID. + pub id: EntityId, + /// Scoped role; defaults to `member` independently of OAuth global roles. + #[serde(default = "default_member_role")] + pub role: String, +} + +fn default_member_role() -> String { + "member".into() +} + +/// Invalid tenant onboarding configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TenancySettingsError { + /// Automatic membership grants require a registered non-platform role. + InvalidRole { role: String }, + /// A default tenant must belong to the configured root schema. + InvalidDefaultRoot, + /// Open signup must provide membership before issuing a tenant session. + OpenSignupRequiresDefaultTenant, +} + +impl fmt::Display for TenancySettingsError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidRole { role } => write!(formatter, "tenant onboarding role '{role}' must be nonempty, registered in role_ranks.toml, and cannot be platform_admin"), + Self::InvalidDefaultRoot => formatter.write_str("schema_forge.tenancy.default_tenant.schema must name the configured tenant root"), + Self::OpenSignupRequiresDefaultTenant => formatter.write_str("open OAuth signup with tenancy requires schema_forge.tenancy.default_tenant pointing to an existing root; creator_role alone cannot provide a first login membership"), + } + } +} +impl std::error::Error for TenancySettingsError {} + +impl TenancySettings { + /// Validate settings against the proposed schema hierarchy before serving. + /// The caller must separately verify that the default root entity exists. + pub fn validate( + &self, + tenants: &TenantConfig, + ranks: &RoleRanks, + oauth: &OAuthSettings, + ) -> Result<(), TenancySettingsError> { + for role in self + .creator_role + .iter() + .chain(self.default_tenant.iter().map(|tenant| &tenant.role)) + { + if role.trim().is_empty() || role == PLATFORM_ADMIN_ROLE || ranks.get(role).is_none() { + return Err(TenancySettingsError::InvalidRole { role: role.clone() }); + } + } + if let Some(default) = &self.default_tenant { + if tenants.root_schema.as_ref() != Some(&default.schema) { + return Err(TenancySettingsError::InvalidDefaultRoot); + } + } + if tenants.is_enabled() + && oauth.enabled + && oauth.signup == SignupPolicy::Open + && self.default_tenant.is_none() + { + return Err(TenancySettingsError::OpenSignupRequiresDefaultTenant); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tenants() -> TenantConfig { + TenantConfig { + root_schema: Some(SchemaName::new("Organization").unwrap()), + hierarchy: vec![], + } + } + fn ranks() -> RoleRanks { + RoleRanks::from_toml_str("[roles]\nmember = 10\nowner = 20").unwrap() + } + fn default_tenant() -> DefaultTenant { + DefaultTenant { + schema: SchemaName::new("Organization").unwrap(), + id: EntityId::new("organization"), + role: "member".into(), + } + } + + #[test] + fn open_signup_requires_existing_tenant_configuration_even_with_creator_role() { + let settings = TenancySettings { + creator_role: Some("owner".into()), + ..Default::default() + }; + let mut oauth = OAuthSettings { + enabled: true, + signup: SignupPolicy::Open, + ..Default::default() + }; + assert_eq!( + settings.validate(&tenants(), &ranks(), &oauth), + Err(TenancySettingsError::OpenSignupRequiresDefaultTenant) + ); + oauth.signup = SignupPolicy::InviteOnly; + assert!(settings.validate(&tenants(), &ranks(), &oauth).is_ok()); + oauth.signup = SignupPolicy::Open; + oauth.enabled = false; + assert!(settings.validate(&tenants(), &ranks(), &oauth).is_ok()); + assert!(settings + .validate( + &TenantConfig { + root_schema: None, + hierarchy: vec![] + }, + &ranks(), + &OAuthSettings::default() + ) + .is_ok()); + } + + #[test] + fn rejects_unregistered_empty_and_platform_membership_grants() { + for role in ["", " ", "unknown", "platform_admin"] { + let settings = TenancySettings { + creator_role: Some(role.into()), + ..Default::default() + }; + assert!(matches!( + settings.validate(&tenants(), &ranks(), &OAuthSettings::default()), + Err(TenancySettingsError::InvalidRole { .. }) + )); + let settings = TenancySettings { + default_tenant: Some(DefaultTenant { + role: role.into(), + ..default_tenant() + }), + ..Default::default() + }; + assert!(matches!( + settings.validate(&tenants(), &ranks(), &OAuthSettings::default()), + Err(TenancySettingsError::InvalidRole { .. }) + )); + } + } + + #[test] + fn default_tenant_requires_matching_root_and_valid_typeid() { + let settings = TenancySettings { + default_tenant: Some(default_tenant()), + ..Default::default() + }; + assert!(settings + .validate(&tenants(), &ranks(), &OAuthSettings::default()) + .is_ok()); + assert_eq!( + settings.validate( + &TenantConfig { + root_schema: None, + hierarchy: vec![] + }, + &ranks(), + &OAuthSettings::default() + ), + Err(TenancySettingsError::InvalidDefaultRoot) + ); + let different_root = TenantConfig { + root_schema: Some(SchemaName::new("Company").unwrap()), + hierarchy: vec![], + }; + assert_eq!( + settings.validate(&different_root, &ranks(), &OAuthSettings::default()), + Err(TenancySettingsError::InvalidDefaultRoot) + ); + let mut json = serde_json::to_value(settings).unwrap(); + json["default_tenant"] + .as_object_mut() + .unwrap() + .remove("role"); + assert_eq!( + serde_json::from_value::(json.clone()) + .unwrap() + .default_tenant + .unwrap() + .role, + "member" + ); + json["default_tenant"]["id"] = "invalid-id".into(); + assert!(serde_json::from_value::(json).is_err()); + } +} diff --git a/crates/schema-forge-acton/tests/creator_membership.rs b/crates/schema-forge-acton/tests/creator_membership.rs new file mode 100644 index 00000000..24276f4a --- /dev/null +++ b/crates/schema-forge-acton/tests/creator_membership.rs @@ -0,0 +1,292 @@ +//! Root creation and refreshed tenant access for issue 208. +use acton_service::{ + auth::{config::TokenGenerationConfig, tokens::paseto_generator::PasetoGenerator}, + config::{Config, PasetoConfig}, + middleware::{Claims, PasetoAuth, TokenValidator}, + prelude::ActorHandleInterface, + service_builder::ServiceBuilder, +}; +use axum::{ + body::Body, + http::{Request, StatusCode}, + Extension, Router, +}; +use http_body_util::BodyExt; +use schema_forge_acton::{ + authz::{PrincipalClaimMappings, RoleRanks}, + config::SchemaForgeConfig, + messages::{InitForge, ReplyChannel}, + middleware::tenant_scope::{self, TenantScopeState}, + routes::{auth::auth_routes, forge_routes}, + state::DynAuthStore, + ForgeActor, SchemaForgeExtension, +}; +use schema_forge_backend::{Entity, EntityAuthStore, EntityStore, SchemaBackend}; +use schema_forge_core::{migration::DiffEngine, types::DynamicValue}; +use schema_forge_surrealdb::SurrealBackend; +use serde_json::{json, Value}; +use std::{collections::BTreeMap, sync::Arc}; +use tokio::sync::oneshot; +use tower::ServiceExt; + +struct Fixture { + app: Router, + store: Arc, + claims: Claims, + validator: PasetoAuth, + _key: tempfile::NamedTempFile, +} +async fn fixture(creator_role: Option<&str>) -> Fixture { + let backend = Arc::new( + SurrealBackend::connect_memory("creator", "creator") + .await + .unwrap(), + ); + let root = schema_forge_dsl::parse( + r#" + @tenant(root) + @access(read: ["owner"], write: ["member"]) + schema Organization { name: text required slug: text required unique } + "#, + ) + .unwrap() + .remove(0); + backend + .apply_migration(&root.name, &DiffEngine::create_new(&root).steps) + .await + .unwrap(); + backend.store_schema_metadata(&root).await.unwrap(); + let ranks = RoleRanks::from_toml_str("[roles]\nmember = 10\nowner = 20").unwrap(); + let extension = SchemaForgeExtension::builder() + .with_backend_arc(backend.clone()) + .with_role_ranks(ranks.clone()) + .build() + .await + .unwrap(); + let data = extension.state(); + let schemas = data.registry.list().await; + let user = schemas + .iter() + .find(|s| s.name.as_str() == "User") + .unwrap() + .clone(); + let membership = schemas + .iter() + .find(|s| s.name.as_str() == "TenantMembership") + .unwrap() + .clone(); + let store: Arc = Arc::new( + EntityAuthStore::new(backend.clone(), user, Arc::new(move |role| ranks.get(role))) + .with_tenant_membership_schema(membership), + ); + store + .create_user( + "creator@example.gov", + "strong-password", + &["member".into()], + "Creator", + ) + .await + .unwrap(); + let first = Entity::new( + root.name.clone(), + BTreeMap::from([ + ("name".into(), DynamicValue::Text("Existing".into())), + ("slug".into(), DynamicValue::Text("existing".into())), + ]), + ); + EntityStore::create(backend.as_ref(), &first).await.unwrap(); + store + .add_tenant_membership( + "creator@example.gov", + "Organization", + first.id.as_str(), + Some("member"), + ) + .await + .unwrap(); + let mut config = Config::::default(); + config.custom.schema_forge.tenancy.creator_role = creator_role.map(str::to_owned); + let service = ServiceBuilder::new() + .with_config(config) + .with_actor::() + .build(); + let (tx, rx) = oneshot::channel(); + service + .state() + .actor::() + .unwrap() + .send(InitForge { + registry: schemas + .into_iter() + .map(|s| (s.name.to_string(), s)) + .collect(), + backend: data.backend.clone(), + tenant_config: data.tenant_config.clone(), + record_access_policy: None, + hook_dispatcher: None, + storage_registry: data.storage_registry.clone(), + policy_store: Some(data.policy_store.clone()), + custom_policies_dir: None, + reply: ReplyChannel::new(tx), + }) + .await; + rx.await.unwrap(); + let claims: Claims = serde_json::from_value(json!({ + "sub":"creator@example.gov", "roles":["member"], "perms":[], "exp":9999999999_u64, + "tenant_chain":[{"schema":"Organization","entity_id":first.id}] + })) + .unwrap(); + let key = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(key.path(), [17; 32]).unwrap(); + let validator = PasetoAuth::new(&PasetoConfig { + version: "v4".into(), + purpose: "local".into(), + key_path: key.path().into(), + issuer: None, + audience: None, + public_paths: vec![], + }) + .unwrap(); + let generator = Arc::new(PasetoGenerator::with_symmetric_key( + [17; 32], + TokenGenerationConfig::default(), + )); + let app = Router::new() + .nest( + "/forge", + forge_routes() + .merge(auth_routes()) + .layer(Extension(store.clone())) + .layer(Extension(generator)) + .layer(Extension(Arc::new(PrincipalClaimMappings::default()))) + .layer(Extension(Arc::new(data.tenant_config.clone()))), + ) + .layer(axum::middleware::from_fn_with_state( + TenantScopeState { + entity_store: backend, + tenant_config: Arc::new(data.tenant_config.clone()), + }, + tenant_scope::middleware, + )) + .with_state(service.state().clone()); + Fixture { + app, + store, + claims, + validator, + _key: key, + } +} +async fn request( + f: &Fixture, + method: &str, + path: &str, + body: Value, + claims: Claims, + active: Option<&str>, +) -> (StatusCode, Value) { + let mut req = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json"); + if let Some(active) = active { + req = req.header("x-active-tenant", active); + } + let mut req = req.body(Body::from(body.to_string())).unwrap(); + req.extensions_mut().insert(claims); + let response = f.app.clone().oneshot(req).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap()) +} +#[tokio::test(flavor = "multi_thread")] +async fn root_creator_can_read_after_refresh_with_scoped_owner_role() { + let f = fixture(Some("owner")).await; + let (status, created) = request( + &f, + "POST", + "/forge/schemas/Organization/entities", + json!({"fields":{"name":"New","slug":"new"}}), + f.claims.clone(), + None, + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{created}"); + let id = created["id"].as_str().unwrap(); + let active = format!("Organization:{id}"); + let path = format!("/forge/schemas/Organization/entities/{id}"); + assert_eq!( + request( + &f, + "GET", + &path, + json!(null), + f.claims.clone(), + Some(&active) + ) + .await + .0, + StatusCode::FORBIDDEN + ); + let (status, refresh) = request( + &f, + "POST", + "/forge/auth/refresh", + json!({}), + f.claims.clone(), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{refresh}"); + let refreshed = f + .validator + .validate_token(refresh["token"].as_str().unwrap()) + .unwrap(); + assert_eq!(refreshed.roles, vec!["member"]); + assert_eq!( + request(&f, "GET", &path, json!(null), refreshed, Some(&active)) + .await + .0, + StatusCode::OK + ); + assert_eq!( + f.store + .get_user("creator@example.gov") + .await + .unwrap() + .unwrap() + .roles, + vec!["member"] + ); + assert_eq!( + f.store + .list_tenant_memberships("creator@example.gov") + .await + .unwrap() + .len(), + 2 + ); +} +#[tokio::test(flavor = "multi_thread")] +async fn unset_creator_role_preserves_membership_behavior() { + let f = fixture(None).await; + let (status, created) = request( + &f, + "POST", + "/forge/schemas/Organization/entities", + json!({"fields":{"name":"New","slug":"new"}}), + f.claims.clone(), + None, + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{created}"); + assert_eq!( + f.store + .list_tenant_memberships("creator@example.gov") + .await + .unwrap() + .len(), + 1 + ); +} diff --git a/crates/schema-forge-acton/tests/entity_events.rs b/crates/schema-forge-acton/tests/entity_events.rs index 82873cb3..bd317910 100644 --- a/crates/schema-forge-acton/tests/entity_events.rs +++ b/crates/schema-forge-acton/tests/entity_events.rs @@ -53,6 +53,16 @@ async fn fixture_options( config: EventsConfig, tenancy: bool, read_hooks: bool, +) -> Fixture { + fixture_identity_options(backend, config, tenancy, read_hooks, false).await +} + +async fn fixture_identity_options( + backend: Arc, + config: EventsConfig, + tenancy: bool, + read_hooks: bool, + scoped_role: bool, ) -> Fixture { let source = format!( "{}{}", @@ -121,7 +131,12 @@ async fn fixture_options( store = store.with_tenant_membership_schema(tm.clone()); } let store = Arc::new(store); - AuthStore::create_user_without_password(store.as_ref(), "alice", &["member".into()], "Alice") + let global_roles = if scoped_role { + Vec::new() + } else { + vec!["member".into()] + }; + AuthStore::create_user_without_password(store.as_ref(), "alice", &global_roles, "Alice") .await .unwrap(); let mut chain = Vec::::new(); @@ -246,7 +261,7 @@ async fn fixture_options( let anonymous = forge_routes().with_state(service.state().clone()); let caller = Claims { sub: "alice".into(), - roles: vec!["member".into()], + roles: global_roles, perms: vec![], exp: 9_999_999_999, iat: None, @@ -256,7 +271,14 @@ async fn fixture_options( email: None, username: None, custom: if tenancy { - HashMap::from([("tenant_chain".into(), serde_json::to_value(chain).unwrap())]) + HashMap::from([ + ("tenant_chain".into(), serde_json::to_value(chain).unwrap()), + ( + "tenant_roles".into(), + serde_json::to_value(auth_store.list_tenant_roles("alice").await.unwrap()) + .unwrap(), + ), + ]) } else { HashMap::new() }, @@ -754,6 +776,75 @@ async fn membership_removal_closes_stream_and_active_tenant_cannot_be_impersonat assert_eq!(event["reason"], "authorization_changed"); assert!(event.get("entity").is_none()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn scoped_membership_role_allows_stream_and_role_revocation_closes_it() { + let backend = Arc::new( + schema_forge_surrealdb::SurrealBackend::connect_memory("events", "scoped_roles") + .await + .unwrap(), + ); + let f = fixture_identity_options(backend, enabled(), true, false, true).await; + assert!(f + .store + .get_user("alice") + .await + .unwrap() + .unwrap() + .roles + .is_empty()); + assert_eq!( + f.store.list_tenant_roles("alice").await.unwrap()[0].role, + "member" + ); + let mut body = connect(&f.app, "").await; + let created = json( + request( + &f.app, + "POST", + "/schemas/Note/entities", + serde_json::json!({"title": "Scoped role"}), + ) + .await, + ) + .await; + let (_, event) = change(&mut body).await; + assert_eq!(event["entity"]["id"], created["id"]); + assert_eq!(event["entity"]["fields"]["title"], "Scoped role"); + let response = f + .app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!( + "/schemas/TenantMembership/entities/{}", + f.membership.as_ref().unwrap() + )) + .header("x-test-admin", "1") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({"fields": {"role": null}}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + f.store + .list_tenant_memberships("alice") + .await + .unwrap() + .len(), + 1 + ); + assert!(f.store.list_tenant_roles("alice").await.unwrap().is_empty()); + let (frame, event) = change(&mut body).await; + assert!(frame.contains("event: closed")); + assert_eq!(event["reason"], "authorization_changed"); + assert!(event.get("entity").is_none()); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn owner_denial_never_delivers_entity_or_delete_metadata() { let f = fixture(enabled()).await; diff --git a/crates/schema-forge-acton/tests/invitations.rs b/crates/schema-forge-acton/tests/invitations.rs new file mode 100644 index 00000000..da0bb77b --- /dev/null +++ b/crates/schema-forge-acton/tests/invitations.rs @@ -0,0 +1,302 @@ +//! Invitation policy and delivery regressions for issue 208. +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use acton_service::auth::config::TokenGenerationConfig; +use acton_service::auth::tokens::paseto_generator::PasetoGenerator; +use acton_service::config::Config; +use acton_service::middleware::Claims; +use acton_service::prelude::ActorHandleInterface; +use acton_service::service_builder::ServiceBuilder; +use async_trait::async_trait; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::{Extension, Router}; +use chrono::{DateTime, Utc}; +use http_body_util::BodyExt; +use schema_forge_acton::authz::{ + PolicyStore, PolicyStoreSnapshot, PrincipalClaimMappings, RoleRanks, +}; +use schema_forge_acton::config::SchemaForgeConfig; +use schema_forge_acton::email::{EmailDelivery, EmailError, EmailMessage, EmailSender}; +use schema_forge_acton::messages::{InitForge, ReplyChannel}; +use schema_forge_acton::routes::forge_routes; +use schema_forge_acton::state::{DynAuthStore, DynForgeBackend}; +use schema_forge_acton::ForgeActor; +use schema_forge_backend::tenant::{TenantConfig, TenantLevel}; +use schema_forge_backend::traits::SchemaBackend; +use schema_forge_backend::{ + BackendError, EntityAuthStore, ForgeInvitation, InviteStatus, InviteStore, NewInvitation, +}; +use schema_forge_core::types::{EntityId, SchemaName}; +use schema_forge_surrealdb::SurrealBackend; +use tokio::sync::oneshot; +use tower::ServiceExt; + +#[derive(Default)] +struct MemoryInvites(Mutex>); +#[async_trait] +impl InviteStore for MemoryInvites { + async fn create(&self, invite: NewInvitation) -> Result { + let stored = ForgeInvitation { + id: EntityId::new("entity"), + email: invite.email, + display_name: invite.display_name, + tenant_type: invite.tenant_type, + tenant_id: invite.tenant_id, + role: invite.role, + jti: invite.jti, + token: invite.token, + status: InviteStatus::Pending, + expires_at: Some(invite.expires_at), + invited_by: invite.invited_by, + consumed_at: None, + }; + self.0.lock().unwrap().push(stored.clone()); + Ok(stored) + } + async fn find_by_jti(&self, jti: &str) -> Result, BackendError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .find(|i| i.jti == jti) + .cloned()) + } + async fn mark_consumed(&self, id: &EntityId, at: DateTime) -> Result<(), BackendError> { + for invite in self.0.lock().unwrap().iter_mut().filter(|i| &i.id == id) { + invite.status = InviteStatus::Consumed; + invite.consumed_at = Some(at); + } + Ok(()) + } +} +#[derive(Default)] +struct FailingEmail(Mutex); +#[async_trait] +impl EmailSender for FailingEmail { + async fn send(&self, _message: EmailMessage) -> Result<(), EmailError> { + *self.0.lock().unwrap() += 1; + Err(EmailError::Transport( + "private relay diagnostics secret-password".into(), + )) + } + fn public_base_url(&self) -> Option<&str> { + Some("https://app.example.gov") + } +} + +async fn app( + delivery: EmailDelivery, + action: &str, +) -> (Router, Arc, Arc) { + let backend = Arc::new( + SurrealBackend::connect_memory("invites", "invites") + .await + .unwrap(), + ); + let user_schema = schema_forge_dsl::parse(schema_forge_core::system_schemas::USER_SCHEMA) + .unwrap() + .remove(0); + let plan = schema_forge_core::migration::DiffEngine::create_new(&user_schema); + backend + .apply_migration(&user_schema.name, &plan.steps) + .await + .unwrap(); + backend.store_schema_metadata(&user_schema).await.unwrap(); + let ranks = RoleRanks::from_toml_str("[roles]\nowner = 20\nmember = 10\nadmin = 30").unwrap(); + let custom = tempfile::tempdir().unwrap(); + std::fs::write( + custom.path().join("invite.cedar"), + format!( + r#" +permit(principal in Forge::Group::"owner", action == Action::"{action}User", resource is User) +when {{ resource has "_tenant" && principal in resource["_tenant"] }}; +"# + ), + ) + .unwrap(); + let snapshot = PolicyStoreSnapshot::from_schemas( + std::slice::from_ref(&user_schema), + Some(custom.path()), + ranks.clone(), + PrincipalClaimMappings::default(), + ) + .unwrap(); + let policies = Arc::new(PolicyStore::new(snapshot)); + let resolver = Arc::new(move |role: &str| ranks.get(role)); + let auth_store: Arc = Arc::new(EntityAuthStore::new( + backend.clone(), + user_schema.clone(), + resolver, + )); + let mut config = Config::::default(); + config.custom.schema_forge.email.delivery = delivery; + // Link mode deliberately works with SMTP disabled and no host/from. + config.custom.schema_forge.email.public_base_url = Some("https://app.example.gov".into()); + let service = ServiceBuilder::new() + .with_config(config) + .with_actor::() + .build(); + let (tx, rx) = oneshot::channel(); + let backend_dyn: Arc = backend; + service + .state() + .actor::() + .unwrap() + .send(InitForge { + registry: HashMap::from([("User".into(), user_schema)]), + backend: backend_dyn, + tenant_config: Some(TenantConfig { + root_schema: Some(SchemaName::new("Organization").unwrap()), + hierarchy: vec![TenantLevel { + schema: SchemaName::new("Organization").unwrap(), + parent: None, + parent_field: None, + }], + }), + record_access_policy: None, + hook_dispatcher: None, + storage_registry: schema_forge_acton::storage::StorageRegistry::default(), + policy_store: Some(policies), + custom_policies_dir: None, + reply: ReplyChannel::new(tx), + }) + .await; + rx.await.unwrap(); + let mut claims: Claims = serde_json::from_value(serde_json::json!({ + "sub": "owner@example.gov", "roles": ["owner"], "perms": [], "exp": 9999999999_u64 + })) + .unwrap(); + claims.custom.insert( + "tenant_chain".into(), + serde_json::json!([{"schema": "Organization", "entity_id": "org_a"}]), + ); + let store = Arc::new(MemoryInvites::default()); + let sender = Arc::new(FailingEmail::default()); + let invite_store: Arc = store.clone(); + let email_sender: Arc = sender.clone(); + let generator = Arc::new(PasetoGenerator::with_symmetric_key( + [11; 32], + TokenGenerationConfig::default(), + )); + let app = forge_routes() + .layer(Extension(auth_store)) + .layer(Extension(invite_store)) + .layer(Extension(email_sender)) + .layer(Extension(generator)) + .layer(Extension(claims)) + .with_state(service.state().clone()); + (app, store, sender) +} +async fn post( + app: &Router, + path: &str, + body: serde_json::Value, +) -> (StatusCode, serde_json::Value) { + let request = Request::builder() + .method("POST") + .uri(path) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap()) +} +fn invite(role: &str, tenant: &str) -> serde_json::Value { + serde_json::json!({"email":"new@example.gov", "role":role, "tenant_type":"Organization", "tenant_id":tenant}) +} + +#[tokio::test(flavor = "multi_thread")] +async fn link_invite_uses_scoped_invite_permission_without_smtp_or_create_permission() { + let (app, store, sender) = app(EmailDelivery::Link, "Invite").await; + let (status, body) = post(&app, "/auth/invites", invite("member", "org_a")).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert_eq!(body["delivery"], "link"); + assert_eq!( + body["accept_url"], + format!( + "https://app.example.gov/invite/accept?invite={}", + body["invite_id"].as_str().unwrap() + ) + ); + assert_eq!(*sender.0.lock().unwrap(), 0); + assert_eq!(store.0.lock().unwrap().len(), 1); + let (status, _) = post(&app, "/users", serde_json::json!({"username":"direct@example.gov", "password":"passwordlong", "roles":["member"], "display_name":"Direct"})).await; + assert_eq!(status, StatusCode::FORBIDDEN); +} +#[tokio::test(flavor = "multi_thread")] +async fn create_user_permission_does_not_grant_invites() { + let (app, store, _) = app(EmailDelivery::Link, "Create").await; + let (status, body) = post(&app, "/auth/invites", invite("member", "org_a")).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert!(store.0.lock().unwrap().is_empty()); +} +#[tokio::test(flavor = "multi_thread")] +async fn cross_tenant_and_upward_role_grants_cannot_create_pending_invites() { + let (app, store, sender) = app(EmailDelivery::Link, "Invite").await; + for body in [ + invite("member", "org_b"), + invite("admin", "org_a"), + invite("platform_admin", "org_a"), + ] { + let (status, response) = post(&app, "/auth/invites", body).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{response}"); + } + let mut invalid = invite("member", "org_a"); + invalid["tenant_type"] = "Unknown".into(); + assert_eq!( + post(&app, "/auth/invites", invalid).await.0, + StatusCode::UNPROCESSABLE_ENTITY + ); + assert!(store.0.lock().unwrap().is_empty()); + assert_eq!(*sender.0.lock().unwrap(), 0); +} +#[tokio::test(flavor = "multi_thread")] +async fn smtp_failure_returns_recoverable_safe_error_and_keeps_invitation_pending() { + let (app, store, sender) = app(EmailDelivery::Smtp, "Invite").await; + let (status, body) = post(&app, "/auth/invites", invite("member", "org_a")).await; + assert_eq!(status, StatusCode::BAD_GATEWAY, "{body}"); + assert_eq!(body["error"], "invite_delivery_failed"); + assert_eq!(body["delivery"], "failed"); + assert!(!body.to_string().contains("secret-password")); + let invites = store.0.lock().unwrap(); + assert_eq!(invites.len(), 1); + assert_eq!(invites[0].status, InviteStatus::Pending); + assert_eq!(body["invite_id"], invites[0].jti); + assert!(body["accept_url"] + .as_str() + .unwrap() + .contains(&invites[0].jti)); + assert_eq!(*sender.0.lock().unwrap(), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invite_only_owner_can_list_grantable_roles_without_listing_users() { + let (app, _, _) = app(EmailDelivery::Link, "Invite").await; + for (path, expected_status) in [ + ("/users/roles", StatusCode::OK), + ("/users", StatusCode::FORBIDDEN), + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), expected_status); + if path == "/users/roles" { + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let names: Vec<_> = body["roles"] + .as_array() + .unwrap() + .iter() + .map(|r| r["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, ["member", "owner"]); + } + } +} diff --git a/crates/schema-forge-acton/tests/oauth_login.rs b/crates/schema-forge-acton/tests/oauth_login.rs index 57e08493..73b7ead2 100644 --- a/crates/schema-forge-acton/tests/oauth_login.rs +++ b/crates/schema-forge-acton/tests/oauth_login.rs @@ -30,6 +30,7 @@ use schema_forge_acton::{ oauth::{oauth_routes, OAuthLoginServices, OAuthRuntime}, }, state::DynAuthStore, + tenancy_config::DefaultTenant, }; use schema_forge_backend::{ invite_store::{ @@ -38,7 +39,11 @@ use schema_forge_backend::{ tenant::TenantConfig, EntityAuthStore, EntityStore, SchemaBackend, }; -use schema_forge_core::{migration::DiffEngine, system_schemas, types::SchemaName}; +use schema_forge_core::{ + migration::DiffEngine, + system_schemas, + types::{EntityId, SchemaName}, +}; use schema_forge_surrealdb::SurrealBackend; use serde_json::{json, Value}; use tempfile::NamedTempFile; @@ -83,6 +88,7 @@ struct Options { state_ttl: u64, code_ttl: u64, tenancy: bool, + default_tenant: Option, } impl Default for Options { fn default() -> Self { @@ -106,6 +112,7 @@ impl Default for Options { state_ttl: 600, code_ttl: 60, tenancy: false, + default_tenant: None, } } } @@ -204,7 +211,8 @@ async fn fixture(options: Options) -> Fixture { Arc::new(MemoryOAuthStateManager::new(options.state_ttl)), Arc::new(MemoryOAuthStateManager::new(options.code_ttl)), ) - .unwrap(), + .unwrap() + .with_default_tenant(options.default_tenant.clone()), ); let mut config = Config::::default(); config.custom.schema_forge.auth.oauth = options.settings.clone(); @@ -543,11 +551,81 @@ async fn password_login_can_be_disabled_and_missing_membership_refuses_oauth() { ); } +#[tokio::test] +async fn open_signup_adds_default_membership_before_issuing_tenant_session() { + let tenant_id = EntityId::new("organization"); + let fixture = fixture(Options { + tenancy: true, + default_tenant: Some(DefaultTenant { + schema: SchemaName::new("Organization").unwrap(), + id: tenant_id.clone(), + role: "owner".into(), + }), + ..Default::default() + }) + .await; + let state = start(&fixture.app, None).await; + let code = login_code(callback(&fixture.app, &state).await); + let result = body( + request( + &fixture.app, + "POST", + "/auth/oauth/exchange", + Some(json!({"code": code})), + ) + .await, + ) + .await; + assert_eq!(result["roles"], json!(["member"])); + let claims = fixture + .services + .validator + .validate_token(result["token"].as_str().unwrap()) + .unwrap(); + assert_eq!( + claims.custom["tenant_chain"], + json!([{"schema": "Organization", "entity_id": tenant_id.as_str()}]) + ); + assert_eq!( + claims.custom["tenant_roles"], + json!([{"tenant": {"schema": "Organization", "entity_id": tenant_id.as_str()}, "role": "owner"}]) + ); + let memberships = fixture + .store + .list_tenant_memberships("alice@example.com") + .await + .unwrap(); + assert_eq!(memberships.len(), 1); + assert_eq!(memberships[0].schema, "Organization"); + assert_eq!(memberships[0].entity_id, tenant_id.as_str()); + let schema = fixture + .backend + .load_schema_metadata(&SchemaName::new("TenantMembership").unwrap()) + .await + .unwrap() + .unwrap(); + let rows = fixture + .backend + .query(&schema_forge_core::query::Query::new(schema.id)) + .await + .unwrap(); + assert_eq!(rows.entities.len(), 1); + assert_eq!( + rows.entities[0].fields["role"], + schema_forge_core::types::DynamicValue::Text("owner".into()) + ); +} + #[tokio::test] async fn invitation_signed_claims_override_mutable_columns_and_consumption_is_last() { let mut options = Options::default(); options.settings.signup = SignupPolicy::InviteOnly; options.tenancy = true; + options.default_tenant = Some(DefaultTenant { + schema: SchemaName::new("Organization").unwrap(), + id: EntityId::new("organization"), + role: "member".into(), + }); let fixture = fixture(options).await; let minted = mint_invite_token( &fixture.services.generator, diff --git a/crates/schema-forge-backend/Cargo.toml b/crates/schema-forge-backend/Cargo.toml index 25861c71..6b543478 100644 --- a/crates/schema-forge-backend/Cargo.toml +++ b/crates/schema-forge-backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-backend" -version = "0.20.0" +version = "0.21.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-backend/src/entity_auth_store.rs b/crates/schema-forge-backend/src/entity_auth_store.rs index 618b71a6..ba3e2675 100644 --- a/crates/schema-forge-backend/src/entity_auth_store.rs +++ b/crates/schema-forge-backend/src/entity_auth_store.rs @@ -32,7 +32,7 @@ use crate::error::BackendError; use crate::oauth_identity::ProviderIdentity; use crate::tenant::TenantRef; use crate::traits::EntityStore; -use crate::user_store::{AuthStore, ForgeUser}; +use crate::user_store::{AuthStore, ForgeUser, TenantRole}; const USERNAME_FIELD: &str = "email"; const PASSWORD_HASH_FIELD: &str = "password_hash"; @@ -377,6 +377,32 @@ impl EntityAuthStore { Entity::new(self.user_schema_name().clone(), fields) } + async fn tenant_membership_entities( + &self, + username: &str, + ) -> Result, BackendError> { + // Tenancy not configured for this deployment — return empty. + // The login handler treats "0 memberships + tenancy enabled" as + // a 401; with no schema attached we don't know whether tenancy + // is enabled, so we leave that decision to the caller. + let Some(tm_schema) = self.tenant_membership_schema.as_ref() else { + return Ok(Vec::new()); + }; + + // Resolve the user's EntityId first; without the row there are + // no memberships to read regardless of the TenantMembership + // table's contents. + let Some(user_entity) = self.find_entity_by_username(username).await? else { + return Ok(Vec::new()); + }; + + let query = Query::new(tm_schema.id.clone()).with_filter(Filter::eq( + FieldPath::single(TM_USER_FIELD), + DynamicValue::Ref(user_entity.id.clone()), + )); + Ok(self.store.query(&query).await?.entities) + } + /// Compile-time hint for which User schema fields the auth store /// touches. Surfaced via [`Self::user_schema_field_names`] so a /// future migration command can validate the deployed schema before @@ -717,30 +743,22 @@ impl AuthStore for EntityAuthStore { &self, username: &str, ) -> Result, BackendError> { - // Tenancy not configured for this deployment — return empty. - // The login handler treats "0 memberships + tenancy enabled" as - // a 401; with no schema attached we don't know whether tenancy - // is enabled, so we leave that decision to the caller. - let Some(tm_schema) = self.tenant_membership_schema.as_ref() else { - return Ok(Vec::new()); - }; - - // Resolve the user's EntityId first; without the row there are - // no memberships to read regardless of the TenantMembership - // table's contents. - let Some(user_entity) = self.find_entity_by_username(username).await? else { - return Ok(Vec::new()); - }; + let entities = self.tenant_membership_entities(username).await?; + Ok(entities.iter().filter_map(entity_to_tenant_ref).collect()) + } - let query = Query::new(tm_schema.id.clone()).with_filter(Filter::eq( - FieldPath::single(TM_USER_FIELD), - DynamicValue::Ref(user_entity.id.clone()), - )); - let result = self.store.query(&query).await?; - Ok(result - .entities + async fn list_tenant_roles(&self, username: &str) -> Result, BackendError> { + let entities = self.tenant_membership_entities(username).await?; + Ok(entities .iter() - .filter_map(entity_to_tenant_ref) + .filter_map(|entity| { + let tenant = entity_to_tenant_ref(entity)?; + let role = extract_text(entity, TM_ROLE_FIELD)?; + if role.trim().is_empty() || role == "platform_admin" { + return None; + } + Some(TenantRole { tenant, role }) + }) .collect()) } @@ -1364,6 +1382,62 @@ mod tests { Entity::new(tm_schema.name.clone(), fields) } + #[tokio::test] + async fn tenant_roles_preserve_scope_and_ignore_legacy_empty_and_platform_grants() { + let (auth, mem, tm) = store_with_tm(); + auth.create_user("alice", "secret123", &[], "Alice") + .await + .unwrap(); + auth.create_user("bob", "secret123", &[], "Bob") + .await + .unwrap(); + for (username, tenant, role) in [ + ("alice", "org-owner", Some("owner")), + ("alice", "org-member", Some("member")), + ("alice", "org-legacy", None), + ("alice", "org-empty", Some(" ")), + ("alice", "org-platform", Some("platform_admin")), + ("bob", "org-bob", Some("owner")), + ] { + let user = auth + .find_entity_by_username(username) + .await + .unwrap() + .unwrap(); + let mut row = make_tm_row(&tm, &user.id, "Organization", tenant); + if let Some(role) = role { + row.fields + .insert(TM_ROLE_FIELD.into(), DynamicValue::Text(role.into())); + } + EntityStore::create(mem.as_ref(), &row).await.unwrap(); + } + let roles = auth.list_tenant_roles("alice").await.unwrap(); + assert_eq!(roles.len(), 2); + assert!(roles + .iter() + .any(|grant| grant.tenant.entity_id == "org-owner" && grant.role == "owner")); + assert!(roles + .iter() + .any(|grant| grant.tenant.entity_id == "org-member" && grant.role == "member")); + assert_eq!( + auth.list_tenant_memberships("alice").await.unwrap().len(), + 5 + ); + assert!(auth.list_tenant_roles("ghost").await.unwrap().is_empty()); + assert!(auth + .get_user("alice") + .await + .unwrap() + .unwrap() + .roles + .is_empty()); + assert!(store_with_ranks(&[]) + .list_tenant_roles("alice") + .await + .unwrap() + .is_empty()); + } + #[tokio::test] async fn list_tenant_memberships_returns_empty_for_unknown_user() { let (auth, _mem, _tm) = store_with_tm(); diff --git a/crates/schema-forge-backend/src/lib.rs b/crates/schema-forge-backend/src/lib.rs index 70bb40c5..9c5fc7b6 100644 --- a/crates/schema-forge-backend/src/lib.rs +++ b/crates/schema-forge-backend/src/lib.rs @@ -26,3 +26,6 @@ pub mod conditional; /// Optional durable create reconciliation types. pub mod create_intent; + +/// Validation for atomic tenant creator membership. +pub mod onboarding; diff --git a/crates/schema-forge-backend/src/onboarding.rs b/crates/schema-forge-backend/src/onboarding.rs new file mode 100644 index 00000000..a5e2fd88 --- /dev/null +++ b/crates/schema-forge-backend/src/onboarding.rs @@ -0,0 +1,87 @@ +//! Pure validation shared by atomic creator-membership adapters. +use crate::{BackendError, Entity}; +use schema_forge_core::types::{DynamicValue, EntityId}; + +/// Validate the membership's root link and return its existing User reference. +/// The adapter must check User existence inside its write transaction. +pub fn validate_creator_membership<'a>( + entity: &Entity, + membership: &'a Entity, +) -> Result<&'a EntityId, BackendError> { + let invalid = |field: &str, reason: &str| BackendError::ValidationFailed { + field: field.into(), + reason: reason.into(), + }; + if membership.schema.as_str() != "TenantMembership" { + return Err(invalid( + "schema", + "creator membership must use TenantMembership", + )); + } + if !matches!(membership.field("tenant_type"), Some(DynamicValue::Text(value)) if value == entity.schema.as_str()) + { + return Err(invalid( + "tenant_type", + "creator membership must reference the created root schema", + )); + } + if !matches!(membership.field("tenant_id"), Some(DynamicValue::Text(value)) if value == entity.id.as_str()) + { + return Err(invalid( + "tenant_id", + "creator membership must reference the created root ID", + )); + } + match membership.field("user") { + Some(DynamicValue::Ref(id)) => Ok(id), + _ => Err(invalid( + "user", + "creator membership requires a User reference", + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use schema_forge_core::types::SchemaName; + use std::collections::BTreeMap; + + #[test] + fn membership_cannot_link_another_root_or_non_user() { + let entity = Entity::new(SchemaName::new("Organization").unwrap(), BTreeMap::new()); + let user = EntityId::new("user"); + let mut membership = Entity::new( + SchemaName::new("TenantMembership").unwrap(), + BTreeMap::from([ + ("user".into(), DynamicValue::Ref(user.clone())), + ( + "tenant_type".into(), + DynamicValue::Text(entity.schema.to_string()), + ), + ( + "tenant_id".into(), + DynamicValue::Text(entity.id.to_string()), + ), + ]), + ); + assert_eq!( + validate_creator_membership(&entity, &membership).unwrap(), + &user + ); + for (field, value) in [ + ( + "tenant_id", + DynamicValue::Text(EntityId::new("organization").to_string()), + ), + ("tenant_type", DynamicValue::Text("OtherRoot".into())), + ("user", DynamicValue::Text(user.to_string())), + ] { + let previous = membership.fields.insert(field.into(), value).unwrap(); + assert!(validate_creator_membership(&entity, &membership).is_err()); + membership.fields.insert(field.into(), previous); + } + membership.schema = SchemaName::new("OtherMembership").unwrap(); + assert!(validate_creator_membership(&entity, &membership).is_err()); + } +} diff --git a/crates/schema-forge-backend/src/traits.rs b/crates/schema-forge-backend/src/traits.rs index 4cdebb83..d4141b95 100644 --- a/crates/schema-forge-backend/src/traits.rs +++ b/crates/schema-forge-backend/src/traits.rs @@ -110,6 +110,35 @@ pub trait EntityStore: Send + Sync { async { Err(crate::create_intent::CreateIntentError::Unsupported) } } + /// Atomically commit a create intent together with creator membership. + /// Committed receipts reconcile without inserting another membership. + fn create_intent_with_membership( + &self, + _request: &crate::create_intent::CreateIntentRequest, + _membership: &Entity, + ) -> impl Future< + Output = Result< + crate::create_intent::CreateIntentReceipt, + crate::create_intent::CreateIntentError, + >, + > + Send { + async { Err(crate::create_intent::CreateIntentError::Unsupported) } + } + + /// Create a tenant root and its creator membership in one transaction. + /// Adapters must fail without writing if atomic creation is unsupported. + fn create_with_membership( + &self, + _entity: &Entity, + _membership: &Entity, + ) -> impl Future> + Send { + async { + Err(BackendError::QueryError { + message: "atomic creator membership is unsupported by this backend".into(), + }) + } + } + /// Read an entity and opaque revision from a single consistent snapshot. /// Unsupported backends must not synthesize revisions from ordinary reads. fn get_versioned( @@ -249,6 +278,82 @@ pub trait EntityStore: Send + Sync { mod tests { use super::*; + struct UnsupportedStore(std::sync::atomic::AtomicUsize); + + impl EntityStore for UnsupportedStore { + async fn create(&self, entity: &Entity) -> Result { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(entity.clone()) + } + async fn get(&self, _: &SchemaName, _: &EntityId) -> Result { + panic!("unsupported atomic operation must not read") + } + async fn update(&self, _: &Entity) -> Result { + panic!("unsupported atomic operation must not update") + } + async fn delete(&self, _: &SchemaName, _: &EntityId) -> Result<(), BackendError> { + panic!("unsupported atomic operation must not compensate") + } + async fn query(&self, _: &Query) -> Result { + panic!("unsupported atomic operation must not query") + } + async fn count(&self, _: &Query) -> Result { + panic!("unsupported atomic operation must not count") + } + async fn aggregate( + &self, + _: &AggregateQuery, + ) -> Result, BackendError> { + panic!("unsupported atomic operation must not aggregate") + } + } + + #[tokio::test] + async fn unsupported_creator_membership_never_performs_partial_writes() { + use crate::create_intent::{ + CreateIntentError, CreateIntentId, CreateIntentRequest, CreateIntentScope, + }; + use schema_forge_core::types::SchemaId; + let store = UnsupportedStore(std::sync::atomic::AtomicUsize::new(0)); + let schema = SchemaName::new("Organization").unwrap(); + let entity = Entity::new(schema.clone(), std::collections::BTreeMap::new()); + let membership = Entity::new( + SchemaName::new("TenantMembership").unwrap(), + std::collections::BTreeMap::new(), + ); + assert!(store + .create_with_membership(&entity, &membership) + .await + .is_err()); + let request = CreateIntentRequest::Read { + scope: CreateIntentScope { + principal: "owner".into(), + tenant: String::new(), + schema: SchemaDefinition::new( + SchemaId::new(), + schema, + vec![schema_forge_core::types::FieldDefinition::new( + FieldName::new("name").unwrap(), + schema_forge_core::types::FieldType::Text( + schema_forge_core::types::TextConstraints::unconstrained(), + ), + )], + vec![], + ) + .unwrap(), + }, + id: CreateIntentId::fresh(), + }; + assert_eq!( + store + .create_intent_with_membership(&request, &membership) + .await + .unwrap_err(), + CreateIntentError::Unsupported + ); + assert_eq!(store.0.load(std::sync::atomic::Ordering::SeqCst), 0); + } + // Compile-time verification that traits have the correct bounds. // These functions are never called -- they just verify the trait is object-safe enough // for RPITIT usage and that Send + Sync is required. diff --git a/crates/schema-forge-backend/src/user_store.rs b/crates/schema-forge-backend/src/user_store.rs index 9cddbc21..40a218c4 100644 --- a/crates/schema-forge-backend/src/user_store.rs +++ b/crates/schema-forge-backend/src/user_store.rs @@ -21,6 +21,16 @@ pub struct ForgeUser { pub role_rank: i64, } +/// A role granted only within the referenced tenant. +/// Older memberships without a role remain represented by `TenantRef` alone. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TenantRole { + /// Tenant receiving the grant. + pub tenant: TenantRef, + /// Role applied only while this tenant is active. + pub role: String, +} + /// Storage-agnostic trait for user authentication and management. /// /// Implementations handle: @@ -151,6 +161,15 @@ pub trait AuthStore: Send + Sync { username: &str, ) -> impl Future, BackendError>> + Send; + /// List explicitly assigned tenant roles without granting global roles. + /// Stores without scoped-role support return an empty set. + fn list_tenant_roles( + &self, + _username: &str, + ) -> impl Future, BackendError>> + Send { + async { Ok(Vec::new()) } + } + /// Stamp the user's `last_login` field to `at`. /// /// Called from the login handler after credentials validate, before the diff --git a/crates/schema-forge-backend/tests/support/creator_membership.rs b/crates/schema-forge-backend/tests/support/creator_membership.rs new file mode 100644 index 00000000..6bde44b3 --- /dev/null +++ b/crates/schema-forge-backend/tests/support/creator_membership.rs @@ -0,0 +1,154 @@ +//! Atomic onboarding behavior contract exercised by every shipping adapter. +use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; +use schema_forge_core::{ + migration::DiffEngine, + types::{ + Cardinality, DynamicValue, EntityId, FieldDefinition, FieldName, FieldType, + SchemaDefinition, SchemaId, SchemaName, TextConstraints, + }, +}; +use std::collections::BTreeMap; + +fn schema(name: &str, fields: Vec<(&str, FieldType)>) -> SchemaDefinition { + SchemaDefinition::new( + SchemaId::new(), + SchemaName::new(name).unwrap(), + fields + .into_iter() + .map(|(name, kind)| FieldDefinition::new(FieldName::new(name).unwrap(), kind)) + .collect(), + vec![], + ) + .unwrap() +} + +fn text() -> FieldType { + FieldType::Text(TextConstraints::unconstrained()) +} + +pub async fn setup(backend: &(impl EntityStore + SchemaBackend)) -> Entity { + for schema in [ + schema("User", vec![("email", text())]), + schema("Organization", vec![("name", text())]), + schema( + "TenantMembership", + vec![ + ( + "user", + FieldType::Relation { + target: SchemaName::new("User").unwrap(), + cardinality: Cardinality::One, + }, + ), + ("tenant_type", text()), + ("tenant_id", text()), + ("role", text()), + ], + ), + ] { + backend + .apply_schema_change( + &schema.name, + &DiffEngine::create_new(&schema).steps, + Some(&schema), + ) + .await + .unwrap(); + if backend.supports_record_revisions() { + backend + .prepare_record_revisions(&schema.name) + .await + .unwrap(); + } + } + backend + .create(&Entity::with_id( + EntityId::new("entity"), + SchemaName::new("User").unwrap(), + BTreeMap::from([( + "email".into(), + DynamicValue::Text("owner@example.org".into()), + )]), + )) + .await + .unwrap() +} + +pub fn root(name: &str) -> Entity { + Entity::new( + SchemaName::new("Organization").unwrap(), + BTreeMap::from([("name".into(), DynamicValue::Text(name.into()))]), + ) +} + +pub fn membership(root: &Entity, user: &Entity) -> Entity { + Entity::new( + SchemaName::new("TenantMembership").unwrap(), + BTreeMap::from([ + ("user".into(), DynamicValue::Ref(user.id.clone())), + ( + "tenant_type".into(), + DynamicValue::Text(root.schema.to_string()), + ), + ("tenant_id".into(), DynamicValue::Text(root.id.to_string())), + ("role".into(), DynamicValue::Text("owner".into())), + ]), + ) +} + +pub async fn exercise(backend: &(impl EntityStore + SchemaBackend)) { + let user = setup(backend).await; + let first = root("first"); + let grant = membership(&first, &user); + assert_eq!( + backend + .create_with_membership(&first, &grant) + .await + .unwrap() + .id, + first.id + ); + assert_eq!(backend.get(&grant.schema, &grant.id).await.unwrap(), grant); + if backend.supports_record_revisions() { + backend + .get_versioned(&first.schema, &first.id) + .await + .unwrap(); + backend + .get_versioned(&grant.schema, &grant.id) + .await + .unwrap(); + } + // Root is inserted first, so a duplicate membership must roll it back. + let second = root("rollback"); + let mut duplicate = membership(&second, &user); + duplicate.id = grant.id.clone(); + assert!(backend + .create_with_membership(&second, &duplicate) + .await + .is_err()); + assert!(backend.get(&second.schema, &second.id).await.is_err()); + assert_eq!(backend.get(&grant.schema, &grant.id).await.unwrap(), grant); + if backend.supports_record_revisions() { + assert!(backend + .get_versioned(&second.schema, &second.id) + .await + .is_err()); + } + let third = root("bad link"); + let mut bad_link = membership(&first, &user); + assert!(backend + .create_with_membership(&third, &bad_link) + .await + .is_err()); + assert!(backend.get(&third.schema, &third.id).await.is_err()); + assert!(backend.get(&bad_link.schema, &bad_link.id).await.is_err()); + // A valid-looking User ID must exist when the transaction commits. + bad_link = membership(&third, &Entity::new(user.schema.clone(), BTreeMap::new())); + assert!(backend + .create_with_membership(&third, &bad_link) + .await + .is_err()); + assert!(backend.get(&third.schema, &third.id).await.is_err()); + assert!(backend.get(&bad_link.schema, &bad_link.id).await.is_err()); +} diff --git a/crates/schema-forge-cli/Cargo.toml b/crates/schema-forge-cli/Cargo.toml index 156274d6..235acdf0 100644 --- a/crates/schema-forge-cli/Cargo.toml +++ b/crates/schema-forge-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-cli" -version = "0.48.0" +version = "0.49.0" edition = "2021" [[bin]] @@ -38,7 +38,7 @@ rust-embed = { version = "8.11.0", features = ["interpolate-folder-path"], optio mime_guess = { version = "2.0.5" } sha2 = "0.11.0" tokio-util = { version = "0.7.18", features = ["io"] } -schema-forge-mssql = { version = "0.7.0", path = "../schema-forge-mssql", optional = true } +schema-forge-mssql = { version = "0.8.0", path = "../schema-forge-mssql", optional = true } httpdate = "1.0.3" figment = { version = "0.10.19", features = ["env", "toml"] } diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index 81c2099b..1522005c 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -144,7 +144,7 @@ pub async fn run( backend_arc.clone(), None, &storage_config, - role_ranks, + role_ranks.clone(), principal_claims, // Validate custom policies against the proposed registry below, not the // old registry: a coordinated field/policy rename must be deployable. @@ -160,6 +160,39 @@ pub async fn run( let proposed_schemas = super::schema_update::merge_schema_definitions(registry.values().cloned(), &schemas); super::schema_update::validate_tenant_hierarchy(&proposed_schemas)?; + let proposed_tenants = schema_forge_backend::tenant::TenantConfig::from_schemas( + &proposed_schemas, + ) + .map_err(|error| CliError::Config { + message: error.to_string(), + })?; + svc_config + .custom + .schema_forge + .tenancy + .validate( + &proposed_tenants, + &role_ranks, + &svc_config.custom.schema_forge.auth.oauth, + ) + .map_err(|error| CliError::Config { + message: error.to_string(), + })?; + if let Some(default) = &svc_config.custom.schema_forge.tenancy.default_tenant { + // A fresh root schema has no existing target. Refuse before applying + // user DDL rather than creating accounts that cannot complete login. + if !registry.contains_key(default.schema.as_str()) { + return Err(CliError::Config { + message: "default_tenant must reference an existing tenant root entity; apply and seed the root before enabling open signup".into(), + }); + } + entity_store + .get(&default.schema, &default.id) + .await + .map_err(|error| CliError::Config { + message: format!("cannot load schema_forge.tenancy.default_tenant: {error}"), + })?; + } let prepared_policy = init_data .policy_store .as_ref() @@ -417,7 +450,12 @@ pub async fn run( email_cfg.password = Some(pw); } let project_name = &svc_config.custom.schema_forge.project_name; - let email_sender: Arc = if email_cfg.enabled { + email_cfg.validate_link_delivery().map_err(|error| CliError::Config { + message: format!("invalid [schema_forge.email] config: {error}"), + })?; + let email_sender: Arc = if email_cfg.enabled + && email_cfg.delivery == schema_forge_acton::email::EmailDelivery::Smtp { + Arc::new( schema_forge_acton::email::SmtpEmailSender::from_config(&email_cfg, project_name) .map_err(|e| CliError::Config { @@ -467,7 +505,15 @@ pub async fn run( ) .map_err(|error| CliError::Config { message: format!("invalid OAuth configuration: {error}"), - })?, + })? + .with_default_tenant( + svc_config + .custom + .schema_forge + .tenancy + .default_tenant + .clone(), + ), )) } else { None diff --git a/crates/schema-forge-cli/templates/site/src/App.tsx.jinja b/crates/schema-forge-cli/templates/site/src/App.tsx.jinja index c93abe46..b69f2d54 100644 --- a/crates/schema-forge-cli/templates/site/src/App.tsx.jinja +++ b/crates/schema-forge-cli/templates/site/src/App.tsx.jinja @@ -34,6 +34,7 @@ import { Users, } from "lucide-react" import { defaultEntity, routeManifest, invitationRoutes } from "@/generated/route-manifest" +import { listInviteRoles } from "@/generated/invites" import { InvitePage } from "@/pages/invite" import { InviteAcceptPage } from "@/pages/invite-accept" import { LoginPage } from "@/pages/login" @@ -132,6 +133,7 @@ function Sidebar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => queryKey: ["schemas"], queryFn: () => listSchemas(), }) + const inviteRoles = useQuery({ queryKey: ["invite-roles"], queryFn: listInviteRoles, retry: false }) const appSchemas = (schemas ?? []).filter((s: SchemaResponse) => !isSystemSchema(s)) const isActive = (prefix: string) => @@ -169,7 +171,7 @@ function Sidebar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => ) })} - {schemas?.some((schema) => schema.name === "User" && schema.permissions?.create) && ( + {Boolean(inviteRoles.data?.length) && ( Invite user )} {appSchemas.length === 0 ? ( diff --git a/crates/schema-forge-cli/templates/site/src/generated/invites.ts.jinja b/crates/schema-forge-cli/templates/site/src/generated/invites.ts.jinja index 4b121466..b91bd266 100644 --- a/crates/schema-forge-cli/templates/site/src/generated/invites.ts.jinja +++ b/crates/schema-forge-cli/templates/site/src/generated/invites.ts.jinja @@ -8,7 +8,8 @@ export type InviteRequest = { tenant_type?: string tenant_id?: string } -export type InviteResponse = { invite_id: string; email: string; expires_at: string } +export type StoredInvite = { invite_id: string; accept_url: string } +export type InviteResponse = StoredInvite & { email: string; expires_at: string; delivery: "smtp" | "link" } export type Role = { name: string; rank: number } export type Tenant = { id: string; label: string } @@ -62,6 +63,14 @@ export async function acceptInvite(body: { invite_id: string; password: string; return response.json() as Promise<{ email: string }> } +/** Recover a persisted invitation from the SMTP failure response. */ +export function storedInviteFromError(error: unknown): StoredInvite | undefined { + if (!(error instanceof ApiError) || error.status !== 502 || typeof error.body !== "object" || error.body === null) return undefined + const body = error.body as { error?: unknown; invite_id?: unknown; accept_url?: unknown } + if (body.error !== "invite_delivery_failed" || typeof body.invite_id !== "string" || !body.invite_id || typeof body.accept_url !== "string" || !body.accept_url) return undefined + return { invite_id: body.invite_id, accept_url: body.accept_url } +} + export function inviteError(error: unknown, context: "send" | "accept" = "send"): string { if (error instanceof ApiError) { if (context === "accept" && (error.status === 401 || error.status === 404 || error.status === 410)) return "This invitation is invalid, expired, or already used. Ask your administrator for a new invitation." diff --git a/crates/schema-forge-cli/templates/site/src/pages/invite.tsx.jinja b/crates/schema-forge-cli/templates/site/src/pages/invite.tsx.jinja index 73766ecf..72386229 100644 --- a/crates/schema-forge-cli/templates/site/src/pages/invite.tsx.jinja +++ b/crates/schema-forge-cli/templates/site/src/pages/invite.tsx.jinja @@ -1,20 +1,31 @@ import { useState, type FormEvent } from "react" import { useMutation, useQuery } from "@tanstack/react-query" import { listSchemas } from "@/generated/api-client" -import { createInvite, inviteError, isTenantRoot, listInviteRoles, listWritableTenants } from "@/generated/invites" +import { createInvite, inviteError, isTenantRoot, listInviteRoles, listWritableTenants, storedInviteFromError } from "@/generated/invites" import { useDocumentTitle } from "@/lib/use-document-title" export function InvitePage() { useDocumentTitle("Invite user") const [tenantType, setTenantType] = useState("") const [tenantId, setTenantId] = useState("") + const [copyStatus, setCopyStatus] = useState("") const schemas = useQuery({ queryKey: ["schemas"], queryFn: listSchemas }) const roles = useQuery({ queryKey: ["invite-roles"], queryFn: listInviteRoles }) const tenants = useQuery({ queryKey: ["invite-tenants", tenantType], queryFn: () => listWritableTenants(tenantType), enabled: Boolean(tenantType) }) const mutation = useMutation({ mutationFn: createInvite }) - const canInvite = schemas.data?.some((schema) => schema.name === "User" && schema.permissions?.create) + const deliveryFailed = storedInviteFromError(mutation.error) + const result = mutation.data ?? deliveryFailed const loading = schemas.isPending || roles.isPending - const error = schemas.error ?? roles.error ?? tenants.error ?? mutation.error + const error = schemas.error ?? roles.error ?? tenants.error ?? (deliveryFailed ? undefined : mutation.error) + async function copyLink() { + if (!result) return + try { + await navigator.clipboard.writeText(result.accept_url) + setCopyStatus("Link copied.") + } catch { + setCopyStatus("Select the invitation link and copy it to share.") + } + } function submit(event: FormEvent) { event.preventDefault() const form = new FormData(event.currentTarget) @@ -27,13 +38,23 @@ export function InvitePage() { } return

Invite user

-

The recipient will receive an email to choose a password and activate their account.

+

Create an invitation so the recipient can choose a password and activate their account.

{loading &&

Loading invitation options…

} - {!loading && !canInvite &&

You do not have permission to invite users.

} {error &&

{inviteError(error)}

} - {mutation.data ?

Invitation sent to {mutation.data.email}. It expires {new Date(mutation.data.expires_at).toLocaleString()}.

: + {result ?
+

{deliveryFailed + ? "Invitation created, but the email could not be sent. Share this link with the recipient." + : mutation.data?.delivery === "link" + ? `Invitation created for ${mutation.data.email}. Share this link with the recipient.` + : `Invitation sent to ${mutation.data?.email}.`} + {mutation.data && ` It expires ${new Date(mutation.data.expires_at).toLocaleString()}.`}

+ + event.currentTarget.select()} /> + + {copyStatus &&

{copyStatus}

} +
:
-
+
diff --git a/crates/schema-forge-cli/tests/site_e2e/playwright/tests/invites.spec.ts b/crates/schema-forge-cli/tests/site_e2e/playwright/tests/invites.spec.ts index 110ed27b..ce3936f7 100644 --- a/crates/schema-forge-cli/tests/site_e2e/playwright/tests/invites.spec.ts +++ b/crates/schema-forge-cli/tests/site_e2e/playwright/tests/invites.spec.ts @@ -53,7 +53,7 @@ test("operator can invite with only writable tenant choices", async ({ page }) = let body: unknown await page.route("**/api/v1/forge/auth/invites", async (route) => { body = route.request().postDataJSON() - await route.fulfill({ status: 201, json: { invite_id: "issued", email: "invitee@example.com", expires_at: "2030-01-01T00:00:00Z" } }) + await route.fulfill({ status: 201, json: { invite_id: "issued", email: "invitee@example.com", expires_at: "2030-01-01T00:00:00Z", delivery: "smtp", accept_url: "https://app.example.com/invite/accept?invite=issued" } }) }) await page.goto("/admin/users/invite") await page.getByLabel("Email", { exact: true }).fill("invitee@example.com") @@ -65,3 +65,34 @@ test("operator can invite with only writable tenant choices", async ({ page }) = await expect(page.getByRole("status")).toContainText("Invitation sent to invitee@example.com") expect(body).toEqual({ email: "invitee@example.com", role: "member", tenant_type: "Organization", tenant_id: "writable" }) }) + +for (const mode of ["link", "failed"] as const) { + test(`operator can share a stored invitation after ${mode} delivery`, async ({ page }) => { + await page.addInitScript(() => { + sessionStorage.setItem("schemaforge.token", "owner-session") + sessionStorage.setItem("schemaforge.token_expires_at", new Date(Date.now() + 3600000).toISOString()) + }) + await page.route("**/api/v1/forge/schemas", (route) => route.fulfill({ json: { schemas: [ + { name: "User", annotations: [{ annotation: "System" }], permissions: { create: false } }, + ] } })) + await page.route("**/api/v1/forge/users/roles", (route) => route.fulfill({ json: { roles: [{ name: "member", rank: 1 }] } })) + await page.route("**/api/v1/forge/auth/me", (route) => route.fulfill({ json: { username: "owner", roles: ["owner"], memberships: [] } })) + const acceptUrl = "https://app.example.com/invite/accept?invite=stored-reference" + await page.route("**/api/v1/forge/auth/invites", (route) => route.fulfill({ + status: mode === "link" ? 201 : 502, + json: mode === "link" + ? { invite_id: "stored-reference", email: "invitee@example.com", expires_at: "2030-01-01T00:00:00Z", delivery: "link", accept_url: acceptUrl } + : { error: "invite_delivery_failed", message: "Invitation created, but email delivery failed.", invite_id: "stored-reference", delivery: "failed", accept_url: acceptUrl }, + })) + await page.goto("/admin/users/invite") + await expect(page.getByRole("link", { name: "Invite user", exact: true })).toBeVisible() + await page.getByLabel("Email", { exact: true }).fill("invitee@example.com") + await page.getByRole("button", { name: "Send invitation" }).click() + await expect(page.getByRole("status")).toContainText("Share this link with the recipient") + await expect(page.getByLabel("Invitation link")).toHaveValue(acceptUrl) + await expect(page.getByLabel("Invitation link")).toHaveAttribute("readonly", "") + await expect(page.getByRole("button", { name: "Copy link" })).toBeVisible() + await expect(page.getByRole("button", { name: "Send invitation" })).toHaveCount(0) + await expect(page.getByRole("alert")).toHaveCount(0) + }) +} diff --git a/crates/schema-forge-mssql/Cargo.toml b/crates/schema-forge-mssql/Cargo.toml index 081dc42e..7add0df3 100644 --- a/crates/schema-forge-mssql/Cargo.toml +++ b/crates/schema-forge-mssql/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "schema-forge-mssql" -version = "0.7.0" +version = "0.8.0" edition = "2021" [dependencies] acton-service = { version = "0.45.0", features = ["mssql", "crypto-aws-lc-rs"] } bb8 = "0.9.1" bb8-tiberius = "0.16.0" -schema-forge-backend = { version = "0.20.0", path = "../schema-forge-backend" } +schema-forge-backend = { version = "0.21.0", path = "../schema-forge-backend" } schema-forge-core = { version = "0.19.0", path = "../schema-forge-core" } serde_json = "1.0.151" tiberius = "0.12.3" diff --git a/crates/schema-forge-mssql/src/backend.rs b/crates/schema-forge-mssql/src/backend.rs index 44ca20c5..b99af35d 100644 --- a/crates/schema-forge-mssql/src/backend.rs +++ b/crates/schema-forge-mssql/src/backend.rs @@ -343,6 +343,38 @@ impl SchemaBackend for MssqlBackend { } impl EntityStore for MssqlBackend { + async fn create_with_membership( + &self, + entity: &Entity, + membership: &Entity, + ) -> Result { + let user = + schema_forge_backend::onboarding::validate_creator_membership(entity, membership)?; + let root_data = serde_json::to_string(&entity.fields).map_err(json_error)?; + let membership_data = serde_json::to_string(&membership.fields).map_err(json_error)?; + let statements = format!( + "IF NOT EXISTS (SELECT 1 FROM [User] WITH (UPDLOCK, HOLDLOCK) WHERE [id]=@P1) THROW 50003, 'membership user does not exist', 1; \ + INSERT INTO {} ([id], [data]) VALUES (@P2, @P3); \ + INSERT INTO {} ([id], [data]) VALUES (@P4, @P5);", + quote(entity.schema.as_str()), quote(membership.schema.as_str()), + ); + let mut connection = connection(&self.pool).await?; + connection + .execute( + transaction_batch(&statements), + &[ + &user.as_str(), + &entity.id.as_str(), + &root_data.as_str(), + &membership.id.as_str(), + &membership_data.as_str(), + ], + ) + .await + .map_err(query_error)?; + Ok(entity.clone()) + } + async fn create(&self, entity: &Entity) -> Result { let data = serde_json::to_string(&entity.fields).map_err(json_error)?; let sql = format!( diff --git a/crates/schema-forge-mssql/tests/sql_server.rs b/crates/schema-forge-mssql/tests/sql_server.rs index 4dc25a82..bbb57fe2 100644 --- a/crates/schema-forge-mssql/tests/sql_server.rs +++ b/crates/schema-forge-mssql/tests/sql_server.rs @@ -4,6 +4,9 @@ mod migration_renames; #[path = "../../schema-forge-backend/tests/support/data_correctness.rs"] mod data_correctness; +#[path = "../../schema-forge-backend/tests/support/creator_membership.rs"] +mod creator_membership; + use std::collections::BTreeMap; use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; @@ -62,6 +65,7 @@ async fn connects_and_initializes_metadata(image_tag: &str) { .expect("list initialized metadata") .is_empty()); + creator_membership::exercise(&backend).await; sparse_updates_preserve_other_fields_and_explicit_null(&backend).await; atomic_metadata_failure_rolls_back_rename(&backend).await; exercises_backend_contract(&backend).await; diff --git a/crates/schema-forge-postgres/Cargo.toml b/crates/schema-forge-postgres/Cargo.toml index 34f68226..d3b333f5 100644 --- a/crates/schema-forge-postgres/Cargo.toml +++ b/crates/schema-forge-postgres/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-postgres" -version = "0.15.0" +version = "0.16.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-postgres/src/backend.rs b/crates/schema-forge-postgres/src/backend.rs index 04269fe2..7bf1a1c5 100644 --- a/crates/schema-forge-postgres/src/backend.rs +++ b/crates/schema-forge-postgres/src/backend.rs @@ -284,6 +284,24 @@ impl PgBackend { Ok(()) } + pub(crate) async fn require_membership_user( + connection: &mut sqlx::PgConnection, + user: &EntityId, + ) -> Result<(), BackendError> { + let exists = sqlx::query("SELECT id FROM \"User\" WHERE id=$1 FOR KEY SHARE") + .bind(user.as_str()) + .fetch_optional(connection) + .await + .map_err(|error| map_write_error(error, "User", "verify membership user"))?; + if exists.is_none() { + return Err(BackendError::EntityNotFound { + schema: "User".into(), + entity_id: user.to_string(), + }); + } + Ok(()) + } + pub(crate) async fn insert_with_revision( connection: &mut sqlx::PgConnection, entity: &Entity, @@ -819,6 +837,41 @@ impl EntityStore for PgBackend { self.process_create_intent(request).await } + async fn create_intent_with_membership( + &self, + request: &schema_forge_backend::create_intent::CreateIntentRequest, + membership: &Entity, + ) -> Result< + schema_forge_backend::create_intent::CreateIntentReceipt, + schema_forge_backend::create_intent::CreateIntentError, + > { + self.process_create_intent_with_membership(request, Some(membership)) + .await + } + + async fn create_with_membership( + &self, + entity: &Entity, + membership: &Entity, + ) -> Result { + let user = + schema_forge_backend::onboarding::validate_creator_membership(entity, membership)?; + let schema = self.load_schema_metadata(&entity.schema).await?; + let membership_schema = self.load_schema_metadata(&membership.schema).await?; + let mut tx = self.pool.begin().await.map_err(|error| { + map_write_error(error, entity.schema.as_str(), "begin creator membership") + })?; + Self::require_membership_user(&mut tx, user).await?; + let created = Self::insert_with_revision(&mut tx, entity, schema.as_ref()) + .await? + .entity; + Self::insert_with_revision(&mut tx, membership, membership_schema.as_ref()).await?; + tx.commit().await.map_err(|error| { + map_write_error(error, entity.schema.as_str(), "commit creator membership") + })?; + Ok(created) + } + async fn create(&self, entity: &Entity) -> Result { let schema_def = self.load_schema_metadata(&entity.schema).await?; let mut tx = self diff --git a/crates/schema-forge-postgres/src/create_intent.rs b/crates/schema-forge-postgres/src/create_intent.rs index e5f2d509..1eebe548 100644 --- a/crates/schema-forge-postgres/src/create_intent.rs +++ b/crates/schema-forge-postgres/src/create_intent.rs @@ -5,7 +5,7 @@ use schema_forge_backend::{ CreateFingerprint, CreateIntentError as Error, CreateIntentId, CreateIntentReceipt, CreateIntentRequest, CreateIntentScope, ADMISSION_SECONDS, RECOVERY_SECONDS, }, - BackendError, + BackendError, Entity, }; use schema_forge_core::types::EntityId; use sqlx::{postgres::PgRow, Row}; @@ -58,6 +58,15 @@ impl PgBackend { pub(crate) async fn process_create_intent( &self, request: &CreateIntentRequest, + ) -> Result { + self.process_create_intent_with_membership(request, None) + .await + } + + pub(crate) async fn process_create_intent_with_membership( + &self, + request: &CreateIntentRequest, + membership: Option<&Entity>, ) -> Result { let mut tx = self.pool().begin().await.map_err(storage)?; sqlx::query("SET LOCAL lock_timeout = '3s'") @@ -121,9 +130,35 @@ impl PgBackend { if entity.schema != scope.schema.name { return Err(Error::Invalid); } + if let Some(membership) = membership { + let user = + schema_forge_backend::onboarding::validate_creator_membership( + entity, membership, + )?; + Self::require_membership_user(&mut tx, user).await?; + } let created = Self::insert_with_revision(&mut tx, entity, Some(&scope.schema)) .await?; + if let Some(membership) = membership { + let definition: Option = sqlx::query_scalar( + "SELECT definition FROM _schema_metadata WHERE name=$1 FOR SHARE", + ) + .bind(membership.schema.as_str()) + .fetch_optional(&mut *tx) + .await + .map_err(storage)?; + let definition = definition + .map( + serde_json::from_value::< + schema_forge_core::types::SchemaDefinition, + >, + ) + .transpose() + .map_err(|_| Error::Invalid)?; + Self::insert_with_revision(&mut tx, membership, definition.as_ref()) + .await?; + } sqlx::query("UPDATE _schema_create_intents SET entity_id=$2 WHERE id=$1") .bind(id.as_str()) .bind(created.entity.id.as_str()) diff --git a/crates/schema-forge-postgres/tests/create_intent.rs b/crates/schema-forge-postgres/tests/create_intent.rs index b3c09598..dbacf417 100644 --- a/crates/schema-forge-postgres/tests/create_intent.rs +++ b/crates/schema-forge-postgres/tests/create_intent.rs @@ -20,6 +20,9 @@ use schema_forge_postgres::PgBackend; use sqlx::postgres::PgPoolOptions; use tokio::sync::Barrier; +#[path = "../../schema-forge-backend/tests/support/creator_membership.rs"] +mod creator_membership; + fn definition() -> SchemaDefinition { SchemaDefinition::new( SchemaId::new(), @@ -88,6 +91,8 @@ async fn atomic_create_receipts() { } async fn exercise(backend: Arc) { + creator_membership::exercise(backend.as_ref()).await; + atomic_creator_intents(backend.as_ref()).await; let mut schema = definition(); schema.fields[0] .modifiers @@ -301,3 +306,101 @@ async fn exercise(backend: Arc) { Err(CreateIntentError::SchemaChanged) )); } + +async fn atomic_creator_intents(backend: &PgBackend) { + let user_schema = SchemaName::new("User").unwrap(); + let user = backend + .create(&Entity::new( + user_schema, + BTreeMap::from([( + "email".into(), + DynamicValue::Text("intent-owner@example.org".into()), + )]), + )) + .await + .unwrap(); + let root = creator_membership::root("intent root"); + let scope = CreateIntentScope { + principal: user.id.to_string(), + tenant: String::new(), + schema: backend + .load_schema_metadata(&root.schema) + .await + .unwrap() + .unwrap(), + }; + let fingerprint = CreateFingerprint::parse("c".repeat(64)).unwrap(); + let pending = backend + .create_intent(&CreateIntentRequest::Reserve { + scope: scope.clone(), + fingerprint: fingerprint.clone(), + }) + .await + .unwrap(); + let commit = CreateIntentRequest::Commit { + scope: scope.clone(), + id: pending.id.clone(), + fingerprint, + entity: root.clone(), + }; + let read = CreateIntentRequest::Read { + scope, + id: pending.id, + }; + let grant = creator_membership::membership(&root, &user); + // Occupy this membership ID so the failure occurs after inserting the root. + backend.create(&grant).await.unwrap(); + assert!(backend + .create_intent_with_membership(&commit, &grant) + .await + .is_err()); + assert!(backend.get(&root.schema, &root.id).await.is_err()); + assert!(backend + .create_intent(&read) + .await + .unwrap() + .entity_id + .is_none()); + let revisions: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM _schema_entity_revisions WHERE schema_name=$1 AND entity_id=$2", + ) + .bind(root.schema.as_str()) + .bind(root.id.as_str()) + .fetch_one(backend.pool()) + .await + .unwrap(); + assert_eq!(revisions, 0); + backend.delete(&grant.schema, &grant.id).await.unwrap(); + let receipt = backend + .create_intent_with_membership(&commit, &grant) + .await + .unwrap(); + assert!(receipt.created); + assert_eq!(receipt.entity_id, Some(root.id.clone())); + let revision = backend + .get_versioned(&grant.schema, &grant.id) + .await + .unwrap() + .revision; + // Even newly minted retry IDs must reconcile without creating another grant. + let retry_grant = creator_membership::membership(&root, &user); + let replay = backend + .create_intent_with_membership(&commit, &retry_grant) + .await + .unwrap(); + assert!(!replay.created); + assert_eq!(replay.entity_id, receipt.entity_id); + assert!(backend + .get(&retry_grant.schema, &retry_grant.id) + .await + .is_err()); + assert_eq!( + backend + .get_versioned(&grant.schema, &grant.id) + .await + .unwrap() + .revision, + revision + ); + backend.get_versioned(&root.schema, &root.id).await.unwrap(); +} diff --git a/crates/schema-forge-postgres/tests/data_correctness.rs b/crates/schema-forge-postgres/tests/data_correctness.rs index e73a2aad..e80c7755 100644 --- a/crates/schema-forge-postgres/tests/data_correctness.rs +++ b/crates/schema-forge-postgres/tests/data_correctness.rs @@ -52,3 +52,13 @@ where admin.close().await; result.unwrap(); } + +#[path = "../../schema-forge-backend/tests/support/creator_membership.rs"] +mod creator_membership; + +#[tokio::test] +#[ignore = "requires SCHEMAFORGE_TEST_POSTGRES_URL with CREATE SCHEMA privilege"] +async fn creator_membership_is_atomic() { + with_database(|backend| async move { creator_membership::exercise(backend.as_ref()).await }) + .await; +} diff --git a/crates/schema-forge-surrealdb/Cargo.toml b/crates/schema-forge-surrealdb/Cargo.toml index f7f0462c..a684f0f2 100644 --- a/crates/schema-forge-surrealdb/Cargo.toml +++ b/crates/schema-forge-surrealdb/Cargo.toml @@ -1,7 +1,7 @@ [package] rust-version = "1.97.1" name = "schema-forge-surrealdb" -version = "0.15.0" +version = "0.16.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-surrealdb/src/backend.rs b/crates/schema-forge-surrealdb/src/backend.rs index 76392e4a..d3467eef 100644 --- a/crates/schema-forge-surrealdb/src/backend.rs +++ b/crates/schema-forge-surrealdb/src/backend.rs @@ -705,6 +705,54 @@ fn parse_and_sanitize_definition( } impl EntityStore for SurrealBackend { + async fn create_with_membership( + &self, + entity: &Entity, + membership: &Entity, + ) -> Result { + let user = + schema_forge_backend::onboarding::validate_creator_membership(entity, membership)?; + let root_fields = self.build_field_assignments(entity).await?; + let membership_fields = self.build_field_assignments(membership).await?; + let create = |record: &Entity, fields: &str| { + let suffix = if fields.is_empty() { + String::new() + } else { + format!(" SET {fields}") + }; + format!("CREATE {}:`{}`{suffix};", record.schema, record.id) + }; + let sql = format!( + "BEGIN TRANSACTION; \ + IF !record::exists(User:`{user}`) {{ THROW 'membership user does not exist'; }}; \ + {} {} COMMIT TRANSACTION;", + create(entity, &root_fields), + create(membership, &membership_fields), + ); + let mut response = self.execute_raw(&sql).await?.check().map_err(|error| { + reclassify_unique_violation( + BackendError::QueryError { + message: error.to_string(), + }, + entity.schema.as_str(), + ) + })?; + // BEGIN and the User existence check precede the root's CREATE result. + let value: surrealdb::types::Value = + response.take(2).map_err(|error| BackendError::QueryError { + message: error.to_string(), + })?; + let root = match value { + surrealdb::types::Value::Array(rows) => rows.into_inner().into_iter().next(), + surrealdb::types::Value::None | surrealdb::types::Value::Null => None, + root => Some(root), + } + .ok_or_else(|| BackendError::Internal { + message: "atomic root creation returned no entity".into(), + })?; + surreal_row_to_entity(&entity.schema, &root) + } + async fn create(&self, entity: &Entity) -> Result { let table = entity.schema.as_str(); let id_str = entity.id.as_str(); diff --git a/crates/schema-forge-surrealdb/tests/creator_membership.rs b/crates/schema-forge-surrealdb/tests/creator_membership.rs new file mode 100644 index 00000000..866af62e --- /dev/null +++ b/crates/schema-forge-surrealdb/tests/creator_membership.rs @@ -0,0 +1,11 @@ +#[path = "../../schema-forge-backend/tests/support/creator_membership.rs"] +mod contract; + +#[tokio::test] +async fn creator_membership_is_atomic() { + let backend = + schema_forge_surrealdb::SurrealBackend::connect_memory("onboarding", "onboarding") + .await + .unwrap(); + contract::exercise(&backend).await; +} diff --git a/docs/invitations-reference.md b/docs/invitations-reference.md index 042d4626..ace7ca01 100644 --- a/docs/invitations-reference.md +++ b/docs/invitations-reference.md @@ -1,170 +1,135 @@ -# User invitations & onboarding reference +# User invitations and onboarding reference -Invite a person into a SchemaForge deployment by email, let them set their own password, and provision their account and tenant membership on acceptance. This document covers the two HTTP endpoints, the authorization model, the SMTP/email configuration (including how the password is supplied without ever touching git), and the `project_name` branding that controls what the invitee sees. Three readers — operators wiring SMTP for a deployment, integrators calling the invite endpoints from a console or script, and security auditors tracing the trust boundaries — should jump by heading. Scope is configuration, the wire contract, and security properties. - -## At a glance - -| | | -|---|---| -| Issue an invite | `POST /api/v1/forge/auth/invites` — **authenticated** | -| Accept an invite | `POST /api/v1/forge/auth/invites/accept` — **public** | -| Token | PASETO v4.local, `purpose = "invite"`, 7-day expiry | -| Email | `[schema_forge.email]`, disabled by default; SMTP password via env only | -| Branding | `[schema_forge] project_name` | -| Audit events | `forge.invite.created`, `forge.invite.accepted`, `forge.invite.rejected`, `forge.invite.send_failed`, `forge.access.denied` | - -The account is created **on acceptance**, not at invite time — see [Why create the account at accept](#why-create-the-account-at-accept). The invitation row is the pending state; no half-provisioned, password-less account ever sits in the user table. +Invite a person into a SchemaForge deployment, let them set their password, and provision their account and tenant membership when they accept. Operators can deliver the link through SMTP or share it themselves. ## Issuing an invitation -`POST /api/v1/forge/auth/invites` — requires a valid bearer token. - -Request: +`POST /api/v1/forge/auth/invites` requires a bearer token and explicit `Action::"InviteUser"` permission. ```json { "email": "newuser@agency.gov", "display_name": "New User", "tenant_type": "Organization", - "tenant_id": "01H...", + "tenant_id": "entity_...", "role": "member" } ``` -| Field | Required | Meaning | -|---|---|---| -| `email` | yes | Invitee address; becomes `User.email`, the login identifier. Structurally validated (one `@`, a dotted domain, no whitespace, ≤ 512 chars). | -| `display_name` | no | Seeded onto the future account. | -| `tenant_type` | no | Tenant **root** type the invitee joins (e.g. `"Organization"`). Tenancy is polymorphic — this is whatever schema is annotated `@tenant(root)`. | -| `tenant_id` | no | Tenant root entity id. | -| `role` | no | Role granted to the invitee — used as **both** their `User` role and their `TenantMembership` role. | +`email` is required and becomes the login identifier. `display_name` and `role` are optional. The role is stored on both the future User and TenantMembership. A tenant target requires both `tenant_type` and `tenant_id`; the type must be a configured tenant schema. Non-platform callers must have the exact target in their effective tenant chain, including any active-tenant narrowing. Invalid or incomplete targets return 422; targets outside the caller's chain return 403. -Success — `201 Created`: +Success returns `201 Created`: ```json { - "invite_id": "Hk9c...opaque...", + "invite_id": "opaque-reference", "email": "newuser@agency.gov", - "expires_at": "2026-06-04T18:22:11Z" + "expires_at": "2026-10-13T18:22:11Z", + "delivery": "link", + "accept_url": "https://app.agency.gov/invite/accept?invite=opaque-reference" } ``` -`invite_id` is the opaque, high-entropy reference that is emailed to the invitee. It is the PASETO's token id (`jti`); the full token is **never** placed in the email — only this reference is. - -Failure modes: +`delivery` is `smtp` when the relay accepted the email, or `link` when the caller should share `accept_url`. The opaque reference is the PASETO token id (`jti`). The full token stays on the server. The invitation expires after seven days. | Status | Cause | |---|---| -| `401` | No / invalid bearer token. | -| `403` | The caller lacks `Create` on the user schema, or the requested `role` would exceed the caller's own role rank (see [Authorization](#authorization)). Emits `forge.access.denied`. | -| `422` | `email` failed validation, or an account already exists for that address. | -| `5xx` | The invite row was written but **email delivery failed**. The row stays `Pending` and can be re-sent once SMTP is healthy; emits `forge.invite.send_failed`. | - -The order is deliberate: the invitation is persisted **before** the email is sent, so the accept link always resolves to a row. Delivery failure is surfaced (fail-closed) rather than swallowed. - -## Accepting an invitation +| 401 | Missing or invalid bearer token. | +| 403 | Missing InviteUser permission, unauthorized tenant, upward role grant, or unauthorized platform_admin grant. | +| 422 | Invalid email or tenant target, or an account already exists for the address. | +| 502 with `invite_delivery_failed` | The invitation was stored, but SMTP delivery failed. | -`POST /api/v1/forge/auth/invites/accept` — **public** (no bearer token; the invite token *is* the credential). - -Request: +SMTP delivery failures have a recoverable response: ```json { - "invite_id": "Hk9c...opaque...", - "password": "the-invitee-chosen-password", - "display_name": "Optional Override" + "error": "invite_delivery_failed", + "message": "Invitation created, but email delivery failed. Share the accept link to complete onboarding.", + "invite_id": "opaque-reference", + "accept_url": "https://app.agency.gov/invite/accept?invite=opaque-reference", + "delivery": "failed" } ``` -`password` is validated against the same policy as `POST /users`. `display_name` falls back to the invite's value, then to the email. +The invitation remains Pending and its link is valid. Share that link through another channel. Creating a second invitation is unnecessary. SMTP diagnostics stay in server audit logs and are excluded from the response. -Success — `201 Created`: +## Authorization and policy migration -```json -{ - "email": "newuser@agency.gov", - "roles": ["member"] -} +Inviting users and directly creating accounts are separate capabilities. `CreateUser`, including permission derived from User's `@access(write: [...])`, no longer authorizes invitations. Existing custom invitation policies must explicitly grant `InviteUser` after upgrading. Platform administrators retain their global permit. Granting `InviteUser` alone does not allow `POST /users`. + +A tenant owner can receive invitation permission without general User administration: + +```cedar +permit ( + principal in Forge::Group::"owner", + action == Action::"InviteUser", + resource is User +) when { + resource has "_tenant" && principal in resource["_tenant"] +}; ``` -What happens, in order: +`GET /api/v1/forge/users/roles` keeps the full catalog for callers with ListUser permission. Invitation-only callers receive only roles that their InviteUser policy permits in their active tenant, including the role-rank guard. This role picker does not grant User listing or creation. -1. Look the row up by `invite_id`. If it is not `Pending` or has expired → `422`, emits `forge.invite.rejected`. -2. Reconstruct the full PASETO from the stored `token` column and re-verify it cryptographically. **The signed claims are authoritative** over the database columns (defense-in-depth against DB tampering): role and tenant are read from the verified token, not the row. -3. Refuse if an account now exists for the address (`409 Conflict`, `user_exists`). -4. Create the `User`, then add the `TenantMembership` (if a tenant was scoped), then mark the invitation `Consumed` — **last**, so a partial failure leaves the link retryable. +Declare `owner` and invited roles in `policies/role_ranks.toml`. The resource is a proposed User with the invitee's computed `role_rank` and target `_tenant` reference, so custom policies can inspect the granted rank and tenant. The endpoint evaluates that concrete resource directly, allowing policies scoped to the proposed target. A placeholder preflight is unnecessary. -A replayed link (already `Consumed`) is rejected with `422`. +The global User-management rank forbid includes `InviteUser`: a caller cannot invite a role above their rank even if another policy grants Invite. Only an existing platform administrator can grant `platform_admin`. Configured-tenant and caller-chain validation happens before token minting or persistence. -## Authorization +## Delivery configuration -The privilege checks run **at invite time**, against the proposed role, so deferring account creation to accept does not weaken authorization. An invite can never confer access the inviter could not grant directly. Three guards, mirroring `POST /users` exactly: +SMTP remains the default delivery mode. `enabled` controls SMTP only; selecting `link` bypasses SMTP whether `enabled` is true or false. A link deployment requires no mail host, sender address, credentials, or SMTP service: -1. **Schema access** — `check_schema_access(Create)` on the user schema. -2. **Role-grant guard** — `caller_can_grant_roles`: only a `platform_admin` may grant `platform_admin`. -3. **Cedar rank guard** — `authorize(Create, User, )` against `role_ranks.toml`; inviting a role above the caller's own rank is denied. +```toml +[schema_forge.email] +delivery = "link" +enabled = false +public_base_url = "https://app.agency.gov" +``` -## Email configuration +In link mode, `public_base_url` must be an absolute HTTP(S) URL with a host and no credentials, query, or fragment. Configuration validation rejects missing or malformed values before issuing an invitation. -`[schema_forge.email]` — **disabled by default**. When disabled, the invite endpoints fail closed with a clear "email not configured" error rather than silently dropping mail. +For SMTP delivery: ```toml [schema_forge.email] +delivery = "smtp" # default enabled = true host = "mail.agency.gov" -port = 465 # 465 = implicit TLS (default); 587 = STARTTLS -tls = "implicit" # or "start_tls" +port = 465 # implicit TLS default +tls = "implicit" # or "start_tls", usually port 587 from = "noreply@agency.gov" username = "noreply@agency.gov" public_base_url = "https://app.agency.gov" ``` -| Field | Default | Notes | -|---|---|---| -| `enabled` | `false` | Master switch. | -| `host` | — | SMTP relay hostname. Required when enabled. | -| `port` | `465` | | -| `tls` | `implicit` | `implicit` (SMTPS, port 465) or `start_tls` (port 587). | -| `from` | — | `From` mailbox. Required when enabled. A bare address is branded with `project_name` (see below); embed a display name here — `"Agency "` — to override. | -| `username` | — | SMTP AUTH user. Omit for an unauthenticated relay. | -| `password` | — | **Never** written here — see below. | -| `public_base_url` | — | Used to build the absolute accept link, `{public_base_url}/invite/accept?invite={invite_id}`. Without it the link is site-relative. | +`host` and `from` are required when SMTP is enabled. Credentials are optional for unauthenticated relays. Supply the password through `SCHEMAFORGE_SMTP_PASSWORD`; keep it out of committed configuration. This dedicated variable supports the existing `SCHEMAFORGE_*` convention because the framework's underscore-split `ACTON_` environment mapping cannot address `schema_forge` reliably. -TLS uses the workspace `aws-lc-rs` rustls provider (FIPS-aligned, no `ring`). +SMTP with `enabled = false` still stores the invitation, then returns the typed delivery failure with its accept link. SMTP retains the site-relative link fallback when `public_base_url` is absent; configure an absolute URL for links sent outside the application. -### SMTP password — environment only +## Accepting an invitation -The SMTP password is **never** read from `config.toml` and must never be committed. Supply it at runtime through the `SCHEMAFORGE_SMTP_PASSWORD` environment variable: +`POST /api/v1/forge/auth/invites/accept` is public. Possession of the unexpired, unused invitation reference authorizes acceptance: -```sh -SCHEMAFORGE_SMTP_PASSWORD="$(rbw get 'smtp-relay')" schemaforge serve +```json +{ + "invite_id": "opaque-reference", + "password": "the-invitee-chosen-password", + "display_name": "Optional Override" +} ``` -acton-service's `ACTON_`-prefixed Figment env layering cannot address the `[schema_forge]` section (its `Env::split("_")` shatters the underscore in the section key), so this dedicated variable — matching the existing `SCHEMAFORGE_*` convention used for the token key and trust policy — is the supported path. `serve` reads it and fills `EmailConfig.password` before constructing the transport. +Password validation matches the account-creation policy. The server finds the Pending row, verifies its stored PASETO, and uses the signed role and tenant claims to create the User and optional TenantMembership. It consumes the invitation last. Account and membership writes remain separate operations; partial acceptance failures may require operator recovery. An existing account returns 409; expired or consumed links return 422. -## Branding: `project_name` +Success returns 201 with the created email and roles: -```toml -[schema_forge] -project_name = "Bob's Dog Scheduling" +```json +{ "email": "newuser@agency.gov", "roles": ["member"] } ``` -`project_name` (default `"SchemaForge"`) is the human-facing name of the deployment. An onboarding user should see the application they are joining, not the engine. It drives: - -- **The invitation email body** — "You have been invited to join *Bob's Dog Scheduling*." -- **The invitation email subject** — "You've been invited to *Bob's Dog Scheduling*". -- **The `From` display-name**, when `from` is a bare address — recipients see `Bob's Dog Scheduling `. An explicit display name in `from` is respected verbatim, so operators keep exact control for deliverability. - -`schemaforge init ` seeds `project_name` from the project name into the generated `config.toml`, so the name carries from scaffold into runtime. Edit it any time. - -## Security properties +## Branding and security properties -- **Store, not schema.** Invitations live in an internal `ForgeInvitation` table that is provisioned at boot but **never inserted into the `SchemaRegistry`**. Because `/schemas` and every entity route resolve through that registry, the table — and the token material it holds — is unreachable through the public entity API. -- **Single-use, expiring.** 7-day TTL on both the PASETO `exp` and the stored `expires_at`; consumption flips the status so a replayed link is rejected. -- **Signed claims authoritative.** On accept, role and tenant come from the cryptographically verified token, not from mutable DB columns. -- **Fail-closed delivery.** A send failure is a `5xx` with the invite left `Pending`; it never reports success on undelivered mail. -- **No secret on disk.** The SMTP password enters only through the environment. +`[schema_forge] project_name` brands invitation email subjects, bodies, and a bare `from` address's display name. An explicit mailbox display name overrides that default. `schemaforge init` seeds this value from the project name. -## Tenant delegation +Invitations live in the internal ForgeInvitation store, outside the public schema registry and entity CRUD routes. Signed claims are authoritative over stored mirror columns. References expire after seven days and are consumed on acceptance. Treat returned links as credentials and share them only with the intended recipient. -An invitation with a tenant target must supply both `tenant_type` and `tenant_id`. The type must be a configured tenant schema. Before creating the signed invitation or sending email, the server checks that the pair belongs to the caller's effective tenant chain, including any active-tenant narrowing. A cross-tenant target returns 403. Platform administrators may invite into any configured tenant type; incomplete or unknown targets return 422. +Audit events include `forge.invite.created`, `forge.invite.accepted`, `forge.invite.rejected`, `forge.invite.send_failed`, and `forge.access.denied`. diff --git a/docs/oauth-login.md b/docs/oauth-login.md index 7c3f1cbf..33066c65 100644 --- a/docs/oauth-login.md +++ b/docs/oauth-login.md @@ -93,7 +93,16 @@ but is never used to attach a new provider to an existing account. A matching email without a link returns 409; an administrator must arrange that link. Existing links continue to resolve if a provider's verified email changes. -For open signup, new accounts receive `default_roles`. Invitation-only signup +For open signup, new accounts receive `default_roles`. With tenancy enabled, +configure `[schema_forge.tenancy.default_tenant]` with the root `schema`, an +existing root entity's TypeID in `id`, and a scoped membership `role` (default: +`member`). The membership is written before login completes. Startup rejects +open signup without this default, an invalid root, an unregistered or +`platform_admin` membership role, or a missing target entity. A `creator_role` +setting alone does not provide the membership needed for first login. See +[tenant isolation](tenant-isolation.md) for configuration and creator grants. + +Invitation-only signup without an invite returns 403. An invite must still be pending and unexpired, and its stored PASETO is cryptographically reverified. Its signed email must match the provider's verified email. Signed role and tenant claims determine the diff --git a/docs/tenant-isolation.md b/docs/tenant-isolation.md index fc0eb70e..ba511a88 100644 --- a/docs/tenant-isolation.md +++ b/docs/tenant-isolation.md @@ -11,3 +11,39 @@ Tenant members cannot move rows by supplying `_tenant` in PUT or PATCH: the serv Invitation tenant targets require a configured tenant schema and a type/id pair in the caller's effective tenant chain. Active-tenant narrowing applies before delegation; platform administrators may delegate across tenants. Relation writes are checked after rules and hooks against tenant scope and read authorization. Missing and inaccessible targets return the same validation error. This applies to single and collection relations on create, PUT and PATCH. Platform administrators retain cross-tenant access. + +An operator can grant each authenticated root creator a scoped membership: + +```toml +[schema_forge.tenancy] +creator_role = "owner" +``` + +Declare the role in `policies/role_ranks.toml` and use it in the root schema's +permissions. Root creation and creator membership commit atomically. This grants +permissions within that tenant; it does not grant a global User role. Refresh +the session after creation to load the new membership and role, then select +the new tenant through the active-tenant header when multiple memberships exist. +Omitting +`creator_role` preserves explicit membership administration. Automatic grants +reject empty or unknown roles and `platform_admin`. Refresh the token after root +creation, then select the new root with `X-Active-Tenant`. Signed membership +roles augment account roles only for the selected tenant; other memberships +do not contribute roles to that request. + +Open OAuth signup also needs a default root that already exists. Users without +membership cannot complete a tenant login, so `creator_role` alone cannot solve +first-login onboarding. Configure the default after applying and seeding the root: + +```toml +[schema_forge.tenancy.default_tenant] +schema = "Organization" +id = "organization_01k00000000000000000000000" +role = "member" +``` + +Replace the example ID with the existing entity's TypeID. `role` defaults to +`member` and is independent of OAuth `default_roles`. The server validates the +root schema, registered role and entity existence before applying application +schema migrations. Enabled open OAuth signup with tenancy refuses startup when +this default is missing. Signed invitation membership takes precedence. From 8067ac8ea2cee5e746ebe87706c3d25221e2c511 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Tue, 6 Oct 2026 13:03:29 -0600 Subject: [PATCH 2/4] test(tenancy): cover fresh IDs during creator receipt retries --- crates/schema-forge-postgres/tests/create_intent.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/schema-forge-postgres/tests/create_intent.rs b/crates/schema-forge-postgres/tests/create_intent.rs index dbacf417..40ca30ea 100644 --- a/crates/schema-forge-postgres/tests/create_intent.rs +++ b/crates/schema-forge-postgres/tests/create_intent.rs @@ -383,13 +383,22 @@ async fn atomic_creator_intents(backend: &PgBackend) { .unwrap() .revision; // Even newly minted retry IDs must reconcile without creating another grant. - let retry_grant = creator_membership::membership(&root, &user); + let retry_root = Entity::new(root.schema.clone(), root.fields.clone()); + let retry_grant = creator_membership::membership(&retry_root, &user); + let mut retry_commit = commit.clone(); + if let CreateIntentRequest::Commit { entity, .. } = &mut retry_commit { + *entity = retry_root.clone(); + } let replay = backend - .create_intent_with_membership(&commit, &retry_grant) + .create_intent_with_membership(&retry_commit, &retry_grant) .await .unwrap(); assert!(!replay.created); assert_eq!(replay.entity_id, receipt.entity_id); + assert!(backend + .get(&retry_root.schema, &retry_root.id) + .await + .is_err()); assert!(backend .get(&retry_grant.schema, &retry_grant.id) .await From f52542c76a0d1f1baabf74398bdcb442f6965389 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Tue, 6 Oct 2026 13:07:10 -0600 Subject: [PATCH 3/4] test(auth): mount invitation endpoints in the regression fixture --- crates/schema-forge-acton/tests/invitations.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/schema-forge-acton/tests/invitations.rs b/crates/schema-forge-acton/tests/invitations.rs index da0bb77b..c3847b09 100644 --- a/crates/schema-forge-acton/tests/invitations.rs +++ b/crates/schema-forge-acton/tests/invitations.rs @@ -182,6 +182,7 @@ when {{ resource has "_tenant" && principal in resource["_tenant"] }}; TokenGenerationConfig::default(), )); let app = forge_routes() + .merge(schema_forge_acton::routes::auth::auth_routes()) .layer(Extension(auth_store)) .layer(Extension(invite_store)) .layer(Extension(email_sender)) From 9aff4f13407226a4f483bfa0e6f58a5cd03c26d4 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Tue, 6 Oct 2026 13:23:36 -0600 Subject: [PATCH 4/4] fix(site): keep invitation capability checks unobtrusive --- crates/schema-forge-cli/templates/site/src/App.tsx.jinja | 2 +- .../tests/site_e2e/playwright/tests/client-errors.spec.ts | 5 +++++ .../tests/site_e2e/playwright/tests/relation-labels.spec.ts | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/schema-forge-cli/templates/site/src/App.tsx.jinja b/crates/schema-forge-cli/templates/site/src/App.tsx.jinja index b69f2d54..dd57370b 100644 --- a/crates/schema-forge-cli/templates/site/src/App.tsx.jinja +++ b/crates/schema-forge-cli/templates/site/src/App.tsx.jinja @@ -133,7 +133,7 @@ function Sidebar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => queryKey: ["schemas"], queryFn: () => listSchemas(), }) - const inviteRoles = useQuery({ queryKey: ["invite-roles"], queryFn: listInviteRoles, retry: false }) + const inviteRoles = useQuery({ queryKey: ["invite-roles"], queryFn: listInviteRoles, retry: false, meta: { suppressGlobalError: true } }) const appSchemas = (schemas ?? []).filter((s: SchemaResponse) => !isSystemSchema(s)) const isActive = (prefix: string) => diff --git a/crates/schema-forge-cli/tests/site_e2e/playwright/tests/client-errors.spec.ts b/crates/schema-forge-cli/tests/site_e2e/playwright/tests/client-errors.spec.ts index 4e2e7be7..05f7f661 100644 --- a/crates/schema-forge-cli/tests/site_e2e/playwright/tests/client-errors.spec.ts +++ b/crates/schema-forge-cli/tests/site_e2e/playwright/tests/client-errors.spec.ts @@ -10,6 +10,8 @@ async function session(page: Page, entityResponse?: (route: Route) => Promise route.fulfill({ status: 403, json: { error: "forbidden" } })) await page.route("**/api/v1/forge/auth/me", async route => { const active = route.request().headers()["x-active-tenant"] await route.fulfill({ json: { @@ -39,6 +41,9 @@ test("multi-membership tenant picker scopes the entity list after switching", as await page.getByRole("combobox", { name: "Switch active tenant" }).selectOption("Org:org-b") await expect(page.getByText("Company for Org:org-b", { exact: true })).toBeVisible() await expect(page.getByText("Company for Org:org-a", { exact: true })).toHaveCount(0) + await expect(page.getByRole("link", { name: "Invite user", exact: true })).toHaveCount(0) + await expect(page.locator("[data-sonner-toast]")).toHaveCount(0) + expect(await page.evaluate(() => sessionStorage.getItem("schemaforge.token"))).toBe("test-token") }) test("request headers preserve explicit tenant overrides and refresh retries", async ({ page }) => { diff --git a/crates/schema-forge-cli/tests/site_e2e/playwright/tests/relation-labels.spec.ts b/crates/schema-forge-cli/tests/site_e2e/playwright/tests/relation-labels.spec.ts index e7f81bca..f194fa5e 100644 --- a/crates/schema-forge-cli/tests/site_e2e/playwright/tests/relation-labels.spec.ts +++ b/crates/schema-forge-cli/tests/site_e2e/playwright/tests/relation-labels.spec.ts @@ -6,6 +6,8 @@ async function session(page: Page) { sessionStorage.setItem("schemaforge.token_expires_at", new Date(Date.now() + 60 * 60 * 1000).toISOString()) sessionStorage.setItem("schemaforge.roles", JSON.stringify(["member"])) }) + // The shell probes invitation capabilities independently of entity access. + await page.route("**/api/v1/forge/users/roles", route => route.fulfill({ status: 403, json: { error: "forbidden" } })) await page.route("**/api/v1/forge/auth/me", route => route.fulfill({ json: { user_id: "member", roles: ["member"], tenant_chain: [], active_tenant: null, } }))