feat(auth): enforce single-owner invariant in bootstrap flow
This commit is contained in:
@ -359,7 +359,10 @@ export async function ensureSupportUserBootstrap(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
supportBootstrapPromise = bootstrapSystemUsers()
|
||||
supportBootstrapPromise = (async () => {
|
||||
await bootstrapSystemUsers()
|
||||
await enforceOwnerInvariant()
|
||||
})()
|
||||
|
||||
try {
|
||||
await supportBootstrapPromise
|
||||
@ -369,8 +372,88 @@ export async function ensureSupportUserBootstrap(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function promoteFirstRegisteredUserToOwner(userId: string): Promise<boolean> {
|
||||
type OwnerInvariantState = {
|
||||
ownerId: string | null
|
||||
ownerCount: number
|
||||
repaired: boolean
|
||||
}
|
||||
|
||||
export async function enforceOwnerInvariant(): Promise<OwnerInvariantState> {
|
||||
return db.$transaction(async (tx) => {
|
||||
const owners = await tx.user.findMany({
|
||||
where: { role: "owner" },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
select: { id: true, isProtected: true, isBanned: true },
|
||||
})
|
||||
|
||||
if (owners.length === 0) {
|
||||
const candidate = await tx.user.findFirst({
|
||||
where: {
|
||||
role: {
|
||||
not: "support",
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!candidate) {
|
||||
return {
|
||||
ownerId: null,
|
||||
ownerCount: 0,
|
||||
repaired: false,
|
||||
}
|
||||
}
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: candidate.id },
|
||||
data: {
|
||||
role: "owner",
|
||||
isProtected: true,
|
||||
isBanned: false,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
ownerId: candidate.id,
|
||||
ownerCount: 1,
|
||||
repaired: true,
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalOwner = owners[0]
|
||||
const extraOwnerIds = owners.slice(1).map((owner) => owner.id)
|
||||
|
||||
if (extraOwnerIds.length > 0) {
|
||||
await tx.user.updateMany({
|
||||
where: { id: { in: extraOwnerIds } },
|
||||
data: {
|
||||
role: "admin",
|
||||
isProtected: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!canonicalOwner.isProtected || canonicalOwner.isBanned) {
|
||||
await tx.user.update({
|
||||
where: { id: canonicalOwner.id },
|
||||
data: {
|
||||
isProtected: true,
|
||||
isBanned: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ownerId: canonicalOwner.id,
|
||||
ownerCount: 1,
|
||||
repaired: extraOwnerIds.length > 0 || !canonicalOwner.isProtected || canonicalOwner.isBanned,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function promoteFirstRegisteredUserToOwner(userId: string): Promise<boolean> {
|
||||
const promoted = await db.$transaction(async (tx) => {
|
||||
const existingOwner = await tx.user.findFirst({
|
||||
where: { role: "owner" },
|
||||
select: { id: true },
|
||||
@ -393,6 +476,12 @@ export async function promoteFirstRegisteredUserToOwner(userId: string): Promise
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (promoted) {
|
||||
await enforceOwnerInvariant()
|
||||
}
|
||||
|
||||
return promoted
|
||||
}
|
||||
|
||||
export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null {
|
||||
|
||||
Reference in New Issue
Block a user