mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
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:
@@ -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,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 {
|
||||
|
||||
Reference in New Issue
Block a user