@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.
|
|
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",
|
|
@@ -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 (
|
|
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
|
|
294
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
655
|
-
|
|
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 (
|
|
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
|
|
1141
|
-
|
|
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
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
1409
|
-
|
|
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 (
|
|
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
|
-
(
|
|
137
|
+
(Array.isArray(field.restrictCreate) && field.restrictCreate.includes(role));
|
|
127
138
|
const restrictUpdate = field.restrictUpdate === true ||
|
|
128
|
-
(
|
|
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.
|
|
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.
|
|
28
|
-
"@stoker-platform/types": "0.5.
|
|
29
|
-
"@stoker-platform/utils": "0.5.
|
|
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",
|