@reventlessdev/reventless-seed 1.0.0-alpha.19 → 1.0.0-alpha.20
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 +7 -0
- package/lib/bs/.compiler.log +2 -2
- package/lib/bs/compiler-info.json +1 -1
- package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Client.ast +0 -0
- package/lib/bs/src/Seed_Client.res +108 -1
- package/lib/bs/src/Seed_Client.res.mjs +103 -1
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Connect.ast +0 -0
- package/lib/bs/src/Seed_Connect.res +7 -0
- package/lib/bs/src/Seed_Connect.res.mjs +4 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
- package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
- package/lib/bs/tests/Seed_ClientTest.ast +0 -0
- package/lib/bs/tests/Seed_ClientTest.res +98 -0
- package/lib/bs/tests/Seed_ClientTest.res.mjs +103 -0
- package/lib/ocaml/.compiler.log +2 -2
- package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Client.ast +0 -0
- package/lib/ocaml/Seed_Client.res +108 -1
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
- package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_ClientTest.ast +0 -0
- package/lib/ocaml/Seed_ClientTest.res +98 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Connect.ast +0 -0
- package/lib/ocaml/Seed_Connect.res +7 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
- package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
- package/package.json +2 -2
- package/src/Seed_Client.res +108 -1
- package/src/Seed_Client.res.mjs +103 -1
- package/src/Seed_Connect.res +7 -0
- package/src/Seed_Connect.res.mjs +4 -0
- package/tests/Seed_ClientTest.res +98 -0
- package/tests/Seed_ClientTest.res.mjs +103 -0
|
@@ -52,6 +52,87 @@ let endpoint = (t: t): string => t.config.endpoint
|
|
|
52
52
|
token is a valid bearer for `endpoint`. */
|
|
53
53
|
let useToken = (t: t, token: string): unit => t.token = Some(token)
|
|
54
54
|
|
|
55
|
+
// ── What the bearer actually grants ─────────────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// An account's listed groups are not necessarily the ones its token carries:
|
|
58
|
+
// both platforms narrow a token to the single role the caller last chose to act
|
|
59
|
+
// as. So a seed can log in as an account that looks eligible and be refused, and
|
|
60
|
+
// the account list cannot explain it — only the token can.
|
|
61
|
+
|
|
62
|
+
/** Claim names a narrowed token carries. These mirror the cross-provider
|
|
63
|
+
contract in `ReventlessCore.Auth_ActiveRole`; spelled out rather than
|
|
64
|
+
imported because this harness deliberately depends on no framework package. */
|
|
65
|
+
let activeRoleClaim = "activeRole"
|
|
66
|
+
let availableRolesClaim = "availableRoles"
|
|
67
|
+
|
|
68
|
+
/** Group claim per provider, tried in order. */
|
|
69
|
+
let groupClaimNames = ["cognito:groups", "groups"]
|
|
70
|
+
|
|
71
|
+
let decodeSegment = (segment: string): option<dict<JSON.t>> =>
|
|
72
|
+
try {
|
|
73
|
+
segment
|
|
74
|
+
->NodeBuffer.fromStringBase64Url
|
|
75
|
+
->NodeBuffer.toStringUtf8
|
|
76
|
+
->JSON.parseOrThrow
|
|
77
|
+
->JSON.Decode.object
|
|
78
|
+
} catch {
|
|
79
|
+
| _ => None
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The bearer's payload, from whichever segment carries it — a Cognito JWT puts
|
|
83
|
+
it second and a local dev token first, so trying each in turn saves this from
|
|
84
|
+
having to know which platform signed the token. A segment that is not a JSON
|
|
85
|
+
object (a signature) simply does not decode. */
|
|
86
|
+
let claims = (t: t): option<dict<JSON.t>> =>
|
|
87
|
+
t.token->Option.flatMap(token =>
|
|
88
|
+
token
|
|
89
|
+
->String.split(".")
|
|
90
|
+
->Array.reduce(None, (found, segment) =>
|
|
91
|
+
switch found {
|
|
92
|
+
| Some(_) => found
|
|
93
|
+
| None => decodeSegment(segment)
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// Arrays on the group claim, comma-joined on `availableRoles`.
|
|
99
|
+
let asStrings = (value: JSON.t): option<array<string>> =>
|
|
100
|
+
switch value {
|
|
101
|
+
| Array(entries) => Some(entries->Array.filterMap(JSON.Decode.string))
|
|
102
|
+
| String(joined) =>
|
|
103
|
+
Some(joined->String.split(",")->Array.map(String.trim)->Array.filter(s => s != ""))
|
|
104
|
+
| _ => None
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let claimStrings = (t: t, name: string): option<array<string>> =>
|
|
108
|
+
t->claims->Option.flatMap(c => c->Dict.get(name))->Option.flatMap(asStrings)
|
|
109
|
+
|
|
110
|
+
/** The groups the bearer actually presents — what every enforcement point reads.
|
|
111
|
+
`None` when the token is opaque to us, which is not an error: it just means
|
|
112
|
+
this cannot add anything. */
|
|
113
|
+
let effectiveGroups = (t: t): option<array<string>> =>
|
|
114
|
+
groupClaimNames->Array.reduce(None, (found, name) =>
|
|
115
|
+
switch found {
|
|
116
|
+
| Some(_) => found
|
|
117
|
+
| None => t->claimStrings(name)
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
/** One line naming the identity the bearer carries, and — when it was narrowed —
|
|
122
|
+
the membership it was narrowed from. The second half is the point: without it
|
|
123
|
+
a refusal looks like a misconfigured grant rather than a role switch. */
|
|
124
|
+
let identitySummary = (t: t): option<string> =>
|
|
125
|
+
t
|
|
126
|
+
->effectiveGroups
|
|
127
|
+
->Option.map(groups => {
|
|
128
|
+
let held = groups->Array.length == 0 ? "no groups" : groups->Array.join(", ")
|
|
129
|
+
switch t->claimStrings(availableRolesClaim) {
|
|
130
|
+
| Some(available) if available->Array.length > 0 =>
|
|
131
|
+
`${held} — narrowed to this role from ${available->Array.join(", ")}`
|
|
132
|
+
| _ => held
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
55
136
|
// ── JSON helpers ────────────────────────────────────────────────────────────
|
|
56
137
|
|
|
57
138
|
let field = (json: JSON.t, key: string): option<JSON.t> =>
|
|
@@ -138,6 +219,21 @@ let isTransient = (errors: JSON.t): bool =>
|
|
|
138
219
|
)
|
|
139
220
|
}
|
|
140
221
|
|
|
222
|
+
/** An authorization refusal, in either platform's vocabulary — AppSync answers
|
|
223
|
+
`errorType: "Unauthorized"`, the local server carries it in the message. */
|
|
224
|
+
let isDenied = (errors: JSON.t): bool =>
|
|
225
|
+
switch errors->JSON.Decode.array {
|
|
226
|
+
| None => false
|
|
227
|
+
| Some(entries) =>
|
|
228
|
+
entries->Array.some(e =>
|
|
229
|
+
switch (e->nodeString("errorType"), e->nodeString("message")) {
|
|
230
|
+
| (Some(errorType), _) if errorType->String.includes("Unauthorized") => true
|
|
231
|
+
| (_, Some(message)) => message->String.includes("Not Authorized")
|
|
232
|
+
| _ => false
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
141
237
|
let attempts = 4
|
|
142
238
|
|
|
143
239
|
/**
|
|
@@ -180,8 +276,19 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
|
|
|
180
276
|
await attempt(n + 1)
|
|
181
277
|
} else {
|
|
182
278
|
let tried = n > 1 ? `\n gave up after ${n->Int.toString} attempts` : ""
|
|
279
|
+
// A refusal is the one failure the response cannot explain on its own:
|
|
280
|
+
// it names the field, never the caller. The account list can't either —
|
|
281
|
+
// it holds membership, and a narrowed token carries less.
|
|
282
|
+
let identity = switch isDenied(errors) ? t->identitySummary : None {
|
|
283
|
+
| Some(summary) => `\n identity: ${summary}`
|
|
284
|
+
| None => ""
|
|
285
|
+
}
|
|
183
286
|
throw(
|
|
184
|
-
Failed(
|
|
287
|
+
Failed(
|
|
288
|
+
`${label} failed\n query: ${query}\n response: ${JSON.stringify(
|
|
289
|
+
errors,
|
|
290
|
+
)}${identity}${tried}`,
|
|
291
|
+
),
|
|
185
292
|
)
|
|
186
293
|
}
|
|
187
294
|
| None => json->field("data")->Option.getOr(JSON.Encode.null)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -57,3 +57,101 @@ describe("Seed_Client.isTransient:", () => {
|
|
|
57
57
|
expect(Seed_Client.isTransient(JSON.Encode.null))->toBe(false)
|
|
58
58
|
})
|
|
59
59
|
})
|
|
60
|
+
|
|
61
|
+
// An account's listed groups are not what authorizes it — the token's are. Both
|
|
62
|
+
// platforms narrow a token to the single role the caller last chose, so a seed
|
|
63
|
+
// can log in as an account the user list shows as `[Admin, Shopper]` and present
|
|
64
|
+
// a token carrying `Shopper` alone. The refusal that follows names the field and
|
|
65
|
+
// not the caller, and the account list cannot explain it either. These pin the
|
|
66
|
+
// reading-back that turns that into a sentence.
|
|
67
|
+
|
|
68
|
+
let b64url = payload =>
|
|
69
|
+
payload
|
|
70
|
+
->NodeBuffer.fromStringUtf8
|
|
71
|
+
->NodeBuffer.toStringBase64Url
|
|
72
|
+
|
|
73
|
+
let jwt = claims =>
|
|
74
|
+
`header.${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
|
|
75
|
+
|
|
76
|
+
let localToken = claims =>
|
|
77
|
+
`${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
|
|
78
|
+
|
|
79
|
+
let strings = values => JSON.Encode.array(values->Array.map(JSON.Encode.string))
|
|
80
|
+
|
|
81
|
+
let clientWith = token => {
|
|
82
|
+
let c = Seed_Client.make(~config={endpoint: "http://example.invalid/graphql"})
|
|
83
|
+
c->Seed_Client.useToken(token)
|
|
84
|
+
c
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("Seed_Client.effectiveGroups:", () => {
|
|
88
|
+
// A Cognito id token carries its payload in the second segment, a local dev
|
|
89
|
+
// token in the first. Neither platform should have to announce which it is.
|
|
90
|
+
testSync("reads the payload wherever the provider put it", () => {
|
|
91
|
+
expect(
|
|
92
|
+
clientWith(jwt([("cognito:groups", strings(["Admin", "Shopper"]))]))->Seed_Client.effectiveGroups,
|
|
93
|
+
)->toEqual(Some(["Admin", "Shopper"]))
|
|
94
|
+
expect(
|
|
95
|
+
clientWith(localToken([("groups", strings(["Merchandiser"]))]))->Seed_Client.effectiveGroups,
|
|
96
|
+
)->toEqual(Some(["Merchandiser"]))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// An opaque bearer is not an error — it just means this can add nothing, and
|
|
100
|
+
// the failure reports what it always did.
|
|
101
|
+
testSync("says nothing about a token it cannot read", () => {
|
|
102
|
+
expect(clientWith("not-a-token")->Seed_Client.effectiveGroups)->toEqual(None)
|
|
103
|
+
expect(
|
|
104
|
+
Seed_Client.make(~config={endpoint: "http://example.invalid/graphql"})
|
|
105
|
+
->Seed_Client.effectiveGroups,
|
|
106
|
+
)->toEqual(None)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe("Seed_Client.identitySummary:", () => {
|
|
111
|
+
// The case that cost the debugging: eligible in the user list, refused by the
|
|
112
|
+
// token. Naming what it was narrowed FROM is what points at the role switch.
|
|
113
|
+
testSync("names the membership a narrowed token gave up", () =>
|
|
114
|
+
expect(
|
|
115
|
+
clientWith(
|
|
116
|
+
jwt([
|
|
117
|
+
("cognito:groups", strings(["Shopper"])),
|
|
118
|
+
("availableRoles", JSON.Encode.string("Admin,Shopper")),
|
|
119
|
+
]),
|
|
120
|
+
)->Seed_Client.identitySummary,
|
|
121
|
+
)->toEqual(Some("Shopper — narrowed to this role from Admin, Shopper"))
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
// An ordinary login is not narrowed and has nothing to explain.
|
|
125
|
+
testSync("stays quiet about an unnarrowed token", () =>
|
|
126
|
+
expect(
|
|
127
|
+
clientWith(jwt([("cognito:groups", strings(["Admin", "Shopper"]))]))
|
|
128
|
+
->Seed_Client.identitySummary,
|
|
129
|
+
)->toEqual(Some("Admin, Shopper"))
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
// A token granting nothing reads as a sentence rather than an empty string.
|
|
133
|
+
testSync("says so when the token carries no groups at all", () =>
|
|
134
|
+
expect(
|
|
135
|
+
clientWith(jwt([("cognito:groups", strings([]))]))->Seed_Client.identitySummary,
|
|
136
|
+
)->toEqual(Some("no groups"))
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe("Seed_Client.isDenied:", () => {
|
|
141
|
+
// AppSync types it; the local server carries it in the message.
|
|
142
|
+
testSync("recognises a refusal in either platform's vocabulary", () => {
|
|
143
|
+
expect(Seed_Client.isDenied(errors([errorOfType("Unauthorized")])))->toBe(true)
|
|
144
|
+
expect(
|
|
145
|
+
Seed_Client.isDenied(
|
|
146
|
+
errors([Dict.fromArray([("message", JSON.Encode.string("Not Authorized to access X"))])]),
|
|
147
|
+
),
|
|
148
|
+
)->toBe(true)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// Attaching an identity to an unrelated failure would point the reader at the
|
|
152
|
+
// wrong thing.
|
|
153
|
+
testSync("leaves every other failure alone", () => {
|
|
154
|
+
expect(Seed_Client.isDenied(errors([errorOfType("ValidationException")])))->toBe(false)
|
|
155
|
+
expect(Seed_Client.isDenied(errors([])))->toBe(false)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -38,6 +38,13 @@ let make = async (
|
|
|
38
38
|
let token = await login(~username, ~password)
|
|
39
39
|
let client = Seed_Client.make(~config={endpoint: endpoint})
|
|
40
40
|
client->Seed_Client.useToken(token)
|
|
41
|
+
// What the bearer grants, which is not always what the account list showed: a
|
|
42
|
+
// token narrowed to one role presents that role alone. Said at login so the
|
|
43
|
+
// run starts from the identity it will actually be refused or served under.
|
|
44
|
+
switch Seed_Client.identitySummary(client) {
|
|
45
|
+
| Some(summary) => Console.log(`Acting as: ${summary}`)
|
|
46
|
+
| None => ()
|
|
47
|
+
}
|
|
41
48
|
{client, uploadsSkipped, label}
|
|
42
49
|
}
|
|
43
50
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-seed",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.20",
|
|
4
4
|
"description": "Reusable harness for seeding a Reventless platform through its public GraphQL command API",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"yaml": "^2.8.3",
|
|
8
|
-
"@reventlessdev/rescript-node": "2.0.0-alpha.
|
|
8
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.10"
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"rescript": "12.3.0",
|
package/src/Seed_Client.res
CHANGED
|
@@ -52,6 +52,87 @@ let endpoint = (t: t): string => t.config.endpoint
|
|
|
52
52
|
token is a valid bearer for `endpoint`. */
|
|
53
53
|
let useToken = (t: t, token: string): unit => t.token = Some(token)
|
|
54
54
|
|
|
55
|
+
// ── What the bearer actually grants ─────────────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// An account's listed groups are not necessarily the ones its token carries:
|
|
58
|
+
// both platforms narrow a token to the single role the caller last chose to act
|
|
59
|
+
// as. So a seed can log in as an account that looks eligible and be refused, and
|
|
60
|
+
// the account list cannot explain it — only the token can.
|
|
61
|
+
|
|
62
|
+
/** Claim names a narrowed token carries. These mirror the cross-provider
|
|
63
|
+
contract in `ReventlessCore.Auth_ActiveRole`; spelled out rather than
|
|
64
|
+
imported because this harness deliberately depends on no framework package. */
|
|
65
|
+
let activeRoleClaim = "activeRole"
|
|
66
|
+
let availableRolesClaim = "availableRoles"
|
|
67
|
+
|
|
68
|
+
/** Group claim per provider, tried in order. */
|
|
69
|
+
let groupClaimNames = ["cognito:groups", "groups"]
|
|
70
|
+
|
|
71
|
+
let decodeSegment = (segment: string): option<dict<JSON.t>> =>
|
|
72
|
+
try {
|
|
73
|
+
segment
|
|
74
|
+
->NodeBuffer.fromStringBase64Url
|
|
75
|
+
->NodeBuffer.toStringUtf8
|
|
76
|
+
->JSON.parseOrThrow
|
|
77
|
+
->JSON.Decode.object
|
|
78
|
+
} catch {
|
|
79
|
+
| _ => None
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The bearer's payload, from whichever segment carries it — a Cognito JWT puts
|
|
83
|
+
it second and a local dev token first, so trying each in turn saves this from
|
|
84
|
+
having to know which platform signed the token. A segment that is not a JSON
|
|
85
|
+
object (a signature) simply does not decode. */
|
|
86
|
+
let claims = (t: t): option<dict<JSON.t>> =>
|
|
87
|
+
t.token->Option.flatMap(token =>
|
|
88
|
+
token
|
|
89
|
+
->String.split(".")
|
|
90
|
+
->Array.reduce(None, (found, segment) =>
|
|
91
|
+
switch found {
|
|
92
|
+
| Some(_) => found
|
|
93
|
+
| None => decodeSegment(segment)
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// Arrays on the group claim, comma-joined on `availableRoles`.
|
|
99
|
+
let asStrings = (value: JSON.t): option<array<string>> =>
|
|
100
|
+
switch value {
|
|
101
|
+
| Array(entries) => Some(entries->Array.filterMap(JSON.Decode.string))
|
|
102
|
+
| String(joined) =>
|
|
103
|
+
Some(joined->String.split(",")->Array.map(String.trim)->Array.filter(s => s != ""))
|
|
104
|
+
| _ => None
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let claimStrings = (t: t, name: string): option<array<string>> =>
|
|
108
|
+
t->claims->Option.flatMap(c => c->Dict.get(name))->Option.flatMap(asStrings)
|
|
109
|
+
|
|
110
|
+
/** The groups the bearer actually presents — what every enforcement point reads.
|
|
111
|
+
`None` when the token is opaque to us, which is not an error: it just means
|
|
112
|
+
this cannot add anything. */
|
|
113
|
+
let effectiveGroups = (t: t): option<array<string>> =>
|
|
114
|
+
groupClaimNames->Array.reduce(None, (found, name) =>
|
|
115
|
+
switch found {
|
|
116
|
+
| Some(_) => found
|
|
117
|
+
| None => t->claimStrings(name)
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
/** One line naming the identity the bearer carries, and — when it was narrowed —
|
|
122
|
+
the membership it was narrowed from. The second half is the point: without it
|
|
123
|
+
a refusal looks like a misconfigured grant rather than a role switch. */
|
|
124
|
+
let identitySummary = (t: t): option<string> =>
|
|
125
|
+
t
|
|
126
|
+
->effectiveGroups
|
|
127
|
+
->Option.map(groups => {
|
|
128
|
+
let held = groups->Array.length == 0 ? "no groups" : groups->Array.join(", ")
|
|
129
|
+
switch t->claimStrings(availableRolesClaim) {
|
|
130
|
+
| Some(available) if available->Array.length > 0 =>
|
|
131
|
+
`${held} — narrowed to this role from ${available->Array.join(", ")}`
|
|
132
|
+
| _ => held
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
55
136
|
// ── JSON helpers ────────────────────────────────────────────────────────────
|
|
56
137
|
|
|
57
138
|
let field = (json: JSON.t, key: string): option<JSON.t> =>
|
|
@@ -138,6 +219,21 @@ let isTransient = (errors: JSON.t): bool =>
|
|
|
138
219
|
)
|
|
139
220
|
}
|
|
140
221
|
|
|
222
|
+
/** An authorization refusal, in either platform's vocabulary — AppSync answers
|
|
223
|
+
`errorType: "Unauthorized"`, the local server carries it in the message. */
|
|
224
|
+
let isDenied = (errors: JSON.t): bool =>
|
|
225
|
+
switch errors->JSON.Decode.array {
|
|
226
|
+
| None => false
|
|
227
|
+
| Some(entries) =>
|
|
228
|
+
entries->Array.some(e =>
|
|
229
|
+
switch (e->nodeString("errorType"), e->nodeString("message")) {
|
|
230
|
+
| (Some(errorType), _) if errorType->String.includes("Unauthorized") => true
|
|
231
|
+
| (_, Some(message)) => message->String.includes("Not Authorized")
|
|
232
|
+
| _ => false
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
141
237
|
let attempts = 4
|
|
142
238
|
|
|
143
239
|
/**
|
|
@@ -180,8 +276,19 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
|
|
|
180
276
|
await attempt(n + 1)
|
|
181
277
|
} else {
|
|
182
278
|
let tried = n > 1 ? `\n gave up after ${n->Int.toString} attempts` : ""
|
|
279
|
+
// A refusal is the one failure the response cannot explain on its own:
|
|
280
|
+
// it names the field, never the caller. The account list can't either —
|
|
281
|
+
// it holds membership, and a narrowed token carries less.
|
|
282
|
+
let identity = switch isDenied(errors) ? t->identitySummary : None {
|
|
283
|
+
| Some(summary) => `\n identity: ${summary}`
|
|
284
|
+
| None => ""
|
|
285
|
+
}
|
|
183
286
|
throw(
|
|
184
|
-
Failed(
|
|
287
|
+
Failed(
|
|
288
|
+
`${label} failed\n query: ${query}\n response: ${JSON.stringify(
|
|
289
|
+
errors,
|
|
290
|
+
)}${identity}${tried}`,
|
|
291
|
+
),
|
|
185
292
|
)
|
|
186
293
|
}
|
|
187
294
|
| None => json->field("data")->Option.getOr(JSON.Encode.null)
|
package/src/Seed_Client.res.mjs
CHANGED
|
@@ -30,6 +30,69 @@ function useToken(t, token) {
|
|
|
30
30
|
t.token = token;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
let availableRolesClaim = "availableRoles";
|
|
34
|
+
|
|
35
|
+
let groupClaimNames = [
|
|
36
|
+
"cognito:groups",
|
|
37
|
+
"groups"
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function decodeSegment(segment) {
|
|
41
|
+
try {
|
|
42
|
+
return Stdlib_JSON.Decode.object(JSON.parse(Buffer.from(segment, "base64url").toString("utf8")));
|
|
43
|
+
} catch (exn) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function claims(t) {
|
|
49
|
+
return Stdlib_Option.flatMap(t.token, token => Stdlib_Array.reduce(token.split("."), undefined, (found, segment) => {
|
|
50
|
+
if (found !== undefined) {
|
|
51
|
+
return found;
|
|
52
|
+
} else {
|
|
53
|
+
return decodeSegment(segment);
|
|
54
|
+
}
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function asStrings(value) {
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
return Stdlib_Array.filterMap(value, Stdlib_JSON.Decode.string);
|
|
61
|
+
}
|
|
62
|
+
switch (typeof value) {
|
|
63
|
+
case "string" :
|
|
64
|
+
return value.split(",").map(prim => prim.trim()).filter(s => s !== "");
|
|
65
|
+
default:
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function claimStrings(t, name) {
|
|
71
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(claims(t), c => c[name]), asStrings);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function effectiveGroups(t) {
|
|
75
|
+
return Stdlib_Array.reduce(groupClaimNames, undefined, (found, name) => {
|
|
76
|
+
if (found !== undefined) {
|
|
77
|
+
return found;
|
|
78
|
+
} else {
|
|
79
|
+
return claimStrings(t, name);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function identitySummary(t) {
|
|
85
|
+
return Stdlib_Option.map(effectiveGroups(t), groups => {
|
|
86
|
+
let held = groups.length === 0 ? "no groups" : groups.join(", ");
|
|
87
|
+
let available = claimStrings(t, availableRolesClaim);
|
|
88
|
+
if (available !== undefined && available.length !== 0) {
|
|
89
|
+
return held + ` — narrowed to this role from ` + available.join(", ");
|
|
90
|
+
} else {
|
|
91
|
+
return held;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
33
96
|
function field(json, key) {
|
|
34
97
|
if (typeof json === "object" && json !== null && !Array.isArray(json)) {
|
|
35
98
|
return json[key];
|
|
@@ -125,6 +188,31 @@ function isTransient(errors) {
|
|
|
125
188
|
}
|
|
126
189
|
}
|
|
127
190
|
|
|
191
|
+
function isDenied(errors) {
|
|
192
|
+
let entries = Stdlib_JSON.Decode.array(errors);
|
|
193
|
+
if (entries !== undefined) {
|
|
194
|
+
return entries.some(e => {
|
|
195
|
+
let match = nodeString(e, "errorType");
|
|
196
|
+
let match$1 = nodeString(e, "message");
|
|
197
|
+
if (match !== undefined) {
|
|
198
|
+
if (match.includes("Unauthorized")) {
|
|
199
|
+
return true;
|
|
200
|
+
} else if (match$1 !== undefined) {
|
|
201
|
+
return match$1.includes("Not Authorized");
|
|
202
|
+
} else {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
} else if (match$1 !== undefined) {
|
|
206
|
+
return match$1.includes("Not Authorized");
|
|
207
|
+
} else {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
} else {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
128
216
|
async function gql(t, query, label) {
|
|
129
217
|
let headers = Object.fromEntries([[
|
|
130
218
|
"content-type",
|
|
@@ -163,9 +251,11 @@ async function gql(t, query, label) {
|
|
|
163
251
|
return await attempt(n + 1 | 0);
|
|
164
252
|
}
|
|
165
253
|
let tried = n > 1 ? `\n gave up after ` + n.toString() + ` attempts` : "";
|
|
254
|
+
let summary = isDenied(errors) ? identitySummary(t) : undefined;
|
|
255
|
+
let identity = summary !== undefined ? `\n identity: ` + summary : "";
|
|
166
256
|
throw {
|
|
167
257
|
RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
|
|
168
|
-
_1: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors) + tried,
|
|
258
|
+
_1: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors) + identity + tried,
|
|
169
259
|
Error: new Error()
|
|
170
260
|
};
|
|
171
261
|
};
|
|
@@ -304,6 +394,8 @@ async function waitForIds(t, fieldName, ids, timeoutMsOpt) {
|
|
|
304
394
|
return Stdlib_Option.getOr(result, 0);
|
|
305
395
|
}
|
|
306
396
|
|
|
397
|
+
let activeRoleClaim = "activeRole";
|
|
398
|
+
|
|
307
399
|
let attempts = 4;
|
|
308
400
|
|
|
309
401
|
export {
|
|
@@ -312,12 +404,22 @@ export {
|
|
|
312
404
|
currentToken,
|
|
313
405
|
endpoint,
|
|
314
406
|
useToken,
|
|
407
|
+
activeRoleClaim,
|
|
408
|
+
availableRolesClaim,
|
|
409
|
+
groupClaimNames,
|
|
410
|
+
decodeSegment,
|
|
411
|
+
claims,
|
|
412
|
+
asStrings,
|
|
413
|
+
claimStrings,
|
|
414
|
+
effectiveGroups,
|
|
415
|
+
identitySummary,
|
|
315
416
|
field,
|
|
316
417
|
asString,
|
|
317
418
|
nodeString,
|
|
318
419
|
login,
|
|
319
420
|
transientErrorTypes,
|
|
320
421
|
isTransient,
|
|
422
|
+
isDenied,
|
|
321
423
|
attempts,
|
|
322
424
|
gql,
|
|
323
425
|
commandResultSelection,
|
package/src/Seed_Connect.res
CHANGED
|
@@ -38,6 +38,13 @@ let make = async (
|
|
|
38
38
|
let token = await login(~username, ~password)
|
|
39
39
|
let client = Seed_Client.make(~config={endpoint: endpoint})
|
|
40
40
|
client->Seed_Client.useToken(token)
|
|
41
|
+
// What the bearer grants, which is not always what the account list showed: a
|
|
42
|
+
// token narrowed to one role presents that role alone. Said at login so the
|
|
43
|
+
// run starts from the identity it will actually be refused or served under.
|
|
44
|
+
switch Seed_Client.identitySummary(client) {
|
|
45
|
+
| Some(summary) => Console.log(`Acting as: ${summary}`)
|
|
46
|
+
| None => ()
|
|
47
|
+
}
|
|
41
48
|
{client, uploadsSkipped, label}
|
|
42
49
|
}
|
|
43
50
|
|
package/src/Seed_Connect.res.mjs
CHANGED
|
@@ -15,6 +15,10 @@ async function make(label, endpoint, login, localDefaultsOpt) {
|
|
|
15
15
|
endpoint: endpoint
|
|
16
16
|
});
|
|
17
17
|
Seed_Client$ReventlessSeed.useToken(client, token);
|
|
18
|
+
let summary = Seed_Client$ReventlessSeed.identitySummary(client);
|
|
19
|
+
if (summary !== undefined) {
|
|
20
|
+
console.log(`Acting as: ` + summary);
|
|
21
|
+
}
|
|
18
22
|
return {
|
|
19
23
|
client: client,
|
|
20
24
|
uploadsSkipped: uploadsSkipped,
|