Skip to content

feat: add admin organization member invitations - #1029

Draft
italic-jinxin wants to merge 2 commits into
mainfrom
feat/admin-org-member-invitations
Draft

italic-jinxin wants to merge 2 commits into
mainfrom
feat/admin-org-member-invitations

Conversation

@italic-jinxin

Copy link
Copy Markdown
Contributor

Summary

  • add a system-admin endpoint for inviting organization members by email
  • allow assigning admin or member roles without requiring organization membership
  • normalize email addresses and handle duplicate or repeated invitations safely
  • add pending-invitation uniqueness and case-insensitive email indexes
  • prevent invitations to existing active members and organizations without an owner
  • document authorization requirements and expose the endpoint through OpenAPI

Testing

  • cargo fmt --all -- --check
  • cargo check -p api
  • cargo test -p services organization --lib — 25 passed
  • admin organization member E2E tests — 15 passed
  • invitation E2E tests — 2 passed

@ironloopai

ironloopai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Review · Status

⬛ Stopped

IronLoop stopped because the pull request was closed while this Run was active.

Run details
  • Run: 704e3298-7b37-4401-a5b9-a6e67af0343a
  • Base: main at 5f68657
  • Head: feat/admin-org-member-invitations at 4a2d73d
  • Created: 2026-09-07 13:09 UTC
  • Updated: 2026-09-07 13:13 UTC

Automatic trigger · attempt 1 of 3 · stopped after 4m

@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown

Review: admin organization member invitations

Traced the whole path (route → create_invitations_for_admin_implcreate_invitations_with_role_implPgOrganizationInvitationRepository::create) plus V0007/V0049 for migration compatibility. No existing review threads to build on.

Things that check out (worth stating, since they are the risky parts):

  • V0077 drops UNIQUE(organization_id, email, status) before the de-duplicating UPDATE, so the backfill cannot trip the old constraint. Constraint name matches PostgreSQL auto-generated naming (57 chars, no truncation).
  • Wrapping expire+insert in a transaction and moving generate_token() inside the retry_db! block is a real fix — the old code reused one token across retries. Unique-violation → RepositoryError::AlreadyExists survives the anyhow conversion, so the downcast_ref in the service works.
  • has_owner/owner_id are consistent: Organization::owner_id is itself derived from organization_members WHERE role = 'owner', so the new check is not a second source of truth.
  • Switching resend_invitation to get_active_name_by_id incidentally fixes resend for orgs that db_to_domain_organization would reject with "Organization has no owner".
  • Admin-access-token auth resolves to a real users row (created_by_user_id), so the invited_by_user_id FK is satisfied on that path too.

Issues

1. Ownerless orgs become unrepairable through this endpoint (services/src/organization/mod.rs:1008, :993)
The has_owner gate rejects the batch, and MemberRole::Owner is rejected outright — so for an org that has lost its owner there is no path through the admin API to restore membership or ownership. That is precisely the remediation case a system-admin endpoint exists for. Either let system admins assign owner (they are already the highest privilege in the system), or drop the has_owner guard and add a separate admin transfer-ownership endpoint. As written the guard blocks the endpoint main use case.

2. role: "owner" fails the whole batch instead of the entry (mod.rs:993-1000)

if invitations.iter().any(|(_, role)| matches!(role, MemberRole::Owner)) {
    return Err(OrganizationError::InvalidParams(...));
}

A 50-entry batch with one owner entry creates nothing and returns a bare 400 with no per-entry detail. The existing /v1/organizations/{id}/members/invite-by-email returns a per-entry Insufficient permissions to invite members as owner and processes the rest. Same request shape, two different failure models. Since requester_role is hardcoded to Admin, can_invite_as already rejects Owner per-entry — dropping the pre-check gets consistent behaviour for free.

3. successful: 1 for invitations that can never be accepted (mod.rs:1079, database/src/repositories/organization.rs:739)
has_member_with_email filters on user_account.is_active = true, so an email belonging to an inactive member passes the check and an invitation is created. accept_invitation_impl then hits its get_member check and returns AlreadyMember, so the invite is dead on arrival. test_admin_invitation_ignores_inactive_members_but_checks_active_same_email pins this in, so it looks deliberate — but reporting successful for a guaranteed-unacceptable invitation will read as a bug in ops. Either drop the is_active filter, or return a distinct per-entry error.

4. Behaviour change on the existing (non-admin) endpoint (mod.rs:1031-1063)
create_invitations_with_role_impl is shared, so /v1/organizations/{id}/members/invite-by-email also changes:

  • stored email and InvitationResult.email are now trimmed + lowercased (clients matching the echoed email against their input will mismatch);
  • an intra-batch duplicate now counts as failed instead of last-write-wins.

