@nextage/nx-frame-be 1.0.46 → 1.0.48

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.
@@ -32,6 +32,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
32
32
  const constants_1 = require("../../constants");
33
33
  const base_model_1 = require("../base.model");
34
34
  const mongo_utils_1 = require("../mongo-utils");
35
+ const mongo_utils_2 = require("../mongo-utils");
35
36
  const enums_1 = require("../../enums");
36
37
  /** Marks a document every tenant shares, as an application would mark it. */
37
38
  const SHARED = '__GLOBAL__';
@@ -125,6 +126,17 @@ describe('a model that overrides nothing is scoped anyway', () => {
125
126
  yield expect(unaware.remove(doc.id, outsider)).rejects.toBeDefined();
126
127
  expect(yield unaware.mgModel.collection.countDocuments({})).toBe(1);
127
128
  }));
129
+ // A refusal that only happens when the document EXISTS is an oracle: delete an id at
130
+ // random and the answer tells you whether it names something in somebody else's tenant.
131
+ // The two outcomes must therefore be indistinguishable - the existence of another
132
+ // tenant's document is not a fact this caller is entitled to learn.
133
+ it('refuses a delete the same way whether the document is missing or another tenant\'s', () => __awaiter(void 0, void 0, void 0, function* () {
134
+ const doc = yield unaware.create({ code: 'A' }, owner);
135
+ const missing = (0, mongo_utils_2.genObjectId)().toHexString();
136
+ const foreign = yield unaware.remove(doc.id, outsider).then(() => null, (e) => e.constructor.name);
137
+ const absent = yield unaware.remove(missing, outsider).then(() => null, (e) => e.constructor.name);
138
+ expect(absent).toBe(foreign);
139
+ }));
128
140
  it('refuses to update another tenant document', () => __awaiter(void 0, void 0, void 0, function* () {
129
141
  const doc = yield unaware.create({ code: 'A' }, owner);
130
142
  yield expect(unaware.update(doc.id, { code: 'B' }, outsider)).rejects.toBeDefined();
@@ -214,6 +214,13 @@ export declare class BaseModel<TAttrs, TDoc extends BaseMongoDoc, TMongoModel ex
214
214
  * id. The document is re-read unscoped on purpose - the point is to judge its real
215
215
  * owner, and a scoped read would hide the very case being guarded against.
216
216
  *
217
+ * A MISSING document is refused in exactly the same way as a foreign one, and that is
218
+ * not defensive coding: refusing only when the document exists turns this hook into an
219
+ * oracle. Delete an id at random and the outcome answers a question the caller was
220
+ * never entitled to ask - whether that id names something in somebody else's tenant.
221
+ * The two cases are therefore made indistinguishable, which costs nothing: deleting an
222
+ * id that matches nothing had no effect to begin with.
223
+ *
217
224
  * @param {*} id
218
225
  */
219
226
  beforeRemove(id: string, context: NxContext): Promise<string>;
@@ -410,12 +410,21 @@ class BaseModel extends events_1.EventEmitter {
410
410
  * id. The document is re-read unscoped on purpose - the point is to judge its real
411
411
  * owner, and a scoped read would hide the very case being guarded against.
412
412
  *
413
+ * A MISSING document is refused in exactly the same way as a foreign one, and that is
414
+ * not defensive coding: refusing only when the document exists turns this hook into an
415
+ * oracle. Delete an id at random and the outcome answers a question the caller was
416
+ * never entitled to ask - whether that id names something in somebody else's tenant.
417
+ * The two cases are therefore made indistinguishable, which costs nothing: deleting an
418
+ * id that matches nothing had no effect to begin with.
419
+ *
413
420
  * @param {*} id
414
421
  */
415
422
  beforeRemove(id, context) {
416
423
  return __awaiter(this, void 0, void 0, function* () {
417
424
  if (this.tenancy && id) {
418
425
  const doc = yield this.mgModel.findOne({ _id: (0, mongo_utils_1.toObjectId)(id) });
426
+ if (!doc)
427
+ throw new errors_1.ForbiddenError();
419
428
  this.assertWritable(doc, context);
420
429
  }
421
430
  return id;
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ /* -------------------------------------------------------------------------- */
3
+ /* Per-operation @auth role overrides */
4
+ /* */
5
+ /* These tests assert the SDL emitted by the generator, not a running server: */
6
+ /* the point is WHICH directive is attached to WHICH generated operation. */
7
+ /* Mounting Apollo here is impossible anyway, since registering the same */
8
+ /* controller twice collides on `type Mutation`. */
9
+ /* -------------------------------------------------------------------------- */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ const coded_entity_controller_1 = require("../../models/coded-entity.controller");
12
+ const generator_1 = require("../generator");
13
+ const loader_1 = require("../loader");
14
+ const READ = 'READ_ROLE';
15
+ const WRITE = 'WRITE_ROLE';
16
+ const NARROW = 'CROSS_TENANT_ROLE';
17
+ /**
18
+ * `loadSchema` APPENDS to the process-wide `mainSchema`, so every scenario has to
19
+ * start from an empty accumulator or it would read the previous scenario's SDL.
20
+ */
21
+ function generate(roles) {
22
+ loader_1.mainSchema.typeDefs.query = '';
23
+ loader_1.mainSchema.typeDefs.mutation = '';
24
+ loader_1.mainSchema.typeDefs.subscription = '';
25
+ loader_1.mainSchema.typeDefs.type = '';
26
+ loader_1.mainSchema.resolvers = {};
27
+ (0, generator_1.generateSchema)(coded_entity_controller_1.ceCtrl, roles ? { roles } : {});
28
+ return {
29
+ query: loader_1.mainSchema.typeDefs.query,
30
+ mutation: loader_1.mainSchema.typeDefs.mutation,
31
+ subscription: loader_1.mainSchema.typeDefs.subscription
32
+ };
33
+ }
34
+ /**
35
+ * Return the `@auth` directive argument attached to one generated operation, or
36
+ * `null` when the operation is absent from the SDL.
37
+ *
38
+ * IMPORTANT: the match is anchored on the FIELD (`name(`), never on the bare name:
39
+ * `codedEntity` is a prefix of `codedEntityList`, and asserting on the bare name
40
+ * would silently read the wrong line.
41
+ */
42
+ function authOf(sdl, operation) {
43
+ var _a;
44
+ const line = sdl.split('\n').find(l => l.trim().startsWith(`${operation}(`));
45
+ if (!line)
46
+ return null;
47
+ const match = line.match(/@auth(\(requires: "[^"]*"\))?/);
48
+ return match ? ((_a = match[1]) !== null && _a !== void 0 ? _a : '') : null;
49
+ }
50
+ const requires = (roles) => `(requires: "${roles}")`;
51
+ /* -------------------------------------------------------------------------- */
52
+ /* Default behaviour (non-regression) */
53
+ /* -------------------------------------------------------------------------- */
54
+ it('keeps read on queries and write on mutations when no override is given', () => {
55
+ const sdl = generate({ read: READ, write: WRITE });
56
+ expect(authOf(sdl.query, 'codedEntityCount')).toEqual(requires(READ));
57
+ expect(authOf(sdl.query, 'codedEntityList')).toEqual(requires(READ));
58
+ expect(authOf(sdl.query, 'codedEntity')).toEqual(requires(READ));
59
+ expect(authOf(sdl.query, 'findOneCodedEntity')).toEqual(requires(READ));
60
+ expect(authOf(sdl.mutation, 'createCodedEntity')).toEqual(requires(WRITE));
61
+ expect(authOf(sdl.mutation, 'updateCodedEntity')).toEqual(requires(WRITE));
62
+ expect(authOf(sdl.mutation, 'removeCodedEntity')).toEqual(requires(WRITE));
63
+ expect(authOf(sdl.mutation, 'copyCodedEntity')).toEqual(requires(WRITE));
64
+ expect(authOf(sdl.mutation, 'bulkRemoveCodedEntity')).toEqual(requires(WRITE));
65
+ });
66
+ it('emits a bare @auth when a role list is not configured', () => {
67
+ const sdl = generate({ write: WRITE });
68
+ expect(authOf(sdl.query, 'codedEntityList')).toEqual('');
69
+ expect(authOf(sdl.mutation, 'removeCodedEntity')).toEqual(requires(WRITE));
70
+ });
71
+ it('still accepts a plain role list as read and write', () => {
72
+ const sdl = generate([READ, WRITE]);
73
+ expect(authOf(sdl.query, 'codedEntityList')).toEqual(requires(`${READ},${WRITE}`));
74
+ expect(authOf(sdl.mutation, 'removeCodedEntity')).toEqual(requires(`${READ},${WRITE}`));
75
+ });
76
+ /* -------------------------------------------------------------------------- */
77
+ /* Overrides */
78
+ /* -------------------------------------------------------------------------- */
79
+ it('narrows a single mutation and leaves its siblings on write', () => {
80
+ const sdl = generate({ read: READ, write: WRITE, overrides: { remove: NARROW } });
81
+ expect(authOf(sdl.mutation, 'removeCodedEntity')).toEqual(requires(NARROW));
82
+ expect(authOf(sdl.mutation, 'createCodedEntity')).toEqual(requires(WRITE));
83
+ expect(authOf(sdl.mutation, 'updateCodedEntity')).toEqual(requires(WRITE));
84
+ expect(authOf(sdl.mutation, 'copyCodedEntity')).toEqual(requires(WRITE));
85
+ expect(authOf(sdl.mutation, 'bulkRemoveCodedEntity')).toEqual(requires(WRITE));
86
+ expect(authOf(sdl.mutation, 'removeCodedEntityArrayItem')).toEqual(requires(WRITE));
87
+ });
88
+ it('narrows a single query and leaves its siblings on read', () => {
89
+ const sdl = generate({ read: READ, write: WRITE, overrides: { list: NARROW } });
90
+ expect(authOf(sdl.query, 'codedEntityList')).toEqual(requires(NARROW));
91
+ expect(authOf(sdl.query, 'codedEntityCount')).toEqual(requires(READ));
92
+ expect(authOf(sdl.query, 'codedEntity')).toEqual(requires(READ));
93
+ expect(authOf(sdl.query, 'findOneCodedEntity')).toEqual(requires(READ));
94
+ });
95
+ it('joins a multi-role override into a single requires argument', () => {
96
+ const sdl = generate({ read: READ, write: WRITE, overrides: { remove: [NARROW, 'ADMIN'] } });
97
+ expect(authOf(sdl.mutation, 'removeCodedEntity')).toEqual(requires(`${NARROW},ADMIN`));
98
+ });
99
+ /* -------------------------------------------------------------------------- */
100
+ /* Fail-safe */
101
+ /* -------------------------------------------------------------------------- */
102
+ it('falls back to the default instead of publishing the operation when an override is empty', () => {
103
+ const empty = generate({ read: READ, write: WRITE, overrides: { remove: [], list: '' } });
104
+ expect(authOf(empty.mutation, 'removeCodedEntity')).toEqual(requires(WRITE));
105
+ expect(authOf(empty.query, 'codedEntityList')).toEqual(requires(READ));
106
+ const missing = generate({ read: READ, write: WRITE, overrides: { remove: undefined } });
107
+ expect(authOf(missing.mutation, 'removeCodedEntity')).toEqual(requires(WRITE));
108
+ });
109
+ /* -------------------------------------------------------------------------- */
110
+ /* Subscriptions */
111
+ /* -------------------------------------------------------------------------- */
112
+ it('does not let an override reach the subscriptions, which always follow read', () => {
113
+ const sdl = generate({ read: READ, write: WRITE, overrides: { remove: NARROW } });
114
+ expect(sdl.subscription.match(new RegExp(`@auth\\(requires: "${READ}"\\)`, 'g'))).toHaveLength(3);
115
+ expect(sdl.subscription).not.toContain(NARROW);
116
+ });
@@ -79,6 +79,19 @@ exports.foldedEntityFields = {
79
79
  `
80
80
  };
81
81
  exports.mainSubChannel = 'mainCH';
82
+ /**
83
+ * Every base operation generated by generateTypeDefs, in declaration order.
84
+ */
85
+ const baseMethodNames = [
86
+ 'count', 'list', 'get', 'findOne',
87
+ 'create', 'update', 'remove', 'copy',
88
+ 'addArrayItem', 'updateArrayItem', 'removeArrayItem',
89
+ 'bulkUpdate', 'bulkRemove'
90
+ ];
91
+ /**
92
+ * Base operations that default to the `read` roles; every other one defaults to `write`.
93
+ */
94
+ const readMethodNames = ['count', 'list', 'get', 'findOne'];
82
95
  /**
83
96
  *
84
97
  * @param modelName
@@ -119,24 +132,52 @@ function getSubscriptionNames(modelName) {
119
132
  };
120
133
  }
121
134
  /**
135
+ * Formats a role list as an `@auth` directive argument list.
136
+ *
137
+ * @param roles
138
+ * @returns `(requires: "ROLE_A,ROLE_B")`, or an empty string when no role is required
139
+ */
140
+ function toAuthDirective(roles) {
141
+ if (!roles)
142
+ return '';
143
+ return `(requires: "${Array.isArray(roles) ? roles.join(',') : roles}")`;
144
+ }
145
+ /**
146
+ * Resolves the `@auth` directive of every generated operation.
147
+ *
148
+ * Read operations default to `roles.read` and the remaining ones to `roles.write`; a per-operation
149
+ * entry in `roles.overrides` replaces that default for that operation only.
150
+ *
151
+ * IMPORTANT: an override is applied only when it carries roles. An entry whose value is empty or
152
+ * undefined is treated as ABSENT and the default stands, because the alternative fails OPEN: a
153
+ * mistyped constant, or one that did not resolve, would silently strip the directive and publish
154
+ * the operation to everyone. An operation meant to require no role is expressed by leaving the
155
+ * module's `roles` out, not by an empty override.
122
156
  *
123
157
  * @param extension
124
- * @returns
158
+ * @returns the directive suffix of each base operation, empty string when unconstrained
125
159
  */
126
160
  function getAuth(extension = {}) {
127
- const roles = { read: '', write: '' };
161
+ var _a;
162
+ const methods = {};
163
+ const auth = { read: '', write: '', methods };
164
+ for (const method of baseMethodNames)
165
+ methods[method] = '';
128
166
  if (!extension.roles)
129
- return roles;
167
+ return auth;
130
168
  if ((0, utils_1.isString)(extension.roles))
131
169
  extension.roles = extension.roles.split(',');
132
170
  if (Array.isArray(extension.roles))
133
171
  extension.roles = { read: extension.roles, write: extension.roles };
134
172
  const sr = extension.roles;
135
- if ((sr.read))
136
- roles.read = `(requires: "${Array.isArray(sr.read) ? sr.read.join(',') : sr.read}")`;
137
- if (sr.write)
138
- roles.write = `(requires: "${Array.isArray(sr.write) ? sr.write.join(',') : sr.write}")`;
139
- return roles;
173
+ auth.read = toAuthDirective(sr.read);
174
+ auth.write = toAuthDirective(sr.write);
175
+ for (const method of baseMethodNames) {
176
+ const override = (_a = sr.overrides) === null || _a === void 0 ? void 0 : _a[method];
177
+ const fallback = readMethodNames.includes(method) ? auth.read : auth.write;
178
+ methods[method] = (override === null || override === void 0 ? void 0 : override.length) ? toAuthDirective(override) : fallback;
179
+ }
180
+ return auth;
140
181
  }
141
182
  /**
142
183
  *
@@ -163,23 +204,23 @@ function generateTypeDefs(ctrl, schemaExtension = {}, mergeCodedEntity, readOnly
163
204
  dataFields = exports.codedEntityFields;
164
205
  const baseTypeDefs = {
165
206
  query: `
166
- ${methods.count}(params: ${modelName}Query) : Int! @auth${roles.read}
167
- ${methods.list}(params: ${modelName}Query, pagination: OffsetPagination): [${modelName}]! @auth${roles.read}
168
- ${methods.get}(id: ID!) : ${modelName}! @auth${roles.read}
169
- ${methods.findOne}(params: ${modelName}Query) : ${modelName} @auth${roles.read}
207
+ ${methods.count}(params: ${modelName}Query) : Int! @auth${roles.methods.count}
208
+ ${methods.list}(params: ${modelName}Query, pagination: OffsetPagination): [${modelName}]! @auth${roles.methods.list}
209
+ ${methods.get}(id: ID!) : ${modelName}! @auth${roles.methods.get}
210
+ ${methods.findOne}(params: ${modelName}Query) : ${modelName} @auth${roles.methods.findOne}
170
211
  `,
171
212
  mutation: `
172
- ${methods.create}(item: ${modelName}Input!) : ${modelName}! @auth${roles.write}
173
- ${methods.update}(id: ID!, item: ${modelName}Input!) : ${modelName}! @auth${roles.write}
174
- ${methods.remove}(id: ID!) : ${modelName}! @auth${roles.write}
175
- ${methods.copy}(id: ID!, item: ${modelName}Input!) : ${modelName}! @auth${roles.write}
213
+ ${methods.create}(item: ${modelName}Input!) : ${modelName}! @auth${roles.methods.create}
214
+ ${methods.update}(id: ID!, item: ${modelName}Input!) : ${modelName}! @auth${roles.methods.update}
215
+ ${methods.remove}(id: ID!) : ${modelName}! @auth${roles.methods.remove}
216
+ ${methods.copy}(id: ID!, item: ${modelName}Input!) : ${modelName}! @auth${roles.methods.copy}
176
217
 
177
- ${methods.addArrayItem}(id: ID!, item: AddArrayElemItem!) : ${modelName}! @auth${roles.write}
178
- ${methods.updateArrayItem}(id: ID!, item: UpdateArrayElemItem!) : ${modelName}! @auth${roles.write}
179
- ${methods.removeArrayItem}(id: ID!, item: RemoveArrayElemItem!) : ${modelName}! @auth${roles.write}
218
+ ${methods.addArrayItem}(id: ID!, item: AddArrayElemItem!) : ${modelName}! @auth${roles.methods.addArrayItem}
219
+ ${methods.updateArrayItem}(id: ID!, item: UpdateArrayElemItem!) : ${modelName}! @auth${roles.methods.updateArrayItem}
220
+ ${methods.removeArrayItem}(id: ID!, item: RemoveArrayElemItem!) : ${modelName}! @auth${roles.methods.removeArrayItem}
180
221
 
181
- ${methods.bulkUpdate}(ids: [ID]!, items: [${modelName}Input]!) : [${modelName}]! @auth${roles.write}
182
- ${methods.bulkRemove}(ids: [ID]!) : [${modelName}]! @auth${roles.write}
222
+ ${methods.bulkUpdate}(ids: [ID]!, items: [${modelName}Input]!) : [${modelName}]! @auth${roles.methods.bulkUpdate}
223
+ ${methods.bulkRemove}(ids: [ID]!) : [${modelName}]! @auth${roles.methods.bulkRemove}
183
224
  `,
184
225
  subscription: `
185
226
  ${subscriptions.create.id}(channelId: String!, filter: ${modelName}Query): ${modelName}! @auth${roles.read}
@@ -64,4 +64,28 @@ export interface NxGraphQLSchemaExtension extends NxGraphQLSchemaData {
64
64
  export interface NxGraphQLSchemaRole {
65
65
  read?: string | string[];
66
66
  write?: string | string[];
67
+ overrides?: NxGraphQLSchemaRoleOverrides;
68
+ }
69
+ /**
70
+ * Per-operation role overrides.
71
+ *
72
+ * Each key is one of the generated base methods (see NxGraphQLBaseMethodNames) and its value
73
+ * replaces the roles required by that single operation. Operations left out keep the default
74
+ * behaviour: `read` for count/list/get/findOne, `write` for every other operation.
75
+ *
76
+ * IMPORTANT: an empty or undefined value does NOT remove the role constraint, it falls back to
77
+ * the default. Publishing an operation without any constraint must stay an explicit choice made
78
+ * by omitting `read`/`write`, never an accident of a half-filled override map.
79
+ *
80
+ * Subscriptions are not overridable: they always follow `read`.
81
+ */
82
+ export type NxGraphQLSchemaRoleOverrides = Partial<Record<keyof NxGraphQLBaseMethodNames, string | string[]>>;
83
+ /**
84
+ * Resolved `@auth` directive suffixes, ready to be interpolated into the generated SDL.
85
+ * Each value is either an empty string (no role constraint) or `(requires: "ROLE_A,ROLE_B")`.
86
+ */
87
+ export interface NxGraphQLAuthDirectives {
88
+ read: string;
89
+ write: string;
90
+ methods: Record<keyof NxGraphQLBaseMethodNames, string>;
67
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextage/nx-frame-be",
3
- "version": "1.0.46",
3
+ "version": "1.0.48",
4
4
  "description": "",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -16,6 +16,9 @@
16
16
  "jest": {
17
17
  "preset": "ts-jest",
18
18
  "testEnvironment": "node",
19
+ "testPathIgnorePatterns": [
20
+ "<rootDir>/build/"
21
+ ],
19
22
  "setupFilesAfterEnv": [
20
23
  "./src/test/setup.ts"
21
24
  ]