feat: user invite & onboard (issue #71) + project_name branding#82
Merged
Conversation
Foundation for issue #71 user invite & onboard: - email.rs: [schema_forge.email] config + EmailSender trait with lettre SMTP impl (aws-lc-rs rustls, FIPS-clean) and InMemoryEmailSender fake. SMTP password is env-only, never committed TOML. - invite_store.rs: InviteStore over an internal ForgeInvitation entity table provisioned at boot but never registered in the SchemaRegistry, so it stays unreachable from the public schema/entity API. - invite.rs: mint_invite_token/verify_invite_token. Invites are PASETOs minted with the login generator but carry purpose="invite"; verify refuses any token lacking it, hard-separating invite links from API sessions. Role/tenant are read from the signed claims, authoritative over the stored row. Endpoints and wiring follow in subsequent commits.
Adds the two invite endpoints under /api/v1/forge and wires the supporting state at boot (issue #71): - POST /auth/invites (authenticated): reuses the exact create_user privilege guards — schema-level CreateUser access, platform_admin grant guard, and the Cedar role_rank guard against a synthetic User carrying the proposed role — so an invite can never confer access the inviter lacks. Mints the PASETO invite, persists it, and emails an accept link. Fails closed on delivery error (invite stays Pending, retryable). - POST /auth/invites/accept (public): looks up the pending invite by its opaque id, reconstructs and re-verifies the full token from the stored row (signed claims authoritative over DB columns), creates the User and any TenantMembership, then consumes the invite last so a partial failure leaves it retryable rather than burnt. Added to the token middleware public_paths since the invitee has no bearer. Supporting changes: - AuthStore/DynAuthStore gain add_tenant_membership; EntityAuthStore writes a TenantMembership row referencing the user entity. - email.rs: DisabledEmailSender (fail-closed) injected when SMTP is off. - system.rs: provision_invite_store creates the ForgeInvitation table WITHOUT registering it in the SchemaRegistry, keeping it off the public API. - serve.rs builds the PasetoAuth validator, invite store, and email sender and layers them onto the versioned router. - users.rs guard/audit helpers promoted to pub(crate) for reuse. 900 tests pass; clippy clean.
The invite email transport needs an SMTP password that must never live in
committed TOML. acton-service's ACTON_-prefixed Figment env layering can't
target the [schema_forge] section — Env::split("_") shatters the
underscore in the section key — so the documented "password via env" path
did not actually work for this section.
serve.rs now reads a dedicated SCHEMAFORGE_SMTP_PASSWORD env var (matching
the existing SCHEMAFORGE_* convention used for token/trust-policy) and
overrides EmailConfig.password before building the sender. config.toml
documents the [schema_forge.email] section and the env-only password rule.
Verified end-to-end against Stalwart (mail.govcraft.ai): invite minted +
emailed + delivered, accept created the user + membership, replay rejected
(single-use), new user logged in. Secret supplied via env only, confirmed
absent from disk.
A SchemaForge deployment had no human-facing name: invitation emails hardcoded "join the SchemaForge workspace" and the From display-name came only from the operator's `from` mailbox, so an app like "Bob's Dog Scheduling" greeted onboarding users with the engine's name. Add a single branding knob, `[schema_forge] project_name` (defaults to "SchemaForge"), and wire it through: - invite email body + subject are branded with project_name - SmtpEmailSender brands a bare `from` address with project_name as the display-name; an explicit display name in `from` still wins (operators keep exact control for deliverability) - `serve` reads project_name from config and passes it to from_config - `init` seeds [schema_forge] project_name from the project name so the loop closes: the name you pass to `init` survives into runtime config (TOML-escaped for names with quotes/backslashes) Defaults preserve existing behavior. Tests cover config defaults/round -trip, branded body+subject, bare-vs-explicit From, and init scaffolding.
Document the invite/accept endpoints (wire contract, auth model, status codes), the [schema_forge.email] SMTP configuration and the SCHEMAFORGE_SMTP_PASSWORD env-only secret path, the project_name branding knob, and the security properties (store-not-schema, single-use expiring links, signed-claims-authoritative, fail-closed delivery). README: list invitations under Implemented with a pointer to the new reference, and note that `init` seeds [schema_forge] project_name.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #71 (backend slice). Frontend is split to #81.
What this adds
User invitation & onboarding — invite a person by email, they set their own password, and the account + tenant membership are provisioned on acceptance.
POST /auth/invites(authenticated) — issue an invite, optionally scoped to a tenant and role. The same privilege guards asPOST /usersrun at invite time (schemaCreateaccess,platform_admin-grant guard, Cedar role-rank guard), so an invite can never grant access the inviter lacks.POST /auth/invites/accept(public) — invitee presents the opaqueinvite_id+ a password; the server reconstructs and re-verifies the PASETO (signed claims authoritative over DB columns), creates theUser+TenantMembership, then marks the invite consumed (last, so partial failure is retryable).purpose="invite", 7-day expiry; only the opaqueinvite_idis emailed.aws-lc-rsrustls — FIPS-clean);[schema_forge.email], disabled by default, fails closed.ForgeInvitationtable never inserted into theSchemaRegistry, so the token material is unreachable via the public entity API.project_namebranding — new[schema_forge] project_name(default"SchemaForge"). Drives the invite email body + subject and theFromdisplay-name whenfromis a bare address.schemaforge init <name>seeds it into the generatedconfig.toml, so the project name carries from scaffold into runtime.Security notes
SCHEMAFORGE_SMTP_PASSWORD(acton-service'sACTON_env layering can't address the[schema_forge]section). Verified absent from disk in the live test.Docs
New
docs/invitations-reference.md(endpoints, email config, the env-only password path, branding, security properties); README lists invitations under Implemented and notes theinitseeding.Release
Includes a merge of
main(#78 entity query fix, #80 tenant switcher — no conflicts) and a release bump to cli 0.33.0 / acton 0.32.0 / backend 0.13.0. A signedv0.33.0tag will be pushed on the merge commit. The release sweeps in the 7 unreleased main PRs since v0.32.0 (#68, #72, #74, #75, #77, #78, #80) plus this slice.Verification
cargo check --workspace --all-targets: cleancargo clippy --workspace --all-targets: 0 warningscargo nextest run --workspace: 1756 passed, 0 failed (4 DB-gated skipped)