Skip to content
Draft
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
21 changes: 21 additions & 0 deletions lambdas/libs/storage-providers/aws/dynamodb/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

let memoisedClient: DynamoDBClient | undefined;

export function getDynamoDbClient(): DynamoDBClient {
memoisedClient ??= getTracedAWSV3Client(
new DynamoDBClient({
region: process.env.AWS_REGION,
maxAttempts: 10,
// One client serves two tables, so avoid an adaptive rate bucket coupling their throttling behavior.
retryMode: 'standard',
}),
);
return memoisedClient;
}

// Test-only reset for cases that need a fresh AWS SDK client.
export function resetDynamoDbClient(): void {
memoisedClient = undefined;
}
37 changes: 37 additions & 0 deletions lambdas/libs/storage-providers/aws/dynamodb/durable-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { GetItemCommand } from '@aws-sdk/client-dynamodb';

import { getDynamoDbClient } from './client';
import { ID_ATTRIBUTE, SCOPE_ATTRIBUTE, VALUE_ATTRIBUTE } from './keys';

export async function getDurableConfigValue(
tableName: string,
scope: string,
id: string,
description: string,
): Promise<string> {
const result = await getDynamoDbClient().send(
new GetItemCommand({
TableName: tableName,
Key: {
[SCOPE_ATTRIBUTE]: { S: scope },
[ID_ATTRIBUTE]: { S: id },
},
ConsistentRead: true,
ProjectionExpression: '#value',
ExpressionAttributeNames: {
'#value': VALUE_ATTRIBUTE,
},
}),
);

if (!result.Item) {
throw new Error(`${description} item '${scope}/${id}' was not found`);
}

const value = result.Item[VALUE_ATTRIBUTE]?.S;
if (value === undefined) {
throw new Error(`${description} item '${scope}/${id}' does not contain a string value`);
}

return value;
}
13 changes: 13 additions & 0 deletions lambdas/libs/storage-providers/aws/dynamodb/environment.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export {};

declare global {
namespace NodeJS {
interface ProcessEnv {
RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME?: string;
RUNNER_CONFIG_DYNAMODB_ENTRY_ID?: string;
RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME?: string;
RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS?: string;
RUNNER_CONFIG_DYNAMODB_TTL_SECONDS?: string;
}
}
}
29 changes: 29 additions & 0 deletions lambdas/libs/storage-providers/aws/dynamodb/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type DynamoDbEnvironmentVariable =
| 'RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME'
| 'RUNNER_CONFIG_DYNAMODB_ENTRY_ID'
| 'RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TABLE_NAME'
| 'RUNNER_CONFIG_DYNAMODB_RUNNER_STATE_TTL_SECONDS'
| 'RUNNER_CONFIG_DYNAMODB_TTL_SECONDS';

