Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions server/public/admin-people.html
Original file line number Diff line number Diff line change
Expand Up @@ -643,11 +643,33 @@ <h3 style="margin: 0 0 var(--space-2); font-size: var(--text-base);">Sign-in cre
alert('Confirmation did not match. Cancelled.');
return;
}
const promoteUrl = `/api/admin/users/${encodeURIComponent(hostId)}/credentials/${encodeURIComponent(credId)}/promote`;
try {
const res = await AdminSidebar.fetch(`/api/admin/users/${encodeURIComponent(hostId)}/credentials/${encodeURIComponent(credId)}/promote`, {
method: 'POST',
});
const data = await res.json();
let res = await AdminSidebar.fetch(promoteUrl, { method: 'POST' });
let data = await res.json();
// Server refused because the consolidation would DELETE memberships
// the incoming credential already holds. Escalate the confirmation
// and retry, same shape as the link-existing flow above.
if (res.status === 409 && data.consolidate_confirmation_required) {
const orgCount = (data.superseded_organization_ids || []).length;
const wgCount = (data.superseded_working_group_ids || []).length;
const lost = [
orgCount ? `${orgCount} organization membership(s)` : null,
wgCount ? `${wgCount} working-group membership(s)` : null,
].filter(Boolean).join(' and ');
const ack = prompt(`${credLabel} already belongs to the same organizations or working groups as the current primary.\n\nPromoting DELETES the current primary's ${lost}. Their role, seat, WorkOS membership id, and join provenance cannot be recovered — unlinking later will not bring them back. Certification progress, credentials, badges, and committee interest are also consolidated and deduplicated.\n\nType CONSOLIDATE (in caps) to confirm.`);
if (ack === null) return;
if (ack.trim() !== 'CONSOLIDATE') {
alert('Confirmation phrase did not match. Cancelled.');
return;
}
res = await AdminSidebar.fetch(promoteUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consolidate: true }),
});
data = await res.json();
}
if (!res.ok) {
alert(data.message || data.error || `Promote failed (${res.status})`);
return;
Expand Down
71 changes: 71 additions & 0 deletions server/src/db/membership-consolidation-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Consolidation checks for the credential-binding admin paths (#6827).
*
* `mergeUsers` moves a membership onto the target credential by rewriting
* `workos_user_id`. Where the target already holds a row for the same
* organization or working group, the unique constraint turns that move into a
* delete and the source row's role, seat type, upstream membership id, and
* join provenance are gone — an unlink afterwards cannot restore them.
* Endpoints that trigger a consolidation use this to require stated operator
* intent first.
*
* Scope is the membership tables. `mergeUsers` also deduplicates
* `learner_progress`, `user_credentials`, `committee_interest`, and
* `user_badges`; those are records rather than authority, and refusing a
* promote over an overlapping badge or completed module would block nearly
* every legitimate consolidation. Callers say so in the confirmation text
* instead of enumerating them.
*/

import { query } from './client.js';

export interface SupersededMembershipOverlap {
/** Organizations where the source's `organization_memberships` row is deleted. */
organizationIds: string[];
/** Working groups where the source's `working_group_memberships` row is deleted. */
workingGroupIds: string[];
}

/**
* Memberships that consolidating `sourceUserId` onto `targetUserId` would
* DELETE because the target already holds a row for the same partner key.
*
* Only the overlap is reported. Non-overlapping memberships move forward
* intact, so including those would refuse every ordinary consolidation.
*/
export async function findSupersededMemberships(
sourceUserId: string,
targetUserId: string,
): Promise<SupersededMembershipOverlap> {
const [organizations, workingGroups] = await Promise.all([
query<{ workos_organization_id: string }>(
`SELECT om.workos_organization_id
FROM organization_memberships om
WHERE om.workos_user_id = $1
AND EXISTS (
SELECT 1 FROM organization_memberships target
WHERE target.workos_user_id = $2
AND target.workos_organization_id = om.workos_organization_id
)
ORDER BY om.workos_organization_id`,
[sourceUserId, targetUserId],
),
query<{ working_group_id: string }>(
`SELECT wgm.working_group_id
FROM working_group_memberships wgm
WHERE wgm.workos_user_id = $1
AND EXISTS (
SELECT 1 FROM working_group_memberships target
WHERE target.workos_user_id = $2
AND target.working_group_id = wgm.working_group_id
)
ORDER BY wgm.working_group_id`,
[sourceUserId, targetUserId],
),
]);

return {
organizationIds: organizations.rows.map((row) => row.workos_organization_id),
workingGroupIds: workingGroups.rows.map((row) => row.working_group_id),
};
}
22 changes: 22 additions & 0 deletions server/src/routes/admin/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { SlackDatabase } from '../../db/slack-db.js';
import { WorkingGroupDatabase } from '../../db/working-group-db.js';
import { getPool } from '../../db/client.js';
import { bumpAuthorizationEpochs } from '../../db/authorization-epoch-db.js';
import { findSupersededMemberships } from '../../db/membership-consolidation-db.js';
import { backfillOrganizationMemberships, backfillUsers, backfillOrganizationDomains } from '../workos-webhooks.js';
import { sendSlackInviteEmail, hasSlackInviteBeenSent } from '../../notifications/email.js';
import { getWorkos } from '../../auth/workos-client.js';
Expand Down Expand Up @@ -1395,6 +1396,27 @@ export function createAdminUsersRouter(): Router {
});
}

