import * as iam from "@distilled.cloud/aws/iam"; import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import * as Stream from "effect/Stream"; import { isResolved } from "../../Diff.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import type { Providers } from "../Providers.ts"; import { toRedactedString } from "./common.ts"; export interface AccessKeyProps { /** * User that owns the access key. */ userName: string; /** * Desired access key status. * @default "Active" */ status?: iam.StatusType; } export interface AccessKey extends Resource< "AWS.IAM.AccessKey", AccessKeyProps, { /** The IAM user the access key belongs to. */ userName: string; /** The access key ID. */ accessKeyId: string; /** Whether the key is `Active` or `Inactive`. */ status: iam.StatusType; /** When the access key was created. */ createDate: Date | undefined; /** The secret access key. AWS only returns it at creation; later reads preserve the originally stored redacted value. */ secretAccessKey: Redacted.Redacted | undefined; /** When the access key was last used, if ever. */ lastUsedDate: Date | undefined; /** The AWS service the key last authenticated to. */ lastUsedServiceName: string | undefined; /** The region of the key's last use. */ lastUsedRegion: string | undefined; }, never, Providers > {} /** * An IAM access key for a user. * * `AccessKey` manages long-lived programmatic credentials for an IAM user. The * secret access key is only returned during creation, so later reads preserve * the originally stored redacted value instead of pretending AWS can return it again. * ### Managing Programmatic Credentials * **Example:** Create an Access Key * ```typescript * const user = yield* User("DeployUser", { * userName: "deploy-user", * }); * * const key = yield* AccessKey("DeployUserKey", { * userName: user.userName, * status: "Active", * }); * ``` * * @resource */ export const AccessKey = Resource("AWS.IAM.AccessKey"); export const AccessKeyProvider = () => Provider.succeed(AccessKey, { stables: ["accessKeyId"], diff: Effect.fn(function* ({ olds, news }) { if (!isResolved(news)) return; if (olds.userName !== news.userName) { return { action: "replace" } as const; } }), read: Effect.fn(function* ({ output }) { if (!output) { return undefined; } const listed = yield* iam.listAccessKeys({ UserName: output.userName, }); const metadata = listed.AccessKeyMetadata.find( (entry) => entry.AccessKeyId === output.accessKeyId, ); if (!metadata?.AccessKeyId) { return undefined; } const lastUsed = yield* iam.getAccessKeyLastUsed({ AccessKeyId: output.accessKeyId, }); return { userName: metadata.UserName ?? output.userName, accessKeyId: metadata.AccessKeyId, status: metadata.Status ?? output.status, createDate: metadata.CreateDate, secretAccessKey: output.secretAccessKey, lastUsedDate: lastUsed?.AccessKeyLastUsed?.LastUsedDate, lastUsedServiceName: lastUsed?.AccessKeyLastUsed?.ServiceName, lastUsedRegion: lastUsed?.AccessKeyLastUsed?.Region, }; }), reconcile: Effect.fn(function* ({ news, output, session }) { // Observe — `accessKeyId` is generated by AWS, so we can only locate // an existing key when we already have its id from a prior output. // Without it (first reconciliation), we go straight to create. const existing = output ? yield* iam.listAccessKeys({ UserName: output.userName }).pipe( Effect.map((listed) => listed.AccessKeyMetadata.find( (entry) => entry.AccessKeyId === output.accessKeyId, ), ), Effect.catchTag("NoSuchEntityException", () => Effect.succeed(undefined), ), ) : undefined; // Ensure — create the access key if it does not exist on the cloud. // The secret is only returned at creation time, so on adoption the // best we can do is preserve any redacted value already stored. let accessKeyId = existing?.AccessKeyId ?? output?.accessKeyId; let secretAccessKey = output?.secretAccessKey; let createDate: Date | undefined = existing?.CreateDate ?? output?.createDate; if (!existing) { const created = yield* iam.createAccessKey({ UserName: news.userName, }); accessKeyId = created.AccessKey.AccessKeyId; secretAccessKey = toRedactedString(created.AccessKey.SecretAccessKey); createDate = created.AccessKey.CreateDate; } if (!accessKeyId) { return yield* Effect.fail( new Error(`AccessKey for user '${news.userName}' has no id`), ); } // Sync — apply the desired status when it differs from the observed // value. Default of `Active` matches AWS's behaviour. const observedStatus = existing?.Status ?? output?.status ?? "Active"; const desiredStatus = news.status ?? observedStatus; if (desiredStatus !== observedStatus) { yield* iam.updateAccessKey({ UserName: news.userName, AccessKeyId: accessKeyId, Status: desiredStatus, }); } const lastUsed = yield* iam.getAccessKeyLastUsed({ AccessKeyId: accessKeyId, }); yield* session.note(accessKeyId); return { userName: existing?.UserName ?? news.userName, accessKeyId, status: desiredStatus, createDate, secretAccessKey, lastUsedDate: lastUsed?.AccessKeyLastUsed?.LastUsedDate, lastUsedServiceName: lastUsed?.AccessKeyLastUsed?.ServiceName, lastUsedRegion: lastUsed?.AccessKeyLastUsed?.Region, }; }), delete: Effect.fn(function* ({ output }) { yield* iam .deleteAccessKey({ UserName: output.userName, AccessKeyId: output.accessKeyId, }) .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void)); }), // IAM is a global service. `listAccessKeys` requires a `UserName`, so we // enumerate every IAM user first (paginated) and then list the keys for // each user (also paginated) with bounded concurrency. The secret access // key and last-used details are not part of the list metadata — the secret // is only returned at creation and cannot be re-read — so those fields are // left undefined here. list: Effect.fn(function* () { const users = yield* iam.listUsers.pages({}).pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.Users)), ); const perUser = yield* Effect.forEach( users, (user) => iam.listAccessKeys.pages({ UserName: user.UserName }).pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.AccessKeyMetadata.filter( ( entry, ): entry is iam.AccessKeyMetadata & { AccessKeyId: string; } => entry.AccessKeyId != null, ).map((entry) => ({ userName: entry.UserName ?? user.UserName, accessKeyId: entry.AccessKeyId, status: entry.Status ?? "Active", createDate: entry.CreateDate, secretAccessKey: undefined, lastUsedDate: undefined, lastUsedServiceName: undefined, lastUsedRegion: undefined, })), ), ), // The user may be deleted between enumeration and per-user list. Effect.catchTag("NoSuchEntityException", () => Effect.succeed([])), ), { concurrency: 10 }, ); return perUser.flat(); }), });