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:
Rich Bayliss 2020-11-11 16:26:32 +00:00 committed by Miguel Casqueira
parent 53e7412f75
commit 02aeb4fc1c
4 changed files with 228 additions and 74 deletions

View File

@ -510,25 +510,22 @@ export function createV2Api(router: Router) {
overallDownloadProgress = downloadProgressTotal / downloads;
}
if (_.uniq(appIds).length > 1) {
// We can't accurately return the commit value each app without changing
// the shape of the data, and instead we'd like users to use the new v3
// endpoints, which will come with multiapp
// If we're going to return information about more than one app, error out
return res.status(405).json({
status: 'failed',
message: `Cannot use /v2/ endpoints with a key that is scoped to multiple applications`,
});
}
const commit = await commitStore.getCommitForApp(appIds[0]);
// This endpoint does not support multi-app but the device might be running multiple apps
// We must return information for only 1 application so use the first one in the list
const appId = appIds[0];
// Get the commit for this application
const commit = await commitStore.getCommitForApp(appId);
// Filter containers by this application
const appContainers = containerStates.filter((c) => c.appId === appId);
// Filter images by this application
const appImages = imagesStates.filter((i) => i.appId === appId);
return res.status(200).send({
status: 'success',
appState: pending ? 'applying' : 'applied',
overallDownloadProgress,
containers: containerStates,
images: imagesStates,
containers: appContainers,
images: appImages,
release: commit,
});
});

View File

@ -121,7 +121,7 @@ export const authMiddleware: AuthorizedRequestHandler = async (
req.auth = {
apiKey,
scopes: [],
isScoped: () => false,
isScoped: (resources) => isScoped(resources, req.auth.scopes),
};
try {
@ -134,6 +134,7 @@ export const authMiddleware: AuthorizedRequestHandler = async (
// no need to authenticate, shortcut
if (!needsAuth) {
// Allow requests that do not need auth to be scoped for all applications
req.auth.isScoped = () => true;
return next();
}
@ -146,10 +147,6 @@ export const authMiddleware: AuthorizedRequestHandler = async (
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();
}
}

View File

@ -13,17 +13,52 @@ import * as applicationManager from '../src/compose/application-manager';
import { InstancedAppState } from '../src/types/state';
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 db from '../src/db';
import * as config from '../src/config';
import { Service } from '../src/compose/service';
import { Image } from '../src/compose/images';
const mockedOptions = {
listenPort: 54321,
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', () => {
let api: SupervisorAPI;
let healthCheckStubs: SinonStub[];
@ -286,6 +321,19 @@ describe('SupervisorAPI', () => {
});
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', () => {
it('returns information about VPN connection', async () => {
await request
@ -359,63 +407,135 @@ describe('SupervisorAPI', () => {
});
describe('GET /v2/state/status', () => {
let serviceManagerMock: SinonStub;
const mockService = (
appId: number,
serviceId: number,
serviceName: string,
) => {
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'),
]);
it('should return scoped application', async () => {
// Create scoped key for application
const appScopedKey = await apiKeys.generateScopedKey(1658654, 640681);
// 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')
.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 });
});
});

View File

@ -77,8 +77,48 @@
"status": "failed",
"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": {}
}
}
}