port k8s plugin from aws v2 to v3 client

Signed-off-by: Brian Fletcher <brian@roadie.io>
This commit is contained in:
Brian Fletcher
2023-03-24 14:21:37 +00:00
parent 740771b8ff
commit c75db01052
4 changed files with 143 additions and 199 deletions
+3 -2
View File
@@ -34,6 +34,9 @@
"clean": "backstage-cli package clean"
},
"dependencies": {
"@aws-crypto/sha256-js": "^3.0.0",
"@aws-sdk/credential-providers": "^3.208.0",
"@aws-sdk/signature-v4": "^3.208.0",
"@azure/identity": "^2.0.4",
"@backstage/backend-common": "workspace:^",
"@backstage/catalog-client": "workspace:^",
@@ -49,7 +52,6 @@
"@kubernetes/client-node": "0.18.1",
"@types/express": "^4.17.6",
"@types/luxon": "^3.0.0",
"aws-sdk": "^2.840.0",
"aws4": "^1.11.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -71,7 +73,6 @@
"@backstage/cli": "workspace:^",
"@types/aws4": "^1.5.1",
"@types/http-proxy-middleware": "^0.19.3",
"aws-sdk-mock": "^5.2.1",
"mock-fs": "^5.2.0",
"msw": "^1.0.0",
"supertest": "^6.1.3"
@@ -13,148 +13,111 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import AWS from 'aws-sdk';
import AWSMock from 'aws-sdk-mock';
import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator';
const awsError: AWS.AWSError = {
code: '123',
message: 'no way',
name: 'nope',
time: new Date(),
};
let presign = jest.fn(async () => ({
hostname: 'https://example.com',
query: {},
path: '/asdf',
}));
const fromEnv = jest.fn(() => {
return {
AccessId: 'asdf',
};
});
const fromTemporaryCredentials = jest.fn();
jest.mock('@aws-sdk/signature-v4', () => ({
SignatureV4: jest.fn().mockImplementation(() => ({
presign,
})),
}));
jest.mock('@aws-sdk/credential-providers', () => ({
fromEnv: () => fromEnv(),
fromTemporaryCredentials: (opts: any) => fromTemporaryCredentials(opts),
}));
describe('AwsIamKubernetesAuthTranslator tests', () => {
let role: any = undefined;
const credentials: any = {
accessKeyId: 'bloop',
secretAccessKey: 'omg-so-secret',
sessionToken: 'token',
};
let assumeResponse: any = {
Credentials: {
AccessKeyId: credentials.accessKeyId,
SecretAccessKey: credentials.secretAccessKey,
SessionToken: credentials.sessionToken,
},
};
let mockedCredentials: any = undefined;
AWS.config.credentials = new AWS.Credentials(credentials);
AWSMock.setSDKInstance(AWS);
beforeEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
jest.resetAllMocks();
});
function executeTranslation() {
AWSMock.mock('STS', 'assumeRole', (_params: any, callback: Function) => {
callback(null, assumeResponse);
});
beforeEach(() => {});
it('returns a signed url for AWS credentials without assume role', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator();
if (mockedCredentials) {
jest
.spyOn(authTranslator, 'awsGetCredentials')
.mockImplementation(async () => mockedCredentials);
}
const response = authTranslator.decorateClusterDetailsWithAuth({
assumeRole: role,
const authPromise = authTranslator.decorateClusterDetailsWithAuth({
name: 'test-cluster',
url: '',
authProvider: 'aws',
});
mockedCredentials = undefined;
return response;
}
it('returns a signed url for AWS credentials', async () => {
// These credentials are not real.
// Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html
AWS.config.credentials = new AWS.Credentials(
'AKIAIOSFODNN7EXAMPLE',
'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
expect(fromEnv).toHaveBeenCalledWith();
expect((await authPromise).serviceAccountToken).toEqual(
'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
);
});
const response = await executeTranslation();
expect(response.serviceAccountToken).toBeDefined();
it('returns a signed url for AWS credentials with assume role', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator();
const authPromise = authTranslator.decorateClusterDetailsWithAuth({
assumeRole: 'asdf',
name: 'test-cluster',
url: '',
authProvider: 'aws',
});
expect(fromTemporaryCredentials).toHaveBeenCalledWith({
clientConfig: {
region: 'us-east-1',
},
masterCredentials: fromEnv(),
params: {
ExternalId: undefined,
RoleArn: 'asdf',
},
});
expect((await authPromise).serviceAccountToken).toEqual(
'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
);
});
it('returns a signed url for AWS credentials and passes the external id', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator();
const authPromise = authTranslator.decorateClusterDetailsWithAuth({
assumeRole: 'SomeRole',
externalId: 'external-id',
name: 'test-cluster',
url: '',
authProvider: 'aws',
});
expect(fromTemporaryCredentials).toHaveBeenCalledWith({
clientConfig: {
region: 'us-east-1',
},
masterCredentials: fromEnv(),
params: {
ExternalId: 'external-id',
RoleArn: 'SomeRole',
},
});
expect((await authPromise).serviceAccountToken).toEqual(
'k8s-aws-v1.aHR0cHM6Ly9odHRwczovL2V4YW1wbGUuY29tL2FzZGY_',
);
});
describe('When the credentials is failing', () => {
beforeEach(() => {
jest.spyOn(AWS.config, 'getCredentials').mockImplementation(cb => {
cb(awsError, null);
presign = jest.fn(async () => {
throw new Error('no way');
});
});
it('throws the right error', async () => {
const authTranslator = new AwsIamKubernetesAuthTranslator();
await expect(
authTranslator.decorateClusterDetailsWithAuth({
assumeRole: role,
name: 'test-cluster',
url: '',
authProvider: 'aws',
}),
).rejects.toEqual(awsError);
});
});
describe('When the role is assumed', () => {
// These credentials are not real.
// Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html
AWS.config.credentials = new AWS.Credentials(
'AKIAIOSFODNN7EXAMPLE',
'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
);
role = 'SomeRole';
describe('When the role is valid', () => {
it('returns a signed url for AWS credentials', async () => {
const response = await executeTranslation();
expect(response.serviceAccountToken).toBeDefined();
});
});
describe('When the role is invalid', () => {
it('returns the original AWS credentials', async () => {
assumeResponse = undefined;
await expect(executeTranslation()).rejects.toThrow(
/Unable to assume role:/,
);
});
});
});
describe('When no AWS creds are available', () => {
it('throws unable to get AWS credentials', async () => {
mockedCredentials = new Error();
await expect(executeTranslation()).rejects.toThrow(
'No AWS credentials found.',
);
});
});
describe('When invalid AWS creds are available', () => {
it('throws credentials are invalid to get AWS credentials', async () => {
const undefinedSecret: any = undefined;
AWS.config.credentials = new AWS.Credentials(
'AKIAIOSFODNN7EXAMPLE',
undefinedSecret,
);
await expect(executeTranslation()).rejects.toThrow(
'Invalid AWS credentials found.',
);
).rejects.toThrow('no way');
});
});
});
@@ -13,10 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import AWS, { Credentials } from 'aws-sdk';
import { sign } from 'aws4';
import { AWSClusterDetails } from '../types/types';
import { KubernetesAuthTranslator } from './types';
import {
fromEnv,
fromTemporaryCredentials,
} from '@aws-sdk/credential-providers';
import { SignatureV4 } from '@aws-sdk/signature-v4';
import { Sha256 } from '@aws-crypto/sha256-js';
/**
*
@@ -28,6 +32,8 @@ export type SigningCreds = {
sessionToken: string | undefined;
};
const defaultRegion = 'us-east-1';
/**
*
* @public
@@ -35,90 +41,63 @@ export type SigningCreds = {
export class AwsIamKubernetesAuthTranslator
implements KubernetesAuthTranslator
{
validCredentials(creds: SigningCreds): boolean {
return (creds?.accessKeyId && creds?.secretAccessKey) as unknown as boolean;
}
awsGetCredentials = async (): Promise<Credentials> => {
return new Promise((resolve, reject) => {
AWS.config.getCredentials(err => {
if (err) {
return reject(err);
}
return resolve(AWS.config.credentials as Credentials);
});
});
};
async getCredentials(
assumeRole?: string,
externalId?: string,
): Promise<SigningCreds> {
const awsCreds = await this.awsGetCredentials();
if (!(awsCreds instanceof Credentials))
throw new Error('No AWS credentials found.');
let creds: SigningCreds = {
accessKeyId: awsCreds.accessKeyId,
secretAccessKey: awsCreds.secretAccessKey,
sessionToken: awsCreds.sessionToken,
};
if (!this.validCredentials(creds))
throw new Error('Invalid AWS credentials found.');
if (!assumeRole) return creds;
try {
const params: AWS.STS.Types.AssumeRoleRequest = {
RoleArn: assumeRole,
RoleSessionName: 'backstage-login',
};
if (externalId) params.ExternalId = externalId;
const assumedRole = await new AWS.STS().assumeRole(params).promise();
if (!assumedRole.Credentials) {
throw new Error(`No credentials returned for role ${assumeRole}`);
}
creds = {
accessKeyId: assumedRole.Credentials.AccessKeyId,
secretAccessKey: assumedRole.Credentials.SecretAccessKey,
sessionToken: assumedRole.Credentials.SessionToken,
};
} catch (e) {
console.warn(`There was an error assuming the role: ${e}`);
throw new Error(`Unable to assume role: ${e}`);
}
return creds;
}
async getBearerToken(
private async getBearerToken(
clusterName: string,
assumeRole?: string,
externalId?: string,
): Promise<string> {
const credentials = await this.getCredentials(assumeRole, externalId);
const region = process.env.AWS_REGION ?? defaultRegion;
let credentials = fromEnv();
if (assumeRole) {
credentials = fromTemporaryCredentials({
masterCredentials: credentials,
clientConfig: {
region,
},
params: {
RoleArn: assumeRole,
ExternalId: externalId,
},
});
}
const request = {
host: `sts.amazonaws.com`,
path: `/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Expires=60`,
headers: {
'x-k8s-aws-id': clusterName,
const signer = new SignatureV4({
credentials,
region,
service: 'sts',
sha256: Sha256,
});
const request = await signer.presign(
{
headers: {
host: `sts.${region}.amazonaws.com`,
'x-k8s-aws-id': clusterName,
},
hostname: `sts.${region}.amazonaws.com`,
method: 'GET',
path: '/',
protocol: 'https:',
query: {
Action: 'GetCallerIdentity',
Version: '2011-06-15',
},
},
signQuery: true,
};
{ expiresIn: 0 },
);
const signed = sign(request, credentials);
const url = `https://${signed.host}${signed.path}`;
const base64Url = Buffer.from(url, 'binary').toString('base64');
const urlSafeBase64Url = base64Url
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const query = Object.keys(request?.query ?? {})
.map(
q =>
`${encodeURIComponent(q)}=${encodeURIComponent(
request.query?.[q] as string,
)}`,
)
.join('&');
return `k8s-aws-v1.${urlSafeBase64Url}`;
const url = `https://${request.hostname}${request.path}?${query}`;
return `k8s-aws-v1.${Buffer.from(url).toString('base64url')}`;
}
async decorateClusterDetailsWithAuth(
+4 -3
View File
@@ -1404,7 +1404,7 @@ __metadata:
languageName: node
linkType: hard
"@aws-sdk/signature-v4@npm:3.296.0":
"@aws-sdk/signature-v4@npm:3.296.0, @aws-sdk/signature-v4@npm:^3.208.0":
version: 3.296.0
resolution: "@aws-sdk/signature-v4@npm:3.296.0"
dependencies:
@@ -6881,6 +6881,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "@backstage/plugin-kubernetes-backend@workspace:plugins/kubernetes-backend"
dependencies:
"@aws-crypto/sha256-js": ^3.0.0
"@aws-sdk/credential-providers": ^3.208.0
"@aws-sdk/signature-v4": ^3.208.0
"@azure/identity": ^2.0.4
"@backstage/backend-common": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
@@ -6900,8 +6903,6 @@ __metadata:
"@types/express": ^4.17.6
"@types/http-proxy-middleware": ^0.19.3
"@types/luxon": ^3.0.0
aws-sdk: ^2.840.0
aws-sdk-mock: ^5.2.1
aws4: ^1.11.0
compression: ^1.7.4
cors: ^2.8.5