1import * as transfer from "@distilled.cloud/aws/transfer";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
8import type { Providers } from "../Providers.ts";
9
10export interface UserProps {
11 /**
12 * ID of the Transfer Family server the user belongs to (e.g.
13 * `server.serverId`). Changing it replaces the user.
14 */
15 serverId: string;
16 /**
17 * User name clients authenticate as. Must be 3-100 characters. Changing it
18 * replaces the user.
19 */
20 userName: string;
21 /**
22 * IAM role ARN granting the user access to the storage backend (S3/EFS).
23 */
24 role: string;
25 /**
26 * Landing directory when the user connects (PATH mode).
27 */
28 homeDirectory?: string;
29 /**
30 * Whether the user sees the absolute bucket path (`PATH`) or a virtual
31 * chroot built from `homeDirectoryMappings` (`LOGICAL`).
32 * @default "PATH"
33 */
34 homeDirectoryType?: transfer.HomeDirectoryType;
35 /**
36 * Virtual-to-actual path mappings for `LOGICAL` home-directory mode.
37 */
38 homeDirectoryMappings?: transfer.HomeDirectoryMapEntry[];
39 /**
40 * Inline session policy scoping the user's access, as a JSON string.
41 */
42 policy?: string;
43 /**
44 * POSIX identity (uid/gid) applied to the user, required for EFS servers.
45 */
46 posixProfile?: transfer.PosixProfile;
47 /**
48 * SSH public key body to register for the user at creation (service-managed
49 * identity provider). Additional keys can be managed out of band.
50 */
51 sshPublicKeyBody?: string;
52 /**
53 * User-defined tags for the user.
54 */
55 tags?: Record<string, string>;
56}
57
58export interface User extends Resource<
59 "AWS.Transfer.User",
60 UserProps,
61 {
62 /**
63 * User name clients authenticate as.
64 */
65 userName: string;
66 /**
67 * ID of the Transfer Family server the user belongs to.
68 */
69 serverId: string;
70 /**
71 * ARN of the user.
72 */
73 arn: string;
74 /**
75 * IAM role ARN granting the user access to the storage backend.
76 */
77 role: string | undefined;
78 /**
79 * Landing directory when the user connects.
80 */
81 homeDirectory: string | undefined;
82 /**
83 * Current tags reported for the user.
84 */
85 tags: Record<string, string>;
86 },
87 never,
88 Providers
89> {}
90
91/**
92 * A user of an AWS Transfer Family server (service-managed identity
93 * provider). Users are free configuration objects attached to a
94 * {@link Server}; the server itself is what incurs hourly cost.
95 * ### Creating a User
96 * **Example:** Service-Managed SFTP User
97 * ```typescript
98 * const user = yield* User("Alice", {
99 * serverId: server.serverId,
100 * userName: "alice",
101 * role: transferRole.roleArn,
102 * homeDirectory: "/my-bucket/alice",
103 * sshPublicKeyBody: "ssh-ed25519 AAAA...",
104 * });
105 * ```
106 *
107 * @resource
108 */
109export const User = Resource<User>("AWS.Transfer.User");
110
111const toTagRecord = (
112 tags: Array<{ Key?: string; Value?: string }> | undefined,
113): Record<string, string> =>
114 Object.fromEntries(
115 (tags ?? [])
116 .filter(
117 (tag): tag is { Key: string; Value: string } =>
118 typeof tag.Key === "string" && typeof tag.Value === "string",
119 )
120 .map((tag) => [tag.Key, tag.Value]),
121 );
122
123export const UserProvider = () =>
124 Provider.effect(
125 User,
126 Effect.gen(function* () {
127 const describe = Effect.fn(function* (
128 serverId: string,
129 userName: string,
130 ) {
131 const response = yield* transfer
132 .describeUser({ ServerId: serverId, UserName: userName })
133 .pipe(
134 Effect.catchTag("ResourceNotFoundException", () =>
135 Effect.succeed(undefined),
136 ),
137 );
138 return response?.User;
139 });
140
141 const readTags = Effect.fn(function* (arn: string) {
142 const response = yield* transfer
143 .listTagsForResource({ Arn: arn })
144 .pipe(Effect.catch(() => Effect.succeed(undefined)));
145 return toTagRecord(response?.Tags);
146 });
147
148 const toAttrs = Effect.fn(function* (
149 serverId: string,
150 user: transfer.DescribedUser,
151 ) {
152 if (!user.UserName) {
153 return yield* Effect.fail(
154 new Error("Transfer user is missing its UserName"),
155 );
156 }
157 return {
158 userName: user.UserName,
159 serverId,
160 arn: user.Arn,
161 role: user.Role,
162 homeDirectory: user.HomeDirectory,
163 tags: yield* readTags(user.Arn),
164 };
165 });
166
167 return {
168 stables: ["userName", "serverId", "arn"],
169
170 diff: Effect.fn(function* ({ olds, news }) {
171 if (!isResolved(news)) return undefined;
172 // ServerId and UserName are the resource identity.
173 if (
174 news.serverId !== olds?.serverId ||
175 news.userName !== olds?.userName
176 ) {
177 return { action: "replace" } as const;
178 }
179 }),
180
181 read: Effect.fn(function* ({ id, olds, output }) {
182 const serverId = output?.serverId ?? olds?.serverId;
183 const userName = output?.userName ?? olds?.userName;
184 if (!serverId || !userName) return undefined;
185 const user = yield* describe(serverId, userName);
186 if (!user?.UserName) return undefined;
187 const attrs = yield* toAttrs(serverId, user);
188 return (yield* hasAlchemyTags(id, attrs.tags))
189 ? attrs
190 : Unowned(attrs);
191 }),
192
193 reconcile: Effect.fn(function* ({ id, news, output, session }) {
194 const internalTags = yield* createInternalTags(id);
195 const desiredTags = { ...internalTags, ...news.tags };
196
197 // 1. Observe — cloud state is authoritative.
198 let observed = yield* describe(news.serverId, news.userName);
199
200 // 2. Ensure — create if missing; tolerate AlreadyExists as a race.
201 if (observed === undefined) {
202 yield* transfer
203 .createUser({
204 ServerId: news.serverId,
205 UserName: news.userName,
206 Role: news.role,
207 HomeDirectory: news.homeDirectory,
208 HomeDirectoryType: news.homeDirectoryType,
209 HomeDirectoryMappings: news.homeDirectoryMappings,
210 Policy: news.policy,
211 PosixProfile: news.posixProfile,
212 SshPublicKeyBody: news.sshPublicKeyBody,
213 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
214 Key,
215 Value,
216 })),
217 })
218 .pipe(
219 Effect.catchTag("ResourceExistsException", () => Effect.void),
220 );
221 } else {
222 // 3. Sync — push mutable configuration.
223 yield* transfer.updateUser({
224 ServerId: news.serverId,
225 UserName: news.userName,
226 Role: news.role,
227 HomeDirectory: news.homeDirectory,
228 HomeDirectoryType: news.homeDirectoryType,
229 HomeDirectoryMappings: news.homeDirectoryMappings,
230 Policy: news.policy,
231 PosixProfile: news.posixProfile,
232 });
233 }
234
235 observed = yield* describe(news.serverId, news.userName);
236 if (!observed?.UserName) {
237 return yield* Effect.fail(
238 new Error(
239 `Transfer user '${news.userName}' not found after reconcile`,
240 ),
241 );
242 }
243
244 // 3b. Sync tags — diff against OBSERVED cloud tags.
245 const arn = observed.Arn;
246 const observedTags = yield* readTags(arn);
247 const { upsert, removed } = diffTags(observedTags, desiredTags);
248 if (upsert.length > 0) {
249 yield* transfer.tagResource({ Arn: arn, Tags: upsert });
250 }
251 if (removed.length > 0) {
252 yield* transfer.untagResource({ Arn: arn, TagKeys: removed });
253 }
254
255 yield* session.note(`${news.serverId}/${news.userName}`);
256 return yield* toAttrs(news.serverId, observed);
257 }),
258
259 delete: Effect.fn(function* ({ output }) {
260 yield* transfer
261 .deleteUser({
262 ServerId: output.serverId,
263 UserName: output.userName,
264 })
265 .pipe(
266 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
267 );
268 }),
269
270 // Users are sub-resources keyed by their parent server; there is no
271 // account-wide enumeration without a server id.
272 list: () => Effect.succeed([]),
273 };
274 }),
275 );