@stoker-platform/cli 0.5.136 → 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@stoker-platform/cli",
3
- "version": "0.5.135",
3
+ "version": "0.5.137",
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.85",
28
- "@stoker-platform/types": "0.5.63",
29
- "@stoker-platform/utils": "0.5.76",
27
+ "@stoker-platform/node-client": "0.5.86",
28
+ "@stoker-platform/types": "0.5.64",
29
+ "@stoker-platform/utils": "0.5.77",
30
30
  "algoliasearch": "^5.53.0",
31
31
  "commander": "^15.0.0",
32
32
  "cross-spawn": "^7.0.6",
@@ -1,10 +1,12 @@
1
1
  import { fetchCurrentSchema, initializeFirebase, runChildProcess } from "@stoker-platform/node-client";
2
2
  import { setDeploymentStatus } from "./maintenance/setDeploymentStatus.js";
3
3
  import { getFunctionsData } from "./cloud-functions/getFunctionsData.js";
4
- import { retryOperation } from "@stoker-platform/utils";
4
+ import { getFirestoreDatabaseId, retryOperation } from "@stoker-platform/utils";
5
5
  import { generateSchema } from "./schema/generateSchema.js";
6
6
  import isEqual from "lodash/isEqual.js";
7
7
  import cloneDeep from "lodash/cloneDeep.js";
8
+ import { readFile, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
8
10
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
11
  export const deployProject = async (options) => {
10
12
  try {
@@ -59,6 +61,34 @@ export const deployProject = async (options) => {
59
61
  await runChildProcess("npx", ["stoker", "migrate"]);
60
62
  }
61
63
  await initializeFirebase();
64
+ const firebaseJsonPath = join(process.cwd(), "firebase.json");
65
+ const firebaseJson = JSON.parse(await readFile(firebaseJsonPath, "utf8"));
66
+ if ((process.env.FB_FIRESTORE_EDITION || "enterprise") === "enterprise") {
67
+ firebaseJson.firestore = [
68
+ {
69
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
70
+ database: getFirestoreDatabaseId(process.env.FB_FIRESTORE_EDITION, process.env.GCP_PROJECT),
71
+ rules: "firebase-rules/firestore.rules",
72
+ indexes: "firebase-rules/firestore.indexes.json",
73
+ },
74
+ ];
75
+ }
76
+ else {
77
+ firebaseJson.firestore = {
78
+ rules: "firebase-rules/firestore.rules",
79
+ indexes: "firebase-rules/firestore.indexes.json",
80
+ };
81
+ }
82
+ await writeFile(firebaseJsonPath, JSON.stringify(firebaseJson, null, 4), "utf8");
83
+ const extensionEnvPath = join(process.cwd(), "extensions", "firestore-send-email.env");
84
+ const extensionEnvFile = await readFile(extensionEnvPath, "utf8");
85
+ const extensionEnvFileLines = extensionEnvFile.split("\n");
86
+ const linesToRemove = ["DATABASE="];
87
+ const filteredLines = extensionEnvFileLines.filter((line) => !linesToRemove.some((removeStr) => line.startsWith(removeStr)));
88
+ filteredLines.push(
89
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
90
+ `DATABASE=${getFirestoreDatabaseId(process.env.FB_FIRESTORE_EDITION, process.env.GCP_PROJECT)}`);
91
+ await writeFile(extensionEnvPath, filteredLines.join("\n"));
62
92
  if (options.initial) {
63
93
  await retryOperation(async () => {
64
94
  await runChildProcess("npx", [
@@ -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 (relation.roles) {
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 && !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`);
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
- if (!rangeField.access?.includes(role)) {
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 ("roles" in filter && filter.roles) {
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 && !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`);
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 (field.access && !field.access.includes(role)) {
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 && !field.access.includes(role)) {
1141
- errors.push(`Role ${role} requires access to field ${field.name}, as it is required for access control.`);
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
- 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`);
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
- 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
- }
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 (access && !access.includes(role)) {
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 (typeof sorting === "object" && sorting.roles) {
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 && !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`);
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 (access.operations.read?.includes(role)) {
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
- (typeof field.restrictCreate === "object" && field.restrictCreate.includes(role));
132
+ (Array.isArray(field.restrictCreate) && field.restrictCreate.includes(role));
127
133
  const restrictUpdate = field.restrictUpdate === true ||
128
- (typeof field.restrictUpdate === "object" && field.restrictUpdate.includes(role));
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.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",