@nextage/nx-frame-be 1.0.44 → 1.0.46
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/build/common/base/__test__/audit.test.d.ts +15 -0
- package/build/common/base/__test__/audit.test.js +126 -0
- package/build/common/base/__test__/before-get-contract.test.d.ts +16 -0
- package/build/common/base/__test__/before-get-contract.test.js +90 -0
- package/build/common/base/__test__/tenancy-opt-out.test.d.ts +21 -0
- package/build/common/base/__test__/tenancy-opt-out.test.js +214 -0
- package/build/common/base/audit.d.ts +55 -0
- package/build/common/base/audit.js +77 -0
- package/build/common/base/base.model.d.ts +67 -2
- package/build/common/base/base.model.js +132 -9
- package/build/common/base/mongo.types.d.ts +17 -0
- package/build/common/base/tenancy.d.ts +101 -0
- package/build/common/base/tenancy.js +66 -0
- package/build/common/crypto/index.d.ts +1 -0
- package/build/common/crypto/index.js +1 -0
- package/build/common/enums.d.ts +17 -0
- package/build/common/enums.js +19 -1
- package/build/common/index.d.ts +2 -0
- package/build/common/index.js +2 -0
- package/build/common/types.d.ts +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*********************************************************************************************
|
|
2
|
+
* *
|
|
3
|
+
* AUDIT - the library keeps `createdBy`/`createdAt`/`updatedBy`/`updatedAt` for models that *
|
|
4
|
+
* declare them, and keeps out of the way of models that do not. *
|
|
5
|
+
* *
|
|
6
|
+
* The classes below are EMPTY on purpose. Whatever is stamped has to come from the library, *
|
|
7
|
+
* so a test that passed while a model helped would prove nothing about the models that *
|
|
8
|
+
* never do - which are precisely the ones that used to be left without an audit. *
|
|
9
|
+
* *
|
|
10
|
+
* Every assertion is on the payload the hooks return, not on stored documents: the point is *
|
|
11
|
+
* what the library puts there, and mongo would only add a round trip and a way to be wrong *
|
|
12
|
+
* about it. *
|
|
13
|
+
* *
|
|
14
|
+
*********************************************************************************************/
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*********************************************************************************************
|
|
3
|
+
* *
|
|
4
|
+
* AUDIT - the library keeps `createdBy`/`createdAt`/`updatedBy`/`updatedAt` for models that *
|
|
5
|
+
* declare them, and keeps out of the way of models that do not. *
|
|
6
|
+
* *
|
|
7
|
+
* The classes below are EMPTY on purpose. Whatever is stamped has to come from the library, *
|
|
8
|
+
* so a test that passed while a model helped would prove nothing about the models that *
|
|
9
|
+
* never do - which are precisely the ones that used to be left without an audit. *
|
|
10
|
+
* *
|
|
11
|
+
* Every assertion is on the payload the hooks return, not on stored documents: the point is *
|
|
12
|
+
* what the library puts there, and mongo would only add a round trip and a way to be wrong *
|
|
13
|
+
* about it. *
|
|
14
|
+
* *
|
|
15
|
+
*********************************************************************************************/
|
|
16
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
17
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
18
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
19
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
20
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
21
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
22
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
const base_model_1 = require("../base.model");
|
|
27
|
+
const mongo_utils_1 = require("../mongo-utils");
|
|
28
|
+
/** Declares all four fields, as a model wanting an audit does. */
|
|
29
|
+
class Audited extends base_model_1.BaseModel {
|
|
30
|
+
}
|
|
31
|
+
/** Declares none of them, as a model wanting no audit does. */
|
|
32
|
+
class Plain extends base_model_1.BaseModel {
|
|
33
|
+
}
|
|
34
|
+
/** Declares them and keeps them itself - the reason `audit: false` exists. */
|
|
35
|
+
class OptedOut extends base_model_1.BaseModel {
|
|
36
|
+
}
|
|
37
|
+
const auditedSchema = {
|
|
38
|
+
code: { type: String, required: true },
|
|
39
|
+
createdBy: { type: String },
|
|
40
|
+
createdAt: { type: Date },
|
|
41
|
+
updatedBy: { type: String },
|
|
42
|
+
updatedAt: { type: Date }
|
|
43
|
+
};
|
|
44
|
+
const audited = (0, mongo_utils_1.createModel)({
|
|
45
|
+
name: 'AuditAudited',
|
|
46
|
+
modelName: 'AuditAudited',
|
|
47
|
+
collection: 'auditAudited',
|
|
48
|
+
classDef: Audited,
|
|
49
|
+
modelParams: {},
|
|
50
|
+
schema: auditedSchema
|
|
51
|
+
}, true);
|
|
52
|
+
const plain = (0, mongo_utils_1.createModel)({
|
|
53
|
+
name: 'AuditPlain',
|
|
54
|
+
modelName: 'AuditPlain',
|
|
55
|
+
collection: 'auditPlain',
|
|
56
|
+
classDef: Plain,
|
|
57
|
+
modelParams: {},
|
|
58
|
+
schema: { code: { type: String, required: true } }
|
|
59
|
+
}, true);
|
|
60
|
+
const optedOut = (0, mongo_utils_1.createModel)({
|
|
61
|
+
name: 'AuditOptedOut',
|
|
62
|
+
modelName: 'AuditOptedOut',
|
|
63
|
+
collection: 'auditOptedOut',
|
|
64
|
+
classDef: OptedOut,
|
|
65
|
+
modelParams: { audit: false },
|
|
66
|
+
schema: auditedSchema
|
|
67
|
+
}, true);
|
|
68
|
+
const caller = { user: { id: 'user-1' } };
|
|
69
|
+
const other = { user: { id: 'user-2' } };
|
|
70
|
+
const anonymous = {};
|
|
71
|
+
describe('audit kept by the library', () => {
|
|
72
|
+
describe('a model that declares the fields', () => {
|
|
73
|
+
it('stamps author and moment on creation', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
74
|
+
const item = yield audited.beforeCreate({ code: 'a' }, caller);
|
|
75
|
+
expect(item.createdBy).toBe('user-1');
|
|
76
|
+
expect(item.createdAt).toBeInstanceOf(Date);
|
|
77
|
+
}));
|
|
78
|
+
it('stamps author and moment on update, and never the creation ones', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
79
|
+
const item = yield audited.beforeUpdate({ code: 'a' }, other);
|
|
80
|
+
expect(item.updatedBy).toBe('user-2');
|
|
81
|
+
expect(item.updatedAt).toBeInstanceOf(Date);
|
|
82
|
+
expect(item.createdBy).toBeUndefined();
|
|
83
|
+
expect(item.createdAt).toBeUndefined();
|
|
84
|
+
}));
|
|
85
|
+
// The payload is the client's, so an audit it could write would say whatever the client
|
|
86
|
+
// wanted it to say - which is the same as having none.
|
|
87
|
+
it('ignores an author named by the payload', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
88
|
+
const created = yield audited.beforeCreate({ code: 'a', createdBy: 'forged' }, caller);
|
|
89
|
+
const updated = yield audited.beforeUpdate({ code: 'a', updatedBy: 'forged' }, caller);
|
|
90
|
+
expect(created.createdBy).toBe('user-1');
|
|
91
|
+
expect(updated.updatedBy).toBe('user-1');
|
|
92
|
+
}));
|
|
93
|
+
// A document is created once: an update carrying the creation fields is either noise or
|
|
94
|
+
// an attempt to rewrite history, and dropping them costs nothing either way.
|
|
95
|
+
it('drops the creation fields an update tries to carry', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
96
|
+
const item = yield audited.beforeUpdate({ code: 'a', createdBy: 'forged', createdAt: new Date(0) }, caller);
|
|
97
|
+
expect(item.createdBy).toBeUndefined();
|
|
98
|
+
expect(item.createdAt).toBeUndefined();
|
|
99
|
+
}));
|
|
100
|
+
// Nothing to attribute, so nothing is attributed - rather than a document claiming an
|
|
101
|
+
// author that does not exist.
|
|
102
|
+
it('leaves the author out when there is no user, and still records the moment', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
103
|
+
const item = yield audited.beforeCreate({ code: 'a' }, anonymous);
|
|
104
|
+
expect(item.createdBy).toBeUndefined();
|
|
105
|
+
expect(item.createdAt).toBeInstanceOf(Date);
|
|
106
|
+
}));
|
|
107
|
+
});
|
|
108
|
+
describe('a model that declares none of them', () => {
|
|
109
|
+
// What keeps this change safe for consumers that never asked for an audit: no field
|
|
110
|
+
// appears in a collection that did not declare it.
|
|
111
|
+
it('is left untouched', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
112
|
+
const created = yield plain.beforeCreate({ code: 'a' }, caller);
|
|
113
|
+
const updated = yield plain.beforeUpdate({ code: 'a' }, caller);
|
|
114
|
+
expect(created).toEqual({ code: 'a' });
|
|
115
|
+
expect(updated).toEqual({ code: 'a' });
|
|
116
|
+
}));
|
|
117
|
+
});
|
|
118
|
+
describe('a model that opted out', () => {
|
|
119
|
+
it('is left untouched even though it declares the fields', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
120
|
+
const created = yield optedOut.beforeCreate({ code: 'a' }, caller);
|
|
121
|
+
const updated = yield optedOut.beforeUpdate({ code: 'a' }, caller);
|
|
122
|
+
expect(created).toEqual({ code: 'a' });
|
|
123
|
+
expect(updated).toEqual({ code: 'a' });
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*********************************************************************************************
|
|
2
|
+
* *
|
|
3
|
+
* BEFOREGET CONTRACT *
|
|
4
|
+
* *
|
|
5
|
+
* `beforeGet` is declared `Promise<FilterParams>`, so the filter it returns is part of the *
|
|
6
|
+
* contract. `get` used to await it and throw the result away, handing `findOne` the object *
|
|
7
|
+
* it had built itself - which worked only because every override in existence MUTATES its *
|
|
8
|
+
* argument. *
|
|
9
|
+
* *
|
|
10
|
+
* That difference is invisible at compile time and silent at run time, and it is the whole *
|
|
11
|
+
* point of this file: an override written in pure style - `return { ...filter, tenantId }`, *
|
|
12
|
+
* the shape the signature invites - compiled, passed review, and read across every tenant. *
|
|
13
|
+
* These tests state that BOTH styles scope the read. *
|
|
14
|
+
* *
|
|
15
|
+
*********************************************************************************************/
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*********************************************************************************************
|
|
3
|
+
* *
|
|
4
|
+
* BEFOREGET CONTRACT *
|
|
5
|
+
* *
|
|
6
|
+
* `beforeGet` is declared `Promise<FilterParams>`, so the filter it returns is part of the *
|
|
7
|
+
* contract. `get` used to await it and throw the result away, handing `findOne` the object *
|
|
8
|
+
* it had built itself - which worked only because every override in existence MUTATES its *
|
|
9
|
+
* argument. *
|
|
10
|
+
* *
|
|
11
|
+
* That difference is invisible at compile time and silent at run time, and it is the whole *
|
|
12
|
+
* point of this file: an override written in pure style - `return { ...filter, tenantId }`, *
|
|
13
|
+
* the shape the signature invites - compiled, passed review, and read across every tenant. *
|
|
14
|
+
* These tests state that BOTH styles scope the read. *
|
|
15
|
+
* *
|
|
16
|
+
*********************************************************************************************/
|
|
17
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
18
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
19
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
20
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
21
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
22
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
23
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
const base_model_1 = require("../base.model");
|
|
28
|
+
const mongo_utils_1 = require("../mongo-utils");
|
|
29
|
+
/** Scopes the read the way every override in the wild does today: by MUTATING the filter. */
|
|
30
|
+
class MutatingModel extends base_model_1.BaseModel {
|
|
31
|
+
beforeGet() {
|
|
32
|
+
return __awaiter(this, arguments, void 0, function* (filter = {}, context = {}) {
|
|
33
|
+
filter.tenantId = context.tenantId;
|
|
34
|
+
return filter;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Scopes the read in pure style - the shape the `Promise<FilterParams>` signature invites. */
|
|
39
|
+
class PureModel extends base_model_1.BaseModel {
|
|
40
|
+
beforeGet() {
|
|
41
|
+
return __awaiter(this, arguments, void 0, function* (filter = {}, context = {}) {
|
|
42
|
+
return Object.assign(Object.assign({}, filter), { tenantId: context.tenantId });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const schema = {
|
|
47
|
+
code: { type: String, required: true },
|
|
48
|
+
tenantId: { type: String, required: true },
|
|
49
|
+
};
|
|
50
|
+
const mutating = (0, mongo_utils_1.createModel)({
|
|
51
|
+
name: 'BeforeGetMutating',
|
|
52
|
+
modelName: 'BeforeGetMutating',
|
|
53
|
+
collection: 'beforeGetMutating',
|
|
54
|
+
classDef: MutatingModel,
|
|
55
|
+
modelParams: {},
|
|
56
|
+
schema
|
|
57
|
+
}, true);
|
|
58
|
+
const pure = (0, mongo_utils_1.createModel)({
|
|
59
|
+
name: 'BeforeGetPure',
|
|
60
|
+
modelName: 'BeforeGetPure',
|
|
61
|
+
collection: 'beforeGetPure',
|
|
62
|
+
classDef: PureModel,
|
|
63
|
+
modelParams: {},
|
|
64
|
+
schema
|
|
65
|
+
}, true);
|
|
66
|
+
describe('BaseModel.get honours the filter beforeGet returns', () => {
|
|
67
|
+
const owner = { tenantId: 'tenant-a' };
|
|
68
|
+
const outsider = { tenantId: 'tenant-b' };
|
|
69
|
+
let mutatingId;
|
|
70
|
+
let pureId;
|
|
71
|
+
// Inserted per test: the shared setup empties every collection before each one.
|
|
72
|
+
beforeEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
73
|
+
const a = yield mutating.mgModel.collection.insertOne({ code: 'A', tenantId: owner.tenantId });
|
|
74
|
+
const b = yield pure.mgModel.collection.insertOne({ code: 'B', tenantId: owner.tenantId });
|
|
75
|
+
mutatingId = a.insertedId.toString();
|
|
76
|
+
pureId = b.insertedId.toString();
|
|
77
|
+
}));
|
|
78
|
+
it('denies a foreign read when the override MUTATES the filter', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
79
|
+
expect(yield mutating.get(mutatingId, outsider)).toBeNull();
|
|
80
|
+
}));
|
|
81
|
+
// The one that used to pass in silence: the filter handed to `findOne` was the untouched
|
|
82
|
+
// `{ _id }`, so another tenant's document came back in full.
|
|
83
|
+
it('denies a foreign read when the override RETURNS a new filter', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
84
|
+
expect(yield pure.get(pureId, outsider)).toBeNull();
|
|
85
|
+
}));
|
|
86
|
+
it('still finds the document for its own tenant, in both styles', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
87
|
+
expect(yield mutating.get(mutatingId, owner)).not.toBeNull();
|
|
88
|
+
expect(yield pure.get(pureId, owner)).not.toBeNull();
|
|
89
|
+
}));
|
|
90
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/*********************************************************************************************
|
|
2
|
+
* *
|
|
3
|
+
* TENANT SCOPING IS OPT-OUT *
|
|
4
|
+
* *
|
|
5
|
+
* These tests state the behaviour of a model that overrides NOTHING. That is the whole *
|
|
6
|
+
* point: every leak this file guards against was born the same way - a class that declared *
|
|
7
|
+
* a tenant field and forgot one of the hooks that isolate it, silently answering every *
|
|
8
|
+
* tenant from then on. *
|
|
9
|
+
* *
|
|
10
|
+
* So none of the models below has a body. What they have is a schema with a tenant field *
|
|
11
|
+
* and, where it matters, a declared mode - and the isolation has to follow from that *
|
|
12
|
+
* alone. *
|
|
13
|
+
* *
|
|
14
|
+
* Two properties are asserted together, and they pull in opposite directions on purpose: *
|
|
15
|
+
* *
|
|
16
|
+
* 1. WITH a provider registered, an unaware model is scoped anyway. *
|
|
17
|
+
* 2. WITHOUT one, nothing changes at all - the library is used by consumers that have no *
|
|
18
|
+
* notion of a tenant, and for them this file must be a no-op. *
|
|
19
|
+
* *
|
|
20
|
+
*********************************************************************************************/
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*********************************************************************************************
|
|
3
|
+
* *
|
|
4
|
+
* TENANT SCOPING IS OPT-OUT *
|
|
5
|
+
* *
|
|
6
|
+
* These tests state the behaviour of a model that overrides NOTHING. That is the whole *
|
|
7
|
+
* point: every leak this file guards against was born the same way - a class that declared *
|
|
8
|
+
* a tenant field and forgot one of the hooks that isolate it, silently answering every *
|
|
9
|
+
* tenant from then on. *
|
|
10
|
+
* *
|
|
11
|
+
* So none of the models below has a body. What they have is a schema with a tenant field *
|
|
12
|
+
* and, where it matters, a declared mode - and the isolation has to follow from that *
|
|
13
|
+
* alone. *
|
|
14
|
+
* *
|
|
15
|
+
* Two properties are asserted together, and they pull in opposite directions on purpose: *
|
|
16
|
+
* *
|
|
17
|
+
* 1. WITH a provider registered, an unaware model is scoped anyway. *
|
|
18
|
+
* 2. WITHOUT one, nothing changes at all - the library is used by consumers that have no *
|
|
19
|
+
* notion of a tenant, and for them this file must be a no-op. *
|
|
20
|
+
* *
|
|
21
|
+
*********************************************************************************************/
|
|
22
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
23
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
24
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
25
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
26
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
27
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
28
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
const constants_1 = require("../../constants");
|
|
33
|
+
const base_model_1 = require("../base.model");
|
|
34
|
+
const mongo_utils_1 = require("../mongo-utils");
|
|
35
|
+
const enums_1 = require("../../enums");
|
|
36
|
+
/** Marks a document every tenant shares, as an application would mark it. */
|
|
37
|
+
const SHARED = '__GLOBAL__';
|
|
38
|
+
/**
|
|
39
|
+
* A provider written the way an application would write one: it knows the roles, the
|
|
40
|
+
* library does not. `systemTier` stands in for the role group entitled to the shared tier.
|
|
41
|
+
*/
|
|
42
|
+
const provider = {
|
|
43
|
+
field: 'tenantId',
|
|
44
|
+
read(mode, context) {
|
|
45
|
+
const tenant = context.tenantId;
|
|
46
|
+
return (mode === enums_1.TenancyMode.shared) ? { $in: [tenant, SHARED] } : tenant;
|
|
47
|
+
},
|
|
48
|
+
write(mode, requested, context) {
|
|
49
|
+
const ctx = context;
|
|
50
|
+
if (mode === enums_1.TenancyMode.shared && requested === SHARED) {
|
|
51
|
+
if (!ctx.systemTier)
|
|
52
|
+
throw new Error('denied');
|
|
53
|
+
return SHARED;
|
|
54
|
+
}
|
|
55
|
+
return ctx.tenantId;
|
|
56
|
+
},
|
|
57
|
+
canWrite(mode, current, context) {
|
|
58
|
+
const ctx = context;
|
|
59
|
+
if (current === SHARED)
|
|
60
|
+
return !!ctx.systemTier;
|
|
61
|
+
return current === ctx.tenantId;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const schema = {
|
|
65
|
+
code: { type: String, required: true },
|
|
66
|
+
tenantId: { type: String, required: true }
|
|
67
|
+
};
|
|
68
|
+
/** No body: whatever isolates it has to come from the library. */
|
|
69
|
+
class Unaware extends base_model_1.BaseModel {
|
|
70
|
+
}
|
|
71
|
+
/** Same, in a collection that mixes the shared tier with each tenant's own additions. */
|
|
72
|
+
class Mixed extends base_model_1.BaseModel {
|
|
73
|
+
}
|
|
74
|
+
/** Same, exempted on purpose - as a collection written by event listeners must be. */
|
|
75
|
+
class Exempt extends base_model_1.BaseModel {
|
|
76
|
+
}
|
|
77
|
+
const unaware = (0, mongo_utils_1.createModel)({
|
|
78
|
+
name: 'TenancyUnaware',
|
|
79
|
+
modelName: 'TenancyUnaware',
|
|
80
|
+
collection: 'tenancyUnaware',
|
|
81
|
+
classDef: Unaware,
|
|
82
|
+
modelParams: {},
|
|
83
|
+
schema
|
|
84
|
+
}, true);
|
|
85
|
+
const mixed = (0, mongo_utils_1.createModel)({
|
|
86
|
+
name: 'TenancyMixed',
|
|
87
|
+
modelName: 'TenancyMixed',
|
|
88
|
+
collection: 'tenancyMixed',
|
|
89
|
+
classDef: Mixed,
|
|
90
|
+
modelParams: { tenancy: enums_1.TenancyMode.shared },
|
|
91
|
+
schema
|
|
92
|
+
}, true);
|
|
93
|
+
const exempt = (0, mongo_utils_1.createModel)({
|
|
94
|
+
name: 'TenancyExempt',
|
|
95
|
+
modelName: 'TenancyExempt',
|
|
96
|
+
collection: 'tenancyExempt',
|
|
97
|
+
classDef: Exempt,
|
|
98
|
+
modelParams: { tenancy: enums_1.TenancyMode.none },
|
|
99
|
+
schema
|
|
100
|
+
}, true);
|
|
101
|
+
const owner = { user: { id: 'u1' }, tenantId: 'tenant-a' };
|
|
102
|
+
const outsider = { user: { id: 'u2' }, tenantId: 'tenant-b' };
|
|
103
|
+
const system = { user: { id: 'u3' }, tenantId: 'tenant-a', systemTier: true };
|
|
104
|
+
describe('a model that overrides nothing is scoped anyway', () => {
|
|
105
|
+
beforeEach(() => { constants_1.APP.tenancy = provider; });
|
|
106
|
+
afterEach(() => { delete constants_1.APP.tenancy; });
|
|
107
|
+
it('stamps the owner on create, ignoring what the payload asked for', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
108
|
+
const doc = yield unaware.create({ code: 'A', tenantId: 'tenant-b' }, owner);
|
|
109
|
+
expect(doc.tenantId).toBe('tenant-a');
|
|
110
|
+
}));
|
|
111
|
+
it('does not resolve another tenant document by id', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
112
|
+
const doc = yield unaware.create({ code: 'A' }, owner);
|
|
113
|
+
expect(yield unaware.get(doc.id, outsider)).toBeNull();
|
|
114
|
+
expect(yield unaware.get(doc.id, owner)).not.toBeNull();
|
|
115
|
+
}));
|
|
116
|
+
it('keeps another tenant out of list, count and findOne', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
117
|
+
yield unaware.create({ code: 'A' }, owner);
|
|
118
|
+
expect(yield unaware.list({}, {}, {}, outsider)).toHaveLength(0);
|
|
119
|
+
expect(yield unaware.count({}, outsider)).toBe(0);
|
|
120
|
+
expect(yield unaware.findOne({ code: 'A' }, outsider)).toBeNull();
|
|
121
|
+
expect(yield unaware.list({}, {}, {}, owner)).toHaveLength(1);
|
|
122
|
+
}));
|
|
123
|
+
it('refuses to delete another tenant document', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
124
|
+
const doc = yield unaware.create({ code: 'A' }, owner);
|
|
125
|
+
yield expect(unaware.remove(doc.id, outsider)).rejects.toBeDefined();
|
|
126
|
+
expect(yield unaware.mgModel.collection.countDocuments({})).toBe(1);
|
|
127
|
+
}));
|
|
128
|
+
it('refuses to update another tenant document', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
129
|
+
const doc = yield unaware.create({ code: 'A' }, owner);
|
|
130
|
+
yield expect(unaware.update(doc.id, { code: 'B' }, outsider)).rejects.toBeDefined();
|
|
131
|
+
}));
|
|
132
|
+
it('will not let an update re-home a document into another tenant', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
133
|
+
const doc = yield unaware.create({ code: 'A' }, owner);
|
|
134
|
+
const updated = yield unaware.update(doc.id, { code: 'B', tenantId: 'tenant-b' }, owner);
|
|
135
|
+
expect(updated.tenantId).toBe('tenant-a');
|
|
136
|
+
}));
|
|
137
|
+
});
|
|
138
|
+
describe('a shared collection reads across tiers and writes within one', () => {
|
|
139
|
+
beforeEach(() => { constants_1.APP.tenancy = provider; });
|
|
140
|
+
afterEach(() => { delete constants_1.APP.tenancy; });
|
|
141
|
+
it('shows a tenant both the shared documents and its own', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
142
|
+
yield mixed.create({ code: 'SYS', tenantId: SHARED }, system);
|
|
143
|
+
yield mixed.create({ code: 'OWN' }, owner);
|
|
144
|
+
const seen = yield mixed.list({}, {}, {}, owner);
|
|
145
|
+
expect(seen.map(d => d.code).sort()).toEqual(['OWN', 'SYS']);
|
|
146
|
+
}));
|
|
147
|
+
it('hides another tenant additions while still showing the shared ones', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
148
|
+
yield mixed.mgModel.collection.insertOne({ code: 'SYS', tenantId: SHARED });
|
|
149
|
+
yield mixed.create({ code: 'OWN' }, owner);
|
|
150
|
+
const seen = yield mixed.list({}, {}, {}, outsider);
|
|
151
|
+
expect(seen.map(d => d.code)).toEqual(['SYS']);
|
|
152
|
+
}));
|
|
153
|
+
it('lets the system tier create a shared document', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
154
|
+
const doc = yield mixed.create({ code: 'SYS', tenantId: SHARED }, system);
|
|
155
|
+
expect(doc.tenantId).toBe(SHARED);
|
|
156
|
+
}));
|
|
157
|
+
it('refuses a shared document to a tenant caller, rather than quietly downgrading it', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
158
|
+
yield expect(mixed.create({ code: 'SYS', tenantId: SHARED }, owner)).rejects.toBeDefined();
|
|
159
|
+
}));
|
|
160
|
+
// The one a filter alone cannot express: the shared document IS readable by this caller,
|
|
161
|
+
// so scoping the read is not enough to stop the write.
|
|
162
|
+
it('refuses a tenant caller the update and the removal of a shared document', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
163
|
+
const inserted = yield mixed.mgModel.collection.insertOne({ code: 'SYS', tenantId: SHARED });
|
|
164
|
+
const id = inserted.insertedId.toString();
|
|
165
|
+
yield expect(mixed.update(id, { code: 'HACKED' }, owner)).rejects.toBeDefined();
|
|
166
|
+
yield expect(mixed.remove(id, owner)).rejects.toBeDefined();
|
|
167
|
+
}));
|
|
168
|
+
it('lets the system tier update a shared document', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
169
|
+
const inserted = yield mixed.mgModel.collection.insertOne({ code: 'SYS', tenantId: SHARED });
|
|
170
|
+
const updated = yield mixed.update(inserted.insertedId.toString(), { code: 'FIXED' }, system);
|
|
171
|
+
expect(updated.code).toBe('FIXED');
|
|
172
|
+
}));
|
|
173
|
+
});
|
|
174
|
+
describe('subscription payloads are scoped too', () => {
|
|
175
|
+
beforeEach(() => { constants_1.APP.tenancy = provider; });
|
|
176
|
+
afterEach(() => { delete constants_1.APP.tenancy; });
|
|
177
|
+
// T58: both hooks defaulted to "any authenticated user", and nothing in the project
|
|
178
|
+
// overrode them - so an event carried a document to every subscriber on the channel.
|
|
179
|
+
it('withholds another tenant document from a subscriber', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
180
|
+
const doc = { code: 'A', tenantId: 'tenant-a' };
|
|
181
|
+
expect(yield unaware.checkContextPermission(doc, owner)).not.toBeNull();
|
|
182
|
+
expect(yield unaware.checkContextPermission(doc, outsider)).toBeNull();
|
|
183
|
+
}));
|
|
184
|
+
it('delivers a shared document to every tenant, and a private one to its owner alone', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
185
|
+
expect(yield mixed.checkContextPermission({ code: 'S', tenantId: SHARED }, outsider)).not.toBeNull();
|
|
186
|
+
expect(yield mixed.checkContextPermission({ code: 'P', tenantId: 'tenant-a' }, outsider)).toBeNull();
|
|
187
|
+
}));
|
|
188
|
+
it('turns away a channel subscriber from another tenant', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
189
|
+
expect(yield unaware.checkChannelSubscriber({ tenantId: 'tenant-a' }, owner)).toBe(true);
|
|
190
|
+
expect(yield unaware.checkChannelSubscriber({ tenantId: 'tenant-a' }, outsider)).toBe(false);
|
|
191
|
+
}));
|
|
192
|
+
});
|
|
193
|
+
describe('an exempt model is left alone', () => {
|
|
194
|
+
beforeEach(() => { constants_1.APP.tenancy = provider; });
|
|
195
|
+
afterEach(() => { delete constants_1.APP.tenancy; });
|
|
196
|
+
// Written by event listeners, which run with no user in context: scoping them
|
|
197
|
+
// automatically would break the very cascade they exist to perform.
|
|
198
|
+
it('neither stamps nor filters', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
199
|
+
yield exempt.create({ code: 'A', tenantId: 'tenant-b' }, owner);
|
|
200
|
+
expect(yield exempt.list({}, {}, {}, outsider)).toHaveLength(1);
|
|
201
|
+
expect(yield exempt.list({}, {}, {}, {})).toHaveLength(1);
|
|
202
|
+
}));
|
|
203
|
+
});
|
|
204
|
+
describe('with no provider registered nothing is scoped at all', () => {
|
|
205
|
+
// The library is used by projects that have no notion of a tenant. For them every
|
|
206
|
+
// assertion above must NOT hold, or upgrading would silently empty their queries.
|
|
207
|
+
it('behaves as it did before tenancy existed', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
208
|
+
expect(constants_1.APP.tenancy).toBeUndefined();
|
|
209
|
+
const doc = yield unaware.create({ code: 'A', tenantId: 'tenant-b' }, owner);
|
|
210
|
+
expect(doc.tenantId).toBe('tenant-b');
|
|
211
|
+
expect(yield unaware.list({}, {}, {}, outsider)).toHaveLength(1);
|
|
212
|
+
expect(yield unaware.get(doc.id, outsider)).not.toBeNull();
|
|
213
|
+
}));
|
|
214
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*********************************************************************************************
|
|
2
|
+
* *
|
|
3
|
+
* AUDIT - who created a document and when, who last changed it and when. *
|
|
4
|
+
* *
|
|
5
|
+
* Written by `BaseModel` rather than by every model, for the same reason tenancy is: a *
|
|
6
|
+
* rule each author has to remember is a rule that gets forgotten, and forgetting this one *
|
|
7
|
+
* is SILENT - the document is simply stored without a trace of who made it. *
|
|
8
|
+
* *
|
|
9
|
+
* That is not hypothetical. Before this existed, some sixty assignments were spread over *
|
|
10
|
+
* twenty-one models in four spellings of the same line, two of them commented out, and one *
|
|
11
|
+
* model wrote fields its schema never declared - so mongoose dropped them and the audit it *
|
|
12
|
+
* appeared to keep was never kept at all. *
|
|
13
|
+
* *
|
|
14
|
+
* ONLY WHAT THE SCHEMA DECLARES *
|
|
15
|
+
* *
|
|
16
|
+
* A field is stamped only if the model's schema has it. That keeps the change INERT for *
|
|
17
|
+
* every consumer that never asked for an audit - nothing appears in collections that did *
|
|
18
|
+
* not declare it - which is the same property that made tenancy safe to invert. Wanting *
|
|
19
|
+
* the audit is therefore an act: declare the field. *
|
|
20
|
+
* *
|
|
21
|
+
* WHY NOT MONGOOSE `timestamps: true` *
|
|
22
|
+
* *
|
|
23
|
+
* Because it knows the WHEN and cannot know the WHO. `createdBy` needs the request context, *
|
|
24
|
+
* which mongoose has no access to, so the timestamps would come from the schema and the *
|
|
25
|
+
* actors from here - two mechanisms, in two places, that drift apart the moment a write *
|
|
26
|
+
* goes through only one of them. `updateArrayItem` and `addArrayItem` are exactly that *
|
|
27
|
+
* write: they call `findOneAndUpdate` directly, so mongoose would refresh `updatedAt` *
|
|
28
|
+
* while `updatedBy` kept naming whoever wrote last through `update()`. An audit that names *
|
|
29
|
+
* the wrong author is worse than one that names nobody, because it is believed. *
|
|
30
|
+
* *
|
|
31
|
+
*********************************************************************************************/
|
|
32
|
+
import { NxContext, NxObject } from '../interfaces';
|
|
33
|
+
/** The four fields this module knows how to keep, and the only ones it ever touches. */
|
|
34
|
+
export declare const AUDIT_FIELDS: {
|
|
35
|
+
createdBy: string;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
updatedBy: string;
|
|
38
|
+
updatedAt: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Stamps the audit fields a schema declares, and leaves every other field alone.
|
|
42
|
+
*
|
|
43
|
+
* On UPDATE the creation fields are dropped from the payload instead of being checked: a
|
|
44
|
+
* document is created once, so an update carrying `createdBy` is either noise or an attempt
|
|
45
|
+
* to rewrite history, and neither is worth honouring. Same reasoning as the tenant field.
|
|
46
|
+
*
|
|
47
|
+
* The user is read from the context and never from the payload - a client naming its own
|
|
48
|
+
* author would make the whole record worthless.
|
|
49
|
+
*
|
|
50
|
+
* @param item the payload, mutated in place
|
|
51
|
+
* @param schema the model's schema definition, used to know which fields exist
|
|
52
|
+
* @param context the request context
|
|
53
|
+
* @param creating whether this is a creation or an update
|
|
54
|
+
*/
|
|
55
|
+
export declare function stampAudit(item: NxObject | undefined, schema: NxObject | undefined, context: NxContext | undefined, creating: boolean): void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*********************************************************************************************
|
|
3
|
+
* *
|
|
4
|
+
* AUDIT - who created a document and when, who last changed it and when. *
|
|
5
|
+
* *
|
|
6
|
+
* Written by `BaseModel` rather than by every model, for the same reason tenancy is: a *
|
|
7
|
+
* rule each author has to remember is a rule that gets forgotten, and forgetting this one *
|
|
8
|
+
* is SILENT - the document is simply stored without a trace of who made it. *
|
|
9
|
+
* *
|
|
10
|
+
* That is not hypothetical. Before this existed, some sixty assignments were spread over *
|
|
11
|
+
* twenty-one models in four spellings of the same line, two of them commented out, and one *
|
|
12
|
+
* model wrote fields its schema never declared - so mongoose dropped them and the audit it *
|
|
13
|
+
* appeared to keep was never kept at all. *
|
|
14
|
+
* *
|
|
15
|
+
* ONLY WHAT THE SCHEMA DECLARES *
|
|
16
|
+
* *
|
|
17
|
+
* A field is stamped only if the model's schema has it. That keeps the change INERT for *
|
|
18
|
+
* every consumer that never asked for an audit - nothing appears in collections that did *
|
|
19
|
+
* not declare it - which is the same property that made tenancy safe to invert. Wanting *
|
|
20
|
+
* the audit is therefore an act: declare the field. *
|
|
21
|
+
* *
|
|
22
|
+
* WHY NOT MONGOOSE `timestamps: true` *
|
|
23
|
+
* *
|
|
24
|
+
* Because it knows the WHEN and cannot know the WHO. `createdBy` needs the request context, *
|
|
25
|
+
* which mongoose has no access to, so the timestamps would come from the schema and the *
|
|
26
|
+
* actors from here - two mechanisms, in two places, that drift apart the moment a write *
|
|
27
|
+
* goes through only one of them. `updateArrayItem` and `addArrayItem` are exactly that *
|
|
28
|
+
* write: they call `findOneAndUpdate` directly, so mongoose would refresh `updatedAt` *
|
|
29
|
+
* while `updatedBy` kept naming whoever wrote last through `update()`. An audit that names *
|
|
30
|
+
* the wrong author is worse than one that names nobody, because it is believed. *
|
|
31
|
+
* *
|
|
32
|
+
*********************************************************************************************/
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.AUDIT_FIELDS = void 0;
|
|
35
|
+
exports.stampAudit = stampAudit;
|
|
36
|
+
/** The four fields this module knows how to keep, and the only ones it ever touches. */
|
|
37
|
+
exports.AUDIT_FIELDS = {
|
|
38
|
+
createdBy: 'createdBy',
|
|
39
|
+
createdAt: 'createdAt',
|
|
40
|
+
updatedBy: 'updatedBy',
|
|
41
|
+
updatedAt: 'updatedAt'
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Stamps the audit fields a schema declares, and leaves every other field alone.
|
|
45
|
+
*
|
|
46
|
+
* On UPDATE the creation fields are dropped from the payload instead of being checked: a
|
|
47
|
+
* document is created once, so an update carrying `createdBy` is either noise or an attempt
|
|
48
|
+
* to rewrite history, and neither is worth honouring. Same reasoning as the tenant field.
|
|
49
|
+
*
|
|
50
|
+
* The user is read from the context and never from the payload - a client naming its own
|
|
51
|
+
* author would make the whole record worthless.
|
|
52
|
+
*
|
|
53
|
+
* @param item the payload, mutated in place
|
|
54
|
+
* @param schema the model's schema definition, used to know which fields exist
|
|
55
|
+
* @param context the request context
|
|
56
|
+
* @param creating whether this is a creation or an update
|
|
57
|
+
*/
|
|
58
|
+
function stampAudit(item, schema, context, creating) {
|
|
59
|
+
var _a;
|
|
60
|
+
if (!item || !schema)
|
|
61
|
+
return;
|
|
62
|
+
const userId = (_a = context === null || context === void 0 ? void 0 : context.user) === null || _a === void 0 ? void 0 : _a.id;
|
|
63
|
+
const now = new Date();
|
|
64
|
+
if (creating) {
|
|
65
|
+
if (exports.AUDIT_FIELDS.createdAt in schema)
|
|
66
|
+
item[exports.AUDIT_FIELDS.createdAt] = now;
|
|
67
|
+
if (userId && exports.AUDIT_FIELDS.createdBy in schema)
|
|
68
|
+
item[exports.AUDIT_FIELDS.createdBy] = userId;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
delete item[exports.AUDIT_FIELDS.createdBy];
|
|
72
|
+
delete item[exports.AUDIT_FIELDS.createdAt];
|
|
73
|
+
if (exports.AUDIT_FIELDS.updatedAt in schema)
|
|
74
|
+
item[exports.AUDIT_FIELDS.updatedAt] = now;
|
|
75
|
+
if (userId && exports.AUDIT_FIELDS.updatedBy in schema)
|
|
76
|
+
item[exports.AUDIT_FIELDS.updatedBy] = userId;
|
|
77
|
+
}
|