Skip to content
Merged
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
18 changes: 0 additions & 18 deletions .claude/harness/baselines/handler-no-direct-sdk-import.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,6 @@
"line": 4,
"match": "@aws-sdk/client-sts"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/external-id-audit-handler/index.ts",
"line": 5,
"match": "@aws-sdk/client-cloudwatch"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/external-id-audit-handler/index.ts",
"line": 6,
"match": "@aws-sdk/client-dynamodb"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/external-id-audit-handler/index.ts",
"line": 7,
"match": "@aws-sdk/lib-dynamodb"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/generic-scoring-handler/index.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import {
CloudWatchClient,
type CloudWatchClientConfig,
PutMetricDataCommand,
} from "@aws-sdk/client-cloudwatch";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, ScanCommand } from "@aws-sdk/lib-dynamodb";
import { getEnv } from "../../../helper-functions.js";
import type { CompetitorAccountItem } from "../competitor-accounts-handler/types.js";
import {
composeRepositories,
type Repositories,
type RotationAgeMetricDatum,
} from "./repository.js";

/**
* Phase 3.2 / Issue #603: ExternalId rotation 監査 Lambda。
Expand All @@ -26,27 +24,21 @@ import type { CompetitorAccountItem } from "../competitor-accounts-handler/types
* - 1 metric = 1 (tenantId, awsAccountId) dimension。operator が CloudWatch Alarm で
* "RotationAge > 90 days" を 1 ルールでカバーできる。
*
* Metric namespace / dimension:
* Issue #1237: SDK の Command 構築は `repository.ts` に閉じ込める。本 index.ts は
* 「環境変数 → repository 呼び出し → 結果の構造化ログ」のオーケストレーションに専念
* し、`@aws-sdk/*` を直接 import しない (= `handler-no-direct-sdk-import` 不変条件)。
*
* Metric namespace / dimension (= repository が保証する物理形):
* - Namespace: `TenkaCloud/CompetitorAccounts`
* - MetricName: `RotationAge`
* - Dimensions: `TenantId`, `AwsAccountId`, `Environment`
* - Unit: `None` (= 日数 raw)
*/

const METRIC_NAMESPACE = "TenkaCloud/CompetitorAccounts";
const METRIC_NAME = "RotationAge";

const MS_PER_DAY = 24 * 60 * 60 * 1000;

/**
* PutMetricData は 1 call あたり 1000 datapoint まで。MVP 規模で 1000 を超えることは
* 無いが、防御的に chunk 化する。
*/
const PUT_METRIC_BATCH_SIZE = 1000;

