Sigra.Organizations.Invitations (Sigra v1.20.0)

Copy Markdown View Source

Phase 17 invitation lifecycle: create/2, revoke/3, list_pending/2, list_pending_for_user/2. accept/3 and accept_with_signup/3 land in Plan 17-05.

All security-critical logic (HMAC token generation via Sigra.Token.generate_invite_envelope/2, Ecto.Multi atomicity, dual-key Hammer rate limiting, authorization) lives in this library module. The generated OrganizationMembersLive is a thin event-handler wrapper that delegates to use Sigra.Organizations re-exports.

Key invariants

  • organization_invitations.hashed_token is SHA-256 of the raw base64 invite token. The raw token never touches the DB; it is only present in the signed envelope threaded into the accept URL (HMAC-bound to email via Sigra.Token.generate_invite_envelope/2).
  • Pending uniqueness is enforced by the partial-unique index organization_invitations_pending_index (IS-NULL-only predicate, IMMUTABLE-safe per D-03).
  • Re-invite on a pending email (D-05) revokes the old row and inserts a new row in a single Ecto.Multi, preserving the partial-unique invariant with zero windows where two rows are pending.
  • Rate limiting is dual-key: "sigra:org_invite_create:user:<user_id>" checked first, then "sigra:org_invite_create:org:<org_id>". Fail-open per D-41 is handled inside Sigra.RateLimiters.Hammer.
  • Email delivery happens after successful repo.transact/2. Mailer failures are logged + telemetry-emitted and never roll back the committed DB row (D-12 discretion).

Asymmetric error exposure (Pitfall 7)

Creating an invite for an existing member returns :already_member while creating for a user with no account returns {:ok, _}. This is acceptable because the actor is already an admin who can list_members/2 and see membership state directly — no new information is leaked.

Telemetry events

  • [:sigra, :invitation, :email_sent] — after successful email send
  • [:sigra, :invitation, :email_delivery_failed] — after mailer raises
  • [:sigra, :invitation, :email_skipped] — when emails_module is nil

Summary

Functions

Accept an invitation as an already-signed-in user whose email matches the invitation email (case-insensitive via citext + String.downcase/1 belt-and-suspenders).

Accept an invitation as an anonymous visitor by atomically registering a new user, confirming them (HMAC-bound invite acceptance proves email ownership), inserting a membership, and stamping the invitation.

Create a pending invitation for email to join organization_id with role.

List pending invitations for org (struct or id). Returns the rows sorted by inserted_at descending with :invited_by preloaded.

List pending invitations for a user (by email, case-insensitive via citext). Returns rows with :organization and :invited_by preloaded, sorted by inserted_at descending.

Load an invitation and classify it into a render-branch tuple for InvitationAcceptLive (Plan 17-07, D-06).

Revoke a pending invitation.

Functions

accept(config, signed_token, current_user)

@spec accept(map(), String.t(), struct()) ::
  {:ok, %{membership: struct(), invitation: struct()}}
  | {:error, :invalid | :expired | :revoked | :already_accepted | :mismatch}

Accept an invitation as an already-signed-in user whose email matches the invitation email (case-insensitive via citext + String.downcase/1 belt-and-suspenders).

