From 583b577bd916f530108b97bcf61290fefcebe170 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 9 Sep 2026 14:47:15 -0400 Subject: [PATCH] feat(git-sources): node-scoped polling settings endpoint --- .../src/__tests__/git-source-routes.test.ts | 159 ++++++++++++++++++ .../proxy-scoped-settings-authz.test.ts | 48 ++++++ backend/src/proxy/remoteNodeProxy.ts | 11 +- backend/src/routes/gitSources.ts | 82 +++++++++ 4 files changed, 298 insertions(+), 2 deletions(-) diff --git a/backend/src/__tests__/git-source-routes.test.ts b/backend/src/__tests__/git-source-routes.test.ts index 75149f6e..b160c431 100644 --- a/backend/src/__tests__/git-source-routes.test.ts +++ b/backend/src/__tests__/git-source-routes.test.ts @@ -2526,3 +2526,162 @@ describe('git-source policy compatibility', () => { } }); }); + +describe('git-source polling settings', () => { + // A real viewer row, not just a signed token: the auth middleware resolves + // users against the database, so a token for a username with no row is + // rejected 401 before the node:manage gate ever runs. + let pollingViewerToken: string; + beforeAll(async () => { + DatabaseService.getInstance().addUser({ + username: 'polling-viewer', + password_hash: 'test', + role: 'viewer', + }); + pollingViewerToken = jwt.sign( + { username: 'polling-viewer', role: 'viewer' }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + }); + + function seedPollApp(stackName: string, policy: 'manual' | 'review' | 'automatic'): void { + const row = directApplicationFixture(`poll-app-${stackName}`, stackName); + row.source_policy = policy; + GitOpsStore.getInstance().insertApplication(row); + } + + function deletePollRows(stackName: string): void { + DatabaseService.getInstance().getDb() + .prepare('DELETE FROM gitops_applications WHERE id = ?') + .run(`poll-app-${stackName}`); + } + + it('GET requires node:manage', async () => { + const res = await request(app) + .get('/api/git-sources/polling') + .set('Authorization', `Bearer ${pollingViewerToken}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('GET returns the global interval and per-source rows', async () => { + const stackName = 'polling-get-row'; + seedPollApp(stackName, 'review'); + DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '7'); + try { + const res = await request(app) + .get('/api/git-sources/polling') + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(200); + expect(res.body.poll_interval_mins).toBe(7); + const row = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === stackName); + expect(row).toBeDefined(); + expect(row.source_policy).toBe('review'); + expect(row.poll_interval_secs).toBeNull(); + expect(row.next_poll_at).toBeNull(); + } finally { + DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0'); + deletePollRows(stackName); + } + }); + + it('PATCH rejects non-integer, negative, and oversized values with 400', async () => { + for (const value of [-1, 0.5, 10081, 'five', true, null]) { + const res = await request(app) + .patch('/api/git-sources/polling') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ poll_interval_mins: value }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/poll_interval_mins/i); + } + }); + + it('PATCH requires node:manage', async () => { + const res = await request(app) + .patch('/api/git-sources/polling') + .set('Authorization', `Bearer ${pollingViewerToken}`) + .send({ poll_interval_mins: 5 }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('PATCH stores the value and recomputes next_poll_at', async () => { + const stackName = 'polling-patch-auto'; + seedPollApp(stackName, 'automatic'); + const before = Date.now(); + try { + const res = await request(app) + .patch('/api/git-sources/polling') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ poll_interval_mins: 5 }); + expect(res.status).toBe(200); + expect(res.body.poll_interval_mins).toBe(5); + const row = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === stackName); + // Automatic sources get a cursor at now + 300s; review sources + // are re-armed on the same cadence, and only manual is left off + // the unattended schedule. + expect(row.next_poll_at).toBeGreaterThanOrEqual(before + 300_000); + expect(row.next_poll_at).toBeLessThanOrEqual(Date.now() + 300_000); + + const stored = GitOpsStore.getInstance().getApplication(`poll-app-${stackName}`); + expect(stored?.next_poll_at).toBe(row.next_poll_at); + } finally { + DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0'); + deletePollRows(stackName); + } + }); + + it('PATCH re-arms review sources on the new cadence and skips manual', async () => { + const reviewStack = 'polling-patch-review'; + const manualStack = 'polling-patch-manual'; + seedPollApp(reviewStack, 'review'); + seedPollApp(manualStack, 'manual'); + const staleCursor = 123456; + for (const stackName of [reviewStack, manualStack]) { + DatabaseService.getInstance().getDb() + .prepare('UPDATE gitops_applications SET next_poll_at = ? WHERE id = ?') + .run(staleCursor, `poll-app-${stackName}`); + } + try { + const res = await request(app) + .patch('/api/git-sources/polling') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ poll_interval_mins: 5 }); + expect(res.status).toBe(200); + const reviewRow = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === reviewStack); + const manualRow = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === manualStack); + // Review rides the unattended cadence like automatic; manual keeps + // its stale cursor because rescheduleAll never arms it. + expect(reviewRow.next_poll_at).toBeGreaterThan(staleCursor); + expect(manualRow.next_poll_at).toBe(staleCursor); + } finally { + DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0'); + deletePollRows(reviewStack); + deletePollRows(manualStack); + } + }); + + it('PATCH 0 leaves existing next_poll_at values alone (off means off)', async () => { + const stackName = 'polling-patch-off'; + seedPollApp(stackName, 'automatic'); + const existing = GitOpsStore.getInstance().getApplication(`poll-app-${stackName}`)!; + existing.next_poll_at = 123456; + DatabaseService.getInstance().getDb() + .prepare('UPDATE gitops_applications SET next_poll_at = ? WHERE id = ?') + .run(123456, existing.id); + try { + const res = await request(app) + .patch('/api/git-sources/polling') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ poll_interval_mins: 0 }); + expect(res.status).toBe(200); + expect(res.body.poll_interval_mins).toBe(0); + const row = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === stackName); + expect(row.next_poll_at).toBe(123456); + } finally { + DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0'); + deletePollRows(stackName); + } + }); +}); diff --git a/backend/src/__tests__/proxy-scoped-settings-authz.test.ts b/backend/src/__tests__/proxy-scoped-settings-authz.test.ts index 2262dd13..5d5c6ab1 100644 --- a/backend/src/__tests__/proxy-scoped-settings-authz.test.ts +++ b/backend/src/__tests__/proxy-scoped-settings-authz.test.ts @@ -341,3 +341,51 @@ describe('remote proxy scoped node-admin settings writes', () => { db.deleteRoleAssignmentsByUser(viewerId); }); }); + +describe('remote proxy scoped node-admin git-source polling writes', () => { + it('forwards polling PATCH from scoped node-admin on granted node with elevated role header', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .patch('/api/git-sources/polling') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ poll_interval_mins: 15 }); + + expect(res.status).toBe(200); + const hop = grantedHops.find((h) => h.url?.includes('/git-sources/polling')); + expect(hop).toBeDefined(); + expect(hop!.method).toBe('PATCH'); + expect(hop!.roleHeader).toBe('node-admin'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('denies polling PATCH from scoped node-admin on ungranted remote node', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .patch('/api/git-sources/polling') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(ungrantedNodeId)) + .send({ poll_interval_mins: 15 }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + expect(ungrantedHops).toHaveLength(0); + + db.deleteRoleAssignmentsByUser(viewerId); + }); +}); diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index 76ef77af..e42bcfb2 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -832,10 +832,17 @@ export function createRemoteProxyMiddleware(): RequestHandler { /** Max request body size for buffered settings writes (same as ALERT_PROXY_BODY_LIMIT). */ const SETTINGS_PROXY_BODY_LIMIT = 100 * 1024; -/** True when the request is a settings write destined for a remote node (path is post-/api strip). */ +/** + * True when the request configures a remote node's own settings and must run + * the node-admin elevation check on the hop (path is post-/api strip). + */ function isSettingsWrite(req: Request): boolean { if (req.method !== 'POST' && req.method !== 'PATCH') return false; - return /^\/settings\/?$/.test(req.path); + if (/^\/settings\/?$/.test(req.path)) return true; + // Polling cadence is configured per instance, so the hub PATCH must reach + // the target node's own route; classify it like a settings write so the + // same node-admin elevation check applies before the hop. + return /^\/git-sources\/polling\/?$/.test(req.path); } /** diff --git a/backend/src/routes/gitSources.ts b/backend/src/routes/gitSources.ts index e14d00e2..7e8eb54c 100644 --- a/backend/src/routes/gitSources.ts +++ b/backend/src/routes/gitSources.ts @@ -1,5 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { GitSourceService, type PublicGitSource, type SourcePolicy } from '../services/GitSourceService'; +import { GitOpsStore } from '../services/gitops/store'; +import { SourceController } from '../services/gitops/SourceController'; import type { GitOpsRevisionProjection } from '../services/gitops/types'; import { GitProjectManifestService } from '../services/GitProjectManifestService'; import { FileSystemService } from '../services/FileSystemService'; @@ -244,6 +246,86 @@ gitSourcesRouter.post('/browse', async (req: Request, res: Response): Promise 10080) return null; + return raw; + } + if (typeof raw === 'string') { + if (!/^\d{1,5}$/.test(raw)) return null; + const value = Number(raw); + return value <= 10080 ? value : null; + } + return null; +} + +function requireNodeManage(req: Request, res: Response): boolean { + if (typeof req.nodeId === 'number') { + return requirePermission(req, res, 'node:manage', 'node', String(req.nodeId)); + } + return requirePermission(req, res, 'node:manage'); +} + +function pollingSettingsPayload(): { + poll_interval_mins: number; + per_source: Array<{ + stack_name: string; + poll_interval_secs: number | null; + next_poll_at: number | null; + source_policy: string; + }>; +} { + return { + poll_interval_mins: DatabaseService.getInstance().getGitOpsPollIntervalMins(), + per_source: GitOpsStore.getInstance().listActiveDirectApplications().map((app) => ({ + stack_name: app.stack_name ?? '', + poll_interval_secs: app.poll_interval_secs, + next_poll_at: app.next_poll_at, + source_policy: app.source_policy, + })), + }; +} + +/** + * Node-scoped polling configuration. The GET projects the global interval and + * each live source's own cadence; the PATCH stores the interval and + * reschedules every non-manual source, both gated by node:manage because the + * cadence governs unattended fetches against the node. + */ +gitSourcesRouter.get('/polling', async (req: Request, res: Response): Promise => { + if (!requireNodeManage(req, res)) return; + try { + res.json(pollingSettingsPayload()); + } catch (error) { + console.error('[GitSources] polling settings read failed:', error instanceof Error ? error.message : String(error)); + res.status(500).json({ error: 'Could not read polling settings.' }); + } +}); + +gitSourcesRouter.patch('/polling', async (req: Request, res: Response): Promise => { + if (!requireNodeManage(req, res)) return; + const value = parsePollIntervalMins((req.body ?? {}).poll_interval_mins); + if (value === null) { + res.status(400).json({ error: 'poll_interval_mins must be an integer between 0 and 10080' }); + return; + } + try { + DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', String(value)); + SourceController.getInstance().rescheduleAll('system:git-source'); + SourceController.getInstance().restartPolling(); + res.json(pollingSettingsPayload()); + } catch (error) { + console.error('[GitSources] polling settings write failed:', error instanceof Error ? error.message : String(error)); + res.status(500).json({ error: 'Could not save polling settings.' }); + } +}); + /** * Router for per-stack git-source endpoints. Mount at `/api/stacks` so the * `/:stackName/git-source*` paths work alongside other stack-scoped routes