Both are improvements, but they are response-semantics changes on a live endpoint — worth a changelog entry and a check that the dashboard does not key off the echoed email.

5. V0077 silently expires outstanding invitations
The ROW_NUMBER() backfill expires every pending invitation that collides case-insensitively, keeping only the newest. Those links stop working with no trace. It is unavoidable in order to build the unique index, but please add a RAISE NOTICE with the affected count (or pre-query prod) so the blast radius is known rather than assumed.

6. Rolling-update window (V0077)
Once the partial unique index exists, old pods still expire with an exact-case predicate (email = $2) and insert un-normalized emails. An old pod inviting alice@x.com while Alice@x.com is pending will fail the INSERT and surface Failed to create invitation: .... Non-corrupting and arguably the right outcome, but expect transient failures for the length of the deploy. Also note both migrations build indexes non-CONCURRENTLY (unavoidable inside refinery per-migration transaction) alongside a table-wide UPDATE, so startup holds ACCESS EXCLUSIVE on organization_invitations — fine at current table size, worth knowing before it grows.

Nit
idx_org_invitations_email (V0007, plain email) is now unreachable — every query in organization_invitation.rs goes through LOWER(email). Consider dropping it in V0078 rather than paying write amplification for three overlapping email indexes.

Nothing here is data-corrupting, and the concurrency/migration handling is careful. (1) and (3) are the ones I would want resolved before merge, since they affect whether the endpoint does its job.

⚠️

@italic-jinxin italic-jinxin reopened this Sep 7, 2026
@italic-jinxin
italic-jinxin marked this pull request as draft September 7, 2026 13:14
@ironloopai

ironloopai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Review · Status

🟩 Completed

IronLoop completed the review and posted it to GitHub.

Result

Open submitted review →

Run details
  • Run: 2e069fb0-ce3d-461e-9d34-8d5ee1636ac9
  • Base: main at 5f68657
  • Head: feat/admin-org-member-invitations at 4a2d73d
  • Created: 2026-09-07 13:19 UTC
  • Updated: 2026-09-07 13:25 UTC

Automatic trigger · attempt 1 of 3 · completed in 6m 53s

@ironloopai ironloopai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review · Summary

Found one authorization issue in the invitation email normalization.

Findings: 🔴 High 1

Code-specific findings are attached to the diff.

Validation
  • Captured unit tests — The captured unit-test check passed.
  • Captured E2E tests — The most recent captured E2E check failed with exit code 100; the supplied evidence does not identify the failing test.
Review details
  • Run: 2e069fb0-ce3d-461e-9d34-8d5ee1636ac9
  • Attempts: 1

JOIN organizations o ON o.id = i.organization_id
LEFT JOIN users u ON u.id = i.invited_by_user_id
WHERE i.email = $1 AND i.status = $2
WHERE LOWER(i.email) = LOWER($1) AND i.status = $2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 High · Case-folded invitation lookup crosses account boundaries

User emails are uniquely stored with exact-case semantics, and OAuth provisioning keys accounts by provider identity, so distinct active accounts can differ only by email case. This lookup now exposes a pending invitation for either account; the existing acceptance flow also compares case-folded emails and grants the invitation’s role (including owner). Enforce a single canonical email identity before case-insensitive matching, or retain exact matching for invitation visibility and acceptance.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 OpenCodeReview found 11 issue(s) in this PR.

  • ✅ 10 posted as inline comment(s)
  • 📝 1 posted as summary

⚠️ 2 warning(s) occurred during review.


📄 crates/api/tests/e2e_all/invitations.rs

Test coverage gap: the test verifies that list_by_email_with_details (used by /v1/users/me/invitations) matches emails case-insensitively, but does not exercise the case-insensitive behavior of the create() method itself. The key behavioral guarantee introduced by the repository change is that calling create() with "admin@test.com" will expire an existing pending invitation stored as "Admin@Test.com" (via the LOWER(email) = LOWER($2) UPDATE). Without a test for this path, a regression that reverts the expire-UPDATE to case-sensitive matching would go undetected. Consider adding a test that creates an invitation with one case variant via the API, then creates another with a different case variant for the same org, and asserts the first is expired.

