@reventlessdev/reventless-aws 3.0.0-alpha.292 → 3.0.0-alpha.294

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Aws from "@pulumi/aws";
4
+ import * as IAM$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/IAM.res.mjs";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Pulumi from "@pulumi/pulumi";
7
+ import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
8
+ import * as AWS$ReventlessAws from "../AWS.res.mjs";
9
+ import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
10
+ import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
11
+ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
12
+ import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
13
+ import * as Util_LambdaLogging$ReventlessAws from "../../util/Util_LambdaLogging.res.mjs";
14
+
15
+ function make(activeRoleTableName, activeRoleTableArn, nameOpt, opts) {
16
+ let name = nameOpt !== undefined ? nameOpt : "ActiveRoleTrigger";
17
+ let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
18
+ let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name, "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
19
+ activeRoleTableArn.apply(tableArn => {
20
+ new (Aws.iam.RolePolicy)(name + "Policy", {
21
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "Policy", [
22
+ {
23
+ Sid: "AllowLambdaLogging",
24
+ Effect: "Allow",
25
+ Action: [
26
+ "logs:CreateLogGroup",
27
+ "logs:CreateLogStream",
28
+ "logs:PutLogEvents"
29
+ ],
30
+ Resource: "arn:aws:logs:*:*:*"
31
+ },
32
+ {
33
+ Sid: "AllowReadingStoredRole",
34
+ Effect: "Allow",
35
+ Action: "dynamodb:GetItem",
36
+ Resource: tableArn
37
+ }
38
+ ])),
39
+ role: lambdaRole.id
40
+ }, opts$1);
41
+ });
42
+ let packageDirs = Object.fromEntries([[
43
+ "@reventlessdev/reventless-aws",
44
+ Util_Bundle$ReventlessAws.resolvePackageRoot(undefined, "@reventlessdev/reventless-aws")
45
+ ]]);
46
+ let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs", packageDirs, undefined, false);
47
+ let sourceCodeHash = match.sourceCodeHash;
48
+ let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
49
+ let logGroup = Util_LambdaLogging$ReventlessAws.makeManagedLogGroup(name, undefined, AWS_Tags$ReventlessAws.make(name + "LogGroup", "Platform", "Logs", "Platform", undefined, undefined, undefined, undefined), opts$1, undefined);
50
+ let lambda = new (Aws.lambda.Function)(name, {
51
+ handler: "index.handler",
52
+ runtime: "nodejs22.x",
53
+ code: match.code,
54
+ role: lambdaRole.arn,
55
+ memorySize: 256,
56
+ timeout: 5,
57
+ layers: layers,
58
+ tags: AWS_Tags$ReventlessAws.make(name, "Platform", "Runtime", "Platform", undefined, undefined, undefined, undefined),
59
+ environment: {
60
+ variables: Object.fromEntries([
61
+ [
62
+ "Environment",
63
+ Pulumi.getStack()
64
+ ],
65
+ [
66
+ "ACTIVE_ROLE_TABLE",
67
+ activeRoleTableName
68
+ ],
69
+ [
70
+ "NODE_OPTIONS",
71
+ Util_Bundle$ReventlessAws.esmLoaderNodeOptions
72
+ ],
73
+ [
74
+ "ESM_FALLBACK_DIRS",
75
+ Util_Bundle$ReventlessAws.esmFallbackDirs
76
+ ],
77
+ Util_LambdaLogging$ReventlessAws.logLevelEntry()
78
+ ])
79
+ },
80
+ sourceCodeHash: sourceCodeHash,
81
+ loggingConfig: Util_LambdaLogging$ReventlessAws.loggingConfigFor(logGroup)
82
+ }, opts$1);
83
+ return {
84
+ functionArn: lambda.arn,
85
+ functionName: lambda.name,
86
+ sourceCodeHash: sourceCodeHash
87
+ };
88
+ }
89
+
90
+ function grantInvoke(trigger, cognitoUserPoolArn, nameOpt, opts) {
91
+ let name = nameOpt !== undefined ? nameOpt : "ActiveRoleTrigger";
92
+ let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
93
+ trigger.functionName.apply(functionName => {
94
+ new (Aws.lambda.Permission)(name + "Invoke", {
95
+ action: "lambda:InvokeFunction",
96
+ function: functionName,
97
+ principal: "cognito-idp.amazonaws.com",
98
+ sourceArn: cognitoUserPoolArn
99
+ }, opts$1);
100
+ });
101
+ }
102
+
103
+ export {
104
+ make,
105
+ grantInvoke,
106
+ }
107
+ /* @pulumi/aws Not a pure module */
@@ -0,0 +1,201 @@
1
+ // Runtime handler for the Cognito **pre token generation** trigger — the AWS
2
+ // minting point for "acting as one of the roles you hold". See
3
+ // [docs/plans/active-role-narrows-the-token.md] §6.
4
+ //
5
+ // Compiled, type-checked and Pulumi-free so it ships as an EntryPoint module
6
+ // ([Auth_ActiveRoleTrigger.res] bundles it and attaches it to the user pool).
7
+ //
8
+ // The narrowing has to happen here rather than in our own enforcement code
9
+ // because `@authorize(AllowGroups([...]))` compiles to `@aws_auth`, which AppSync
10
+ // evaluates against `cognito:groups` **before any of our code executes**. A
11
+ // request header we invent could scope reads correctly and still leave every
12
+ // group-gated mutation callable — right about the data, wrong about the writes.
13
+ // Overriding the group claim is the one place every enforcement point already
14
+ // looks.
15
+ //
16
+ // **Version 1 of the trigger, deliberately.** Group override and added ID-token
17
+ // claims are `V1_0` capabilities; `V2_0`/`V3_0` buy access-token customisation
18
+ // this does not need and are gated behind the Essentials and Plus feature plans.
19
+ // A deployment on the Lite tier must not be excluded from acting as a role.
20
+
21
+ open AwsSdk
22
+
23
+ // ── Environment ─────────────────────────────────────────────────────────────
24
+
25
+ let getEnv = (k: string): option<string> =>
26
+ switch NodeProcess.env->Dict.get(k) {
27
+ | Some("") | None => None
28
+ | Some(v) => Some(v)
29
+ }
30
+
31
+ let tableName = (): string =>
32
+ switch getEnv("ACTIVE_ROLE_TABLE") {
33
+ | Some(t) => t
34
+ | None => JsError.throwWithMessage("ACTIVE_ROLE_TABLE is not configured")
35
+ }
36
+
37
+ // ── Cognito pre-token-generation event (V1_0) ───────────────────────────────
38
+
39
+ type userAttributes = {sub?: string}
40
+
41
+ /** The groups the pool says the user is really in, supplied by the pool itself.
42
+ This is why the subset check here needs no lookup and no IAM to perform one:
43
+ the authority arrives in the payload, and the check is a containment test
44
+ against it. */
45
+ type groupConfiguration = {
46
+ groupsToOverride?: array<string>,
47
+ iamRolesToOverride?: array<string>,
48
+ preferredRole?: Nullable.t<string>,
49
+ }
50
+
51
+ type triggerRequest = {
52
+ userAttributes?: userAttributes,
53
+ groupConfiguration?: groupConfiguration,
54
+ }
55
+
56
+ type groupOverrideDetails = {
57
+ groupsToOverride: array<string>,
58
+ iamRolesToOverride: array<string>,
59
+ preferredRole?: Nullable.t<string>,
60
+ }
61
+
62
+ type claimsOverrideDetails = {
63
+ claimsToAddOrOverride?: dict<string>,
64
+ groupOverrideDetails?: groupOverrideDetails,
65
+ }
66
+
67
+ type triggerResponse = {claimsOverrideDetails?: claimsOverrideDetails}
68
+
69
+ type event = {
70
+ request?: triggerRequest,
71
+ response?: triggerResponse,
72
+ userName?: string,
73
+ }
74
+
75
+ // ── The decision ────────────────────────────────────────────────────────────
76
+
77
+ type decision =
78
+ /** No stored preference — mint exactly the token that would have been minted
79
+ before any of this existed. This is the path every existing caller takes. */
80
+ | Unchanged
81
+ /** The stored role is one the pool says the caller holds. */
82
+ | Narrow({role: string, membership: array<string>})
83
+ /** The row outlived the membership that justified it.
84
+
85
+ Resolved by minting the full set rather than narrowing to a group the pool no
86
+ longer grants — but *decided* here rather than falling out of the code,
87
+ because the alternative reading is a token scoped to a role the caller has
88
+ lost. Unlike the write door, this path has no client to refuse: the trigger
89
+ meets a stale row on an ordinary refresh with nobody asking for anything. It
90
+ says so on the token instead, so a caller whose chosen role silently stopped
91
+ applying has something to read. */
92
+ | Stale({role: string, membership: array<string>})
93
+
94
+ let decide = (~membership: array<string>, ~storedRole: option<string>): decision =>
95
+ switch storedRole {
96
+ | None | Some("") => Unchanged
97
+ | Some(role) =>
98
+ membership->Array.includes(role) ? Narrow({role, membership}) : Stale({role, membership})
99
+ }
100
+
101
+ /**
102
+ Turn a decision into the trigger's response.
103
+
104
+ 🚨 **`groupOverrideDetails` replaces the whole group configuration.** Supplying it
105
+ with only `groupsToOverride` drops the caller's `iamRolesToOverride` and
106
+ `preferredRole` — the same reset-by-omission hazard `UpdateUserPool` has, and just
107
+ as quiet. Both are echoed from the incoming configuration so the override changes
108
+ exactly one thing.
109
+
110
+ `Unchanged` returns the event untouched rather than an empty override: an empty
111
+ `claimsOverrideDetails` is not the same as none, and the regression line for this
112
+ whole feature is that a caller with no stored role gets byte-identical output.
113
+ */
114
+ let respond = (~event: event, ~decision: decision): event => {
115
+ let incoming = event.request->Option.flatMap(r => r.groupConfiguration)
116
+ let iamRolesToOverride = incoming->Option.flatMap(g => g.iamRolesToOverride)->Option.getOr([])
117
+ let preferredRole = incoming->Option.flatMap(g => g.preferredRole)
118
+
119
+ let overrideWith = (~groups: array<string>, ~claims: dict<string>) => {
120
+ ...event,
121
+ response: {
122
+ claimsOverrideDetails: {
123
+ claimsToAddOrOverride: claims,
124
+ groupOverrideDetails: {
125
+ groupsToOverride: groups,
126
+ iamRolesToOverride,
127
+ preferredRole: ?preferredRole,
128
+ },
129
+ },
130
+ },
131
+ }
132
+
133
+ switch decision {
134
+ | Unchanged => event
135
+ | Narrow({role, membership}) =>
136
+ overrideWith(
137
+ ~groups=[role],
138
+ ~claims=Dict.fromArray([
139
+ (ReventlessCore.Auth_ActiveRole.activeRoleClaim, role),
140
+ (ReventlessCore.Auth_ActiveRole.availableRolesClaim, membership->Array.join(",")),
141
+ ]),
142
+ )
143
+ | Stale({role, membership}) =>
144
+ // The full set, as the pool granted it — plus the marker that explains why the
145
+ // stored choice did not apply.
146
+ overrideWith(
147
+ ~groups=membership,
148
+ ~claims=Dict.fromArray([
149
+ (ReventlessCore.Auth_ActiveRole.staleRoleClaim, role),
150
+ (ReventlessCore.Auth_ActiveRole.availableRolesClaim, membership->Array.join(",")),
151
+ ]),
152
+ )
153
+ }
154
+ }
155
+
156
+ // ── Stored preference ───────────────────────────────────────────────────────
157
+
158
+ /**
159
+ The role this subject last chose, or `None` if they never chose one or cleared it.
160
+
161
+ A read failure resolves to `None`, not to an error. This trigger sits in the
162
+ critical path of every token Cognito mints for this pool: a throw here fails the
163
+ sign-in outright, and failing a login because a *preference* could not be read
164
+ trades a working session for a cosmetic one. The caller lands on full membership —
165
+ their existing privileges, not more — which is the safe direction to fail.
166
+ */
167
+ let storedRoleFor = async (~sub: string, ~table: string): option<string> =>
168
+ try {
169
+ let out = await DynamoDb_DocumentClient.GetCommand.make({
170
+ tableName: table,
171
+ key: Dict.fromArray([("id", JSON.Encode.string(sub))]),
172
+ })->DynamoDb_DocumentClient.GetCommand.send
173
+ out.item
174
+ ->Option.flatMap(JSON.Decode.object)
175
+ ->Option.flatMap(o => o->Dict.get("activeRole"))
176
+ ->Option.flatMap(JSON.Decode.string)
177
+ } catch {
178
+ | _ => None
179
+ }
180
+
181
+ // ── Handler ─────────────────────────────────────────────────────────────────
182
+
183
+ let handler = async (event: event): event => {
184
+ let membership =
185
+ event.request
186
+ ->Option.flatMap(r => r.groupConfiguration)
187
+ ->Option.flatMap(g => g.groupsToOverride)
188
+ ->Option.getOr([])
189
+
190
+ let sub =
191
+ event.request->Option.flatMap(r => r.userAttributes)->Option.flatMap(u => u.sub)->Option.getOr("")
192
+
193
+ // No subject means no row to look up. Returning the event untouched keeps the
194
+ // sign-in working on exactly the membership the pool granted.
195
+ if sub == "" {
196
+ event
197
+ } else {
198
+ let storedRole = await storedRoleFor(~sub, ~table=tableName())
199
+ respond(~event, ~decision=decide(~membership, ~storedRole))
200
+ }
201
+ }
@@ -0,0 +1,126 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
6
+ import * as LibDynamodb from "@aws-sdk/lib-dynamodb";
7
+ import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
8
+ import * as DynamoDb_DocumentClient$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DocumentClient.res.mjs";
9
+
10
+ function getEnv(k) {
11
+ let v = process.env[k];
12
+ if (v !== undefined && v !== "") {
13
+ return v;
14
+ }
15
+ }
16
+
17
+ function tableName() {
18
+ let t = getEnv("ACTIVE_ROLE_TABLE");
19
+ if (t !== undefined) {
20
+ return t;
21
+ } else {
22
+ return Stdlib_JsError.throwWithMessage("ACTIVE_ROLE_TABLE is not configured");
23
+ }
24
+ }
25
+
26
+ function decide(membership, storedRole) {
27
+ if (storedRole !== undefined && storedRole !== "") {
28
+ if (membership.includes(storedRole)) {
29
+ return {
30
+ TAG: "Narrow",
31
+ role: storedRole,
32
+ membership: membership
33
+ };
34
+ } else {
35
+ return {
36
+ TAG: "Stale",
37
+ role: storedRole,
38
+ membership: membership
39
+ };
40
+ }
41
+ } else {
42
+ return "Unchanged";
43
+ }
44
+ }
45
+
46
+ function respond(event, decision) {
47
+ let incoming = Stdlib_Option.flatMap(event.request, r => r.groupConfiguration);
48
+ let iamRolesToOverride = Stdlib_Option.getOr(Stdlib_Option.flatMap(incoming, g => g.iamRolesToOverride), []);
49
+ let preferredRole = Stdlib_Option.flatMap(incoming, g => g.preferredRole);
50
+ let overrideWith = (groups, claims) => {
51
+ let newrecord = {...event};
52
+ newrecord.response = {
53
+ claimsOverrideDetails: {
54
+ claimsToAddOrOverride: claims,
55
+ groupOverrideDetails: {
56
+ groupsToOverride: groups,
57
+ iamRolesToOverride: iamRolesToOverride,
58
+ preferredRole: preferredRole
59
+ }
60
+ }
61
+ };
62
+ return newrecord;
63
+ };
64
+ if (typeof decision !== "object") {
65
+ return event;
66
+ }
67
+ if (decision.TAG === "Narrow") {
68
+ let role = decision.role;
69
+ return overrideWith([role], Object.fromEntries([
70
+ [
71
+ Auth_ActiveRole$ReventlessCore.activeRoleClaim,
72
+ role
73
+ ],
74
+ [
75
+ Auth_ActiveRole$ReventlessCore.availableRolesClaim,
76
+ decision.membership.join(",")
77
+ ]
78
+ ]));
79
+ }
80
+ let membership = decision.membership;
81
+ return overrideWith(membership, Object.fromEntries([
82
+ [
83
+ Auth_ActiveRole$ReventlessCore.staleRoleClaim,
84
+ decision.role
85
+ ],
86
+ [
87
+ Auth_ActiveRole$ReventlessCore.availableRolesClaim,
88
+ membership.join(",")
89
+ ]
90
+ ]));
91
+ }
92
+
93
+ async function storedRoleFor(sub, table) {
94
+ try {
95
+ let out = await DynamoDb_DocumentClient$AwsSdk.GetCommand.send(new LibDynamodb.GetCommand({
96
+ TableName: table,
97
+ Key: Object.fromEntries([[
98
+ "id",
99
+ sub
100
+ ]])
101
+ }));
102
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(out.Item, Stdlib_JSON.Decode.object), o => o["activeRole"]), Stdlib_JSON.Decode.string);
103
+ } catch (exn) {
104
+ return;
105
+ }
106
+ }
107
+
108
+ async function handler(event) {
109
+ let membership = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.groupConfiguration), g => g.groupsToOverride), []);
110
+ let sub = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.userAttributes), u => u.sub), "");
111
+ if (sub === "") {
112
+ return event;
113
+ }
114
+ let storedRole = await storedRoleFor(sub, tableName());
115
+ return respond(event, decide(membership, storedRole));
116
+ }
117
+
118
+ export {
119
+ getEnv,
120
+ tableName,
121
+ decide,
122
+ respond,
123
+ storedRoleFor,
124
+ handler,
125
+ }
126
+ /* @aws-sdk/lib-dynamodb Not a pure module */