@stoker-platform/cli 0.5.137 → 0.5.139

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@stoker-platform/cli",
3
- "version": "0.5.136",
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.86",
28
- "@stoker-platform/types": "0.5.64",
29
- "@stoker-platform/utils": "0.5.77",
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",
@@ -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,23 @@ 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 (/-\d+$/.test(groupKey)) {
208
+ errors.push(`Collection ${collectionName} has a field access group key ${groupKey} that ends with a hyphen followed by digits.`);
209
+ }
210
+ // eslint-disable-next-line security/detect-object-injection
211
+ if (!groupFields[groupKey]?.length) {
212
+ warnings.push(`Collection ${collectionName} has a field access group ${groupKey} that is not referenced by any field`);
213
+ }
214
+ lintFieldAccessCondition(condition, `Collection ${collectionName} field access group ${groupKey}`, collectionSchema, roles, errors, warnings, schema, { requireCollectionReadAccess: true });
215
+ }
216
+ }
135
217
  const firestoreRegex = /^(?!\/)(?!.*\/)(?!\.$)(?!\.\.$)(?!__.*__)[^/\s]{1,1500}$/;
136
218
  if (!firestoreRegex.test(collectionName)) {
137
219
  errors.push(`Invalid collection name: ${collectionName}. Must be a valid Firestore collection ID.`);
@@ -285,13 +367,19 @@ export const lintSchema = async (noLog = false) => {
285
367
  errors.push(`Collection ${collectionName} has a relation list field ${relation.field} that does not exist in collection ${relation.collection}`);
286
368
  }
287
369
  else {
288
- if (relation.roles) {
370
+ if (isFieldAccessGroupReference(relationField.access)) {
371
+ 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.`);
372
+ }
373
+ else if (relation.roles) {
289
374
  for (const role of relation.roles) {
290
375
  if (!roles.includes(role)) {
291
376
  errors.push(`Collection ${collectionName} has a relation list field ${relation.field} for collection ${relation.collection} with role ${role} that does not exist`);
292
377
  }
293
- if (relationField.access && !relationField.access.includes(role)) {
294
- 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`);
378
+ if (relationField.access) {
379
+ const accessibleRoles = getFieldAccessRoles(relationField.access);
380
+ if (!accessibleRoles?.includes(role)) {
381
+ 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`);
382
+ }
295
383
  }
296
384
  }
297
385
  }
@@ -334,9 +422,13 @@ export const lintSchema = async (noLog = false) => {
334
422
  if (index > 0 && !rangeField.nullable) {
335
423
  errors.push(`Collection ${collectionName} has a preload cache range field ${field} that must be nullable`);
336
424
  }
337
- if (rangeField.access) {
425
+ if (isFieldAccessGroupReference(rangeField.access)) {
426
+ 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.`);
427
+ }
428
+ else if (rangeField.access) {
338
429
  preloadCache.roles.forEach((role) => {
339
- if (!rangeField.access?.includes(role)) {
430
+ const accessibleRoles = getFieldAccessRoles(rangeField.access);
431
+ if (!accessibleRoles?.includes(role)) {
340
432
  errors.push(`Collection ${collectionName} has a preload cache range field ${field} that can't be accessed by role ${role}`);
341
433
  }
342
434
  });
@@ -418,6 +510,17 @@ export const lintSchema = async (noLog = false) => {
418
510
  statusField.archived.every((value) => statusFieldSchema.values?.includes(value)))))) {
419
511
  errors.push(`Collection ${collectionName} has a status field ${statusField.field} with values that do not match the matching field's values`);
420
512
  }
513
+ else if (isFieldAccessGroupReference(statusFieldSchema.access)) {
514
+ 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.`);
515
+ }
516
+ else if (statusFieldSchema.access) {
517
+ for (const role of readRoles) {
518
+ const accessibleRoles = getFieldAccessRoles(statusFieldSchema.access);
519
+ if (!accessibleRoles?.includes(role)) {
520
+ errors.push(`Collection ${collectionName} has a status field ${statusField.field} that can't be accessed by role ${role}`);
521
+ }
522
+ }
523
+ }
421
524
  }
422
525
  const defaultSort = (await tryPromise(customization?.admin?.defaultSort));
