Files
docker-registry-aio/app/server/utils/authz.ts
T
ysj7705andCursor 3fa4ae2b66 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>
2026-08-06 17:12:27 +09:00

131 lines
3.4 KiB
TypeScript

import type { Acl, Role, User } from '../../generated/prisma/client'
import {
actionsFromAcl,
intersectActions,
matchRepository,
type Action,
} from './acl'
import type { RegistryAccess } from './token'
export type UserWithAcls = User & { acls: Acl[] }
export type ParsedScope = {
type: string
name: string
actions: string[]
}
/** Parse Docker scope strings like `repository:library/nginx:pull,push` */
export function parseScopes(scopeParam: string | string[] | undefined): ParsedScope[] {
if (!scopeParam) return []
const raw = Array.isArray(scopeParam) ? scopeParam : [scopeParam]
const scopes: ParsedScope[] = []
for (const item of raw) {
for (const part of item.split(' ')) {
if (!part) continue
const first = part.indexOf(':')
const last = part.lastIndexOf(':')
if (first < 0 || last <= first) continue
const type = part.slice(0, first)
const name = part.slice(first + 1, last)
const actions = part
.slice(last + 1)
.split(',')
.map((a) => a.trim())
.filter(Boolean)
scopes.push({ type, name, actions })
}
}
return scopes
}
export function allowedActionsForRepo(user: UserWithAcls, repository: string): Action[] {
if (user.role === 'ADMIN') {
return ['pull', 'push', 'delete', '*']
}
const allowed = new Set<Action>()
for (const acl of user.acls) {
if (!matchRepository(acl.repository, repository)) continue
for (const action of actionsFromAcl(acl)) {
allowed.add(action)
}
}
return [...allowed]
}
export function authorizeScopes(user: UserWithAcls, scopes: ParsedScope[]): RegistryAccess[] {
const access: RegistryAccess[] = []
for (const scope of scopes) {
if (user.role === 'ADMIN') {
access.push({
type: scope.type,
name: scope.name,
actions: scope.actions,
})
continue
}
if (scope.type !== 'repository') {
continue
}
const allowed = allowedActionsForRepo(user, scope.name)
const granted = intersectActions(scope.actions, allowed)
if (granted.length > 0) {
access.push({
type: scope.type,
name: scope.name,
actions: granted,
})
}
}
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 {
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
const idx = decoded.indexOf(':')
if (idx < 0) return null
return {
username: decoded.slice(0, idx),
password: decoded.slice(idx + 1),
}
} catch {
return null
}
}
export function assertRole(role: string): Role {
if (role === 'ADMIN' || role === 'USER') return role
throw createError({ statusCode: 400, statusMessage: 'Invalid role' })
}