Add everyone system user for anonymous registry pull.
Reserved PUBLIC ACLs can grant unauthenticated pull while blocking login, deletion, and anonymous push/delete. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
4f99a99083
commit
3fa4ae2b66
12 files changed
+196
-39
No files matched your search
@@ -9,6 +9,10 @@ export default defineEventHandler(async (event) => {
|
||||
await ensureBootstrap()
|
||||
const body = bodySchema.parse(await readBody(event))
|
||||
|
||||
if (isEveryoneUsername(body.username)) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username: body.username } })
|
||||
if (!user || !user.active) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
|
||||
@@ -20,6 +20,10 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Cannot delete bootstrap admin' })
|
||||
}
|
||||
|
||||
if (isEveryoneUsername(existing.username)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Cannot delete system user "everyone"' })
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id } })
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -29,6 +29,14 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const body = bodySchema.parse(await readBody(event))
|
||||
const isEveryone = isEveryoneUsername(existing.username)
|
||||
|
||||
if (isEveryone && (body.password || body.role)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Cannot change password or role for system user "everyone"',
|
||||
})
|
||||
}
|
||||
|
||||
if (existing.username === (process.env.ADMIN_USERNAME || 'admin') && body.active === false) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Cannot deactivate bootstrap admin' })
|
||||
@@ -42,13 +50,15 @@ export default defineEventHandler(async (event) => {
|
||||
if (body.acls) {
|
||||
await tx.acl.deleteMany({ where: { userId: id } })
|
||||
await tx.acl.createMany({
|
||||
data: body.acls.map((acl) => ({
|
||||
userId: id,
|
||||
repository: acl.repository,
|
||||
canPull: acl.canPull,
|
||||
canPush: acl.canPush,
|
||||
canDelete: acl.canDelete,
|
||||
})),
|
||||
data: body.acls
|
||||
.filter((acl) => !isEveryone || acl.canPull)
|
||||
.map((acl) => ({
|
||||
userId: id,
|
||||
repository: acl.repository,
|
||||
canPull: isEveryone ? true : acl.canPull,
|
||||
canPush: isEveryone ? false : acl.canPush,
|
||||
canDelete: isEveryone ? false : acl.canDelete,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ export default defineEventHandler(async (event) => {
|
||||
await ensureBootstrap()
|
||||
|
||||
const body = bodySchema.parse(await readBody(event))
|
||||
if (isEveryoneUsername(body.username)) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Username "everyone" is reserved' })
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username: body.username } })
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Username already exists' })
|
||||
|
||||
@@ -13,7 +13,8 @@ export default defineEventHandler(async (event) => {
|
||||
let password: string | undefined
|
||||
let refreshTokenIn: string | undefined
|
||||
|
||||
const basic = parseBasicAuth(getHeader(event, 'authorization'))
|
||||
const authorizationHeader = getHeader(event, 'authorization')
|
||||
const basic = parseBasicAuth(authorizationHeader)
|
||||
if (basic) {
|
||||
username = basic.username
|
||||
password = basic.password
|
||||
@@ -32,6 +33,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
let user =
|
||||
null as null | Awaited<ReturnType<typeof prisma.user.findUnique<{ include: { acls: true } }>>>
|
||||
let anonymous = false
|
||||
|
||||
if (refreshTokenIn) {
|
||||
const refreshUser = await verifyRefreshToken(refreshTokenIn)
|
||||
@@ -42,9 +44,9 @@ export default defineEventHandler(async (event) => {
|
||||
where: { username: refreshUser },
|
||||
include: { acls: true },
|
||||
})
|
||||
} else {
|
||||
if (!username || !password) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
|
||||
} else if (username || password || authorizationHeader) {
|
||||
if (!username || !password || isEveryoneUsername(username)) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
@@ -57,19 +59,31 @@ export default defineEventHandler(async (event) => {
|
||||
if (!ok) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
} else {
|
||||
anonymous = true
|
||||
user = await prisma.user.findUnique({
|
||||
where: { username: EVERYONE_USERNAME },
|
||||
include: { acls: true },
|
||||
})
|
||||
}
|
||||
|
||||
if (!user || !user.active) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
if (account && account !== user.username) {
|
||||
if (!anonymous && isEveryoneUsername(user.username)) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
if (!anonymous && account && account !== user.username) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Account mismatch' })
|
||||
}
|
||||
|
||||
const access = authorizeScopes(user, scopes)
|
||||
const access = anonymous
|
||||
? authorizeAnonymousScopes(user, scopes)
|
||||
: authorizeScopes(user, scopes)
|
||||
const { token, expiresIn, issuedAt } = await signRegistryToken({
|
||||
subject: user.username,
|
||||
subject: anonymous ? 'anonymous' : user.username,
|
||||
access,
|
||||
})
|
||||
|
||||
@@ -80,7 +94,7 @@ export default defineEventHandler(async (event) => {
|
||||
issued_at: issuedAt,
|
||||
}
|
||||
|
||||
if (offline || refreshTokenIn) {
|
||||
if (!anonymous && (offline || refreshTokenIn)) {
|
||||
response.refresh_token = await createRefreshToken(user.username)
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,29 @@ export function authorizeScopes(user: UserWithAcls, scopes: ParsedScope[]): Regi
|
||||
return access
|
||||
}
|
||||
|
||||
/** Anonymous clients may only receive pull access explicitly granted to everyone. */
|
||||
export function authorizeAnonymousScopes(
|
||||
everyone: UserWithAcls,
|
||||
scopes: ParsedScope[],
|
||||
): RegistryAccess[] {
|
||||
if (!everyone.active) return []
|
||||
|
||||
const access: RegistryAccess[] = []
|
||||
for (const scope of scopes) {
|
||||
if (scope.type !== 'repository' || !scope.actions.includes('pull')) continue
|
||||
|
||||
const allowed = allowedActionsForRepo(everyone, scope.name)
|
||||
if (!allowed.includes('pull') && !allowed.includes('*')) continue
|
||||
|
||||
access.push({
|
||||
type: 'repository',
|
||||
name: scope.name,
|
||||
actions: ['pull'],
|
||||
})
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
export function parseBasicAuth(header: string | undefined): { username: string; password: string } | null {
|
||||
if (!header?.startsWith('Basic ')) return null
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ensureTokenCerts } from './certs'
|
||||
import { prisma } from './prisma'
|
||||
import { seedAdmin } from '../../prisma/seed'
|
||||
import { seedAdmin, seedEveryone } from '../../prisma/seed'
|
||||
|
||||
let ready: Promise<void> | null = null
|
||||
|
||||
@@ -8,6 +8,7 @@ async function bootstrap() {
|
||||
ensureTokenCerts()
|
||||
await prisma.$connect()
|
||||
await seedAdmin(prisma)
|
||||
await seedEveryone(prisma)
|
||||
}
|
||||
|
||||
export async function ensureBootstrap() {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const EVERYONE_USERNAME = 'everyone'
|
||||
|
||||
export function isEveryoneUsername(username: string): boolean {
|
||||
return username.toLowerCase() === EVERYONE_USERNAME
|
||||
}
|
||||
Reference in new issue
Block a user