1import * as iam from "@distilled.cloud/aws/iam";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import { unwrapRedactedString } from "./common.ts";
10
11export interface LoginProfileProps {
12 /**
13 * User that owns the console login profile.
14 */
15 userName: string;
16 /**
17 * Console password. AWS never returns this value after create/update.
18 */
19 password: Redacted.Redacted<string> | string;
20 /**
21 * Require a password reset on next sign in.
22 */
23 passwordResetRequired?: boolean;
24}
25
26export interface LoginProfile extends Resource<
27 "AWS.IAM.LoginProfile",
28 LoginProfileProps,
29 {
30 /** The IAM user the login profile belongs to. */
31 userName: string;
32 /** When the login profile was created. */
33 createDate: Date | undefined;
34 /** Whether the user must set a new password on next sign-in. */
35 passwordResetRequired: boolean | undefined;
36 },
37 never,
38 Providers
39> {}
40
41/**
42 * An IAM console login profile for a user.
43 *
44 * `LoginProfile` manages AWS Management Console access for an IAM user. The
45 * password is write-only, so AWS never returns it during later reads.
46 * ### Managing Console Access
47 * **Example:** Create a Console Login Profile
48 * ```typescript
49 * const user = yield* User("ConsoleUser", {
50 * userName: "console-user",
51 * });
52 *
53 * const profile = yield* LoginProfile("ConsoleLogin", {
54 * userName: user.userName,
55 * password: Redacted.make("TempPassword123!"),
56 * passwordResetRequired: true,
57 * });
58 * ```
59 *
60 * @resource
61 */
62export const LoginProfile = Resource<LoginProfile>("AWS.IAM.LoginProfile");
63
64export const LoginProfileProvider = () =>
65 Provider.succeed(LoginProfile, {
66 stables: ["userName"],
67 diff: Effect.fn(function* ({ olds, news }) {
68 if (!isResolved(news)) return;
69 if (olds.userName !== news.userName) {
70 return { action: "replace" } as const;
71 }
72 }),
73 read: Effect.fn(function* ({ output }) {
74 if (!output) {
75 return undefined;
76 }
77 const response = yield* iam
78 .getLoginProfile({
79 UserName: output.userName,
80 })
81 .pipe(
82 Effect.catchTag("NoSuchEntityException", () =>
83 Effect.succeed(undefined),
84 ),
85 );
86 if (!response?.LoginProfile) {
87 return undefined;
88 }
89 return {
90 userName: response.LoginProfile.UserName,
91 createDate: response.LoginProfile.CreateDate,
92 passwordResetRequired: response.LoginProfile.PasswordResetRequired,
93 };
94 }),
95 reconcile: Effect.fn(function* ({ news, session }) {
96 // Observe — read the live login profile (or absence) for the user.
97 const observed = yield* iam
98 .getLoginProfile({ UserName: news.userName })
99 .pipe(
100 Effect.catchTag("NoSuchEntityException", () =>
101 Effect.succeed(undefined),
102 ),
103 );
104
105 // Ensure / Sync — the password is write-only, so we always send the
106 // desired value. Use `createLoginProfile` when the profile is
107 // missing and `updateLoginProfile` otherwise. A race that turns the
108 // create into `EntityAlreadyExistsException` is recovered by
109 // calling update.
110 let response = observed;
111 if (!observed?.LoginProfile) {
112 response = yield* iam
113 .createLoginProfile({
114 UserName: news.userName,
115 Password: unwrapRedactedString(news.password),
116 PasswordResetRequired: news.passwordResetRequired,
117 })
118 .pipe(
119 Effect.catchTag("EntityAlreadyExistsException", () =>
120 Effect.gen(function* () {
121 yield* iam.updateLoginProfile({
122 UserName: news.userName,
123 Password: unwrapRedactedString(news.password),
124 PasswordResetRequired: news.passwordResetRequired,
125 });
126 return yield* iam.getLoginProfile({
127 UserName: news.userName,
128 });
129 }),
130 ),
131 );
132 } else {
133 yield* iam.updateLoginProfile({
134 UserName: news.userName,
135 Password: unwrapRedactedString(news.password),
136 PasswordResetRequired: news.passwordResetRequired,
137 });
138 response = yield* iam.getLoginProfile({ UserName: news.userName });
139 }
140
141 yield* session.note(news.userName);
142 return {
143 userName: response!.LoginProfile.UserName,
144 createDate: response!.LoginProfile.CreateDate,
145 passwordResetRequired: response!.LoginProfile.PasswordResetRequired,
146 };
147 }),
148 delete: Effect.fn(function* ({ output }) {
149 yield* iam
150 .deleteLoginProfile({
151 UserName: output.userName,
152 })
153 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
154 }),
155 // There is no list-login-profiles API: a login profile is a per-user
156 // singleton. Enumerate every IAM user (paginated), then probe each with
157 // `getLoginProfile`; users without console access return a typed
158 // `NoSuchEntityException`, which we skip. Bounded concurrency keeps the
159 // fan-out reasonable.
160 list: Effect.fn(function* () {
161 const users = yield* iam.listUsers.pages({}).pipe(
162 Stream.runCollect,
163 Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.Users)),
164 );
165 const profiles = yield* Effect.forEach(
166 users,
167 (user) =>
168 iam.getLoginProfile({ UserName: user.UserName }).pipe(
169 Effect.map((response) => ({
170 userName: response.LoginProfile.UserName,
171 createDate: response.LoginProfile.CreateDate,
172 passwordResetRequired:
173 response.LoginProfile.PasswordResetRequired,
174 })),
175 // The user has no console login profile, or was deleted between
176 // enumeration and the per-user probe.
177 Effect.catchTag("NoSuchEntityException", () =>
178 Effect.succeed(undefined),
179 ),
180 ),
181 { concurrency: 10 },
182 );
183 const result: LoginProfile["Attributes"][] = profiles.filter(
184 (profile): profile is NonNullable<typeof profile> =>
185 profile !== undefined,
186 );
187 return result;
188 }),
189 });