fix(api): make app_members POST and PATCH atomic

The previous handlers did `find()` then `upsert()` in two round-trips:

- POST: two concurrent grants both pass the duplicate check; the
  second `upsert` silently rewrites the role instead of returning
  409, weakening the "409 on duplicate" contract under load.
- PATCH: a concurrent DELETE between `find` and `upsert` makes PATCH
  silently re-create a row instead of returning 404, weakening the
  "404 if no existing membership" contract.

Adds two repo primitives that fold the check into the write:

- `try_insert` — `INSERT ... ON CONFLICT DO NOTHING RETURNING`; None
  return ⇒ already exists ⇒ 409.
- `update_role` — `UPDATE ... WHERE app_id AND user_id RETURNING`;
  None return ⇒ no row ⇒ 404.

Handlers use these directly; existing `upsert` stays for test helpers
that genuinely want upsert semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-27 22:00:04 +02:00
parent b7175cc581
commit 2948875a96
2 changed files with 82 additions and 20 deletions

View File

@@ -69,6 +69,27 @@ pub trait AppMembersRepository: Send + Sync {
role: AppRole,
) -> Result<AppMembershipRow, AppMembersRepositoryError>;
/// Atomic insert. Returns `Some(row)` on success, `None` if a
/// membership already exists. Lets the HTTP handler return 409
/// without a separate `find` round-trip (no TOCTOU between check
/// and insert).
async fn try_insert(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
/// Atomic role update. Returns `Some(row)` on success, `None` if no
/// membership row exists. Lets PATCH return 404 without a separate
/// `find` round-trip (no TOCTOU between check and update).
async fn update_role(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
/// Remove a membership. No-op (Ok) when the row doesn't exist —
/// the user wasn't a member, which is the desired post-condition.
async fn remove(
@@ -165,6 +186,45 @@ impl AppMembersRepository for PostgresAppMembersRepository {
Ok(())
}
async fn try_insert(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
let row = sqlx::query_as::<_, AppMembershipRecord>(
"INSERT INTO app_members (app_id, user_id, role) \
VALUES ($1, $2, $3) \
ON CONFLICT (app_id, user_id) DO NOTHING \
RETURNING app_id, user_id, role, created_at",
)
.bind(app_id.into_inner())
.bind(user_id.into_inner())
.bind(role.as_str())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn update_role(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
let row = sqlx::query_as::<_, AppMembershipRecord>(
"UPDATE app_members SET role = $1 \
WHERE app_id = $2 AND user_id = $3 \
RETURNING app_id, user_id, role, created_at",
)
.bind(role.as_str())
.bind(app_id.into_inner())
.bind(user_id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn list_for_user(
&self,
user_id: AdminUserId,