Skip to content

feat: user invite & onboard (issue #71) + project_name branding#82

Merged
rrrodzilla merged 7 commits into
mainfrom
feat/issue-71-invite-onboard
May 28, 2026
Merged

feat: user invite & onboard (issue #71) + project_name branding#82
rrrodzilla merged 7 commits into
mainfrom
feat/issue-71-invite-onboard

Conversation

@rrrodzilla
Copy link
Copy Markdown
Contributor

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 as POST /users run at invite time (schema Create access, 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 opaque invite_id + a password; the server reconstructs and re-verifies the PASETO (signed claims authoritative over DB columns), creates the User + TenantMembership, then marks the invite consumed (last, so partial failure is retryable).
  • PASETO v4.local invite token, purpose="invite", 7-day expiry; only the opaque invite_id is emailed.
  • SMTP transport (lettre, aws-lc-rs rustls — FIPS-clean); [schema_forge.email], disabled by default, fails closed.
  • Store-not-schema: invitations live in an internal ForgeInvitation table never inserted into the SchemaRegistry, so the token material is unreachable via the public entity API.

project_name branding — new [schema_forge] project_name (default "SchemaForge"). Drives the invite email body + subject and the From display-name when from is a bare address. schemaforge init <name> seeds it into the generated config.toml, so the project name carries from scaffold into runtime.

Security notes

  • SMTP password is never read from committed TOML — supplied at runtime via SCHEMAFORGE_SMTP_PASSWORD (acton-service's ACTON_ env layering can't address the [schema_forge] section). Verified absent from disk in the live test.
  • Live Stalwart SMTP test passed end-to-end: invite → delivered → accept (user+membership created) → replay rejected (single-use) → new-user login → audit events emitted.

Docs

New docs/invitations-reference.md (endpoints, email config, the env-only password path, branding, security properties); README lists invitations under Implemented and notes the init seeding.

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 signed v0.33.0 tag 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: clean
  • cargo clippy --workspace --all-targets: 0 warnings
  • cargo nextest run --workspace: 1756 passed, 0 failed (4 DB-gated skipped)

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.
Integrate #78 (entity query fix) and #80 (tenant switcher chrome) before
releasing the invite/onboard slice. No conflicts; main's changes touch
entities.rs, site templates, and tests — disjoint from the invite,
email, config, and init changes on this branch.
@rrrodzilla rrrodzilla merged commit 0d2fa29 into main May 28, 2026
1 check passed
@rrrodzilla rrrodzilla deleted the feat/issue-71-invite-onboard branch May 28, 2026 22:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

admin: polished tenancy UX (Org/User/TenantMembership/Role) — replace generic CRUD with invite + role-picker + membership composer

1 participant