diff --git a/package-lock.json b/package-lock.json index a4213adc..a280366b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2910,12 +2910,13 @@ "dev": true }, "d": { - "version": "1.0.0", - "resolved": "http://registry.npmjs.org/d/-/d-1.0.0.tgz", - "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", "dev": true, "requires": { - "es5-ext": "^0.10.9" + "es5-ext": "^0.10.50", + "type": "^1.0.1" } }, "dashdash": { @@ -3613,14 +3614,22 @@ } }, "es5-ext": { - "version": "0.10.46", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.46.tgz", - "integrity": "sha512-24XxRvJXNFwEMpJb3nOkiRJKRoupmjYmOPVlI65Qy2SrtxwOTB+g6ODjBKOtwEHbYrhWRty9xxOWLNdClT2djw==", + "version": "0.10.53", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz", + "integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==", "dev": true, "requires": { "es6-iterator": "~2.0.3", - "es6-symbol": "~3.1.1", - "next-tick": "1" + "es6-symbol": "~3.1.3", + "next-tick": "~1.0.0" + }, + "dependencies": { + "next-tick": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", + "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", + "dev": true + } } }, "es6-iterator": { @@ -3650,24 +3659,24 @@ } }, "es6-symbol": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", - "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", + "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", "dev": true, "requires": { - "d": "1", - "es5-ext": "~0.10.14" + "d": "^1.0.1", + "ext": "^1.1.2" } }, "es6-weak-map": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.2.tgz", - "integrity": "sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", "dev": true, "requires": { "d": "1", - "es5-ext": "^0.10.14", - "es6-iterator": "^2.0.1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.1" } }, @@ -3911,6 +3920,23 @@ } } }, + "ext": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz", + "integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==", + "dev": true, + "requires": { + "type": "^2.0.0" + }, + "dependencies": { + "type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type/-/type-2.1.0.tgz", + "integrity": "sha512-G9absDWvhAWCV2gmF1zKud3OyC61nZDwWvBL2DApaVFogI07CprggiQAOOjvp2NRjYWFzPyu7vwtDrQFq8jeSA==", + "dev": true + } + } + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5363,9 +5389,9 @@ } }, "is-promise": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", - "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", "dev": true }, "is-regexp": { @@ -6750,9 +6776,9 @@ } }, "next-tick": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", - "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "dev": true }, "nice-try": { @@ -9463,6 +9489,12 @@ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, + "type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", + "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==", + "dev": true + }, "type-detect": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz", diff --git a/src/compose/app.ts b/src/compose/app.ts index 9d219ca6..cdbaa2cb 100644 --- a/src/compose/app.ts +++ b/src/compose/app.ts @@ -775,8 +775,9 @@ export class App { imageInfo, serviceName: svc.serviceName, }; + // FIXME: Typings for DeviceMetadata - return Service.fromComposeObject( + return await Service.fromComposeObject( svc, (thisSvcOpts as unknown) as DeviceMetadata, ); diff --git a/src/compose/service.ts b/src/compose/service.ts index bcad6f9c..ae8e9230 100644 --- a/src/compose/service.ts +++ b/src/compose/service.ts @@ -100,10 +100,10 @@ export class Service { // The type here is actually ServiceComposeConfig, except that the // keys must be camelCase'd first - public static fromComposeObject( + public static async fromComposeObject( appConfig: ConfigMap, options: DeviceMetadata, - ): Service { + ): Promise { const service = new Service(); appConfig = ComposeUtils.camelCaseConfig(appConfig); @@ -443,7 +443,7 @@ export class Service { } // Mutate service with extra features - ComposeUtils.addFeaturesFromLabels(service, options); + await ComposeUtils.addFeaturesFromLabels(service, options); return service; } diff --git a/src/compose/utils.ts b/src/compose/utils.ts index 2736b1b6..713b2e8b 100644 --- a/src/compose/utils.ts +++ b/src/compose/utils.ts @@ -18,6 +18,8 @@ import { import log from '../lib/supervisor-console'; +import * as apiKeys from '../lib/api-keys'; + export function camelCaseConfig( literalConfig: ConfigMap, ): ServiceComposeConfig { @@ -316,10 +318,10 @@ export function dockerDeviceToStr(device: DockerDevice): string { // TODO: Export these strings to a constant lib, to // enable changing them easily // Mutates service -export function addFeaturesFromLabels( +export async function addFeaturesFromLabels( service: Service, options: DeviceMetadata, -): void { +): Promise { const setEnvVariables = function (key: string, val: string) { service.config.environment[`RESIN_${key}`] = val; service.config.environment[`BALENA_${key}`] = val; @@ -356,18 +358,24 @@ export function addFeaturesFromLabels( setEnvVariables('API_KEY', options.deviceApiKey); setEnvVariables('API_URL', options.apiEndpoint); }, - 'io.balena.features.supervisor-api': () => { + 'io.balena.features.supervisor-api': async () => { + // create a app/service specific API secret + const apiSecret = await apiKeys.generateScopedKey( + service.appId, + service.serviceId, + ); + + const host = (() => { + if (service.config.networkMode === 'host') { + return '127.0.0.1'; + } else { + service.config.networks[constants.supervisorNetworkInterface] = {}; + return options.supervisorApiHost; + } + })(); + + setEnvVariables('SUPERVISOR_API_KEY', apiSecret); setEnvVariables('SUPERVISOR_PORT', options.listenPort.toString()); - setEnvVariables('SUPERVISOR_API_KEY', options.apiSecret); - - let host: string; - - if (service.config.networkMode === 'host') { - host = '127.0.0.1'; - } else { - host = options.supervisorApiHost; - service.config.networks[constants.supervisorNetworkInterface] = {}; - } setEnvVariables('SUPERVISOR_HOST', host); setEnvVariables( 'SUPERVISOR_ADDRESS', @@ -388,11 +396,12 @@ export function addFeaturesFromLabels( } as Dockerode.DeviceRequest), }; - _.each(features, (fn, label) => { - if (checkTruthy(service.config.labels[label])) { - fn(); + for (const feature of Object.keys(features) as [keyof typeof features]) { + const fn = features[feature]; + if (checkTruthy(service.config.labels[feature])) { + await fn(); } - }); + } // This is a special case, and folding it into the // structure above would unnecessarily complicate things. diff --git a/src/config/functions.ts b/src/config/functions.ts index 88713145..4ab0109c 100644 --- a/src/config/functions.ts +++ b/src/config/functions.ts @@ -97,7 +97,6 @@ export const fnSchema = { 'uuid', 'listenPort', 'name', - 'apiSecret', 'apiEndpoint', 'deviceApiKey', 'version', diff --git a/src/config/index.ts b/src/config/index.ts index 4da88c83..18ec710f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -281,14 +281,13 @@ function validateConfigMap( } export async function generateRequiredFields() { - return getMany(['uuid', 'deviceApiKey', 'apiSecret', 'unmanaged']).then( - ({ uuid, deviceApiKey, apiSecret, unmanaged }) => { + return getMany(['uuid', 'deviceApiKey', 'unmanaged']).then( + ({ uuid, deviceApiKey, unmanaged }) => { // These fields need to be set regardless - if (uuid == null || apiSecret == null) { + if (uuid == null) { uuid = uuid || newUniqueKey(); - apiSecret = apiSecret || newUniqueKey(); } - return set({ uuid, apiSecret }).then(() => { + return set({ uuid }).then(() => { if (unmanaged) { return; } diff --git a/src/config/schema-type.ts b/src/config/schema-type.ts index 985c0950..c39f2aed 100644 --- a/src/config/schema-type.ts +++ b/src/config/schema-type.ts @@ -88,10 +88,6 @@ export const schemaTypes = { }, // Database types - apiSecret: { - type: t.string, - default: NullOrUndefined, - }, name: { type: t.string, default: 'local', @@ -231,7 +227,6 @@ export const schemaTypes = { uuid: t.union([t.string, NullOrUndefined]), listenPort: PermissiveNumber, name: t.string, - apiSecret: t.union([t.string, NullOrUndefined]), deviceApiKey: t.string, apiEndpoint: t.string, version: t.string, diff --git a/src/config/schema.ts b/src/config/schema.ts index b219be30..af6fa61a 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -85,11 +85,6 @@ export const schema = { removeIfNull: false, }, - apiSecret: { - source: 'db', - mutable: true, - removeIfNull: false, - }, name: { source: 'db', mutable: true, diff --git a/src/device-api/v1.js b/src/device-api/v1.ts similarity index 69% rename from src/device-api/v1.js rename to src/device-api/v1.ts index 1209f07b..82d79e75 100644 --- a/src/device-api/v1.js +++ b/src/device-api/v1.ts @@ -1,4 +1,5 @@ import * as Promise from 'bluebird'; +import * as express from 'express'; import * as _ from 'lodash'; import * as eventTracker from '../event-tracker'; @@ -8,21 +9,37 @@ import { doRestart, doPurge } from './common'; import * as applicationManager from '../compose/application-manager'; import { generateStep } from '../compose/composition-steps'; +import { AuthorizedRequest } from '../lib/api-keys'; -export const createV1Api = function (router) { - router.post('/v1/restart', function (req, res, next) { +export function createV1Api(router: express.Router) { + router.post('/v1/restart', (req: AuthorizedRequest, res, next) => { const appId = checkInt(req.body.appId); const force = checkTruthy(req.body.force) ?? false; eventTracker.track('Restart container (v1)', { appId }); if (appId == null) { return res.status(400).send('Missing app id'); } + + // handle the case where the appId is out of scope + if (!req.auth.isScoped({ apps: [appId] })) { + res.status(401).json({ + status: 'failed', + message: 'Application is not available', + }); + return; + } + return doRestart(appId, force) .then(() => res.status(200).send('OK')) .catch(next); }); - const v1StopOrStart = function (req, res, next, action) { + const v1StopOrStart = ( + req: AuthorizedRequest, + res: express.Response, + next: express.NextFunction, + action: 'start' | 'stop', + ) => { const appId = checkInt(req.params.appId); const force = checkTruthy(req.body.force) ?? false; if (appId == null) { @@ -47,13 +64,21 @@ export const createV1Api = function (router) { 'Some v1 endpoints are only allowed on single-container apps', ); } + + // check that the request is scoped to cover this application + if (!req.auth.isScoped({ apps: [app.appId] })) { + return res.status(401).send('Unauthorized'); + } + applicationManager.setTargetVolatileForService(service.imageId, { running: action !== 'stop', }); + + const stopOpts = { wait: true }; + const step = generateStep(action, { current: service, ...stopOpts }); + return applicationManager - .executeStep(generateStep(action, { current: service, wait: true }), { - force, - }) + .executeStep(step, { force }) .then(function () { if (action === 'stop') { return service; @@ -75,13 +100,13 @@ export const createV1Api = function (router) { .catch(next); }; - const createV1StopOrStartHandler = (action) => + const createV1StopOrStartHandler = (action: 'start' | 'stop') => _.partial(v1StopOrStart, _, _, _, action); router.post('/v1/apps/:appId/stop', createV1StopOrStartHandler('stop')); router.post('/v1/apps/:appId/start', createV1StopOrStartHandler('start')); - router.get('/v1/apps/:appId', function (req, res, next) { + router.get('/v1/apps/:appId', (req: AuthorizedRequest, res, next) => { const appId = checkInt(req.params.appId); eventTracker.track('GET app (v1)', { appId }); if (appId == null) { @@ -96,6 +121,7 @@ export const createV1Api = function (router) { if (service == null) { return res.status(400).send('App not found'); } + if (app.services.length > 1) { return res .status(400) @@ -103,31 +129,50 @@ export const createV1Api = function (router) { 'Some v1 endpoints are only allowed on single-container apps', ); } + + // handle the case where the appId is out of scope + if (!req.auth.isScoped({ apps: [app.appId] })) { + res.status(401).json({ + status: 'failed', + message: 'Application is not available', + }); + return; + } + // Don't return data that will be of no use to the user const appToSend = { appId, + commit: status.commit!, containerId: service.containerId, env: _.omit(service.config.environment, constants.privateAppEnvVars), - releaseId: service.releaseId, imageId: service.config.image, + releaseId: service.releaseId, }; - if (status.commit != null) { - appToSend.commit = status.commit; - } + return res.json(appToSend); }, ).catch(next); }); - router.post('/v1/purge', function (req, res, next) { + router.post('/v1/purge', (req: AuthorizedRequest, res, next) => { const appId = checkInt(req.body.appId); const force = checkTruthy(req.body.force) ?? false; if (appId == null) { const errMsg = 'Invalid or missing appId'; return res.status(400).send(errMsg); } + + // handle the case where the appId is out of scope + if (!req.auth.isScoped({ apps: [appId] })) { + res.status(401).json({ + status: 'failed', + message: 'Application is not available', + }); + return; + } + return doPurge(appId, force) .then(() => res.status(200).json({ Data: 'OK', Error: '' })) .catch(next); }); -}; +} diff --git a/src/device-api/v2.ts b/src/device-api/v2.ts index b61bfb28..da5d67fc 100644 --- a/src/device-api/v2.ts +++ b/src/device-api/v2.ts @@ -1,5 +1,5 @@ import * as Bluebird from 'bluebird'; -import { NextFunction, Request, Response, Router } from 'express'; +import { NextFunction, Response, Router } from 'express'; import * as _ from 'lodash'; import * as deviceState from '../device-state'; @@ -30,10 +30,11 @@ import supervisorVersion = require('../lib/supervisor-version'); import { checkInt, checkTruthy } from '../lib/validation'; import { isVPNActive } from '../network'; import { doPurge, doRestart, safeStateClone } from './common'; +import { AuthorizedRequest } from '../lib/api-keys'; export function createV2Api(router: Router) { const handleServiceAction = ( - req: Request, + req: AuthorizedRequest, res: Response, next: NextFunction, action: CompositionStepAction, @@ -48,6 +49,15 @@ export function createV2Api(router: Router) { return; } + // handle the case where the appId is out of scope + if (!req.auth.isScoped({ apps: [appId] })) { + res.status(401).json({ + status: 'failed', + message: 'Application is not available', + }); + return; + } + return applicationManager.lockingIfNecessary(appId, { force }, () => { return Promise.all([applicationManager.getCurrentApps(), getApp(appId)]) .then(([apps, targetApp]) => { @@ -115,7 +125,7 @@ export function createV2Api(router: Router) { router.post( '/v2/applications/:appId/purge', - (req: Request, res: Response, next: NextFunction) => { + (req: AuthorizedRequest, res: Response, next: NextFunction) => { const { force } = req.body; const appId = checkInt(req.params.appId); if (!appId) { @@ -125,6 +135,15 @@ export function createV2Api(router: Router) { }); } + // handle the case where the application is out of scope + if (!req.auth.isScoped({ apps: [appId] })) { + res.status(401).json({ + status: 'failed', + message: 'Application is not available', + }); + return; + } + return doPurge(appId, force) .then(() => { res.status(200).send('OK'); @@ -150,7 +169,7 @@ export function createV2Api(router: Router) { router.post( '/v2/applications/:appId/restart', - (req: Request, res: Response, next: NextFunction) => { + (req: AuthorizedRequest, res: Response, next: NextFunction) => { const { force } = req.body; const appId = checkInt(req.params.appId); if (!appId) { @@ -160,6 +179,15 @@ export function createV2Api(router: Router) { }); } + // handle the case where the appId is out of scope + if (!req.auth.isScoped({ apps: [appId] })) { + res.status(401).json({ + status: 'failed', + message: 'Application is not available', + }); + return; + } + return doRestart(appId, force) .then(() => { res.status(200).send('OK'); @@ -171,7 +199,7 @@ export function createV2Api(router: Router) { // TODO: Support dependent applications when this feature is complete router.get( '/v2/applications/state', - async (_req: Request, res: Response, next: NextFunction) => { + async (req: AuthorizedRequest, res: Response, next: NextFunction) => { // It's kinda hacky to access the services and db via the application manager // maybe refactor this code Bluebird.join( @@ -200,44 +228,52 @@ export function createV2Api(router: Router) { const appNameById: { [id: number]: string } = {}; - apps.forEach((app) => { - const appId = parseInt(app.appId, 10); - response[app.name] = { - appId, - commit: app.commit, - services: {}, - }; + // only access scoped apps + apps + .filter((app) => + req.auth.isScoped({ apps: [parseInt(app.appId, 10)] }), + ) + .forEach((app) => { + const appId = parseInt(app.appId, 10); + response[app.name] = { + appId, + commit: app.commit, + services: {}, + }; - appNameById[appId] = app.name; - }); - - imgs.forEach((img) => { - const appName = appNameById[img.appId]; - if (appName == null) { - log.warn( - `Image found for unknown application!\nImage: ${JSON.stringify( - img, - )}`, - ); - return; - } - - const svc = _.find(services, (service: Service) => { - return service.imageId === img.imageId; + appNameById[appId] = app.name; }); - let status: string | undefined; - if (svc == null) { - status = img.status; - } else { - status = svc.status || img.status; - } - response[appName].services[img.serviceName] = { - status, - releaseId: img.releaseId, - downloadProgress: img.downloadProgress || null, - }; - }); + // only access scoped images + imgs + .filter((img) => req.auth.isScoped({ apps: [img.appId] })) + .forEach((img) => { + const appName = appNameById[img.appId]; + if (appName == null) { + log.warn( + `Image found for unknown application!\nImage: ${JSON.stringify( + img, + )}`, + ); + return; + } + + const svc = _.find(services, (service: Service) => { + return service.imageId === img.imageId; + }); + + let status: string | undefined; + if (svc == null) { + status = img.status; + } else { + status = svc.status || img.status; + } + response[appName].services[img.serviceName] = { + status, + releaseId: img.releaseId, + downloadProgress: img.downloadProgress || null, + }; + }); res.status(200).json(response); }, @@ -247,7 +283,7 @@ export function createV2Api(router: Router) { router.get( '/v2/applications/:appId/state', - async (req: Request, res: Response) => { + async (req: AuthorizedRequest, res: Response) => { // Check application ID provided is valid const appId = checkInt(req.params.appId); if (!appId) { @@ -256,6 +292,7 @@ export function createV2Api(router: Router) { message: `Invalid application ID: ${req.params.appId}`, }); } + // Query device for all applications let apps: any; try { @@ -268,12 +305,22 @@ export function createV2Api(router: Router) { }); } // Check if the application exists - if (!(appId in apps.local)) { + if (!(appId in apps.local) || !req.auth.isScoped({ apps: [appId] })) { return res.status(409).json({ status: 'failed', message: `Application ID does not exist: ${appId}`, }); } + + // handle the case where the appId is out of scope + if (!req.auth.isScoped({ apps: [appId] })) { + res.status(401).json({ + status: 'failed', + message: 'Application is not available', + }); + return; + } + // Filter applications we do not want for (const app in apps.local) { if (app !== appId.toString()) { @@ -380,8 +427,10 @@ export function createV2Api(router: Router) { }); }); - router.get('/v2/containerId', async (req, res) => { - const services = await serviceManager.getAll(); + router.get('/v2/containerId', async (req: AuthorizedRequest, res) => { + const services = (await serviceManager.getAll()).filter((service) => + req.auth.isScoped({ apps: [service.appId] }), + ); if (req.query.serviceName != null || req.query.service != null) { const serviceName = req.query.serviceName || req.query.service; @@ -411,41 +460,45 @@ export function createV2Api(router: Router) { } }); - router.get('/v2/state/status', async (_req, res) => { + router.get('/v2/state/status', async (req: AuthorizedRequest, res) => { const currentRelease = await config.get('currentCommit'); const pending = deviceState.isApplyInProgress(); - const containerStates = (await serviceManager.getAll()).map((svc) => - _.pick( - svc, - 'status', - 'serviceName', - 'appId', - 'imageId', - 'serviceId', - 'containerId', - 'createdAt', - ), - ); + const containerStates = (await serviceManager.getAll()) + .filter((service) => req.auth.isScoped({ apps: [service.appId] })) + .map((svc) => + _.pick( + svc, + 'status', + 'serviceName', + 'appId', + 'imageId', + 'serviceId', + 'containerId', + 'createdAt', + ), + ); let downloadProgressTotal = 0; let downloads = 0; - const imagesStates = (await images.getStatus()).map((img) => { - if (img.downloadProgress != null) { - downloadProgressTotal += img.downloadProgress; - downloads += 1; - } - return _.pick( - img, - 'name', - 'appId', - 'serviceName', - 'imageId', - 'dockerImageId', - 'status', - 'downloadProgress', - ); - }); + const imagesStates = (await images.getStatus()) + .filter((img) => req.auth.isScoped({ apps: [img.appId] })) + .map((img) => { + if (img.downloadProgress != null) { + downloadProgressTotal += img.downloadProgress; + downloads += 1; + } + return _.pick( + img, + 'name', + 'appId', + 'serviceName', + 'imageId', + 'dockerImageId', + 'status', + 'downloadProgress', + ); + }); let overallDownloadProgress: number | null = null; if (downloads > 0) { @@ -500,10 +553,15 @@ export function createV2Api(router: Router) { }); }); - router.get('/v2/cleanup-volumes', async (_req, res) => { + router.get('/v2/cleanup-volumes', async (req: AuthorizedRequest, res) => { const targetState = await applicationManager.getTargetApps(); const referencedVolumes: string[] = []; _.each(targetState, (app, appId) => { + // if this app isn't in scope of the request, do not cleanup it's volumes + if (!req.auth.isScoped({ apps: [parseInt(appId, 10)] })) { + return; + } + _.each(app.volumes, (_volume, volumeName) => { referencedVolumes.push( Volume.generateDockerName(parseInt(appId, 10), volumeName), diff --git a/src/device-state.ts b/src/device-state.ts index 2dec6371..c88085ec 100644 --- a/src/device-state.ts +++ b/src/device-state.ts @@ -38,6 +38,7 @@ import { InstancedAppState, } from './types/state'; import * as dbFormat from './device-state/db-format'; +import * as apiKeys from './lib/api-keys'; function validateLocalState(state: any): asserts state is TargetState['local'] { if (state.name != null) { @@ -265,9 +266,7 @@ export const initialized = (async () => { if (changedConfig.loggingEnabled != null) { logger.enable(changedConfig.loggingEnabled); } - if (changedConfig.apiSecret != null) { - reportCurrentState({ api_secret: changedConfig.apiSecret }); - } + if (changedConfig.appUpdatePollInterval != null) { maxPollTime = changedConfig.appUpdatePollInterval; } @@ -346,11 +345,11 @@ async function saveInitialConfig() { export async function loadInitialState() { await applicationManager.initialized; + await apiKeys.initialized; const conf = await config.getMany([ 'initialConfigSaved', 'listenPort', - 'apiSecret', 'osVersion', 'osVariant', 'macAddress', @@ -374,7 +373,7 @@ export async function loadInitialState() { log.info('Reporting initial state, supervisor version and API info'); reportCurrentState({ api_port: conf.listenPort, - api_secret: conf.apiSecret, + api_secret: apiKeys.cloudApiKey, os_version: conf.osVersion, os_variant: conf.osVariant, mac_address: conf.macAddress, diff --git a/src/lib/api-keys.ts b/src/lib/api-keys.ts new file mode 100644 index 00000000..e8ab161a --- /dev/null +++ b/src/lib/api-keys.ts @@ -0,0 +1,359 @@ +import * as _ from 'lodash'; +import * as express from 'express'; +import * as memoizee from 'memoizee'; + +import * as config from '../config'; +import * as db from '../db'; + +import { generateUniqueKey } from './register-device'; + +export class KeyNotFoundError extends Error {} + +/** + * The schema for the `apiSecret` table in the database + */ +interface DbApiSecret { + id: number; + appId: number; + serviceId: number; + scopes: string; + key: string; +} + +export type Scope = SerializableScope; +type ScopeTypeKey = keyof ScopeTypes; +type SerializableScope = { + type: T; +} & ScopeTypes[T]; +type ScopeCheck = ( + resources: Partial, + scope: ScopeTypes[T], +) => Resolvable; +type ScopeCheckCollection = { + [K in ScopeTypeKey]: ScopeCheck; +}; + +/** + * The scopes which a key can cover. + */ +type ScopeTypes = { + global: {}; + app: { + appId: number; + }; +}; + +/** + * The resources which can be protected with scopes. + */ +interface ScopedResources { + apps: number[]; +} + +/** + * The checks when determining if a key is scoped for a resource. + */ +const scopeChecks: ScopeCheckCollection = { + global: () => true, + app: (resources, { appId }) => + resources.apps != null && resources.apps.includes(appId), +}; + +export function serialiseScopes(scopes: Scope[]): string { + return JSON.stringify(scopes); +} + +export function deserialiseScopes(json: string): Scope[] { + return JSON.parse(json); +} + +export const isScoped = ( + resources: Partial, + scopes: Scope[], +) => + scopes.some((scope) => + scopeChecks[scope.type](resources, (scope as unknown) as any), + ); + +export type AuthorizedRequest = express.Request & { + auth: { + isScoped: (resources: Partial) => boolean; + apiKey: string; + scopes: Scope[]; + }; +}; +export type AuthorizedRequestHandler = ( + req: AuthorizedRequest, + res: express.Response, + next: express.NextFunction, +) => void; + +// empty until populated in `initialized` +export let cloudApiKey: string = ''; + +// should be called before trying to use this singleton +export const initialized = (async () => { + await db.initialized; + + // make sure we have an API key which the cloud will use to call us + await generateCloudKey(); +})(); + +/** + * This middleware will extract an API key used to make a call, and then expand it out to provide + * access to the scopes it has. The `req` will be updated to include this `auth` data. + * + * E.g. `req.auth.scopes: []` + * + * @param req + * @param res + * @param next + */ +export const authMiddleware: AuthorizedRequestHandler = async ( + req, + res, + next, +) => { + // grab the API key used for the request + const apiKey = getApiKeyFromRequest(req) ?? ''; + + // store the key in the request, and an empty scopes array to populate after resolving the key scopes + req.auth = { + apiKey, + scopes: [], + isScoped: () => false, + }; + + try { + const conf = await config.getMany(['localMode', 'unmanaged', 'osVariant']); + + // we only need to check the API key if a) unmanaged and on a production image, or b) managed and not in local mode + const needsAuth = conf.unmanaged + ? conf.osVariant === 'prod' + : !conf.localMode; + + // no need to authenticate, shortcut + if (!needsAuth) { + return next(); + } + + // if we have a key, find the scopes and add them to the request + if (apiKey && apiKey !== '') { + await initialized; + const scopes = await getScopesForKey(apiKey); + + if (scopes != null) { + // keep the scopes for later incase they're desired + req.auth.scopes.push(...scopes); + + // which resources are scoped... + req.auth.isScoped = (resources) => isScoped(resources, req.auth.scopes); + + return next(); + } + } + + // we do not have a valid key... + return res.sendStatus(401); + } catch (err) { + console.error(err); + res.status(503).send(`Unexpected error: ${err}`); + } +}; + +function isEqualScope(a: Scope, b: Scope): boolean { + return _.isEqual(a, b); +} + +function getApiKeyFromRequest(req: express.Request): string | undefined { + // Check query for key + if (req.query.apikey) { + return req.query.apikey; + } + + // Get Authorization header to search for key + const authHeader = req.get('Authorization'); + + // Check header for key + if (!authHeader) { + return undefined; + } + + // Check authHeader with various schemes + const match = authHeader.match(/^(?:ApiKey|Bearer) (\w+)$/i); + + // Return key from match or undefined + return match?.[1]; +} + +export type GenerateKeyOptions = { force: boolean; scopes: Scope[] }; + +export async function getScopesForKey(key: string): Promise { + const apiKey = await getApiKeyByKey(key); + + // null means the key wasn't known... + if (apiKey == null) { + return null; + } + + return deserialiseScopes(apiKey.scopes); +} + +export async function generateScopedKey( + appId: number, + serviceId: number, + options?: Partial, +): Promise { + await initialized; + return await generateKey(appId, serviceId, options); +} + +export async function generateCloudKey( + force: boolean = false, +): Promise { + cloudApiKey = await generateKey(0, 0, { + force, + scopes: [{ type: 'global' }], + }); + return cloudApiKey; +} + +export async function refreshKey(key: string): Promise { + const apiKey = await getApiKeyByKey(key); + + if (apiKey == null) { + throw new KeyNotFoundError(); + } + + const { appId, serviceId, scopes } = apiKey; + + // if this is a cloud key that is being refreshed + if (appId === 0 && serviceId === 0) { + return await generateCloudKey(true); + } + + // generate a new key, expiring the old one... + const newKey = await generateScopedKey(appId, serviceId, { + force: true, + scopes: deserialiseScopes(scopes), + }); + + // return the regenerated key + return newKey; +} + +/** + * A cached lookup of the database key + */ +const getApiKeyForService = memoizee( + async (appId: number, serviceId: number): Promise => { + await db.initialized; + + return await db.models('apiSecret').where({ appId, serviceId }).select(); + }, + { + promise: true, + maxAge: 60000, // 1 minute + normalizer: ([appId, serviceId]) => `${appId}-${serviceId}`, + }, +); + +/** + * A cached lookup of the database key for a given application/service pair + */ +const getApiKeyByKey = memoizee( + async (key: string): Promise => { + await db.initialized; + + const [apiKey] = await db.models('apiSecret').where({ key }).select(); + return apiKey; + }, + { + promise: true, + maxAge: 60000, // 1 minute + }, +); + +/** + * All key generate logic should come though this method. It handles cache clearing. + * + * @param appId + * @param serviceId + * @param options + */ +async function generateKey( + appId: number, + serviceId: number, + options?: Partial, +): Promise { + // set default options + const { force, scopes }: GenerateKeyOptions = { + force: false, + scopes: [{ type: 'app', appId }], + ...options, + }; + + // grab the existing API key info + const secrets = await getApiKeyForService(appId, serviceId); + + // if we need a new key + if (secrets.length === 0 || force) { + // are forcing a new key? + if (force) { + await db.models('apiSecret').where({ appId, serviceId }).del(); + } + + // remove the cached lookup for the key + const [apiKey] = secrets; + if (apiKey != null) { + getApiKeyByKey.clear(apiKey.key); + } + + // remove the cached value for this lookup + getApiKeyForService.clear(appId, serviceId); + + // return a new API key + return await createNewKey(appId, serviceId, scopes); + } + + // grab the current secret and scopes + const [currentSecret] = secrets; + const currentScopes: Scope[] = JSON.parse(currentSecret.scopes); + + const scopesWeAlreadyHave = scopes.filter((desiredScope) => + currentScopes.some((currentScope) => + isEqualScope(desiredScope, currentScope), + ), + ); + + // if we have the correct scopes, then return our existing key... + if ( + scopes.length === currentScopes.length && + scopesWeAlreadyHave.length === currentScopes.length + ) { + return currentSecret.key; + } + + // forcibly get a new key... + return await generateKey(appId, serviceId, { ...options, force: true }); +} + +/** + * Generates a new key value and inserts it into the DB. + * + * @param appId + * @param serviceId + * @param scopes + */ +async function createNewKey(appId: number, serviceId: number, scopes: Scope[]) { + const key = generateUniqueKey(); + await db.models('apiSecret').insert({ + appId, + serviceId, + key, + scopes: serialiseScopes(scopes), + }); + + // return the new key + return key; +} diff --git a/src/migrations/M00005.js b/src/migrations/M00005.js new file mode 100644 index 00000000..f7b39968 --- /dev/null +++ b/src/migrations/M00005.js @@ -0,0 +1,32 @@ +import { generateScopedKey } from '../lib/api-keys'; + +export async function up(knex) { + // Create a new table to hold the api keys + await knex.schema.createTable('apiSecret', (table) => { + table.increments('id').primary(); + table.integer('appId'); + table.integer('serviceId'); + table.string('key'); + table.string('scopes'); + table.unique(['appId', 'serviceId']); + }); + + // Delete any existing API secrets + await knex('config').where({ key: 'apiSecret' }).del(); + + // Add an api secret per service in the db + const apps = await knex('app'); + + for (const app of apps) { + const appId = app.appId; + const services = JSON.parse(app.services); + for (const service of services) { + const serviceId = service.id; + await generateScopedKey(appId, serviceId); + } + } +} + +export function down() { + return Promise.reject(new Error('Not Implemented')); +} diff --git a/src/supervisor-api.ts b/src/supervisor-api.ts index de0c3ed6..cd5a89e5 100644 --- a/src/supervisor-api.ts +++ b/src/supervisor-api.ts @@ -4,59 +4,12 @@ import { Server } from 'http'; import * as _ from 'lodash'; import * as morgan from 'morgan'; -import * as config from './config'; import * as eventTracker from './event-tracker'; import blink = require('./lib/blink'); import log from './lib/supervisor-console'; - -function getKeyFromReq(req: express.Request): string | undefined { - // Check query for key - if (req.query.apikey) { - return req.query.apikey; - } - // Get Authorization header to search for key - const authHeader = req.get('Authorization'); - // Check header for key - if (!authHeader) { - return undefined; - } - // Check authHeader with various schemes - const match = authHeader.match(/^(?:ApiKey|Bearer) (\w+)$/i); - // Return key from match or undefined - return match?.[1]; -} - -function authenticate(): express.RequestHandler { - return async (req, res, next) => { - try { - const conf = await config.getMany([ - 'apiSecret', - 'localMode', - 'unmanaged', - 'osVariant', - ]); - - const needsAuth = conf.unmanaged - ? conf.osVariant === 'prod' - : !conf.localMode; - - if (needsAuth) { - // Only get the key if we need it - const key = getKeyFromReq(req); - if (key && conf.apiSecret && key === conf.apiSecret) { - return next(); - } else { - return res.sendStatus(401); - } - } else { - return next(); - } - } catch (err) { - res.status(503).send(`Unexpected error: ${err}`); - } - }; -} +import * as apiKeys from './lib/api-keys'; +import * as deviceState from './device-state'; const expressLogger = morgan( (tokens, req, res) => @@ -112,7 +65,7 @@ export class SupervisorAPI { this.api.get('/ping', (_req, res) => res.send('OK')); - this.api.use(authenticate()); + this.api.use(apiKeys.authMiddleware); this.api.post('/v1/blink', (_req, res) => { eventTracker.track('Device blink'); @@ -123,11 +76,30 @@ export class SupervisorAPI { // Expires the supervisor's API key and generates a new one. // It also communicates the new key to the balena API. - this.api.post('/v1/regenerate-api-key', async (_req, res) => { - const secret = config.newUniqueKey(); - await config.set({ apiSecret: secret }); - res.status(200).send(secret); - }); + this.api.post( + '/v1/regenerate-api-key', + async (req: apiKeys.AuthorizedRequest, res) => { + await deviceState.initialized; + await apiKeys.initialized; + + // check if we're updating the cloud API key + const updateCloudKey = req.auth.apiKey === apiKeys.cloudApiKey; + + // regenerate the key... + const newKey = await apiKeys.refreshKey(req.auth.apiKey); + + // if we need to update the cloud API with our new key + if (updateCloudKey) { + // report the new key to the cloud API + deviceState.reportCurrentState({ + api_secret: apiKeys.cloudApiKey, + }); + } + + // return the value of the new key to the caller + res.status(200).send(newKey); + }, + ); // And assign all external routers for (const router of this.routers) { @@ -135,7 +107,6 @@ export class SupervisorAPI { } // Error handling. - const messageFromError = (err?: Error | string | null): string => { let message = 'Unknown error'; if (err != null) { diff --git a/src/supervisor.ts b/src/supervisor.ts index 2e7fb0bb..7827bb7e 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -19,7 +19,6 @@ const startupConfigFields: config.ConfigKey[] = [ 'uuid', 'listenPort', 'apiEndpoint', - 'apiSecret', 'apiTimeout', 'unmanaged', 'deviceApiKey', diff --git a/test/03-config.spec.ts b/test/03-config.spec.ts index babb03ca..9ffbc91d 100644 --- a/test/03-config.spec.ts +++ b/test/03-config.spec.ts @@ -64,12 +64,6 @@ describe('Config', () => { }); }); - it('allows removing a db key', async () => { - await conf.remove('apiSecret'); - const secret = await conf.get('apiSecret'); - return expect(secret).to.be.undefined; - }); - it('allows deleting a config.json key and returns a default value if none is set', async () => { await conf.remove('appUpdatePollInterval'); const poll = await conf.get('appUpdatePollInterval'); diff --git a/test/04-service.spec.ts b/test/04-service.spec.ts index b5b2e845..9bbd7b7c 100644 --- a/test/04-service.spec.ts +++ b/test/04-service.spec.ts @@ -28,7 +28,7 @@ const configs = { }; describe('compose/service', () => { - it('extends environment variables properly', () => { + it('extends environment variables properly', async () => { const extendEnvVarsOpts = { uuid: '1234', appName: 'awesomeApp', @@ -50,7 +50,10 @@ describe('compose/service', () => { A_VARIABLE: 'ITS_VALUE', }, }; - const s = Service.fromComposeObject(service, extendEnvVarsOpts as any); + const s = await Service.fromComposeObject( + service, + extendEnvVarsOpts as any, + ); expect(s.config.environment).to.deep.equal({ FOO: 'bar', @@ -81,8 +84,8 @@ describe('compose/service', () => { }); }); - it('returns the correct default bind mounts', () => { - const s = Service.fromComposeObject( + it('returns the correct default bind mounts', async () => { + const s = await Service.fromComposeObject( { appId: '1234', serviceName: 'foo', @@ -99,8 +102,8 @@ describe('compose/service', () => { ]); }); - it('produces the correct port bindings and exposed ports', () => { - const s = Service.fromComposeObject( + it('produces the correct port bindings and exposed ports', async () => { + const s = await Service.fromComposeObject( { appId: '1234', serviceName: 'foo', @@ -155,8 +158,8 @@ describe('compose/service', () => { }); }); - it('correctly handles port ranges', () => { - const s = Service.fromComposeObject( + it('correctly handles port ranges', async () => { + const s = await Service.fromComposeObject( { appId: '1234', serviceName: 'foo', @@ -207,9 +210,9 @@ describe('compose/service', () => { }); }); - it('should correctly handle large port ranges', function () { + it('should correctly handle large port ranges', async function () { this.timeout(60000); - const s = Service.fromComposeObject( + const s = await Service.fromComposeObject( { appId: '1234', serviceName: 'foo', @@ -224,8 +227,8 @@ describe('compose/service', () => { expect((s as any).generateExposeAndPorts()).to.not.throw; }); - it('should correctly report implied exposed ports from portMappings', () => { - const service = Service.fromComposeObject( + it('should correctly report implied exposed ports from portMappings', async () => { + const service = await Service.fromComposeObject( { appId: 123456, serviceId: 123456, @@ -240,8 +243,8 @@ describe('compose/service', () => { .that.deep.equals(['80/tcp', '100/tcp']); }); - it('should correctly handle spaces in volume definitions', () => { - const service = Service.fromComposeObject( + it('should correctly handle spaces in volume definitions', async () => { + const service = await Service.fromComposeObject( { appId: 123, serviceId: 123, @@ -270,8 +273,8 @@ describe('compose/service', () => { }); describe('Ordered array parameters', () => { - it('Should correctly compare ordered array parameters', () => { - const svc1 = Service.fromComposeObject( + it('Should correctly compare ordered array parameters', async () => { + const svc1 = await Service.fromComposeObject( { appId: 1, serviceId: 1, @@ -280,7 +283,7 @@ describe('compose/service', () => { }, { appName: 'test' } as any, ); - let svc2 = Service.fromComposeObject( + let svc2 = await Service.fromComposeObject( { appId: 1, serviceId: 1, @@ -291,7 +294,7 @@ describe('compose/service', () => { ); assert(svc1.isEqualConfig(svc2, {})); - svc2 = Service.fromComposeObject( + svc2 = await Service.fromComposeObject( { appId: 1, serviceId: 1, @@ -303,8 +306,8 @@ describe('compose/service', () => { assert(!svc1.isEqualConfig(svc2, {})); }); - it('should correctly compare non-ordered array parameters', () => { - const svc1 = Service.fromComposeObject( + it('should correctly compare non-ordered array parameters', async () => { + const svc1 = await Service.fromComposeObject( { appId: 1, serviceId: 1, @@ -313,7 +316,7 @@ describe('compose/service', () => { }, { appName: 'test' } as any, ); - let svc2 = Service.fromComposeObject( + let svc2 = await Service.fromComposeObject( { appId: 1, serviceId: 1, @@ -324,7 +327,7 @@ describe('compose/service', () => { ); assert(svc1.isEqualConfig(svc2, {})); - svc2 = Service.fromComposeObject( + svc2 = await Service.fromComposeObject( { appId: 1, serviceId: 1, @@ -336,8 +339,8 @@ describe('compose/service', () => { assert(svc1.isEqualConfig(svc2, {})); }); - it('should correctly compare both ordered and non-ordered array parameters', () => { - const svc1 = Service.fromComposeObject( + it('should correctly compare both ordered and non-ordered array parameters', async () => { + const svc1 = await Service.fromComposeObject( { appId: 1, serviceId: 1, @@ -347,7 +350,7 @@ describe('compose/service', () => { }, { appName: 'test' } as any, ); - const svc2 = Service.fromComposeObject( + const svc2 = await Service.fromComposeObject( { appId: 1, serviceId: 1, @@ -362,8 +365,8 @@ describe('compose/service', () => { }); describe('parseMemoryNumber()', () => { - const makeComposeServiceWithLimit = (memLimit?: string | number) => - Service.fromComposeObject( + const makeComposeServiceWithLimit = async (memLimit?: string | number) => + await Service.fromComposeObject( { appId: 123456, serviceId: 123456, @@ -373,74 +376,82 @@ describe('compose/service', () => { { appName: 'test' } as any, ); - it('should correctly parse memory number strings without a unit', () => - expect(makeComposeServiceWithLimit('64').config.memLimit).to.equal(64)); + it('should correctly parse memory number strings without a unit', async () => + expect( + (await makeComposeServiceWithLimit('64')).config.memLimit, + ).to.equal(64)); - it('should correctly apply the default value', () => - expect(makeComposeServiceWithLimit(undefined).config.memLimit).to.equal( - 0, + it('should correctly apply the default value', async () => + expect( + (await makeComposeServiceWithLimit(undefined)).config.memLimit, + ).to.equal(0)); + + it('should correctly support parsing numbers as memory limits', async () => + expect((await makeComposeServiceWithLimit(64)).config.memLimit).to.equal( + 64, )); - it('should correctly support parsing numbers as memory limits', () => - expect(makeComposeServiceWithLimit(64).config.memLimit).to.equal(64)); - - it('should correctly parse memory number strings that use a byte unit', () => { - expect(makeComposeServiceWithLimit('64b').config.memLimit).to.equal(64); - expect(makeComposeServiceWithLimit('64B').config.memLimit).to.equal(64); + it('should correctly parse memory number strings that use a byte unit', async () => { + expect( + (await makeComposeServiceWithLimit('64b')).config.memLimit, + ).to.equal(64); + expect( + (await makeComposeServiceWithLimit('64B')).config.memLimit, + ).to.equal(64); }); - it('should correctly parse memory number strings that use a kilobyte unit', () => { - expect(makeComposeServiceWithLimit('64k').config.memLimit).to.equal( - 65536, - ); - expect(makeComposeServiceWithLimit('64K').config.memLimit).to.equal( - 65536, - ); + it('should correctly parse memory number strings that use a kilobyte unit', async () => { + expect( + (await makeComposeServiceWithLimit('64k')).config.memLimit, + ).to.equal(65536); + expect( + (await makeComposeServiceWithLimit('64K')).config.memLimit, + ).to.equal(65536); - expect(makeComposeServiceWithLimit('64kb').config.memLimit).to.equal( - 65536, - ); - expect(makeComposeServiceWithLimit('64Kb').config.memLimit).to.equal( - 65536, - ); + expect( + (await makeComposeServiceWithLimit('64kb')).config.memLimit, + ).to.equal(65536); + expect( + (await makeComposeServiceWithLimit('64Kb')).config.memLimit, + ).to.equal(65536); }); - it('should correctly parse memory number strings that use a megabyte unit', () => { - expect(makeComposeServiceWithLimit('64m').config.memLimit).to.equal( - 67108864, - ); - expect(makeComposeServiceWithLimit('64M').config.memLimit).to.equal( - 67108864, - ); + it('should correctly parse memory number strings that use a megabyte unit', async () => { + expect( + (await makeComposeServiceWithLimit('64m')).config.memLimit, + ).to.equal(67108864); + expect( + (await makeComposeServiceWithLimit('64M')).config.memLimit, + ).to.equal(67108864); - expect(makeComposeServiceWithLimit('64mb').config.memLimit).to.equal( - 67108864, - ); - expect(makeComposeServiceWithLimit('64Mb').config.memLimit).to.equal( - 67108864, - ); + expect( + (await makeComposeServiceWithLimit('64mb')).config.memLimit, + ).to.equal(67108864); + expect( + (await makeComposeServiceWithLimit('64Mb')).config.memLimit, + ).to.equal(67108864); }); - it('should correctly parse memory number strings that use a gigabyte unit', () => { - expect(makeComposeServiceWithLimit('64g').config.memLimit).to.equal( - 68719476736, - ); - expect(makeComposeServiceWithLimit('64G').config.memLimit).to.equal( - 68719476736, - ); + it('should correctly parse memory number strings that use a gigabyte unit', async () => { + expect( + (await makeComposeServiceWithLimit('64g')).config.memLimit, + ).to.equal(68719476736); + expect( + (await makeComposeServiceWithLimit('64G')).config.memLimit, + ).to.equal(68719476736); - expect(makeComposeServiceWithLimit('64gb').config.memLimit).to.equal( - 68719476736, - ); - expect(makeComposeServiceWithLimit('64Gb').config.memLimit).to.equal( - 68719476736, - ); + expect( + (await makeComposeServiceWithLimit('64gb')).config.memLimit, + ).to.equal(68719476736); + expect( + (await makeComposeServiceWithLimit('64Gb')).config.memLimit, + ).to.equal(68719476736); }); }); describe('getWorkingDir', () => { - const makeComposeServiceWithWorkdir = (workdir?: string) => - Service.fromComposeObject( + const makeComposeServiceWithWorkdir = async (workdir?: string) => + await Service.fromComposeObject( { appId: 123456, serviceId: 123456, @@ -450,17 +461,20 @@ describe('compose/service', () => { { appName: 'test' } as any, ); - it('should remove a trailing slash', () => { + it('should remove a trailing slash', async () => { expect( - makeComposeServiceWithWorkdir('/usr/src/app/').config.workingDir, + (await makeComposeServiceWithWorkdir('/usr/src/app/')).config + .workingDir, ).to.equal('/usr/src/app'); - expect(makeComposeServiceWithWorkdir('/').config.workingDir).to.equal( - '/', - ); expect( - makeComposeServiceWithWorkdir('/usr/src/app').config.workingDir, + (await makeComposeServiceWithWorkdir('/')).config.workingDir, + ).to.equal('/'); + expect( + (await makeComposeServiceWithWorkdir('/usr/src/app')).config.workingDir, ).to.equal('/usr/src/app'); - expect(makeComposeServiceWithWorkdir('').config.workingDir).to.equal(''); + expect( + (await makeComposeServiceWithWorkdir('')).config.workingDir, + ).to.equal(''); }); }); @@ -469,8 +483,8 @@ describe('compose/service', () => { Count: 1, Capabilities: [['gpu']], }; - it('should succeed from compose object', () => { - const s = Service.fromComposeObject( + it('should succeed from compose object', async () => { + const s = await Service.fromComposeObject( { appId: 123, serviceId: 123, @@ -504,8 +518,8 @@ describe('compose/service', () => { const omitConfigForComparison = (config: ServiceConfig) => _.omit(config, ['running', 'networks']); - it('should be identical when converting a simple service', () => { - const composeSvc = Service.fromComposeObject( + it('should be identical when converting a simple service', async () => { + const composeSvc = await Service.fromComposeObject( configs.simple.compose, configs.simple.imageInfo, ); @@ -518,8 +532,8 @@ describe('compose/service', () => { expect(dockerSvc.isEqualConfig(composeSvc, {})).to.be.true; }); - it('should correctly convert formats with a null entrypoint', () => { - const composeSvc = Service.fromComposeObject( + it('should correctly convert formats with a null entrypoint', async () => { + const composeSvc = await Service.fromComposeObject( configs.entrypoint.compose, configs.entrypoint.imageInfo, ); @@ -533,11 +547,11 @@ describe('compose/service', () => { }); describe('Networks', () => { - it('should correctly convert networks from compose to docker format', () => { - const makeComposeServiceWithNetwork = ( + it('should correctly convert networks from compose to docker format', async () => { + const makeComposeServiceWithNetwork = async ( networks: ServiceComposeConfig['networks'], ) => - Service.fromComposeObject( + await Service.fromComposeObject( { appId: 123456, serviceId: 123456, @@ -548,11 +562,13 @@ describe('compose/service', () => { ); expect( - makeComposeServiceWithNetwork({ - balena: { - ipv4Address: '1.2.3.4', - }, - }).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig, + ( + await makeComposeServiceWithNetwork({ + balena: { + ipv4Address: '1.2.3.4', + }, + }) + ).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig, ).to.deep.equal({ EndpointsConfig: { '123456_balena': { @@ -565,14 +581,16 @@ describe('compose/service', () => { }); expect( - makeComposeServiceWithNetwork({ - balena: { - aliases: ['test', '1123'], - ipv4Address: '1.2.3.4', - ipv6Address: '5.6.7.8', - linkLocalIps: ['123.123.123'], - }, - }).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig, + ( + await makeComposeServiceWithNetwork({ + balena: { + aliases: ['test', '1123'], + ipv4Address: '1.2.3.4', + ipv6Address: '5.6.7.8', + linkLocalIps: ['123.123.123'], + }, + }) + ).toDockerContainer({ deviceName: 'foo' } as any).NetworkingConfig, ).to.deep.equal({ EndpointsConfig: { '123456_balena': { @@ -638,8 +656,8 @@ describe('compose/service', () => { }); return describe('Network mode=service:', () => { - it('should correctly add a depends_on entry for the service', () => { - let s = Service.fromComposeObject( + it('should correctly add a depends_on entry for the service', async () => { + let s = await Service.fromComposeObject( { appId: '1234', serviceName: 'foo', @@ -653,7 +671,7 @@ describe('compose/service', () => { expect(s.dependsOn).to.deep.equal(['test']); - s = Service.fromComposeObject( + s = await Service.fromComposeObject( { appId: '1234', serviceName: 'foo', @@ -669,8 +687,8 @@ describe('compose/service', () => { expect(s.dependsOn).to.deep.equal(['another_service', 'test']); }); - it('should correctly convert a network_mode service: to a container:', () => { - const s = Service.fromComposeObject( + it('should correctly convert a network_mode service: to a container:', async () => { + const s = await Service.fromComposeObject( { appId: '1234', serviceName: 'foo', @@ -692,8 +710,8 @@ describe('compose/service', () => { .that.equals('container:abcdef'); }); - it('should not cause a container restart if a service: container has not changed', () => { - const composeSvc = Service.fromComposeObject( + it('should not cause a container restart if a service: container has not changed', async () => { + const composeSvc = await Service.fromComposeObject( configs.networkModeService.compose, configs.networkModeService.imageInfo, ); @@ -709,8 +727,8 @@ describe('compose/service', () => { .true; }); - it('should restart a container when its dependent network mode container changes', () => { - const composeSvc = Service.fromComposeObject( + it('should restart a container when its dependent network mode container changes', async () => { + const composeSvc = await Service.fromComposeObject( configs.networkModeService.compose, configs.networkModeService.imageInfo, ); diff --git a/test/05-device-state.spec.ts b/test/05-device-state.spec.ts index d4aa78a7..41a3d8fd 100644 --- a/test/05-device-state.spec.ts +++ b/test/05-device-state.spec.ts @@ -313,7 +313,9 @@ describe('deviceState', () => { service.image = imageName; (service as any).imageName = imageName; services.push( - Service.fromComposeObject(service, { appName: 'supertest' } as any), + await Service.fromComposeObject(service, { + appName: 'supertest', + } as any), ); } diff --git a/test/21-supervisor-api.spec.ts b/test/21-supervisor-api.spec.ts index 548651d1..64be45b4 100644 --- a/test/21-supervisor-api.spec.ts +++ b/test/21-supervisor-api.spec.ts @@ -12,13 +12,14 @@ import mockedAPI = require('./lib/mocked-device-api'); import * as applicationManager from '../src/compose/application-manager'; import { InstancedAppState } from '../src/types/state'; +import * as apiKeys from '../src/lib/api-keys'; +import * as db from '../src/db'; + const mockedOptions = { listenPort: 54321, timeout: 30000, }; -const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret; - describe('SupervisorAPI', () => { let api: SupervisorAPI; let healthCheckStubs: SinonStub[]; @@ -40,6 +41,10 @@ describe('SupervisorAPI', () => { // Start test API await api.listen(mockedOptions.listenPort, mockedOptions.timeout); + + // Create a scoped key + await apiKeys.initialized; + await apiKeys.generateCloudKey(); }); after(async () => { @@ -56,6 +61,104 @@ describe('SupervisorAPI', () => { await mockedAPI.cleanUp(); }); + describe('API Key Scope', () => { + it('should generate a key which is scoped for a single application', async () => { + // single app scoped key... + const appScopedKey = await apiKeys.generateScopedKey(1, 1); + + await request + .get('/v2/applications/1/state') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${appScopedKey}`) + .expect(200); + }); + it('should generate a key which is scoped for multiple applications', async () => { + // multi-app scoped key... + const multiAppScopedKey = await apiKeys.generateScopedKey(1, 2, { + scopes: [1, 2].map((appId) => { + return { type: 'app', appId }; + }), + }); + + await request + .get('/v2/applications/1/state') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${multiAppScopedKey}`) + .expect(200); + + await request + .get('/v2/applications/2/state') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${multiAppScopedKey}`) + .expect(200); + }); + it('should generate a key which is scoped for all applications', async () => { + await request + .get('/v2/applications/1/state') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .expect(200); + + await request + .get('/v2/applications/2/state') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) + .expect(200); + }); + it('should have a cached lookup of the key scopes to save DB loading', async () => { + const scopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey); + + const key = 'not-a-normal-key'; + await db.initialized; + await db + .models('apiSecret') + .update({ + key, + }) + .where({ + key: apiKeys.cloudApiKey, + }); + + // the key we had is now gone, but the cache should return values + const cachedScopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey); + expect(cachedScopes).to.deep.equal(scopes); + + // this should bust the cache... + await apiKeys.generateCloudKey(true); + + // the key we changed should be gone now, and the new key should have the cloud scopes + const missingScopes = await apiKeys.getScopesForKey(key); + const freshScopes = await apiKeys.getScopesForKey(apiKeys.cloudApiKey); + + expect(missingScopes).to.be.null; + expect(freshScopes).to.deep.equal(scopes); + }); + it('should regenerate a key and invalidate the old one', async () => { + // single app scoped key... + const appScopedKey = await apiKeys.generateScopedKey(1, 1); + + await request + .get('/v2/applications/1/state') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${appScopedKey}`) + .expect(200); + + const newScopedKey = await apiKeys.refreshKey(appScopedKey); + + await request + .get('/v2/applications/1/state') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${appScopedKey}`) + .expect(401); + + await request + .get('/v2/applications/1/state') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${newScopedKey}`) + .expect(200); + }); + }); + describe('/ping', () => { it('responds with OK (without auth)', async () => { await request.get('/ping').set('Accept', 'application/json').expect(200); @@ -64,7 +167,7 @@ describe('SupervisorAPI', () => { await request .get('/ping') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${VALID_SECRET}`) + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) .expect(200); }); }); @@ -77,7 +180,7 @@ describe('SupervisorAPI', () => { await request .get('/v1/healthy') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${VALID_SECRET}`) + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) .expect(sampleResponses.V1.GET['/healthy'].statusCode) .then((response) => { expect(response.body).to.deep.equal( @@ -138,7 +241,7 @@ describe('SupervisorAPI', () => { await request .get('/v1/apps/2') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${VALID_SECRET}`) + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) .expect(sampleResponses.V1.GET['/apps/2'].statusCode) .expect('Content-Type', /json/) .then((response) => { @@ -154,7 +257,7 @@ describe('SupervisorAPI', () => { await request .post('/v1/apps/2/stop') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${VALID_SECRET}`) + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) .expect(sampleResponses.V1.GET['/apps/2/stop'].statusCode) .expect('Content-Type', /json/) .then((response) => { @@ -170,7 +273,7 @@ describe('SupervisorAPI', () => { const response = await request .get('/v1/device') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${VALID_SECRET}`) + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) .expect(200); expect(response.body).to.have.property('mac_address').that.is.not.empty; @@ -184,7 +287,7 @@ describe('SupervisorAPI', () => { await request .get('/v2/device/vpn') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${VALID_SECRET}`) + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) .expect('Content-Type', /json/) .expect(sampleResponses.V2.GET['/device/vpn'].statusCode) .then((response) => { @@ -200,9 +303,9 @@ describe('SupervisorAPI', () => { await request .get('/v2/applications/1/state') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${VALID_SECRET}`) - .expect('Content-Type', /json/) + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) .expect(sampleResponses.V2.GET['/applications/1/state'].statusCode) + .expect('Content-Type', /json/) .then((response) => { expect(response.body).to.deep.equal( sampleResponses.V2.GET['/applications/1/state'].body, @@ -214,7 +317,7 @@ describe('SupervisorAPI', () => { await request .get('/v2/applications/123invalid/state') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${VALID_SECRET}`) + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) .expect('Content-Type', /json/) .expect( sampleResponses.V2.GET['/applications/123invalid/state'].statusCode, @@ -230,8 +333,7 @@ describe('SupervisorAPI', () => { await request .get('/v2/applications/9000/state') .set('Accept', 'application/json') - .set('Authorization', `Bearer ${VALID_SECRET}`) - .expect('Content-Type', /json/) + .set('Authorization', `Bearer ${apiKeys.cloudApiKey}`) .expect(sampleResponses.V2.GET['/applications/9000/state'].statusCode) .then((response) => { expect(response.body).to.deep.equal( @@ -239,6 +341,17 @@ describe('SupervisorAPI', () => { ); }); }); + + describe('Scoped API Keys', () => { + it('returns 409 because app is out of scope of the key', async () => { + const apiKey = await apiKeys.generateScopedKey(3, 1); + await request + .get('/v2/applications/2/state') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${apiKey}`) + .expect(409); + }); + }); }); // TODO: add tests for rest of V2 endpoints diff --git a/test/26-supervisor-api-auth.spec.ts b/test/26-supervisor-api-auth.spec.ts index 3e135475..e6ad28cf 100644 --- a/test/26-supervisor-api-auth.spec.ts +++ b/test/26-supervisor-api-auth.spec.ts @@ -2,13 +2,13 @@ import * as supertest from 'supertest'; import SupervisorAPI from '../src/supervisor-api'; import mockedAPI = require('./lib/mocked-device-api'); +import { cloudApiKey } from '../src/lib/api-keys'; const mockedOptions = { listenPort: 12345, timeout: 30000, }; -const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret; const INVALID_SECRET = 'bad_api_secret'; describe('SupervisorAPI authentication', () => { @@ -39,20 +39,20 @@ describe('SupervisorAPI authentication', () => { }); it('finds apiKey from query', async () => { - return request.post(`/v1/blink?apikey=${VALID_SECRET}`).expect(200); + return request.post(`/v1/blink?apikey=${cloudApiKey}`).expect(200); }); it('finds apiKey from Authorization header (ApiKey scheme)', async () => { return request .post('/v1/blink') - .set('Authorization', `ApiKey ${VALID_SECRET}`) + .set('Authorization', `ApiKey ${cloudApiKey}`) .expect(200); }); it('finds apiKey from Authorization header (Bearer scheme)', async () => { return request .post('/v1/blink') - .set('Authorization', `Bearer ${VALID_SECRET}`) + .set('Authorization', `Bearer ${cloudApiKey}`) .expect(200); }); @@ -70,7 +70,7 @@ describe('SupervisorAPI authentication', () => { for (const scheme of randomCases) { return request .post('/v1/blink') - .set('Authorization', `${scheme} ${VALID_SECRET}`) + .set('Authorization', `${scheme} ${cloudApiKey}`) .expect(200); } }); diff --git a/test/34-compose-app.ts b/test/34-compose-app.ts index 68e72fc6..c58b0dff 100644 --- a/test/34-compose-app.ts +++ b/test/34-compose-app.ts @@ -45,7 +45,7 @@ function createApp( ); } -function createService( +async function createService( conf: Partial, appId = 1, serviceName = 'test', @@ -54,7 +54,7 @@ function createService( imageId = 4, extraState?: Partial, ) { - const svc = Service.fromComposeObject( + const svc = await Service.fromComposeObject( { appId, serviceName, @@ -265,15 +265,15 @@ describe('compose/app', () => { .that.deep.equals({ 'io.balena.supervised': 'true', test: 'test' }); }); - it('should kill dependencies of a volume before changing config', () => { + it('should kill dependencies of a volume before changing config', async () => { const current = createApp( - [createService({ volumes: ['test-volume'] })], + [await createService({ volumes: ['test-volume'] })], [], [Volume.fromComposeObject('test-volume', 1, {})], false, ); const target = createApp( - [createService({ volumes: ['test-volume'] })], + [await createService({ volumes: ['test-volume'] })], [], [ Volume.fromComposeObject('test-volume', 1, { @@ -381,14 +381,14 @@ describe('compose/app', () => { .that.equals('test-network'); }); - it('should kill dependencies of networks before removing', () => { + it('should kill dependencies of networks before removing', async () => { const current = createApp( - [createService({ networks: { 'test-network': {} } })], + [await createService({ networks: { 'test-network': {} } })], [Network.fromComposeObject('test-network', 1, {})], [], false, ); - const target = createApp([createService({})], [], [], true); + const target = createApp([await createService({})], [], [], true); const steps = current.nextStepsForAppUpdate(defaultContext, target); const idx = expectStep('kill', steps); @@ -398,15 +398,15 @@ describe('compose/app', () => { .that.equals('test'); }); - it('should kill dependencies of networks before changing config', () => { + it('should kill dependencies of networks before changing config', async () => { const current = createApp( - [createService({ networks: { 'test-network': {} } })], + [await createService({ networks: { 'test-network': {} } })], [Network.fromComposeObject('test-network', 1, {})], [], false, ); const target = createApp( - [createService({ networks: { 'test-network': {} } })], + [await createService({ networks: { 'test-network': {} } })], [ Network.fromComposeObject('test-network', 1, { labels: { test: 'test' }, @@ -426,8 +426,8 @@ describe('compose/app', () => { expect(() => expectStep('removeNetwork', steps)).to.throw(); }); - it('should not output a kill step for a service which is already stopping when changing a volume', () => { - const service = createService({ volumes: ['test-volume'] }); + it('should not output a kill step for a service which is already stopping when changing a volume', async () => { + const service = await createService({ volumes: ['test-volume'] }); service.status = 'Stopping'; const current = createApp( [service], @@ -464,13 +464,16 @@ describe('compose/app', () => { it('should create a kill step for service which is no longer referenced', async () => { const current = createApp( - [createService({}, 1, 'main', 1, 1), createService({}, 1, 'aux', 1, 2)], + [ + await createService({}, 1, 'main', 1, 1), + await createService({}, 1, 'aux', 1, 2), + ], [Network.fromComposeObject('test-network', 1, {})], [], false, ); const target = createApp( - [createService({}, 1, 'main', 2, 1)], + [await createService({}, 1, 'main', 2, 1)], [Network.fromComposeObject('test-network', 1, {})], [], true, @@ -486,7 +489,7 @@ describe('compose/app', () => { it('should emit a noop when a service which is no longer referenced is already stopping', async () => { const current = createApp( - [createService({}, 1, 'main', 1, 1, 1, { status: 'Stopping' })], + [await createService({}, 1, 'main', 1, 1, 1, { status: 'Stopping' })], [], [], false, @@ -497,15 +500,15 @@ describe('compose/app', () => { expectStep('noop', steps); }); - it('should remove a dead container that is still referenced in the target state', () => { + it('should remove a dead container that is still referenced in the target state', async () => { const current = createApp( - [createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })], + [await createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })], [], [], false, ); const target = createApp( - [createService({}, 1, 'main', 1, 1, 1)], + [await createService({}, 1, 'main', 1, 1, 1)], [], [], true, @@ -515,9 +518,9 @@ describe('compose/app', () => { expectStep('remove', steps); }); - it('should remove a dead container that is not referenced in the target state', () => { + it('should remove a dead container that is not referenced in the target state', async () => { const current = createApp( - [createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })], + [await createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })], [], [], false, @@ -528,10 +531,10 @@ describe('compose/app', () => { expectStep('remove', steps); }); - it('should emit a noop when a service has an image downloading', () => { + it('should emit a noop when a service has an image downloading', async () => { const current = createApp([], [], [], false); const target = createApp( - [createService({}, 1, 'main', 1, 1, 1)], + [await createService({}, 1, 'main', 1, 1, 1)], [], [], true, @@ -544,15 +547,15 @@ describe('compose/app', () => { expectStep('noop', steps); }); - it('should emit an updateMetadata step when a service has not changed but the release has', () => { + it('should emit an updateMetadata step when a service has not changed but the release has', async () => { const current = createApp( - [createService({}, 1, 'main', 1, 1, 1)], + [await createService({}, 1, 'main', 1, 1, 1)], [], [], false, ); const target = createApp( - [createService({}, 1, 'main', 2, 1, 1)], + [await createService({}, 1, 'main', 2, 1, 1)], [], [], true, @@ -562,15 +565,15 @@ describe('compose/app', () => { expectStep('updateMetadata', steps); }); - it('should stop a container which has stoppped as its target', () => { + it('should stop a container which has stoppped as its target', async () => { const current = createApp( - [createService({}, 1, 'main', 1, 1, 1)], + [await createService({}, 1, 'main', 1, 1, 1)], [], [], false, ); const target = createApp( - [createService({ running: false }, 1, 'main', 1, 1, 1)], + [await createService({ running: false }, 1, 'main', 1, 1, 1)], [], [], true, @@ -580,7 +583,7 @@ describe('compose/app', () => { expectStep('stop', steps); }); - it('should recreate a container if the target configuration changes', () => { + it('should recreate a container if the target configuration changes', async () => { const contextWithImages = { ...defaultContext, ...{ @@ -598,13 +601,13 @@ describe('compose/app', () => { }, }; let current = createApp( - [createService({}, 1, 'main', 1, 1, 1, {})], + [await createService({}, 1, 'main', 1, 1, 1, {})], [defaultNetwork], [], false, ); const target = createApp( - [createService({ privileged: true }, 1, 'main', 1, 1, 1, {})], + [await createService({ privileged: true }, 1, 'main', 1, 1, 1, {})], [defaultNetwork], [], true, @@ -624,7 +627,7 @@ describe('compose/app', () => { .forTarget((t) => t.serviceName === 'main').to.exist; }); - it('should not start a container when it depends on a service which is being installed', () => { + it('should not start a container when it depends on a service which is being installed', async () => { const mainImage: Image = { appId: 1, dependent: 0, @@ -651,7 +654,7 @@ describe('compose/app', () => { try { let current = createApp( [ - createService({ running: false }, 1, 'dep', 1, 2, 2, { + await createService({ running: false }, 1, 'dep', 1, 2, 2, { status: 'Installing', containerId: 'id', }), @@ -662,8 +665,8 @@ describe('compose/app', () => { ); const target = createApp( [ - createService({}, 1, 'main', 1, 1, 1, { dependsOn: ['dep'] }), - createService({}, 1, 'dep', 1, 2, 2), + await createService({}, 1, 'main', 1, 1, 1, { dependsOn: ['dep'] }), + await createService({}, 1, 'dep', 1, 2, 2), ], [defaultNetwork], [], @@ -681,7 +684,7 @@ describe('compose/app', () => { // we now make our current state have the 'dep' service as started... current = createApp( - [createService({}, 1, 'dep', 1, 2, 2, { containerId: 'id' })], + [await createService({}, 1, 'dep', 1, 2, 2, { containerId: 'id' })], [defaultNetwork], [], false, @@ -704,10 +707,10 @@ describe('compose/app', () => { } }); - it('should emit a fetch step when an image has not been downloaded for a service', () => { + it('should emit a fetch step when an image has not been downloaded for a service', async () => { const current = createApp([], [], [], false); const target = createApp( - [createService({}, 1, 'main', 1, 1, 1)], + [await createService({}, 1, 'main', 1, 1, 1)], [], [], true, @@ -717,15 +720,15 @@ describe('compose/app', () => { withSteps(steps).expectStep('fetch').to.exist; }); - it('should stop a container which has stoppped as its target', () => { + it('should stop a container which has stoppped as its target', async () => { const current = createApp( - [createService({}, 1, 'main', 1, 1, 1)], + [await createService({}, 1, 'main', 1, 1, 1)], [], [], false, ); const target = createApp( - [createService({ running: false }, 1, 'main', 1, 1, 1)], + [await createService({ running: false }, 1, 'main', 1, 1, 1)], [], [], true, @@ -735,7 +738,7 @@ describe('compose/app', () => { withSteps(steps).expectStep('stop'); }); - it('should create a start step when all that changes is a running state', () => { + it('should create a start step when all that changes is a running state', async () => { const contextWithImages = { ...defaultContext, ...{ @@ -753,13 +756,13 @@ describe('compose/app', () => { }, }; const current = createApp( - [createService({ running: false }, 1, 'main', 1, 1, 1, {})], + [await createService({ running: false }, 1, 'main', 1, 1, 1, {})], [defaultNetwork], [], false, ); const target = createApp( - [createService({}, 1, 'main', 1, 1, 1, {})], + [await createService({}, 1, 'main', 1, 1, 1, {})], [defaultNetwork], [], true, @@ -772,7 +775,7 @@ describe('compose/app', () => { .forTarget((t) => t.serviceName === 'main').to.exist; }); - it('should not infer a fetch step when the download is already in progress', () => { + it('should not infer a fetch step when the download is already in progress', async () => { const contextWithDownloading = { ...defaultContext, ...{ @@ -781,7 +784,7 @@ describe('compose/app', () => { }; const current = createApp([], [], [], false); const target = createApp( - [createService({}, 1, 'main', 1, 1, 1)], + [await createService({}, 1, 'main', 1, 1, 1)], [], [], true, @@ -791,7 +794,7 @@ describe('compose/app', () => { withSteps(steps).expectStep('fetch').forTarget('main').to.not.exist; }); - it('should create a kill step when a service has to be updated but the strategy is kill-then-download', () => { + it('should create a kill step when a service has to be updated but the strategy is kill-then-download', async () => { const contextWithImages = { ...defaultContext, ...{ @@ -814,14 +817,24 @@ describe('compose/app', () => { }; const current = createApp( - [createService({ labels, image: 'main-image' }, 1, 'main', 1, 1, 1, {})], + [ + await createService( + { labels, image: 'main-image' }, + 1, + 'main', + 1, + 1, + 1, + {}, + ), + ], [defaultNetwork], [], false, ); const target = createApp( [ - createService( + await createService( { labels, image: 'main-image-2' }, 1, 'main', @@ -854,7 +867,7 @@ describe('compose/app', () => { .that.equals('main-image-2'); }); - it('should not infer a kill step with the default strategy if a dependency is not downloaded', () => { + it('should not infer a kill step with the default strategy if a dependency is not downloaded', async () => { const contextWithImages = { ...defaultContext, ...{ @@ -893,10 +906,10 @@ describe('compose/app', () => { const current = createApp( [ - createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, { + await createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, { dependsOn: ['dep'], }), - createService({ image: 'dep-image' }, 1, 'dep', 1, 2, 2, {}), + await createService({ image: 'dep-image' }, 1, 'dep', 1, 2, 2, {}), ], [defaultNetwork], [], @@ -904,10 +917,10 @@ describe('compose/app', () => { ); const target = createApp( [ - createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 3, { + await createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 3, { dependsOn: ['dep'], }), - createService({ image: 'dep-image-2' }, 1, 'dep', 2, 2, 4, {}), + await createService({ image: 'dep-image-2' }, 1, 'dep', 2, 2, 4, {}), ], [defaultNetwork], [], @@ -918,7 +931,7 @@ describe('compose/app', () => { withSteps(steps).expectStep('kill').forCurrent('main').to.not.exist; }); - it('should create several kill steps as long as there is no unmet dependencies', () => { + it('should create several kill steps as long as there is no unmet dependencies', async () => { const contextWithImages = { ...defaultContext, ...{ @@ -946,13 +959,13 @@ describe('compose/app', () => { }; const current = createApp( - [createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {})], + [await createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {})], [defaultNetwork], [], false, ); const target = createApp( - [createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 2)], + [await createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 2)], [defaultNetwork], [], true, @@ -966,14 +979,14 @@ describe('compose/app', () => { withSteps(steps).expectStep('kill').forCurrent('main').to.exist; }); - it('should create a kill step when a service has to be updated but the strategy is kill-then-download', () => { + it('should create a kill step when a service has to be updated but the strategy is kill-then-download', async () => { const labels = { 'io.balena.update.strategy': 'kill-then-download', }; - const current = createApp([createService({ labels })], [], [], false); + const current = createApp([await createService({ labels })], [], [], false); const target = createApp( - [createService({ privileged: true })], + [await createService({ privileged: true })], [], [], true, @@ -983,15 +996,15 @@ describe('compose/app', () => { withSteps(steps).expectStep('kill').forCurrent('main'); }); - it('should not infer a kill step with the default strategy if a dependency is not downloaded', () => { + it('should not infer a kill step with the default strategy if a dependency is not downloaded', async () => { const current = createApp( - [createService({ image: 'image1' })], + [await createService({ image: 'image1' })], [], [], false, ); const target = createApp( - [createService({ image: 'image2' })], + [await createService({ image: 'image2' })], [], [], true, @@ -1002,19 +1015,19 @@ describe('compose/app', () => { withSteps(steps).rejectStep('kill'); }); - it('should create several kill steps as long as there is no unmet dependencies', () => { + it('should create several kill steps as long as there is no unmet dependencies', async () => { const current = createApp( [ - createService({}, 1, 'one', 1, 2), - createService({}, 1, 'two', 1, 3), - createService({}, 1, 'three', 1, 4), + await createService({}, 1, 'one', 1, 2), + await createService({}, 1, 'two', 1, 3), + await createService({}, 1, 'three', 1, 4), ], [], [], false, ); const target = createApp( - [createService({}, 1, 'three', 1, 4)], + [await createService({}, 1, 'three', 1, 4)], [], [], true, @@ -1023,10 +1036,10 @@ describe('compose/app', () => { const steps = current.nextStepsForAppUpdate(defaultContext, target); withSteps(steps).expectStep('kill').to.have.length(2); }); - it('should not create a service when a network it depends on is not ready', () => { + it('should not create a service when a network it depends on is not ready', async () => { const current = createApp([], [defaultNetwork], [], false); const target = createApp( - [createService({ networks: ['test'] }, 1)], + [await createService({ networks: ['test'] }, 1)], [defaultNetwork, Network.fromComposeObject('test', 1, {})], [], true, diff --git a/test/lib/mocked-device-api.ts b/test/lib/mocked-device-api.ts index 4f570fc5..587a8798 100644 --- a/test/lib/mocked-device-api.ts +++ b/test/lib/mocked-device-api.ts @@ -18,7 +18,6 @@ const DB_PATH = './test/data/supervisor-api.sqlite'; // Holds all values used for stubbing const STUBBED_VALUES = { config: { - apiSecret: 'secure_api_secret', currentCommit: '7fc9c5bea8e361acd49886fe6cc1e1cd', }, services: [ @@ -109,10 +108,7 @@ async function createAPIOpts(): Promise { async function initConfig(): Promise { // Initialize this config await config.initialized; - // Set testing secret - await config.set({ - apiSecret: STUBBED_VALUES.config.apiSecret, - }); + // Set a currentCommit await config.set({ currentCommit: STUBBED_VALUES.config.currentCommit,