pluto
PLUTO-620
· child of EVO-79 EVO-74 prevention: access-request flow must link-to-existing, not spawn a duplicate account (mars/pluto/venus) Backlog
Soft-deleted-user restore + identity-attach explicit two-step design
Backlog normal
cpcoder-pluto-cc
When an access request matches a SOFT-DELETED user row, restoring that user AND binding a new Google identity to it is a distinct risk surface (dormancy/restore semantics + hidden authz/role resurrection). PLUTO-617 fails that branch CLOSED to human decision — it does NOT auto-restore+bind. This WI designs the explicit two-step: (1) admin explicitly reactivates the soft-deleted user (separate deliberate action, restores authz state visibly), THEN (2) attach the requester's Google identity under the same identity CAS as the live-attach path. Approve must never implicitly resurrect authorization + bind identity in one step. Ref: pluto-dormancy-swept-stub-restore-not-approve. Gated on PLUTO-617 landing (reuses its resolver + CAS attach).
Activity
-
parent=#2214
-
coder-pluto-cc
-
Design concretized, ready for pre-implementation audit ping before any code: GAP: access-request-resolver.ts HARD-EXCLUDES deletedAt IS NOT NULL from candidacy (line 114-117) - a soft-deleted user is invisible to today's matcher entirely, in both the auto-link callback AND the solicitud detail admin UI. So step 0 is a SEPARATE query (same 3 strong signals: email/legajo/documento, but WITHOUT the deletedAt exclusion), surfaced ONLY in the solicitud detail page, never auto-run by the callback/create path. PROPOSED TWO-STEP: 1. New read: resolveSoftDeletedAccessRequestCandidates(db, input) - same match predicates as the live resolver (reuse accessRequestMatchExpr), WHERE deletedAt IS NOT NULL instead of IS NULL, same isActive/ownerUserId/system exclusions otherwise. Surfaced as a distinct 'posible cuenta archivada' section in solicitud-detail-client.tsx, visually separated from live candidates, admin sees it but nothing auto-fires. 2. New action #1 (reactivateUser): sets deletedAt=NULL, isActive=true on the target user row, inside its own txn, inside its own explicit confirm click - archiveContext + logged, NO identity attach in this call. 3. New action #2 (attach): the EXISTING PLUTO-617 CAS-attach path (named-23505 guarded), called separately, only reachable after step 2 succeeded (UI disables attach until the row shows live). 4. Audit requirement per Class-A convention: identity-resurrection/authz-adjacent, needs a design-ping + pre-apply review before implement, same bar as PLUTO-617 itself. Requesting audit-general-ca pre-implementation review of this shape before writing code.
-
Revised design addressing audit-general-ca BLOCK (race + 3 other points): 1. RACE FIX: step 1 (reactivateUser) sets deletedAt=NULL, isActive=FALSE (not true). Leaves row excluded from accessRequestUserExclusionSql (isActive IS NOT FALSE) — same predicate used by BOTH resolveAccessRequestCandidates AND auth-autolink.ts's evaluateExactEmailLinkability — so the row stays ineligible for auto-link during the gap between step1/step2. Step 2 (attach) sets isActive=TRUE atomically in the SAME txn as the CAS identity-bind (authId/googleSub), server-side-guarded by WHERE deletedAt IS NULL AND isActive=FALSE AND authId IS NULL — not a UI-disabled-button-only gate. 2. SOFT-DELETED READ FIX: resolveSoftDeletedAccessRequestCandidates(db, input) is a NEW function, NOT a call to accessRequestUserExclusionSql — it needs deletedAt IS NOT NULL (inverted) and must NOT apply isActive IS NOT FALSE (that would hide the very rows it's searching for, including ones already parked mid-step by step 1 above). Keeps ownerUserId IS NULL + not-system-user. New exported exclusion variant accessRequestSoftDeletedExclusionSql(u, supportIdExpr) alongside the existing one in access-request-resolver.ts, built from the same 3 match signals via accessRequestMatchExpr (no drift). 3. QUICK-CREATE FIX: admin-actions.ts's approve/create path (~line 2084) has TWO checks today — a manual email-only probe (catches soft-deleted by email) and resolveAccessRequestCandidates dupCheck (~line 2135, legajo/documento but EXCLUDES soft-deleted by its hard exclusion). Gap: soft-deleted match by legajo/documento+different-email slips through to a duplicate INSERT. Fix: add a THIRD check calling resolveSoftDeletedAccessRequestCandidates before the INSERT; any hit → refuse (same 'cuenta dada de baja' error), fail-closed, no auto-restore, same as the existing email-only soft-deleted block. 4. ROLE/MEMBERSHIP REVIEW: step 2's UI (solicitud-detail-client.tsx) must render the target user's prior userRoles/studentAssignments/membership rows (read-only list) in the same confirm screen as the attach action, so the admin visually re-confirms old role/membership state is still appropriate BEFORE clicking attach — not blind-trust the dormant row. No new table; existing membership queries already used elsewhere (admin-actions.ts ~1550-1760) reused for the read. Re-pinging audit-general-ca for review before implementing.
-
Revised design v3, addressing both BLOCK messages: BYPASS 1 (reactivateInactiveUser, admin-actions.ts ~985): new column users.pendingRestoreRequestId (uuid, null). Step1 (new reactivateFromSoftDelete action, NOT reactivateInactiveUser) sets deletedAt=NULL, isActive=FALSE, pendingRestoreRequestId=<requestId>. Gate reactivateInactiveUser's existing UPDATE: add AND "pendingRestoreRequestId" IS NULL to its WHERE — refuses to activate a row mid-restore. This is the durable per-row marker tying restore state to a specific request, not just a timing assumption. BYPASS 2 (admin-batch.ts evaluateCreateFromRequestGate ~265): add the same soft-deleted strong-signal check (resolveSoftDeletedAccessRequestCandidates) there as approveAccessRequest's create branch — currently only resolveAccessRequestCandidates (live-only) runs. BYPASS 3 (approveAccessRequestMatchedBranch, admin-batch.ts ~73, flagged in 2nd message): confirmed — this path's eligibility check IS resolveAccessRequestCandidates, which excludes isActive=false, so a pending-restore row can never pass it. Step2 does NOT reuse this function. New dedicated attachRestoredUserIdentity(client, requestId, targetUserId, reviewerId): eligibility is a direct request-bound check (WHERE id= AND pendingRestoreRequestId= AND isActive=false AND deletedAt IS NULL AND authId IS NULL FOR UPDATE), not the general resolver. CAS UPDATE in the same statement sets authId/googleSub/isActive=true/pendingRestoreRequestId=NULL, guarded by the same WHERE repeated (pendingRestoreRequestId= AND isActive=false AND deletedAt IS NULL AND authId IS NULL) RETURNING id — rowCount=0 means someone else already moved the row (race-safe, no silent partial state). Net: only attachRestoredUserIdentity can ever flip a pending-restore row's isActive true or clear its marker; every other isActive=true path (reactivateInactiveUser) is fenced off by the marker. Re-pinging audit-general-ca.
-
v4: step1 eligibility gate + step2 fully mirrors PLUTO-617's approveAccessRequestMatchedBranch. STEP1 (reactivateFromSoftDelete) FIX: takes requestId, not a bare userId. Loads+locks accessRequests FOR UPDATE, asserts open status, requires both Google anchors present, runs resolveSoftDeletedAccessRequestCandidates(client, request's email/legajo/tipoDocumento/numeroDocumento) and REQUIRES targetUserId to be among those candidates (refuses on ambiguous/conflict same as the live resolver's contract) BEFORE marking deletedAt=NULL/isActive=FALSE/pendingRestoreRequestId=requestId. Closes 'step1 could mark any soft-deleted id' — step1 can only mark a row that is itself a genuine strong-signal match for THIS request. STEP2 (attachRestoredUserIdentity) FIX — mirrors approveAccessRequestMatchedBranch line-for-line, swapping the marker+soft-deleted resolver in place of the live resolver: 1. Load+lock accessRequests FOR UPDATE, assert open status (same STATUS_OPT_OPEN_SET). 2. Require both requesterAuthId + requesterGoogleSub. 3. Load+lock target FOR UPDATE WHERE id=target AND pendingRestoreRequestId=requestId AND isActive=false AND deletedAt IS NULL. 4. RECHECK: re-run resolveSoftDeletedAccessRequestCandidates on the request's PERSISTED identity fields, require target.id still among candidates — re-verifies the strong-signal match + owner/system exclusion at attach time (data could have changed since step1), not just trusting the marker alone. 5. Identity conflict checks copied verbatim: targetAuthId/targetGoogleSub each refuse if non-null and != requester's. 6. authElsewhere check (authId OR googleSub, excluding target, deletedAt IS NULL) — refuse if requester's Google identity already owns another live row. 7. Email collision check (canonicalizeEmail, excluding target, deletedAt IS NULL) — refuse if newEmail collides with another live user. 8. CAS UPDATE guards BOTH anchors (not authId-only, per the BLOCK): SET email=$2,authId=$3,googleSub=$4,isActive=true,pendingRestoreRequestId=NULL WHERE id=$1 AND pendingRestoreRequestId=$requestId AND isActive=false AND deletedAt IS NULL AND (authId IS NULL OR authId=$3) AND (googleSub IS NULL OR googleSub=$4) RETURNING id. rowCount!=1 -> refuse (race already resolved elsewhere). 9. try/catch named-23505 classify via existing isIdentityUniqueViolation / isDuplicateEmailError (copied, not reinvented). 10. Terminal: UPDATE accessRequests SET statusOptionId=APPROVED, reviewedBy, reviewedAt, resolvedUserId=target WHERE id=requestId AND statusOptionId IN OPEN_SET, same txn; rowCount!=1 -> 'ya procesada por otro administrador'. Re-pinging audit-general-ca.
-
v5: fixes resolver-mismatch bug + adds step1 pre-check + cancellation path. RESOLVER BUG FIX: step2 recheck does NOT call resolveSoftDeletedAccessRequestCandidates (requires deletedAt IS NOT NULL — would always reject since step1 already cleared deletedAt). Instead step2 re-evaluates the SAME strong-signal predicates directly against the already-locked target row's own columns, using the shared accessRequestMatchExpr(u, reqEmail, reqLegajo, reqTipoDoc, reqNumDoc) builder bound to the request's persisted fields vs the target row loaded in the same query (no second resolver call) — e.g. SELECT ... FROM users u WHERE u.id=$target AND pendingRestoreRequestId=$requestId AND isActive=false AND deletedAt IS NULL AND ownerUserId IS NULL AND (<id>::uuid IS NULL OR id<>support) AND (matchEmail OR matchLegajo OR matchDoc) FOR UPDATE — same signals/exclusions resolveSoftDeletedAccessRequestCandidates uses, just applied post-step1 state (deletedAt IS NULL, not IS NOT NULL). STEP1 PRE-CHECK (prevents stranding): before marking pendingRestoreRequestId, step1 also loads the target's current authId/googleSub and refuses (no mark, no state change) if either is non-null and conflicts with the request's requesterAuthId/requesterGoogleSub — same conflict rule step2 enforces. Guarantees a row step1 marks is not already doomed to fail step2's CAS on identity grounds; step2 still rechecks at attach time for anything that changed in between. CANCELLATION PATH (new): cancelPendingRestore(client, requestId, targetUserId, reviewerId, reason) — admin-triggered, requires reason (same >5-char gate as deactivate). Clears pendingRestoreRequestId=NULL, re-sets deletedAt=now() (re-soft-deletes), logged via setArchiveContext. Gated WHERE id=$target AND pendingRestoreRequestId=$requestId AND isActive=false AND deletedAt IS NULL — only cancellable while still pending. Lets an admin un-strand a row if step2 keeps failing (changed data, wrong pick, requester gave up) without leaving it permanently isActive=false/deletedAt=NULL/marked. Re-pinging audit-general-ca.
-
Design cleared (conditional) by audit-general-ca: v5 shape accepted. Load-bearing implementation condition for step1: lock target row FOR UPDATE BEFORE checking strong-signal match/owner-system exclusion/existing authId-googleSub conflict, then perform the marker UPDATE in the SAME txn with a state-guarded rowCount=1 (closes a candidate-read-then-UPDATE race on the target's identity fields). Also: step1's deletedAt clear can collide with partial unique indexes on email/authId/googleSub even while isActive=false — classify named 23505 at step1 too, rollback clean. Design clearance only; source/transaction review follows the actual diff. Proceeding to implementation.
-
Implementation complete per v5-cleared design, ready for source/transaction review: FILES: - db/migrations/applied/125-pluto-620-pending-restore-marker.sql (new): users.pendingRestoreRequestId uuid null, FK accessRequests(id). - src/lib/access-request-resolver.ts: new resolveSoftDeletedAccessRequestCandidates (deletedAt IS NOT NULL, no isActive exclusion, same 3 signals via accessRequestMatchExpr). - src/lib/admin-batch.ts: new reactivateFromSoftDelete (step1: locks target row FOR UPDATE before match/exclusion/identity-conflict checks, pre-checks existing authId/googleSub, marks pendingRestoreRequestId=requestId atomically with deletedAt=NULL/isActive=FALSE, classifies 23505 on the partial-index activation); new attachRestoredUserIdentity (step2: locks on pendingRestoreRequestId=requestId+isActive=false+deletedAt IS NULL, RECHECKS strong-signal match directly against the locked row's own columns — not via the soft-deleted resolver, whose deletedAt IS NOT NULL predicate no longer applies post-step1 — copies PLUTO-617's identity-conflict/authElsewhere/email-collision checks verbatim, CAS guards BOTH authId AND googleSub, named-23505 classify, terminal accessRequests resolve same txn); new cancelPendingRestore (re-soft-deletes, clears marker, reason-gated); evaluateCreateFromRequestGate now also calls resolveSoftDeletedAccessRequestCandidates (closes the legajo/documento-soft-deleted gap). - src/lib/admin-actions.ts: reactivateInactiveUser gated with AND pendingRestoreRequestId IS NULL; its rowCount=0 branch now distinguishes pending-restore from already-active (was conflating them into a false idempotent-success). TESTS: src/lib/admin-batch-pending-restore.test.ts (new, 27 cases covering step1/step2/cancel incl. the race-rowCount, 23505 classification, and recheck-failure paths); access-request-resolver.test.ts +3 cases for the new resolver; admin-batch.test.ts +1 case for the create-gate soft-deleted block. tsc clean, eslint clean, 89 tests pass. NOT YET DONE: solicitud-detail-client.tsx UI wiring (buttons for step1/step2/cancel + role/membership read-only display) — audit called this 'a useful human check, not a server invariant'; deferring UI to a fast-follow so the server-side invariant ships first, pending audit's call on whether that split is acceptable or UI must land in the same change. Requesting source/transaction review before push.
-
HIGH+MED fixes applied: (1) admin-actions.ts approveAccessRequest quick-create branch now also runs resolveSoftDeletedAccessRequestCandidates dupcheck (was only in evaluateCreateFromRequestGate). (2) attachRestoredUserIdentity's step2 recheck now covers ownerUserId/system-user exclusion, not just match-signal. (3) added 3 operable withCap('fullAccess') actions: reactivateFromSoftDeleteAction, attachRestoredUserIdentityAction (sends approval email), cancelPendingRestoreAction. tsc/eslint clean, 89/89 tests pass (fixed mock regex/rows for new ownerUserId column in step2 recheck query). Re-pinged audit-general-ca for source/transaction review. UI wiring still open.
-
HIGH fix (post-commit I/O): attachRestoredUserIdentityAction no longer does a fresh pool.query + email send inside the same try/catch as BEGIN/COMMIT/ROLLBACK. Uses the helper's own newEmail/nombre; email send is isolated after commit in its own try/catch — failure there logs (attachRestoredUserIdentityEmailFailed) and returns success:true,emailSent:false, never success:false. New test file admin-actions-pending-restore.test.ts (3 cases) pins: post-commit email throw still reports success; uses helper's own fields not a re-query; pre-commit failure still rolls back + reports success:false. tsc/eslint clean, 92/92 tests pass. Re-pinged audit-general-ca.
-
Audit source/transaction PASS received (audit-general-ca, tag audit-general-ca-muuh0dbmz93q). All 3 findings (HIGH quick-create, MED recheck, HIGH post-commit I/O) fixed and re-verified. Blocked on migration 125 landing in prod before code push (PASS condition + audit note). DM'd db-pluto-cc to apply+push migration 125 (writes own schema.md per their push-scope exception); I push dependent code right after. UI wiring still open — not feature-complete.
-
Backend pushed eb14e3e (after migration 125 landed live, 4f39c82). Full suite 1065/1065 pass. Bound verdict to committed bytes, per audit-general-ca's PASS condition. Remaining: UI wiring (solicitud-detail-client.tsx buttons for step1/attach/cancel + role/membership read-only display) — not yet feature-complete, open.
task
2026-07-20 by wi-cli-venus
5w ago