Skip to content

Add support for regional secrets #300

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ jobs:
<project-id>/<secret-id>
```

- <a name="region"></a><a href="#user-content-region"><code>region</code></a>: _(Optional)_ Region/location to fetch secrets from specific region. List of supported regions for Secret Manager can be [seen here](https://cloud.google.com/secret-manager/docs/locations).
```yaml
region: us-west1
secrets: |-
output1:my-project/my-secret1
output2:my-project/my-secret2
```

- <a name="min_mask_length"></a><a href="#user-content-min_mask_length"><code>min_mask_length</code></a>: _(Optional, default: `4`)_ Minimum line length for a secret to be masked. Extremely short secrets
(e.g. `{` or `a`) can make GitHub Actions log output unreadable. This is
especially important for multi-line secrets, since each line of the secret
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js

Large diffs are not rendered by default.

16 changes: 13 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ type AccessSecretVersionResponse = {
* @returns Client
*/
export class Client {
readonly defaultEndpoint = 'https://secretmanager.googleapis.com/v1';
// location placeholder for secret reference with location
readonly defaultEndpoint = 'https://secretmanager{location}.googleapis.com/v1';
readonly defaultScope = 'https://www.googleapis.com/auth/cloud-platform';

readonly auth: GoogleAuth;
Expand All @@ -76,14 +77,23 @@ export class Client {
* @param ref String of the full secret reference.
* @returns string secret contents.
*/
async accessSecret(ref: string, encoding: BufferEncoding): Promise<string> {
async accessSecret(ref: string, location: string, encoding: BufferEncoding): Promise<string> {
if (!ref) {
throw new Error(`Secret ref "${ref}" is empty!`);
}
// Updating endpoint with location if available in reference
let location_endpoint = this.endpoint;
if (location) {
// updating endpoint with location from reference (ie.secretmanager.{location}.rep.googleapis.com/v1 )
location_endpoint = this.endpoint.replace(/{location}/g, '.' + location + '.rep');
} else {
// In case of location is not available use global endpoint
location_endpoint = this.endpoint.replace(/{location}/g, '');
}

try {
const token = await this.auth.getAccessToken();
const response = await this.client.get(`${this.endpoint}/${ref}:access`, {
const response = await this.client.get(`${location_endpoint}/${ref}:access`, {
'Authorization': `Bearer ${token}`,
'User-Agent': userAgent,
});
Expand Down
5 changes: 3 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,17 @@ async function run(): Promise<void> {
const minMaskLength = parseInt(getInput('min_mask_length'));
const exportEnvironment = parseBoolean(getInput('export_to_environment'));
const encoding = (getInput('encoding') || 'utf8') as BufferEncoding;
const location = (getInput('region') || '').trim();

// Create an API client.
const client = new Client();

// Parse all the provided secrets into references.
const secretsRefs = parseSecretsRefs(secretsInput);
const secretsRefs = parseSecretsRefs(secretsInput, location);

// Access and export each secret.
for (const ref of secretsRefs) {
const value = await client.accessSecret(ref.selfLink(), encoding);
const value = await client.accessSecret(ref.selfLink(), location, encoding);

// Split multiline secrets by line break and mask each line.
// Read more here: https://github.com/actions/runner/issues/161
Expand Down
13 changes: 10 additions & 3 deletions src/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { parseCSV } from '@google-github-actions/actions-utils';
* output:project/secret/version
*
* @param s String reference to parse
* @param location String location/region of secret
* @returns Reference
*/
export class Reference {
Expand All @@ -32,8 +33,9 @@ export class Reference {
readonly project: string;
readonly name: string;
readonly version: string;
readonly location: string;

constructor(s: string) {
constructor(s: string, location: string) {
const sParts = s.split(':');
if (sParts.length < 2) {
throw new TypeError(`Invalid reference "${s}" - missing destination`);
Expand Down Expand Up @@ -76,6 +78,7 @@ export class Reference {
throw new TypeError(`Invalid reference "${s}" - unknown format`);
}
}
this.location = location;
}

/**
Expand All @@ -84,6 +87,9 @@ export class Reference {
* @returns String self link.
*/
public selfLink(): string {
if (this.location) {
return `projects/${this.project}/locations/${this.location}/secrets/${this.name}/versions/${this.version}`;
}
return `projects/${this.project}/secrets/${this.name}/versions/${this.version}`;
}
}
Expand All @@ -93,15 +99,16 @@ export class Reference {
*
* @param input List of secrets, from the actions input, can be
* comma-delimited or newline, whitespace around secret entires is removed.
* @param location String value of secret location/region
* @returns Array of References for each secret, in the same order they were
* given.
*/
export function parseSecretsRefs(input: string): Reference[] {
export function parseSecretsRefs(input: string, location: string): Reference[] {
const secrets: Reference[] = [];
for (const line of input.split(/\r|\n/)) {
const pieces = parseCSV(line);
for (const piece of pieces) {
secrets.push(new Reference(piece));
secrets.push(new Reference(piece, location));
}
}
return secrets;
Expand Down
144 changes: 119 additions & 25 deletions tests/reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,39 +20,75 @@ import assert from 'node:assert';
import { Reference, parseSecretsRefs } from '../src/reference';

test('Reference', { concurrency: true }, async (suite) => {
await suite.test('parses a full ref', async () => {
const ref = new Reference('out:projects/fruits/secrets/apple/versions/123');
await suite.test('parses a full ref without location', async () => {
const ref = new Reference('out:projects/fruits/secrets/apple/versions/123', '');
const link = ref.selfLink();
assert.deepStrictEqual(link, 'projects/fruits/secrets/apple/versions/123');
});

await suite.test('parses a full ref sans version', async () => {
const ref = new Reference('out:projects/fruits/secrets/apple');
await suite.test('parses a full ref with location', async () => {
const ref = new Reference('out:projects/fruits/secrets/apple/versions/123', 'us-central1');
const link = ref.selfLink();
assert.deepStrictEqual(
link,
'projects/fruits/locations/us-central1/secrets/apple/versions/123',
);
});

await suite.test('parses a full ref sans version without location', async () => {
const ref = new Reference('out:projects/fruits/secrets/apple', '');
const link = ref.selfLink();
assert.deepStrictEqual(link, 'projects/fruits/secrets/apple/versions/latest');
});

await suite.test('parses a short ref', async () => {
const ref = new Reference('out:fruits/apple/123');
await suite.test('parses a full ref sans version with location', async () => {
const ref = new Reference('out:projects/fruits/secrets/apple', 'us-central1');
const link = ref.selfLink();
assert.deepStrictEqual(
link,
'projects/fruits/locations/us-central1/secrets/apple/versions/latest',
);
});

await suite.test('parses a short ref without location', async () => {
const ref = new Reference('out:fruits/apple/123', '');
const link = ref.selfLink();
assert.deepStrictEqual(link, 'projects/fruits/secrets/apple/versions/123');
});

await suite.test('parses a short ref sans version', async () => {
const ref = new Reference('out:fruits/apple');
await suite.test('parses a short ref with location', async () => {
const ref = new Reference('out:fruits/apple/123', 'us-central1');
const link = ref.selfLink();
assert.deepStrictEqual(
link,
'projects/fruits/locations/us-central1/secrets/apple/versions/123',
);
});

await suite.test('parses a short ref sans version without location', async () => {
const ref = new Reference('out:fruits/apple', '');
const link = ref.selfLink();
assert.deepStrictEqual(link, 'projects/fruits/secrets/apple/versions/latest');
});

await suite.test('parses a short ref sans version with location', async () => {
const ref = new Reference('out:fruits/apple', 'us-central1');
const link = ref.selfLink();
assert.deepStrictEqual(
link,
'projects/fruits/locations/us-central1/secrets/apple/versions/latest',
);
});

await suite.test('errors on invalid format', async () => {
await assert.rejects(async () => {
return new Reference('out:projects/fruits/secrets/apple/versions/123/subversions/5');
return new Reference('out:projects/fruits/secrets/apple/versions/123/subversions/5', '');
}, TypeError);
});

await suite.test('errors on missing output', async () => {
await assert.rejects(async () => {
return new Reference('fruits/apple/123');
return new Reference('fruits/apple/123', 'us-central1');
}, TypeError);
});
});
Expand All @@ -62,62 +98,120 @@ test('#parseSecretsRefs', { concurrency: true }, async (suite) => {
{
name: 'empty string',
input: '',
location: '',
expected: [],
},
{
name: 'single value',
name: 'single value without location',
input: 'output:project/secret',
location: '',
expected: [new Reference('output:project/secret', '')],
},
{
name: 'single value with location',
input: 'output:project/secret',
expected: [new Reference('output:project/secret')],
location: 'us-east1',
expected: [new Reference('output:project/secret', 'us-east1')],
},
{
name: 'multi value commas without location',
input: 'output1:project/secret, output2:project/secret',
location: '',
expected: [
new Reference('output1:project/secret', ''),
new Reference('output2:project/secret', ''),
],
},
{
name: 'multi value commas',
name: 'multi value commas with location',
input: 'output1:project/secret, output2:project/secret',
expected: [new Reference('output1:project/secret'), new Reference('output2:project/secret')],
location: 'us-east1',
expected: [
new Reference('output1:project/secret', 'us-east1'),
new Reference('output2:project/secret', 'us-east1'),
],
},
{
name: 'multi value newlines without location',
input: 'output1:project/secret\noutput2:project/secret',
location: '',
expected: [
new Reference('output1:project/secret', ''),
new Reference('output2:project/secret', ''),
],
},
{
name: 'multi value newlines',
name: 'multi value newlines with location',
input: 'output1:project/secret\noutput2:project/secret',
expected: [new Reference('output1:project/secret'), new Reference('output2:project/secret')],
location: 'us-east1',
expected: [
new Reference('output1:project/secret', 'us-east1'),
new Reference('output2:project/secret', 'us-east1'),
],
},
{
name: 'multi value carriage',
input: 'output1:project/secret\routput2:project/secret',
expected: [new Reference('output1:project/secret'), new Reference('output2:project/secret')],
location: '',
expected: [
new Reference('output1:project/secret', ''),
new Reference('output2:project/secret', ''),
],
},
{
name: 'multi value carriage newline',
input: 'output1:project/secret\r\noutput2:project/secret',
expected: [new Reference('output1:project/secret'), new Reference('output2:project/secret')],
location: '',
expected: [
new Reference('output1:project/secret', ''),
new Reference('output2:project/secret', ''),
],
},
{
name: 'multi value empty lines',
input: 'output1:project/secret\n\n\noutput2:project/secret',
expected: [new Reference('output1:project/secret'), new Reference('output2:project/secret')],
location: '',
expected: [
new Reference('output1:project/secret', ''),
new Reference('output2:project/secret', ''),
],
},
{
name: 'multi value commas without location',
input: 'output1:project/secret\noutput2:project/secret,output3:project/secret',
location: '',
expected: [
new Reference('output1:project/secret', ''),
new Reference('output2:project/secret', ''),
new Reference('output3:project/secret', ''),
],
},
{
name: 'multi value commas',
name: 'multi value commas with location',
input: 'output1:project/secret\noutput2:project/secret,output3:project/secret',
location: 'us-east1',
expected: [
new Reference('output1:project/secret'),
new Reference('output2:project/secret'),
new Reference('output3:project/secret'),
new Reference('output1:project/secret', 'us-east1'),
new Reference('output2:project/secret', 'us-east1'),
new Reference('output3:project/secret', 'us-east1'),
],
},
{
name: 'invalid input',
input: 'not/valid',
location: '',
error: 'Invalid reference',
},
];

for await (const tc of cases) {
await suite.test(tc.name, async () => {
if (tc.expected) {
const actual = parseSecretsRefs(tc.input);
const actual = parseSecretsRefs(tc.input, tc.location);
assert.deepStrictEqual(actual, tc.expected);
} else if (tc.error) {
await assert.rejects(async () => {
parseSecretsRefs(tc.input);
parseSecretsRefs(tc.input, tc.location);
}, tc.error);
}
});
Expand Down