export function requiredEnvironmentValue(name: DynamoDbEnvironmentVariable): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Environment variable ${name} is not set`);
}

return value;
}

export function positiveIntegerEnvironmentValue(name: DynamoDbEnvironmentVariable): number {
const value = requiredEnvironmentValue(name);
if (!/^[1-9]\d*$/.test(value)) {
throw new Error(`Environment variable ${name} must be a positive integer`);
}

const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`Environment variable ${name} must be a positive integer`);
}

return parsed;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { mockClient } from 'aws-sdk-client-mock';
import 'aws-sdk-client-mock-jest/vitest';
import { beforeEach, describe, expect, it } from 'vitest';

import { resetDynamoDbClient } from './client';
import { createAwsDynamoDbGitHubAppCredentialsStore } from './github-app-credentials-store';

const mockDynamoDbClient = mockClient(DynamoDBClient);
const cleanEnv = process.env;

describe('aws_dynamodb GitHub App credentials store', () => {
beforeEach(() => {
mockDynamoDbClient.reset();
resetDynamoDbClient();
process.env = { ...cleanEnv };
process.env.AWS_REGION = 'eu-west-1';
process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = 'runner-configuration';
});

it('strongly reads and decodes an ordered credential array', async () => {
const value = JSON.stringify([
{ appId: 123, privateKeyBase64: Buffer.from('primary\\nkey').toString('base64') },
{
appId: 456,
privateKeyBase64: Buffer.from('additional-key').toString('base64'),
installationId: 789,
},
]);
mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: value } } });
const store = createAwsDynamoDbGitHubAppCredentialsStore();

await expect(store.get()).resolves.toEqual([
{ appId: 123, privateKey: 'primary\nkey', installationId: undefined },
{ appId: 456, privateKey: 'additional-key', installationId: 789 },
]);
expect(mockDynamoDbClient).toHaveReceivedCommandWith(GetItemCommand, {
TableName: 'runner-configuration',
Key: {
scope: { S: 'global#github-app' },
id: { S: 'github-app-credentials' },
},
ConsistentRead: true,
ProjectionExpression: '#value',
ExpressionAttributeNames: { '#value': 'value' },
});
});

it.each([
['not-json', 'contains invalid JSON'],
['[]', 'must contain a non-empty array'],
[JSON.stringify([null]), 'credential at index 0 has an invalid stored value'],
[JSON.stringify([{ appId: 0, privateKeyBase64: 'a2V5' }]), 'credential at index 0 has an invalid stored value'],
[
JSON.stringify([{ appId: 1, privateKeyBase64: 'not-base64' }]),
'credential at index 0 has an invalid stored value',
],
[
JSON.stringify([{ appId: 1, privateKeyBase64: 'a2V5', installationId: 1.5 }]),
'credential at index 0 has an invalid stored value',
],
])('rejects malformed stored credentials without returning their value', async (value, message) => {
mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: value } } });
const store = createAwsDynamoDbGitHubAppCredentialsStore();

await expect(store.get()).rejects.toThrow(message);
});

it('rejects a missing credentials item', async () => {
mockDynamoDbClient.on(GetItemCommand).resolves({});

await expect(createAwsDynamoDbGitHubAppCredentialsStore().get()).rejects.toThrow(
"GitHub App credentials item 'global#github-app/github-app-credentials' was not found",
);
});

it('rejects a non-string credentials value', async () => {
mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { L: [] } } });

await expect(createAwsDynamoDbGitHubAppCredentialsStore().get()).rejects.toThrow(
"GitHub App credentials item 'global#github-app/github-app-credentials' does not contain a string value",
);
});

it.each([undefined, '', ' '])('requires the durable table name for input %j', (tableName) => {
if (tableName === undefined) {
delete process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME;
} else {
process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = tableName;
}

expect(() => createAwsDynamoDbGitHubAppCredentialsStore()).toThrow(
'Environment variable RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME is not set',
);
});

it('propagates reads errors without exposing stored credentials', async () => {
const error = new Error('access denied');
mockDynamoDbClient.on(GetItemCommand).rejects(error);

await expect(createAwsDynamoDbGitHubAppCredentialsStore().get()).rejects.toBe(error);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { GitHubAppCredential, GitHubAppCredentialsStore } from '../../core';
import { getDurableConfigValue } from './durable-config';
import { requiredEnvironmentValue } from './environment';
import { GITHUB_APP_CREDENTIALS_ID, GITHUB_APP_SCOPE } from './keys';

interface StoredGitHubAppCredential {
appId: number;
privateKeyBase64: string;
installationId?: number;
}

export function createAwsDynamoDbGitHubAppCredentialsStore(): GitHubAppCredentialsStore {
return new AwsDynamoDbGitHubAppCredentialsStore(requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME'));
}

class AwsDynamoDbGitHubAppCredentialsStore implements GitHubAppCredentialsStore {
constructor(private readonly tableName: string) {}

async get(): Promise<GitHubAppCredential[]> {
const value = await getDurableConfigValue(
this.tableName,
GITHUB_APP_SCOPE,
GITHUB_APP_CREDENTIALS_ID,
'GitHub App credentials',
);
const credentials = parseCredentials(value);

return credentials.map((credential) => ({
appId: credential.appId,
privateKey: decodePrivateKey(credential.privateKeyBase64),
installationId: credential.installationId,
}));
}
}

function parseCredentials(value: string): StoredGitHubAppCredential[] {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error('GitHub App credentials item contains invalid JSON');
}

if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error('GitHub App credentials item must contain a non-empty array');
}

return parsed.map((credential, index) => parseCredential(credential, index));
}

function parseCredential(value: unknown, index: number): StoredGitHubAppCredential {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw invalidCredential(index);
}

const credential = value as Record<string, unknown>;
if (!isPositiveSafeInteger(credential.appId) || !isValidBase64(credential.privateKeyBase64)) {
throw invalidCredential(index);
}
if (credential.installationId !== undefined && !isPositiveSafeInteger(credential.installationId)) {
throw invalidCredential(index);
}

return {
appId: credential.appId,
privateKeyBase64: credential.privateKeyBase64,
installationId: credential.installationId,
};
}

function isPositiveSafeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}

function isValidBase64(value: unknown): value is string {
if (typeof value !== 'string' || value.length === 0 || value.length % 4 !== 0) {
return false;
}

return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
}

function decodePrivateKey(privateKeyBase64: string): string {
return Buffer.from(privateKeyBase64, 'base64').toString().replace(/\\n/g, '\n');
}

function invalidCredential(index: number): Error {
return new Error(`GitHub App credential at index ${index} has an invalid stored value`);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { mockClient } from 'aws-sdk-client-mock';
import 'aws-sdk-client-mock-jest/vitest';
import { beforeEach, describe, expect, it } from 'vitest';

import { resetDynamoDbClient } from './client';
import { createAwsDynamoDbGitHubWebhookSecretStore } from './github-webhook-secret-store';

const mockDynamoDbClient = mockClient(DynamoDBClient);
const cleanEnv = process.env;

describe('aws_dynamodb GitHub webhook secret store', () => {
beforeEach(() => {
mockDynamoDbClient.reset();
resetDynamoDbClient();
process.env = { ...cleanEnv };
process.env.AWS_REGION = 'eu-west-1';
process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME = 'runner-configuration';
});

it('strongly reads the global webhook secret', async () => {
mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: 'webhook-secret' } } });

await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).resolves.toBe('webhook-secret');
expect(mockDynamoDbClient).toHaveReceivedCommandWith(GetItemCommand, {
TableName: 'runner-configuration',
Key: {
scope: { S: 'global#webhook' },
id: { S: 'github-webhook-secret' },
},
ConsistentRead: true,
ProjectionExpression: '#value',
ExpressionAttributeNames: { '#value': 'value' },
});
});

it('leaves empty-value validation to the webhook config loader', async () => {
mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { S: '' } } });

await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).resolves.toBe('');
});

it('rejects a missing secret item without logging or returning a value', async () => {
mockDynamoDbClient.on(GetItemCommand).resolves({});

await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).rejects.toThrow(
"GitHub webhook secret item 'global#webhook/github-webhook-secret' was not found",
);
});

it('rejects a non-string secret item', async () => {
mockDynamoDbClient.on(GetItemCommand).resolves({ Item: { value: { B: new Uint8Array() } } });

await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).rejects.toThrow(
"GitHub webhook secret item 'global#webhook/github-webhook-secret' does not contain a string value",
);
});

it('requires the durable table name before reading', () => {
delete process.env.RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME;

expect(() => createAwsDynamoDbGitHubWebhookSecretStore()).toThrow(
'Environment variable RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME is not set',
);
expect(mockDynamoDbClient.calls()).toHaveLength(0);
});

it('propagates read errors without handling the secret value', async () => {
const error = new Error('access denied');
mockDynamoDbClient.on(GetItemCommand).rejects(error);

await expect(createAwsDynamoDbGitHubWebhookSecretStore().get()).rejects.toBe(error);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { GitHubWebhookSecretStore } from '../../core';
import { getDurableConfigValue } from './durable-config';
import { requiredEnvironmentValue } from './environment';
import { GITHUB_WEBHOOK_SCOPE, GITHUB_WEBHOOK_SECRET_ID } from './keys';

export function createAwsDynamoDbGitHubWebhookSecretStore(): GitHubWebhookSecretStore {
return new AwsDynamoDbGitHubWebhookSecretStore(requiredEnvironmentValue('RUNNER_CONFIG_DYNAMODB_CONFIG_TABLE_NAME'));
}

class AwsDynamoDbGitHubWebhookSecretStore implements GitHubWebhookSecretStore {
constructor(private readonly tableName: string) {}

async get(): Promise<string> {
return await getDurableConfigValue(
this.tableName,
GITHUB_WEBHOOK_SCOPE,
GITHUB_WEBHOOK_SECRET_ID,
'GitHub webhook secret',
);
}
}
Loading
Loading