From 2298f470bd195cd64513a182852f49f28406085f Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 8 Jun 2026 08:59:27 -0400 Subject: [PATCH] feat: allow local Docker Hub, GHCR, and custom registry credentials on Community (#1338) Private registry credentials are no longer paid-only. Community admins can add and manage Docker Hub, GHCR, and custom/self-hosted registry credentials, stored locally on the node. AWS ECR (short-lived token refresh, AWS region) stays on the paid tier. Backend gates ECR per-type via a single helper applied at create, update (using the effective type so an existing row cannot be switched to ECR), the per-id connection test (gated on the stored type), and the stateless test. The admin role and API-token-scope rejection are unchanged on every route. The frontend drops the blanket paywall on the Registries section, filters ECR out of the type selector for Community, and states the ECR requirement inline. --- backend/src/__tests__/registries-tier.test.ts | 164 ++++++++++++++++++ backend/src/routes/registries.ts | 21 ++- frontend/src/components/RegistriesSection.tsx | 17 +- .../settings/__tests__/registry.test.ts | 5 +- frontend/src/components/settings/registry.ts | 2 +- 5 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 backend/src/__tests__/registries-tier.test.ts diff --git a/backend/src/__tests__/registries-tier.test.ts b/backend/src/__tests__/registries-tier.test.ts new file mode 100644 index 00000000..afaf500e --- /dev/null +++ b/backend/src/__tests__/registries-tier.test.ts @@ -0,0 +1,164 @@ +/** + * Tier split for private registry credentials: + * Docker Hub / GHCR / custom -> Community (admin only) + * AWS ECR -> Admiral (paid) + * + * Self-hosters routinely pull from GHCR and private Docker Hub, so local + * credential management is free; only ECR (short-lived token refresh) is paid. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let adminCookie: string; +let viewerCookie: string; +let LicenseService: typeof import('../services/LicenseService').LicenseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ LicenseService } = await import('../services/LicenseService')); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + const { DatabaseService } = await import('../services/DatabaseService'); + const viewerHash = await bcrypt.hash('regviewer1', 1); + DatabaseService.getInstance().addUser({ username: 'registry-viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app).post('/api/auth/login').send({ username: 'registry-viewer', password: 'regviewer1' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +const dockerhub = { name: 'dh', url: 'https://index.docker.io/v1/', type: 'dockerhub', username: 'u', secret: 's' }; +const ghcr = { name: 'gh', url: 'ghcr.io', type: 'ghcr', username: 'u', secret: 's' }; +const ecr = { name: 'ec', url: '123456789.dkr.ecr.us-east-1.amazonaws.com', type: 'ecr', username: 'AKIA', secret: 's', aws_region: 'us-east-1' }; + +function asCommunity(): { restore: () => void } { + const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + return { restore: () => spy.mockReturnValue('paid') }; +} + +describe('Registry credentials tier split', () => { + it('lets a Community admin create Docker Hub and GHCR credentials', async () => { + const { restore } = asCommunity(); + try { + const dh = await request(app).post('/api/registries').set('Cookie', adminCookie).send(dockerhub); + expect(dh.status).toBe(201); + const gh = await request(app).post('/api/registries').set('Cookie', adminCookie).send(ghcr); + expect(gh.status).toBe(201); + } finally { + restore(); + } + }); + + it('lets a Community admin list registries', async () => { + const { restore } = asCommunity(); + try { + const res = await request(app).get('/api/registries').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + } finally { + restore(); + } + }); + + it('rejects an ECR create for a Community admin with 403 PAID_REQUIRED', async () => { + const { restore } = asCommunity(); + try { + const res = await request(app).post('/api/registries').set('Cookie', adminCookie).send(ecr); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + } finally { + restore(); + } + }); + + it('rejects an ECR stateless test for a Community admin with 403 PAID_REQUIRED', async () => { + const { restore } = asCommunity(); + try { + const res = await request(app).post('/api/registries/test').set('Cookie', adminCookie).send(ecr); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + } finally { + restore(); + } + }); + + it('lets a Community admin create a custom registry', async () => { + const { restore } = asCommunity(); + try { + const res = await request(app).post('/api/registries').set('Cookie', adminCookie) + .send({ name: 'self', url: 'registry.example.com', type: 'custom', username: 'u', secret: 's' }); + expect(res.status).toBe(201); + } finally { + restore(); + } + }); + + it('lets a paid admin create an ECR registry', async () => { + const res = await request(app).post('/api/registries').set('Cookie', adminCookie).send(ecr); + expect(res.status).toBe(201); + }); + + it('rejects a Community admin switching an existing registry to ECR via PUT', async () => { + // Created as a paid admin so the row exists. + const created = await request(app).post('/api/registries').set('Cookie', adminCookie) + .send({ name: 'switchme', url: 'ghcr.io', type: 'ghcr', username: 'u', secret: 's' }); + expect(created.status).toBe(201); + const id = created.body.id; + + const { restore } = asCommunity(); + try { + const res = await request(app).put(`/api/registries/${id}`).set('Cookie', adminCookie) + .send({ type: 'ecr', aws_region: 'us-east-1' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + } finally { + restore(); + } + }); + + it('rejects a Community admin editing an existing ECR registry via PUT (effectiveType falls back to stored type)', async () => { + const created = await request(app).post('/api/registries').set('Cookie', adminCookie).send(ecr); + expect(created.status).toBe(201); + const id = created.body.id; + + const { restore } = asCommunity(); + try { + const res = await request(app).put(`/api/registries/${id}`).set('Cookie', adminCookie) + .send({ name: 'renamed' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + } finally { + restore(); + } + }); + + it('rejects a Community admin testing a stored ECR registry via POST /:id/test', async () => { + const created = await request(app).post('/api/registries').set('Cookie', adminCookie).send(ecr); + expect(created.status).toBe(201); + const id = created.body.id; + + const { restore } = asCommunity(); + try { + const res = await request(app).post(`/api/registries/${id}/test`).set('Cookie', adminCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + } finally { + restore(); + } + }); + + it('denies a non-admin (viewer) registry creation regardless of tier', async () => { + const res = await request(app).post('/api/registries').set('Cookie', viewerCookie).send(dockerhub); + expect(res.status).toBe(403); + }); +}); diff --git a/backend/src/routes/registries.ts b/backend/src/routes/registries.ts index ec756208..6cc80b0e 100644 --- a/backend/src/routes/registries.ts +++ b/backend/src/routes/registries.ts @@ -22,12 +22,19 @@ function isValidRegistryUrl(url: string, type: string): boolean { return true; } +// Docker Hub, GHCR, and custom registry credentials are a Community capability. +// ECR (short-lived token refresh, AWS region) stays paid. Returns true when the +// request may proceed and false after sending the 403, mirroring the tier guards. +function allowRegistryType(type: string | undefined, req: Request, res: Response): boolean { + if (type === 'ecr') return requirePaid(req, res); + return true; +} + export const registriesRouter = Router(); registriesRouter.get('/', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; try { res.json(RegistryService.getInstance().getAll()); } catch (error) { @@ -39,7 +46,6 @@ registriesRouter.get('/', (req: Request, res: Response): void => { registriesRouter.post('/', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; try { const { name, url, type, username, secret, aws_region } = req.body; @@ -52,6 +58,7 @@ registriesRouter.post('/', (req: Request, res: Response): void => { if (!type || !(VALID_REGISTRY_TYPES as readonly string[]).includes(type)) { res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return; } + if (!allowRegistryType(type, req, res)) return; if (!isValidRegistryUrl(url, type)) { res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return; } @@ -76,7 +83,6 @@ registriesRouter.post('/', (req: Request, res: Response): void => { registriesRouter.put('/:id', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'registry ID'); if (id === null) return; @@ -96,6 +102,7 @@ registriesRouter.put('/:id', (req: Request, res: Response): void => { res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return; } const effectiveType = type ?? existing.type; + if (!allowRegistryType(effectiveType, req, res)) return; if (url !== undefined && !isValidRegistryUrl(url, effectiveType)) { res.status(400).json({ error: 'Registry URL must use http:// or https:// (or no protocol).' }); return; } @@ -114,7 +121,6 @@ registriesRouter.put('/:id', (req: Request, res: Response): void => { registriesRouter.delete('/:id', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'registry ID'); if (id === null) return; @@ -133,11 +139,14 @@ registriesRouter.delete('/:id', (req: Request, res: Response): void => { registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'registry ID'); if (id === null) return; + const existing = RegistryService.getInstance().getById(id); + if (!existing) { res.status(404).json({ error: 'Registry not found' }); return; } + if (!allowRegistryType(existing.type, req, res)) return; + const result = await RegistryService.getInstance().testConnection(id); res.json(result); } catch (error) { @@ -149,13 +158,13 @@ registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise< registriesRouter.post('/test', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; try { const { type, url, username, secret, aws_region } = req.body; if (!type || !(VALID_REGISTRY_TYPES as readonly string[]).includes(type)) { res.status(400).json({ error: `Type must be one of: ${VALID_REGISTRY_TYPES.join(', ')}` }); return; } + if (!allowRegistryType(type, req, res)) return; if (typeof url !== 'string' || url.length === 0 || url.length > 500) { res.status(400).json({ error: 'URL is required (max 500 characters).' }); return; } diff --git a/frontend/src/components/RegistriesSection.tsx b/frontend/src/components/RegistriesSection.tsx index 07bd03c4..83a06d4c 100644 --- a/frontend/src/components/RegistriesSection.tsx +++ b/frontend/src/components/RegistriesSection.tsx @@ -8,7 +8,7 @@ import { Combobox } from '@/components/ui/combobox'; import { ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; -import { PaidGate } from './PaidGate'; +import { useLicense } from '@/context/LicenseContext'; import { CapabilityGate } from './CapabilityGate'; import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock, Zap } from 'lucide-react'; import { SettingsPrimaryButton } from './settings/SettingsActions'; @@ -88,6 +88,10 @@ function formatDate(ts: number): string { } export function RegistriesSection() { + // Docker Hub, GHCR, and custom registry credentials are free; AWS ECR + // (short-lived token refresh) stays paid, so the ECR type is gated. + const { isPaid } = useLicense(); + const typeOptions = isPaid ? TYPE_OPTIONS : TYPE_OPTIONS.filter(o => o.value !== 'ecr'); const [registries, setRegistries] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -283,8 +287,7 @@ export function RegistriesSection() { }; return ( - - +
{ resetForm(); setShowForm(true); }}> @@ -298,12 +301,15 @@ export function RegistriesSection() {
handleTypeChange(v as RegistryType)} placeholder="Select a registry type" searchPlaceholder="Search types..." /> + {!isPaid && ( +

AWS ECR requires Admiral.

+ )}
@@ -473,7 +479,6 @@ export function RegistriesSection() {

- - + ); } diff --git a/frontend/src/components/settings/__tests__/registry.test.ts b/frontend/src/components/settings/__tests__/registry.test.ts index b4f84771..aebc09d4 100644 --- a/frontend/src/components/settings/__tests__/registry.test.ts +++ b/frontend/src/components/settings/__tests__/registry.test.ts @@ -66,8 +66,9 @@ describe('settings registry', () => { expect(byId.get('security')?.label).toBe('Vulnerability Scanning'); }); - it('preserves the paid gate on Registries', () => { + it('opens Registries to Community while keeping it admin-only', () => { const registries = SETTINGS_ITEMS.find(i => i.id === 'registries'); - expect(registries?.tier).toBe('paid'); + expect(registries?.tier).toBeNull(); + expect(registries?.adminOnly).toBe(true); }); }); diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index bb804b20..b2322625 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -139,7 +139,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ label: 'Registries', description: 'Private Docker registries and pull credentials.', keywords: ['docker', 'ghcr', 'ecr', 'private', 'pull', 'auth'], - tier: 'paid', + tier: null, scope: 'global', adminOnly: true, hiddenOnRemote: true,