export interface AuditDependencies {
readonly ddb: Pick<DynamoDBDocumentClient, "send">;
readonly cw: Pick<CloudWatchClient, "send">;
readonly repositories: Repositories;
readonly tableName: string;
readonly environmentName: string;
readonly now: () => number;
Expand All @@ -67,63 +59,39 @@ export function computeRotationAgeDays(
return Math.floor(ageMs / MS_PER_DAY);
}

interface AuditDatapoint {
readonly tenantId: string;
readonly awsAccountId: string;
readonly ageDays: number;
}

export async function collectRotationAges(deps: AuditDependencies): Promise<AuditDatapoint[]> {
export async function collectRotationAges(
deps: AuditDependencies,
): Promise<RotationAgeMetricDatum[]> {
const nowMs = deps.now();
const datapoints: AuditDatapoint[] = [];
let exclusiveStartKey: Record<string, unknown> | undefined;
const datapoints: RotationAgeMetricDatum[] = [];
let cursor: Record<string, unknown> | undefined;
do {
const out = await deps.ddb.send(
new ScanCommand({
TableName: deps.tableName,
ProjectionExpression: "tenantId, awsAccountId, rotatedAt, createdAt",
ExclusiveStartKey: exclusiveStartKey,
}),
);
const items = (out.Items ?? []) as Partial<CompetitorAccountItem>[];
for (const item of items) {
const page = await deps.repositories.competitorAccounts.scanPage({
tableName: deps.tableName,
cursor,
});
for (const item of page.items) {
if (typeof item.tenantId !== "string" || typeof item.awsAccountId !== "string") continue;
datapoints.push({
tenantId: item.tenantId,
awsAccountId: item.awsAccountId,
ageDays: computeRotationAgeDays(item, nowMs),
});
}
exclusiveStartKey = out.LastEvaluatedKey as Record<string, unknown> | undefined;
} while (exclusiveStartKey);
cursor = page.nextCursor;
} while (cursor);
return datapoints;
}

export async function emitRotationAgeMetrics(
deps: AuditDependencies,
datapoints: readonly AuditDatapoint[],
datapoints: readonly RotationAgeMetricDatum[],
): Promise<void> {
if (datapoints.length === 0) return;
const timestamp = new Date(deps.now());
for (let i = 0; i < datapoints.length; i += PUT_METRIC_BATCH_SIZE) {
const slice = datapoints.slice(i, i + PUT_METRIC_BATCH_SIZE);
await deps.cw.send(
new PutMetricDataCommand({
Namespace: METRIC_NAMESPACE,
MetricData: slice.map((d) => ({
MetricName: METRIC_NAME,
Value: d.ageDays,
Unit: "None",
Timestamp: timestamp,
Dimensions: [
{ Name: "TenantId", Value: d.tenantId },
{ Name: "AwsAccountId", Value: d.awsAccountId },
{ Name: "Environment", Value: deps.environmentName },
],
})),
}),
);
}
await deps.repositories.rotationAgeMetrics.putRotationAge({
datapoints,
environmentName: deps.environmentName,
timestamp: new Date(deps.now()),
});
}

export async function runAudit(deps: AuditDependencies): Promise<{ readonly count: number }> {
Expand All @@ -132,14 +100,9 @@ export async function runAudit(deps: AuditDependencies): Promise<{ readonly coun
return { count: datapoints.length };
}

// Lambda module-scope client (warm invoke で reuse、cold start 軽減)。
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const cw = new CloudWatchClient({} satisfies CloudWatchClientConfig);

export async function handler(): Promise<void> {
const deps: AuditDependencies = {
ddb,
cw,
repositories: composeRepositories(),
tableName: getEnv("COMPETITOR_ACCOUNTS_TABLE_NAME"),
environmentName: getEnv("DEPLOY_ENVIRONMENT"),
now: () => Date.now(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import {
CloudWatchClient,
type CloudWatchClientConfig,
PutMetricDataCommand,
} from "@aws-sdk/client-cloudwatch";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, ScanCommand } from "@aws-sdk/lib-dynamodb";
import type { CompetitorAccountItem } from "../competitor-accounts-handler/types.js";

/**
* Issue #1237 / SOLID enforcement: SDK adapter for the
* `external-id-audit-handler` Lambda.
*
* The handler `index.ts` must not import `@aws-sdk/*` directly (see
* `.claude/harness/src/rules/handler-no-direct-sdk-import.ts`). This module
* owns:
*
* - Construction of the `CompetitorAccounts` DDB Scan command + paging.
* - Construction of the CloudWatch `PutMetricData` command + dimension
* marshalling.
* - Construction of the module-scope clients used by the warm Lambda invoke
* path (= cold start fan-out is amortised across invocations).
*
* The handler stays free of AWS SDK types: it depends on the
* `CompetitorAccountsRepository` / `RotationAgeMetricsRepository` interfaces
* and on the `Repositories` factory that wires the production clients.
*
* Behaviour parity (= no runtime change vs the previous in-handler
* implementation):
*
* - `ProjectionExpression` keeps `tenantId, awsAccountId, rotatedAt,
* createdAt` so the DDB read footprint is unchanged.
* - `PutMetricData` keeps `Namespace = TenkaCloud/CompetitorAccounts`,
* `MetricName = RotationAge`, `Unit = None`, `Dimensions = TenantId /
* AwsAccountId / Environment` — exactly the values the existing
* CloudWatch alarm + dashboard consume.
* - `PUT_METRIC_BATCH_SIZE = 1000` matches the AWS PutMetricData hard
* limit. MVP scale (~150 accounts) fits in one batch; the loop exists
* as defence in depth for future scale-out.
*/

const METRIC_NAMESPACE = "TenkaCloud/CompetitorAccounts";
const METRIC_NAME = "RotationAge";

/**
* PutMetricData は 1 call あたり 1000 datapoint まで。MVP 規模で 1000 を超えることは
* 無いが、防御的に chunk 化する。
*/
const PUT_METRIC_BATCH_SIZE = 1000;

export interface CompetitorAccountsScanPage {
/** projection-applied subset of CompetitorAccountItem (tenantId / awsAccountId / rotatedAt / createdAt). */
readonly items: readonly Partial<CompetitorAccountItem>[];
/** opaque pagination cursor (DDB `LastEvaluatedKey`); `undefined` once iteration completes. */
readonly nextCursor?: Record<string, unknown>;
}

export interface CompetitorAccountsRepository {
/**
* Scan one page of `CompetitorAccounts`. Caller drives the loop using the
* returned `nextCursor` to stay agnostic of DDB-specific paging shapes.
*/
scanPage(opts: {
readonly tableName: string;
readonly cursor?: Record<string, unknown>;
}): Promise<CompetitorAccountsScanPage>;
}

export interface RotationAgeMetricDatum {
readonly tenantId: string;
readonly awsAccountId: string;
readonly ageDays: number;
}

export interface RotationAgeMetricsRepository {
/**
* Publish a batch of `(tenantId, awsAccountId, ageDays)` datapoints to the
* `TenkaCloud/CompetitorAccounts` namespace. Internally chunks into the
* 1000-datapoint PutMetricData limit so callers can pass arbitrarily large
* arrays.
*/
putRotationAge(opts: {
readonly datapoints: readonly RotationAgeMetricDatum[];
readonly environmentName: string;
readonly timestamp: Date;
}): Promise<void>;
}

/**
* Bundles the two repositories so the handler only depends on a single
* factory. Tests can swap in fakes by passing an alternate `Repositories`
* object to `runAudit` (= the production `composeRepositories()` is one of
* many possible implementations).
*/
export interface Repositories {
readonly competitorAccounts: CompetitorAccountsRepository;
readonly rotationAgeMetrics: RotationAgeMetricsRepository;
}

export function createCompetitorAccountsRepository(
ddb: Pick<DynamoDBDocumentClient, "send">,
): CompetitorAccountsRepository {
return {
async scanPage({ tableName, cursor }) {
const out = await ddb.send(
new ScanCommand({
TableName: tableName,
ProjectionExpression: "tenantId, awsAccountId, rotatedAt, createdAt",
ExclusiveStartKey: cursor,
}),
);
return {
items: (out.Items ?? []) as Partial<CompetitorAccountItem>[],
nextCursor: out.LastEvaluatedKey as Record<string, unknown> | undefined,
};
},
};
}

export function createRotationAgeMetricsRepository(
cw: Pick<CloudWatchClient, "send">,
): RotationAgeMetricsRepository {
return {
async putRotationAge({ datapoints, environmentName, timestamp }) {
if (datapoints.length === 0) return;
for (let i = 0; i < datapoints.length; i += PUT_METRIC_BATCH_SIZE) {
const slice = datapoints.slice(i, i + PUT_METRIC_BATCH_SIZE);
await cw.send(
new PutMetricDataCommand({
Namespace: METRIC_NAMESPACE,
MetricData: slice.map((d) => ({
MetricName: METRIC_NAME,
Value: d.ageDays,
Unit: "None",
Timestamp: timestamp,
Dimensions: [
{ Name: "TenantId", Value: d.tenantId },
{ Name: "AwsAccountId", Value: d.awsAccountId },
{ Name: "Environment", Value: environmentName },
],
})),
}),
);
}
},
};
}

/**
* Module-scope production clients. Lambda warm invokes reuse the same socket
* pool — keep the constructors outside the request path.
*/
let cachedRepositories: Repositories | undefined;

export function composeRepositories(): Repositories {
if (!cachedRepositories) {
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const cw = new CloudWatchClient({} satisfies CloudWatchClientConfig);
cachedRepositories = {
competitorAccounts: createCompetitorAccountsRepository(ddb),
rotationAgeMetrics: createRotationAgeMetricsRepository(cw),
};
}
return cachedRepositories;
}
Loading
Loading