mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-05-30 14:24:23 +00:00
fix: Scoped keys breaking livepush with existing cloud images on the device
Closes: #1512 Change-type: patch Signed-off-by: Rich Bayliss <rich@balena.io>
This commit is contained in:
parent
53e7412f75
commit
02aeb4fc1c
@ -510,25 +510,22 @@ export function createV2Api(router: Router) {
|
|||||||
overallDownloadProgress = downloadProgressTotal / downloads;
|
overallDownloadProgress = downloadProgressTotal / downloads;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_.uniq(appIds).length > 1) {
|
// This endpoint does not support multi-app but the device might be running multiple apps
|
||||||
// We can't accurately return the commit value each app without changing
|
// We must return information for only 1 application so use the first one in the list
|
||||||
// the shape of the data, and instead we'd like users to use the new v3
|
const appId = appIds[0];
|
||||||
// endpoints, which will come with multiapp
|
// Get the commit for this application
|
||||||
// If we're going to return information about more than one app, error out
|
const commit = await commitStore.getCommitForApp(appId);
|
||||||
return res.status(405).json({
|
// Filter containers by this application
|
||||||
status: 'failed',
|
const appContainers = containerStates.filter((c) => c.appId === appId);
|
||||||
message: `Cannot use /v2/ endpoints with a key that is scoped to multiple applications`,
|
// Filter images by this application
|
||||||
});
|
const appImages = imagesStates.filter((i) => i.appId === appId);
|
||||||
}
|
|
||||||
|
|
||||||
const commit = await commitStore.getCommitForApp(appIds[0]);
|
|
||||||
|
|
||||||
return res.status(200).send({
|
return res.status(200).send({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
appState: pending ? 'applying' : 'applied',
|
appState: pending ? 'applying' : 'applied',
|
||||||
overallDownloadProgress,
|
overallDownloadProgress,
|
||||||
containers: containerStates,
|
containers: appContainers,
|
||||||
images: imagesStates,
|
images: appImages,
|
||||||
release: commit,
|
release: commit,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -121,7 +121,7 @@ export const authMiddleware: AuthorizedRequestHandler = async (
|
|||||||
req.auth = {
|
req.auth = {
|
||||||
apiKey,
|
apiKey,
|
||||||
scopes: [],
|
scopes: [],
|
||||||
isScoped: () => false,
|
isScoped: (resources) => isScoped(resources, req.auth.scopes),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -134,6 +134,7 @@ export const authMiddleware: AuthorizedRequestHandler = async (
|
|||||||
|
|
||||||
// no need to authenticate, shortcut
|
// no need to authenticate, shortcut
|
||||||
if (!needsAuth) {
|
if (!needsAuth) {
|
||||||
|
// Allow requests that do not need auth to be scoped for all applications
|
||||||
req.auth.isScoped = () => true;
|
req.auth.isScoped = () => true;
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
@ -146,10 +147,6 @@ export const authMiddleware: AuthorizedRequestHandler = async (
|
|||||||
if (scopes != null) {
|
if (scopes != null) {
|
||||||
// keep the scopes for later incase they're desired
|
// keep the scopes for later incase they're desired
|
||||||
req.auth.scopes.push(...scopes);
|
req.auth.scopes.push(...scopes);
|
||||||
|
|
||||||
// which resources are scoped...
|
|
||||||
req.auth.isScoped = (resources) => isScoped(resources, req.auth.scopes);
|
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,17 +13,52 @@ import * as applicationManager from '../src/compose/application-manager';
|
|||||||
import { InstancedAppState } from '../src/types/state';
|
import { InstancedAppState } from '../src/types/state';
|
||||||
|
|
||||||
import * as serviceManager from '../src/compose/service-manager';
|
import * as serviceManager from '../src/compose/service-manager';
|
||||||
|
import * as images from '../src/compose/images';
|
||||||
|
|
||||||
import * as apiKeys from '../src/lib/api-keys';
|
import * as apiKeys from '../src/lib/api-keys';
|
||||||
import * as db from '../src/db';
|
import * as db from '../src/db';
|
||||||
import * as config from '../src/config';
|
import * as config from '../src/config';
|
||||||
import { Service } from '../src/compose/service';
|
import { Service } from '../src/compose/service';
|
||||||
|
import { Image } from '../src/compose/images';
|
||||||
|
|
||||||
const mockedOptions = {
|
const mockedOptions = {
|
||||||
listenPort: 54321,
|
listenPort: 54321,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockService = (overrides?: Partial<Service>) => {
|
||||||
|
return {
|
||||||
|
...{
|
||||||
|
appId: 1658654,
|
||||||
|
status: 'Running',
|
||||||
|
serviceName: 'main',
|
||||||
|
imageId: 2885946,
|
||||||
|
serviceId: 640681,
|
||||||
|
containerId:
|
||||||
|
'f93d386599d1b36e71272d46ad69770cff333842db04e2e4c64dda7b54da07c6',
|
||||||
|
createdAt: '2020-11-13T20:29:44.143Z',
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
} as Service;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockImage = (overrides?: Partial<Image>) => {
|
||||||
|
return {
|
||||||
|
...{
|
||||||
|
name:
|
||||||
|
'registry2.balena-cloud.com/v2/e2bf6410ffc30850e96f5071cdd1dca8@sha256:e2e87a8139b8fc14510095b210ad652d7d5badcc64fdc686cbf749d399fba15e',
|
||||||
|
appId: 1658654,
|
||||||
|
serviceName: 'main',
|
||||||
|
imageId: 2885946,
|
||||||
|
dockerImageId:
|
||||||
|
'sha256:4502983d72e2c72bc292effad1b15b49576da3801356f47fd275ba274d409c1a',
|
||||||
|
status: 'Downloaded',
|
||||||
|
downloadProgress: null,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
} as Image;
|
||||||
|
};
|
||||||
|
|
||||||
describe('SupervisorAPI', () => {
|
describe('SupervisorAPI', () => {
|
||||||
let api: SupervisorAPI;
|
let api: SupervisorAPI;
|
||||||
let healthCheckStubs: SinonStub[];
|
let healthCheckStubs: SinonStub[];
|
||||||
@ -286,6 +321,19 @@ describe('SupervisorAPI', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('V2 endpoints', () => {
|
describe('V2 endpoints', () => {
|
||||||
|
let serviceManagerMock: SinonStub;
|
||||||
|
let imagesMock: SinonStub;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
serviceManagerMock = stub(serviceManager, 'getAll').resolves([]);
|
||||||
|
imagesMock = stub(images, 'getStatus').resolves([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
serviceManagerMock.restore();
|
||||||
|
imagesMock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
describe('GET /v2/device/vpn', () => {
|
describe('GET /v2/device/vpn', () => {
|
||||||
it('returns information about VPN connection', async () => {
|
it('returns information about VPN connection', async () => {
|
||||||
await request
|
await request
|
||||||
@ -359,63 +407,135 @@ describe('SupervisorAPI', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /v2/state/status', () => {
|
describe('GET /v2/state/status', () => {
|
||||||
let serviceManagerMock: SinonStub;
|
it('should return scoped application', async () => {
|
||||||
|
// Create scoped key for application
|
||||||
const mockService = (
|
const appScopedKey = await apiKeys.generateScopedKey(1658654, 640681);
|
||||||
appId: number,
|
// Setup device conditions
|
||||||
serviceId: number,
|
serviceManagerMock.resolves([mockService({ appId: 1658654 })]);
|
||||||
serviceName: string,
|
imagesMock.resolves([mockImage({ appId: 1658654 })]);
|
||||||
) => {
|
// Make request and evaluate response
|
||||||
return {
|
|
||||||
appId,
|
|
||||||
status: 'Running',
|
|
||||||
serviceName,
|
|
||||||
imageId: appId,
|
|
||||||
serviceId,
|
|
||||||
containerId: Math.random()
|
|
||||||
.toString(36)
|
|
||||||
.replace(/[^a-z]+/g, '')
|
|
||||||
.substr(0, 16),
|
|
||||||
createdAt: new Date(),
|
|
||||||
} as Service;
|
|
||||||
};
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
await config.set({ localMode: true });
|
|
||||||
serviceManagerMock = stub(serviceManager, 'getAll').resolves([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await config.set({ localMode: false });
|
|
||||||
serviceManagerMock.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should succeed in LocalMode with a single application', async () => {
|
|
||||||
serviceManagerMock.resolves([mockService(1, 1, 'main')]);
|
|
||||||
|
|
||||||
const { body } = await request
|
|
||||||
.get('/v2/state/status')
|
|
||||||
.set('Accept', 'application/json')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(body).to.have.property('status').which.equals('success');
|
|
||||||
expect(body).to.have.property('appState').which.equals('applied');
|
|
||||||
expect(body)
|
|
||||||
.to.have.property('containers')
|
|
||||||
.which.is.an('array')
|
|
||||||
.with.lengthOf(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should error in LocalMode with multiple applications', async () => {
|
|
||||||
serviceManagerMock.resolves([
|
|
||||||
mockService(1, 1, 'main'),
|
|
||||||
mockService(2, 2, 'extra'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await request
|
await request
|
||||||
.get('/v2/state/status')
|
.get('/v2/state/status')
|
||||||
.set('Accept', 'application/json')
|
.set('Accept', 'application/json')
|
||||||
.expect(405);
|
.set('Authorization', `Bearer ${appScopedKey}`)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(
|
||||||
|
sampleResponses.V2.GET['/state/status?desc=single_application']
|
||||||
|
.statusCode,
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body).to.deep.equal(
|
||||||
|
sampleResponses.V2.GET['/state/status?desc=single_application']
|
||||||
|
.body,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return no application info due to lack of scope', async () => {
|
||||||
|
// Create scoped key for wrong application
|
||||||
|
const appScopedKey = await apiKeys.generateScopedKey(1, 1);
|
||||||
|
// Setup device conditions
|
||||||
|
serviceManagerMock.resolves([mockService({ appId: 1658654 })]);
|
||||||
|
imagesMock.resolves([mockImage({ appId: 1658654 })]);
|
||||||
|
// Make request and evaluate response
|
||||||
|
await request
|
||||||
|
.get('/v2/state/status')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${appScopedKey}`)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(
|
||||||
|
sampleResponses.V2.GET['/state/status?desc=no_applications']
|
||||||
|
.statusCode,
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body).to.deep.equal(
|
||||||
|
sampleResponses.V2.GET['/state/status?desc=no_applications'].body,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return success when device has no applications', async () => {
|
||||||
|
// Create scoped key for any application
|
||||||
|
const appScopedKey = await apiKeys.generateScopedKey(1658654, 1658654);
|
||||||
|
// Setup device conditions
|
||||||
|
serviceManagerMock.resolves([]);
|
||||||
|
imagesMock.resolves([]);
|
||||||
|
// Make request and evaluate response
|
||||||
|
await request
|
||||||
|
.get('/v2/state/status')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${appScopedKey}`)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(
|
||||||
|
sampleResponses.V2.GET['/state/status?desc=no_applications']
|
||||||
|
.statusCode,
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body).to.deep.equal(
|
||||||
|
sampleResponses.V2.GET['/state/status?desc=no_applications'].body,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only return 1 application when N > 1 applications on device', async () => {
|
||||||
|
// Create scoped key for application
|
||||||
|
const appScopedKey = await apiKeys.generateScopedKey(1658654, 640681);
|
||||||
|
// Setup device conditions
|
||||||
|
serviceManagerMock.resolves([
|
||||||
|
mockService({ appId: 1658654 }),
|
||||||
|
mockService({ appId: 222222 }),
|
||||||
|
]);
|
||||||
|
imagesMock.resolves([
|
||||||
|
mockImage({ appId: 1658654 }),
|
||||||
|
mockImage({ appId: 222222 }),
|
||||||
|
]);
|
||||||
|
// Make request and evaluate response
|
||||||
|
await request
|
||||||
|
.get('/v2/state/status')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', `Bearer ${appScopedKey}`)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(
|
||||||
|
sampleResponses.V2.GET['/state/status?desc=single_application']
|
||||||
|
.statusCode,
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body).to.deep.equal(
|
||||||
|
sampleResponses.V2.GET['/state/status?desc=single_application']
|
||||||
|
.body,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only return 1 application when in LOCAL MODE (no auth)', async () => {
|
||||||
|
// Activate localmode
|
||||||
|
await config.set({ localMode: true });
|
||||||
|
// Setup device conditions
|
||||||
|
serviceManagerMock.resolves([
|
||||||
|
mockService({ appId: 1658654 }),
|
||||||
|
mockService({ appId: 222222 }),
|
||||||
|
]);
|
||||||
|
imagesMock.resolves([
|
||||||
|
mockImage({ appId: 1658654 }),
|
||||||
|
mockImage({ appId: 222222 }),
|
||||||
|
]);
|
||||||
|
// Make request and evaluate response
|
||||||
|
await request
|
||||||
|
.get('/v2/state/status')
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(
|
||||||
|
sampleResponses.V2.GET['/state/status?desc=single_application']
|
||||||
|
.statusCode,
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body).to.deep.equal(
|
||||||
|
sampleResponses.V2.GET['/state/status?desc=single_application']
|
||||||
|
.body,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Deactivate localmode
|
||||||
|
await config.set({ localMode: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -77,8 +77,48 @@
|
|||||||
"status": "failed",
|
"status": "failed",
|
||||||
"message": "Invalid application ID: 123invalid"
|
"message": "Invalid application ID: 123invalid"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/state/status?desc=single_application": {
|
||||||
|
"statusCode": 200,
|
||||||
|
"body": {
|
||||||
|
"status": "success",
|
||||||
|
"appState": "applied",
|
||||||
|
"overallDownloadProgress": null,
|
||||||
|
"containers": [
|
||||||
|
{
|
||||||
|
"appId": 1658654,
|
||||||
|
"status": "Running",
|
||||||
|
"serviceName": "main",
|
||||||
|
"imageId": 2885946,
|
||||||
|
"serviceId": 640681,
|
||||||
|
"containerId": "f93d386599d1b36e71272d46ad69770cff333842db04e2e4c64dda7b54da07c6",
|
||||||
|
"createdAt": "2020-11-13T20:29:44.143Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"name": "registry2.balena-cloud.com/v2/e2bf6410ffc30850e96f5071cdd1dca8@sha256:e2e87a8139b8fc14510095b210ad652d7d5badcc64fdc686cbf749d399fba15e",
|
||||||
|
"appId": 1658654,
|
||||||
|
"serviceName": "main",
|
||||||
|
"imageId": 2885946,
|
||||||
|
"dockerImageId": "sha256:4502983d72e2c72bc292effad1b15b49576da3801356f47fd275ba274d409c1a",
|
||||||
|
"status": "Downloaded",
|
||||||
|
"downloadProgress": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/state/status?desc=no_applications": {
|
||||||
|
"statusCode": 200,
|
||||||
|
"body": {
|
||||||
|
"status": "success",
|
||||||
|
"appState": "applied",
|
||||||
|
"overallDownloadProgress": null,
|
||||||
|
"containers": [],
|
||||||
|
"images": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"POST": {}
|
"POST": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user