@reventlessdev/reventless-spec 3.0.0-alpha.156 → 3.0.0-alpha.157

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.157 (2026-10-01)
7
+
8
+ ### Bug Fixes
9
+
10
+ * check a plugin's roles against the user pool's groups ([ddb3c42](https://github.com/ReventlessDev/reventless-core/commit/ddb3c422b7226a19800e781abd5f03af18867007))
11
+
12
+
6
13
  # 3.0.0-alpha.156 (2026-10-01)
7
14
 
8
15
  * feat!: roles a plugin declares, groups a platform provides ([43daace](https://github.com/ReventlessDev/reventless-core/commit/43daace6ca588afe22562d079af1e6f1da81942e))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.156",
3
+ "version": "3.0.0-alpha.157",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -567,12 +567,17 @@ let renderMain = (~config: Config.config): string => {
567
567
  "module Platform = ReventlessAws.Platform.Make()",
568
568
  "module " ++ name ++ " = Plugin.Make(Platform)",
569
569
  "",
570
- "let default = Platform.deployPlugin(",
571
- " ~plugin=module(" ++ name ++ "),",
572
- ")",
573
- "",
570
+ // The plugin is deployed once the user pool's groups are in, for the check
571
+ // that every role it needs has one, which runs while `deployPlugin` builds
572
+ // it. A promise rather than a top-level await: Pulumi `require`s the
573
+ // program, which Node refuses for a module that awaits at the top level.
574
+ // Pulumi resolves the promise in the stack outputs.
575
+ "let default = ReventlessAws.Platform.loadProvidedGroups()->Promise.thenResolve(() => {",
576
+ " let deployed = Platform.deployPlugin(~plugin=module(" ++ name ++ "))",
574
577
  // PostDeploy runs after the graph is registered (exports, cross-stack output).
575
- "ReventlessInfra.DeployBootstrap.run(PostDeploy)",
578
+ " ReventlessInfra.DeployBootstrap.run(PostDeploy)",
579
+ " deployed",
580
+ "})",
576
581
  "",
577
582
  ]->Array.join("\n")
578
583
  }
@@ -365,11 +365,11 @@ function renderMain(config) {
365
365
  "module Platform = ReventlessAws.Platform.Make()",
366
366
  "module " + name + " = Plugin.Make(Platform)",
367
367
  "",
368
- "let default = Platform.deployPlugin(",
369
- " ~plugin=module(" + name + "),",
370
- ")",
371
- "",
372
- "ReventlessInfra.DeployBootstrap.run(PostDeploy)",
368
+ "let default = ReventlessAws.Platform.loadProvidedGroups()->Promise.thenResolve(() => {",
369
+ " let deployed = Platform.deployPlugin(~plugin=module(" + name + "))",
370
+ " ReventlessInfra.DeployBootstrap.run(PostDeploy)",
371
+ " deployed",
372
+ "})",
373
373
  ""
374
374
  ].join("\n");
375
375
  }
@@ -107,9 +107,9 @@ let groupOf = (role: name): string =>
107
107
  let adminGroup = (): string => groupOf(admin)
108
108
 
109
109
  /**
110
- Where the groups this deployment provides come from: the administrator group and
111
- accounts manifest a platform finds for itself, and the groups of a user pool it
112
- did not create, which the platform root declares.
110
+ Where the groups this deployment provides come from: what the identity provider
111
+ reports where the platform can ask it, else the administrator group and accounts
112
+ manifest a platform finds for itself and the groups a platform root declares.
113
113
 
114
114
  Sources rather than values, read when the check runs: a platform states them
115
115
  while its functor is applied, before the root has said how its roles map. Empty
@@ -118,26 +118,61 @@ has an answer to compare against.
118
118
  */
119
119
  let groupSources: ref<array<unit => array<string>>> = ref([])
120
120
 
121
+ /** The groups a root declared with `provideGroups`, kept apart so a listing can
122
+ contradict them. */
123
+ let declaredGroups: ref<array<string>> = ref([])
124
+
125
+ /** The groups the identity provider itself reported, and which provider that was. */
126
+ type listing = {source: string, groups: array<string>}
127
+
128
+ let listing: ref<option<listing>> = ref(None)
129
+
121
130
  let provideGroupsFrom = (source: unit => array<string>) =>
122
131
  groupSources := groupSources.contents->Array.concat([source])
123
132
 
124
- let provideGroups = (groups: array<string>) => provideGroupsFrom(() => groups)
133
+ let provideGroups = (groups: array<string>) => {
134
+ declaredGroups := declaredGroups.contents->Array.concat(groups)
135
+ provideGroupsFrom(() => groups)
136
+ }
137
+
138
+ /**
139
+ Record what the identity provider says it has. A platform that can ask (the AWS
140
+ user pool) does, before its plugins are built. The listing is then the whole
141
+ answer: a group a manifest names, a root declares or the mapping points to is
142
+ provided only if the provider has it, since a group it lacks refuses everybody.
143
+ */
144
+ let provideListedGroups = (~source: string, groups: array<string>) =>
145
+ listing := Some({source, groups})
146
+
147
+ let clearProvidedGroups = () => {
148
+ groupSources := []
149
+ declaredGroups := []
150
+ listing := None
151
+ }
125
152
 
126
- let clearProvidedGroups = () => groupSources := []
153
+ let _distinct = (groups: array<string>) => groups->Set.fromArray->Set.values->Array.fromIterator
127
154
 
128
- /** The groups a deployment provides: every source plus every group the mapping
129
- names. `None` when nothing was provided — a process that is not a platform (a
130
- unit test, a function runtime) has no answer to give. */
155
+ /** The groups a deployment provides: the provider's listing where there is one,
156
+ else every source plus every group the mapping names. `None` when nothing was
157
+ provided — a process that is not a platform (a unit test, a function runtime)
158
+ has no answer to give. */
131
159
  let providedGroups = (): option<array<string>> =>
132
- switch groupSources.contents {
133
- | [] => None
134
- | sources =>
160
+ switch (listing.contents, groupSources.contents) {
161
+ | (Some({groups}), _) => Some(_distinct(groups))
162
+ | (None, []) => None
163
+ | (None, sources) =>
135
164
  Some(
136
165
  sources
137
166
  ->Array.flatMap(source => source())
138
167
  ->Array.concat(renamed()->Array.map(((_, group)) => group))
139
- ->Set.fromArray
140
- ->Set.values
141
- ->Array.fromIterator,
168
+ ->_distinct,
142
169
  )
143
170
  }
171
+
172
+ /** The declared groups the provider's listing does not have. Empty without a
173
+ listing, when there is nothing to contradict them. */
174
+ let declaredButMissing = (): array<string> =>
175
+ switch listing.contents {
176
+ | None => []
177
+ | Some({groups}) => declaredGroups.contents->Array.filter(g => !(groups->Array.includes(g)))
178
+ }
@@ -85,23 +85,60 @@ let groupSources = {
85
85
  contents: []
86
86
  };
87
87
 
88
+ let declaredGroups = {
89
+ contents: []
90
+ };
91
+
92
+ let listing = {
93
+ contents: undefined
94
+ };
95
+
88
96
  function provideGroupsFrom(source) {
89
97
  groupSources.contents = groupSources.contents.concat([source]);
90
98
  }
91
99
 
92
100
  function provideGroups(groups) {
101
+ declaredGroups.contents = declaredGroups.contents.concat(groups);
93
102
  provideGroupsFrom(() => groups);
94
103
  }
95
104
 
105
+ function provideListedGroups(source, groups) {
106
+ listing.contents = {
107
+ source: source,
108
+ groups: groups
109
+ };
110
+ }
111
+
96
112
  function clearProvidedGroups() {
97
113
  groupSources.contents = [];
114
+ declaredGroups.contents = [];
115
+ listing.contents = undefined;
116
+ }
117
+
118
+ function _distinct(groups) {
119
+ return Array.from(new Set(groups).values());
98
120
  }
99
121
 
100
122
  function providedGroups() {
101
- let sources = groupSources.contents;
102
- if (sources.length !== 0) {
103
- return Array.from(new Set(sources.flatMap(source => source()).concat(renamed().map(param => param[1]))).values());
123
+ let match = listing.contents;
124
+ let match$1 = groupSources.contents;
125
+ if (match !== undefined) {
126
+ return Array.from(new Set(match.groups).values());
127
+ }
128
+ if (match$1.length === 0) {
129
+ return;
130
+ }
131
+ let groups = match$1.flatMap(source => source()).concat(renamed().map(param => param[1]));
132
+ return Array.from(new Set(groups).values());
133
+ }
134
+
135
+ function declaredButMissing() {
136
+ let match = listing.contents;
137
+ if (match === undefined) {
138
+ return [];
104
139
  }
140
+ let groups = match.groups;
141
+ return declaredGroups.contents.filter(g => !groups.includes(g));
105
142
  }
106
143
 
107
144
  let envKey = "REVENTLESS_ROLE_GROUPS";
@@ -121,9 +158,14 @@ export {
121
158
  groupOf,
122
159
  adminGroup,
123
160
  groupSources,
161
+ declaredGroups,
162
+ listing,
124
163
  provideGroupsFrom,
125
164
  provideGroups,
165
+ provideListedGroups,
126
166
  clearProvidedGroups,
167
+ _distinct,
127
168
  providedGroups,
169
+ declaredButMissing,
128
170
  }
129
171
  /* nameSchema Not a pure module */
@@ -4,9 +4,9 @@ Whether a platform can provide every role a plugin needs.
4
4
  A rule naming a role no group stands for refuses everybody it was written for, and
5
5
  nothing says so until someone is refused on a deployed stack. So every role a
6
6
  component's rule names, and every elevated role, must map to a group the platform
7
- provides: the administrator group it declares, the groups of its accounts
8
- manifest, the groups the role mapping names, and any a root declares for a user
9
- pool it did not create.
7
+ provides: the groups its identity provider reports where the platform can ask it,
8
+ else the administrator group, the accounts manifest, the role mapping and any
9
+ groups a root declares (see `Role.providedGroups`).
10
10
  */
11
11
 
12
12
  type need = {
@@ -55,12 +55,37 @@ let describe = (n: need): string => {
55
55
  : `${n.plugin}: ${n.item} needs the role ${role} (mapped to the group ${n.group})`
56
56
  }
57
57
 
58
- let unmetMessage = (missing: array<need>, ~provided: array<string>): string =>
58
+ /** What to do about it depends on where the provided groups came from: a
59
+ provider that was asked can only be given the group, while a manifest or a
60
+ declaration can be corrected. */
61
+ let remedy = (~provided: array<string>, ~listing: option<Role.listing>): string =>
62
+ switch listing {
63
+ | Some({source}) =>
64
+ let source = source->String.charAt(0)->String.toUpperCase ++ source->String.slice(~start=1)
65
+ ` ${source} has the groups: ${provided->Array.join(", ")}.\n` ++
66
+ ` Create the group there (provision-accounts creates the groups of the accounts ` ++ `manifest), or map the role to a group it has (Platform.roleGroups).`
67
+ | None =>
68
+ ` The platform provides the groups: ${provided->Array.join(", ")}.\n` ++
69
+ ` Add an account in that group to the accounts manifest (.reventless/users.yaml), ` ++
70
+ `map the role to a group that exists (Platform.roleGroups), or declare the groups ` ++ `of a user pool this deployment did not create (Platform.providedGroups).`
71
+ }
72
+
73
+ let unmetMessage = (
74
+ missing: array<need>,
75
+ ~provided: array<string>,
76
+ ~listing: option<Role.listing>=?,
77
+ ): string =>
59
78
  `Roles this platform cannot provide — every caller would be refused:\n` ++
60
79
  missing->Array.map(n => ` ${describe(n)}`)->Array.join("\n") ++
61
- `\n The platform provides the groups: ${provided->Array.join(", ")}.\n` ++
62
- ` Add an account in that group to the accounts manifest (.reventless/users.yaml), ` ++
63
- `map the role to a group that exists (Platform.roleGroups), or declare the groups ` ++ `of a user pool this deployment did not create (Platform.providedGroups).`
80
+ "\n" ++
81
+ remedy(~provided, ~listing)
82
+
83
+ /** A declared group the provider says it does not have: the declaration is wrong,
84
+ whether or not a plugin needs the group today. */
85
+ let contradictedMessage = (groups: array<string>, ~listing: Role.listing): string =>
86
+ `Groups declared with Platform.providedGroups that ${listing.source} does not have: ` ++
87
+ `${groups->Array.join(", ")}.\n` ++
88
+ ` It has: ${listing.groups->Array.join(", ")}. Create them there or remove the declaration.`
64
89
 
65
90
  /**
66
91
  Refuse a plugin needing a role nobody provides, and a deployment electing a role
@@ -76,9 +101,15 @@ let check = (structure: Plugin.pluginStructure, ~plugin: string): unit =>
76
101
  switch Role.providedGroups() {
77
102
  | None => ()
78
103
  | Some(provided) =>
104
+ let listing = Role.listing.contents
105
+ switch (listing, Role.declaredButMissing()) {
106
+ | (Some(listing), groups) if groups != [] =>
107
+ JsError.throwWithMessage(contradictedMessage(groups, ~listing))
108
+ | _ => ()
109
+ }
79
110
  let elevated = OwnerScope.explicitElevatedRoles.contents->Option.getOr([])
80
111
  switch unmet(needsOf(structure, ~plugin)->Array.concat(elevatedNeeds(elevated)), ~provided) {
81
112
  | [] => ()
82
- | missing => JsError.throwWithMessage(unmetMessage(missing, ~provided))
113
+ | missing => JsError.throwWithMessage(unmetMessage(missing, ~provided, ~listing?))
83
114
  }
84
115
  }
@@ -3,6 +3,7 @@
3
3
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
4
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
5
5
  import * as Role$Reventless from "./Role.res.mjs";
6
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
6
7
  import * as OwnerScope$Reventless from "./OwnerScope.res.mjs";
7
8
 
8
9
  function needsOf(structure, plugin) {
@@ -45,8 +46,21 @@ function describe(n) {
45
46
  }
46
47
  }
47
48
 
48
- function unmetMessage(missing, provided) {
49
- return `Roles this platform cannot provide — every caller would be refused:\n` + missing.map(n => ` ` + describe(n)).join("\n") + (`\n The platform provides the groups: ` + provided.join(", ") + `.\n`) + ` Add an account in that group to the accounts manifest (.reventless/users.yaml), map the role to a group that exists (Platform.roleGroups), or declare the groups of a user pool this deployment did not create (Platform.providedGroups).`;
49
+ function remedy(provided, listing) {
50
+ if (listing === undefined) {
51
+ return ` The platform provides the groups: ` + provided.join(", ") + `.\n Add an account in that group to the accounts manifest (.reventless/users.yaml), map the role to a group that exists (Platform.roleGroups), or declare the groups of a user pool this deployment did not create (Platform.providedGroups).`;
52
+ }
53
+ let source = listing.source;
54
+ let source$1 = source.charAt(0).toUpperCase() + source.slice(1);
55
+ return ` ` + source$1 + ` has the groups: ` + provided.join(", ") + `.\n Create the group there (provision-accounts creates the groups of the accounts manifest), or map the role to a group it has (Platform.roleGroups).`;
56
+ }
57
+
58
+ function unmetMessage(missing, provided, listing) {
59
+ return `Roles this platform cannot provide — every caller would be refused:\n` + missing.map(n => ` ` + describe(n)).join("\n") + "\n" + remedy(provided, listing);
60
+ }
61
+
62
+ function contradictedMessage(groups, listing) {
63
+ return `Groups declared with Platform.providedGroups that ` + listing.source + ` does not have: ` + (groups.join(", ") + `.\n`) + (` It has: ` + listing.groups.join(", ") + `. Create them there or remove the declaration.`);
50
64
  }
51
65
 
52
66
  function check(structure, plugin) {
@@ -54,10 +68,15 @@ function check(structure, plugin) {
54
68
  if (provided === undefined) {
55
69
  return;
56
70
  }
71
+ let listing = Role$Reventless.listing.contents;
72
+ let match = Role$Reventless.declaredButMissing();
73
+ if (listing !== undefined && Primitive_object.notequal(match, [])) {
74
+ Stdlib_JsError.throwWithMessage(contradictedMessage(match, listing));
75
+ }
57
76
  let elevated = Stdlib_Option.getOr(OwnerScope$Reventless.explicitElevatedRoles.contents, []);
58
77
  let missing = unmet(needsOf(structure, plugin).concat(elevatedNeeds(elevated)), provided);
59
78
  if (missing.length !== 0) {
60
- return Stdlib_JsError.throwWithMessage(unmetMessage(missing, provided));
79
+ return Stdlib_JsError.throwWithMessage(unmetMessage(missing, provided, listing));
61
80
  }
62
81
  }
63
82
 
@@ -66,7 +85,9 @@ export {
66
85
  elevatedNeeds,
67
86
  unmet,
68
87
  describe,
88
+ remedy,
69
89
  unmetMessage,
90
+ contradictedMessage,
70
91
  check,
71
92
  }
72
93
  /* Role-Reventless Not a pure module */