// Foot-gun gate: promote runs the same consolidation as link-credential.
// Memberships whose partner key the incoming credential already holds are
// DELETED by it — the outgoing row's role, seat, upstream membership id,
// and join provenance are not recoverable, and an unlink cannot put them
// back. Require stated intent, matching the `consolidate: true` gate on
// the bind path above.
if (req.body?.consolidate !== true) {
const superseded = await findSupersededMemberships(currentPrimaryId, newPrimaryId);
const supersededCount =
superseded.organizationIds.length + superseded.workingGroupIds.length;
if (supersededCount > 0) {
return res.status(409).json({
error: 'Promoting would delete memberships',
message: `The outgoing primary holds ${supersededCount} membership(s) whose organization or working group the incoming credential already belongs to. Promoting deletes those rows — their role, seat, WorkOS membership id, and join provenance cannot be recovered. Certification progress, credentials, badges, and committee interest are consolidated in the same operation and deduplicated on conflict; they are not enumerated here. Re-submit with \`"consolidate": true\` to confirm this is intended.`,
consolidate_confirmation_required: true,
superseded_organization_ids: superseded.organizationIds,
superseded_working_group_ids: superseded.workingGroupIds,
});
}
}

// Run mergeUsers with ensurePrimaryFlag so the data move, the secondary
// rebind, AND the new primary's is_primary=TRUE flip all happen in one
// transaction. This closes the window where the identity has zero
Expand Down
61 changes: 61 additions & 0 deletions server/tests/integration/admin-promote-credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,67 @@ describe('admin promote credential to primary', () => {
expect(memberships.rows.every(r => r.workos_user_id === TARGET_USER_ID)).toBe(true);
});

/**
* Both credentials hold a membership in the same organization. The
* consolidation deletes the outgoing primary's row, so the endpoint must
* refuse without stated intent.
*/
async function setupOverlappingPair() {
await setupBoundPair();
// Give the target its own membership in the host's org, so promoting
// would delete the host's row for that org rather than move it.
await pool.query(
`INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role, created_at, updated_at)
VALUES ($1, $2, 'target@test.example', 'member', NOW(), NOW())`,
[TARGET_USER_ID, HOST_ORG_ID]
);
}

it('409s rather than silently deleting a membership the target already holds', async () => {
await setupOverlappingPair();

const response = await request(app)
.post(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}/promote`)
.expect(409);

expect(response.body).toMatchObject({
consolidate_confirmation_required: true,
superseded_organization_ids: [HOST_ORG_ID],
superseded_working_group_ids: [],
});

// Nothing moved: the host is still primary and still holds its row.
const bindings = await pool.query<{ is_primary: boolean }>(
`SELECT is_primary FROM identity_workos_users WHERE workos_user_id = $1`,
[HOST_USER_ID]
);
expect(bindings.rows[0].is_primary).toBe(true);
});

it('promotes once the caller confirms the consolidation', async () => {
await setupOverlappingPair();

await request(app)
.post(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}/promote`)
.send({ consolidate: true })
.expect(200);

const bindings = await pool.query<{ workos_user_id: string; is_primary: boolean }>(
`SELECT workos_user_id, is_primary FROM identity_workos_users
WHERE workos_user_id IN ($1, $2)`,
[HOST_USER_ID, TARGET_USER_ID]
);
expect(bindings.rows.find(r => r.workos_user_id === TARGET_USER_ID)?.is_primary).toBe(true);
});

it('promotes without confirmation when the memberships do not overlap', async () => {
await setupBoundPair();

await request(app)
.post(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}/promote`)
.expect(200);
});

it('writes a promote_credential_to_primary audit row', async () => {
await setupBoundPair();
await request(app)
Expand Down
Loading
Loading