@reventlessdev/reventless-core 3.0.0-alpha.231 → 3.0.0-alpha.232
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.232 (2026-08-13)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **aws:** narrow the token to the role a caller chose to act as ([194332f](https://github.com/ReventlessDev/reventless-core/commit/194332fa0af8e13417f72d57904e2ed469747dde))
|
|
11
|
+
|
|
12
|
+
|
|
6
13
|
# 3.0.0-alpha.231 (2026-08-13)
|
|
7
14
|
|
|
8
15
|
**Note:** Version bump only for package @reventlessdev/reventless-core
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.232",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -28,17 +28,17 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"sury": "11.0.0-alpha.4",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
32
31
|
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.5",
|
|
33
32
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
33
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
34
34
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
35
35
|
"@reventlessdev/rescript-node": "2.0.0-alpha.5",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
37
37
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.5",
|
|
38
38
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
39
39
|
"@reventlessdev/reventless-infra": "3.0.0-alpha.139",
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-
|
|
40
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.112",
|
|
41
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.31"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The cross-provider contract for "a caller holding several roles acting as one
|
|
2
|
+
// of them" — see [docs/plans/active-role-narrows-the-token.md].
|
|
3
|
+
//
|
|
4
|
+
// The narrowing itself is minted in two places that cannot share code: the local
|
|
5
|
+
// platform signs its own tokens (`LocalAuth.Login`), and on AWS the minting point
|
|
6
|
+
// is Cognito's pre-token-generation trigger, running in Cognito's runtime against
|
|
7
|
+
// Cognito's event shape. What the two must agree on is exactly what lives here:
|
|
8
|
+
// the claim names a client reads, and the table of cases the subset rule has to
|
|
9
|
+
// satisfy on both paths.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
Claim naming the role the caller chose to act as. Present only on a narrowed
|
|
13
|
+
token, so its presence *is* the answer to "am I acting as one of my roles?".
|
|
14
|
+
|
|
15
|
+
Kept apart from the group claim even though a narrowed token's groups are exactly
|
|
16
|
+
this one role, because the two say different things: groups are what every
|
|
17
|
+
enforcement point evaluates, and this is what the caller asked for.
|
|
18
|
+
*/
|
|
19
|
+
let activeRoleClaim = "activeRole"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
Claim naming the roles the caller gave up by narrowing — their full membership,
|
|
23
|
+
comma-joined.
|
|
24
|
+
|
|
25
|
+
🚨 **Never read this for authorization.** It exists so a client can offer the
|
|
26
|
+
switch back, and it is by definition wider than what the caller is currently
|
|
27
|
+
permitted. Every enforcement point reads the group claim; this is the one piece
|
|
28
|
+
of an identity that deliberately describes privilege the caller does *not*
|
|
29
|
+
currently have.
|
|
30
|
+
*/
|
|
31
|
+
let availableRolesClaim = "availableRoles"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
Claim naming a stored role that was *not* applied because the caller no longer
|
|
35
|
+
holds it.
|
|
36
|
+
|
|
37
|
+
Only the Cognito path can produce this. A stored preference outlives the
|
|
38
|
+
membership that justified it, and the trigger meets that on an ordinary refresh
|
|
39
|
+
with no client asking for anything — so it cannot refuse the way the local path
|
|
40
|
+
refuses a request. It mints the full set instead, and says so here: without this
|
|
41
|
+
claim a caller would be silently un-narrowed with nothing to explain why the role
|
|
42
|
+
they picked stopped taking effect.
|
|
43
|
+
*/
|
|
44
|
+
let staleRoleClaim = "activeRoleStale"
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
The conformance table §6 of the plan requires: the same (membership, requested,
|
|
48
|
+
expected) cases run against both minting paths, so the two implementations cannot
|
|
49
|
+
drift on the question that carries the security.
|
|
50
|
+
|
|
51
|
+
It lives in product code rather than in either package's tests because ReScript
|
|
52
|
+
test directories are `type: dev` and therefore invisible across a package
|
|
53
|
+
boundary — and a table copied into two test suites is exactly the drift it exists
|
|
54
|
+
to prevent.
|
|
55
|
+
|
|
56
|
+
`expected` is the group set the minted token must carry. `None` means the request
|
|
57
|
+
must be **refused** rather than honoured, ignored, or reduced to the empty set —
|
|
58
|
+
the distinction §3 turns on. A path with no client to refuse (the trigger, reading
|
|
59
|
+
stored state) resolves `None` by minting `membership` and marking
|
|
60
|
+
[staleRoleClaim]; a path answering a live request refuses it.
|
|
61
|
+
*/
|
|
62
|
+
type conformanceCase = {
|
|
63
|
+
label: string,
|
|
64
|
+
membership: array<string>,
|
|
65
|
+
requested: option<string>,
|
|
66
|
+
expected: option<array<string>>,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let conformanceCases: array<conformanceCase> = [
|
|
70
|
+
{
|
|
71
|
+
label: "no role requested mints exactly what it always minted",
|
|
72
|
+
membership: ["Fulfilment", "Shopper"],
|
|
73
|
+
requested: None,
|
|
74
|
+
expected: Some(["Fulfilment", "Shopper"]),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
label: "a held role narrows to that role alone",
|
|
78
|
+
membership: ["Fulfilment", "Shopper"],
|
|
79
|
+
requested: Some("Shopper"),
|
|
80
|
+
expected: Some(["Shopper"]),
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
label: "the other held role narrows just as well",
|
|
84
|
+
membership: ["Fulfilment", "Shopper"],
|
|
85
|
+
requested: Some("Fulfilment"),
|
|
86
|
+
expected: Some(["Fulfilment"]),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
label: "a role the caller does not hold is refused, never widened into",
|
|
90
|
+
membership: ["Fulfilment", "Shopper"],
|
|
91
|
+
requested: Some("Admin"),
|
|
92
|
+
expected: None,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: "a sole role narrows to itself rather than being a special case",
|
|
96
|
+
membership: ["Shopper"],
|
|
97
|
+
requested: Some("Shopper"),
|
|
98
|
+
expected: Some(["Shopper"]),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
label: "a caller holding nothing can request nothing",
|
|
102
|
+
membership: [],
|
|
103
|
+
requested: Some("Shopper"),
|
|
104
|
+
expected: None,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
label: "the empty string is a request, and one no caller can hold",
|
|
108
|
+
membership: ["Shopper"],
|
|
109
|
+
requested: Some(""),
|
|
110
|
+
expected: None,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
label: "group names are matched exactly, not case-insensitively",
|
|
114
|
+
membership: ["Shopper"],
|
|
115
|
+
requested: Some("shopper"),
|
|
116
|
+
expected: None,
|
|
117
|
+
},
|
|
118
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
let conformanceCases = [
|
|
5
|
+
{
|
|
6
|
+
label: "no role requested mints exactly what it always minted",
|
|
7
|
+
membership: [
|
|
8
|
+
"Fulfilment",
|
|
9
|
+
"Shopper"
|
|
10
|
+
],
|
|
11
|
+
requested: undefined,
|
|
12
|
+
expected: [
|
|
13
|
+
"Fulfilment",
|
|
14
|
+
"Shopper"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
label: "a held role narrows to that role alone",
|
|
19
|
+
membership: [
|
|
20
|
+
"Fulfilment",
|
|
21
|
+
"Shopper"
|
|
22
|
+
],
|
|
23
|
+
requested: "Shopper",
|
|
24
|
+
expected: ["Shopper"]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
label: "the other held role narrows just as well",
|
|
28
|
+
membership: [
|
|
29
|
+
"Fulfilment",
|
|
30
|
+
"Shopper"
|
|
31
|
+
],
|
|
32
|
+
requested: "Fulfilment",
|
|
33
|
+
expected: ["Fulfilment"]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
label: "a role the caller does not hold is refused, never widened into",
|
|
37
|
+
membership: [
|
|
38
|
+
"Fulfilment",
|
|
39
|
+
"Shopper"
|
|
40
|
+
],
|
|
41
|
+
requested: "Admin",
|
|
42
|
+
expected: undefined
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: "a sole role narrows to itself rather than being a special case",
|
|
46
|
+
membership: ["Shopper"],
|
|
47
|
+
requested: "Shopper",
|
|
48
|
+
expected: ["Shopper"]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
label: "a caller holding nothing can request nothing",
|
|
52
|
+
membership: [],
|
|
53
|
+
requested: "Shopper",
|
|
54
|
+
expected: undefined
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
label: "the empty string is a request, and one no caller can hold",
|
|
58
|
+
membership: ["Shopper"],
|
|
59
|
+
requested: "",
|
|
60
|
+
expected: undefined
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
label: "group names are matched exactly, not case-insensitively",
|
|
64
|
+
membership: ["Shopper"],
|
|
65
|
+
requested: "shopper",
|
|
66
|
+
expected: undefined
|
|
67
|
+
}
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
let activeRoleClaim = "activeRole";
|
|
71
|
+
|
|
72
|
+
let availableRolesClaim = "availableRoles";
|
|
73
|
+
|
|
74
|
+
let staleRoleClaim = "activeRoleStale";
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
activeRoleClaim,
|
|
78
|
+
availableRolesClaim,
|
|
79
|
+
staleRoleClaim,
|
|
80
|
+
conformanceCases,
|
|
81
|
+
}
|
|
82
|
+
/* No side effect */
|
|
@@ -119,6 +119,33 @@ let geocodeTypes = [
|
|
|
119
119
|
|
|
120
120
|
let geocodeQueryFields = [` geocode(text: String!): [GeocodeCandidate!]`]
|
|
121
121
|
|
|
122
|
+
// Acting as one of the roles you hold (docs/plans/active-role-narrows-the-token.md).
|
|
123
|
+
//
|
|
124
|
+
// The write door for the caller's chosen role. Like uploads and geocoding this is
|
|
125
|
+
// an ordinary authenticated-user operation, so it belongs on the **domain** base
|
|
126
|
+
// (default `AllowAuthenticated`), never the Admin-gated admin base — the whole
|
|
127
|
+
// point is that a non-admin can drop into a narrower role.
|
|
128
|
+
//
|
|
129
|
+
// There is deliberately no `sub` argument: the subject is the authorizer-verified
|
|
130
|
+
// `ctx.identity.sub`, so the mutation can only ever address the caller's own row.
|
|
131
|
+
// `activeRole: null` clears the choice and widens back to full membership.
|
|
132
|
+
//
|
|
133
|
+
// 🚨 **Mounted by the AWS platform only.** This is the Cognito path's door —
|
|
134
|
+
// Cognito mints the token, so the choice has to be stored for the pre-token-
|
|
135
|
+
// generation trigger to read on the next refresh. The local platform mints its own
|
|
136
|
+
// tokens and therefore re-mints immediately over `POST /__inmemory/switch-role`
|
|
137
|
+
// (`LocalAuth.Login.reissue`); a mutation there would be a second way to do
|
|
138
|
+
// something already done, with a token the caller would still have to go and
|
|
139
|
+
// fetch. The constants live here with the other platform SDL rather than in the
|
|
140
|
+
// AWS package so every platform-owned field stays in one file.
|
|
141
|
+
let activeRoleTypes = [
|
|
142
|
+
`type Platform_ActiveRole {\n activeRole: String\n availableRoles: [String!]!\n}`,
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
let activeRoleMutationFields = [
|
|
146
|
+
` Platform_SetActiveRole(activeRole: String): Platform_ActiveRole`,
|
|
147
|
+
]
|
|
148
|
+
|
|
122
149
|
let baseFragment = (~cloner: bool) => {
|
|
123
150
|
let base = GraphQL_FragmentGenerator.generate(
|
|
124
151
|
~mutationEntries=mutationEntries(~cloner),
|
|
@@ -86,6 +86,10 @@ let geocodeTypes = [`type GeocodeCandidate {\n label: String!\n lat: Float!\n
|
|
|
86
86
|
|
|
87
87
|
let geocodeQueryFields = [` geocode(text: String!): [GeocodeCandidate!]`];
|
|
88
88
|
|
|
89
|
+
let activeRoleTypes = [`type Platform_ActiveRole {\n activeRole: String\n availableRoles: [String!]!\n}`];
|
|
90
|
+
|
|
91
|
+
let activeRoleMutationFields = [` Platform_SetActiveRole(activeRole: String): Platform_ActiveRole`];
|
|
92
|
+
|
|
89
93
|
function baseFragment(cloner) {
|
|
90
94
|
let base = GraphQL_FragmentGenerator$ReventlessCore.generate(mutationEntries(cloner), PluginBaseFragment$ReventlessCore.queryEntries);
|
|
91
95
|
let parts = GraphQL_Stitcher$ReventlessCore.decode(base);
|
|
@@ -128,6 +132,8 @@ export {
|
|
|
128
132
|
uploadMutationFields,
|
|
129
133
|
geocodeTypes,
|
|
130
134
|
geocodeQueryFields,
|
|
135
|
+
activeRoleTypes,
|
|
136
|
+
activeRoleMutationFields,
|
|
131
137
|
baseFragment,
|
|
132
138
|
}
|
|
133
139
|
/* cloneArgsSchema Not a pure module */
|