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