Returns:

  • {:ok, %{membership: _, invitation: _}} — membership row inserted, invitation stamped with accepted_at + accepted_by_id, audit event emitted atomically via repo.transact/1.
  • {:error, :invalid} — HMAC verify failed, base64 decode failed, payload shape wrong, bound_email does not match DB row email, or invitation row missing for the looked-up hashed_token. Collapsed uniformly to :invalid to avoid information leakage.
  • {:error, :expired} — invitation envelope or DB expires_at is in the past.
  • {:error, :revoked}revoked_at IS NOT NULL.
  • {:error, :already_accepted}accepted_at IS NOT NULL (replay).
  • {:error, :mismatch}current_user.email != invitation.email (Jetstream #907 / CVE-2026-1529 defense). ZERO DB writes.

All non-:ok branches skip audit emission for organization.invitation.accepted.

accept_with_signup(config, signed_token, user_params)

@spec accept_with_signup(map(), String.t(), map()) ::
  {:ok, %{user: struct(), membership: struct(), invitation: struct()}}
  | {:error,
     :invalid
     | :expired
     | :revoked
     | :already_accepted
     | :email_mismatch
     | Ecto.Changeset.t()}

Accept an invitation as an anonymous visitor by atomically registering a new user, confirming them (HMAC-bound invite acceptance proves email ownership), inserting a membership, and stamping the invitation.

All four Multi steps run inside a single repo.transact/1. Any step failure rolls back the entire transaction — zero orphan rows across the user, membership, and invitation tables (Pow #534 regression invariant).

The caller-supplied user_params["email"] is rejected server-side if it does not match invitation.email case-insensitively, even though the UI locks the field via disabled + readonly. The locked email is also force-overwritten onto the registration params as a belt-and-suspenders defense against direct POST tampering.

Raises RuntimeError when config.user_registration_changeset_fn is nil — the host app's installer must wire this to &YourApp.Accounts.User.registration_changeset/1.

create(config, attrs)

@spec create(map(), map()) ::
  {:ok, struct()}
  | {:error,
     :rate_limited_user
     | :rate_limited_org
     | :unauthorized
     | :already_member
     | Ecto.Changeset.t()}

Create a pending invitation for email to join organization_id with role.

Requires the actor (passed via attrs.actor) to carry a membership with role in [:owner, :admin]. Returns:

  • {:ok, %OrganizationInvitation{} = invitation} — invitation row inserted, with an ephemeral __encoded_token__ key on the struct carrying the signed envelope (useful if the caller wants to thread it somewhere beyond the built-in email delivery path).
  • {:error, :unauthorized} — actor lacks owner/admin role
  • {:error, :rate_limited_user} — per-user Hammer limit exceeded
  • {:error, :rate_limited_org} — per-org Hammer limit exceeded
  • {:error, %Ecto.Changeset{}} — invitation changeset invalid

Raises RuntimeError when config.secret_key_base or config.url_builder is nil (Phase 17 runtime requirements).

list_pending(config, org_or_id)

@spec list_pending(map(), struct() | integer() | binary()) :: [struct()]

List pending invitations for org (struct or id). Returns the rows sorted by inserted_at descending with :invited_by preloaded.

"Pending" means accepted_at IS NULL AND revoked_at IS NULL AND expires_at > now(). The expires_at filter is applied at query time so expired-but-not-yet-cleaned-up rows don't surface to the admin UI.

list_pending_for_user(config, map)

@spec list_pending_for_user(
  map(),
  struct()
) :: [struct()]

List pending invitations for a user (by email, case-insensitive via citext). Returns rows with :organization and :invited_by preloaded, sorted by inserted_at descending.

load_for_view(config, signed_token, current_user)

@spec load_for_view(map(), String.t(), struct() | nil) ::
  {:signup, struct(), struct(), struct()}
  | {:accept, struct(), struct(), struct(), struct()}
  | {:mismatch, struct(), struct()}
  | {:invalid, :invalid}
  | {:expired, struct() | nil}
  | {:revoked, struct() | nil}
  | {:already_accepted, struct() | nil, boolean()}

Load an invitation and classify it into a render-branch tuple for InvitationAcceptLive (Plan 17-07, D-06).

Does NOT run any Multi — the caller invokes accept/3 or accept_with_signup/3 separately on user action. This function performs the HMAC verify + DB lookup + optional org/inviter load and returns a tuple the LV assigns directly to :branch + related assigns.

The current_user argument is nil for anonymous visitors (signup branch) or a user struct for signed-in visitors (accept or mismatch branch depending on email match).

Returned tuples:

  • {:signup, invitation, org, inviter} — anonymous visitor, token valid + pending
  • {:accept, invitation, org, inviter, current_user} — signed-in user's email matches invitation email
  • {:mismatch, invitation, current_user} — signed-in user's email does NOT match invitation email (Jetstream #907 mismatch branch). org and inviter are deliberately not returned — the mismatch branch does not render them.
  • {:invalid, :invalid} — HMAC verify fail, tampered token, garbage base64, or invitation row missing. Uniformly :invalid for zero info leakage.
  • {:expired, invitation_or_nil} — envelope age > TTL or DB expires_at <= now(). The row is reloaded without the pending guard so the view can display inviter context.
  • {:revoked, invitation_or_nil} — DB revoked_at IS NOT NULL.
  • {:already_accepted, invitation_or_nil, maybe_member?} — DB accepted_at IS NOT NULL (replay). The boolean is currently always false — future work may use it to auto-redirect members to their org dashboard.

revoke(config, invitation_id, actor_scope)

@spec revoke(map(), integer() | binary(), map()) ::
  {:ok, struct()} | {:error, :not_pending | :unauthorized | :not_found}

Revoke a pending invitation.

Requires the actor to carry a membership with role in [:owner, :admin]. Already-accepted or already-revoked invitations return {:error, :not_pending} — the DB row is untouched. Missing invitation id → {:error, :not_found}. The lookup is scoped to actor_scope.active_organization.id; cross-tenant ids are collapsed to {:error, :not_found} to prevent enumeration.