Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Changed
- `Invoker` resolves service account ids from Parameter Store (`accountsIdsByService`) instead of Secrets Manager, with a temporary fallback to the secret when `DEVOPS_ACCOUNT_ID` is absent

### Added
- `ssm:GetParameter` permission to `invokePermissions` for reading the shared accounts-ids parameter

## [6.3.1] - 2026-06-19
### Fixed
Expand Down
87 changes: 87 additions & 0 deletions lib/helpers/accounts-ids-provider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use strict';

const { SSMClient, GetParameterCommand } = require('@aws-sdk/client-ssm');
const { AwsSecretsManager } = require('@janiscommerce/aws-secrets-manager');

const logger = require('lllog')();

const LambdaError = require('../lambda-error');

const isLocalEnv = require('./is-local-env');

const PARAMETER_NAME = 'accountsIdsByService';

module.exports = class AccountsIdsProvider {

/**
* Build the Parameter Store ARN for the shared Devops parameter
* @private
* @returns {string}
*/
static get parameterArn() {
return `arn:aws:ssm:${process.env.AWS_REGION}:${process.env.DEVOPS_ACCOUNT_ID}:parameter/${PARAMETER_NAME}`;
}

/**
* Fetch accounts-ids-by-service from Parameter Store.
* Falls back to Secrets Manager (with a warning) only when DEVOPS_ACCOUNT_ID is absent.
* @throws {LambdaError} If the parameter/secret value is missing or the SSM call fails
*/
static async fetch() {

if(this.accountsIds)
return;

if(isLocalEnv()) {
this.accountsIds = {};
return;
}

if(!process.env.DEVOPS_ACCOUNT_ID) {
logger.warn('DEVOPS_ACCOUNT_ID env var is not set, falling back to Secrets Manager', {
hint: 'Update the service plugin to get the env var injected'
});
await this.fetchFromSecret();
return;
}

await this.fetchFromParameterStore();
}

/**
* @private
*/
static async fetchFromParameterStore() {

const client = new SSMClient();

try {
const { Parameter } = await client.send(new GetParameterCommand({ Name: this.parameterArn }));
this.accountsIds = (Parameter && Parameter.Value && JSON.parse(Parameter.Value)) || false;
} catch(err) {
logger.error('Failed to fetch accountsIdsByService from Parameter Store', { error: err.message });
this.accountsIds = false;
}

if(this.accountsIds === false)
throw new LambdaError('Secret is missing', LambdaError.codes.JANIS_SECRET_MISSING);
}

/**
* @private
*/
static async fetchFromSecret() {

try {
const secretHandler = AwsSecretsManager.secret('AccountsIdsByService');
this.accountsIds = await secretHandler.getValue();
this.accountsIds = this.accountsIds || false;
} catch(err) {
logger.error('Failed to fetch AccountsIdsByService from Secrets Manager', { error: err.message });
this.accountsIds = false;
}

if(this.accountsIds === false)
throw new LambdaError('Secret is missing', LambdaError.codes.JANIS_SECRET_MISSING);
}
};
51 changes: 0 additions & 51 deletions lib/helpers/secret-fetcher.js

This file was deleted.

7 changes: 7 additions & 0 deletions lib/invoke-permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,12 @@ module.exports = [
action: 'Sts:AssumeRole',
resource: 'arn:aws:iam::*:role/LambdaRemoteInvoke'
}
],
[
'iamStatement',
{
action: 'ssm:GetParameter',
resource: 'arn:aws:ssm:*:*:parameter/accountsIdsByService'
}
]
];
6 changes: 3 additions & 3 deletions lib/invoker.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const { ApiSession } = require('@janiscommerce/api-session');

const LambdaError = require('./lambda-error');

const SecretFetcher = require('./helpers/secret-fetcher');
const AccountsIdsProvider = require('./helpers/accounts-ids-provider');

