1import * as AWS from "@/AWS";
2import { Policy, PolicyStore, Schema } from "@/AWS/VerifiedPermissions";
3import * as Test from "@/Test/Alchemy";
4import * as avp from "@distilled.cloud/aws/verifiedpermissions";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Redacted from "effect/Redacted";
9import * as Schedule from "effect/Schedule";
10
11const unwrap = (v: string | Redacted.Redacted<string> | undefined) =>
12 v === undefined ? undefined : Redacted.isRedacted(v) ? Redacted.value(v) : v;
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16const findStore = (policyStoreId: string) =>
17 avp
18 .getPolicyStore({ policyStoreId, tags: true })
19 .pipe(
20 Effect.catchTag("ResourceNotFoundException", () =>
21 Effect.succeed(undefined),
22 ),
23 );
24
25const findPolicy = (policyStoreId: string, policyId: string) =>
26 avp
27 .getPolicy({ policyStoreId, policyId })
28 .pipe(
29 Effect.catchTag("ResourceNotFoundException", () =>
30 Effect.succeed(undefined),
31 ),
32 );
33
34const findSchema = (policyStoreId: string) =>
35 avp
36 .getSchema({ policyStoreId })
37 .pipe(
38 Effect.catchTag("ResourceNotFoundException", () =>
39 Effect.succeed(undefined),
40 ),
41 );
42
43class StoreStillExists extends Data.TaggedError("StoreStillExists")<{
44 readonly policyStoreId: string;
45}> {}
46
47const assertStoreDeleted = (policyStoreId: string) =>
48 findStore(policyStoreId).pipe(
49 Effect.flatMap((store) =>
50 store === undefined
51 ? Effect.void
52 : Effect.fail(new StoreStillExists({ policyStoreId })),
53 ),
54 Effect.retry({
55 while: (e) => e._tag === "StoreStillExists",
56 schedule: Schedule.max([
57 Schedule.spaced("2 seconds"),
58 Schedule.recurs(15),
59 ]),
60 }),
61 );
62
63const cedarSchema = JSON.stringify({
64 PhotoApp: {
65 entityTypes: {
66 User: {},
67 Photo: {},
68 },
69 actions: {
70 viewPhoto: {
71 appliesTo: {
72 principalTypes: ["User"],
73 resourceTypes: ["Photo"],
74 },
75 },
76 },
77 },
78});
79
80test.provider(
81 "policy store lifecycle: create store + schema + policy, update validation mode + policy statement, destroy",
82 (stack) =>
83 Effect.gen(function* () {
84 yield* stack.destroy();
85
86 // AVP forbids changing a static policy's principal/resource SCOPE on
87 // update — only the description and conditions may change. Keep the
88 // scope fixed and vary the policy description across deploys.
89 const statement = `permit(
90 principal == PhotoApp::User::"alice",
91 action == PhotoApp::Action::"viewPhoto",
92 resource
93 );`;
94
95 const makeStack = (
96 validationMode: "OFF" | "STRICT",
97 description: string,
98 policyDescription: string,
99 ) =>
100 Effect.gen(function* () {
101 const store = yield* PolicyStore("Store", {
102 validationMode,
103 description,
104 deletionProtection: "DISABLED",
105 tags: { Environment: "test" },
106 });
107 const schema = yield* Schema("Schema", {
108 policyStoreId: store.policyStoreId,
109 cedarJson: cedarSchema,
110 });
111 const policy = yield* Policy("AllowUser", {
112 // depend on the schema so, under STRICT validation, the policy is
113 // created after PutSchema — Cedar validates the statement against
114 // the schema at CreatePolicy time
115 policyStoreId: schema.policyStoreId,
116 statement,
117 description: policyDescription,
118 });
119 return { store, schema, policy };
120 });
121
122 // create: STRICT validation requires the schema to be present
123 const { store, policy } = yield* stack.deploy(
124 makeStack("STRICT", "photo app", "initial policy"),
125 );
126 expect(store.policyStoreId).toBeDefined();
127 expect(store.policyStoreArn).toContain(":policy-store/");
128 expect(policy.policyId).toBeDefined();
129 expect(policy.policyStoreId).toBe(store.policyStoreId);
130
131 // out-of-band verify
132 const created = yield* findStore(store.policyStoreId);
133 expect(created?.validationSettings.mode).toBe("STRICT");
134 expect(unwrap(created?.description)).toBe("photo app");
135 expect(created?.tags?.Environment).toBe("test");
136 expect(created?.tags?.["alchemy::id"]).toBe("Store");
137
138 const createdSchema = yield* findSchema(store.policyStoreId);
139 expect(unwrap(createdSchema?.schema)).toContain("PhotoApp");
140
141 const createdPolicy = yield* findPolicy(
142 store.policyStoreId,
143 policy.policyId,
144 );
145 expect(createdPolicy?.policyType).toBe("STATIC");
146
147 // update: store validation mode OFF + description, and the policy's
148 // description in place (policyId is stable)
149 const updated = yield* stack.deploy(
150 makeStack("OFF", "updated app", "updated policy"),
151 );
152 expect(updated.store.policyStoreId).toBe(store.policyStoreId);
153 expect(updated.policy.policyId).toBe(policy.policyId);
154
155 const afterUpdate = yield* findStore(store.policyStoreId);
156 expect(afterUpdate?.validationSettings.mode).toBe("OFF");
157 expect(unwrap(afterUpdate?.description)).toBe("updated app");
158
159 // destroy
160 yield* stack.destroy();
161 yield* assertStoreDeleted(store.policyStoreId);
162 }),
163 {
164 tags: ["provider:aws", "provider:aws:verifiedpermissions", "live"],
165 timeout: 180_000,
166 },
167);