diff --git a/config.default.json b/config.default.json index 3005024..3038a2b 100644 --- a/config.default.json +++ b/config.default.json @@ -1,9 +1,9 @@ { "API_HOSTNAME": "https://api.snyk.io", "API_VERSION":"2024-02-08~experimental", - "API_VERSION_TENANTS": "2024-04-11~experimental", + "API_VERSION_TENANTS": "2024-10-14~experimental", "APP_INSTALL_API_VERSION": "2024-05-31", "MAX_RETRY": 3, "LOG_LEVEL": "info" -} \ No newline at end of file +} diff --git a/src/api/tenants.ts b/src/api/tenants.ts index 69aeb81..41fc111 100644 --- a/src/api/tenants.ts +++ b/src/api/tenants.ts @@ -47,3 +47,26 @@ export const getAccessibleTenants = async () => { throw new Error(error) } } + +export const getTenantRole = async (tenantId: string) => { + const headers = {...commonHeaders, ...getAuthHeader()} + const apiPath = `rest/tenants/${tenantId}/memberships` + const config = getConfig() + + const url = new URL(`${config.API_HOSTNAME}/${apiPath}`) + url.searchParams.append('role_name', 'admin') + url.searchParams.append('version', config.API_VERSION_TENANTS) + + const req: HttpRequest = { + url: url.toString(), + headers: headers, + method: 'GET', + } + try { + const response = await makeRequest(req) + logger.debug({url: req.url, statusCode: response.statusCode, response: response.body}, 'Response') + return JSON.parse(response.body) as TenantsListingResponse + } catch (error: any) { + throw new Error(error) + } +} diff --git a/src/base-command.ts b/src/base-command.ts index 4a3f93c..b508aee 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -8,7 +8,7 @@ import {createDeployment, DeploymentAttributes, DeploymentResponse, getDeploymen import {ConnectionId, ConnectionSelection, DeploymentId, InstallId, SetupParameters, TenantId} from './types.js' import {getConnectionsForDeployment, createConnectionForDeployment} from './api/connections.js' import {captureConnectionParams} from './command-helpers/connections/parameters-capture.js' -import {getAccessibleTenants} from './api/tenants.js' +import {getAccessibleTenants, getTenantRole} from './api/tenants.js' import {validatedInput, ValidationType} from './utils/input-validation.js' import {validateSnykToken} from './api/snyk.js' @@ -52,6 +52,7 @@ export abstract class BaseCommand extends Command { try { await validateSnykToken(process.env.SNYK_TOKEN) + this.log(`✓ Valid Snyk Token.`) } catch (error) { this.error(`Invalid Snyk Token. ${error}`) } @@ -59,11 +60,11 @@ export abstract class BaseCommand extends Command { const accessibleTenants = await getAccessibleTenants() if (accessibleTenants.data.length === 0) { this.error( - 'Not tenant accessible with your credentials. A Tenant is required for Universal Broker. Personal organizations are not compatible.', + 'No Tenant accessible with your credentials. A Tenant is required for Universal Broker. Personal organizations are not compatible.', ) } else if (accessibleTenants.data.length === 1) { process.env.TENANT_ID = accessibleTenants.data[0].id - this.log(ux.colorize('yellow', `Found single accessible Tenant. Using ${process.env.TENANT_ID}.`)) + this.log(ux.colorize('yellow', `✓ Found single accessible Tenant. Using ${process.env.TENANT_ID}.`)) } else { this.log( ux.colorize( @@ -77,6 +78,16 @@ export abstract class BaseCommand extends Command { process.env.TENANT_ID ?? (await validatedInput({message: 'Enter your tenantID.'}, ValidationType.UUID)) process.env.TENANT_ID = tenantId + try { + await getTenantRole(tenantId) + this.log(`✓ Tenant Admin role confirmed.`) + } catch (error) { + this.debug(error) + this.error( + `This tool requires Tenant Admin role. Please use a Tenant level Admin account or upgrade your account to be Tenant Admin.`, + ) + } + let orgId let installId if (process.env.INSTALL_ID) { diff --git a/test/api/tenants.test.ts b/test/api/tenants.test.ts index 4d75c76..aaa0928 100644 --- a/test/api/tenants.test.ts +++ b/test/api/tenants.test.ts @@ -1,8 +1,9 @@ -import {getAccessibleTenants} from '../../src/api/tenants' +import {getAccessibleTenants, getTenantRole} from '../../src/api/tenants' import {expect} from 'chai' import nock from 'nock' describe('Tenants Api calls', () => { + const tenantId = '00000000-0000-0000-0000-000000000000' const tenants = { data: [ { @@ -12,7 +13,7 @@ describe('Tenants Api calls', () => { slug: 'test-tenant', updated_at: 'date', }, - id: '00000000-0000-0000-0000-000000000000', + id: tenantId, relationships: { owner: { data: { @@ -25,17 +26,72 @@ describe('Tenants Api calls', () => { }, ], } + const tenantRoles = { + data: [ + { + type: 'tenant_membership', + id: '00000000-0000-0000-0000-000000000000', + attributes: { + created_at: '2024-07-23T11:39:10.568336Z', + }, + relationships: { + tenant: { + data: { + type: 'tenant', + id: tenantId, + attributes: { + name: 'Snyk Support', + }, + }, + }, + user: { + data: { + type: 'user', + id: '00000000-0000-0000-0000-000000000000', + attributes: { + name: 'Name', + username: 'email@snyk.io', + email: 'email@snyk.io', + login_method: 'samlp', + account_type: 'user', + active: true, + }, + }, + }, + role: { + data: { + type: 'tenant_role', + id: '00000000-0000-0000-0000-000000000000', + attributes: { + name: 'Tenant Admin', + }, + }, + }, + }, + }, + ], + jsonapi: {}, + } before(() => { process.env.SNYK_TOKEN = 'dummy' nock('https://api.snyk.io') .persist() - .get('/rest/tenants?version=2024-04-11~experimental') + .get('/rest/tenants?version=2024-10-14~experimental') .reply(() => { return [200, tenants] }) + .get(`/rest/tenants/${tenantId}/memberships?role_name=admin&version=2024-10-14~experimental`) + .reply(() => { + return [200, tenantRoles] + }) }) it('getAccessibleTenants', async () => { const accessibleTenants = await getAccessibleTenants() expect(accessibleTenants).to.deep.equal(tenants) }) + + it('getTenantRole', async () => { + const tenantRoles = await getTenantRole(tenantId) + expect(tenantRoles).to.deep.equal(tenantRoles) + }) }) diff --git a/test/test-utils/nock-utils.ts b/test/test-utils/nock-utils.ts index dc8554b..54b9ea9 100644 --- a/test/test-utils/nock-utils.ts +++ b/test/test-utils/nock-utils.ts @@ -11,6 +11,7 @@ export const orgId2 = '3a7c1ab9-8914-4f39-a8c0-5752af653a89' export const orgId3 = '3a7c1ab9-8914-4f39-a8c0-5752af653a8a' export const orgId4 = '3a7c1ab9-8914-4f39-a8c0-5752af653a8b' export const tenantId = '00000000-0000-0000-0000-000000000000' +export const nonAdminTenantId = '00000000-0000-0000-0000-000000000001' export const installId = '00000000-0000-0000-0000-000000000000' export const installId2 = '00000000-0000-0000-0000-000000000002' export const installId3 = '00000000-0000-0000-0000-000000000003' @@ -70,6 +71,70 @@ export const tenants = { ], } +const tenantRoles = { + data: [ + { + type: 'tenant_membership', + id: '00000000-0000-0000-0000-000000000000', + attributes: { + created_at: '2024-07-23T11:39:10.568336Z', + }, + relationships: { + tenant: { + data: { + type: 'tenant', + id: tenantId, + attributes: { + name: 'Snyk Support', + }, + }, + }, + user: { + data: { + type: 'user', + id: '00000000-0000-0000-0000-000000000000', + attributes: { + name: 'Name', + username: 'email@snyk.io', + email: 'email@snyk.io', + login_method: 'samlp', + account_type: 'user', + active: true, + }, + }, + }, + role: { + data: { + type: 'tenant_role', + id: '00000000-0000-0000-0000-000000000000', + attributes: { + name: 'Tenant Admin', + }, + }, + }, + }, + }, + ], + jsonapi: {}, +} + +export const forbiddenTenantMembersResponse = { + jsonapi: { + version: '1.0', + }, + errors: [ + { + status: '403', + detail: 'Forbidden', + id: '00000000-0000-0000-0000-000000000000', + title: 'Forbidden', + meta: { + created: '2024-12-17T13:59:39.147423838Z', + }, + }, + ], +} + export const appResponse = { data: [ { @@ -195,10 +260,18 @@ export const beforeStep = () => { .reply(() => { return [200, appResponse4] }) - .get('/rest/tenants?version=2024-04-11~experimental') + .get('/rest/tenants?version=2024-10-14~experimental') .reply(() => { return [200, tenants] }) + .get(`/rest/tenants/${tenantId}/memberships?role_name=admin&version=2024-10-14~experimental`) + .reply(() => { + return [200, tenantRoles] + }) + .get(`/rest/tenants/${nonAdminTenantId}/memberships?role_name=admin&version=2024-10-14~experimental`) + .reply(() => { + return [403, forbiddenTenantMembersResponse] + }) .get(`${urlPrefixTenantIdAndInstallId}/deployments?version=2024-02-08~experimental`) .reply((uri, body) => { const response = apiResponseSchema diff --git a/test/workflows/deployments/get-failed-due-to-tenant-role.test.ts b/test/workflows/deployments/get-failed-due-to-tenant-role.test.ts new file mode 100644 index 0000000..3015f34 --- /dev/null +++ b/test/workflows/deployments/get-failed-due-to-tenant-role.test.ts @@ -0,0 +1,33 @@ +import {captureOutput} from '@oclif/test' +import {expect} from 'chai' +import {stdin as fstdin} from 'mock-stdin' + +import Deployments from '../../../src/commands/workflows/deployments/get' +import {beforeStep, orgId, snykToken} from '../../test-utils/nock-utils' +import {sendScenario} from '../../test-utils/stdin-utils' + +describe('deployment workflows', () => { + before(beforeStep) + + after(() => { + delete process.env.TENANT_ID + }) + it('runs workflow deployment list', async () => { + process.env.TENANT_ID = '00000000-0000-0000-0000-000000000001' + const stdin = fstdin() + // @ts-ignore + const cfg: Config = {} + const getDeployment = new Deployments([], cfg) + const {stdout, stderr, error} = await captureOutput( + async () => { + sendScenario(stdin, [snykToken, 'n', orgId]) + + return getDeployment.run() + }, + {print: false}, + ) + expect(error?.message).to.contain( + 'This tool requires Tenant Admin role. Please use a Tenant level Admin account or upgrade your account to be Tenant Admin.', + ) + }) +}) diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo new file mode 100644 index 0000000..62248ca --- /dev/null +++ b/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/base-command.ts","./src/index.ts","./src/types.ts","./src/api/apps.ts","./src/api/connections.ts","./src/api/credentials.ts","./src/api/deployments.ts","./src/api/integrations-types.ts","./src/api/integrations-utils.ts","./src/api/integrations.ts","./src/api/snyk.ts","./src/api/tenants.ts","./src/api/types.ts","./src/command-helpers/connections/connections-flags.ts","./src/command-helpers/connections/flags.ts","./src/command-helpers/connections/parameters-capture.ts","./src/command-helpers/connections/type-params-mapping.ts","./src/command-helpers/connections/types.ts","./src/command-helpers/credentials/flags.ts","./src/command-helpers/deployments/flags.ts","./src/commands/connections/create.ts","./src/commands/connections/delete.ts","./src/commands/connections/list.ts","./src/commands/connections/update.ts","./src/commands/credentials/create.ts","./src/commands/credentials/delete.ts","./src/commands/credentials/list.ts","./src/commands/credentials/update.ts","./src/commands/deployments/create.ts","./src/commands/deployments/delete.ts","./src/commands/deployments/list.ts","./src/commands/deployments/update.ts","./src/commands/integrations/create.ts","./src/commands/integrations/delete.ts","./src/commands/integrations/list.ts","./src/commands/introduction/index.ts","./src/commands/workflows/connections/create.ts","./src/commands/workflows/connections/delete.ts","./src/commands/workflows/connections/disconnect.ts","./src/commands/workflows/connections/get.ts","./src/commands/workflows/connections/integrate.ts","./src/commands/workflows/connections/migrate.ts","./src/commands/workflows/credentials/create.ts","./src/commands/workflows/credentials/delete.ts","./src/commands/workflows/credentials/get.ts","./src/commands/workflows/deployments/create.ts","./src/commands/workflows/deployments/delete.ts","./src/commands/workflows/deployments/get.ts","./src/commands/workflows/deployments/update.ts","./src/common/args.ts","./src/common/rest-helpers.ts","./src/config/config.ts","./src/utils/auth.ts","./src/utils/display.ts","./src/utils/http-request.ts","./src/utils/input-validation.ts","./src/utils/logger.ts","./src/utils/utils.ts","./src/utils/validation.ts","./src/workflows/apps.ts"],"version":"5.7.2"} \ No newline at end of file