diff --git a/src/compose/utils.ts b/src/compose/utils.ts index f188f781..625857c0 100644 --- a/src/compose/utils.ts +++ b/src/compose/utils.ts @@ -373,7 +373,7 @@ export async function addFeaturesFromLabels( // create a app/service specific API secret const apiSecret = await apiKeys.generateScopedKey( service.appId, - service.serviceId, + service.serviceName, ); const host = (() => { diff --git a/src/device-api/common.js b/src/device-api/common.js index 870c8440..3f05e76f 100644 --- a/src/device-api/common.js +++ b/src/device-api/common.js @@ -121,13 +121,6 @@ export async function doPurge(appId, force) { }); } -export function serviceAction(action, serviceId, current, target, options) { - if (options == null) { - options = {}; - } - return { action, serviceId, current, target, options }; -} - /** * This doesn't truly return an InstancedDeviceState, but it's close enough to mostly work where it's used * diff --git a/src/lib/api-keys.ts b/src/lib/api-keys.ts index 56ef40ed..b3f5f348 100644 --- a/src/lib/api-keys.ts +++ b/src/lib/api-keys.ts @@ -15,7 +15,7 @@ export class KeyNotFoundError extends Error {} interface DbApiSecret { id: number; appId: number; - serviceId: number; + serviceName: string; scopes: string; key: string; } @@ -199,17 +199,17 @@ export async function getScopesForKey(key: string): Promise { export async function generateScopedKey( appId: number, - serviceId: number, + serviceName: string, options?: Partial, ): Promise { await initialized; - return await generateKey(appId, serviceId, options); + return await generateKey(appId, serviceName, options); } export async function generateCloudKey( force: boolean = false, ): Promise { - cloudApiKey = await generateKey(0, 0, { + cloudApiKey = await generateKey(0, null, { force, scopes: [{ type: 'global' }], }); @@ -223,15 +223,15 @@ export async function refreshKey(key: string): Promise { throw new KeyNotFoundError(); } - const { appId, serviceId, scopes } = apiKey; + const { appId, serviceName, scopes } = apiKey; // if this is a cloud key that is being refreshed - if (appId === 0 && serviceId === 0) { + if (appId === 0 && serviceName === null) { return await generateCloudKey(true); } // generate a new key, expiring the old one... - const newKey = await generateScopedKey(appId, serviceId, { + const newKey = await generateScopedKey(appId, serviceName, { force: true, scopes: deserialiseScopes(scopes), }); @@ -244,15 +244,15 @@ export async function refreshKey(key: string): Promise { * A cached lookup of the database key */ const getApiKeyForService = memoizee( - async (appId: number, serviceId: number): Promise => { + async (appId: number, serviceName: string | null): Promise => { await db.initialized; - return await db.models('apiSecret').where({ appId, serviceId }).select(); + return await db.models('apiSecret').where({ appId, serviceName }).select(); }, { promise: true, maxAge: 60000, // 1 minute - normalizer: ([appId, serviceId]) => `${appId}-${serviceId}`, + normalizer: ([appId, serviceName]) => `${appId}-${serviceName}`, }, ); @@ -276,12 +276,12 @@ const getApiKeyByKey = memoizee( * All key generate logic should come though this method. It handles cache clearing. * * @param appId - * @param serviceId + * @param serviceName * @param options */ async function generateKey( appId: number, - serviceId: number, + serviceName: string | null, options?: Partial, ): Promise { // set default options @@ -292,13 +292,13 @@ async function generateKey( }; // grab the existing API key info - const secrets = await getApiKeyForService(appId, serviceId); + const secrets = await getApiKeyForService(appId, serviceName); // 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(); + await db.models('apiSecret').where({ appId, serviceName }).del(); } // remove the cached lookup for the key @@ -308,10 +308,10 @@ async function generateKey( } // remove the cached value for this lookup - getApiKeyForService.clear(appId, serviceId); + getApiKeyForService.clear(appId, serviceName); // return a new API key - return await createNewKey(appId, serviceId, scopes); + return await createNewKey(appId, serviceName, scopes); } // grab the current secret and scopes @@ -333,21 +333,25 @@ async function generateKey( } // forcibly get a new key... - return await generateKey(appId, serviceId, { ...options, force: true }); + return await generateKey(appId, serviceName, { ...options, force: true }); } /** * Generates a new key value and inserts it into the DB. * * @param appId - * @param serviceId + * @param serviceName * @param scopes */ -async function createNewKey(appId: number, serviceId: number, scopes: Scope[]) { +async function createNewKey( + appId: number, + serviceName: string | null, + scopes: Scope[], +) { const key = generateUniqueKey(); await db.models('apiSecret').insert({ appId, - serviceId, + serviceName, key, scopes: serialiseScopes(scopes), }); diff --git a/src/migrations/M00007.js b/src/migrations/M00007.js new file mode 100644 index 00000000..0a9b0108 --- /dev/null +++ b/src/migrations/M00007.js @@ -0,0 +1,41 @@ +export async function up(knex) { + // Add serviceName to apiSecret schema + await knex.schema.table('apiSecret', (table) => { + table.string('serviceName'); + table.unique(['appId', 'serviceName']); + }); + + const targetServices = (await knex('app').select(['appId', 'services'])) + .map(({ appId, services }) => ({ + appId, + // Extract service name and id per app + services: JSON.parse(services).map(({ serviceId, serviceName }) => ({ + serviceId, + serviceName, + })), + })) + .reduce( + // Convert to array of {appId, serviceId, serviceName} + (apps, { appId, services }) => + apps.concat(services.map((svc) => ({ appId, ...svc }))), + [], + ); + + // Update all API secret entries so services can still access the API after + // the change + await Promise.all( + targetServices.map(({ appId, serviceId, serviceName }) => + knex('apiSecret').update({ serviceName }).where({ appId, serviceId }), + ), + ); + + // Update the table schema deleting the serviceId column + await knex.schema.table('apiSecret', (table) => { + table.dropUnique(['appId', 'serviceId']); + table.dropColumn('serviceId'); + }); +} + +export function down() { + return Promise.reject(new Error('Not Implemented')); +} diff --git a/test/21-supervisor-api.spec.ts b/test/21-supervisor-api.spec.ts index b5f0d085..2c01d3b3 100644 --- a/test/21-supervisor-api.spec.ts +++ b/test/21-supervisor-api.spec.ts @@ -64,7 +64,7 @@ describe('SupervisorAPI', () => { 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); + const appScopedKey = await apiKeys.generateScopedKey(1, 'main'); await request .get('/v2/applications/1/state') @@ -74,7 +74,7 @@ describe('SupervisorAPI', () => { }); it('should generate a key which is scoped for multiple applications', async () => { // multi-app scoped key... - const multiAppScopedKey = await apiKeys.generateScopedKey(1, 2, { + const multiAppScopedKey = await apiKeys.generateScopedKey(1, 'other', { scopes: [1, 2].map((appId) => { return { type: 'app', appId }; }), @@ -135,7 +135,7 @@ describe('SupervisorAPI', () => { }); it('should regenerate a key and invalidate the old one', async () => { // single app scoped key... - const appScopedKey = await apiKeys.generateScopedKey(1, 1); + const appScopedKey = await apiKeys.generateScopedKey(1, 'main'); await request .get('/v2/applications/1/state') diff --git a/test/41-device-api-v1.spec.ts b/test/41-device-api-v1.spec.ts index 3cf77890..c22898d5 100644 --- a/test/41-device-api-v1.spec.ts +++ b/test/41-device-api-v1.spec.ts @@ -1232,7 +1232,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { // resolve to true for any isScoped check const scopedKey = await apiKeys.generateScopedKey( 2, - containers[0].serviceId, + containers[0].serviceName, ); await request diff --git a/test/42-device-api-v2.spec.ts b/test/42-device-api-v2.spec.ts index 3c20083a..7fcba569 100644 --- a/test/42-device-api-v2.spec.ts +++ b/test/42-device-api-v2.spec.ts @@ -142,7 +142,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { describe('Scoped API Keys', () => { it('returns 409 because app is out of scope of the key', async () => { - const apiKey = await apiKeys.generateScopedKey(3, 1); + const apiKey = await apiKeys.generateScopedKey(3, 'main'); await request .get('/v2/applications/2/state') .set('Accept', 'application/json') @@ -164,7 +164,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { it('should return scoped application', async () => { // Create scoped key for application - const appScopedKey = await apiKeys.generateScopedKey(1658654, 640681); + const appScopedKey = await apiKeys.generateScopedKey(1658654, 'main'); // Setup device conditions serviceManagerMock.resolves([mockedAPI.mockService({ appId: 1658654 })]); imagesMock.resolves([mockedAPI.mockImage({ appId: 1658654 })]); @@ -188,7 +188,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { 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); + const appScopedKey = await apiKeys.generateScopedKey(1, 'main'); // Setup device conditions serviceManagerMock.resolves([mockedAPI.mockService({ appId: 1658654 })]); imagesMock.resolves([mockedAPI.mockImage({ appId: 1658654 })]); @@ -211,7 +211,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { it('should return success when device has no applications', async () => { // Create scoped key for any application - const appScopedKey = await apiKeys.generateScopedKey(1658654, 1658654); + const appScopedKey = await apiKeys.generateScopedKey(1658654, 'main'); // Setup device conditions serviceManagerMock.resolves([]); imagesMock.resolves([]); @@ -234,7 +234,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { 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); + const appScopedKey = await apiKeys.generateScopedKey(1658654, 'main'); // Setup device conditions serviceManagerMock.resolves([ mockedAPI.mockService({ appId: 1658654 }), @@ -330,7 +330,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { before(async () => { // Create scoped key for application - appScopedKey = await apiKeys.generateScopedKey(1658654, 640681); + appScopedKey = await apiKeys.generateScopedKey(1658654, 'main'); // Mock target state cache targetStateCacheMock = stub(targetStateCache, 'getTargetApp'); @@ -439,7 +439,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { before(async () => { // Create scoped key for application - appScopedKey = await apiKeys.generateScopedKey(1658654, 640681); + appScopedKey = await apiKeys.generateScopedKey(1658654, 'main'); // Mock target state cache targetStateCacheMock = stub(targetStateCache, 'getTargetApp');