| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Result from "effect/Result"; |
| 5 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 6 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 7 | import path from "pathe"; |
| 8 | |
| 9 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 10 | |
| 11 | export class VerifiedPermissionsTestFunction extends AWS.Lambda.Function<AWS.Lambda.Function>()( |
| 12 | "VerifiedPermissionsTestFunction", |
| 13 | ) {} |
| 14 | |
| 15 | export default VerifiedPermissionsTestFunction.make( |
| 16 | { |
| 17 | main, |
| 18 | functionUrl: true, |
| 19 | // isAuthorized fans out an AVP API call; the 3s default is too tight |
| 20 | timeout: Duration.seconds(30), |
| 21 | }, |
| 22 | Effect.gen(function* () { |
| 23 | const store = yield* AWS.VerifiedPermissions.PolicyStore("Store", { |
| 24 | validationMode: "OFF", |
| 25 | }); |
| 26 | // permit only alice to view photos |
| 27 | const allowAlice = yield* AWS.VerifiedPermissions.Policy("AllowAlice", { |
| 28 | policyStoreId: store.policyStoreId, |
| 29 | statement: `permit( |
| 30 | principal == PhotoApp::User::"alice", |
| 31 | action == PhotoApp::Action::"viewPhoto", |
| 32 | resource |
| 33 | );`, |
| 34 | }); |
| 35 | |
| 36 | const authz = yield* AWS.VerifiedPermissions.IsAuthorized(store); |
| 37 | const policies = yield* AWS.VerifiedPermissions.GetPolicies(store); |
| 38 | const PolicyStoreId = yield* store.policyStoreId; |
| 39 | const AllowAlicePolicyId = yield* allowAlice.policyId; |
| 40 | |
| 41 | return { |
| 42 | fetch: Effect.gen(function* () { |
| 43 | const request = yield* HttpServerRequest; |
| 44 | const url = new URL(request.originalUrl); |
| 45 | const pathname = url.pathname; |
| 46 | |
| 47 | if (request.method === "GET" && pathname === "/info") { |
| 48 | const policyStoreId = yield* PolicyStoreId; |
| 49 | return yield* HttpServerResponse.json({ policyStoreId }); |
| 50 | } |
| 51 | |
| 52 | // /authorize?user=alice -> ALLOW, any other user -> DENY |
| 53 | if (request.method === "GET" && pathname === "/authorize") { |
| 54 | const user = url.searchParams.get("user") ?? "alice"; |
| 55 | const result = yield* authz.isAuthorized({ |
| 56 | principal: { entityType: "PhotoApp::User", entityId: user }, |
| 57 | action: { |
| 58 | actionType: "PhotoApp::Action", |
| 59 | actionId: "viewPhoto", |
| 60 | }, |
| 61 | resource: { |
| 62 | entityType: "PhotoApp::Photo", |
| 63 | entityId: "vacation.jpg", |
| 64 | }, |
| 65 | }); |
| 66 | return yield* HttpServerResponse.json({ |
| 67 | decision: result.decision, |
| 68 | }); |
| 69 | } |
| 70 | |
| 71 | // /batch -> two decisions for one resource, alice + bob |
| 72 | if (request.method === "GET" && pathname === "/batch") { |
| 73 | const result = yield* authz.batchIsAuthorized({ |
| 74 | requests: [ |
| 75 | { |
| 76 | principal: { entityType: "PhotoApp::User", entityId: "alice" }, |
| 77 | action: { |
| 78 | actionType: "PhotoApp::Action", |
| 79 | actionId: "viewPhoto", |
| 80 | }, |
| 81 | resource: { |
| 82 | entityType: "PhotoApp::Photo", |
| 83 | entityId: "vacation.jpg", |
| 84 | }, |
| 85 | }, |
| 86 | { |
| 87 | principal: { entityType: "PhotoApp::User", entityId: "bob" }, |
| 88 | action: { |
| 89 | actionType: "PhotoApp::Action", |
| 90 | actionId: "viewPhoto", |
| 91 | }, |
| 92 | resource: { |
| 93 | entityType: "PhotoApp::Photo", |
| 94 | entityId: "vacation.jpg", |
| 95 | }, |
| 96 | }, |
| 97 | ], |
| 98 | }); |
| 99 | return yield* HttpServerResponse.json({ |
| 100 | decisions: result.results.map((r) => r.decision), |
| 101 | }); |
| 102 | } |
| 103 | |
| 104 | // /policies -> batchGetPolicy for the AllowAlice policy |
| 105 | if (request.method === "GET" && pathname === "/policies") { |
| 106 | const policyId = yield* AllowAlicePolicyId; |
| 107 | const result = yield* policies.batchGetPolicy({ |
| 108 | policyIds: [policyId], |
| 109 | }); |
| 110 | return yield* HttpServerResponse.json({ |
| 111 | ids: result.results.map((r) => r.policyId), |
| 112 | types: result.results.map((r) => r.policyType), |
| 113 | errors: result.errors.length, |
| 114 | }); |
| 115 | } |
| 116 | |
| 117 | // /batch-token -> batchIsAuthorizedWithToken with a malformed token; |
| 118 | // no identity source is configured, so AVP rejects the request with |
| 119 | // a typed ValidationException — proving IAM + wiring end-to-end |
| 120 | if (request.method === "GET" && pathname === "/batch-token") { |
| 121 | const result = yield* Effect.result( |
| 122 | authz.batchIsAuthorizedWithToken({ |
| 123 | accessToken: "not-a-jwt", |
| 124 | requests: [ |
| 125 | { |
| 126 | action: { |
| 127 | actionType: "PhotoApp::Action", |
| 128 | actionId: "viewPhoto", |
| 129 | }, |
| 130 | resource: { |
| 131 | entityType: "PhotoApp::Photo", |
| 132 | entityId: "vacation.jpg", |
| 133 | }, |
| 134 | }, |
| 135 | ], |
| 136 | }), |
| 137 | ); |
| 138 | return yield* HttpServerResponse.json({ |
| 139 | tag: Result.isFailure(result) |
| 140 | ? result.failure._tag |
| 141 | : "unexpected-success", |
| 142 | }); |
| 143 | } |
| 144 | |
| 145 | return yield* HttpServerResponse.json( |
| 146 | { error: "Not found" }, |
| 147 | { status: 404 }, |
| 148 | ); |
| 149 | }).pipe(Effect.orDie), |
| 150 | }; |
| 151 | }).pipe( |
| 152 | Effect.provide([ |
| 153 | AWS.VerifiedPermissions.IsAuthorizedHttp, |
| 154 | AWS.VerifiedPermissions.GetPoliciesHttp, |
| 155 | ]), |
| 156 | ), |
| 157 | ); |