const LambdaInstance = require('./helpers/lambda-instance');
const getLambdaFunctionName = require('./helpers/get-lambda-function-name');
Expand Down Expand Up @@ -291,9 +291,9 @@ module.exports = class Invoker {
if(isLocalEnv())
return;

await SecretFetcher.fetch();
await AccountsIdsProvider.fetch();

const { [serviceCode]: serviceAccountId } = SecretFetcher.secretValue;
const { [serviceCode]: serviceAccountId } = AccountsIdsProvider.accountsIds;

if(!serviceAccountId)
throw new LambdaError(`Service account ID not found for service code ${serviceCode}`, LambdaError.codes.NO_SERVICE_ACCOUNT_ID);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"dependencies": {
"@aws-sdk/client-lambda": "^3.632.0",
"@aws-sdk/client-sfn": "^3.632.0",
"@aws-sdk/client-ssm": "^3.1075.0",
"@aws-sdk/client-sts": "^3.632.0",
"@janiscommerce/api-session": "^3.3.1",
"@janiscommerce/aws-secrets-manager": "^1.1.1",
Expand Down
203 changes: 203 additions & 0 deletions tests/helpers/accounts-ids-provider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
'use strict';

const sinon = require('sinon');
const assert = require('assert');
const { mockClient } = require('aws-sdk-client-mock');
const { SSMClient, GetParameterCommand } = require('@aws-sdk/client-ssm');
const { AwsSecretsManager } = require('@janiscommerce/aws-secrets-manager');
const lllog = require('lllog');

const AccountsIdsProvider = require('../../lib/helpers/accounts-ids-provider');
const LambdaError = require('../../lib/lambda-error');

const loggerProto = Object.getPrototypeOf(lllog());

describe('Libraries', () => {

describe('AccountsIdsProvider', () => {

const fakeAccountIdsByService = {
pricing: '123456789012',
wms: '987654321098'
};

const fakeSecretHandler = () => ({ getValue: sinon.stub() });

let ssmClientMock;

beforeEach(() => {
ssmClientMock = mockClient(SSMClient);
});

afterEach(() => {
sinon.restore();
ssmClientMock.reset();
delete AccountsIdsProvider.accountsIds;
delete process.env.JANIS_ENV;
delete process.env.DEVOPS_ACCOUNT_ID;
delete process.env.AWS_REGION;
});

context('When DEVOPS_ACCOUNT_ID is set (Parameter Store path)', () => {

beforeEach(() => {
process.env.DEVOPS_ACCOUNT_ID = '111122223333';
process.env.AWS_REGION = 'us-east-1';
});

it('Should get accounts-ids from Parameter Store and cache the parsed value', async () => {

ssmClientMock.on(GetParameterCommand).resolves({
Parameter: { Value: JSON.stringify(fakeAccountIdsByService) }
});

await AccountsIdsProvider.fetch();

assert.deepStrictEqual(AccountsIdsProvider.accountsIds, fakeAccountIdsByService);

const calls = ssmClientMock.commandCalls(GetParameterCommand);
assert.strictEqual(calls.length, 1);
assert.deepStrictEqual(calls[0].args[0].input, {
Name: 'arn:aws:ssm:us-east-1:111122223333:parameter/accountsIdsByService'
});
});

it('Should use internal cache and call Parameter Store only once across multiple fetch() calls', async () => {

ssmClientMock.on(GetParameterCommand).resolves({
Parameter: { Value: JSON.stringify(fakeAccountIdsByService) }
});

await AccountsIdsProvider.fetch();
await AccountsIdsProvider.fetch();
await AccountsIdsProvider.fetch();

assert.deepStrictEqual(AccountsIdsProvider.accountsIds, fakeAccountIdsByService);
assert.strictEqual(ssmClientMock.commandCalls(GetParameterCommand).length, 1);
});

it('Should throw JANIS_SECRET_MISSING when Parameter Store returns empty Parameter', async () => {

ssmClientMock.on(GetParameterCommand).resolves({ Parameter: null });

await assert.rejects(AccountsIdsProvider.fetch(), {
name: 'LambdaError',
code: LambdaError.codes.JANIS_SECRET_MISSING
});
});

it('Should throw JANIS_SECRET_MISSING when Parameter Store returns Parameter without Value', async () => {

ssmClientMock.on(GetParameterCommand).resolves({ Parameter: {} });

await assert.rejects(AccountsIdsProvider.fetch(), {
name: 'LambdaError',
code: LambdaError.codes.JANIS_SECRET_MISSING
});
});

it('Should throw JANIS_SECRET_MISSING when GetParameterCommand fails — no fallback to Secrets Manager', async () => {

ssmClientMock.on(GetParameterCommand).rejects(new Error('ParameterNotFound'));

sinon.stub(loggerProto, 'error');
sinon.spy(AwsSecretsManager, 'secret');

await assert.rejects(AccountsIdsProvider.fetch(), {
name: 'LambdaError',
code: LambdaError.codes.JANIS_SECRET_MISSING
});

sinon.assert.notCalled(AwsSecretsManager.secret);
});
});

context('When DEVOPS_ACCOUNT_ID is missing (Secrets Manager fallback)', () => {

it('Should emit a logger.warn and fall back to Secrets Manager', async () => {

const secretHandler = fakeSecretHandler();

const warnStub = sinon.stub(loggerProto, 'warn');

sinon.stub(AwsSecretsManager, 'secret').returns(secretHandler);
secretHandler.getValue.resolves(fakeAccountIdsByService);

await AccountsIdsProvider.fetch();

assert.deepStrictEqual(AccountsIdsProvider.accountsIds, fakeAccountIdsByService);

sinon.assert.calledOnce(warnStub);
sinon.assert.calledOnceWithExactly(AwsSecretsManager.secret, 'AccountsIdsByService');
sinon.assert.calledOnce(secretHandler.getValue);
assert.strictEqual(ssmClientMock.commandCalls(GetParameterCommand).length, 0);
});

it('Should treat an empty DEVOPS_ACCOUNT_ID as missing and fall back to Secrets Manager', async () => {

process.env.DEVOPS_ACCOUNT_ID = '';

const secretHandler = fakeSecretHandler();

const warnStub = sinon.stub(loggerProto, 'warn');

sinon.stub(AwsSecretsManager, 'secret').returns(secretHandler);
secretHandler.getValue.resolves(fakeAccountIdsByService);

await AccountsIdsProvider.fetch();

assert.deepStrictEqual(AccountsIdsProvider.accountsIds, fakeAccountIdsByService);

sinon.assert.calledOnce(warnStub);
sinon.assert.calledOnceWithExactly(AwsSecretsManager.secret, 'AccountsIdsByService');
assert.strictEqual(ssmClientMock.commandCalls(GetParameterCommand).length, 0);
});

it('Should throw JANIS_SECRET_MISSING when Secrets Manager returns empty value', async () => {

const secretHandler = fakeSecretHandler();

sinon.stub(loggerProto, 'warn');
sinon.stub(AwsSecretsManager, 'secret').returns(secretHandler);
secretHandler.getValue.resolves();

await assert.rejects(AccountsIdsProvider.fetch(), {
name: 'LambdaError',
code: LambdaError.codes.JANIS_SECRET_MISSING
});
});

it('Should throw JANIS_SECRET_MISSING when Secrets Manager call fails', async () => {

const secretHandler = fakeSecretHandler();

sinon.stub(loggerProto, 'warn');
sinon.stub(loggerProto, 'error');
sinon.stub(AwsSecretsManager, 'secret').returns(secretHandler);
secretHandler.getValue.rejects();

await assert.rejects(AccountsIdsProvider.fetch(), {
name: 'LambdaError',
code: LambdaError.codes.JANIS_SECRET_MISSING
});
});
});

context('When running in local environment', () => {

it('Should return empty object without calling Parameter Store or Secrets Manager', async () => {

process.env.JANIS_ENV = 'local';

sinon.spy(AwsSecretsManager, 'secret');

await AccountsIdsProvider.fetch();

assert.deepStrictEqual(AccountsIdsProvider.accountsIds, {});

sinon.assert.notCalled(AwsSecretsManager.secret);
assert.strictEqual(ssmClientMock.commandCalls(GetParameterCommand).length, 0);
});
});
});
});
Loading
Loading