| 1 | import * as osis from "@distilled.cloud/aws/osis"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { isResolved } from "../../Diff.ts"; |
| 4 | import * as Provider from "../../Provider.ts"; |
| 5 | import { Resource } from "../../Resource.ts"; |
| 6 | import type { Providers } from "../Providers.ts"; |
| 7 | |
| 8 | export interface ResourcePolicyProps { |
| 9 | /** |
| 10 | * ARN of the OSIS resource (pipeline) the policy is attached to. A |
| 11 | * resource has at most one resource-based policy. Changing the resource |
| 12 | * replaces the policy. |
| 13 | */ |
| 14 | resourceArn: string; |
| 15 | /** |
| 16 | * The IAM resource-based policy document (JSON) that grants other |
| 17 | * principals or accounts access to the resource (e.g. `osis:Ingest`, |
| 18 | * `osis:CreatePipelineEndpoint`). Updated in place. |
| 19 | */ |
| 20 | policy: string; |
| 21 | } |
| 22 | |
| 23 | export interface ResourcePolicy extends Resource< |
| 24 | "AWS.OSIS.ResourcePolicy", |
| 25 | ResourcePolicyProps, |
| 26 | { |
| 27 | /** |
| 28 | * ARN of the resource the policy is attached to. |
| 29 | */ |
| 30 | resourceArn: string; |
| 31 | /** |
| 32 | * The attached policy document (JSON). |
| 33 | */ |
| 34 | policy: string; |
| 35 | }, |
| 36 | never, |
| 37 | Providers |
| 38 | > {} |
| 39 | |
| 40 | /** |
| 41 | * The resource-based policy of an Amazon OpenSearch Ingestion (OSIS) |
| 42 | * pipeline — grants cross-account principals access to the pipeline, e.g. |
| 43 | * `osis:Ingest` for cross-account ingestion or |
| 44 | * `osis:CreatePipelineEndpoint` so another account can attach a VPC |
| 45 | * endpoint. A resource has at most one. |
| 46 | * |
| 47 | * ### Creating a Resource Policy |
| 48 | * **Example:** Allow Another Account to Ingest |
| 49 | * ```typescript |
| 50 | * const policy = yield* OSIS.ResourcePolicy("CrossAccountIngest", { |
| 51 | * resourceArn: pipeline.pipelineArn, |
| 52 | * policy: Output.interpolate`{ |
| 53 | * "Version": "2012-10-17", |
| 54 | * "Statement": [ |
| 55 | * { |
| 56 | * "Effect": "Allow", |
| 57 | * "Principal": { "AWS": "arn:aws:iam::123456789012:root" }, |
| 58 | * "Action": ["osis:Ingest"], |
| 59 | * "Resource": "${pipeline.pipelineArn}" |
| 60 | * } |
| 61 | * ] |
| 62 | * }`, |
| 63 | * }); |
| 64 | * ``` |
| 65 | * |
| 66 | * @resource |
| 67 | */ |
| 68 | export const ResourcePolicy = Resource<ResourcePolicy>( |
| 69 | "AWS.OSIS.ResourcePolicy", |
| 70 | ); |
| 71 | |
| 72 | /** Order-insensitive canonical form of a JSON policy document. */ |
| 73 | const canonicalJson = (document: string): string => { |
| 74 | const sort = (value: unknown): unknown => |
| 75 | Array.isArray(value) |
| 76 | ? value.map(sort) |
| 77 | : value !== null && typeof value === "object" |
| 78 | ? Object.fromEntries( |
| 79 | Object.entries(value as Record<string, unknown>) |
| 80 | .sort(([a], [b]) => a.localeCompare(b)) |
| 81 | .map(([k, v]) => [k, sort(v)]), |
| 82 | ) |
| 83 | : value; |
| 84 | try { |
| 85 | return JSON.stringify(sort(JSON.parse(document))); |
| 86 | } catch { |
| 87 | return document; |
| 88 | } |
| 89 | }; |
| 90 | |
| 91 | export const ResourcePolicyProvider = () => |
| 92 | Provider.effect( |
| 93 | ResourcePolicy, |
| 94 | Effect.gen(function* () { |
| 95 | /** |
| 96 | * Read the attached policy. OSIS reports "no policy" two ways: a typed |
| 97 | * `ResourceNotFoundException`, or (observed live) a success response |
| 98 | * whose `Policy` is the empty document `"{}"` — normalize both to |
| 99 | * `undefined`. |
| 100 | */ |
| 101 | const getPolicy = Effect.fn(function* (resourceArn: string) { |
| 102 | const policy = yield* osis |
| 103 | .getResourcePolicy({ ResourceArn: resourceArn }) |
| 104 | .pipe( |
| 105 | Effect.map((response) => response.Policy), |
| 106 | Effect.catchTag("ResourceNotFoundException", () => |
| 107 | Effect.succeed(undefined), |
| 108 | ), |
| 109 | ); |
| 110 | return policy === undefined || canonicalJson(policy) === "{}" |
| 111 | ? undefined |
| 112 | : policy; |
| 113 | }); |
| 114 | |
| 115 | return { |
| 116 | stables: ["resourceArn"], |
| 117 | |
| 118 | diff: Effect.fn(function* ({ olds, news }) { |
| 119 | if (!isResolved(news)) return undefined; |
| 120 | if (olds?.resourceArn !== news.resourceArn) { |
| 121 | return { action: "replace" } as const; |
| 122 | } |
| 123 | }), |
| 124 | |
| 125 | read: Effect.fn(function* ({ olds, output }) { |
| 126 | const resourceArn = output?.resourceArn ?? olds?.resourceArn; |
| 127 | if (!resourceArn) return undefined; |
| 128 | const policy = yield* getPolicy(resourceArn); |
| 129 | if (policy === undefined) return undefined; |
| 130 | // Resource policies are not taggable — ownership is implied by |
| 131 | // the owned parent pipeline. |
| 132 | return { resourceArn, policy }; |
| 133 | }), |
| 134 | |
| 135 | reconcile: Effect.fn(function* ({ news, session }) { |
| 136 | const resourceArn = news!.resourceArn; |
| 137 | |
| 138 | // 1. Observe — the live policy is authoritative. |
| 139 | const observed = yield* getPolicy(resourceArn); |
| 140 | |
| 141 | // 2/3. Ensure + sync — `putResourcePolicy` is a true upsert; |
| 142 | // call it only when the canonical document drifts. |
| 143 | if ( |
| 144 | observed === undefined || |
| 145 | canonicalJson(observed) !== canonicalJson(news!.policy) |
| 146 | ) { |
| 147 | yield* osis.putResourcePolicy({ |
| 148 | ResourceArn: resourceArn, |
| 149 | Policy: news!.policy, |
| 150 | }); |
| 151 | } |
| 152 | |
| 153 | yield* session.note(resourceArn); |
| 154 | return { resourceArn, policy: news!.policy }; |
| 155 | }), |
| 156 | |
| 157 | delete: Effect.fn(function* ({ output }) { |
| 158 | yield* osis |
| 159 | .deleteResourcePolicy({ ResourceArn: output.resourceArn }) |
| 160 | .pipe( |
| 161 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 162 | ); |
| 163 | }), |
| 164 | |
| 165 | // Singleton sub-resource keyed by its parent resource ARN. |
| 166 | list: () => Effect.succeed([]), |
| 167 | }; |
| 168 | }), |
| 169 | ); |