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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/schema-forge-acton/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "schema-forge-acton"
version = "0.46.0"
version = "0.47.0"
edition = "2021"

[dependencies]
Expand Down
3 changes: 3 additions & 0 deletions crates/schema-forge-acton/src/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
15 changes: 13 additions & 2 deletions crates/schema-forge-acton/src/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,14 @@ fn configure_backend_operations(actor: &mut ManagedActor<Idle, ForgeActor>) {
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;
Expand All @@ -462,10 +469,14 @@ fn configure_backend_operations(actor: &mut ManagedActor<Idle, ForgeActor>) {
actor.act_on::<CreateEntity>(|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())
Expand Down
3 changes: 3 additions & 0 deletions crates/schema-forge-acton/src/authz/namespace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions crates/schema-forge-acton/src/cedar/policy_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ forbid (
Action::"ReadUser",
Action::"ListUser",
Action::"CreateUser",
Action::"InviteUser",
Action::"UpdateUser",
Action::"DeleteUser"
],
Expand Down
10 changes: 10 additions & 0 deletions crates/schema-forge-acton/src/cedar/schema_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
6 changes: 6 additions & 0 deletions crates/schema-forge-acton/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
107 changes: 100 additions & 7 deletions crates/schema-forge-acton/src/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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(),
Expand All @@ -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
}
Expand Down Expand Up @@ -257,7 +305,10 @@ impl InMemoryEmailSender {

/// Snapshot of every message handed to [`EmailSender::send`] so far.
pub fn sent(&self) -> Vec<EmailMessage> {
self.sent.lock().expect("email recorder mutex poisoned").clone()
self.sent
.lock()
.expect("email recorder mutex poisoned")
.clone()
}
}

Expand Down Expand Up @@ -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::<EmailConfig>("delivery = 'unknown'").is_err());
}

#[test]
fn config_defaults_to_disabled_smtps() {
let cfg = EmailConfig::default();
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading