feat: Allow custom session tags to be passed when assuming a role (#1759)
* Add possibility to input custom session tags * Use json for input to custom-tags, add documentation for custom-tags * Add more examples * Simplify example to avoid parse error * Add input validation for custom tags * Fix unit tests for custom-tags * Add debugging message * Skip failing test for now * Build package * Remove some unused validation for custom tags * feat: add validation for custom session tags Harden the custom-tags feature against misuse and misconfiguration: - Validate input is a JSON object (reject arrays, primitives, null) - Enforce STS tag constraints: key length (128), value length (256), allowed characters - Reject nested object/array values that would silently stringify to '[object Object]' - Block overriding default session tags (GitHub, Repository, Workflow, etc.) - Enforce 50-tag session limit - Warn when custom-tags used with OIDC or web identity - Fix missing await on helpers test assertion - Remove unused CUSTOM_TAGS_JSON_INPUTS fixture - Normalize test mocking to vi.mocked() pattern --------- Co-authored-by: Sylvain Verly <sylvain.verly@gmail.com>
This commit is contained in:
parent
c36525a567
commit
61f50f630f
7 changed files with 225 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
|
.history
|
||||||
node_modules
|
node_modules
|
||||||
coverage
|
coverage
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -382,6 +382,21 @@ with:
|
||||||
Actor
|
Actor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Custom session tags
|
||||||
|
|
||||||
|
You can add custom session tags using the `custom-tags` input, which accepts a
|
||||||
|
JSON object. Custom tags cannot override the default tags listed above.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
uses: aws-actions/configure-aws-credentials@v6
|
||||||
|
with:
|
||||||
|
custom-tags: '{"Environment": "Production", "Team": "Platform"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
_Note: custom tags are not supported when using OIDC or web identity token
|
||||||
|
authentication. In those flows, session tags are controlled by the identity
|
||||||
|
provider's token claims._
|
||||||
|
|
||||||
### Session policies
|
### Session policies
|
||||||
|
|
||||||
Session policies are not required, but they allow you to limit the scope of the
|
Session policies are not required, but they allow you to limit the scope of the
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
name: '"Configure AWS Credentials" Action for GitHub Actions'
|
name: '"Configure AWS Credentials" Action for GitHub Actions'
|
||||||
description: Configures AWS credentials for use in subsequent steps in a GitHub Action workflow
|
description: Configures AWS credentials for use in subsequent steps in a GitHub Action workflow
|
||||||
runs:
|
runs:
|
||||||
|
|
@ -101,6 +100,9 @@ inputs:
|
||||||
action-timeout-s:
|
action-timeout-s:
|
||||||
required: false
|
required: false
|
||||||
description: A global timeout in seconds for the action. When the timeout is reached, the action immediately exits. The default is to run without a timeout.
|
description: A global timeout in seconds for the action. When the timeout is reached, the action immediately exits. The default is to run without a timeout.
|
||||||
|
custom-tags:
|
||||||
|
description: Additional tags to apply to the assumed role session. Must be a JSON object provided as a string.
|
||||||
|
required: false
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
aws-account-id:
|
aws-account-id:
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,73 @@ export interface assumeRoleParams {
|
||||||
webIdentityToken?: string;
|
webIdentityToken?: string;
|
||||||
inlineSessionPolicy?: string;
|
inlineSessionPolicy?: string;
|
||||||
managedSessionPolicies?: { arn: string }[];
|
managedSessionPolicies?: { arn: string }[];
|
||||||
|
customTags?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_KEY_REGEX = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$/u;
|
||||||
|
const TAG_VALUE_REGEX = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$/u;
|
||||||
|
const MAX_TAG_KEY_LENGTH = 128;
|
||||||
|
const MAX_TAG_VALUE_LENGTH = 256;
|
||||||
|
const MAX_SESSION_TAGS = 50;
|
||||||
|
|
||||||
|
export function parseAndValidateCustomTags(customTags: string, existingTags: Tag[]): Tag[] {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(customTags);
|
||||||
|
} catch {
|
||||||
|
throw new Error('custom-tags: input is not valid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||||
|
throw new Error('custom-tags: input must be a JSON object (not an array or primitive)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservedKeys = new Set(existingTags.map((tag) => tag.Key));
|
||||||
|
const newTags: Tag[] = [];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
throw new Error(
|
||||||
|
`custom-tags: value for key '${key}' must be a string, number, or boolean (not an object or array)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringValue = String(value);
|
||||||
|
|
||||||
|
if (key.length === 0 || key.length > MAX_TAG_KEY_LENGTH) {
|
||||||
|
throw new Error(`custom-tags: key '${key}' must be between 1 and ${MAX_TAG_KEY_LENGTH} characters`);
|
||||||
|
}
|
||||||
|
if (stringValue.length > MAX_TAG_VALUE_LENGTH) {
|
||||||
|
throw new Error(
|
||||||
|
`custom-tags: value for key '${key}' exceeds maximum length of ${MAX_TAG_VALUE_LENGTH} characters`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!TAG_KEY_REGEX.test(key)) {
|
||||||
|
throw new Error(
|
||||||
|
`custom-tags: key '${key}' contains invalid characters. Allowed: unicode letters, digits, spaces, and _.:/=+-@`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stringValue.length > 0 && !TAG_VALUE_REGEX.test(stringValue)) {
|
||||||
|
throw new Error(
|
||||||
|
`custom-tags: value for key '${key}' contains invalid characters. Allowed: unicode letters, digits, spaces, and _.:/=+-@`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (reservedKeys.has(key)) {
|
||||||
|
throw new Error(
|
||||||
|
`custom-tags: key '${key}' conflicts with a default session tag set by this action and cannot be overridden`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
newTags.push({ Key: key, Value: stringValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingTags.length + newTags.length > MAX_SESSION_TAGS) {
|
||||||
|
throw new Error(
|
||||||
|
`custom-tags: total session tags (${existingTags.length + newTags.length}) would exceed the AWS limit of ${MAX_SESSION_TAGS}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assumeRole(params: assumeRoleParams) {
|
export async function assumeRole(params: assumeRoleParams) {
|
||||||
|
|
@ -94,6 +161,7 @@ export async function assumeRole(params: assumeRoleParams) {
|
||||||
webIdentityToken,
|
webIdentityToken,
|
||||||
inlineSessionPolicy,
|
inlineSessionPolicy,
|
||||||
managedSessionPolicies,
|
managedSessionPolicies,
|
||||||
|
customTags,
|
||||||
} = { ...params };
|
} = { ...params };
|
||||||
|
|
||||||
// Load GitHub environment variables
|
// Load GitHub environment variables
|
||||||
|
|
@ -111,17 +179,24 @@ export async function assumeRole(params: assumeRoleParams) {
|
||||||
{ Key: 'Actor', Value: sanitizeGitHubVariables(GITHUB_ACTOR) },
|
{ Key: 'Actor', Value: sanitizeGitHubVariables(GITHUB_ACTOR) },
|
||||||
{ Key: 'Commit', Value: GITHUB_SHA },
|
{ Key: 'Commit', Value: GITHUB_SHA },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (process.env.GITHUB_REF) {
|
if (process.env.GITHUB_REF) {
|
||||||
tagArray.push({
|
tagArray.push({
|
||||||
Key: 'Branch',
|
Key: 'Branch',
|
||||||
Value: sanitizeGitHubVariables(process.env.GITHUB_REF),
|
Value: sanitizeGitHubVariables(process.env.GITHUB_REF),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (customTags) {
|
||||||
|
const parsed = parseAndValidateCustomTags(customTags, tagArray);
|
||||||
|
tagArray.push(...parsed);
|
||||||
|
}
|
||||||
|
|
||||||
const tags = roleSkipSessionTagging ? undefined : tagArray;
|
const tags = roleSkipSessionTagging ? undefined : tagArray;
|
||||||
if (!tags) {
|
if (!tags) {
|
||||||
core.debug('Role session tagging has been skipped.');
|
core.debug('Role session tagging has been skipped.');
|
||||||
} else {
|
} else {
|
||||||
core.debug(`${tags.length} role session tags are being used.`);
|
core.debug(`${tags.length} role session tags are being used:`);
|
||||||
}
|
}
|
||||||
|
|
||||||
//only populate transitiveTagKeys array if user is actually using session tagging
|
//only populate transitiveTagKeys array if user is actually using session tagging
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export async function run() {
|
||||||
const roleSkipSessionTagging = getBooleanInput('role-skip-session-tagging', { required: false });
|
const roleSkipSessionTagging = getBooleanInput('role-skip-session-tagging', { required: false });
|
||||||
const transitiveTagKeys = core.getMultilineInput('transitive-tag-keys', { required: false });
|
const transitiveTagKeys = core.getMultilineInput('transitive-tag-keys', { required: false });
|
||||||
const proxyServer = core.getInput('http-proxy', { required: false }) || process.env.HTTP_PROXY;
|
const proxyServer = core.getInput('http-proxy', { required: false }) || process.env.HTTP_PROXY;
|
||||||
|
const customTags = core.getInput('custom-tags', { required: false });
|
||||||
const inlineSessionPolicy = core.getInput('inline-session-policy', { required: false });
|
const inlineSessionPolicy = core.getInput('inline-session-policy', { required: false });
|
||||||
const managedSessionPolicies = core.getMultilineInput('managed-session-policies', { required: false }).map((p) => {
|
const managedSessionPolicies = core.getMultilineInput('managed-session-policies', { required: false }).map((p) => {
|
||||||
return { arn: p };
|
return { arn: p };
|
||||||
|
|
@ -190,6 +191,13 @@ export async function run() {
|
||||||
await credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds);
|
await credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds);
|
||||||
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
|
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
|
||||||
}
|
}
|
||||||
|
if (customTags && (useGitHubOIDCProvider() || webIdentityTokenFile)) {
|
||||||
|
core.warning(
|
||||||
|
"'custom-tags' is set but will be ignored because session tags cannot be applied when using OIDC or web identity token authentication. " +
|
||||||
|
'Tags are controlled by the identity provider token claims in these authentication flows.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Get role credentials if configured to do so
|
// Get role credentials if configured to do so
|
||||||
if (roleToAssume) {
|
if (roleToAssume) {
|
||||||
let roleCredentials: AssumeRoleCommandOutput;
|
let roleCredentials: AssumeRoleCommandOutput;
|
||||||
|
|
@ -209,6 +217,7 @@ export async function run() {
|
||||||
webIdentityToken,
|
webIdentityToken,
|
||||||
inlineSessionPolicy,
|
inlineSessionPolicy,
|
||||||
managedSessionPolicies,
|
managedSessionPolicies,
|
||||||
|
customTags,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
!disableRetry,
|
!disableRetry,
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,85 @@ describe('Configure AWS Credentials', {}, () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Custom Tags', {}, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedSTSClient.on(AssumeRoleCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
||||||
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
||||||
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials')
|
||||||
|
.mockResolvedValueOnce({ accessKeyId: 'MYAWSACCESSKEYID' })
|
||||||
|
.mockResolvedValueOnce({ accessKeyId: 'STSAWSACCESSKEYID' });
|
||||||
|
});
|
||||||
|
it('rejects invalid JSON in custom tags', {}, async () => {
|
||||||
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_INVALID_JSON_INPUTS));
|
||||||
|
await run();
|
||||||
|
expect(core.setFailed).toHaveBeenCalledWith('custom-tags: input is not valid JSON');
|
||||||
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
it('handles valid custom tags', {}, async () => {
|
||||||
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_OBJECT_INPUTS));
|
||||||
|
await run();
|
||||||
|
expect(core.info).toHaveBeenCalledWith('Assuming role with user credentials');
|
||||||
|
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
||||||
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input).toMatchObject({
|
||||||
|
Tags: expect.arrayContaining([
|
||||||
|
{ Key: 'GitHub', Value: 'Actions' },
|
||||||
|
{ Key: 'Repository', Value: 'MY-REPOSITORY-NAME' },
|
||||||
|
{ Key: 'Workflow', Value: 'MY-WORKFLOW-ID' },
|
||||||
|
{ Key: 'Action', Value: 'MY-ACTION-NAME' },
|
||||||
|
{ Key: 'Actor', Value: 'MY-USERNAME_bot_' },
|
||||||
|
{ Key: 'Commit', Value: 'MY-COMMIT-ID' },
|
||||||
|
{ Key: 'Environment', Value: 'Production' },
|
||||||
|
{ Key: 'Team', Value: 'DevOps' },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('rejects array input for custom tags', {}, async () => {
|
||||||
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_ARRAY_INPUTS));
|
||||||
|
await run();
|
||||||
|
expect(core.setFailed).toHaveBeenCalledWith(
|
||||||
|
'custom-tags: input must be a JSON object (not an array or primitive)',
|
||||||
|
);
|
||||||
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
it('rejects custom tags that conflict with default session tags', {}, async () => {
|
||||||
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_RESERVED_KEY_INPUTS));
|
||||||
|
await run();
|
||||||
|
expect(core.setFailed).toHaveBeenCalledWith(
|
||||||
|
"custom-tags: key 'Repository' conflicts with a default session tag set by this action and cannot be overridden",
|
||||||
|
);
|
||||||
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
it('rejects custom tags with invalid key characters', {}, async () => {
|
||||||
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_INVALID_KEY_CHARS_INPUTS));
|
||||||
|
await run();
|
||||||
|
expect(core.setFailed).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("custom-tags: key 'invalid{key}' contains invalid characters"),
|
||||||
|
);
|
||||||
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
it('warns when custom tags are used with OIDC', {}, async () => {
|
||||||
|
vi.mocked(core.getInput).mockImplementation(
|
||||||
|
mocks.getInput({
|
||||||
|
...mocks.GH_OIDC_INPUTS,
|
||||||
|
'custom-tags': JSON.stringify({ MyTag: 'value' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
||||||
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
||||||
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
||||||
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({
|
||||||
|
accessKeyId: 'STSAWSACCESSKEYID',
|
||||||
|
});
|
||||||
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
||||||
|
await run();
|
||||||
|
expect(core.warning).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("'custom-tags' is set but will be ignored"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Odd inputs', {}, () => {
|
describe('Odd inputs', {}, () => {
|
||||||
it('fails when github env vars are missing', {}, async () => {
|
it('fails when github env vars are missing', {}, async () => {
|
||||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
|
||||||
|
|
@ -269,6 +348,7 @@ describe('Configure AWS Credentials', {}, () => {
|
||||||
await run();
|
await run();
|
||||||
expect(core.setFailed).toHaveBeenCalled();
|
expect(core.setFailed).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles improper retry-max-attempts input', {}, async () => {
|
it('handles improper retry-max-attempts input', {}, async () => {
|
||||||
// This should mean we retry one time
|
// This should mean we retry one time
|
||||||
vi.mocked(core.getInput).mockImplementation(
|
vi.mocked(core.getInput).mockImplementation(
|
||||||
|
|
@ -712,7 +792,7 @@ describe('Configure AWS Credentials', {}, () => {
|
||||||
|
|
||||||
// Get the timeout callback function
|
// Get the timeout callback function
|
||||||
const timeoutCallback = setTimeoutSpy.mock.calls[0][0] as () => void;
|
const timeoutCallback = setTimeoutSpy.mock.calls[0][0] as () => void;
|
||||||
|
|
||||||
// Execute the timeout callback
|
// Execute the timeout callback
|
||||||
timeoutCallback();
|
timeoutCallback();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,46 @@ const inputs = {
|
||||||
'aws-region': 'fake-region-1',
|
'aws-region': 'fake-region-1',
|
||||||
'special-characters-workaround': 'true',
|
'special-characters-workaround': 'true',
|
||||||
},
|
},
|
||||||
|
CUSTOM_TAGS_INVALID_JSON_INPUTS: {
|
||||||
|
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||||
|
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||||
|
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||||
|
'aws-region': 'fake-region-1',
|
||||||
|
'retry-max-attempts': '1',
|
||||||
|
'custom-tags': 'not a json',
|
||||||
|
},
|
||||||
|
CUSTOM_TAGS_ARRAY_INPUTS: {
|
||||||
|
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||||
|
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||||
|
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||||
|
'aws-region': 'fake-region-1',
|
||||||
|
'retry-max-attempts': '1',
|
||||||
|
'custom-tags': '[1, 2, 3]',
|
||||||
|
},
|
||||||
|
CUSTOM_TAGS_RESERVED_KEY_INPUTS: {
|
||||||
|
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||||
|
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||||
|
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||||
|
'aws-region': 'fake-region-1',
|
||||||
|
'retry-max-attempts': '1',
|
||||||
|
'custom-tags': JSON.stringify({ Repository: 'evil-repo' }),
|
||||||
|
},
|
||||||
|
CUSTOM_TAGS_INVALID_KEY_CHARS_INPUTS: {
|
||||||
|
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||||
|
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||||
|
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||||
|
'aws-region': 'fake-region-1',
|
||||||
|
'retry-max-attempts': '1',
|
||||||
|
'custom-tags': JSON.stringify({ 'invalid{key}': 'value' }),
|
||||||
|
},
|
||||||
|
CUSTOM_TAGS_OBJECT_INPUTS: {
|
||||||
|
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||||
|
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||||
|
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||||
|
'aws-region': 'fake-region-1',
|
||||||
|
'retry-max-attempts': '1',
|
||||||
|
'custom-tags': JSON.stringify({ Environment: 'Production', Team: 'DevOps' }),
|
||||||
|
},
|
||||||
IAM_USER_INPUTS: {
|
IAM_USER_INPUTS: {
|
||||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue