@axonflow/sdk 9.0.0 → 9.2.0
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/README.md +117 -22
- package/dist/cjs/authzen.d.ts +328 -0
- package/dist/cjs/authzen.d.ts.map +1 -0
- package/dist/cjs/authzen.js +875 -0
- package/dist/cjs/authzen.js.map +1 -0
- package/dist/cjs/client.d.ts +67 -0
- package/dist/cjs/client.d.ts.map +1 -1
- package/dist/cjs/client.js +122 -3
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/index.d.ts +3 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +45 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/telemetry.d.ts +68 -13
- package/dist/cjs/telemetry.d.ts.map +1 -1
- package/dist/cjs/telemetry.js +92 -9
- package/dist/cjs/telemetry.js.map +1 -1
- package/dist/cjs/types/authzen.gen.d.ts +405 -0
- package/dist/cjs/types/authzen.gen.d.ts.map +1 -0
- package/dist/cjs/types/authzen.gen.js +798 -0
- package/dist/cjs/types/authzen.gen.js.map +1 -0
- package/dist/cjs/types/gateway.d.ts +68 -8
- package/dist/cjs/types/gateway.d.ts.map +1 -1
- package/dist/cjs/types/masfeat.d.ts +98 -0
- package/dist/cjs/types/masfeat.d.ts.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/authzen.d.ts +328 -0
- package/dist/esm/authzen.d.ts.map +1 -0
- package/dist/esm/authzen.js +862 -0
- package/dist/esm/authzen.js.map +1 -0
- package/dist/esm/client.d.ts +67 -0
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +122 -3
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +12 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/telemetry.d.ts +68 -13
- package/dist/esm/telemetry.d.ts.map +1 -1
- package/dist/esm/telemetry.js +91 -9
- package/dist/esm/telemetry.js.map +1 -1
- package/dist/esm/types/authzen.gen.d.ts +405 -0
- package/dist/esm/types/authzen.gen.d.ts.map +1 -0
- package/dist/esm/types/authzen.gen.js +773 -0
- package/dist/esm/types/authzen.gen.js.map +1 -0
- package/dist/esm/types/gateway.d.ts +68 -8
- package/dist/esm/types/gateway.d.ts.map +1 -1
- package/dist/esm/types/masfeat.d.ts +98 -0
- package/dist/esm/types/masfeat.d.ts.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* AuthZEN-native authorization for the AxonFlow SDK.
|
|
4
|
+
*
|
|
5
|
+
* This is the surface the ADR-065 compatibility plan commits to in all five
|
|
6
|
+
* SDKs. It talks to `POST /api/v1/access/evaluation`, whose wire shape is
|
|
7
|
+
* generated from the platform's canonical contract (see
|
|
8
|
+
* `src/types/authzen.gen.ts`); nothing in this file re-states a field name or
|
|
9
|
+
* an enum value.
|
|
10
|
+
*
|
|
11
|
+
* ## What this replaces, and when
|
|
12
|
+
*
|
|
13
|
+
* Nothing yet. The existing decision surface (`decide`, `explainDecision` and
|
|
14
|
+
* the gateway/proxy methods) stays wire-stable through all of v11 and is not
|
|
15
|
+
* deprecated here. This is the surface to write NEW integrations against,
|
|
16
|
+
* because at v11 the engine behind it becomes the ADR-065 Policy Decision Point
|
|
17
|
+
* with no wire change — an integration written against it migrates once rather
|
|
18
|
+
* than twice. See `docs/AUTHZEN_MIGRATION_DRAFT.md`.
|
|
19
|
+
*
|
|
20
|
+
* ## The one thing worth knowing before you call it
|
|
21
|
+
*
|
|
22
|
+
* The server refuses anything it cannot evaluate rather than evaluating around
|
|
23
|
+
* it. Send a subject property, an unrecognised context member, or an argument
|
|
24
|
+
* beside the query, and you get an `AuthZENRefusal` naming the exact member —
|
|
25
|
+
* not a decision computed without it. That is deliberate: a decision that
|
|
26
|
+
* silently ignored an attribute would tell you the attribute was weighed when
|
|
27
|
+
* it was not, and every audit of that decision would inherit the claim.
|
|
28
|
+
*
|
|
29
|
+
* So treat an `AuthZENRefusal` as "fix the request", and retry only when
|
|
30
|
+
* `refusal.retryable` is true.
|
|
31
|
+
*/
|
|
32
|
+
var _a;
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.AuthZENDecision = exports.AuthZENAttribute = exports.AuthZENProtocolError = exports.AuthZENRefusal = exports.AUTHZEN_UNKNOWN_REQUIRED_ABSENT = exports.AUTHZEN_UNKNOWN_MALFORMED_VALUE = exports.AUTHZEN_UNKNOWN_CLOSURE_TRUNCATED = exports.AUTHZEN_UNKNOWN_CLOSURE_UNAVAILABLE = exports.AUTHZEN_UNKNOWN_SCHEMA_MISMATCH = exports.AUTHZEN_UNKNOWN_STALE = exports.AUTHZEN_UNKNOWN_RESOLUTION_FAILED = exports.AUTHZEN_UNKNOWN_NOT_SUPPLIED = exports.AUTHZEN_PROFILE_HEADER = exports.AUTHZEN_PATH = exports.AUTHZEN_ATTRIBUTE_MARKER = void 0;
|
|
35
|
+
exports.resolveEnvelope = resolveEnvelope;
|
|
36
|
+
exports.checkEnvelopeComplete = checkEnvelopeComplete;
|
|
37
|
+
exports.buildEnvelope = buildEnvelope;
|
|
38
|
+
exports.evaluateEnvelope = evaluateEnvelope;
|
|
39
|
+
exports.assertFullyResolved = assertFullyResolved;
|
|
40
|
+
exports.toWire = toWire;
|
|
41
|
+
const errors_1 = require("./errors");
|
|
42
|
+
const authzen_gen_1 = require("./types/authzen.gen");
|
|
43
|
+
/**
|
|
44
|
+
* The marker every tri-state attribute carries. See {@link AuthZENAttribute}.
|
|
45
|
+
*
|
|
46
|
+
* Exported so a caller building attributes across a serialisation boundary can
|
|
47
|
+
* reconstruct one the SDK will still recognise.
|
|
48
|
+
*/
|
|
49
|
+
exports.AUTHZEN_ATTRIBUTE_MARKER = '__axonflow_authzen_attribute__';
|
|
50
|
+
/** The AuthZEN evaluation endpoint. */
|
|
51
|
+
exports.AUTHZEN_PATH = '/api/v1/access/evaluation';
|
|
52
|
+
/**
|
|
53
|
+
* How a Policy Enforcement Point negotiates the AxonFlow profile.
|
|
54
|
+
*
|
|
55
|
+
* The SDK always sends it. AuthZEN 1.0's response is a bare boolean, and the
|
|
56
|
+
* four-valued state, the obligations and the approval challenge ride in the
|
|
57
|
+
* response context, which the server returns only to a caller that asked for it
|
|
58
|
+
* by version. This SDK understands the profile, so there is no reason to ask
|
|
59
|
+
* for less than it can read — and a response WITHOUT the context is therefore a
|
|
60
|
+
* protocol failure here rather than a decision with no obligations.
|
|
61
|
+
*/
|
|
62
|
+
exports.AUTHZEN_PROFILE_HEADER = 'X-Axonflow-AuthZEN-Profile';
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Why an attribute could not be established.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
//
|
|
67
|
+
// These mirror ADR-065's tri-state reason codes so an operator reading an SDK
|
|
68
|
+
// refusal and an operator reading a platform trace use the same words. They are
|
|
69
|
+
// CLIENT-LOCAL: an unknown attribute never reaches the wire, because the whole
|
|
70
|
+
// point is that the request is not sent. The reason is a free-form string on
|
|
71
|
+
// purpose — a closed set hand-copied from the platform would be a transcription
|
|
72
|
+
// that drifts, and it would buy nothing, since nothing on the far side reads it.
|
|
73
|
+
exports.AUTHZEN_UNKNOWN_NOT_SUPPLIED = 'attribute_not_supplied';
|
|
74
|
+
exports.AUTHZEN_UNKNOWN_RESOLUTION_FAILED = 'resolution_failed';
|
|
75
|
+
exports.AUTHZEN_UNKNOWN_STALE = 'stale';
|
|
76
|
+
exports.AUTHZEN_UNKNOWN_SCHEMA_MISMATCH = 'schema_mismatch';
|
|
77
|
+
exports.AUTHZEN_UNKNOWN_CLOSURE_UNAVAILABLE = 'closure_unavailable';
|
|
78
|
+
exports.AUTHZEN_UNKNOWN_CLOSURE_TRUNCATED = 'closure_truncated';
|
|
79
|
+
exports.AUTHZEN_UNKNOWN_MALFORMED_VALUE = 'malformed_value';
|
|
80
|
+
exports.AUTHZEN_UNKNOWN_REQUIRED_ABSENT = 'required_attribute_absent';
|
|
81
|
+
/**
|
|
82
|
+
* The request was NOT evaluated, and here is the typed reason why.
|
|
83
|
+
*
|
|
84
|
+
* A refusal is not a denial. `decision: false` says the request WAS evaluated
|
|
85
|
+
* and the answer was no; a refusal says no decision exists. Code that treats
|
|
86
|
+
* every error as a deny fails closed — which is safe — but will block traffic
|
|
87
|
+
* that should have been allowed once the request is corrected.
|
|
88
|
+
*
|
|
89
|
+
* `refusedBy` says who made the call. `'gateway'` is a refusal document the
|
|
90
|
+
* server sent; `'client'` is this SDK declining to send a request it can
|
|
91
|
+
* already see will not be evaluated — an attribute the caller could not
|
|
92
|
+
* resolve, or an evaluation with no subject. The code vocabulary is shared
|
|
93
|
+
* because the REASONS are shared: an incomplete evaluation is an incomplete
|
|
94
|
+
* evaluation whoever notices it first.
|
|
95
|
+
*/
|
|
96
|
+
class AuthZENRefusal extends errors_1.AxonFlowError {
|
|
97
|
+
constructor(code, message, options) {
|
|
98
|
+
super(message, {
|
|
99
|
+
code,
|
|
100
|
+
pointer: options.pointer,
|
|
101
|
+
supported: options.supported,
|
|
102
|
+
requestId: options.requestId,
|
|
103
|
+
refusedBy: options.refusedBy,
|
|
104
|
+
});
|
|
105
|
+
this.name = 'AuthZENRefusal';
|
|
106
|
+
this.code = code;
|
|
107
|
+
this.pointer = options.pointer;
|
|
108
|
+
this.supported = options.supported;
|
|
109
|
+
this.requestId = options.requestId;
|
|
110
|
+
this.refusedBy = options.refusedBy;
|
|
111
|
+
Object.setPrototypeOf(this, AuthZENRefusal.prototype);
|
|
112
|
+
}
|
|
113
|
+
/** Build a refusal from the structured document the server sent. */
|
|
114
|
+
static fromBody(body) {
|
|
115
|
+
return new AuthZENRefusal(body.code, body.message, {
|
|
116
|
+
refusedBy: 'gateway',
|
|
117
|
+
pointer: body.pointer,
|
|
118
|
+
supported: body.supported,
|
|
119
|
+
requestId: body.request_id,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Whether sending the same request again could give a different answer.
|
|
124
|
+
*
|
|
125
|
+
* Only a dependency failure the GATEWAY reported is. Every other code names
|
|
126
|
+
* something about the request itself, which will not change on a retry — so
|
|
127
|
+
* a client that retries on any refusal burns its budget on requests that
|
|
128
|
+
* cannot succeed.
|
|
129
|
+
*
|
|
130
|
+
* A client-side refusal is never retryable, whatever its code: this SDK does
|
|
131
|
+
* not resolve the caller's attributes, so nothing it can do will change the
|
|
132
|
+
* answer. Reading retryability off the code alone would have told a caller to
|
|
133
|
+
* retry an attribute its own resolver failed to produce.
|
|
134
|
+
*/
|
|
135
|
+
get retryable() {
|
|
136
|
+
return this.refusedBy === 'gateway' && this.code === authzen_gen_1.AUTHZEN_ERROR_CODE_EVALUATION_UNAVAILABLE;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
exports.AuthZENRefusal = AuthZENRefusal;
|
|
140
|
+
/**
|
|
141
|
+
* A 200 whose body this build cannot safely interpret.
|
|
142
|
+
*
|
|
143
|
+
* Deliberately NOT an `AuthZENRefusal`. A refusal carries the server's own
|
|
144
|
+
* typed code from a closed vocabulary the server owns; a response this build
|
|
145
|
+
* cannot read is not something the server said, and dressing it in a server
|
|
146
|
+
* code would tell the caller the gateway refused when it did not. The two also
|
|
147
|
+
* demand different actions: a refusal means fix the request, a protocol error
|
|
148
|
+
* means upgrade the SDK or go and look at the deployment.
|
|
149
|
+
*
|
|
150
|
+
* It is always fail-closed: no decision is returned, so a caller that lets it
|
|
151
|
+
* propagate blocks the operation.
|
|
152
|
+
*/
|
|
153
|
+
class AuthZENProtocolError extends errors_1.AxonFlowError {
|
|
154
|
+
constructor(message) {
|
|
155
|
+
super(message);
|
|
156
|
+
this.name = 'AuthZENProtocolError';
|
|
157
|
+
Object.setPrototypeOf(this, AuthZENProtocolError.prototype);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
exports.AuthZENProtocolError = AuthZENProtocolError;
|
|
161
|
+
/**
|
|
162
|
+
* One policy-visible attribute in exactly one of three states.
|
|
163
|
+
*
|
|
164
|
+
* `undefined` and `null` cannot express this. ADR-065's model has three:
|
|
165
|
+
*
|
|
166
|
+
* - `known` — the authoritative source returned a value. It is sent.
|
|
167
|
+
* - `absent` — the source successfully established that there is NO value.
|
|
168
|
+
* Absence is a FACT, not a failure, so the member is omitted and the request
|
|
169
|
+
* is sent: a policy that handles absence gets to handle it.
|
|
170
|
+
* - `unknown` — the value could not be established. The request is NOT sent.
|
|
171
|
+
* Sending it would have the gateway evaluate as though the attribute were
|
|
172
|
+
* absent, and the resulting decision — and every audit of it — would record
|
|
173
|
+
* that an attribute was weighed when nobody ever read it. That is the exact
|
|
174
|
+
* failure the whole surface refuses to commit, one hop earlier.
|
|
175
|
+
*
|
|
176
|
+
* Collapsing absent into unknown is the defect this type exists to prevent, and
|
|
177
|
+
* it is not hypothetical: on the platform side an ABSENT `subject.type` was
|
|
178
|
+
* read as the one supported value, so omitting the field bypassed the
|
|
179
|
+
* impersonation refusal that naming it correctly triggered.
|
|
180
|
+
*
|
|
181
|
+
* Where it may be used: inside the ATTRIBUTE bags — `context` on a request or a
|
|
182
|
+
* bulk envelope, and the `properties` bag on a subject, action or resource — at
|
|
183
|
+
* any depth. Not on the structural members (`subject.id`, `action.name`,
|
|
184
|
+
* `resource.type` …): those are the identity of the question being asked, not
|
|
185
|
+
* data about it, and an identity the caller cannot resolve is not an attribute
|
|
186
|
+
* whose absence a policy could evaluate — there is simply no request to make.
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* ```typescript
|
|
190
|
+
* AuthZENAttribute.known('acme-corp');
|
|
191
|
+
* AuthZENAttribute.absent();
|
|
192
|
+
* AuthZENAttribute.unknown(AUTHZEN_UNKNOWN_RESOLUTION_FAILED);
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
class AuthZENAttribute {
|
|
196
|
+
constructor(state, value, reason) {
|
|
197
|
+
/**
|
|
198
|
+
* The marker, carried as an ordinary ENUMERABLE property.
|
|
199
|
+
*
|
|
200
|
+
* Recognising an attribute by `instanceof` alone has a silent failure in the
|
|
201
|
+
* dangerous direction: a bundler that duplicated this package gives two
|
|
202
|
+
* distinct classes. A non-enumerable Symbol brand fixes that and introduces
|
|
203
|
+
* the same failure by another route - every ordinary copy strips it, so
|
|
204
|
+
* `structuredClone`, a spread, a JSON round trip or a worker boundary turned
|
|
205
|
+
* an UNKNOWN attribute back into ordinary data and SENT it. Measured against
|
|
206
|
+
* a live gateway.
|
|
207
|
+
*
|
|
208
|
+
* Recognising it by SHAPE alone has the mirror failure: a caller's own bag
|
|
209
|
+
* carrying `state`/`value`/`reason` is read as an attribute, and a legitimate
|
|
210
|
+
* request is refused with a message asserting the caller could not establish
|
|
211
|
+
* a value it did establish.
|
|
212
|
+
*
|
|
213
|
+
* An enumerable marker closes both. It survives every ordinary copy, and no
|
|
214
|
+
* caller's data carries it by accident. The Python sibling uses the same key
|
|
215
|
+
* for the same reason.
|
|
216
|
+
*/
|
|
217
|
+
this[_a] = true;
|
|
218
|
+
this.state = state;
|
|
219
|
+
this.value = value;
|
|
220
|
+
this.reason = reason;
|
|
221
|
+
}
|
|
222
|
+
/** The source returned this value. */
|
|
223
|
+
static known(value) {
|
|
224
|
+
return new AuthZENAttribute('known', value, '');
|
|
225
|
+
}
|
|
226
|
+
/** The source established that there is no value. */
|
|
227
|
+
static absent() {
|
|
228
|
+
return new AuthZENAttribute('absent', undefined, '');
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The value could not be established, for the named reason.
|
|
232
|
+
*
|
|
233
|
+
* The reason is mandatory. An unknown with no reason carries no more than
|
|
234
|
+
* `undefined` already did, and the whole point of the third state is that it
|
|
235
|
+
* says why.
|
|
236
|
+
*/
|
|
237
|
+
static unknown(reason) {
|
|
238
|
+
if (!reason || reason.trim() === '') {
|
|
239
|
+
throw new errors_1.AxonFlowError('an unknown attribute must name why it could not be established; an unknown with ' +
|
|
240
|
+
'no reason carries no more information than undefined, which is the collapse ' +
|
|
241
|
+
'this type exists to prevent');
|
|
242
|
+
}
|
|
243
|
+
return new AuthZENAttribute('unknown', undefined, reason);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Whether `value` is a tri-state attribute, however it was copied.
|
|
247
|
+
*
|
|
248
|
+
* See the marker above for why this is neither a bare `instanceof` nor a
|
|
249
|
+
* structural shape check.
|
|
250
|
+
*/
|
|
251
|
+
static is(value) {
|
|
252
|
+
if (value instanceof AuthZENAttribute)
|
|
253
|
+
return true;
|
|
254
|
+
if (typeof value !== 'object' || value === null)
|
|
255
|
+
return false;
|
|
256
|
+
const candidate = value;
|
|
257
|
+
if (candidate[exports.AUTHZEN_ATTRIBUTE_MARKER] !== true)
|
|
258
|
+
return false;
|
|
259
|
+
return (candidate.state === 'known' || candidate.state === 'absent' || candidate.state === 'unknown');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
exports.AuthZENAttribute = AuthZENAttribute;
|
|
263
|
+
_a = exports.AUTHZEN_ATTRIBUTE_MARKER;
|
|
264
|
+
/**
|
|
265
|
+
* The decision, with the readings a Policy Enforcement Point acts on.
|
|
266
|
+
*
|
|
267
|
+
* It implements the generated wire type rather than replacing it, so `decision`
|
|
268
|
+
* and `context` remain exactly what the server sent while the readings below
|
|
269
|
+
* stay in hand-written code the generator never has to know about.
|
|
270
|
+
*/
|
|
271
|
+
class AuthZENDecision {
|
|
272
|
+
constructor(response) {
|
|
273
|
+
this.decision = response.decision;
|
|
274
|
+
this.context = response.context;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Whether the enforcement point may proceed.
|
|
278
|
+
*
|
|
279
|
+
* Read this rather than `decision`. `decision` is AuthZEN 1.0's collapsed
|
|
280
|
+
* boolean; the operational STATE is what the policy engine actually produced,
|
|
281
|
+
* and exactly one state permits execution. Requiring both means a response
|
|
282
|
+
* whose boolean and state disagree can never be read as an allow — and such a
|
|
283
|
+
* response is refused before it gets here anyway, so this is the second of
|
|
284
|
+
* two locks rather than the only one.
|
|
285
|
+
*
|
|
286
|
+
* An allow with an undischarged MANDATORY obligation is not an allow. See
|
|
287
|
+
* `mandatoryObligations`.
|
|
288
|
+
*/
|
|
289
|
+
get allowed() {
|
|
290
|
+
return (this.decision === true &&
|
|
291
|
+
this.context !== undefined &&
|
|
292
|
+
this.context.state === authzen_gen_1.AUTHZEN_OPERATIONAL_STATE_ALLOW);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The four-valued operational state.
|
|
296
|
+
*
|
|
297
|
+
* `ERROR` when there is no context. Unreachable via `evaluate`, which refuses
|
|
298
|
+
* a context-less 200 before constructing this — but the type is public and a
|
|
299
|
+
* caller can build one by hand, and the safe reading of an outcome that
|
|
300
|
+
* carries no state is not ALLOW.
|
|
301
|
+
*/
|
|
302
|
+
get state() {
|
|
303
|
+
return this.context ? this.context.state : authzen_gen_1.AUTHZEN_OPERATIONAL_STATE_ERROR;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The id of the evaluation that DETERMINED this outcome.
|
|
307
|
+
*
|
|
308
|
+
* For a bulk envelope this is the entry that decided the meet, not the last
|
|
309
|
+
* one evaluated: it is the id an operator looks up to explain the answer.
|
|
310
|
+
*/
|
|
311
|
+
get decisionId() {
|
|
312
|
+
return this.context?.decision_id;
|
|
313
|
+
}
|
|
314
|
+
/** The safe machine-readable reason code, when the server sent one. */
|
|
315
|
+
get reason() {
|
|
316
|
+
return this.context?.reason;
|
|
317
|
+
}
|
|
318
|
+
/** The coarse outcome category, when the server sent one. */
|
|
319
|
+
get category() {
|
|
320
|
+
return this.context?.category;
|
|
321
|
+
}
|
|
322
|
+
/** Instructions the enforcement point must discharge before proceeding. */
|
|
323
|
+
get obligations() {
|
|
324
|
+
return this.context?.obligations ?? [];
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The obligations that are not optional.
|
|
328
|
+
*
|
|
329
|
+
* A mandatory obligation that cannot be discharged means the operation must
|
|
330
|
+
* NOT proceed, even though `allowed` is true. This SDK cannot make that call
|
|
331
|
+
* for you — whether your enforcement point can discharge a redaction is a
|
|
332
|
+
* fact about your seam, not about the decision — so it gives you the list and
|
|
333
|
+
* stays out of the way.
|
|
334
|
+
*/
|
|
335
|
+
get mandatoryObligations() {
|
|
336
|
+
return this.obligations.filter(obligation => obligation.mandatory);
|
|
337
|
+
}
|
|
338
|
+
/** The approval challenge, when the state is CHALLENGE. */
|
|
339
|
+
get approval() {
|
|
340
|
+
return this.context?.approval;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
exports.AuthZENDecision = AuthZENDecision;
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Tri-state resolution
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// RFC 6901. A correlation key containing a slash would otherwise produce a
|
|
348
|
+
// pointer naming a member that does not exist, on the refusal whose entire
|
|
349
|
+
// diagnostic value is the pointer.
|
|
350
|
+
function escapePointerToken(token) {
|
|
351
|
+
return token.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
352
|
+
}
|
|
353
|
+
class Unresolvable extends Error {
|
|
354
|
+
constructor(pointer, reason) {
|
|
355
|
+
super(pointer);
|
|
356
|
+
this.pointer = pointer;
|
|
357
|
+
this.reason = reason;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Distinguishes "this member resolved to no value, drop it" from "this member
|
|
361
|
+
// resolved to the value null". They are different: a caller may legitimately
|
|
362
|
+
// send a JSON null, and reusing null for the drop signal would silently rewrite
|
|
363
|
+
// one into the other.
|
|
364
|
+
const DROP = Symbol('authzen.absent');
|
|
365
|
+
/**
|
|
366
|
+
* How deep an attribute bag may nest before the SDK stops walking it.
|
|
367
|
+
*
|
|
368
|
+
* Without a bound, a bag that refers to itself recurses until the engine gives
|
|
369
|
+
* up, and the caller gets a `RangeError` out of `evaluate` - an error type
|
|
370
|
+
* nothing documents and no enforcement point catches. A bound turns that into
|
|
371
|
+
* the same typed refusal every other malformed bag gets. 64 is far past
|
|
372
|
+
* anything a policy attribute path plausibly nests and far short of the stack.
|
|
373
|
+
*/
|
|
374
|
+
const MAX_ATTRIBUTE_DEPTH = 64;
|
|
375
|
+
function resolveValue(value, pointer, depth = 0) {
|
|
376
|
+
if (depth > MAX_ATTRIBUTE_DEPTH) {
|
|
377
|
+
throw new Unresolvable(pointer, `nests deeper than ${MAX_ATTRIBUTE_DEPTH} levels, which this SDK will not walk; a bag ` +
|
|
378
|
+
`that refers to itself would otherwise recurse until the engine stopped`);
|
|
379
|
+
}
|
|
380
|
+
if (AuthZENAttribute.is(value)) {
|
|
381
|
+
if (value.state === 'known') {
|
|
382
|
+
// A known attribute may itself hold a container carrying more
|
|
383
|
+
// attributes; resolving the payload keeps the rule uniform rather than
|
|
384
|
+
// depending on how deeply a caller nested its resolver output.
|
|
385
|
+
return resolveValue(value.value, pointer, depth + 1);
|
|
386
|
+
}
|
|
387
|
+
if (value.state === 'absent')
|
|
388
|
+
return DROP;
|
|
389
|
+
throw new Unresolvable(pointer, value.reason);
|
|
390
|
+
}
|
|
391
|
+
if (Array.isArray(value)) {
|
|
392
|
+
// An ABSENT element is dropped from the list rather than left as a hole. A
|
|
393
|
+
// list with a gap in it is a different list, and the index a policy reads
|
|
394
|
+
// would shift under it either way; dropping is the reading that matches
|
|
395
|
+
// "there is no value here".
|
|
396
|
+
const items = [];
|
|
397
|
+
value.forEach((item, index) => {
|
|
398
|
+
const resolved = resolveValue(item, `${pointer}/${index}`, depth + 1);
|
|
399
|
+
if (resolved !== DROP)
|
|
400
|
+
items.push(resolved);
|
|
401
|
+
});
|
|
402
|
+
return items;
|
|
403
|
+
}
|
|
404
|
+
if (typeof value === 'object' && value !== null) {
|
|
405
|
+
const out = {};
|
|
406
|
+
Object.keys(value).forEach(key => {
|
|
407
|
+
const resolved = resolveValue(value[key], `${pointer}/${escapePointerToken(key)}`, depth + 1);
|
|
408
|
+
if (resolved === DROP)
|
|
409
|
+
return;
|
|
410
|
+
// defineProperty, not `out[key] = …`. A plain assignment to the key
|
|
411
|
+
// `__proto__` invokes the inherited setter instead of creating a member,
|
|
412
|
+
// so a context carrying `__proto__` - which `JSON.parse` produces as an
|
|
413
|
+
// ordinary own property - VANISHED from the request with no refusal.
|
|
414
|
+
// That is "mapped or refused, never silently ignored" broken by this SDK,
|
|
415
|
+
// in the one place the gateway cannot see it: the server refuses the
|
|
416
|
+
// member by name, and it never arrived. It reaches the wire now.
|
|
417
|
+
Object.defineProperty(out, key, {
|
|
418
|
+
value: resolved,
|
|
419
|
+
writable: true,
|
|
420
|
+
enumerable: true,
|
|
421
|
+
configurable: true,
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
return value;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Resolve one attribute bag.
|
|
430
|
+
*
|
|
431
|
+
* ABSENCE DOES NOT CASCADE. A bag whose every member resolved absent is sent as
|
|
432
|
+
* an empty object, not deleted: the bag is the caller's structure and the
|
|
433
|
+
* attributes are the data inside it, and an SDK that removed a container the
|
|
434
|
+
* caller placed would be editing the question rather than resolving the answer.
|
|
435
|
+
*
|
|
436
|
+
* The lever a caller wants sits one level in, which is where the attributes
|
|
437
|
+
* are: `{ args, correlation: AuthZENAttribute.absent() }` drops `correlation`
|
|
438
|
+
* and keeps everything else. Omitting the bag itself is ordinary TypeScript —
|
|
439
|
+
* do not pass it — because a bag that is not part of the question is not an
|
|
440
|
+
* attribute whose absence anything could evaluate.
|
|
441
|
+
*/
|
|
442
|
+
function resolveBag(bag, pointer) {
|
|
443
|
+
if (bag === undefined)
|
|
444
|
+
return undefined;
|
|
445
|
+
return resolveValue(bag, pointer);
|
|
446
|
+
}
|
|
447
|
+
// Every member the caller wrote is CARRIED FORWARD, and only the attribute bags
|
|
448
|
+
// are rewritten.
|
|
449
|
+
//
|
|
450
|
+
// These functions used to whitelist-copy `{type, id, properties}`, which meant a
|
|
451
|
+
// member the contract does not declare - `{"type":"gateway","id":"g1",
|
|
452
|
+
// "department":"finance"}` - was deleted here, before the generated validator
|
|
453
|
+
// ran, and the request went out without it and without a refusal. The
|
|
454
|
+
// validator's own documentation says it "catches a member the caller invented
|
|
455
|
+
// before it becomes a 422"; the whitelist made that claim false for exactly
|
|
456
|
+
// those members. Python refuses them at model construction, so this was also a
|
|
457
|
+
// place the two SDKs answered the same request differently.
|
|
458
|
+
//
|
|
459
|
+
// Spreading means an invented member survives to `validateAuthZENEnvelope` and
|
|
460
|
+
// is refused by name, with its pointer.
|
|
461
|
+
function resolveSubject(subject, at) {
|
|
462
|
+
if (!subject)
|
|
463
|
+
return undefined;
|
|
464
|
+
return { ...subject, properties: resolveBag(subject.properties, `${at}/subject/properties`) };
|
|
465
|
+
}
|
|
466
|
+
function resolveAction(action, at) {
|
|
467
|
+
if (!action)
|
|
468
|
+
return undefined;
|
|
469
|
+
return { ...action, properties: resolveBag(action.properties, `${at}/action/properties`) };
|
|
470
|
+
}
|
|
471
|
+
function resolveResource(resource, at) {
|
|
472
|
+
if (!resource)
|
|
473
|
+
return undefined;
|
|
474
|
+
return { ...resource, properties: resolveBag(resource.properties, `${at}/resource/properties`) };
|
|
475
|
+
}
|
|
476
|
+
function resolveRequest(request, at) {
|
|
477
|
+
return {
|
|
478
|
+
...request,
|
|
479
|
+
subject: resolveSubject(request.subject, at),
|
|
480
|
+
action: resolveAction(request.action, at),
|
|
481
|
+
resource: resolveResource(request.resource, at),
|
|
482
|
+
context: resolveBag(request.context, `${at}/context`),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Return the envelope with every tri-state attribute resolved to the wire.
|
|
487
|
+
*
|
|
488
|
+
* Throws `AuthZENRefusal` with `refusedBy: 'client'` and the JSON Pointer of
|
|
489
|
+
* the offending member when an attribute is UNKNOWN.
|
|
490
|
+
*
|
|
491
|
+
* The pointers match the server's own vocabulary — `/evaluation/...` for a
|
|
492
|
+
* singular envelope, `/evaluations/evaluations/<i>/...` for a plural entry — so
|
|
493
|
+
* a client-side refusal and a gateway refusal name the same member the same
|
|
494
|
+
* way, and a caller does not have to learn two pointer dialects.
|
|
495
|
+
*/
|
|
496
|
+
function resolveEnvelope(envelope) {
|
|
497
|
+
try {
|
|
498
|
+
// BOTH members are resolved when both are present, rather than the first
|
|
499
|
+
// one winning. Python's models refuse a two-member envelope at
|
|
500
|
+
// construction; TypeScript has no such moment, so an early return here
|
|
501
|
+
// would silently DROP the second member and send a request the caller did
|
|
502
|
+
// not write — and the exactly-one-of rule, which lives in the generated
|
|
503
|
+
// validator, would never see the violation it exists to refuse. One
|
|
504
|
+
// implementation of that rule, not two.
|
|
505
|
+
const out = {};
|
|
506
|
+
if (envelope.evaluation !== undefined && envelope.evaluation !== null) {
|
|
507
|
+
out.evaluation = resolveRequest(envelope.evaluation, '/evaluation');
|
|
508
|
+
}
|
|
509
|
+
if (envelope.evaluations !== undefined && envelope.evaluations !== null) {
|
|
510
|
+
const bulk = envelope.evaluations;
|
|
511
|
+
out.evaluations = {
|
|
512
|
+
subject: resolveSubject(bulk.subject, '/evaluations'),
|
|
513
|
+
action: resolveAction(bulk.action, '/evaluations'),
|
|
514
|
+
resource: resolveResource(bulk.resource, '/evaluations'),
|
|
515
|
+
context: resolveBag(bulk.context, '/evaluations/context'),
|
|
516
|
+
evaluations: (bulk.evaluations ?? []).map((entry, index) => resolveRequest(entry, `/evaluations/evaluations/${index}`)),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (out.evaluation !== undefined || out.evaluations !== undefined) {
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
if (!(err instanceof Unresolvable))
|
|
525
|
+
throw err;
|
|
526
|
+
throw new AuthZENRefusal(authzen_gen_1.AUTHZEN_ERROR_CODE_UNEVALUABLE_ATTRIBUTE, `the attribute at ${err.pointer} could not be established (${err.reason}), so this ` +
|
|
527
|
+
`request was not sent. The gateway would have evaluated as though the attribute ` +
|
|
528
|
+
`had no value, and the decision — and every audit of it — would record that it ` +
|
|
529
|
+
`was considered when nothing read it. Establish the value, or send it as an ` +
|
|
530
|
+
`explicitly ABSENT attribute if the source proved there is none.`, { refusedBy: 'client', pointer: err.pointer });
|
|
531
|
+
}
|
|
532
|
+
throw new AuthZENRefusal(authzen_gen_1.AUTHZEN_ERROR_CODE_MALFORMED_ENVELOPE, 'the envelope names neither an evaluation nor an evaluations member', { refusedBy: 'client', pointer: '' });
|
|
533
|
+
}
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// Completeness
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
/**
|
|
538
|
+
* Check the one invariant the artifact says it cannot express.
|
|
539
|
+
*
|
|
540
|
+
* The artifact marks every member of `authzen_request` structurally optional,
|
|
541
|
+
* because a plural entry inherits anything it omits from the shared base.
|
|
542
|
+
* Whether the MERGED entry names a subject, an action and a resource is a
|
|
543
|
+
* cross-object property no per-object schema can carry, and the platform's own
|
|
544
|
+
* projection enforces it server-side.
|
|
545
|
+
*
|
|
546
|
+
* This is deliberately the ONLY thing checked here, and it is checked by
|
|
547
|
+
* PRESENCE alone. Everything else the server refuses — which action names are
|
|
548
|
+
* evaluable, which resource types exist, which correlation keys this deployment
|
|
549
|
+
* records — is deployment state the SDK does not have. A client that guessed at
|
|
550
|
+
* it would refuse requests a newer gateway accepts, and the caller would have
|
|
551
|
+
* no way to tell an SDK that is out of date from a request that is wrong.
|
|
552
|
+
*/
|
|
553
|
+
function checkComplete(request, base, at) {
|
|
554
|
+
const missing = ['subject', 'action', 'resource'].filter(member => !request[member] && !(base && base[member]));
|
|
555
|
+
if (missing.length > 0) {
|
|
556
|
+
throw new AuthZENRefusal(authzen_gen_1.AUTHZEN_ERROR_CODE_INCOMPLETE_EVALUATION, `after inheriting from the shared base this evaluation still has no ` +
|
|
557
|
+
`${missing.join(', ')}; there is nothing to evaluate`, { refusedBy: 'client', pointer: at });
|
|
558
|
+
}
|
|
559
|
+
const subject = request.subject ?? base?.subject;
|
|
560
|
+
if (subject && subject.id.trim() === '') {
|
|
561
|
+
throw new AuthZENRefusal(authzen_gen_1.AUTHZEN_ERROR_CODE_INCOMPLETE_EVALUATION, 'the subject id must not be blank; a decision has to name the caller it was made for', { refusedBy: 'client', pointer: `${at}/subject/id` });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/** Refuse an envelope that cannot produce a decision, before the round trip. */
|
|
565
|
+
function checkEnvelopeComplete(envelope) {
|
|
566
|
+
if (envelope.evaluation) {
|
|
567
|
+
checkComplete(envelope.evaluation, undefined, '/evaluation');
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (envelope.evaluations) {
|
|
571
|
+
const bulk = envelope.evaluations;
|
|
572
|
+
const base = {
|
|
573
|
+
subject: bulk.subject,
|
|
574
|
+
action: bulk.action,
|
|
575
|
+
resource: bulk.resource,
|
|
576
|
+
context: bulk.context,
|
|
577
|
+
};
|
|
578
|
+
(bulk.evaluations ?? []).forEach((entry, index) => {
|
|
579
|
+
checkComplete(entry, base, `/evaluations/evaluations/${index}`);
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// The response direction
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
/**
|
|
587
|
+
* Refuse a 200 this build cannot act on.
|
|
588
|
+
*
|
|
589
|
+
* Every check here closes a way for an un-actionable body to be read as an
|
|
590
|
+
* allow. A decoded response that is merely well-typed is not enough: the
|
|
591
|
+
* boolean and the state are two renderings of one outcome, and a build that
|
|
592
|
+
* trusts either alone will act on a decision the other contradicts.
|
|
593
|
+
*/
|
|
594
|
+
function validateDecision(response, body) {
|
|
595
|
+
if (!response.context) {
|
|
596
|
+
throw new AuthZENProtocolError(`the server answered without the profile context. This SDK negotiates ` +
|
|
597
|
+
`${exports.AUTHZEN_PROFILE_HEADER}: ${authzen_gen_1.AUTHZEN_PROFILE_V1} on every request, so a response ` +
|
|
598
|
+
`carrying only the boolean means the gateway did not honour the negotiation — an ` +
|
|
599
|
+
`older build, or a proxy that dropped the header. The obligations and the approval ` +
|
|
600
|
+
`challenge that CONSTRAIN an allow ride in that payload, so an allow without it is ` +
|
|
601
|
+
`an allow whose mandatory conditions cannot be read. body=${body}`);
|
|
602
|
+
}
|
|
603
|
+
const context = response.context;
|
|
604
|
+
if (context.profile !== authzen_gen_1.AUTHZEN_PROFILE_V1) {
|
|
605
|
+
throw new AuthZENProtocolError(`the server answered with AuthZEN profile '${context.profile}'; this build can only ` +
|
|
606
|
+
`interpret '${authzen_gen_1.AUTHZEN_PROFILE_V1}'. The obligations and approval challenge that ` +
|
|
607
|
+
`constrain an allow are carried in that payload, so the decision cannot be acted ` +
|
|
608
|
+
`on safely. Upgrade the SDK.`);
|
|
609
|
+
}
|
|
610
|
+
if (!authzen_gen_1.AUTHZEN_OPERATIONAL_STATE_VALUES.includes(context.state)) {
|
|
611
|
+
throw new AuthZENProtocolError(`the server reported the operational state '${context.state}', which this build does ` +
|
|
612
|
+
`not know. Under profile ${authzen_gen_1.AUTHZEN_PROFILE_V1} the state set is closed, so a new ` +
|
|
613
|
+
`value means the response was produced by something this SDK cannot interpret — ` +
|
|
614
|
+
`and a state whose meaning is unknown must not be resolved into permission. ` +
|
|
615
|
+
`body=${body}`);
|
|
616
|
+
}
|
|
617
|
+
const executable = context.state === authzen_gen_1.AUTHZEN_OPERATIONAL_STATE_ALLOW;
|
|
618
|
+
if (response.decision !== executable) {
|
|
619
|
+
throw new AuthZENProtocolError(`the decision boolean (${response.decision}) and the operational state ` +
|
|
620
|
+
`(${context.state}) disagree; exactly one state permits execution, so one of the ` +
|
|
621
|
+
`two renderings of this outcome is wrong and there is no safe way to choose ` +
|
|
622
|
+
`between them. body=${body}`);
|
|
623
|
+
}
|
|
624
|
+
if (!executable && context.obligations && context.obligations.length > 0) {
|
|
625
|
+
throw new AuthZENProtocolError(`the server attached obligations to a ${context.state} decision. Obligations ride ` +
|
|
626
|
+
`only on an executable decision: instructions on a refusal invite an enforcement ` +
|
|
627
|
+
`point to discharge them and proceed. body=${body}`);
|
|
628
|
+
}
|
|
629
|
+
// `schema_version` is deliberately NOT enforced. The PROFILE is the
|
|
630
|
+
// negotiated contract and is checked above; schema_version is carried so a
|
|
631
|
+
// support conversation can name the contract a deployment answered from.
|
|
632
|
+
// Enforcing both would mean the two have to be bumped in lockstep, and this
|
|
633
|
+
// SDK would start refusing decisions over a discrepancy that changes nothing
|
|
634
|
+
// it reads.
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Validate the envelope, reporting a schema violation as a TYPED refusal.
|
|
638
|
+
*
|
|
639
|
+
* Shared by `evaluateEnvelope` and `toWire` so the two cannot answer the same
|
|
640
|
+
* malformed envelope differently. `toWire` used to leak the raw
|
|
641
|
+
* `AuthZENSchemaError` while `evaluateEnvelope` converted it, which made the
|
|
642
|
+
* function documented as "the exact document this SDK would send" throw
|
|
643
|
+
* something the send path never throws.
|
|
644
|
+
*/
|
|
645
|
+
function validateEnvelopeOrRefuse(resolved) {
|
|
646
|
+
try {
|
|
647
|
+
return (0, authzen_gen_1.validateAuthZENEnvelope)(stripUndefined(resolved), '');
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
if (!(err instanceof authzen_gen_1.AuthZENSchemaError))
|
|
651
|
+
throw err;
|
|
652
|
+
throw new AuthZENRefusal(authzen_gen_1.AUTHZEN_ERROR_CODE_MALFORMED_ENVELOPE, err.message, {
|
|
653
|
+
refusedBy: 'client',
|
|
654
|
+
pointer: err.pointer,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Decode a structured refusal document, or undefined if the body is not one.
|
|
660
|
+
*
|
|
661
|
+
* Decoded LENIENTLY, unlike the decision path, and the asymmetry is deliberate.
|
|
662
|
+
* Strictness on a DECISION is a safety control: an unknown member means the
|
|
663
|
+
* server is speaking a profile this build cannot fully read, and the unread part
|
|
664
|
+
* may be the one that constrains an allow. A refusal constrains nothing - it
|
|
665
|
+
* says no decision exists - so the same strictness buys no safety and costs the
|
|
666
|
+
* caller the whole point of the surface: one additive field on the refusal
|
|
667
|
+
* envelope would degrade every typed refusal into a bare error, losing the code,
|
|
668
|
+
* the pointer, the supported set and the retryable signal on the one path whose
|
|
669
|
+
* entire purpose is to be branchable.
|
|
670
|
+
*
|
|
671
|
+
* Unknown members are dropped here, and the SHAPE is still checked: a body
|
|
672
|
+
* carrying no code or no message is not a refusal document and returns
|
|
673
|
+
* undefined, which sends the caller down the generic-error path rather than
|
|
674
|
+
* fabricating a refusal the server did not make.
|
|
675
|
+
*/
|
|
676
|
+
const AUTHZEN_ERROR_MEMBERS = ['code', 'pointer', 'message', 'supported', 'request_id'];
|
|
677
|
+
function decodeRefusal(body) {
|
|
678
|
+
let parsed;
|
|
679
|
+
try {
|
|
680
|
+
parsed = JSON.parse(body);
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
686
|
+
return undefined;
|
|
687
|
+
const known = {};
|
|
688
|
+
AUTHZEN_ERROR_MEMBERS.forEach(member => {
|
|
689
|
+
const value = parsed[member];
|
|
690
|
+
if (value !== undefined)
|
|
691
|
+
known[member] = value;
|
|
692
|
+
});
|
|
693
|
+
try {
|
|
694
|
+
return (0, authzen_gen_1.validateAuthZENError)(known, '');
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
return undefined;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Build an envelope: RESOLVE, check completeness, then validate.
|
|
702
|
+
*
|
|
703
|
+
* The counterpart of the Python sibling's `build_envelope`, and it exists for
|
|
704
|
+
* the same reason: `evaluate` and `evaluateAll` must not each assemble an
|
|
705
|
+
* envelope inline, or the two entry points drift on the order these steps run
|
|
706
|
+
* in - and that order decides which of two problems a caller with two problems
|
|
707
|
+
* is told about.
|
|
708
|
+
*/
|
|
709
|
+
function buildEnvelope(evaluation, evaluations) {
|
|
710
|
+
const envelope = {};
|
|
711
|
+
if (evaluation !== undefined)
|
|
712
|
+
envelope.evaluation = evaluation;
|
|
713
|
+
if (evaluations !== undefined)
|
|
714
|
+
envelope.evaluations = evaluations;
|
|
715
|
+
const resolved = resolveEnvelope(envelope);
|
|
716
|
+
if (resolved.evaluation === undefined || resolved.evaluations === undefined) {
|
|
717
|
+
checkEnvelopeComplete(resolved);
|
|
718
|
+
}
|
|
719
|
+
assertFullyResolved(resolved);
|
|
720
|
+
return validateEnvelopeOrRefuse(resolved);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Run one envelope through `send` and interpret the answer.
|
|
724
|
+
*
|
|
725
|
+
* `send` is the SDK's own transport — the same authenticated fetch wrapper,
|
|
726
|
+
* headers and heartbeat gate every other method uses. It is passed in rather
|
|
727
|
+
* than built here so this module owns the AuthZEN semantics and nothing else; a
|
|
728
|
+
* second transport would be a second place for credentials, timeouts and proxy
|
|
729
|
+
* configuration to drift out of step with the client the user configured.
|
|
730
|
+
*/
|
|
731
|
+
async function evaluateEnvelope(send, envelope) {
|
|
732
|
+
const resolved = resolveEnvelope(envelope);
|
|
733
|
+
// Completeness runs FIRST, so a missing subject/action/resource is reported
|
|
734
|
+
// as `incomplete_evaluation` for both envelope shapes. The generated
|
|
735
|
+
// validator would otherwise reach the singular member's own required set
|
|
736
|
+
// first and report the same mistake under a different code depending on
|
|
737
|
+
// which shape the caller used.
|
|
738
|
+
//
|
|
739
|
+
// Except when the envelope names BOTH members: its fault is not that an
|
|
740
|
+
// evaluation is incomplete, and answering "no action" for a request whose
|
|
741
|
+
// real problem is that it asks two questions at once sends the caller to the
|
|
742
|
+
// wrong member entirely.
|
|
743
|
+
if (resolved.evaluation === undefined || resolved.evaluations === undefined) {
|
|
744
|
+
checkEnvelopeComplete(resolved);
|
|
745
|
+
}
|
|
746
|
+
assertFullyResolved(resolved);
|
|
747
|
+
const wire = validateEnvelopeOrRefuse(resolved);
|
|
748
|
+
const { status, body } = await send(exports.AUTHZEN_PATH, wire, {
|
|
749
|
+
[exports.AUTHZEN_PROFILE_HEADER]: authzen_gen_1.AUTHZEN_PROFILE_V1,
|
|
750
|
+
});
|
|
751
|
+
if (status === 401) {
|
|
752
|
+
// Authentication is answered by the gateway's own middleware, before the
|
|
753
|
+
// route runs, so it never carries an AuthZEN refusal document. Surfacing it
|
|
754
|
+
// as the SDK's existing AuthenticationError keeps one error for "your
|
|
755
|
+
// credentials are wrong" across every method on this client, instead of a
|
|
756
|
+
// second one only AuthZEN callers know to catch.
|
|
757
|
+
throw new errors_1.AuthenticationError(`Invalid credentials for ${exports.AUTHZEN_PATH}: ${body}`);
|
|
758
|
+
}
|
|
759
|
+
if (status !== 200) {
|
|
760
|
+
const refusal = decodeRefusal(body);
|
|
761
|
+
if (refusal)
|
|
762
|
+
throw AuthZENRefusal.fromBody(refusal);
|
|
763
|
+
// A non-OK body that is not a refusal document still surfaces as an error —
|
|
764
|
+
// never as a decision.
|
|
765
|
+
throw new errors_1.AxonFlowError(`HTTP ${status} from ${exports.AUTHZEN_PATH}: ${body}`);
|
|
766
|
+
}
|
|
767
|
+
let parsed;
|
|
768
|
+
try {
|
|
769
|
+
parsed = JSON.parse(body);
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
throw new AuthZENProtocolError(`the decision could not be decoded: ${err.message}. body=${body}`);
|
|
773
|
+
}
|
|
774
|
+
let response;
|
|
775
|
+
try {
|
|
776
|
+
// Strict decoding on the success path. An unknown member in a decision is a
|
|
777
|
+
// server speaking a profile this build does not understand, and quietly
|
|
778
|
+
// dropping it would mean acting on a partial reading of an authorization
|
|
779
|
+
// decision. A TypeScript cast would do none of this — the interface is
|
|
780
|
+
// erased at runtime — which is why the validator is generated.
|
|
781
|
+
response = (0, authzen_gen_1.validateAuthZENResponse)(parsed, '');
|
|
782
|
+
}
|
|
783
|
+
catch (err) {
|
|
784
|
+
throw new AuthZENProtocolError(`the decision could not be decoded: ${err.message}. body=${body}`);
|
|
785
|
+
}
|
|
786
|
+
validateDecision(response, body);
|
|
787
|
+
return new AuthZENDecision(response);
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Drop `undefined` members so they are not serialised as `null`.
|
|
791
|
+
*
|
|
792
|
+
* `JSON.stringify` already omits an `undefined` property, but the generated
|
|
793
|
+
* validator runs BEFORE serialisation and would otherwise see the key present
|
|
794
|
+
* with an undefined value. Normalising here means the validator, the bytes on
|
|
795
|
+
* the wire and `toWire` below all describe the same document.
|
|
796
|
+
*/
|
|
797
|
+
function stripUndefined(value) {
|
|
798
|
+
if (Array.isArray(value))
|
|
799
|
+
return value.map(stripUndefined);
|
|
800
|
+
if (typeof value === 'object' && value !== null) {
|
|
801
|
+
const out = {};
|
|
802
|
+
Object.entries(value).forEach(([key, item]) => {
|
|
803
|
+
if (item === undefined)
|
|
804
|
+
return;
|
|
805
|
+
// defineProperty for the same reason as the resolver: a plain assignment
|
|
806
|
+
// to the key `__proto__` invokes the inherited setter instead of creating
|
|
807
|
+
// a member, so the member would be dropped HERE even after the resolver
|
|
808
|
+
// carried it through. Two places, one rule.
|
|
809
|
+
Object.defineProperty(out, key, {
|
|
810
|
+
value: stripUndefined(item),
|
|
811
|
+
writable: true,
|
|
812
|
+
enumerable: true,
|
|
813
|
+
configurable: true,
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
return value;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Guarantee no tri-state attribute reaches the wire.
|
|
822
|
+
*
|
|
823
|
+
* `resolveEnvelope` walks every bag the contract declares, so on today's
|
|
824
|
+
* contract this has nothing to catch. It exists for the case that does not
|
|
825
|
+
* announce itself: a container kind added to the artifact later, or an attribute
|
|
826
|
+
* reaching a member the resolver does not visit. Without it that attribute is
|
|
827
|
+
* not a crash - `JSON.stringify` turns the instance into an ordinary
|
|
828
|
+
* `{"state": …, "value": …, "reason": …}` object - so the request is SENT,
|
|
829
|
+
* carrying a resolver's internal shape where the gateway expects a value, and an
|
|
830
|
+
* UNKNOWN attribute reaches the network after all.
|
|
831
|
+
*
|
|
832
|
+
* The Python sibling carries the same control, and had exactly this defect:
|
|
833
|
+
* there the check ran after serialisation and could never fire.
|
|
834
|
+
*/
|
|
835
|
+
function assertFullyResolved(value, path = '', depth = 0) {
|
|
836
|
+
if (depth > MAX_ATTRIBUTE_DEPTH) {
|
|
837
|
+
// Bounded for the same reason the resolver is, and by the same number. Two
|
|
838
|
+
// walkers that disagree on their bound is how a cycle reaching only the
|
|
839
|
+
// unbounded one comes back as a RangeError - the error type the bound
|
|
840
|
+
// exists to remove.
|
|
841
|
+
throw new AuthZENProtocolError(`the structure at ${path || '/'} nests deeper than ${MAX_ATTRIBUTE_DEPTH} levels; ` +
|
|
842
|
+
`this SDK will not walk it, and a structure that refers to itself would ` +
|
|
843
|
+
`otherwise recurse until the engine stopped`);
|
|
844
|
+
}
|
|
845
|
+
if (AuthZENAttribute.is(value)) {
|
|
846
|
+
throw new AuthZENProtocolError(`an unresolved AuthZENAttribute reached the wire at ${path || '/'}. Tri-state ` +
|
|
847
|
+
`attributes are only supported inside the context and properties bags; this one is ` +
|
|
848
|
+
`somewhere the resolver does not reach.`);
|
|
849
|
+
}
|
|
850
|
+
if (Array.isArray(value)) {
|
|
851
|
+
value.forEach((item, index) => assertFullyResolved(item, `${path}/${index}`, depth + 1));
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (typeof value === 'object' && value !== null) {
|
|
855
|
+
Object.keys(value).forEach(key => {
|
|
856
|
+
assertFullyResolved(value[key], `${path}/${escapePointerToken(key)}`, depth + 1);
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* The exact document this SDK would send for `envelope`.
|
|
862
|
+
*
|
|
863
|
+
* Exported for tests and for support: "what did the SDK actually put on the
|
|
864
|
+
* wire" is the first question of every integration problem, and answering it by
|
|
865
|
+
* reading the client's source is how the answer ends up wrong.
|
|
866
|
+
*/
|
|
867
|
+
function toWire(envelope) {
|
|
868
|
+
const resolved = resolveEnvelope(envelope);
|
|
869
|
+
if (resolved.evaluation === undefined || resolved.evaluations === undefined) {
|
|
870
|
+
checkEnvelopeComplete(resolved);
|
|
871
|
+
}
|
|
872
|
+
assertFullyResolved(resolved);
|
|
873
|
+
return validateEnvelopeOrRefuse(resolved);
|
|
874
|
+
}
|
|
875
|
+
//# sourceMappingURL=authzen.js.map
|