feat(fleet-secrets): graduate encrypted fleet-wide environment bundles to Community (#1758)

* feat(fleet-secrets): graduate encrypted fleet-wide environment bundles to Community

* fix(fleet-secrets): update reachability test for Secrets community graduation

* fix(fleet-secrets): address review findings

* fix(fleet-secrets): add HTTP-level Community admin push/import tests and non-admin tab-hidden test
This commit is contained in:
Anso
2026-08-03 08:42:46 -04:00
committed by GitHub
parent 5240957ff3
commit 5f5cd5ea51
11 changed files with 183 additions and 73 deletions
+120 -29
View File
@@ -5,7 +5,7 @@
* - encrypt round-trip via CryptoService
* - DatabaseService secret + version + push CRUD
* - SecretsService versioning, importFromStack, executePush aggregation
* - Route guards (requirePaid 403, requireAdmin 403, requireUserSession 403, push lock 409)
* - Route guards (requireAdmin 403, requireUserSession 403, push lock 409)
* - Hub-only enforcement is covered in hub-only-guard.test.ts
* - developer_mode diagnostics gating (and that diagnostics never log the secret value)
* - getAuditSummary patterns for /secrets routes
@@ -74,9 +74,6 @@ beforeAll(async () => {
({ SecretsService } = await import('../services/SecretsService'));
({ CryptoService } = await import('../services/CryptoService'));
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
({ app } = await import('../index'));
});
@@ -387,34 +384,12 @@ describe('getAuditSummary for secrets routes', () => {
// ---- Route guards via supertest ----
describe('Routes /api/secrets tier gating and lock', () => {
it('returns 403 when license is community', async () => {
const { LicenseService } = await import('../services/LicenseService');
// Use mockReturnValueOnce so the outer beforeAll spy keeps returning 'paid' for sibling tests.
// requirePaid only consults getTier once per request via effectiveTier(req).
const inst = LicenseService.getInstance();
const tierSpy = vi.spyOn(inst, 'getTier');
tierSpy.mockReturnValueOnce('community');
const res = await request(app)
.get('/api/secrets')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
describe('Routes /api/secrets basic guards', () => {
it('rejects unauthenticated requests', async () => {
const res = await request(app).get('/api/secrets');
expect(res.status).toBe(401);
});
it('returns 200 when paid', async () => {
const res = await request(app)
.get('/api/secrets')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('rejects malformed body on POST /secrets', async () => {
const res = await request(app)
.post('/api/secrets')
@@ -424,6 +399,114 @@ describe('Routes /api/secrets tier gating and lock', () => {
});
});
// ---- Community Admin happy path: Fleet Secrets is available without a paid license ----
describe('Routes /api/secrets Community Admin access', () => {
it('lets a Community admin list bundles', async () => {
const res = await request(app)
.get('/api/secrets')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('lets a Community admin create, read, update, and delete a bundle', async () => {
// Create
const create = await request(app)
.post('/api/secrets')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ name: 'community-test-bundle', kv: { KEY: 'val' } });
expect(create.status).toBe(201);
const id: number = create.body.id;
// Read
const get = await request(app)
.get(`/api/secrets/${id}`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(get.status).toBe(200);
expect(get.body.kv).toEqual({ KEY: 'val' });
// Update
const upd = await request(app)
.put(`/api/secrets/${id}`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ kv: { KEY: 'updated' } });
expect(upd.status).toBe(200);
// Delete
const del = await request(app)
.delete(`/api/secrets/${id}`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(del.status).toBe(200);
});
it('lets a Community admin list versions', async () => {
const svc = SecretsService.getInstance();
const { id } = svc.create({ name: 'versions-community', kv: { X: '1' }, user: TEST_USERNAME });
const res = await request(app)
.get(`/api/secrets/${id}/versions`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
});
it('lets a Community admin import from a stack over HTTP', async () => {
const composeDir = process.env.COMPOSE_DIR!;
const stackDir = path.join(composeDir, 'importstack');
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, '.env'), 'IMPORT_KEY=hello\n');
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
const db = DatabaseService.getInstance();
const localNode = db.getNodes().find(n => n.type === 'local')!;
const svc = SecretsService.getInstance();
const { id } = svc.create({ name: 'import-http', kv: { X: '1' }, user: TEST_USERNAME });
const res = await request(app)
.post(`/api/secrets/${id}/import-from-stack`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ nodeId: localNode.id, stackName: 'importstack', envFileBasename: '.env' });
expect(res.status).toBe(200);
expect(res.body.kv).toEqual({ IMPORT_KEY: 'hello' });
});
it('lets a Community admin preview and execute a push over HTTP', async () => {
const composeDir = process.env.COMPOSE_DIR!;
const stackDir = path.join(composeDir, 'pushstack');
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, '.env'), 'EXISTING=keep\n');
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
const db = DatabaseService.getInstance();
const localNode = db.getNodes().find(n => n.type === 'local')!;
const svc = SecretsService.getInstance();
const { id } = svc.create({ name: 'push-http', kv: { EXISTING: 'updated', NEWKEY: 'added' }, user: TEST_USERNAME });
// Preview
const preview = await request(app)
.post(`/api/secrets/${id}/push/preview`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ selector: { type: 'nodes', ids: [localNode.id] }, stackName: 'pushstack', envFileBasename: '.env' });
expect(preview.status).toBe(200);
expect(Array.isArray(preview.body)).toBe(true);
expect(preview.body.length).toBeGreaterThanOrEqual(1);
expect(preview.body[0].reachable).toBe(true);
// Execute push
const push = await request(app)
.post(`/api/secrets/${id}/push`)
.set('Authorization', `Bearer ${adminToken()}`)
.send({ selector: { type: 'nodes', ids: [localNode.id] }, stackName: 'pushstack', envFileBasename: '.env' });
expect(push.status).toBe(200);
expect(push.body.pushId).toBeTruthy();
expect(push.body.results).toHaveLength(1);
expect(push.body.results[0].status).toBe('ok');
// Verify the .env was actually written
const envText = fs.readFileSync(path.join(composeDir, 'pushstack', '.env'), 'utf-8');
const kv = parseEnv(envText);
expect(kv.EXISTING).toBe('updated');
expect(kv.NEWKEY).toBe('added');
});
});
// ---- Admin-role gating: secrets reveal decrypted values, so every route is admin-only ----
describe('Routes /api/secrets admin-role gating', () => {
@@ -439,18 +522,26 @@ describe('Routes /api/secrets admin-role gating', () => {
return authToken('sec-viewer', 'viewer', user.token_version);
}
it.each(SECRET_ENDPOINTS)('403s a non-admin paid user on %s %s', async (method, p) => {
it.each(SECRET_ENDPOINTS)('403s a non-admin user on %s %s', async (method, p) => {
const res = await callWithToken(method, p, viewerToken());
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('lets an admin paid user list (200)', async () => {
it('lets an admin user list (200)', async () => {
const res = await request(app)
.get('/api/secrets')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
});
it('403s a Community viewer on all endpoints', async () => {
for (const [method, p] of SECRET_ENDPOINTS) {
const res = await callWithToken(method, p, viewerToken());
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
}
});
});
// ---- Machine-credential rejection: secrets need a real signed-in user session ----
+1 -10
View File
@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireAdmin, requireUserSession, requireBody } from '../middleware/tierGates';
import { requireAdmin, requireUserSession, requireBody } from '../middleware/tierGates';
import { SecretsService, PushBusyError, type SecretKv } from '../services/SecretsService';
import { DatabaseService, type BlueprintSelector } from '../services/DatabaseService';
import { isValidStackName } from '../utils/validation';
@@ -66,7 +66,6 @@ function parsePushBody(body: unknown): PushBody | { error: string } {
secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireUserSession(req, res)) return;
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
try {
const items = SecretsService.getInstance().list();
@@ -79,7 +78,6 @@ secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Prom
secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireUserSession(req, res)) return;
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
try {
@@ -121,7 +119,6 @@ secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Pro
secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireUserSession(req, res)) return;
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
@@ -141,7 +138,6 @@ secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): P
secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireUserSession(req, res)) return;
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
try {
@@ -181,7 +177,6 @@ secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): P
secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireUserSession(req, res)) return;
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
@@ -201,7 +196,6 @@ secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response)
secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireUserSession(req, res)) return;
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'secret ID');
@@ -219,7 +213,6 @@ secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Res
secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireUserSession(req, res)) return;
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
try {
@@ -249,7 +242,6 @@ secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request
secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireUserSession(req, res)) return;
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
try {
@@ -274,7 +266,6 @@ secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res
secretsRouter.post('/:id/push', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireUserSession(req, res)) return;
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
try {