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,