@sasjs/api 0.6.0 → 0.7.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 +3 -2
- package/build/browser.js +2231 -118
- package/build/cjs/corpus/records.js +1222 -0
- package/build/cjs/corpus/runner.js +15 -6
- package/build/cjs/drive/deploy.js +49 -10
- package/build/cjs/host/dispatch.js +190 -2
- package/build/cjs/identity/memory-store.js +196 -11
- package/build/cjs/identity/records.js +398 -0
- package/build/cjs/index.js +8 -1
- package/build/cjs/routes/generated.js +184 -4
- package/build/cjs/routes/overlay.js +53 -58
- package/build/esm/corpus/records.d.ts +25 -0
- package/build/esm/corpus/records.d.ts.map +1 -0
- package/build/esm/corpus/records.js +1219 -0
- package/build/esm/corpus/runner.d.ts.map +1 -1
- package/build/esm/corpus/runner.js +15 -6
- package/build/esm/drive/deploy.d.ts +10 -0
- package/build/esm/drive/deploy.d.ts.map +1 -1
- package/build/esm/drive/deploy.js +48 -9
- package/build/esm/host/dispatch.d.ts +9 -0
- package/build/esm/host/dispatch.d.ts.map +1 -1
- package/build/esm/host/dispatch.js +190 -2
- package/build/esm/identity/fixture.d.ts +6 -0
- package/build/esm/identity/fixture.d.ts.map +1 -1
- package/build/esm/identity/memory-store.d.ts +29 -5
- package/build/esm/identity/memory-store.d.ts.map +1 -1
- package/build/esm/identity/memory-store.js +196 -11
- package/build/esm/identity/records.d.ts +102 -0
- package/build/esm/identity/records.d.ts.map +1 -0
- package/build/esm/identity/records.js +393 -0
- package/build/esm/identity/types.d.ts +102 -2
- package/build/esm/identity/types.d.ts.map +1 -1
- package/build/esm/index.d.ts +4 -1
- package/build/esm/index.d.ts.map +1 -1
- package/build/esm/index.js +2 -0
- package/build/esm/routes/generated.d.ts.map +1 -1
- package/build/esm/routes/generated.js +184 -4
- package/build/esm/routes/overlay.d.ts.map +1 -1
- package/build/esm/routes/overlay.js +53 -58
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ const dispatch_js_1 = require("../host/dispatch.js");
|
|
|
5
5
|
const service_js_1 = require("../drive/service.js");
|
|
6
6
|
const memory_store_js_1 = require("../drive/memory-store.js");
|
|
7
7
|
const service_js_2 = require("../identity/service.js");
|
|
8
|
+
const records_js_1 = require("../identity/records.js");
|
|
8
9
|
const memory_store_js_2 = require("../identity/memory-store.js");
|
|
9
10
|
const fixture_js_1 = require("../identity/fixture.js");
|
|
10
11
|
const types_js_1 = require("../types.js");
|
|
@@ -14,12 +15,20 @@ const types_js_1 = require("../types.js");
|
|
|
14
15
|
* The identity store is built for every case, drive or not, so a case that starts
|
|
15
16
|
* mid-flow can declare the tokens and codes it begins with.
|
|
16
17
|
*/
|
|
17
|
-
const servicesFor = (testCase) =>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
const servicesFor = (testCase) => {
|
|
19
|
+
// one identity store, shared by both services: a token the sign-in flow issues
|
|
20
|
+
// has to be the token a record route reads, and a user a record route creates
|
|
21
|
+
// has to be one the flow can sign in. Two stores built from the same fixture
|
|
22
|
+
// would let a case assert across two independent states and still pass.
|
|
23
|
+
const identity = new memory_store_js_2.MemoryIdentityStore(testCase.identity || fixture_js_1.DEFAULT_IDENTITY);
|
|
24
|
+
return {
|
|
25
|
+
drive: (0, service_js_1.createDriveService)(testCase.unwritable
|
|
26
|
+
? new RefusingDriveStore(testCase.seed || {})
|
|
27
|
+
: new memory_store_js_1.MemoryDriveStore(testCase.seed || {})),
|
|
28
|
+
identity: (0, service_js_2.createIdentityService)(identity),
|
|
29
|
+
records: (0, records_js_1.createRecordService)(identity)
|
|
30
|
+
};
|
|
31
|
+
};
|
|
23
32
|
exports.servicesFor = servicesFor;
|
|
24
33
|
/**
|
|
25
34
|
* A drive that reads but refuses every write.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.deployTree = exports.isDeployTree = exports.checkDeployTree = exports.measureDeployTree = exports.decodeBase64 = exports.DEPLOY_SUCCESS = exports.DEPLOY_PAYLOAD_BYTES = exports.DEPLOY_LIMITS = void 0;
|
|
3
|
+
exports.deployTree = exports.isDeployTree = exports.checkDeployTree = exports.measureDeployTree = exports.decodeBase64 = exports.MAX_DEPTH = exports.DEPLOY_SUCCESS = exports.DEPLOY_PAYLOAD_BYTES = exports.DEPLOY_LIMITS = void 0;
|
|
4
4
|
const types_js_1 = require("../types.js");
|
|
5
5
|
const paths_js_1 = require("./paths.js");
|
|
6
6
|
exports.DEPLOY_LIMITS = {
|
|
@@ -36,6 +36,16 @@ exports.DEPLOY_SUCCESS = {
|
|
|
36
36
|
status: 'success',
|
|
37
37
|
message: 'Files deployed successfully to @sasjs/server.'
|
|
38
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* How deep a tree may nest.
|
|
41
|
+
*
|
|
42
|
+
* A deployment is an app: `services/`, `web/`, a folder per service. A few dozen
|
|
43
|
+
* levels is generous, and the number matters because the walk recurses once per
|
|
44
|
+
* level - without a ceiling, a tree nested deeply enough exhausts the stack and
|
|
45
|
+
* throws a `RangeError`, which is neither the `400` nor the `413` this route
|
|
46
|
+
* documents, on input the cap exists to bound.
|
|
47
|
+
*/
|
|
48
|
+
exports.MAX_DEPTH = 32;
|
|
39
49
|
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
40
50
|
/** A base64 body without its padding, which is what the arithmetic needs. */
|
|
41
51
|
const base64Body = (value) => value.replace(/[^A-Za-z0-9+/]/g, '');
|
|
@@ -82,14 +92,28 @@ const utf8Length = (value) => {
|
|
|
82
92
|
const memberName = (member) => member.type === 'service' ? `${member.name}.sas` : member.name;
|
|
83
93
|
/** The bytes a member writes. */
|
|
84
94
|
const memberBytes = (member) => member.type === 'file' ? base64Length(member.code) : utf8Length(member.code);
|
|
95
|
+
const MEGABYTE = 1024 * 1024;
|
|
85
96
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
97
|
+
* A MEASURED size, rounded UP to one decimal in binary megabytes.
|
|
98
|
+
*
|
|
99
|
+
* Up, not to nearest: a tree one byte over a 100 MB cap must not read as "100 MB
|
|
100
|
+
* (maximum 100 MB)", which is a refusal that states no overage at all. Rounding
|
|
101
|
+
* up is the direction that never understates what is being refused.
|
|
102
|
+
*
|
|
103
|
+
* Bytes below a megabyte are printed as bytes, because a limit small enough to
|
|
104
|
+
* be measured in bytes should not read as "0 MB" - which is what a host passing
|
|
105
|
+
* a tighter set would otherwise report.
|
|
89
106
|
*/
|
|
90
|
-
const describeSize = (bytes) => bytes <
|
|
107
|
+
const describeSize = (bytes) => bytes < MEGABYTE
|
|
91
108
|
? `${bytes} bytes`
|
|
92
|
-
: `${Math.
|
|
109
|
+
: `${Math.ceil((bytes / MEGABYTE) * 10) / 10} MB`;
|
|
110
|
+
/**
|
|
111
|
+
* A LIMIT, printed as it is stated: `DEPLOY_LIMITS` holds exact megabytes, so
|
|
112
|
+
* rounding cannot make it disagree with itself.
|
|
113
|
+
*/
|
|
114
|
+
const describeLimit = (bytes) => bytes < MEGABYTE
|
|
115
|
+
? `${bytes} bytes`
|
|
116
|
+
: `${Math.round((bytes / MEGABYTE) * 10) / 10} MB`;
|
|
93
117
|
/** A walk's starting point. */
|
|
94
118
|
const emptyWalk = () => ({
|
|
95
119
|
entries: 0,
|
|
@@ -113,8 +137,12 @@ const isSafeSegment = (name) => name !== '' &&
|
|
|
113
137
|
* a caller that only wants the numbers gets the size of the whole tree, and the
|
|
114
138
|
* validator decides whether the name matters. Returning early would report a
|
|
115
139
|
* truncated tree as if it were the real one.
|
|
140
|
+
*
|
|
141
|
+
* The one thing that stops the walk is depth: past `MAX_DEPTH` it does not
|
|
142
|
+
* descend, because descending is the work that would exhaust the stack. The
|
|
143
|
+
* level it reached is recorded so the refusal can state it.
|
|
116
144
|
*/
|
|
117
|
-
const walk = (members, parentPath, acc) => {
|
|
145
|
+
const walk = (members, parentPath, acc, depth = 1) => {
|
|
118
146
|
for (const member of members) {
|
|
119
147
|
const name = memberName(member);
|
|
120
148
|
const memberPath = parentPath ? `${parentPath}/${name}` : name;
|
|
@@ -126,7 +154,12 @@ const walk = (members, parentPath, acc) => {
|
|
|
126
154
|
};
|
|
127
155
|
acc.entries++;
|
|
128
156
|
if (member.type === 'folder') {
|
|
129
|
-
|
|
157
|
+
if (depth + 1 > exports.MAX_DEPTH) {
|
|
158
|
+
if (!acc.tooDeep)
|
|
159
|
+
acc.tooDeep = depth + 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
walk(member.members, memberPath, acc, depth + 1);
|
|
130
163
|
continue;
|
|
131
164
|
}
|
|
132
165
|
const bytes = memberBytes(member);
|
|
@@ -166,6 +199,12 @@ const checkDeployTree = (members, limits = exports.DEPLOY_LIMITS) => {
|
|
|
166
199
|
walk(members, '', measured);
|
|
167
200
|
if (measured.nameRefusal)
|
|
168
201
|
return measured.nameRefusal;
|
|
202
|
+
if (measured.tooDeep)
|
|
203
|
+
return {
|
|
204
|
+
code: 413,
|
|
205
|
+
status: 'failure',
|
|
206
|
+
message: `The file tree is nested too deeply: ${measured.tooDeep} levels (maximum ${exports.MAX_DEPTH}).`
|
|
207
|
+
};
|
|
169
208
|
if (measured.entries > limits.entries)
|
|
170
209
|
return {
|
|
171
210
|
code: 413,
|
|
@@ -176,13 +215,13 @@ const checkDeployTree = (members, limits = exports.DEPLOY_LIMITS) => {
|
|
|
176
215
|
return {
|
|
177
216
|
code: 413,
|
|
178
217
|
status: 'failure',
|
|
179
|
-
message: `The file tree exceeds the deploy limit: ${describeSize(measured.totalBytes)} (maximum ${
|
|
218
|
+
message: `The file tree exceeds the deploy limit: ${describeSize(measured.totalBytes)} (maximum ${describeLimit(limits.totalBytes)}).`
|
|
180
219
|
};
|
|
181
220
|
if (measured.largest.bytes > limits.fileBytes)
|
|
182
221
|
return {
|
|
183
222
|
code: 413,
|
|
184
223
|
status: 'failure',
|
|
185
|
-
message: `A file in the tree exceeds the deploy limit: ${measured.largest.path} is ${describeSize(measured.largest.bytes)} (maximum ${
|
|
224
|
+
message: `A file in the tree exceeds the deploy limit: ${measured.largest.path} is ${describeSize(measured.largest.bytes)} (maximum ${describeLimit(limits.fileBytes)} per file).`
|
|
186
225
|
};
|
|
187
226
|
return null;
|
|
188
227
|
};
|
|
@@ -4,6 +4,8 @@ exports.HANDLED_ROUTES = exports.dispatch = void 0;
|
|
|
4
4
|
const index_js_1 = require("../routes/index.js");
|
|
5
5
|
const types_js_1 = require("../types.js");
|
|
6
6
|
const deploy_js_1 = require("../drive/deploy.js");
|
|
7
|
+
/** The defaults sasjs/server's Client schema applies to an omitted lifetime. */
|
|
8
|
+
const SECONDS_IN_A_DAY = 86400;
|
|
7
9
|
/**
|
|
8
10
|
* The parameters the table marks required, checked before the handler runs.
|
|
9
11
|
*
|
|
@@ -42,10 +44,36 @@ const dispatch = async (services, routeId, request = {}) => {
|
|
|
42
44
|
const route = (0, index_js_1.routeById)(routeId);
|
|
43
45
|
if (!route)
|
|
44
46
|
throw new types_js_1.ApiError(404, `No such operation: ${routeId}`);
|
|
47
|
+
const { drive, identity, records } = services;
|
|
48
|
+
// The server authenticates before its payload schema runs, so an anonymous
|
|
49
|
+
// request with an incomplete body is a 401 there rather than a 400. The order
|
|
50
|
+
// is only observable on the route groups that gate on a caller: the sign-in
|
|
51
|
+
// flow has no caller by definition, and the drive has no identity at all.
|
|
52
|
+
const identityScoped = ['User', 'Group', 'Permission', 'Client'].includes(route.tag) ||
|
|
53
|
+
routeId === 'PATCH /SASjsApi/auth/updatePassword';
|
|
54
|
+
if (identityScoped && records) {
|
|
55
|
+
const subject = request.subject ??
|
|
56
|
+
(identity ? await identity.authenticate(request.token) : undefined);
|
|
57
|
+
if (!subject) {
|
|
58
|
+
// A host may serve records without a sign-in flow - it resolves the caller
|
|
59
|
+
// itself and passes `subject`. Without one it cannot authenticate at all,
|
|
60
|
+
// and a 401 would send every request round the login loop for ever, so it
|
|
61
|
+
// is told which capability it is missing instead.
|
|
62
|
+
if (!identity)
|
|
63
|
+
throw new types_js_1.ApiError(501, `This host has no identity service, so it cannot answer ${routeId}.`);
|
|
64
|
+
throw new types_js_1.ApiError(401, 'Unauthorized');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
45
67
|
validate(route, request);
|
|
46
|
-
const { drive, identity } = services;
|
|
47
68
|
const query = request.query || {};
|
|
48
69
|
const body = request.body || {};
|
|
70
|
+
const params = request.params || {};
|
|
71
|
+
/** The record service, or the 501 a host without one answers. */
|
|
72
|
+
const required = () => {
|
|
73
|
+
if (!records)
|
|
74
|
+
throw new types_js_1.ApiError(501, `This host has no record service, so it cannot answer ${routeId}.`);
|
|
75
|
+
return records;
|
|
76
|
+
};
|
|
49
77
|
/** The caller, from the request's own subject or the token it carried. */
|
|
50
78
|
const caller = async () => {
|
|
51
79
|
if (request.subject)
|
|
@@ -138,6 +166,144 @@ const dispatch = async (services, routeId, request = {}) => {
|
|
|
138
166
|
}
|
|
139
167
|
case 'GET /SASjsApi/drive/filetree':
|
|
140
168
|
return { status: 200, body: await drive.tree(query._folderPath) };
|
|
169
|
+
// ------------------------------------------------------------- records
|
|
170
|
+
//
|
|
171
|
+
// The administration routes: users, groups, permission rules, clients and the
|
|
172
|
+
// caller's own password. Each handler resolves the caller and passes it on;
|
|
173
|
+
// the admin gate and the wording live in the service.
|
|
174
|
+
case 'GET /SASjsApi/user':
|
|
175
|
+
return { status: 200, body: await required().listUsers(await caller()) };
|
|
176
|
+
case 'GET /SASjsApi/user/{uid}':
|
|
177
|
+
return {
|
|
178
|
+
status: 200,
|
|
179
|
+
body: await required().getUser(await caller(), {
|
|
180
|
+
uid: String(params.uid ?? '')
|
|
181
|
+
})
|
|
182
|
+
};
|
|
183
|
+
case 'GET /SASjsApi/user/by/username/{username}':
|
|
184
|
+
return {
|
|
185
|
+
status: 200,
|
|
186
|
+
body: await required().getUser(await caller(), {
|
|
187
|
+
username: String(params.username ?? '')
|
|
188
|
+
})
|
|
189
|
+
};
|
|
190
|
+
case 'POST /SASjsApi/user':
|
|
191
|
+
return {
|
|
192
|
+
status: 200,
|
|
193
|
+
body: await required().createUser(await caller(), {
|
|
194
|
+
username: String(body.username ?? ''),
|
|
195
|
+
displayName: String(body.displayName ?? ''),
|
|
196
|
+
password: String(body.password ?? ''),
|
|
197
|
+
isAdmin: body.isAdmin,
|
|
198
|
+
isActive: body.isActive,
|
|
199
|
+
autoExec: body.autoExec
|
|
200
|
+
})
|
|
201
|
+
};
|
|
202
|
+
case 'PATCH /SASjsApi/user/{uid}':
|
|
203
|
+
return {
|
|
204
|
+
status: 200,
|
|
205
|
+
body: await required().updateUser(await caller(), { uid: String(params.uid ?? '') }, body)
|
|
206
|
+
};
|
|
207
|
+
case 'PATCH /SASjsApi/user/by/username/{username}':
|
|
208
|
+
return {
|
|
209
|
+
status: 200,
|
|
210
|
+
body: await required().updateUser(await caller(), { username: String(params.username ?? '') }, body)
|
|
211
|
+
};
|
|
212
|
+
case 'DELETE /SASjsApi/user/{uid}':
|
|
213
|
+
await required().deleteUser(await caller(), { uid: String(params.uid ?? '') }, { password: body.password });
|
|
214
|
+
return { status: 204 };
|
|
215
|
+
case 'DELETE /SASjsApi/user/by/username/{username}':
|
|
216
|
+
await required().deleteUser(await caller(), { username: String(params.username ?? '') }, { password: body.password });
|
|
217
|
+
return { status: 204 };
|
|
218
|
+
case 'GET /SASjsApi/group':
|
|
219
|
+
return { status: 200, body: await required().listGroups(await caller()) };
|
|
220
|
+
case 'GET /SASjsApi/group/{uid}':
|
|
221
|
+
return {
|
|
222
|
+
status: 200,
|
|
223
|
+
body: await required().getGroup(await caller(), {
|
|
224
|
+
uid: String(params.uid ?? '')
|
|
225
|
+
})
|
|
226
|
+
};
|
|
227
|
+
case 'GET /SASjsApi/group/by/groupname/{name}':
|
|
228
|
+
return {
|
|
229
|
+
status: 200,
|
|
230
|
+
body: await required().getGroup(await caller(), {
|
|
231
|
+
name: String(params.name ?? '')
|
|
232
|
+
})
|
|
233
|
+
};
|
|
234
|
+
case 'POST /SASjsApi/group':
|
|
235
|
+
return {
|
|
236
|
+
status: 200,
|
|
237
|
+
body: await required().createGroup(await caller(), {
|
|
238
|
+
name: String(body.name ?? ''),
|
|
239
|
+
description: String(body.description ?? ''),
|
|
240
|
+
isActive: body.isActive
|
|
241
|
+
})
|
|
242
|
+
};
|
|
243
|
+
case 'POST /SASjsApi/group/{groupUid}/{userUid}':
|
|
244
|
+
return {
|
|
245
|
+
status: 200,
|
|
246
|
+
body: await required().addUserToGroup(await caller(), String(params.groupUid ?? ''), String(params.userUid ?? ''))
|
|
247
|
+
};
|
|
248
|
+
case 'DELETE /SASjsApi/group/{groupUid}/{userUid}':
|
|
249
|
+
return {
|
|
250
|
+
status: 200,
|
|
251
|
+
body: await required().removeUserFromGroup(await caller(), String(params.groupUid ?? ''), String(params.userUid ?? ''))
|
|
252
|
+
};
|
|
253
|
+
case 'DELETE /SASjsApi/group/{uid}':
|
|
254
|
+
// the document states 200 for this one - the server answers with the
|
|
255
|
+
// driver's delete result rather than nothing
|
|
256
|
+
await required().deleteGroup(await caller(), String(params.uid ?? ''));
|
|
257
|
+
return { status: 200 };
|
|
258
|
+
case 'GET /SASjsApi/permission':
|
|
259
|
+
return {
|
|
260
|
+
status: 200,
|
|
261
|
+
body: await required().listPermissions(await caller())
|
|
262
|
+
};
|
|
263
|
+
case 'POST /SASjsApi/permission':
|
|
264
|
+
return {
|
|
265
|
+
status: 200,
|
|
266
|
+
body: await required().createPermission(await caller(), {
|
|
267
|
+
path: String(body.path ?? ''),
|
|
268
|
+
type: String(body.type ?? ''),
|
|
269
|
+
setting: String(body.setting ?? ''),
|
|
270
|
+
principalType: String(body.principalType ?? ''),
|
|
271
|
+
principalId: String(body.principalId ?? '')
|
|
272
|
+
})
|
|
273
|
+
};
|
|
274
|
+
case 'PATCH /SASjsApi/permission/{uid}':
|
|
275
|
+
return {
|
|
276
|
+
status: 200,
|
|
277
|
+
body: await required().updatePermission(await caller(), String(params.uid ?? ''), { setting: String(body.setting ?? '') })
|
|
278
|
+
};
|
|
279
|
+
case 'DELETE /SASjsApi/permission/{uid}':
|
|
280
|
+
await required().deletePermission(await caller(), String(params.uid ?? ''));
|
|
281
|
+
return { status: 204 };
|
|
282
|
+
case 'GET /SASjsApi/client':
|
|
283
|
+
return { status: 200, body: await required().listClients(await caller()) };
|
|
284
|
+
case 'POST /SASjsApi/client':
|
|
285
|
+
return {
|
|
286
|
+
status: 200,
|
|
287
|
+
// An omitted lifetime is the schema's default, not NaN: the server's
|
|
288
|
+
// Client model declares 86400 and 2592000, and a NaN lifetime would make
|
|
289
|
+
// every token the client buys expire on arrival.
|
|
290
|
+
body: await required().createClient(await caller(), {
|
|
291
|
+
clientId: String(body.clientId ?? ''),
|
|
292
|
+
clientSecret: String(body.clientSecret ?? ''),
|
|
293
|
+
accessTokenExpiration: body.accessTokenExpiration === undefined
|
|
294
|
+
? SECONDS_IN_A_DAY
|
|
295
|
+
: Number(body.accessTokenExpiration),
|
|
296
|
+
refreshTokenExpiration: body.refreshTokenExpiration === undefined
|
|
297
|
+
? SECONDS_IN_A_DAY * 30
|
|
298
|
+
: Number(body.refreshTokenExpiration)
|
|
299
|
+
})
|
|
300
|
+
};
|
|
301
|
+
case 'PATCH /SASjsApi/auth/updatePassword':
|
|
302
|
+
await required().updatePassword(await caller(), {
|
|
303
|
+
currentPassword: String(body.currentPassword ?? ''),
|
|
304
|
+
newPassword: String(body.newPassword ?? '')
|
|
305
|
+
});
|
|
306
|
+
return { status: 204 };
|
|
141
307
|
default: {
|
|
142
308
|
// The reason the table carries travels with the refusal: a host developer
|
|
143
309
|
// reading a 501 wants to know what the operation waits on, not only that
|
|
@@ -166,5 +332,27 @@ exports.HANDLED_ROUTES = [
|
|
|
166
332
|
'POST /SASLogon/login',
|
|
167
333
|
'POST /SASLogon/authorize',
|
|
168
334
|
'GET /SASLogon/logout',
|
|
169
|
-
'GET /SASjsApi/session'
|
|
335
|
+
'GET /SASjsApi/session',
|
|
336
|
+
'GET /SASjsApi/user',
|
|
337
|
+
'GET /SASjsApi/user/{uid}',
|
|
338
|
+
'GET /SASjsApi/user/by/username/{username}',
|
|
339
|
+
'POST /SASjsApi/user',
|
|
340
|
+
'PATCH /SASjsApi/user/{uid}',
|
|
341
|
+
'PATCH /SASjsApi/user/by/username/{username}',
|
|
342
|
+
'DELETE /SASjsApi/user/{uid}',
|
|
343
|
+
'DELETE /SASjsApi/user/by/username/{username}',
|
|
344
|
+
'GET /SASjsApi/group',
|
|
345
|
+
'GET /SASjsApi/group/{uid}',
|
|
346
|
+
'GET /SASjsApi/group/by/groupname/{name}',
|
|
347
|
+
'POST /SASjsApi/group',
|
|
348
|
+
'POST /SASjsApi/group/{groupUid}/{userUid}',
|
|
349
|
+
'DELETE /SASjsApi/group/{groupUid}/{userUid}',
|
|
350
|
+
'DELETE /SASjsApi/group/{uid}',
|
|
351
|
+
'GET /SASjsApi/permission',
|
|
352
|
+
'POST /SASjsApi/permission',
|
|
353
|
+
'PATCH /SASjsApi/permission/{uid}',
|
|
354
|
+
'DELETE /SASjsApi/permission/{uid}',
|
|
355
|
+
'GET /SASjsApi/client',
|
|
356
|
+
'POST /SASjsApi/client',
|
|
357
|
+
'PATCH /SASjsApi/auth/updatePassword'
|
|
170
358
|
];
|
|
@@ -42,9 +42,12 @@ const live = (issued, now) => issued && (issued.expiresAt === undefined || issue
|
|
|
42
42
|
*/
|
|
43
43
|
class MemoryIdentityStore {
|
|
44
44
|
constructor(fixture = fixture_js_1.DEFAULT_IDENTITY) {
|
|
45
|
-
this.
|
|
45
|
+
this.userList = [];
|
|
46
46
|
this.passwords = new Map();
|
|
47
|
-
this.
|
|
47
|
+
this.groupList = new Map();
|
|
48
|
+
this.permissionList = new Map();
|
|
49
|
+
this.clientRecords = new Map();
|
|
50
|
+
this.clientMap = new Map();
|
|
48
51
|
this.pairs = new Map();
|
|
49
52
|
this.access = new Map();
|
|
50
53
|
this.refresh = new Map();
|
|
@@ -53,24 +56,60 @@ class MemoryIdentityStore {
|
|
|
53
56
|
if (fixture.format !== fixture_js_1.IDENTITY_FORMAT)
|
|
54
57
|
throw new types_js_1.ApiError(500, `The identity fixture states format ${String(fixture.format)}; this store reads ${fixture_js_1.IDENTITY_FORMAT}.`);
|
|
55
58
|
for (const user of fixture.users) {
|
|
56
|
-
this.
|
|
59
|
+
this.userList.push({
|
|
57
60
|
uid: user.uid,
|
|
58
61
|
username: user.username,
|
|
59
62
|
displayName: user.displayName,
|
|
60
63
|
isAdmin: user.isAdmin,
|
|
61
64
|
isActive: user.isActive,
|
|
62
|
-
needsToUpdatePassword: user.needsToUpdatePassword
|
|
65
|
+
needsToUpdatePassword: user.needsToUpdatePassword,
|
|
66
|
+
authProvider: user.authProvider,
|
|
67
|
+
autoExec: user.autoExec
|
|
63
68
|
});
|
|
64
69
|
this.passwords.set(user.uid, user.password);
|
|
65
70
|
}
|
|
66
|
-
for (const client of fixture.clients)
|
|
67
|
-
this.
|
|
71
|
+
for (const client of fixture.clients) {
|
|
72
|
+
this.clientMap.set(client.clientId, {
|
|
68
73
|
clientId: client.clientId,
|
|
69
74
|
accessTokenExpiration: client.accessTokenExpiration,
|
|
70
75
|
refreshTokenExpiration: client.refreshTokenExpiration
|
|
71
76
|
});
|
|
77
|
+
this.clientRecords.set(client.clientId, {
|
|
78
|
+
clientId: client.clientId,
|
|
79
|
+
clientSecret: client.clientSecret,
|
|
80
|
+
accessTokenExpiration: client.accessTokenExpiration,
|
|
81
|
+
refreshTokenExpiration: client.refreshTokenExpiration
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
for (const group of fixture.groups) {
|
|
85
|
+
const uid = group.uid;
|
|
86
|
+
this.groupList.set(uid, {
|
|
87
|
+
uid,
|
|
88
|
+
name: group.name,
|
|
89
|
+
description: group.description,
|
|
90
|
+
isActive: group.isActive,
|
|
91
|
+
authProvider: group.authProvider,
|
|
92
|
+
users: group.users
|
|
93
|
+
.map((username) => this.userList.find((user) => user.username === username)?.uid)
|
|
94
|
+
.filter((member) => member !== undefined)
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
for (const permission of fixture.permissions) {
|
|
98
|
+
this.permissionList.set(permission.uid, {
|
|
99
|
+
uid: permission.uid,
|
|
100
|
+
path: permission.path,
|
|
101
|
+
type: permission.type,
|
|
102
|
+
setting: permission.setting,
|
|
103
|
+
user: permission.user
|
|
104
|
+
? this.userList.find((user) => user.uid === permission.user)?.uid
|
|
105
|
+
: undefined,
|
|
106
|
+
group: permission.group
|
|
107
|
+
? this.groupList.get(permission.group)?.uid
|
|
108
|
+
: undefined
|
|
109
|
+
});
|
|
110
|
+
}
|
|
72
111
|
for (const token of fixture.tokens || []) {
|
|
73
|
-
const user = this.
|
|
112
|
+
const user = this.userList.find((candidate) => candidate.username === token.username);
|
|
74
113
|
if (!user)
|
|
75
114
|
continue;
|
|
76
115
|
const subject = { userId: user.uid, clientId: token.clientId };
|
|
@@ -87,7 +126,7 @@ class MemoryIdentityStore {
|
|
|
87
126
|
});
|
|
88
127
|
}
|
|
89
128
|
for (const code of fixture.codes || []) {
|
|
90
|
-
const user = this.
|
|
129
|
+
const user = this.userList.find((candidate) => candidate.username === code.username);
|
|
91
130
|
if (!user)
|
|
92
131
|
continue;
|
|
93
132
|
this.codes.set(code.code, {
|
|
@@ -96,17 +135,19 @@ class MemoryIdentityStore {
|
|
|
96
135
|
}
|
|
97
136
|
}
|
|
98
137
|
async userByName(username) {
|
|
99
|
-
|
|
138
|
+
const user = this.userList.find((candidate) => candidate.username === username);
|
|
139
|
+
return user ? this.recordOf(user) : undefined;
|
|
100
140
|
}
|
|
101
141
|
async userById(uid) {
|
|
102
|
-
|
|
142
|
+
const user = this.userList.find((candidate) => candidate.uid === uid);
|
|
143
|
+
return user ? this.recordOf(user) : undefined;
|
|
103
144
|
}
|
|
104
145
|
/** A fixture holds the password in clear, so the check is an equality. */
|
|
105
146
|
async passwordMatches(user, password) {
|
|
106
147
|
return this.passwords.get(user.uid) === password;
|
|
107
148
|
}
|
|
108
149
|
async client(clientId) {
|
|
109
|
-
return this.
|
|
150
|
+
return this.clientMap.get(clientId);
|
|
110
151
|
}
|
|
111
152
|
async issue(subject, client) {
|
|
112
153
|
// The lifetimes are the client's, recorded against the instant of issue: a
|
|
@@ -191,5 +232,149 @@ class MemoryIdentityStore {
|
|
|
191
232
|
this.claimed.add(code);
|
|
192
233
|
return true;
|
|
193
234
|
}
|
|
235
|
+
// ---------------------------------------------------------------- records
|
|
236
|
+
async users() {
|
|
237
|
+
return this.userList.map((user) => this.recordOf(user));
|
|
238
|
+
}
|
|
239
|
+
/** A user as the administration routes report one: groups derived, not stored. */
|
|
240
|
+
recordOf(user) {
|
|
241
|
+
return {
|
|
242
|
+
...user,
|
|
243
|
+
groups: [...this.groupList.values()]
|
|
244
|
+
.filter((group) => group.users.includes(user.uid))
|
|
245
|
+
.map((group) => group.uid)
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
async createUser(input) {
|
|
249
|
+
const user = {
|
|
250
|
+
uid: `user-${randomHex(6)}`,
|
|
251
|
+
username: input.username,
|
|
252
|
+
displayName: input.displayName,
|
|
253
|
+
isAdmin: input.isAdmin,
|
|
254
|
+
isActive: input.isActive,
|
|
255
|
+
needsToUpdatePassword: false,
|
|
256
|
+
autoExec: input.autoExec,
|
|
257
|
+
authProvider: input.authProvider
|
|
258
|
+
};
|
|
259
|
+
this.userList.push(user);
|
|
260
|
+
return this.recordOf(user);
|
|
261
|
+
}
|
|
262
|
+
async updateUser(uid, change) {
|
|
263
|
+
const user = this.userList.find((candidate) => candidate.uid === uid);
|
|
264
|
+
if (!user)
|
|
265
|
+
return undefined;
|
|
266
|
+
if (change.username !== undefined)
|
|
267
|
+
user.username = change.username;
|
|
268
|
+
if (change.displayName !== undefined)
|
|
269
|
+
user.displayName = change.displayName;
|
|
270
|
+
if (change.isAdmin !== undefined)
|
|
271
|
+
user.isAdmin = change.isAdmin;
|
|
272
|
+
if (change.isActive !== undefined)
|
|
273
|
+
user.isActive = change.isActive;
|
|
274
|
+
if (change.needsToUpdatePassword !== undefined)
|
|
275
|
+
user.needsToUpdatePassword = change.needsToUpdatePassword;
|
|
276
|
+
if (change.autoExec !== undefined)
|
|
277
|
+
user.autoExec = change.autoExec;
|
|
278
|
+
return this.recordOf(user);
|
|
279
|
+
}
|
|
280
|
+
async deleteUser(uid) {
|
|
281
|
+
const index = this.userList.findIndex((user) => user.uid === uid);
|
|
282
|
+
if (index === -1)
|
|
283
|
+
return;
|
|
284
|
+
this.userList.splice(index, 1);
|
|
285
|
+
this.passwords.delete(uid);
|
|
286
|
+
for (const group of this.groupList.values())
|
|
287
|
+
group.users = group.users.filter((member) => member !== uid);
|
|
288
|
+
for (const [key, permission] of this.permissionList)
|
|
289
|
+
if (permission.user === uid)
|
|
290
|
+
this.permissionList.delete(key);
|
|
291
|
+
for (const key of this.pairs.keys())
|
|
292
|
+
if (key.startsWith(`${uid}|`))
|
|
293
|
+
this.pairs.delete(key);
|
|
294
|
+
}
|
|
295
|
+
async setPassword(uid, password) {
|
|
296
|
+
this.passwords.set(uid, password);
|
|
297
|
+
}
|
|
298
|
+
async groups() {
|
|
299
|
+
return [...this.groupList.values()];
|
|
300
|
+
}
|
|
301
|
+
async groupById(uid) {
|
|
302
|
+
return this.groupList.get(uid);
|
|
303
|
+
}
|
|
304
|
+
async groupByName(name) {
|
|
305
|
+
return [...this.groupList.values()].find((group) => group.name === name);
|
|
306
|
+
}
|
|
307
|
+
async createGroup(input) {
|
|
308
|
+
const group = {
|
|
309
|
+
uid: `group-${randomHex(6)}`,
|
|
310
|
+
name: input.name,
|
|
311
|
+
description: input.description,
|
|
312
|
+
isActive: input.isActive,
|
|
313
|
+
users: []
|
|
314
|
+
};
|
|
315
|
+
this.groupList.set(group.uid, group);
|
|
316
|
+
return group;
|
|
317
|
+
}
|
|
318
|
+
async deleteGroup(uid) {
|
|
319
|
+
this.groupList.delete(uid);
|
|
320
|
+
for (const [key, permission] of this.permissionList)
|
|
321
|
+
if (permission.group === uid)
|
|
322
|
+
this.permissionList.delete(key);
|
|
323
|
+
}
|
|
324
|
+
async addToGroup(groupUid, userUid) {
|
|
325
|
+
const group = this.groupList.get(groupUid);
|
|
326
|
+
if (!group)
|
|
327
|
+
return undefined;
|
|
328
|
+
if (!group.users.includes(userUid))
|
|
329
|
+
group.users.push(userUid);
|
|
330
|
+
return group;
|
|
331
|
+
}
|
|
332
|
+
async removeFromGroup(groupUid, userUid) {
|
|
333
|
+
const group = this.groupList.get(groupUid);
|
|
334
|
+
if (!group)
|
|
335
|
+
return undefined;
|
|
336
|
+
group.users = group.users.filter((member) => member !== userUid);
|
|
337
|
+
return group;
|
|
338
|
+
}
|
|
339
|
+
async permissions() {
|
|
340
|
+
return [...this.permissionList.values()];
|
|
341
|
+
}
|
|
342
|
+
async permissionById(uid) {
|
|
343
|
+
return this.permissionList.get(uid);
|
|
344
|
+
}
|
|
345
|
+
async createPermission(input) {
|
|
346
|
+
const permission = {
|
|
347
|
+
uid: `rule-${randomHex(6)}`,
|
|
348
|
+
path: input.path,
|
|
349
|
+
type: input.type,
|
|
350
|
+
setting: input.setting,
|
|
351
|
+
user: input.user,
|
|
352
|
+
group: input.group
|
|
353
|
+
};
|
|
354
|
+
this.permissionList.set(permission.uid, permission);
|
|
355
|
+
return permission;
|
|
356
|
+
}
|
|
357
|
+
async setPermissionSetting(uid, setting) {
|
|
358
|
+
const permission = this.permissionList.get(uid);
|
|
359
|
+
if (!permission)
|
|
360
|
+
return undefined;
|
|
361
|
+
permission.setting = setting;
|
|
362
|
+
return permission;
|
|
363
|
+
}
|
|
364
|
+
async deletePermission(uid) {
|
|
365
|
+
this.permissionList.delete(uid);
|
|
366
|
+
}
|
|
367
|
+
async clients() {
|
|
368
|
+
return [...this.clientRecords.values()];
|
|
369
|
+
}
|
|
370
|
+
async createClient(input) {
|
|
371
|
+
this.clientRecords.set(input.clientId, input);
|
|
372
|
+
this.clientMap.set(input.clientId, {
|
|
373
|
+
clientId: input.clientId,
|
|
374
|
+
accessTokenExpiration: input.accessTokenExpiration,
|
|
375
|
+
refreshTokenExpiration: input.refreshTokenExpiration
|
|
376
|
+
});
|
|
377
|
+
return input;
|
|
378
|
+
}
|
|
194
379
|
}
|
|
195
380
|
exports.MemoryIdentityStore = MemoryIdentityStore;
|