1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 lastSegment,
13 orgParent,
14 organizationFromName,
15 sameJson,
16 toResourceId,
17} from "./names.ts";
18import {
19 createInternalLabels,
20 encodeOwnership,
21 hasAlchemyLabels,
22 hasOwnershipMarker,
23 parseOwnership,
24} from "./ownership.ts";
25
26const MAX_NAME_LENGTH = 63;
27const DEFAULT_FEEDBACK_TYPE = "EXCLUDED_DETECTION";
28
29export type SecurityFeedbackContext = {
30 /**
31 * Attribute the feedback constrains.
32 */
33 attribute:
34 | apigee.GoogleCloudApigeeV1SecurityFeedbackFeedbackContextAttributeEnum
35 | (string & {});
36 /**
37 * Values of that attribute.
38 */
39 values: string[];
40};
41
42export type SecurityFeedbackProps = {
43 /**
44 * Apigee organization id. Defaults to the current GCP project id.
45 * Immutable — changing it replaces the feedback report.
46 */
47 organization?: string;
48 /**
49 * Feedback id (the `{feedback}` segment of
50 * `organizations/{org}/securityFeedback/{feedback}`). If omitted, a
51 * unique name is generated from the stack, stage, and logical id.
52 * Immutable — changing it replaces the report.
53 */
54 securityFeedbackId?: string;
55 /**
56 * Display name.
57 */
58 displayName?: string;
59 /**
60 * Feedback type.
61 * @default "EXCLUDED_DETECTION"
62 */
63 feedbackType?:
64 | apigee.GoogleCloudApigeeV1SecurityFeedbackFeedbackTypeEnum
65 | (string & {});
66 /**
67 * Reason for the feedback.
68 */
69 reason?: apigee.GoogleCloudApigeeV1SecurityFeedbackReasonEnum | (string & {});
70 /**
71 * Attribute/value pairs constraining the feedback.
72 */
73 feedbackContexts: SecurityFeedbackContext[];
74 /**
75 * Free-form comment. Alchemy ownership is stored in a `[alchemy …]`
76 * prefix and stripped from attributes. Feedback reports have no
77 * labels field.
78 */
79 comment?: string;
80};
81
82export type SecurityFeedback = Resource<
83 "GCP.Apigee.SecurityFeedback",
84 SecurityFeedbackProps,
85 {
86 /** Full resource name `organizations/{org}/securityFeedback/{feedback}`. */
87 name: string;
88 /** Feedback id (last path segment). */
89 securityFeedbackId: string;
90 /** Apigee organization id. */
91 organization: string;
92 /** Display name. */
93 displayName: string | undefined;
94 /** Feedback type. */
95 feedbackType: string | undefined;
96 /** Reason. */
97 reason: string | undefined;
98 /** Constraining contexts. */
99 feedbackContexts: SecurityFeedbackContext[];
100 /** User comment with the Alchemy ownership prefix stripped. */
101 comment: string | undefined;
102 /** RFC3339 creation timestamp. */
103 createTime: string | undefined;
104 /** RFC3339 last-update timestamp. */
105 updateTime: string | undefined;
106 },
107 never,
108 Providers
109>;
110
111/**
112 * An Advanced API Security customer feedback report.
113 *
114 * Feedback reports have no labels field, so Alchemy stamps ownership
115 * into the comment for `list` / nuke. Name and organization are
116 * identity — changing them replaces the report. Display name, type,
117 * reason, contexts, and comment update in place.
118 *
119 * ### Creating Feedback
120 * **Example:** Exclude detections from an environment
121 * ```typescript
122 * const feedback = yield* GCP.Apigee.SecurityFeedback("PenTest", {
123 * reason: "PENETRATION_TEST",
124 * feedbackContexts: [{
125 * attribute: "ATTRIBUTE_ENVIRONMENTS",
126 * values: ["eval"],
127 * }],
128 * });
129 * ```
130 *
131 * @resource
132 * @category Apigee
133 */
134export const SecurityFeedback = Resource<SecurityFeedback>(
135 "GCP.Apigee.SecurityFeedback",
136);
137
138export class SecurityFeedbackNotResolved extends Data.TaggedError(
139 "GCP.Apigee.SecurityFeedbackNotResolved",
140)<{
141 name: string;
142}> {}
143
144const resourceName = (organization: string, feedbackId: string) =>
145 `${orgParent(organization)}/securityFeedback/${feedbackId}`;
146
147const feedbackIdOf = (feedback: apigee.GoogleCloudApigeeV1SecurityFeedback) =>
148 lastSegment(feedback.name ?? "");
149
150const contextsOf = (
151 contexts:
152 | readonly {
153 attribute?: SecurityFeedbackContext["attribute"];
154 values?: readonly string[];
155 }[]
156 | undefined,
157): SecurityFeedbackContext[] =>
158 (contexts ?? [])
159 .filter(
160 (
161 context,
162 ): context is {
163 attribute: SecurityFeedbackContext["attribute"];
164 values?: readonly string[];
165 } => context.attribute !== undefined,
166 )
167 .map((context) => ({
168 attribute: context.attribute,
169 values: [...(context.values ?? [])],
170 }));
171
172const toAttrs = (
173 feedback: apigee.GoogleCloudApigeeV1SecurityFeedback,
174 organization: string,
175) => {
176 const securityFeedbackId = feedbackIdOf(feedback);
177 const name = feedback.name?.includes("/")
178 ? feedback.name
179 : resourceName(organization, securityFeedbackId);
180 const parsed = parseOwnership(feedback.comment);
181 return {
182 name,
183 securityFeedbackId,
184 organization: organizationFromName(name) ?? organization,
185 displayName: feedback.displayName,
186 feedbackType: feedback.feedbackType,
187 reason: feedback.reason,
188 feedbackContexts: contextsOf(feedback.feedbackContexts),
189 comment: parsed.text,
190 createTime: feedback.createTime,
191 updateTime: feedback.updateTime,
192 };
193};
194
195const getByName = (name: string) =>
196 apigee
197 .getOrganizationsSecurityFeedback({ name })
198 .pipe(
199 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
200 Effect.succeed(undefined),
201 ),
202 );
203
204const toBody = (
205 news: SecurityFeedbackProps,
206 comment: string,
207): apigee.GoogleCloudApigeeV1SecurityFeedback => ({
208 displayName: news.displayName,
209 feedbackType: news.feedbackType ?? DEFAULT_FEEDBACK_TYPE,
210 reason: news.reason,
211 comment,
212 feedbackContexts: contextsOf(news.feedbackContexts),
213});
214
215export const SecurityFeedbackProvider = () =>
216 Provider.succeed(SecurityFeedback, {
217 stables: ["name", "securityFeedbackId", "organization", "createTime"],
218
219 diff: Effect.fn(function* ({ news, olds, output }) {
220 if (!isResolved(news)) return undefined;
221 const previousId = olds?.securityFeedbackId ?? output?.securityFeedbackId;
222 const previousOrg = olds?.organization ?? output?.organization;
223 if (
224 (previousId !== undefined &&
225 news.securityFeedbackId !== undefined &&
226 news.securityFeedbackId !== previousId) ||
227 (previousOrg !== undefined &&
228 news.organization !== undefined &&
229 news.organization !== previousOrg)
230 ) {
231 return { action: "replace" as const, deleteFirst: true };
232 }
233 return undefined;
234 }),
235
236 read: Effect.fn(function* ({ id, olds, output }) {
237 const env = yield* GcpEnvironment.current;
238 const organization =
239 organizationFromName(output?.name) ?? olds?.organization ?? env.project;
240 const securityFeedbackId = yield* toResourceId(
241 id,
242 olds?.securityFeedbackId,
243 output?.securityFeedbackId,
244 MAX_NAME_LENGTH,
245 );
246 const name =
247 output?.name ?? resourceName(organization, securityFeedbackId);
248 const existing = yield* getByName(name);
249 if (existing === undefined) return undefined;
250 const attrs = toAttrs(existing, organization);
251 const { labels } = parseOwnership(existing.comment);
252 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
253 }),
254
255 list: () =>
256 Effect.gen(function* () {
257 const env = yield* GcpEnvironment.current;
258 return yield* apigee.listOrganizationsSecurityFeedback
259 .pages({
260 parent: orgParent(env.project),
261 pageSize: 100,
262 })
263 .pipe(
264 Stream.flatMap((page) =>
265 Stream.fromIterable(page.securityFeedback ?? []),
266 ),
267 Stream.filter((feedback) => hasOwnershipMarker(feedback.comment)),
268 Stream.map((feedback) => toAttrs(feedback, env.project)),
269 Stream.runCollect,
270 Effect.map((chunk) => Array.from(chunk)),
271 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
272 Effect.succeed([] as SecurityFeedback["Attributes"][]),
273 ),
274 );
275 }),
276
277 reconcile: Effect.fn(function* ({ id, news, output }) {
278 const env = yield* GcpEnvironment.current;
279 const organization =
280 news.organization ?? output?.organization ?? env.project;
281 const securityFeedbackId = yield* toResourceId(
282 id,
283 news.securityFeedbackId,
284 output?.securityFeedbackId,
285 MAX_NAME_LENGTH,
286 );
287 const name = resourceName(organization, securityFeedbackId);
288 const ownership = yield* createInternalLabels(id);
289 const desiredComment = encodeOwnership(ownership, news.comment);
290 const desiredType = news.feedbackType ?? DEFAULT_FEEDBACK_TYPE;
291
292 let current = yield* getByName(output?.name ?? name);
293
294 if (current === undefined) {
295 const created = yield* apigee
296 .createOrganizationsSecurityFeedback({
297 parent: orgParent(organization),
298 securityFeedbackId,
299 body: toBody(news, desiredComment),
300 })
301 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
302 current = created ?? undefined;
303 }
304
305 if (current === undefined) {
306 return yield* new SecurityFeedbackNotResolved({ name });
307 }
308
309 const displayChanged =
310 (current.displayName ?? "") !== (news.displayName ?? "");
311 const typeChanged = (current.feedbackType ?? "") !== desiredType;
312 const reasonChanged = (current.reason ?? "") !== (news.reason ?? "");
313 const commentChanged = (current.comment ?? "") !== desiredComment;
314 const contextsChanged = !sameJson(
315 contextsOf(current.feedbackContexts),
316 contextsOf(news.feedbackContexts),
317 );
318
319 const updateMask = [
320 displayChanged ? "displayName" : undefined,
321 typeChanged ? "feedbackType" : undefined,
322 reasonChanged ? "reason" : undefined,
323 commentChanged ? "comment" : undefined,
324 contextsChanged ? "feedbackContexts" : undefined,
325 ].filter((field): field is string => field !== undefined);
326
327 if (updateMask.length > 0) {
328 current = yield* apigee.patchOrganizationsSecurityFeedback({
329 name: current.name?.includes("/") ? current.name : name,
330 updateMask: updateMask.join(","),
331 body: toBody(news, desiredComment),
332 });
333 }
334
335 return toAttrs(current, organization);
336 }),
337
338 delete: Effect.fn(function* ({ output }) {
339 yield* apigee
340 .deleteOrganizationsSecurityFeedback({ name: output.name })
341 .pipe(
342 Effect.catchTag(
343 ["NotFound", "ApigeeResourceNotFound"],
344 () => Effect.void,
345 ),
346 );
347 }),
348 });