@stoker-platform/cli 0.5.137 → 0.5.138
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/lib/package.json
CHANGED
|
@@ -1,8 +1,73 @@
|
|
|
1
|
-
import { tryPromise, getCustomization, getField, getInverseRelationType, isDependencyField, isRelationField, systemFields, isIncludedField, getSystemFieldsSchema, getAccessFields, getDependencyIndexFields, isPaginationEnabled, roleHasOperationAccess, tryFunction, getFieldCustomization, } from "@stoker-platform/utils";
|
|
1
|
+
import { tryPromise, getCustomization, getField, getInverseRelationType, isDependencyField, isRelationField, systemFields, isIncludedField, getSystemFieldsSchema, getAccessFields, getDependencyIndexFields, getFieldAccessGroupFields, getFieldAccessRoles, isFieldAccessGroupReference, isPaginationEnabled, roleHasOperationAccess, tryFunction, getFieldCustomization, } from "@stoker-platform/utils";
|
|
2
2
|
import { generateSchema } from "../deploy/schema/generateSchema.js";
|
|
3
3
|
import { getCustomizationFiles } from "@stoker-platform/node-client";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { pathToFileURL } from "url";
|
|
6
|
+
const lintFieldAccessCondition = (condition, label, collectionSchema, roles, errors, warnings, schema, options) => {
|
|
7
|
+
const checks = [
|
|
8
|
+
condition.collectionAuth !== undefined,
|
|
9
|
+
condition.roles !== undefined,
|
|
10
|
+
condition.claims !== undefined,
|
|
11
|
+
condition.restrictions !== undefined,
|
|
12
|
+
].filter(Boolean).length;
|
|
13
|
+
if (checks === 0) {
|
|
14
|
+
errors.push(`${label} has no checks. At least one of collectionAuth, roles, claims or restrictions is required.`);
|
|
15
|
+
}
|
|
16
|
+
if (checks > 1 && condition.match === undefined) {
|
|
17
|
+
errors.push(`${label} has multiple checks but no explicit match. Set match to "any" or "all".`);
|
|
18
|
+
}
|
|
19
|
+
if (!condition.applicableRoles.length) {
|
|
20
|
+
errors.push(`${label} must include at least one applicable role`);
|
|
21
|
+
}
|
|
22
|
+
condition.applicableRoles.forEach((role) => {
|
|
23
|
+
if (!roles.includes(role)) {
|
|
24
|
+
errors.push(`${label} has an applicable role ${role} that does not exist`);
|
|
25
|
+
}
|
|
26
|
+
else if (options?.requireCollectionReadAccess && !roleHasOperationAccess(collectionSchema, role, "read")) {
|
|
27
|
+
errors.push(`${label} includes applicable role ${role}, but that role does not have collection read access`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
condition.roles?.forEach((role) => {
|
|
31
|
+
if (!roles.includes(role)) {
|
|
32
|
+
errors.push(`${label} has a role ${role} that does not exist`);
|
|
33
|
+
}
|
|
34
|
+
else if (!condition.applicableRoles.includes(role)) {
|
|
35
|
+
errors.push(`${label} has a role ${role} that is not included in applicable roles`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
if (condition.claims) {
|
|
39
|
+
const standardClaims = ["role", "doc", "collection", "tenant"];
|
|
40
|
+
const tokenFields = new Set();
|
|
41
|
+
Object.values(schema.collections).forEach((tokenCollection) => {
|
|
42
|
+
tokenCollection.fields
|
|
43
|
+
.filter((field) => field.saveToAuthToken)
|
|
44
|
+
.forEach((field) => tokenFields.add(field.name));
|
|
45
|
+
});
|
|
46
|
+
Object.keys(condition.claims).forEach((claim) => {
|
|
47
|
+
if (!standardClaims.includes(claim) && !tokenFields.has(claim)) {
|
|
48
|
+
warnings.push(`${label} references claim ${claim} which is not a standard claim or a saveToAuthToken field of an auth collection. Ensure the claim is set on the auth token.`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (condition.restrictions) {
|
|
53
|
+
const attributeTypes = collectionSchema.access.attributeRestrictions?.map((restriction) => restriction.type) || [];
|
|
54
|
+
const restrictionKeys = [
|
|
55
|
+
["recordOwner", "Record_Owner"],
|
|
56
|
+
["recordUser", "Record_User"],
|
|
57
|
+
["recordProperty", "Record_Property"],
|
|
58
|
+
];
|
|
59
|
+
restrictionKeys.forEach(([key, type]) => {
|
|
60
|
+
// eslint-disable-next-line security/detect-object-injection
|
|
61
|
+
if (condition.restrictions?.[key] !== undefined && !attributeTypes.includes(type)) {
|
|
62
|
+
errors.push(`${label} references restriction ${key} but the collection does not declare a ${type} attribute restriction`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
if (condition.restrictions.restrictEntities !== undefined &&
|
|
66
|
+
!collectionSchema.access.entityRestrictions?.restrictions) {
|
|
67
|
+
errors.push(`${label} references restriction restrictEntities but the collection does not declare entity restrictions`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
6
71
|
export const lintSchema = async (noLog = false) => {
|
|
7
72
|
const path = join(process.cwd(), "lib", "main.js");
|
|
8
73
|
const url = pathToFileURL(path).href;
|
|
@@ -132,6 +197,26 @@ export const lintSchema = async (noLog = false) => {
|
|
|
132
197
|
const customization = customizationModules[collectionName];
|
|
133
198
|
const readRoles = roles.filter((role) => roleHasOperationAccess(collectionSchema, role, "read"));
|
|
134
199
|
const fieldNames = fields.map((field) => field.name);
|
|
200
|
+
if (collectionSchema.fieldAccessGroups) {
|
|
201
|
+
const groupFields = getFieldAccessGroupFields(collectionSchema);
|
|
202
|
+
const groupKeyRegex = /^[A-Za-z][A-Za-z0-9-]*$/;
|
|
203
|
+
for (const [groupKey, condition] of Object.entries(collectionSchema.fieldAccessGroups)) {
|
|
204
|
+
if (!groupKeyRegex.test(groupKey)) {
|
|
205
|
+
errors.push(`Collection ${collectionName} has a field access group key ${groupKey} that is invalid. Keys must start with a letter and contain only letters, digits and hyphens.`);
|
|
206
|
+
}
|
|
207
|
+
if (fieldNames.includes(groupKey)) {
|
|
208
|
+
errors.push(`Collection ${collectionName} has a field access group key ${groupKey} that collides with a field name`);
|
|
209
|
+
}
|
|
210
|
+
if (roles.includes(groupKey)) {
|
|
211
|
+
errors.push(`Collection ${collectionName} has a field access group key ${groupKey} that collides with a role name`);
|
|
212
|
+
}
|
|
213
|
+
// eslint-disable-next-line security/detect-object-injection
|
|
214
|
+
if (!groupFields[groupKey]?.length) {
|
|
215
|
+
warnings.push(`Collection ${collectionName} has a field access group ${groupKey} that is not referenced by any field`);
|
|
216
|
+
}
|
|
217
|
+
lintFieldAccessCondition(condition, `Collection ${collectionName} field access group ${groupKey}`, collectionSchema, roles, errors, warnings, schema, { requireCollectionReadAccess: true });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
135
220
|
const firestoreRegex = /^(?!\/)(?!.*\/)(?!\.$)(?!\.\.$)(?!__.*__)[^/\s]{1,1500}$/;
|
|
136
221
|
if (!firestoreRegex.test(collectionName)) {
|
|
137
222
|
errors.push(`Invalid collection name: ${collectionName}. Must be a valid Firestore collection ID.`);
|
|
@@ -285,13 +370,19 @@ export const lintSchema = async (noLog = false) => {
|
|
|
285
370
|
errors.push(`Collection ${collectionName} has a relation list field ${relation.field} that does not exist in collection ${relation.collection}`);
|
|
286
371
|
}
|
|
287
372
|
else {
|
|
288
|
-
if (
|
|
373
|
+
if (isFieldAccessGroupReference(relationField.access)) {
|
|
374
|
+
errors.push(`Collection ${collectionName} has a relation list field ${relation.field} for collection ${relation.collection} that is in a field access group. Field access group fields cannot be used as relation list fields.`);
|
|
375
|
+
}
|
|
376
|
+
else if (relation.roles) {
|
|
289
377
|
for (const role of relation.roles) {
|
|
290
378
|
if (!roles.includes(role)) {
|
|
291
379
|
errors.push(`Collection ${collectionName} has a relation list field ${relation.field} for collection ${relation.collection} with role ${role} that does not exist`);
|
|
292
380
|
}
|
|
293
|
-
if (relationField.access
|
|
294
|
-
|
|
381
|
+
if (relationField.access) {
|
|
382
|
+
const accessibleRoles = getFieldAccessRoles(relationField.access);
|
|
383
|
+
if (!accessibleRoles?.includes(role)) {
|
|
384
|
+
errors.push(`Collection ${collectionName} has a relation list field ${relation.field} for collection ${relation.collection} with role ${role} that does not have access to the field`);
|
|
385
|
+
}
|
|
295
386
|
}
|
|
296
387
|
}
|
|
297
388
|
}
|
|
@@ -334,9 +425,13 @@ export const lintSchema = async (noLog = false) => {
|
|
|
334
425
|
if (index > 0 && !rangeField.nullable) {
|
|
335
426
|
errors.push(`Collection ${collectionName} has a preload cache range field ${field} that must be nullable`);
|
|
336
427
|
}
|
|
337
|
-
if (rangeField.access) {
|
|
428
|
+
if (isFieldAccessGroupReference(rangeField.access)) {
|
|
429
|
+
errors.push(`Collection ${collectionName} has a preload cache range field ${field} that is in a field access group. Field access group fields cannot be used as preload cache range fields.`);
|
|
430
|
+
}
|
|
431
|
+
else if (rangeField.access) {
|
|
338
432
|
preloadCache.roles.forEach((role) => {
|
|
339
|
-
|
|
433
|
+
const accessibleRoles = getFieldAccessRoles(rangeField.access);
|
|
434
|
+
if (!accessibleRoles?.includes(role)) {
|
|
340
435
|
errors.push(`Collection ${collectionName} has a preload cache range field ${field} that can't be accessed by role ${role}`);
|
|
341
436
|
}
|
|
342
437
|
});
|
|
@@ -418,6 +513,17 @@ export const lintSchema = async (noLog = false) => {
|
|
|
418
513
|
statusField.archived.every((value) => statusFieldSchema.values?.includes(value)))))) {
|
|
419
514
|
errors.push(`Collection ${collectionName} has a status field ${statusField.field} with values that do not match the matching field's values`);
|
|
420
515
|
}
|
|
516
|
+
else if (isFieldAccessGroupReference(statusFieldSchema.access)) {
|
|
517
|
+
errors.push(`Collection ${collectionName} has a status field ${statusField.field} that is in a field access group. Field access group fields cannot be used as status fields.`);
|
|
518
|
+
}
|
|
519
|
+
else if (statusFieldSchema.access) {
|
|
520
|
+
for (const role of readRoles) {
|
|
521
|
+
const accessibleRoles = getFieldAccessRoles(statusFieldSchema.access);
|
|
522
|
+
if (!accessibleRoles?.includes(role)) {
|
|
523
|
+
errors.push(`Collection ${collectionName} has a status field ${statusField.field} that can't be accessed by role ${role}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
421
527
|
}
|
|
422
528
|
const defaultSort = (await tryPromise(customization?.admin?.defaultSort));
|
|
423
529
|
if (defaultSort) {
|
|
@@ -434,6 +540,9 @@ export const lintSchema = async (noLog = false) => {
|
|
|
434
540
|
errors.push(`Collection ${collectionName} has a default sort field ${defaultSort.field} that must be required or nullable.`);
|
|
435
541
|
}
|
|
436
542
|
}
|
|
543
|
+
if (isFieldAccessGroupReference(fieldSchema.access)) {
|
|
544
|
+
errors.push(`Collection ${collectionName} has a default sort field ${defaultSort.field} that is in a field access group. Field access group fields cannot be used as default sort fields.`);
|
|
545
|
+
}
|
|
437
546
|
}
|
|
438
547
|
}
|
|
439
548
|
const breadcrumbs = (await tryPromise(customization?.admin?.breadcrumbs));
|
|
@@ -446,9 +555,13 @@ export const lintSchema = async (noLog = false) => {
|
|
|
446
555
|
}
|
|
447
556
|
const cards = (await tryPromise(customization?.admin?.cards));
|
|
448
557
|
if (cards) {
|
|
558
|
+
const cardsStatusFieldSchema = getField(fields, cards.statusField);
|
|
449
559
|
if (cards.statusField && !fieldNames.includes(cards.statusField)) {
|
|
450
560
|
errors.push(`Collection ${collectionName} has a cards status field ${cards.statusField} that does not exist`);
|
|
451
561
|
}
|
|
562
|
+
else if (cards.statusField && isFieldAccessGroupReference(cardsStatusFieldSchema.access)) {
|
|
563
|
+
errors.push(`Collection ${collectionName} has a cards status field ${cards.statusField} that is in a field access group. Field access group fields cannot be used as status fields.`);
|
|
564
|
+
}
|
|
452
565
|
if (!fieldNames.concat(systemFields).includes(cards.headerField)) {
|
|
453
566
|
errors.push(`Collection ${collectionName} has a cards header field ${cards.headerField} that does not exist`);
|
|
454
567
|
}
|
|
@@ -646,13 +759,21 @@ export const lintSchema = async (noLog = false) => {
|
|
|
646
759
|
errors.push(`Collection ${collectionName} has a filter field ${filter.field} that does not exist`);
|
|
647
760
|
}
|
|
648
761
|
else {
|
|
649
|
-
if (
|
|
762
|
+
if (field.access) {
|
|
763
|
+
if (isFieldAccessGroupReference(field.access)) {
|
|
764
|
+
errors.push(`Collection ${collectionName} has a filter for field ${filter.field} that is in a field access group. Field access group fields cannot be used as filter fields.`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
else if ("roles" in filter && filter.roles) {
|
|
650
768
|
for (const role of filter.roles) {
|
|
651
769
|
if (!roles.includes(role)) {
|
|
652
770
|
errors.push(`Collection ${collectionName} has a filter for field ${filter.field} that has an access role ${role} that does not exist`);
|
|
653
771
|
}
|
|
654
|
-
if (field.access
|
|
655
|
-
|
|
772
|
+
if (field.access) {
|
|
773
|
+
const accessibleRoles = getFieldAccessRoles(field.access);
|
|
774
|
+
if (!accessibleRoles?.includes(role)) {
|
|
775
|
+
errors.push(`Collection ${collectionName} has a filter for field ${filter.field} that has an access role ${role} that does not have access to the field`);
|
|
776
|
+
}
|
|
656
777
|
}
|
|
657
778
|
}
|
|
658
779
|
}
|
|
@@ -762,7 +883,25 @@ export const lintSchema = async (noLog = false) => {
|
|
|
762
883
|
}
|
|
763
884
|
for (const role of operations.delete) {
|
|
764
885
|
for (const field of fields) {
|
|
765
|
-
if (
|
|
886
|
+
if (!field.access)
|
|
887
|
+
continue;
|
|
888
|
+
const accessibleRoles = getFieldAccessRoles(field.access);
|
|
889
|
+
if (isFieldAccessGroupReference(field.access)) {
|
|
890
|
+
const fieldAccessGroup = collectionSchema.fieldAccessGroups?.[field.access.group];
|
|
891
|
+
const checks = [
|
|
892
|
+
fieldAccessGroup?.collectionAuth !== undefined,
|
|
893
|
+
fieldAccessGroup?.roles !== undefined,
|
|
894
|
+
fieldAccessGroup?.claims !== undefined,
|
|
895
|
+
fieldAccessGroup?.restrictions !== undefined,
|
|
896
|
+
].filter(Boolean).length;
|
|
897
|
+
if (!(fieldAccessGroup?.applicableRoles?.some((applicableRole) => applicableRole === role) &&
|
|
898
|
+
fieldAccessGroup?.roles?.includes(role) &&
|
|
899
|
+
(checks === 1 || fieldAccessGroup?.match === "any")) &&
|
|
900
|
+
!(collectionSchema.auth && checks === 1 && fieldAccessGroup?.collectionAuth !== undefined)) {
|
|
901
|
+
warnings.push(`Collection ${collectionName} can be deleted by role ${role}, who may not have access to field ${field.name} because it is in a field access group`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
else if (!accessibleRoles?.includes(role)) {
|
|
766
905
|
warnings.push(`Collection ${collectionName} can be deleted by role ${role}, who does not have access to field ${field.name}`);
|
|
767
906
|
}
|
|
768
907
|
}
|
|
@@ -1137,8 +1276,14 @@ export const lintSchema = async (noLog = false) => {
|
|
|
1137
1276
|
const accessFields = getAccessFields(collectionSchema, role);
|
|
1138
1277
|
for (const field of accessFields) {
|
|
1139
1278
|
allAccessFields.add(field);
|
|
1140
|
-
if (field.access
|
|
1141
|
-
|
|
1279
|
+
if (field.access) {
|
|
1280
|
+
const accessibleRoles = getFieldAccessRoles(field.access);
|
|
1281
|
+
if (isFieldAccessGroupReference(field.access)) {
|
|
1282
|
+
errors.push(`Role ${role} requires access to field ${field.name} for access control, but the field is in a field access group so access may not be determinable.`);
|
|
1283
|
+
}
|
|
1284
|
+
else if (!accessibleRoles?.includes(role)) {
|
|
1285
|
+
errors.push(`Role ${role} requires access to field ${field.name}, as it is required for access control.`);
|
|
1286
|
+
}
|
|
1142
1287
|
}
|
|
1143
1288
|
}
|
|
1144
1289
|
}
|
|
@@ -1198,31 +1343,54 @@ export const lintSchema = async (noLog = false) => {
|
|
|
1198
1343
|
].includes(type)) {
|
|
1199
1344
|
errors.push(`Collection ${collectionName} has a field ${name} with an invalid type ${type}`);
|
|
1200
1345
|
}
|
|
1201
|
-
if (access) {
|
|
1346
|
+
if (isFieldAccessGroupReference(access)) {
|
|
1347
|
+
if (!collectionSchema.fieldAccessGroups?.[access.group]) {
|
|
1348
|
+
errors.push(`Collection ${collectionName} has a field ${name} with access group ${access.group} that does not exist`);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
else if (access) {
|
|
1202
1352
|
for (const role of access) {
|
|
1203
1353
|
if (!roles.includes(role)) {
|
|
1204
1354
|
errors.push(`Collection ${collectionName} has a field ${name} with access role ${role} that does not exist`);
|
|
1205
1355
|
}
|
|
1206
1356
|
}
|
|
1207
1357
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1358
|
+
const lintRestrictWrite = (restriction, label) => {
|
|
1359
|
+
if (Array.isArray(restriction)) {
|
|
1360
|
+
for (const role of restriction) {
|
|
1361
|
+
if (!roles.includes(role)) {
|
|
1362
|
+
errors.push(`Collection ${collectionName} has a field ${name} with ${label} role ${role} that does not exist`);
|
|
1363
|
+
}
|
|
1212
1364
|
}
|
|
1213
1365
|
}
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
for (const role of restrictUpdate) {
|
|
1217
|
-
if (!roles.includes(role)) {
|
|
1218
|
-
errors.push(`Collection ${collectionName} has a field ${name} with restrict update role ${role} that does not exist`);
|
|
1219
|
-
}
|
|
1366
|
+
else if (typeof restriction === "object" && restriction !== null) {
|
|
1367
|
+
lintFieldAccessCondition(restriction, `Collection ${collectionName} field ${name} ${label} condition`, collectionSchema, roles, errors, warnings, schema);
|
|
1220
1368
|
}
|
|
1221
|
-
}
|
|
1369
|
+
};
|
|
1370
|
+
lintRestrictWrite(restrictCreate, "restrict create");
|
|
1371
|
+
lintRestrictWrite(restrictUpdate, "restrict update");
|
|
1222
1372
|
if (required) {
|
|
1223
1373
|
const createRoles = operations.create || [];
|
|
1224
1374
|
for (const role of createRoles) {
|
|
1225
|
-
if (
|
|
1375
|
+
if (!access)
|
|
1376
|
+
continue;
|
|
1377
|
+
const accessibleRoles = getFieldAccessRoles(access);
|
|
1378
|
+
if (isFieldAccessGroupReference(access)) {
|
|
1379
|
+
const fieldAccessGroup = collectionSchema.fieldAccessGroups?.[access.group];
|
|
1380
|
+
const checks = [
|
|
1381
|
+
fieldAccessGroup?.collectionAuth !== undefined,
|
|
1382
|
+
fieldAccessGroup?.roles !== undefined,
|
|
1383
|
+
fieldAccessGroup?.claims !== undefined,
|
|
1384
|
+
fieldAccessGroup?.restrictions !== undefined,
|
|
1385
|
+
].filter(Boolean).length;
|
|
1386
|
+
if (!(fieldAccessGroup?.applicableRoles?.some((applicableRole) => applicableRole === role) &&
|
|
1387
|
+
fieldAccessGroup?.roles?.includes(role) &&
|
|
1388
|
+
(checks === 1 || fieldAccessGroup?.match === "any")) &&
|
|
1389
|
+
!(collectionSchema.auth && checks === 1 && fieldAccessGroup?.collectionAuth !== undefined)) {
|
|
1390
|
+
warnings.push(`Collection ${collectionName} has a required field ${name} that role ${role} with create access may not be able to access because it is in a field access group`);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
else if (!accessibleRoles?.includes(role)) {
|
|
1226
1394
|
errors.push(`Collection ${collectionName} has a required field ${name} that is not accessible to role ${role} which has create access`);
|
|
1227
1395
|
}
|
|
1228
1396
|
}
|
|
@@ -1400,13 +1568,19 @@ export const lintSchema = async (noLog = false) => {
|
|
|
1400
1568
|
if (isRelationField(field) && !field.titleField) {
|
|
1401
1569
|
errors.push(`Collection ${collectionName} has sorting enabled for relation field ${name}, but no title field has been set`);
|
|
1402
1570
|
}
|
|
1403
|
-
if (
|
|
1571
|
+
if (isFieldAccessGroupReference(field.access)) {
|
|
1572
|
+
errors.push(`Collection ${collectionName} has a sorting field ${field.name} that is in a field access group. Field access group fields cannot be used as sorting fields.`);
|
|
1573
|
+
}
|
|
1574
|
+
else if (typeof sorting === "object" && sorting.roles) {
|
|
1404
1575
|
for (const role of sorting.roles) {
|
|
1405
1576
|
if (!roles.includes(role)) {
|
|
1406
1577
|
errors.push(`Collection ${collectionName} has sorting enabled for field ${name} with role ${role} that does not exist`);
|
|
1407
1578
|
}
|
|
1408
|
-
if (field.access
|
|
1409
|
-
|
|
1579
|
+
if (field.access) {
|
|
1580
|
+
const accessibleRoles = getFieldAccessRoles(field.access);
|
|
1581
|
+
if (!accessibleRoles?.includes(role)) {
|
|
1582
|
+
errors.push(`Collection ${collectionName} has sorting enabled for field ${name} with role ${role} that does not have access to the field`);
|
|
1583
|
+
}
|
|
1410
1584
|
}
|
|
1411
1585
|
}
|
|
1412
1586
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { generateSchema } from "../deploy/schema/generateSchema.js";
|
|
2
|
-
import { getAccessFields, getField, getDependencyIndexFields, isDependencyField, isRelationField, getRoleGroups, getFieldCustomization, } from "@stoker-platform/utils";
|
|
2
|
+
import { getAccessFields, getField, getDependencyIndexFields, getFieldAccessGroupFields, getFieldAccessGroupIndexFields, getRoleFields, isDependencyField, isRelationField, getRoleGroups, getFieldCustomization, roleHasOperationAccess, } from "@stoker-platform/utils";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
/* eslint-disable security/detect-object-injection */
|
|
@@ -31,6 +31,12 @@ export const securityReport = async () => {
|
|
|
31
31
|
roleGroups.forEach(() => {
|
|
32
32
|
writeRuleReads[collectionName].batch.add("Main Document");
|
|
33
33
|
});
|
|
34
|
+
const fieldAccessGroups = getFieldAccessGroupFields(collectionSchema);
|
|
35
|
+
Object.values(fieldAccessGroups).forEach((groupFields) => {
|
|
36
|
+
if (groupFields.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
writeRuleReads[collectionName].batch.add(`Main Document`);
|
|
39
|
+
});
|
|
34
40
|
if (auth) {
|
|
35
41
|
writeRuleReads[collectionName].main.add("Document Lock Lookup");
|
|
36
42
|
writeRuleReads[collectionName].batch.add("Document Lock Lookup");
|
|
@@ -58,7 +64,7 @@ export const securityReport = async () => {
|
|
|
58
64
|
roles[role] = {};
|
|
59
65
|
for (const [collectionName, collectionSchema] of Object.entries(schema.collections)) {
|
|
60
66
|
const { fields, access } = collectionSchema;
|
|
61
|
-
if (
|
|
67
|
+
if (roleHasOperationAccess(collectionSchema, role, "read")) {
|
|
62
68
|
for (const field of fields) {
|
|
63
69
|
if (field.access)
|
|
64
70
|
continue;
|
|
@@ -123,9 +129,9 @@ export const securityReport = async () => {
|
|
|
123
129
|
roles[role][field.collection][`${collectionName} "${field.name}" Relation${field.preserve ? "- Preserved" : ""}`] = new Set();
|
|
124
130
|
}
|
|
125
131
|
const restrictCreate = field.restrictCreate === true ||
|
|
126
|
-
(
|
|
132
|
+
(Array.isArray(field.restrictCreate) && field.restrictCreate.includes(role));
|
|
127
133
|
const restrictUpdate = field.restrictUpdate === true ||
|
|
128
|
-
(
|
|
134
|
+
(Array.isArray(field.restrictUpdate) && field.restrictUpdate.includes(role));
|
|
129
135
|
if (access.operations.assignable === true ||
|
|
130
136
|
(typeof access.operations.assignable === "object" &&
|
|
131
137
|
access.operations.assignable.includes(role)) ||
|
|
@@ -148,6 +154,34 @@ export const securityReport = async () => {
|
|
|
148
154
|
}
|
|
149
155
|
}
|
|
150
156
|
}
|
|
157
|
+
if (roleHasOperationAccess(collectionSchema, role, "read")) {
|
|
158
|
+
const fieldAccessGroups = getFieldAccessGroupFields(collectionSchema);
|
|
159
|
+
const roleFields = getRoleFields(collectionSchema, role);
|
|
160
|
+
const roleFieldNames = new Set(roleFields.map((field) => field.name));
|
|
161
|
+
for (const [groupKey, groupFields] of Object.entries(fieldAccessGroups)) {
|
|
162
|
+
if (groupFields.length === 0)
|
|
163
|
+
continue;
|
|
164
|
+
// eslint-disable-next-line security/detect-object-injection
|
|
165
|
+
if (!collectionSchema.fieldAccessGroups?.[groupKey]?.applicableRoles.includes(role))
|
|
166
|
+
continue;
|
|
167
|
+
const groupFieldNames = new Set(groupFields.map((field) => field.name));
|
|
168
|
+
const overlayFields = getFieldAccessGroupIndexFields(groupKey, collectionSchema);
|
|
169
|
+
for (const overlayField of overlayFields) {
|
|
170
|
+
if (overlayField.name === "id" || overlayField.name === "Collection_Path") {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (groupFieldNames.has(overlayField.name))
|
|
174
|
+
continue;
|
|
175
|
+
if (roleFieldNames.has(overlayField.name))
|
|
176
|
+
continue;
|
|
177
|
+
roles[role][collectionName] ||= {};
|
|
178
|
+
if (!roles[role][collectionName][overlayField.name]) {
|
|
179
|
+
roles[role][collectionName][overlayField.name] = new Set();
|
|
180
|
+
}
|
|
181
|
+
roles[role][collectionName][overlayField.name].add(`Field Access Group ${groupKey}- Index`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
151
185
|
}
|
|
152
186
|
}
|
|
153
187
|
console.log("Security Rule Write Reads:\n");
|
|
@@ -178,6 +212,7 @@ export const securityReport = async () => {
|
|
|
178
212
|
console.log("\n");
|
|
179
213
|
}
|
|
180
214
|
console.log("\n\nPossible Excess Permissions:\n");
|
|
215
|
+
console.log("(Includes fields on field access group overlays that are not on the role's mirror, after filtering overlay metadata to the roles declared on each field access group.)\n");
|
|
181
216
|
for (const [role, collections] of Object.entries(roles)) {
|
|
182
217
|
console.log(`${role.toUpperCase()}\n`);
|
|
183
218
|
for (const [collection, fields] of Object.entries(collections)) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fetchCurrentSchema, initializeStoker, getStokerFirestore } from "@stoker-platform/node-client";
|
|
2
|
-
import { getDependencyIndexFields, getLowercaseFields, getRoleGroups, getSingleFieldRelations, isDependencyField, isRelationField, } from "@stoker-platform/utils";
|
|
2
|
+
import { getDependencyIndexFields, getFieldAccessGroupFields, getFieldAccessGroupIndexFields, getLowercaseFields, getRoleGroups, getSingleFieldRelations, isDependencyField, isRelationField, } from "@stoker-platform/utils";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import isEqual from "lodash/isEqual.js";
|
|
5
5
|
import isEmpty from "lodash/isEmpty.js";
|
|
@@ -117,6 +117,62 @@ export const auditDenormalized = async (options) => {
|
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
|
+
const fieldAccessGroups = getFieldAccessGroupFields(collectionSchema);
|
|
121
|
+
for (const [groupKey, groupFields] of Object.entries(fieldAccessGroups)) {
|
|
122
|
+
if (groupFields.length === 0)
|
|
123
|
+
continue;
|
|
124
|
+
const overlayIndexFields = getFieldAccessGroupIndexFields(groupKey, collectionSchema);
|
|
125
|
+
const overlaySnapshot = await db
|
|
126
|
+
.collection("tenants")
|
|
127
|
+
.doc(options.tenant)
|
|
128
|
+
.collection("system_fields")
|
|
129
|
+
.doc(collectionName)
|
|
130
|
+
.collection(`${collectionName}-${groupKey}`)
|
|
131
|
+
.get();
|
|
132
|
+
const overlayIds = new Set();
|
|
133
|
+
const lowercaseFields = getLowercaseFields(collectionSchema, overlayIndexFields);
|
|
134
|
+
const lowercaseFieldNames = Array.from(lowercaseFields).map((field) => field.name);
|
|
135
|
+
overlaySnapshot.forEach((overlay) => {
|
|
136
|
+
overlayIds.add(overlay.id);
|
|
137
|
+
const overlayData = overlay.data();
|
|
138
|
+
for (const indexField of overlayIndexFields) {
|
|
139
|
+
if (indexField.name === "Collection_Path_String")
|
|
140
|
+
continue;
|
|
141
|
+
if (isRelationField(indexField)) {
|
|
142
|
+
const overlayValue = {
|
|
143
|
+
[indexField.name]: overlayData[indexField.name],
|
|
144
|
+
[`${indexField.name}_Array`]: overlayData[`${indexField.name}_Array`],
|
|
145
|
+
};
|
|
146
|
+
const collectionValue = {
|
|
147
|
+
[indexField.name]: collectionData[overlay.id]?.[indexField.name],
|
|
148
|
+
[`${indexField.name}_Array`]: collectionData[overlay.id]?.[`${indexField.name}_Array`],
|
|
149
|
+
};
|
|
150
|
+
if (singleFieldRelationNames.includes(indexField.name)) {
|
|
151
|
+
overlayValue[`${indexField.name}_Single`] = overlayData[`${indexField.name}_Single`];
|
|
152
|
+
collectionValue[`${indexField.name}_Single`] =
|
|
153
|
+
collectionData[overlay.id]?.[`${indexField.name}_Single`];
|
|
154
|
+
}
|
|
155
|
+
if (!isEqual(overlayValue, collectionValue)) {
|
|
156
|
+
console.log(`${collectionName} ${overlay.id} Field Access Group ${groupKey}: ${indexField.name} - ${JSON.stringify(overlayValue)} !== ${JSON.stringify(collectionValue)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const overlayValue = overlayData[indexField.name];
|
|
161
|
+
const collectionValue = collectionData[overlay.id]?.[indexField.name];
|
|
162
|
+
if (!isEqual(overlayValue, collectionValue)) {
|
|
163
|
+
console.log(`${collectionName} ${overlay.id} Field Access Group ${groupKey}: ${indexField.name} - ${JSON.stringify(overlayValue)} !== ${JSON.stringify(collectionValue)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (lowercaseFieldNames.includes(indexField.name)) {
|
|
167
|
+
const overlayValue = overlayData[`${indexField.name}_Lowercase`];
|
|
168
|
+
const collectionValue = collectionData[overlay.id]?.[`${indexField.name}_Lowercase`];
|
|
169
|
+
if (!isEqual(overlayValue, collectionValue)) {
|
|
170
|
+
console.log(`${collectionName} ${overlay.id} Field Access Group ${groupKey}: ${indexField.name}_Lowercase - ${JSON.stringify(overlayValue)} !== ${JSON.stringify(collectionValue)}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
120
176
|
console.log(`${collectionName} audited.\n`);
|
|
121
177
|
}
|
|
122
178
|
process.exit();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchCurrentSchema, getCollectionRefs, initializeStoker, getStokerFirestore, } from "@stoker-platform/node-client";
|
|
1
|
+
import { fetchCurrentSchema, getCollectionRefs, initializeStoker, getStokerFirestore, getUser, } from "@stoker-platform/node-client";
|
|
2
2
|
import { tryPromise, getRange } from "@stoker-platform/utils";
|
|
3
3
|
import { Filter } from "firebase-admin/firestore";
|
|
4
4
|
import { join } from "path";
|
|
@@ -43,6 +43,11 @@ export const explainPreloadQueries = async (options) => {
|
|
|
43
43
|
if (!permissionsSnapshot.exists) {
|
|
44
44
|
throw new Error("User not found");
|
|
45
45
|
}
|
|
46
|
+
const user = await getUser(options.id);
|
|
47
|
+
if (!user) {
|
|
48
|
+
throw new Error("User not found");
|
|
49
|
+
}
|
|
50
|
+
const claims = user.customClaims ?? {};
|
|
46
51
|
const permissions = permissionsSnapshot.data();
|
|
47
52
|
const timezone = await tryPromise(globalConfig.timezone);
|
|
48
53
|
const preloadConfigSync = await tryPromise(globalConfig.preload?.sync);
|
|
@@ -63,7 +68,7 @@ export const explainPreloadQueries = async (options) => {
|
|
|
63
68
|
const rangeConstraints = preloadCache?.range;
|
|
64
69
|
const constraints = (await tryPromise(collectionSchema.custom?.preloadCacheConstraints));
|
|
65
70
|
const orQueries = (await tryPromise(collectionSchema.custom?.preloadCacheOrQueries));
|
|
66
|
-
const queries = getCollectionRefs(options.tenant, [collection], schema, options.id, permissions).map((ref) => {
|
|
71
|
+
const queries = getCollectionRefs(options.tenant, [collection], schema, options.id, permissions, claims).map((ref) => {
|
|
67
72
|
const disjunctions = [];
|
|
68
73
|
if (rangeConstraints) {
|
|
69
74
|
const { start, end } = getRange(rangeConstraints, timezone);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stoker-platform/cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.138",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"main": "./lib/src/main.js",
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"@google-cloud/secret-manager": "^6.1.2",
|
|
25
25
|
"@google-cloud/storage": "^7.19.0",
|
|
26
26
|
"@inquirer/prompts": "^8.5.2",
|
|
27
|
-
"@stoker-platform/node-client": "0.5.
|
|
28
|
-
"@stoker-platform/types": "0.5.
|
|
29
|
-
"@stoker-platform/utils": "0.5.
|
|
27
|
+
"@stoker-platform/node-client": "0.5.87",
|
|
28
|
+
"@stoker-platform/types": "0.5.65",
|
|
29
|
+
"@stoker-platform/utils": "0.5.78",
|
|
30
30
|
"algoliasearch": "^5.53.0",
|
|
31
31
|
"commander": "^15.0.0",
|
|
32
32
|
"cross-spawn": "^7.0.6",
|