Comment on lines +1153 to +1155
} else {
format!("Failed to create invitation: {e}")
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The non-conflict error branch formats the raw anyhow error e directly into the per-invitation error field: format!("Failed to create invitation: {e}"). This InvitationResult.error is passed through to the API consumer via services_invitation_result_to_api inside the 200 OK response body (see admin.rs line ~3028 and organization_members.rs line ~193). Unlike top-level service errors (which are mapped to generic messages like "Failed to create organization invitations"), per-invitation errors are NOT sanitized at the API boundary. The raw anyhow error may contain database connection strings, SQL fragments, or internal stack context, leading to information leakage to API consumers.

Suggestion:

Suggested change
} else {
format!("Failed to create invitation: {e}")
},
} else {
tracing::warn!(
organization_id = %organization_id.0,
"Failed to create invitation: {e}"
);
"Failed to create invitation".to_string()
},

Comment on lines +2123 to +2126
async fn has_owner(&self, id: Uuid) -> Result<bool, RepositoryError> {
let org = self.org.lock().unwrap();
Ok(org.id.0 == id)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test mock for has_owner returns Ok(org.id.0 == id), which is always true whenever the organization exists in the stub. This makes the "Organization has no owner; restore ownership before inviting members" guard (line 1014) untestable — there is no way for a test to exercise the false branch of this check. Consider making the stub configurable (e.g., adding a has_owner: bool or Mutex<bool> field) so that the no-owner scenario can be covered by tests.

Suggestion:

Suggested change
async fn has_owner(&self, id: Uuid) -> Result<bool, RepositoryError> {
let org = self.org.lock().unwrap();
Ok(org.id.0 == id)
}
async fn has_owner(&self, id: Uuid) -> Result<bool, RepositoryError> {
Ok(self.org.lock().unwrap().id.0 == id && *self.has_owner_flag.lock().unwrap())
}

JOIN users user_account ON user_account.id = member.user_id
WHERE member.organization_id = $1
AND user_account.is_active = true
AND LOWER(user_account.email) = LOWER($2)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SQL uses LOWER(user_account.email) = LOWER($2) but does not apply TRIM(), while the Rust caller in create_invitations_with_role_impl normalizes the email with both trim() and to_lowercase() (line 1051). If a user's stored email in the users table contains leading/trailing whitespace, this membership check will fail to match, allowing an invitation to be created for someone who is already a member. The migration V0078 only creates an index on LOWER(email) for organization_invitations and does not normalize or trim existing users.email values. Consider adding TRIM() to both sides of the comparison, or ensuring emails are always trimmed at storage time.

Suggestion:

Suggested change
AND LOWER(user_account.email) = LOWER($2)
AND LOWER(TRIM(user_account.email)) = LOWER(TRIM($2))

Comment on lines +1557 to +1562
let organization_name = self
.repository
.get_active_name_by_id(invitation.organization_id.0)
.await
.map_err(Self::map_repository_error)?
.ok_or(OrganizationError::NotFound)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resend_invitation_email_impl method now uses get_active_name_by_id (which filters on is_active = true) instead of the previous get_organization_impl to fetch the organization name. This introduces a behavior change: if an organization is deactivated after invitations were sent, resending the invitation email will now fail with NotFound instead of proceeding. This may be intentional (preventing emails for deactivated orgs), but it is a breaking change from the prior behavior. If resending for deactivated orgs should still work, consider using a repository method that does not filter on is_active, or document this as intentional behavior.

Suggestion:

Suggested change
let organization_name = self
.repository
.get_active_name_by_id(invitation.organization_id.0)
.await
.map_err(Self::map_repository_error)?
.ok_or(OrganizationError::NotFound)?;
// If resending for deactivated orgs is intentional, keep the previous
// get_organization_impl call which does not filter on is_active:
// let org = self.get_organization_impl(invitation.organization_id.clone()).await?;
// let organization_name = org.name;
// If this is intentional, add a comment documenting the behavior change.

Comment on lines +19 to +21
CREATE UNIQUE INDEX unique_pending_organization_invitation
ON organization_invitations (organization_id, LOWER(email))
WHERE status = 'pending';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing IF NOT EXISTS on CREATE UNIQUE INDEX. The project uses IF NOT EXISTS in other index-creation migrations (e.g., V0062). More importantly, without IF NOT EXISTS, the CONCURRENTLY pre-build workaround documented in V0062 cannot be used: if an operator pre-builds this unique index with CREATE UNIQUE INDEX CONCURRENTLY to avoid an ACCESS EXCLUSIVE lock on a large production table, this migration will then fail because the index already exists. Adding IF NOT EXISTS makes the migration a no-op if the index was pre-built, which is the pattern established in V0062.

Suggestion:

Suggested change
CREATE UNIQUE INDEX unique_pending_organization_invitation
ON organization_invitations (organization_id, LOWER(email))
WHERE status = 'pending';
CREATE UNIQUE INDEX IF NOT EXISTS unique_pending_organization_invitation
ON organization_invitations (organization_id, LOWER(email))
WHERE status = 'pending';

Comment on lines +19 to +21
CREATE UNIQUE INDEX unique_pending_organization_invitation
ON organization_invitations (organization_id, LOWER(email))
WHERE status = 'pending';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No operational note about CONCURRENTLY for large production tables. V0062 includes a comment explaining that Refinery wraps migrations in a transaction (so CONCURRENTLY cannot be used inline) and recommends pre-building indexes manually with CONCURRENTLY before deployment. The same concern applies here: creating a unique index on a large organization_invitations table will hold an ACCESS EXCLUSIVE lock for the duration of the build. Consider adding a comment similar to V0062's note so operators know to pre-build the index on production databases.

Comment on lines +1 to +2
ALTER TABLE organization_invitations
DROP CONSTRAINT IF EXISTS organization_invitations_organization_id_email_status_key;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old case-sensitive index idx_org_invitations_email ON organization_invitations(email) created in V0007 is now redundant: all email-based queries in the repository have been updated to use LOWER(email), which cannot use the raw email index. The only remaining raw-email references are the ILIKE '%' || $2 || '%' filters in list_email_deliveries, but leading-wildcard ILIKE cannot use a B-tree index regardless. Consider dropping idx_org_invitations_email in this migration to avoid unnecessary write-path index maintenance and reduce confusion about which email index is active.

Suggestion:

Suggested change
ALTER TABLE organization_invitations
DROP CONSTRAINT IF EXISTS organization_invitations_organization_id_email_status_key;
ALTER TABLE organization_invitations
DROP CONSTRAINT IF EXISTS organization_invitations_organization_id_email_status_key;
DROP INDEX IF EXISTS idx_org_invitations_email;

Comment on lines +1 to +2
CREATE INDEX idx_org_invitations_email_lower
ON organization_invitations (LOWER(email));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing IF NOT EXISTS. Same reasoning as V0077: if an operator pre-builds this index with CREATE INDEX CONCURRENTLY on a large production table (following the pattern documented in V0062), this migration will fail because the index already exists. Using IF NOT EXISTS makes the migration a no-op when the index was pre-built.

Suggestion:

Suggested change
CREATE INDEX idx_org_invitations_email_lower
ON organization_invitations (LOWER(email));
CREATE INDEX IF NOT EXISTS idx_org_invitations_email_lower
ON organization_invitations (LOWER(email));

Comment on lines 198 to +234
@@ -226,7 +228,10 @@ impl OrganizationInvitationRepository for PgOrganizationInvitationRepository {
],
)
.await
.map_err(map_db_error)
.map_err(map_db_error)?;