423
526
  if (defaultSort) {
@@ -434,6 +537,9 @@ export const lintSchema = async (noLog = false) => {
434
537
  errors.push(`Collection ${collectionName} has a default sort field ${defaultSort.field} that must be required or nullable.`);
435
538
  }
436
539
  }
540
+ if (isFieldAccessGroupReference(fieldSchema.access)) {
541
+ 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.`);
542
+ }
437
543
  }
438
544
  }
439
545
  const breadcrumbs = (await tryPromise(customization?.admin?.breadcrumbs));
@@ -446,9 +552,13 @@ export const lintSchema = async (noLog = false) => {
446
552
  }
447
553
  const cards = (await tryPromise(customization?.admin?.cards));
448
554
  if (cards) {
555
+ const cardsStatusFieldSchema = getField(fields, cards.statusField);
449
556
  if (cards.statusField && !fieldNames.includes(cards.statusField)) {
450
557
  errors.push(`Collection ${collectionName} has a cards status field ${cards.statusField} that does not exist`);
451
558
  }
559
+ else if (cards.statusField && isFieldAccessGroupReference(cardsStatusFieldSchema.access)) {
560
+ 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.`);
561
+ }
452
562
  if (!fieldNames.concat(systemFields).includes(cards.headerField)) {
453
563
  errors.push(`Collection ${collectionName} has a cards header field ${cards.headerField} that does not exist`);
454
564
  }
@@ -646,13 +756,21 @@ export const lintSchema = async (noLog = false) => {
646
756
  errors.push(`Collection ${collectionName} has a filter field ${filter.field} that does not exist`);
647
757
  }
