From ff46feb4a28a24b11b45a04da0802f278dfdc6e2 Mon Sep 17 00:00:00 2001 From: Caesar Mukama Date: Thu, 27 Aug 2026 20:07:26 +0300 Subject: [PATCH] Feat: split the forecast permission into summary, overview and settings Site operators need the forecast component on the main dashboard without access to the Forecast Overview or Settings pages, which a single forecast cap cannot express. The three caps also replace miner:w on the forecast settings and override writes, and gate the forecast GETs that ran no capability check at all. Deploying this needs config/facs/auth.config.json updated on each host and the worker restarted: the old forecast:rw grant matches none of the new caps, so forecast access is denied until the role arrays are migrated. --- README.md | 12 +++++++--- config/facs/auth.config.json.example | 23 ++++++++++++++----- tests/unit/handlers/export.handlers.test.js | 4 ++-- tests/unit/lib/auth.test.js | 18 +++++++++++++++ workers/lib/constants.js | 4 +++- .../lib/export/types/forecast.export.js | 2 +- workers/lib/server/routes/energy.routes.js | 15 +++++++----- 7 files changed, 59 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index aa451e4..c2f9dbd 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,9 @@ DEBUG="*" node worker.js --wtype wrk-node-http --env development --port 3000 "reporting:rw", "settings:rw", "ticket:rw", - "forecast:rw" + "forecast_summary:rw", + "forecast_overview:rw", + "forecast_settings:rw" ], "roles": { "admin": [ @@ -277,14 +279,18 @@ DEBUG="*" node worker.js --wtype wrk-node-http --env development --port 3000 "reporting:rw", "settings:rw", "ticket:rw", - "forecast:rw" + "forecast_summary:rw", + "forecast_overview:rw", + "forecast_settings:rw" ], "reporting_tool_manager": [ "revenue:rw", "production:rw", "reporting:rw", "settings:r", - "forecast:r" + "forecast_summary:r", + "forecast_overview:r", + "forecast_settings:r" ], "site_manager": [ "miner:rw", diff --git a/config/facs/auth.config.json.example b/config/facs/auth.config.json.example index db957e3..0503579 100644 --- a/config/facs/auth.config.json.example +++ b/config/facs/auth.config.json.example @@ -21,7 +21,9 @@ "explorer:rw", "inventory:rw", "reporting:rw", - "forecast:rw", + "forecast_summary:rw", + "forecast_overview:rw", + "forecast_settings:rw", "settings:rw", "ticket:rw", "pool_config:rw", @@ -50,7 +52,9 @@ "explorer:rw", "inventory:rw", "reporting:rw", - "forecast:rw", + "forecast_summary:rw", + "forecast_overview:rw", + "forecast_settings:rw", "settings:rw", "ticket:rw", "pool_config:rw", @@ -78,7 +82,9 @@ "explorer:r", "inventory:r", "reporting:r", - "forecast:r", + "forecast_summary:r", + "forecast_overview:r", + "forecast_settings:r", "settings:r", "ticket:r", "pool_config:r", @@ -90,7 +96,9 @@ "revenue:rw", "production:rw", "reporting:rw", - "forecast:rw", + "forecast_summary:rw", + "forecast_overview:rw", + "forecast_settings:rw", "settings:r", "revenue:r" ], @@ -113,7 +121,9 @@ "explorer:rw", "inventory:rw", "reporting:rw", - "forecast:rw", + "forecast_summary:rw", + "forecast_overview:rw", + "forecast_settings:rw", "settings:rw", "ticket:rw", "work_order:rw", @@ -136,7 +146,8 @@ "settings:rw", "ticket:rw", "alerts:rw", - "work_order:rw" + "work_order:rw", + "forecast_summary:r" ], "field_operator": [ "miner:r", diff --git a/tests/unit/handlers/export.handlers.test.js b/tests/unit/handlers/export.handlers.test.js index 6f5d586..2f9b7d1 100644 --- a/tests/unit/handlers/export.handlers.test.js +++ b/tests/unit/handlers/export.handlers.test.js @@ -146,8 +146,8 @@ test('exportRoute checks the expected read permission per export type', async (t [{ type: 'miner-stats' }, ['reporting:r']], [{ type: 'container-miner-stats', container: '7' }, ['reporting:r']], [{ type: 'historical-miner-kpi', statKey: 'stat-5m', start: 0, end: 1 }, ['reporting:r']], - [{ type: 'forecast-overview' }, ['forecast:r']], - [{ type: 'historical-forecast', start: 1, end: 2 }, ['forecast:r']] + [{ type: 'forecast-overview' }, ['forecast_overview:r']], + [{ type: 'historical-forecast', start: 1, end: 2 }, ['forecast_overview:r']] ] for (const [query, perms] of cases) { let seen = null diff --git a/tests/unit/lib/auth.test.js b/tests/unit/lib/auth.test.js index 4af09a4..0e34b35 100644 --- a/tests/unit/lib/auth.test.js +++ b/tests/unit/lib/auth.test.js @@ -673,3 +673,21 @@ test('AuthLib - _resolveOAuthMicrosoft throws when graph request fails', async ( t.pass() }) + +test('AuthLib - site_operator reads the forecast summary but not overview or settings', async (t) => { + const { a0 } = JSON.parse(require('fs').readFileSync(require('path').join(__dirname, '../../../config/facs/auth.config.json.example'))) + const authLib = new AuthLib({ + httpc: {}, + httpd: {}, + userService: {}, + auth: { + getTokenPerms: () => ({ superadmin: false, perms: a0.roles.site_operator }), + conf: { superAdminPerms: a0.superAdminPerms } + } + }) + + t.is(await authLib.tokenHasPerms('token', false, ['forecast_summary']), true, 'summary is readable') + t.is(await authLib.tokenHasPerms('token', false, ['forecast_overview']), false, 'overview is not') + t.is(await authLib.tokenHasPerms('token', false, ['forecast_settings']), false, 'settings are not') + t.is(await authLib.tokenHasPerms('token', true, ['forecast_settings']), false, 'settings are not writable') +}) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 4589352..bb603db 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -41,7 +41,9 @@ const AUTH_PERMISSIONS = { REPORTING: 'reporting', SETTINGS: 'settings', TICKETS: 'tickets', - FORECAST: 'forecast', + FORECAST_SUMMARY: 'forecast_summary', + FORECAST_OVERVIEW: 'forecast_overview', + FORECAST_SETTINGS: 'forecast_settings', POOL_CONFIG: 'pool_config', POOL_CONFIG_APPROVE: 'pool_config_approve', WORK_ORDER: 'work_order', diff --git a/workers/lib/server/lib/export/types/forecast.export.js b/workers/lib/server/lib/export/types/forecast.export.js index 7534fd3..4f54c60 100644 --- a/workers/lib/server/lib/export/types/forecast.export.js +++ b/workers/lib/server/lib/export/types/forecast.export.js @@ -82,7 +82,7 @@ function pickSummary (payload) { function buildForecastEntry ({ type, filenamePrefix, extDataKey, includeExpected, assertParams, buildQuery }) { return { type, - perms: ['forecast:r'], + perms: ['forecast_overview:r'], jsonRootKey: 'hourlyForecast', columns: includeExpected ? OVERVIEW_COLUMNS : BASE_COLUMNS, filenamePrefix () { diff --git a/workers/lib/server/routes/energy.routes.js b/workers/lib/server/routes/energy.routes.js index 15f0aca..0b414a2 100644 --- a/workers/lib/server/routes/energy.routes.js +++ b/workers/lib/server/routes/energy.routes.js @@ -1,6 +1,6 @@ 'use strict' -const { ENDPOINTS, HTTP_METHODS, AUTH_CAPS } = require('../../constants') +const { ENDPOINTS, HTTP_METHODS, AUTH_CAPS, AUTH_PERMISSIONS } = require('../../constants') const { getEnergyForecast, setAvailableEnergy, getEnergyForecastHistory, setForecastSettings, getForecastSettings, setForecastOverride } = require('../handlers/energy.handlers') const { createCachedAuthRoute, createAuthRoute } = require('../lib/routeHelpers') const schemas = require('../schemas/energy.schemas') @@ -13,7 +13,8 @@ module.exports = (ctx) => [ ctx, (req) => ['energy-forecast'], ENDPOINTS.ENERGY_FORECAST, - getEnergyForecast + getEnergyForecast, + [AUTH_PERMISSIONS.FORECAST_SUMMARY] ) }, { @@ -33,7 +34,8 @@ module.exports = (ctx) => [ ctx, (req) => ['energy-forecast-history'], ENDPOINTS.ENERGY_FORECAST_HISTORY, - getEnergyForecastHistory + getEnergyForecastHistory, + [AUTH_PERMISSIONS.FORECAST_OVERVIEW] ) }, { @@ -51,7 +53,7 @@ module.exports = (ctx) => [ url: ENDPOINTS.ENERGY_FORECAST_SETTINGS, ...createAuthRoute(ctx, async (ctx, req) => { return await setForecastSettings(ctx, req) - }, [`${AUTH_CAPS.m}:w`]), + }, [AUTH_PERMISSIONS.FORECAST_SETTINGS]), schema: { body: schemas.body.forecastSettings } @@ -63,7 +65,8 @@ module.exports = (ctx) => [ ctx, (req) => ['forecast-settings'], ENDPOINTS.ENERGY_FORECAST_SETTINGS, - getForecastSettings + getForecastSettings, + [AUTH_PERMISSIONS.FORECAST_SETTINGS] ) }, { @@ -71,7 +74,7 @@ module.exports = (ctx) => [ url: ENDPOINTS.ENERGY_FORECAST_OVERRIDE, ...createAuthRoute(ctx, async (ctx, req) => { return await setForecastOverride(ctx, req) - }, [`${AUTH_CAPS.m}:w`]), + }, [AUTH_PERMISSIONS.FORECAST_SETTINGS]), schema: { body: schemas.body.forecastOverride }