transaction.commit().await.map_err(map_db_error)?;
Ok(row)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No explicit SET TRANSACTION ISOLATION LEVEL SERIALIZABLE. The retry_db macro only retries on TransactionConflict, ConnectionFailed, and PoolError, and TransactionConflict maps from T_R_SERIALIZATION_FAILURE and T_R_DEADLOCK_DETECTED. Without SERIALIZABLE isolation, the default (READ COMMITTED) will not produce serialization failures, so retry_db will never retry this transaction. The transaction does protect atomicity (expire + insert either both succeed or both roll back), but if the intent was to also gain retry-on-conflict behavior, the isolation level must be set. Note that the partial unique index (V0077) will produce a UNIQUE_VIOLATION (mapped to AlreadyExists, which is NOT retried) rather than a serialization conflict under any isolation level, so two concurrent create() calls for the same org+email will surface as AlreadyExists rather than one being retried. Worth verifying whether that error mapping is the intended behavior.

Comment on lines +392 to 393
WHERE LOWER(email) = LOWER($1)
ORDER BY created_at DESC",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lower(email) expression index usage note: the list_by_email and list_by_email_with_details queries use WHERE LOWER(email) = LOWER($1), which is backed by the new idx_org_invitations_email_lower index from V0078 — good. However, the partial unique index from V0077 (organization_id, LOWER(email) WHERE status = 'pending') cannot serve the general LOWER(email) = LOWER($1) lookup because it is filtered to pending rows. This is fine as long as V0078 is always applied alongside V0077, which it is. Just flagging that the two migrations are interdependent: deploying only V0077 without V0078 would leave the general list_by_email queries without index support on LOWER(email) (they would fall back to sequential scan on large tables).

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.

1 participant