648
758
  else {
649
- if ("roles" in filter && filter.roles) {
759
+ if (field.access) {
760
+ if (isFieldAccessGroupReference(field.access)) {
761
+ 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.`);
762
+ }
763
+ }
764
+ else if ("roles" in filter && filter.roles) {
650
765
  for (const role of filter.roles) {
651
766
  if (!roles.includes(role)) {
652
767
  errors.push(`Collection ${collectionName} has a filter for field ${filter.field} that has an access role ${role} that does not exist`);
653
768
  }
654
- if (field.access && !field.access.includes(role)) {
655
- 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`);
769
+ if (field.access) {
770
+ const accessibleRoles = getFieldAccessRoles(field.access);
771
+ if (!accessibleRoles?.includes(role)) {
772
+ 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`);
773
+ }
656
774
  }
657
775
  }
658
776
  }
@@ -762,7 +880,25 @@ export const lintSchema = async (noLog = false) => {
762
880
  }
763
881
  for (const role of operations.delete) {
764
882
  for (const field of fields) {
765
- if (field.access && !field.access.includes(role)) {
883
+ if (!field.access)
884
+ continue;
885
+ const accessibleRoles = getFieldAccessRoles(field.access);
886
+ if (isFieldAccessGroupReference(field.access)) {
887
+ const fieldAccessGroup = collectionSchema.fieldAccessGroups?.[field.access.group];
888
+ const checks = [
889
+ fieldAccessGroup?.collectionAuth !== undefined,
890
+ fieldAccessGroup?.roles !== undefined,
891
+ fieldAccessGroup?.claims !== undefined,
892
+ fieldAccessGroup?.restrictions !== undefined,
893
+ ].filter(Boolean).length;
894
+ if (!(fieldAccessGroup?.applicableRoles?.some((applicableRole) => applicableRole === role) &&
895
+ fieldAccessGroup?.roles?.includes(role) &&
896
+ (checks === 1 || fieldAccessGroup?.match === "any")) &&
897
+ !(collectionSchema.auth && checks === 1 && fieldAccessGroup?.collectionAuth !== undefined)) {
898
+ 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`);
899
+ }
900
+ }
901
+ else if (!accessibleRoles?.includes(role)) {
766
902
  warnings.push(`Collection ${collectionName} can be deleted by role ${role}, who does not have access to field ${field.name}`);
767
903
  }
768
904
  }
@@ -1137,8 +1273,14 @@ export const lintSchema = async (noLog = false) => {
1137
1273
  const accessFields = getAccessFields(collectionSchema, role);
1138
1274
  for (const field of accessFields) {
1139
1275
  allAccessFields.add(field);
1140
- if (field.access && !field.access.includes(role)) {
1141
- errors.push(`Role ${role} requires access to field ${field.name}, as it is required for access control.`);
1276
+ if (field.access) {
1277
+ const accessibleRoles = getFieldAccessRoles(field.access);
1278
+ if (isFieldAccessGroupReference(field.access)) {
1279
+ 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.`);
1280
+ }
1281
+ else if (!accessibleRoles?.includes(role)) {
1282
+ errors.push(`Role ${role} requires access to field ${field.name}, as it is required for access control.`);
1283
+ }
1142
1284
  }
1143
1285
  }
1144
1286
  }
@@ -1198,31 +1340,54 @@ export const lintSchema = async (noLog = false) => {
1198
1340
  ].includes(type)) {
1199
1341
  errors.push(`Collection ${collectionName} has a field ${name} with an invalid type ${type}`);
1200
1342
  }
1201
- if (access) {
1343
+ if (isFieldAccessGroupReference(access)) {
1344
+ if (!collectionSchema.fieldAccessGroups?.[access.group]) {
1345
+ errors.push(`Collection ${collectionName} has a field ${name} with access group ${access.group} that does not exist`);
1346
+ }
1347
+ }
1348
+ else if (access) {
1202
1349
  for (const role of access) {
1203
1350
  if (!roles.includes(role)) {
1204
1351
  errors.push(`Collection ${collectionName} has a field ${name} with access role ${role} that does not exist`);
1205
1352
  }
1206
1353
  }
1207
1354
  }
1208
- if (typeof restrictCreate === "object") {
1209
- for (const role of restrictCreate) {
1210
- if (!roles.includes(role)) {
1211
- errors.push(`Collection ${collectionName} has a field ${name} with restrict create role ${role} that does not exist`);
1355
+ const lintRestrictWrite = (restriction, label) => {
1356
+ if (Array.isArray(restriction)) {
1357
+ for (const role of restriction) {
1358
+ if (!roles.includes(role)) {
1359
+ errors.push(`Collection ${collectionName} has a field ${name} with ${label} role ${role} that does not exist`);
1360
+ }
1212
1361
  }
1213
1362
  }
1214
- }
1215
- if (typeof restrictUpdate === "object") {
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
- }
1363
+ else if (typeof restriction === "object" && restriction !== null) {
1364
+ lintFieldAccessCondition(restriction, `Collection ${collectionName} field ${name} ${label} condition`, collectionSchema, roles, errors, warnings, schema);
1220
1365
  }
1221
- }
1366
+ };
1367
+ lintRestrictWrite(restrictCreate, "restrict create");
1368
+ lintRestrictWrite(restrictUpdate, "restrict update");
1222
1369
  if (required) {
1223
1370
  const createRoles = operations.create || [];
1224
1371
  for (const role of createRoles) {
1225
- if (access && !access.includes(role)) {
1372
+ if (!access)
1373
+ continue;
1374
+ const accessibleRoles = getFieldAccessRoles(access);
1375
+ if (isFieldAccessGroupReference(access)) {
1376
+ const fieldAccessGroup = collectionSchema.fieldAccessGroups?.[access.group];
1377
+ const checks = [
1378
+ fieldAccessGroup?.collectionAuth !== undefined,
1379
+ fieldAccessGroup?.roles !== undefined,
1380
+ fieldAccessGroup?.claims !== undefined,
1381
+ fieldAccessGroup?.restrictions !== undefined,
1382
+ ].filter(Boolean).length;
1383
+ if (!(fieldAccessGroup?.applicableRoles?.some((applicableRole) => applicableRole === role) &&
1384
+ fieldAccessGroup?.roles?.includes(role) &&
1385
+ (checks === 1 || fieldAccessGroup?.match === "any")) &&
1386
+ !(collectionSchema.auth && checks === 1 && fieldAccessGroup?.collectionAuth !== undefined)) {
1387
+ 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`);
1388
+ }
1389
+ }
1390
+ else if (!accessibleRoles?.includes(role)) {
1226
1391
  errors.push(`Collection ${collectionName} has a required field ${name} that is not accessible to role ${role} which has create access`);
1227
1392
  }
1228
1393
  }
@@ -1400,13 +1565,19 @@ export const lintSchema = async (noLog = false) => {
1400
1565
  if (isRelationField(field) && !field.titleField) {
1401
1566
  errors.push(`Collection ${collectionName} has sorting enabled for relation field ${name}, but no title field has been set`);
1402
1567
  }
1403
- if (typeof sorting === "object" && sorting.roles) {
1568
+ if (isFieldAccessGroupReference(field.access)) {
1569
+ 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.`);
1570
+ }
1571
+ else if (typeof sorting === "object" && sorting.roles) {
1404
1572
  for (const role of sorting.roles) {
1405
1573
  if (!roles.includes(role)) {
1406
1574
  errors.push(`Collection ${collectionName} has sorting enabled for field ${name} with role ${role} that does not exist`);
1407
1575
  }
1408
- if (field.access && !field.access.includes(role)) {
1409
- errors.push(`Collection ${collectionName} has sorting enabled for field ${name} with role ${role} that does not have access to the field`);
1576
+ if (field.access) {
1577
+ const accessibleRoles = getFieldAccessRoles(field.access);
1578
+ if (!accessibleRoles?.includes(role)) {
1579
+ errors.push(`Collection ${collectionName} has sorting enabled for field ${name} with role ${role} that does not have access to the field`);
1580
+ }
1410
1581
  }
1411
1582
  }
1412
1583
  }
@@ -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, 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 */
@@ -27,10 +27,21 @@ export const securityReport = async () => {
27
27
  writeRuleReads[collectionName].batch.add("User Document Lookup");
28
28
  writeRuleReads[collectionName].batch.add("Latest Deploy Document");
29
29
  writeRuleReads[collectionName].batch.add("Maintenance Mode Document");
30
- const roleGroups = getRoleGroups(collectionSchema, schema);
30
+ const roleGroups = Array.from(getRoleGroups(collectionSchema, schema));
31
31
  roleGroups.forEach(() => {
32
32
  writeRuleReads[collectionName].batch.add("Main Document");
33
33
  });
34
+ const fieldAccessGroups = getFieldAccessGroupFields(collectionSchema);
35
+ Object.entries(fieldAccessGroups).forEach(([groupKey, groupFields]) => {
36
+ if (groupFields.length === 0)
37
+ return;
38
+ for (const roleGroup of roleGroups) {
39
+ const overlayFields = getFieldAccessGroupIndexFields(groupKey, collectionSchema, roleGroup);
40
+ if (overlayFields.length === 0)
41
+ continue;
42
+ writeRuleReads[collectionName].batch.add("Main Document");
43
+ }
44
+ });
34
45
  if (auth) {
35
46
  writeRuleReads[collectionName].main.add("Document Lock Lookup");
36
47
  writeRuleReads[collectionName].batch.add("Document Lock Lookup");
@@ -58,7 +69,7 @@ export const securityReport = async () => {
58
69
  roles[role] = {};
59
70
  for (const [collectionName, collectionSchema] of Object.entries(schema.collections)) {
60
71
  const { fields, access } = collectionSchema;
61
- if (access.operations.read?.includes(role)) {
72
+ if (roleHasOperationAccess(collectionSchema, role, "read")) {
62
73
  for (const field of fields) {
63
74
  if (field.access)
64
75
  continue;
@@ -123,9 +134,9 @@ export const securityReport = async () => {
123
134
  roles[role][field.collection][`${collectionName} "${field.name}" Relation${field.preserve ? "- Preserved" : ""}`] = new Set();
124
135
  }
125
136
  const restrictCreate = field.restrictCreate === true ||
126
- (typeof field.restrictCreate === "object" && field.restrictCreate.includes(role));
137
+ (Array.isArray(field.restrictCreate) && field.restrictCreate.includes(role));
127
138
  const restrictUpdate = field.restrictUpdate === true ||
128
- (typeof field.restrictUpdate === "object" && field.restrictUpdate.includes(role));
139
+ (Array.isArray(field.restrictUpdate) && field.restrictUpdate.includes(role));
129
140
  if (access.operations.assignable === true ||
130
141
  (typeof access.operations.assignable === "object" &&
131
142
  access.operations.assignable.includes(role)) ||
@@ -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, getFieldAccessGroupKey, 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,67 @@ 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
+ for (const roleGroup of roleGroups) {
125
+ const overlayIndexFields = getFieldAccessGroupIndexFields(groupKey, collectionSchema, roleGroup);
126
+ if (overlayIndexFields.length === 0)
127
+ continue;
128
+ const overlayKey = getFieldAccessGroupKey(groupKey, roleGroup.key);
129
+ const overlaySnapshot = await db
130
+ .collection("tenants")
131
+ .doc(options.tenant)
132
+ .collection("system_fields")
133
+ .doc(collectionName)
134
+ .collection(`${collectionName}-${overlayKey}`)
135
+ .get();
136
+ const overlayIds = new Set();
137
+ const lowercaseFields = getLowercaseFields(collectionSchema, overlayIndexFields);
138
+ const lowercaseFieldNames = Array.from(lowercaseFields).map((field) => field.name);
139
+ overlaySnapshot.forEach((overlay) => {
140
+ overlayIds.add(overlay.id);
141
+ const overlayData = overlay.data();
142
+ for (const indexField of overlayIndexFields) {
143
+ if (indexField.name === "Collection_Path_String")
144
+ continue;
145
+ if (isRelationField(indexField)) {
146
+ const overlayValue = {
147
+ [indexField.name]: overlayData[indexField.name],
148
+ [`${indexField.name}_Array`]: overlayData[`${indexField.name}_Array`],
149
+ };
150
+ const collectionValue = {
151
+ [indexField.name]: collectionData[overlay.id]?.[indexField.name],
152
+ [`${indexField.name}_Array`]: collectionData[overlay.id]?.[`${indexField.name}_Array`],
153
+ };
154
+ if (singleFieldRelationNames.includes(indexField.name)) {
155
+ overlayValue[`${indexField.name}_Single`] = overlayData[`${indexField.name}_Single`];
156
+ collectionValue[`${indexField.name}_Single`] =
157
+ collectionData[overlay.id]?.[`${indexField.name}_Single`];
158
+ }
159
+ if (!isEqual(overlayValue, collectionValue)) {
160
+ console.log(`${collectionName} ${overlay.id} Field Access Group ${overlayKey}: ${indexField.name} - ${JSON.stringify(overlayValue)} !== ${JSON.stringify(collectionValue)}`);
161
+ }
162
+ }
163
+ else {
164
+ const overlayValue = overlayData[indexField.name];
165
+ const collectionValue = collectionData[overlay.id]?.[indexField.name];
166
+ if (!isEqual(overlayValue, collectionValue)) {
167
+ console.log(`${collectionName} ${overlay.id} Field Access Group ${overlayKey}: ${indexField.name} - ${JSON.stringify(overlayValue)} !== ${JSON.stringify(collectionValue)}`);
168
+ }
169
+ }
170
+ if (lowercaseFieldNames.includes(indexField.name)) {
171
+ const overlayValue = overlayData[`${indexField.name}_Lowercase`];
172
+ const collectionValue = collectionData[overlay.id]?.[`${indexField.name}_Lowercase`];
173
+ if (!isEqual(overlayValue, collectionValue)) {
174
+ console.log(`${collectionName} ${overlay.id} Field Access Group ${overlayKey}: ${indexField.name}_Lowercase - ${JSON.stringify(overlayValue)} !== ${JSON.stringify(collectionValue)}`);
175
+ }
176
+ }
177
+ }
178
+ });
179
+ }
180
+ }
120
181
  console.log(`${collectionName} audited.\n`);
121
182
  }
122
183
  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.137",
3
+ "version": "0.5.139",
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.86",
28
- "@stoker-platform/types": "0.5.64",
29
- "@stoker-platform/utils": "0.5.77",
27
+ "@stoker-platform/node-client": "0.5.88",
28
+ "@stoker-platform/types": "0.5.65",
29
+ "@stoker-platform/utils": "0.5.79",
30
30
  "algoliasearch": "^5.53.0",
31
31
  "commander": "^15.0.0",
32
32
  "cross-spawn": "^7.0.6",