piper-utils 1.1.65 → 1.1.67
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/bin/main.js +414 -2
- package/bin/main.js.map +1 -1
- package/package.json +1 -1
- package/src/audit/audit.js +422 -0
- package/src/audit/audit.test.js +313 -0
- package/src/database/dbUtils/partnerAccess/accessContext.js +70 -0
- package/src/database/dbUtils/partnerAccess/accessGates.js +34 -0
- package/src/database/dbUtils/partnerAccess/accessScope.js +99 -0
- package/src/database/dbUtils/partnerAccess/accessWrites.js +128 -0
- package/src/database/dbUtils/partnerAccess/createAccessHelpers.js +38 -0
- package/src/index.js +98 -64
- package/src/requestResponse/errorCodes.js +5 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import {
|
|
2
|
+
attachAudit,
|
|
3
|
+
auditModels,
|
|
4
|
+
bindAuditRequest,
|
|
5
|
+
decrementWithAudit,
|
|
6
|
+
getAuditFilter,
|
|
7
|
+
getAuditModel,
|
|
8
|
+
incrementWithAudit,
|
|
9
|
+
modelOptions
|
|
10
|
+
} from './audit.js';
|
|
11
|
+
|
|
12
|
+
const buildEvent = ({ uid = '42', email = 'kevin@test.com', auditEnabled = true, businessIds = { '1': 'A' } } = {}) => ({
|
|
13
|
+
requestContext: {
|
|
14
|
+
authorizer: {
|
|
15
|
+
claims: {
|
|
16
|
+
'custom:UID': uid,
|
|
17
|
+
'email': email,
|
|
18
|
+
'custom:SET': JSON.stringify({ auditEnabled }),
|
|
19
|
+
'custom:AR': JSON.stringify({ businessIds })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const resetState = () => {
|
|
26
|
+
Object.keys(auditModels).forEach((k) => delete auditModels[k]);
|
|
27
|
+
Object.keys(modelOptions).forEach((k) => delete modelOptions[k]);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('audit', () => {
|
|
31
|
+
describe('bindAuditRequest', () => {
|
|
32
|
+
it('binds user from JWT claims and businessId from arg', () => {
|
|
33
|
+
resetState();
|
|
34
|
+
const Model = { name: 'Customer' };
|
|
35
|
+
const event = buildEvent({ uid: '7', email: 'kevin@test.com', auditEnabled: true });
|
|
36
|
+
|
|
37
|
+
bindAuditRequest(event, 'BIZ-1', [Model]);
|
|
38
|
+
|
|
39
|
+
expect(modelOptions['Customer'].user.username).toEqual('kevin@test.com');
|
|
40
|
+
expect(modelOptions['Customer'].user.id).toEqual(7);
|
|
41
|
+
expect(modelOptions['Customer'].businessId).toEqual('BIZ-1');
|
|
42
|
+
expect(modelOptions['Customer'].auditEnabled).toEqual(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('reads auditEnabled=false from custom:SET claim', () => {
|
|
46
|
+
resetState();
|
|
47
|
+
const Model = { name: 'Customer' };
|
|
48
|
+
const event = buildEvent({ auditEnabled: false });
|
|
49
|
+
|
|
50
|
+
bindAuditRequest(event, 'BIZ-1', [Model]);
|
|
51
|
+
|
|
52
|
+
expect(modelOptions['Customer'].auditEnabled).toEqual(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('binds multiple models in one call', () => {
|
|
56
|
+
resetState();
|
|
57
|
+
const A = { name: 'ModelA' };
|
|
58
|
+
const B = { name: 'ModelB' };
|
|
59
|
+
|
|
60
|
+
bindAuditRequest(buildEvent(), 'BIZ-1', [A, B]);
|
|
61
|
+
|
|
62
|
+
expect(modelOptions['ModelA']).toBeDefined();
|
|
63
|
+
expect(modelOptions['ModelB']).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('attachAudit', () => {
|
|
68
|
+
it('defines a <Name>Audit model and wires four hooks', async () => {
|
|
69
|
+
resetState();
|
|
70
|
+
const auditModel = {
|
|
71
|
+
belongsTo: jasmine.createSpy('belongsTo'),
|
|
72
|
+
sync: jasmine.createSpy('sync').and.resolveTo()
|
|
73
|
+
};
|
|
74
|
+
const Model = {
|
|
75
|
+
name: 'Customer',
|
|
76
|
+
addHook: jasmine.createSpy('addHook'),
|
|
77
|
+
sequelize: {
|
|
78
|
+
define: jasmine.createSpy('define').and.returnValue(auditModel),
|
|
79
|
+
Sequelize: { STRING: 'STRING_TYPE' }
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await attachAudit(Model);
|
|
84
|
+
|
|
85
|
+
expect(Model.sequelize.define).toHaveBeenCalled();
|
|
86
|
+
const [name, schema, opts] = Model.sequelize.define.calls.mostRecent().args;
|
|
87
|
+
expect(name).toEqual('CustomerAudit');
|
|
88
|
+
expect(schema.changedByUser.allowNull).toEqual(false);
|
|
89
|
+
expect(schema.businessId.allowNull).toEqual(false);
|
|
90
|
+
expect(opts).toEqual({ updatedAt: false, freezeTableName: true });
|
|
91
|
+
|
|
92
|
+
expect(auditModel.belongsTo).toHaveBeenCalledWith(Model, jasmine.objectContaining({ onDelete: 'SET NULL' }));
|
|
93
|
+
expect(Model.addHook).toHaveBeenCalledWith('afterCreate', jasmine.any(Function));
|
|
94
|
+
expect(Model.addHook).toHaveBeenCalledWith('afterUpsert', jasmine.any(Function));
|
|
95
|
+
expect(Model.addHook).toHaveBeenCalledWith('afterUpdate', jasmine.any(Function));
|
|
96
|
+
expect(Model.addHook).toHaveBeenCalledWith('afterDestroy', jasmine.any(Function));
|
|
97
|
+
expect(auditModel.sync).toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('is idempotent — second call is a no-op', async () => {
|
|
101
|
+
resetState();
|
|
102
|
+
const Model = {
|
|
103
|
+
name: 'Customer',
|
|
104
|
+
addHook: jasmine.createSpy('addHook'),
|
|
105
|
+
sequelize: {
|
|
106
|
+
define: jasmine.createSpy('define').and.returnValue({
|
|
107
|
+
belongsTo: () => {},
|
|
108
|
+
sync: () => Promise.resolve()
|
|
109
|
+
}),
|
|
110
|
+
Sequelize: { STRING: 'STRING_TYPE' }
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
await attachAudit(Model);
|
|
115
|
+
await attachAudit(Model);
|
|
116
|
+
|
|
117
|
+
expect(Model.sequelize.define).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('audit hook (auditMe via attachAudit)', () => {
|
|
122
|
+
const buildModel = (name) => {
|
|
123
|
+
const definedAudit = { belongsTo: () => {}, sync: () => Promise.resolve() };
|
|
124
|
+
return {
|
|
125
|
+
name,
|
|
126
|
+
addHook: jasmine.createSpy('addHook'),
|
|
127
|
+
sequelize: {
|
|
128
|
+
define: jasmine.createSpy('define').and.returnValue(definedAudit),
|
|
129
|
+
Sequelize: { STRING: 'STRING_TYPE' }
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
const captureHook = async (Model) => {
|
|
134
|
+
await attachAudit(Model);
|
|
135
|
+
const auditModel = auditModels[Model.name];
|
|
136
|
+
auditModel.create = jasmine.createSpy('create').and.resolveTo();
|
|
137
|
+
const hook = Model.addHook.calls.allArgs().find(([h]) => h === 'afterCreate')[1];
|
|
138
|
+
return { hook, auditModel };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
it('writes changedByUser from bound user, no ChangedByUserId field', async () => {
|
|
142
|
+
resetState();
|
|
143
|
+
const Model = buildModel('Widget');
|
|
144
|
+
const { hook, auditModel } = await captureHook(Model);
|
|
145
|
+
modelOptions['Widget'] = {
|
|
146
|
+
user: { username: 'kevin@test.com', id: 7 },
|
|
147
|
+
businessId: 'BIZ-1',
|
|
148
|
+
auditEnabled: true
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
await hook(
|
|
152
|
+
{ _changed: ['name'], _previousDataValues: { name: 'old' }, dataValues: { id: 99, name: 'new' } },
|
|
153
|
+
{ type: 'INITIAL' }
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const writtenRow = auditModel.create.calls.mostRecent().args[0];
|
|
157
|
+
expect(writtenRow.changedByUser).toEqual('kevin@test.com');
|
|
158
|
+
expect(writtenRow.businessId).toEqual('BIZ-1');
|
|
159
|
+
expect(writtenRow.WidgetId).toEqual(99);
|
|
160
|
+
expect(writtenRow.field).toEqual('name');
|
|
161
|
+
expect(writtenRow.valueOld).toEqual('old');
|
|
162
|
+
expect(writtenRow.valueNew).toEqual('new');
|
|
163
|
+
expect(writtenRow.ChangedByUserId).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('skips audit when auditEnabled is false for that model', async () => {
|
|
167
|
+
resetState();
|
|
168
|
+
const Model = buildModel('Widget');
|
|
169
|
+
const { hook, auditModel } = await captureHook(Model);
|
|
170
|
+
modelOptions['Widget'] = {
|
|
171
|
+
user: { username: 'kevin@test.com', id: 7 },
|
|
172
|
+
businessId: 'BIZ-1',
|
|
173
|
+
auditEnabled: false
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
await hook(
|
|
177
|
+
{ _changed: ['name'], _previousDataValues: { name: 'old' }, dataValues: { id: 99, name: 'new' } },
|
|
178
|
+
{ type: 'INITIAL' }
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(auditModel.create).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('skips audit when no per-request bind exists for the model', async () => {
|
|
185
|
+
resetState();
|
|
186
|
+
const Model = buildModel('Widget');
|
|
187
|
+
const { hook, auditModel } = await captureHook(Model);
|
|
188
|
+
|
|
189
|
+
await hook(
|
|
190
|
+
{ _changed: ['name'], _previousDataValues: { name: 'old' }, dataValues: { id: 99, name: 'new' } },
|
|
191
|
+
{ type: 'INITIAL' }
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(auditModel.create).not.toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('per-model isolation — binding model A does not enable audit for model B', async () => {
|
|
198
|
+
resetState();
|
|
199
|
+
const ModelA = buildModel('A');
|
|
200
|
+
const ModelB = buildModel('B');
|
|
201
|
+
const { hook: hookA, auditModel: auditA } = await captureHook(ModelA);
|
|
202
|
+
const { hook: hookB, auditModel: auditB } = await captureHook(ModelB);
|
|
203
|
+
modelOptions['A'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
|
|
204
|
+
|
|
205
|
+
await hookA({ _changed: ['x'], _previousDataValues: { x: '1' }, dataValues: { id: 1, x: '2' } }, {});
|
|
206
|
+
await hookB({ _changed: ['x'], _previousDataValues: { x: '1' }, dataValues: { id: 1, x: '2' } }, {});
|
|
207
|
+
|
|
208
|
+
expect(auditA.create).toHaveBeenCalled();
|
|
209
|
+
expect(auditB.create).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('masks sensitive fields inside array values', async () => {
|
|
213
|
+
resetState();
|
|
214
|
+
const Model = buildModel('Widget');
|
|
215
|
+
const { hook, auditModel } = await captureHook(Model);
|
|
216
|
+
modelOptions['Widget'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
|
|
217
|
+
|
|
218
|
+
await hook(
|
|
219
|
+
{
|
|
220
|
+
_changed: ['tokens'],
|
|
221
|
+
_previousDataValues: { tokens: [{ token: 'secret-old' }] },
|
|
222
|
+
dataValues: { id: 1, tokens: [{ token: 'secret-new' }] }
|
|
223
|
+
},
|
|
224
|
+
{}
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const row = auditModel.create.calls.mostRecent().args[0];
|
|
228
|
+
expect(row.valueOld).not.toContain('secret-old');
|
|
229
|
+
expect(row.valueNew).not.toContain('secret-new');
|
|
230
|
+
expect(row.valueOld).toContain('***************');
|
|
231
|
+
expect(row.valueNew).toContain('***************');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('passes the transaction through to auditModel.create when present', async () => {
|
|
235
|
+
resetState();
|
|
236
|
+
const Model = buildModel('Widget');
|
|
237
|
+
const { hook, auditModel } = await captureHook(Model);
|
|
238
|
+
modelOptions['Widget'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
|
|
239
|
+
|
|
240
|
+
await hook(
|
|
241
|
+
{ _changed: ['name'], _previousDataValues: { name: 'a' }, dataValues: { id: 1, name: 'b' } },
|
|
242
|
+
{ transaction: 'TX-1' }
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(auditModel.create.calls.mostRecent().args[1]).toEqual({ transaction: 'TX-1' });
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('incrementWithAudit / decrementWithAudit', () => {
|
|
250
|
+
it('increments and writes an audit row using bound context', async () => {
|
|
251
|
+
resetState();
|
|
252
|
+
const auditModel = { create: jasmine.createSpy('create').and.resolveTo() };
|
|
253
|
+
auditModels['Inv'] = auditModel;
|
|
254
|
+
modelOptions['Inv'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
|
|
255
|
+
|
|
256
|
+
const incremented = {
|
|
257
|
+
_previousDataValues: { qty: 5 },
|
|
258
|
+
dataValues: { id: 10, qty: 6 }
|
|
259
|
+
};
|
|
260
|
+
const model = { increment: jasmine.createSpy('increment').and.resolveTo(incremented) };
|
|
261
|
+
|
|
262
|
+
const r = await incrementWithAudit('Inv', model, 'qty', { by: 1, transaction: 'TX' });
|
|
263
|
+
|
|
264
|
+
expect(model.increment).toHaveBeenCalledWith('qty', { by: 1, transaction: 'TX' });
|
|
265
|
+
expect(r._changed).toEqual(['qty']);
|
|
266
|
+
expect(auditModel.create).toHaveBeenCalled();
|
|
267
|
+
expect(auditModel.create.calls.mostRecent().args[1]).toEqual({ transaction: 'TX' });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('decrements and writes an audit row', async () => {
|
|
271
|
+
resetState();
|
|
272
|
+
const auditModel = { create: jasmine.createSpy('create').and.resolveTo() };
|
|
273
|
+
auditModels['Rel'] = auditModel;
|
|
274
|
+
modelOptions['Rel'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
|
|
275
|
+
|
|
276
|
+
const decremented = {
|
|
277
|
+
_previousDataValues: { qty: 6 },
|
|
278
|
+
dataValues: { id: 11, qty: 5 }
|
|
279
|
+
};
|
|
280
|
+
const model = { decrement: jasmine.createSpy('decrement').and.resolveTo(decremented) };
|
|
281
|
+
|
|
282
|
+
const r = await decrementWithAudit('Rel', model, 'qty', { by: 1 });
|
|
283
|
+
|
|
284
|
+
expect(model.decrement).toHaveBeenCalledWith('qty', { by: 1 });
|
|
285
|
+
expect(r._changed).toEqual(['qty']);
|
|
286
|
+
expect(auditModel.create).toHaveBeenCalled();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('getAuditModel / getAuditFilter', () => {
|
|
291
|
+
it('returns the audit model registered for a parent', () => {
|
|
292
|
+
resetState();
|
|
293
|
+
const fakeAudit = { name: 'WidgetAudit' };
|
|
294
|
+
auditModels['Widget'] = fakeAudit;
|
|
295
|
+
expect(getAuditModel({ name: 'Widget' })).toBe(fakeAudit);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('builds a where filter scoped to caller businessIds with parent FK', () => {
|
|
299
|
+
resetState();
|
|
300
|
+
const Widget = { name: 'Widget' };
|
|
301
|
+
const event = buildEvent({ businessIds: { 'B1': 'A', 'B2': 'R' } });
|
|
302
|
+
|
|
303
|
+
const filter = getAuditFilter(Widget, 42, { event, offset: 0, limit: 10 });
|
|
304
|
+
|
|
305
|
+
expect(filter.where.WidgetId).toEqual(42);
|
|
306
|
+
expect(filter.where.businessId).toBeDefined();
|
|
307
|
+
expect(filter.include[0].model).toBe(Widget);
|
|
308
|
+
expect(filter.order).toEqual([['createdAt', 'DESC']]);
|
|
309
|
+
expect(filter.offset).toEqual(0);
|
|
310
|
+
expect(filter.limit).toEqual(10);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { isSuperUser, isPartnerUser, getAccessRightsInfo } from '../queryStringUtils/accessRightsUtils.js';
|
|
2
|
+
import { errorList } from '../../../requestResponse/errorCodes.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the caller's access level from the Lambda event.
|
|
6
|
+
* Synchronous — reads JWT claims only, no Dynamo lookups.
|
|
7
|
+
*
|
|
8
|
+
* Returns an access object:
|
|
9
|
+
* { level: 'global' } — super user or local/test env
|
|
10
|
+
* { level: 'partner', partnerId, partnerBusinessId: null, businessIds: null }
|
|
11
|
+
* { level: 'standard', businessIds: string[] }
|
|
12
|
+
*
|
|
13
|
+
* Partner fields (partnerBusinessId, businessIds) start null and are
|
|
14
|
+
* lazy-loaded by ensurePartnerScope on first use.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} event - Lambda event with Cognito authorizer claims.
|
|
17
|
+
* @returns {object} Access object.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveAccess(event) {
|
|
20
|
+
if (isSuperUser(event)) {
|
|
21
|
+
return { level: 'global' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const env = process.env.BUILD_ENV;
|
|
25
|
+
if (env === 'local' || env === 'test') {
|
|
26
|
+
return { level: 'global' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const partnerId = isPartnerUser(event);
|
|
30
|
+
if (partnerId) {
|
|
31
|
+
return { level: 'partner', partnerId, partnerBusinessId: null, businessIds: null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const arBusinessIds = getAccessRightsInfo(event);
|
|
35
|
+
const businessIds = Object.keys(arBusinessIds);
|
|
36
|
+
if (businessIds.length > 0) {
|
|
37
|
+
return { level: 'standard', businessIds };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
throw errorList.unauthorized;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Lazy-load partner scope from Dynamo. Populates partnerBusinessId and
|
|
45
|
+
* businessIds on the access object, then caches for the request lifetime.
|
|
46
|
+
*
|
|
47
|
+
* No-op for non-partner access objects or if already loaded.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} access - Access object from resolveAccess.
|
|
50
|
+
* @param {{ getPartnerById: Function }} deps - Injected so piper-utils stays DB-agnostic.
|
|
51
|
+
* @returns {object} The same access object, now populated.
|
|
52
|
+
*/
|
|
53
|
+
export async function ensurePartnerScope(access, { getPartnerById }) {
|
|
54
|
+
if (!access || access.level !== 'partner') {
|
|
55
|
+
return access;
|
|
56
|
+
}
|
|
57
|
+
if (access.businessIds !== null && access.businessIds !== undefined) {
|
|
58
|
+
return access;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const partner = await getPartnerById(access.partnerId);
|
|
62
|
+
access.businessIds = partner?.businessIds || [];
|
|
63
|
+
access.partnerBusinessId = partner?.partnerBusinessId || null;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error('partnerAccess: failed to load partner for scoping:', err);
|
|
66
|
+
access.businessIds = [];
|
|
67
|
+
access.partnerBusinessId = null;
|
|
68
|
+
}
|
|
69
|
+
return access;
|
|
70
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { errorList } from '../../../requestResponse/errorCodes.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gate: CRM routes — rejects standard users. Super and partner pass.
|
|
5
|
+
* @param {object} access - Access object from resolveAccess.
|
|
6
|
+
*/
|
|
7
|
+
export function requireCrmAccess(access) {
|
|
8
|
+
if (!access) {
|
|
9
|
+
throw errorList.unauthorized;
|
|
10
|
+
}
|
|
11
|
+
if (access.level === 'standard') {
|
|
12
|
+
throw errorList.unauthorized;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Gate: Ticket routes — all three user types pass. Only rejects null access.
|
|
18
|
+
* @param {object} access - Access object from resolveAccess.
|
|
19
|
+
*/
|
|
20
|
+
export function requireTicketAccess(access) {
|
|
21
|
+
if (!access) {
|
|
22
|
+
throw errorList.unauthorized;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Gate: Super-only routes — throws unless global.
|
|
28
|
+
* @param {object} access - Access object from resolveAccess.
|
|
29
|
+
*/
|
|
30
|
+
export function requireSuper(access) {
|
|
31
|
+
if (!access || access.level !== 'global') {
|
|
32
|
+
throw errorList.unauthorized;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import _ from 'lodash';
|
|
2
|
+
import { ensurePartnerScope } from './accessContext.js';
|
|
3
|
+
import { errorList } from '../../../requestResponse/errorCodes.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Scope a WHERE clause to the partner's own business (partnerBusinessId).
|
|
7
|
+
* Use for "mine" resources: deal, activity, application, template.
|
|
8
|
+
*
|
|
9
|
+
* global → deletes where.businessId (super sees everything)
|
|
10
|
+
* partner → sets where.businessId = partnerBusinessId
|
|
11
|
+
* throws partnerNotConfigured if partnerBusinessId is missing
|
|
12
|
+
* standard → no-op (createFilters already handled)
|
|
13
|
+
*
|
|
14
|
+
* @param {object} where - Sequelize WHERE clause to mutate.
|
|
15
|
+
* @param {object} access - Access object from resolveAccess.
|
|
16
|
+
* @param {{ getPartnerById: Function }} deps
|
|
17
|
+
*/
|
|
18
|
+
export async function scopeToOwnBusiness(where, access, { getPartnerById } = {}) {
|
|
19
|
+
if (!access) {
|
|
20
|
+
throw errorList.unauthorized;
|
|
21
|
+
}
|
|
22
|
+
if (access.level === 'global') {
|
|
23
|
+
delete where.businessId;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (access.level === 'partner') {
|
|
27
|
+
await ensurePartnerScope(access, { getPartnerById });
|
|
28
|
+
if (!access.partnerBusinessId) {
|
|
29
|
+
throw errorList.partnerNotConfigured;
|
|
30
|
+
}
|
|
31
|
+
where.businessId = access.partnerBusinessId;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// standard → no-op (createFilters already injected from JWT)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Scope a WHERE clause to the partner's portfolio (businessIds array).
|
|
39
|
+
* Use for "book" resources (future transaction lists, merchant inbox views).
|
|
40
|
+
*
|
|
41
|
+
* global → deletes where.businessId
|
|
42
|
+
* partner → sets where.businessId = businessIds[]
|
|
43
|
+
* standard → no-op
|
|
44
|
+
*
|
|
45
|
+
* @param {object} where - Sequelize WHERE clause to mutate.
|
|
46
|
+
* @param {object} access - Access object from resolveAccess.
|
|
47
|
+
* @param {{ getPartnerById: Function }} deps
|
|
48
|
+
*/
|
|
49
|
+
export async function scopeToPartnerBook(where, access, { getPartnerById } = {}) {
|
|
50
|
+
if (!access) {
|
|
51
|
+
throw errorList.unauthorized;
|
|
52
|
+
}
|
|
53
|
+
if (access.level === 'global') {
|
|
54
|
+
delete where.businessId;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (access.level === 'partner') {
|
|
58
|
+
await ensurePartnerScope(access, { getPartnerById });
|
|
59
|
+
where.businessId = access.businessIds || [];
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// standard → no-op
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Scope to the union of partnerBusinessId + businessIds.
|
|
67
|
+
* Use for tickets — partner sees own tickets + portfolio merchants' tickets.
|
|
68
|
+
*
|
|
69
|
+
* global → deletes where.businessId
|
|
70
|
+
* partner → sets where.businessId = [partnerBusinessId, ...businessIds]
|
|
71
|
+
* standard → sets where.businessId = access.businessIds
|
|
72
|
+
* (explicitly handled here because ticket routes may not call
|
|
73
|
+
* createFilters before scoping, e.g. delete/resolve handlers)
|
|
74
|
+
*
|
|
75
|
+
* @param {object} where - Sequelize WHERE clause to mutate.
|
|
76
|
+
* @param {object} access - Access object from resolveAccess.
|
|
77
|
+
* @param {{ getPartnerById: Function }} deps
|
|
78
|
+
*/
|
|
79
|
+
export async function scopeToBookUnionOwn(where, access, { getPartnerById } = {}) {
|
|
80
|
+
if (!access) {
|
|
81
|
+
throw errorList.unauthorized;
|
|
82
|
+
}
|
|
83
|
+
if (access.level === 'global') {
|
|
84
|
+
delete where.businessId;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (access.level === 'partner') {
|
|
88
|
+
await ensurePartnerScope(access, { getPartnerById });
|
|
89
|
+
const ids = _.uniq(
|
|
90
|
+
[access.partnerBusinessId, ...(access.businessIds || [])].filter(Boolean)
|
|
91
|
+
);
|
|
92
|
+
where.businessId = ids;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (access.level === 'standard') {
|
|
96
|
+
where.businessId = access.businessIds;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import _ from 'lodash';
|
|
2
|
+
import { ensurePartnerScope } from './accessContext.js';
|
|
3
|
+
import { errorList } from '../../../requestResponse/errorCodes.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Assert the caller can write to their own business.
|
|
7
|
+
* For partner: businessId must equal partnerBusinessId.
|
|
8
|
+
*
|
|
9
|
+
* global → allow
|
|
10
|
+
* partner → businessId must equal partnerBusinessId; throws if not
|
|
11
|
+
* standard → throws (CRM routes gate standard users before reaching this)
|
|
12
|
+
*
|
|
13
|
+
* @param {object} access - Access object from resolveAccess.
|
|
14
|
+
* @param {string} businessId - The businessId being written to.
|
|
15
|
+
* @param {{ getPartnerById: Function }} deps
|
|
16
|
+
*/
|
|
17
|
+
export async function assertCanWriteOwnBusiness(access, businessId, { getPartnerById } = {}) {
|
|
18
|
+
if (!access) {
|
|
19
|
+
throw errorList.unauthorized;
|
|
20
|
+
}
|
|
21
|
+
if (access.level === 'global') {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (access.level === 'partner') {
|
|
25
|
+
await ensurePartnerScope(access, { getPartnerById });
|
|
26
|
+
if (!access.partnerBusinessId) {
|
|
27
|
+
throw { ...errorList.partnerNotConfigured };
|
|
28
|
+
}
|
|
29
|
+
if (businessId !== access.partnerBusinessId) {
|
|
30
|
+
throw errorList.unauthorized;
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
throw errorList.unauthorized;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Assert the caller can write to a business in their portfolio.
|
|
39
|
+
* For partner: businessId must be in the businessIds array.
|
|
40
|
+
*
|
|
41
|
+
* global → allow
|
|
42
|
+
* standard → allow (handled elsewhere via checkWriteAccess)
|
|
43
|
+
* partner → businessId must be in portfolio; throws if not
|
|
44
|
+
*
|
|
45
|
+
* @param {object} access - Access object from resolveAccess.
|
|
46
|
+
* @param {string} businessId - The businessId being written to.
|
|
47
|
+
* @param {{ getPartnerById: Function }} deps
|
|
48
|
+
*/
|
|
49
|
+
export async function assertCanWriteBookBusiness(access, businessId, { getPartnerById } = {}) {
|
|
50
|
+
if (!access) {
|
|
51
|
+
throw errorList.unauthorized;
|
|
52
|
+
}
|
|
53
|
+
if (access.level === 'global') {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (access.level === 'standard') {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (access.level === 'partner') {
|
|
60
|
+
if (!businessId) {
|
|
61
|
+
throw { ...errorList.invalidRequest, message: 'businessId is required' };
|
|
62
|
+
}
|
|
63
|
+
await ensurePartnerScope(access, { getPartnerById });
|
|
64
|
+
if (!(access.businessIds || []).includes(businessId)) {
|
|
65
|
+
throw errorList.unauthorized;
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
throw errorList.unauthorized;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Assert the caller can write to a business in the union of own + portfolio.
|
|
74
|
+
* For partner: businessId must be partnerBusinessId OR in businessIds[].
|
|
75
|
+
* Used by ticket write paths.
|
|
76
|
+
*
|
|
77
|
+
* global → allow
|
|
78
|
+
* standard → allow (handled elsewhere via checkWriteAccess)
|
|
79
|
+
* partner → businessId must be in [partnerBusinessId, ...businessIds]
|
|
80
|
+
*
|
|
81
|
+
* @param {object} access - Access object from resolveAccess.
|
|
82
|
+
* @param {string} businessId - The businessId being written to.
|
|
83
|
+
* @param {{ getPartnerById: Function }} deps
|
|
84
|
+
*/
|
|
85
|
+
export async function assertCanWriteBookUnionOwn(access, businessId, { getPartnerById } = {}) {
|
|
86
|
+
if (!access) {
|
|
87
|
+
throw errorList.unauthorized;
|
|
88
|
+
}
|
|
89
|
+
if (access.level === 'global') {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (access.level === 'standard') {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (access.level === 'partner') {
|
|
96
|
+
if (!businessId) {
|
|
97
|
+
throw { ...errorList.invalidRequest, message: 'businessId is required' };
|
|
98
|
+
}
|
|
99
|
+
await ensurePartnerScope(access, { getPartnerById });
|
|
100
|
+
const allowed = _.uniq(
|
|
101
|
+
[access.partnerBusinessId, ...(access.businessIds || [])].filter(Boolean)
|
|
102
|
+
);
|
|
103
|
+
if (!allowed.includes(businessId)) {
|
|
104
|
+
throw errorList.unauthorized;
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
throw errorList.unauthorized;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Auto-stamp body.businessId from the partner's own business if missing.
|
|
113
|
+
* No-op for non-partner users or if body.businessId is already set.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} access - Access object from resolveAccess.
|
|
116
|
+
* @param {object} body - Request body to mutate.
|
|
117
|
+
* @param {{ getPartnerById: Function }} deps
|
|
118
|
+
*/
|
|
119
|
+
export async function stampOwnBusinessId(access, body, { getPartnerById } = {}) {
|
|
120
|
+
if (!access || access.level !== 'partner') {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (body.businessId) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await ensurePartnerScope(access, { getPartnerById });
|
|
127
|
+
body.businessId = access.partnerBusinessId;
|
|
128
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ensurePartnerScope } from './accessContext.js';
|
|
2
|
+
import { scopeToOwnBusiness, scopeToPartnerBook, scopeToBookUnionOwn } from './accessScope.js';
|
|
3
|
+
import {
|
|
4
|
+
assertCanWriteOwnBusiness,
|
|
5
|
+
assertCanWriteBookBusiness,
|
|
6
|
+
assertCanWriteBookUnionOwn,
|
|
7
|
+
stampOwnBusinessId
|
|
8
|
+
} from './accessWrites.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Factory that pre-binds getPartnerById into all scope/assert/stamp helpers.
|
|
12
|
+
* Bind once at module level, then call without repeating the injection:
|
|
13
|
+
*
|
|
14
|
+
* import { createAccessHelpers } from 'piper-utils';
|
|
15
|
+
* import { getPartnerById } from '../partner/partner.js';
|
|
16
|
+
*
|
|
17
|
+
* const {
|
|
18
|
+
* scopeToOwnBusiness,
|
|
19
|
+
* stampOwnBusinessId,
|
|
20
|
+
* assertCanWriteOwnBusiness
|
|
21
|
+
* } = createAccessHelpers({ getPartnerById });
|
|
22
|
+
*
|
|
23
|
+
* @param {{ getPartnerById: Function }} deps
|
|
24
|
+
* @returns {object} Pre-bound helpers.
|
|
25
|
+
*/
|
|
26
|
+
export function createAccessHelpers({ getPartnerById }) {
|
|
27
|
+
const deps = { getPartnerById };
|
|
28
|
+
return {
|
|
29
|
+
ensurePartnerScope: (access) => ensurePartnerScope(access, deps),
|
|
30
|
+
scopeToOwnBusiness: (where, access) => scopeToOwnBusiness(where, access, deps),
|
|
31
|
+
scopeToPartnerBook: (where, access) => scopeToPartnerBook(where, access, deps),
|
|
32
|
+
scopeToBookUnionOwn: (where, access) => scopeToBookUnionOwn(where, access, deps),
|
|
33
|
+
assertCanWriteOwnBusiness: (access, businessId) => assertCanWriteOwnBusiness(access, businessId, deps),
|
|
34
|
+
assertCanWriteBookBusiness: (access, businessId) => assertCanWriteBookBusiness(access, businessId, deps),
|
|
35
|
+
assertCanWriteBookUnionOwn: (access, businessId) => assertCanWriteBookUnionOwn(access, businessId, deps),
|
|
36
|
+
stampOwnBusinessId: (access, body) => stampOwnBusinessId(access, body, deps)
|
|
37
|
+
};
|
|
38
|
+
}
|