adminizer 5.0.0-build.9 → 5.1.0-build.25
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/assets/abnfDiagram-VCTEODGH-BTgLIXrb.js +1 -0
- package/assets/add-C8V8qjvm.js +1 -0
- package/assets/add-group-Ezq_Ocot.js +1 -0
- package/assets/add-user-CluNf2QG.js +1 -0
- package/assets/ai-assistant/agent.es.js +28464 -0
- package/assets/app-C96pcs-J.js +138 -0
- package/assets/app-DPmI-MvN.css +1 -0
- package/assets/arc-G6n0TtRf.js +1 -0
- package/assets/architectureDiagram-5GKGNRK7-TQ4_iSAG.js +36 -0
- package/assets/asmcrypto-DZeQH0LC.js +1 -0
- package/assets/blockDiagram-I7D4REHJ-1q050zQd.js +129 -0
- package/assets/c4Diagram-7LVT6UL2-BXn0hW62.js +38 -0
- package/assets/{catalog-dk6a0I1u.js → catalog-Cpx4OH_r.js} +3 -3
- package/assets/channel-JZzXP_E5.js +1 -0
- package/assets/chunk-2Q5K7J3B-BSnocdBz.js +1 -0
- package/assets/chunk-5VM5RSS4-BvfoMSpq.js +15 -0
- package/assets/chunk-F27PBJKO-CMyFcBlX.js +1 -0
- package/assets/chunk-IMKFNOWR--8Y02hwt.js +231 -0
- package/assets/chunk-JWPE2WC7-C18m6v8d.js +1 -0
- package/assets/chunk-POPQ4Y6H-DzoTZe0a.js +1 -0
- package/assets/chunk-SVP7TREG-Ce98IXh7.js +88 -0
- package/assets/chunk-TICWLB2K-BeLoUlaL.js +206 -0
- package/assets/chunk-XXDRQBXY-DCZSZ1aW.js +1 -0
- package/assets/classDiagram-ZZMXUADV-jA4zeKxs.js +1 -0
- package/assets/classDiagram-v2-VYDZK3BY-jA4zeKxs.js +1 -0
- package/assets/controls/ckeditor.es.js +9382 -9320
- package/assets/controls/handsontable.es.js +11971 -11938
- package/assets/controls/jsoneditor.es.js +10442 -10233
- package/assets/controls/toast-ui.es.js +1308 -1279
- package/assets/cose-bilkent-JH36ORCC-Cy9np608.js +1 -0
- package/assets/cynefin-OW5HDTMX-Ba6uP79R.js +166 -0
- package/assets/cynefinDiagram-5FMLGOSQ-DhMBBq7T.js +62 -0
- package/assets/cytoscape.esm-DzSz-X2X.js +321 -0
- package/assets/dagre-GXQ25YYZ-BdAnQUCV.js +4 -0
- package/assets/dashboard-BFVFBIio.js +3 -0
- package/assets/debounce-B3mXI_sx.js +1 -0
- package/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/assets/diagram-S7CK7UJ4-BDYUGbNl.js +30 -0
- package/assets/diagram-UQ7AKVKN-PURK0-X-.js +41 -0
- package/assets/diagram-VSXAHHWV-DxHdFxl9.js +3 -0
- package/assets/diagram-VX7I27RA-Vz8xmeYc.js +24 -0
- package/assets/diagram-Z3DM3KII-CFXUpBMy.js +24 -0
- package/assets/docs-DU3h-HtS.js +1 -0
- package/assets/ebnfDiagram-PWID7BFC-BXNvxMGP.js +1 -0
- package/assets/erDiagram-RLTQ6QDP-DN0GgZEI.js +99 -0
- package/assets/flowDiagram-HODETNUW-DXdnwgBv.js +168 -0
- package/assets/ganttDiagram-EL5Y4UJY-Cv8_dxX3.js +292 -0
- package/assets/gitGraphDiagram-WWUBYQGX-BO8T858E.js +106 -0
- package/assets/history-BmwWpckq.js +1 -0
- package/assets/infoDiagram-27XIBGKW-DMSJ1RNc.js +2 -0
- package/assets/init-Gi6I4Gst.js +1 -0
- package/assets/init-user-BIdB8U_4.js +1 -0
- package/assets/isObject-DL-BuaBl.js +1 -0
- package/assets/ishikawaDiagram-5VMMS53U-_tLJESbl.js +70 -0
- package/assets/journeyDiagram-3NMN7TZE-DMMhdo-_.js +139 -0
- package/assets/kanban-definition-UXKFOSKX-CxQrB4t6.js +89 -0
- package/assets/katex-HP8lGamR.js +257 -0
- package/assets/layout-_oS_xxCJ.js +1 -0
- package/assets/linear-VLYuNK3x.js +1 -0
- package/assets/{list-CcMtS4hD.js → list-YTyJQtiR.js} +2 -2
- package/assets/login-B9GlhjZh.js +1 -0
- package/assets/manifest.json +834 -58
- package/assets/media-manager-Bb1VWlzd.js +6 -0
- package/assets/mermaid.core-BknV2vft.js +310 -0
- package/assets/mindmap-definition-YA3MSWOX-DBjjjDq4.js +96 -0
- package/assets/module-DQ1NK8Xg.js +1 -0
- package/assets/notification-CgNOsXvn.js +20 -0
- package/assets/ordinal-Cboi1Yqb.js +1 -0
- package/assets/pegDiagram-XKGWAZYB-uRznTaO4.js +1 -0
- package/assets/pieDiagram-E7YTZNPT-D1TkCH8I.js +39 -0
- package/assets/quadrantDiagram-AXDQQJYC-BofJ36Jz.js +7 -0
- package/assets/railroadDiagram-O6MQD6OU-e1ZM2IT1.js +1 -0
- package/assets/register-DjTtMayF.js +1 -0
- package/assets/requirementDiagram-BXWQKSXE-DQWArRbb.js +84 -0
- package/assets/ru-CqAtka69.js +1 -0
- package/assets/sankeyDiagram-P5KCCOFB-fVOu2jzi.js +40 -0
- package/assets/sequenceDiagram-WJ2MYXX4-Dj24cqpu.js +162 -0
- package/assets/sizeCapture-INFHLROL-CsApVrVi.js +1 -0
- package/assets/stateDiagram-D77RDMKH-IU2Szlid.js +1 -0
- package/assets/stateDiagram-v2-MP3YSRHH-B-EW3cIC.js +1 -0
- package/assets/swimlanes-42K2YHIH-97rBqTR-.js +1 -0
- package/assets/swimlanesDiagram-VR7AAH4N-CqG2iydK.js +8 -0
- package/assets/timeline-definition-24CTP7MA-DSgRzx1V.js +120 -0
- package/assets/toNumber-DZb-r9sy.js +1 -0
- package/assets/use-appearance-CXz2xWgT.js +1 -0
- package/assets/user-filters-list-BAR4CBN_.js +1 -0
- package/assets/vennDiagram-4TSXK5OY-BqMksFxE.js +34 -0
- package/assets/wardleyDiagram-VM6X3IG4-BMmxRODC.js +78 -0
- package/assets/welcome-CPXT7_4b.js +1 -0
- package/assets/with-app-layout-C6dqetfx.js +31 -0
- package/assets/xychartDiagram-S5SC5T6Z-3xRuF0l4.js +7 -0
- package/controllers/access-rights/permissionOptions.d.ts +2 -0
- package/controllers/access-rights/permissionOptions.js +23 -0
- package/controllers/add.js +2 -2
- package/controllers/addGroup.js +15 -6
- package/controllers/ai/AiAgentController.d.ts +66 -0
- package/controllers/ai/AiAgentController.js +435 -0
- package/controllers/ai/AiAssistantController.d.ts +0 -4
- package/controllers/ai/AiAssistantController.js +4 -78
- package/controllers/catalog/FrontendCatalogAdapter.js +7 -8
- package/controllers/docs/index.d.ts +22 -0
- package/controllers/docs/index.js +126 -0
- package/controllers/edit.js +2 -2
- package/controllers/editGroup.js +15 -6
- package/controllers/filter-fields/savedFilters.js +1 -1
- package/controllers/history-actions/HistoryController.js +7 -7
- package/controllers/inlineUpdate.js +10 -3
- package/controllers/list.js +1 -1
- package/controllers/login.js +1 -1
- package/controllers/notifications/NotificationController.js +14 -8
- package/controllers/view.js +3 -3
- package/controllers/widgets/filterInfo.js +1 -1
- package/helpers/accessRightsHelper.d.ts +63 -2
- package/helpers/accessRightsHelper.js +202 -13
- package/helpers/assetVersionHelper.d.ts +14 -0
- package/helpers/assetVersionHelper.js +42 -0
- package/helpers/configHelper.js +19 -18
- package/helpers/controllerHelper.d.ts +1 -0
- package/helpers/controllerHelper.js +25 -15
- package/helpers/fieldsHelper.d.ts +2 -1
- package/helpers/fieldsHelper.js +11 -5
- package/helpers/inertiaActionsHelper.d.ts +1 -1
- package/helpers/inertiaActionsHelper.js +4 -5
- package/helpers/inertiaAddHelper.d.ts +1 -1
- package/helpers/inertiaAddHelper.js +7 -6
- package/helpers/inertiaGroupHelper.js +10 -2
- package/helpers/inertiaListHelper.d.ts +1 -1
- package/helpers/inertiaListHelper.js +7 -8
- package/helpers/inertiaMenuHelper.d.ts +7 -2
- package/helpers/inertiaMenuHelper.js +12 -27
- package/helpers/menuHelper.d.ts +11 -1
- package/helpers/menuHelper.js +12 -0
- package/helpers/modelResourceHelper.d.ts +9 -0
- package/helpers/modelResourceHelper.js +43 -0
- package/helpers/navigationAccessHelper.d.ts +13 -0
- package/helpers/navigationAccessHelper.js +45 -0
- package/index.d.ts +11 -0
- package/index.js +11 -0
- package/interfaces/adminpanelConfig.d.ts +160 -2
- package/interfaces/queryCriteria.d.ts +13 -0
- package/interfaces/types.d.ts +169 -0
- package/lib/Adminizer.d.ts +8 -0
- package/lib/Adminizer.js +30 -2
- package/lib/DataAccessor.d.ts +100 -3
- package/lib/DataAccessor.js +454 -245
- package/lib/access-graph/AccessGraphResolver.d.ts +136 -0
- package/lib/access-graph/AccessGraphResolver.js +605 -0
- package/lib/access-graph/RecordAccessCache.d.ts +23 -0
- package/lib/access-graph/RecordAccessCache.js +30 -0
- package/lib/access-graph/RecordAccessResolver.d.ts +83 -0
- package/lib/access-graph/RecordAccessResolver.js +295 -0
- package/lib/access-graph/shared.d.ts +60 -0
- package/lib/access-graph/shared.js +121 -0
- package/lib/admin-links/AdminLinkHandler.d.ts +72 -0
- package/lib/admin-links/AdminLinkHandler.js +183 -0
- package/lib/ai-assistant/AbstractAiConversationHistoryService.d.ts +39 -0
- package/lib/ai-assistant/AbstractAiConversationHistoryService.js +98 -0
- package/lib/ai-assistant/AbstractAiModelService.d.ts +139 -2
- package/lib/ai-assistant/AbstractAiModelService.js +154 -1
- package/lib/ai-assistant/AiAgentRunManager.d.ts +35 -0
- package/lib/ai-assistant/AiAgentRunManager.js +109 -0
- package/lib/ai-assistant/AiAssistantAgentSkillHandler.d.ts +61 -0
- package/lib/ai-assistant/AiAssistantAgentSkillHandler.js +90 -0
- package/lib/ai-assistant/AiAssistantHandler.d.ts +1 -0
- package/lib/ai-assistant/AiAssistantHandler.js +4 -0
- package/lib/ai-assistant/AiAssistantUiMethodHandler.d.ts +60 -0
- package/lib/ai-assistant/AiAssistantUiMethodHandler.js +144 -0
- package/lib/ai-assistant/builtinAgentSkills.d.ts +9 -0
- package/lib/ai-assistant/builtinAgentSkills.js +259 -0
- package/lib/ai-assistant/jsonSafe.d.ts +13 -0
- package/lib/ai-assistant/jsonSafe.js +81 -0
- package/lib/app-manager/AdminizerApp.d.ts +40 -1
- package/lib/app-manager/AppManager.js +48 -42
- package/lib/app-manager/ConfigLayerHandler.js +4 -0
- package/lib/docs/AbstractDocumentation.d.ts +100 -0
- package/lib/docs/AbstractDocumentation.js +108 -0
- package/lib/docs/DocumentationHandler.d.ts +100 -0
- package/lib/docs/DocumentationHandler.js +198 -0
- package/lib/docs/FileDocumentation.d.ts +47 -0
- package/lib/docs/FileDocumentation.js +189 -0
- package/lib/docs/docsAgentSkills.d.ts +10 -0
- package/lib/docs/docsAgentSkills.js +93 -0
- package/lib/filters/FilterService.js +4 -3
- package/lib/history-actions/AbstractHistoryAdapter.d.ts +26 -1
- package/lib/history-actions/AbstractHistoryAdapter.js +155 -25
- package/lib/media-manager/helpers/MediaManagerHelper.js +9 -7
- package/lib/model/AbstractModel.d.ts +48 -0
- package/lib/model/AbstractModel.js +14 -4
- package/lib/model/ModelHandler.d.ts +61 -2
- package/lib/model/ModelHandler.js +136 -8
- package/lib/model/adapter/sequelize.d.ts +40 -1
- package/lib/model/adapter/sequelize.js +243 -77
- package/lib/model/adapter/typeorm.d.ts +16 -0
- package/lib/model/adapter/typeorm.js +136 -9
- package/lib/notifications/GeneralNotificationService.js +6 -5
- package/lib/notifications/SystemNotificationService.js +1 -1
- package/lib/ui-i18n/uiTranslationKeys.d.ts +2 -1
- package/lib/ui-i18n/uiTranslationKeys.js +42 -1
- package/lib/widgets/widgetHandler.js +10 -6
- package/models/Group.d.ts +2 -1
- package/package.json +35 -23
- package/policies/authPolicies.js +6 -6
- package/policies/permissionResolvers.d.ts +1 -0
- package/policies/permissionResolvers.js +3 -0
- package/system/Router.js +21 -3
- package/system/bindAccessRights.js +13 -3
- package/system/bindDocs.d.ts +13 -0
- package/system/bindDocs.js +77 -0
- package/system/bindInertia.js +30 -7
- package/system/buildInternalModelAccess.d.ts +9 -0
- package/system/buildInternalModelAccess.js +37 -4
- package/system/defaults.js +7 -1
- package/system/validateSystemModels.js +29 -9
- package/assets/add-CBlLpLr0.js +0 -1
- package/assets/add-group-H0IKIsNm.js +0 -1
- package/assets/add-user-CoTLmxtF.js +0 -1
- package/assets/app-DNXiApCk.css +0 -1
- package/assets/app-layout-xxJpLAvb.js +0 -1
- package/assets/app.js +0 -138
- package/assets/dashboard-DVJS5EPP.js +0 -3
- package/assets/debounce-BMmXVQ06.js +0 -1
- package/assets/history-bWRuzbye.js +0 -1
- package/assets/init-user-8aUohVDq.js +0 -1
- package/assets/login-CO4laf6m.js +0 -1
- package/assets/media-manager-BDbpSSjH.js +0 -6
- package/assets/module-CqO_BKsH.js +0 -1
- package/assets/notification-B5l2wwmi.js +0 -20
- package/assets/register-DBYIMXGQ.js +0 -1
- package/assets/ru-rYKdQbWF.js +0 -1
- package/assets/use-appearance-_EbbjHTh.js +0 -1
- package/assets/user-filters-list-C7kMKN5f.js +0 -1
- package/assets/welcome-6z-7sXnQ.js +0 -1
package/lib/DataAccessor.js
CHANGED
|
@@ -1,9 +1,58 @@
|
|
|
1
1
|
import { ControllerHelper } from "../helpers/controllerHelper.js";
|
|
2
2
|
import { Adminizer } from "./Adminizer.js";
|
|
3
3
|
import { isObject } from "../helpers/JsUtils.js";
|
|
4
|
-
|
|
4
|
+
import { RecordAccessResolver } from "./access-graph/RecordAccessResolver.js";
|
|
5
|
+
import { RecordAccessCache } from "./access-graph/RecordAccessCache.js";
|
|
6
|
+
import { AccessGraphResolver, isGraphParentEdge } from "./access-graph/AccessGraphResolver.js";
|
|
7
|
+
import { getTokenAction, modelCrudToken } from "./access-graph/shared.js";
|
|
5
8
|
function hasCriteriaContent(criteria) {
|
|
6
|
-
return Object.keys(criteria).length > 0
|
|
9
|
+
return Object.keys(criteria).length > 0;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Rewrites the association keys of an access filter to their foreign-key columns
|
|
13
|
+
* (`via`), so the condition lands on plain columns of the JOIN alias in every adapter.
|
|
14
|
+
* Returns `undefined` when a key cannot be resolved to a real column — such a filter
|
|
15
|
+
* must keep the post-read verification instead of producing a broken JOIN.
|
|
16
|
+
*/
|
|
17
|
+
function mapAccessWhereToJoinColumns(where, attributes) {
|
|
18
|
+
const result = {};
|
|
19
|
+
for (const [key, value] of Object.entries(where)) {
|
|
20
|
+
const attr = attributes?.[key];
|
|
21
|
+
if (attr?.type === "association-many") {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
if (attr?.type !== "association") {
|
|
25
|
+
result[key] = value;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
// An association key must resolve to a plain FK column; a `via` that is itself
|
|
29
|
+
// the association (TypeORM models without a declared FK property) cannot.
|
|
30
|
+
if (!attr.via || attributes?.[attr.via]?.type === "association") {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
result[attr.via] = value;
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Access-filter shapes every adapter can AND into a JOIN's ON clause: plain columns
|
|
39
|
+
* compared to a primitive or to an `{in: [...primitives]}` list. Anything else (logic
|
|
40
|
+
* operators, adapter-specific subquery operands) stays on the post-read verification.
|
|
41
|
+
*/
|
|
42
|
+
function isPushdownSafeWhere(where) {
|
|
43
|
+
const isPrimitive = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
44
|
+
return Object.entries(where).every(([key, value]) => {
|
|
45
|
+
if (key === "and" || key === "or" || key === "not" || key.includes("$") || key.includes(".")) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (isPrimitive(value)) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return isObject(value)
|
|
52
|
+
&& Object.keys(value).length === 1
|
|
53
|
+
&& Array.isArray(value.in)
|
|
54
|
+
&& value.in.every(isPrimitive);
|
|
55
|
+
});
|
|
7
56
|
}
|
|
8
57
|
function isPlainCriteriaWhere(where) {
|
|
9
58
|
if (!isObject(where)) {
|
|
@@ -20,9 +69,21 @@ function mergeCriteriaWhere(criteriaWhere, sanitizedCriteria) {
|
|
|
20
69
|
return sanitizedCriteria;
|
|
21
70
|
}
|
|
22
71
|
if (isPlainCriteriaWhere(criteriaWhere)) {
|
|
72
|
+
// Spreading would drop the caller's own condition on a key record access also
|
|
73
|
+
// constrains (a filter on the parent column of a graph model, typically) —
|
|
74
|
+
// both must hold, so a collision becomes an explicit AND.
|
|
75
|
+
const collides = Object.keys(sanitizedCriteria).some((key) => key in criteriaWhere);
|
|
76
|
+
if (!collides) {
|
|
77
|
+
return {
|
|
78
|
+
...criteriaWhere,
|
|
79
|
+
...sanitizedCriteria
|
|
80
|
+
};
|
|
81
|
+
}
|
|
23
82
|
return {
|
|
24
|
-
|
|
25
|
-
|
|
83
|
+
and: [
|
|
84
|
+
criteriaWhere,
|
|
85
|
+
sanitizedCriteria
|
|
86
|
+
]
|
|
26
87
|
};
|
|
27
88
|
}
|
|
28
89
|
return {
|
|
@@ -39,36 +100,42 @@ export class DataAccessor {
|
|
|
39
100
|
action;
|
|
40
101
|
fields = null;
|
|
41
102
|
actionVerb;
|
|
42
|
-
|
|
103
|
+
associatedPrimaryKeyCache = new Map();
|
|
104
|
+
recordAccessAssociationFieldsCache;
|
|
105
|
+
/** Set on the internal accessors used to verify associations — see disablePopulateAccess(). */
|
|
106
|
+
populateAccessDisabled = false;
|
|
107
|
+
/** Accessors of associated models, so their access lookups are resolved once per model. */
|
|
108
|
+
associatedAccessorCache = new Map();
|
|
109
|
+
/** Per-request record-access resolver; its memoized id lists live as long as this accessor. */
|
|
110
|
+
recordAccessResolver;
|
|
111
|
+
/**
|
|
112
|
+
* Memoization shared with the other accessors of the same request; pass one when
|
|
113
|
+
* building several accessors for one page so the membership lookups run once.
|
|
114
|
+
*/
|
|
115
|
+
recordAccessCache;
|
|
116
|
+
constructor(adminizer, user, modelResource, action, recordAccessCache) {
|
|
117
|
+
this.recordAccessCache = recordAccessCache ?? new RecordAccessCache();
|
|
43
118
|
this.adminizer = adminizer;
|
|
44
119
|
this.user = user;
|
|
45
120
|
this.modelResource = modelResource;
|
|
46
121
|
this.action = action;
|
|
47
122
|
this.actionVerb = getTokenAction(this.action);
|
|
48
123
|
}
|
|
49
|
-
getModelConfig(
|
|
50
|
-
const
|
|
51
|
-
const entry = Object.entries(this.adminizer.config.models)
|
|
52
|
-
.find(([key, config]) => {
|
|
53
|
-
if (key.toLowerCase() === resolvedModelName.toLowerCase()) {
|
|
54
|
-
return true;
|
|
55
|
-
}
|
|
56
|
-
if (!isObject(config)) {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
return config.model?.toLowerCase() === resolvedModelName.toLowerCase();
|
|
60
|
-
});
|
|
61
|
-
const config = entry ? entry[1] : undefined;
|
|
124
|
+
getModelConfig(resourceName) {
|
|
125
|
+
const config = this.adminizer.config.models?.[resourceName];
|
|
62
126
|
return isObject(config) ? config : undefined;
|
|
63
127
|
}
|
|
64
|
-
|
|
65
|
-
return this.adminizer.modelHandler.internal("data-accessor").get(modelName);
|
|
66
|
-
}
|
|
67
|
-
resolveRelationTarget(modelName) {
|
|
128
|
+
resolveRelationTarget(modelName, resourceName) {
|
|
68
129
|
if (!modelName) {
|
|
69
130
|
return undefined;
|
|
70
131
|
}
|
|
71
|
-
return this.adminizer.modelHandler.
|
|
132
|
+
return this.adminizer.modelHandler.resolveAssociationResource(modelName, resourceName);
|
|
133
|
+
}
|
|
134
|
+
getRelationModel(modelName, resourceName) {
|
|
135
|
+
const resolvedResourceName = this.resolveRelationTarget(modelName, resourceName);
|
|
136
|
+
return resolvedResourceName
|
|
137
|
+
? this.adminizer.modelHandler.getResource(resolvedResourceName)
|
|
138
|
+
: undefined;
|
|
72
139
|
}
|
|
73
140
|
/**
|
|
74
141
|
* Retrieves the fields for the given ModelResource based on action type,
|
|
@@ -79,6 +146,12 @@ export class DataAccessor {
|
|
|
79
146
|
if (this.fields !== null) {
|
|
80
147
|
return this.fields;
|
|
81
148
|
}
|
|
149
|
+
// No access rights to the model at all beats every other outcome, including "no fields"
|
|
150
|
+
const tokenId = modelCrudToken(this.actionVerb, this.modelResource.name);
|
|
151
|
+
if (!this.adminizer.accessRightsHelper.hasStaticPermission(tokenId, this.user)) {
|
|
152
|
+
Adminizer.log.debug(`getFieldsConfig > No access rights to ${this.actionVerb} model: ${this.modelResource.name}`);
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
82
155
|
if (!this.modelResource.model || !this.modelResource.model.attributes) {
|
|
83
156
|
return {};
|
|
84
157
|
}
|
|
@@ -86,59 +159,48 @@ export class DataAccessor {
|
|
|
86
159
|
const actionConfig = ControllerHelper.findActionConfig(this.modelResource, this.action);
|
|
87
160
|
const fieldsConfig = this.modelResource.config?.fields || {};
|
|
88
161
|
const modelAttributes = this.modelResource.model.attributes;
|
|
89
|
-
const
|
|
90
|
-
if (!this.adminizer.accessRightsHelper.hasPermission(tokenId, this.user)) {
|
|
91
|
-
Adminizer.log.debug(`getFieldsConfig > No access rights to ${this.actionVerb} model: ${this.modelResource.model.modelname}`);
|
|
92
|
-
return undefined;
|
|
93
|
-
}
|
|
162
|
+
const primaryKey = (this.modelResource.model.primaryKey ?? 'id');
|
|
94
163
|
const result = {};
|
|
95
164
|
Object.entries(modelAttributes).forEach(([key, modelField]) => {
|
|
96
165
|
// The fields that are recorded separately from the connection in some ORMs, because they are processed at the level above them.
|
|
97
166
|
if (modelAttributes[key].primaryKeyForAssociation === true) {
|
|
98
|
-
return
|
|
167
|
+
return;
|
|
99
168
|
}
|
|
100
169
|
// Supports shorthand model attributes: fieldName: 'string'
|
|
101
170
|
if (typeof modelField === "string") {
|
|
102
171
|
modelField = { type: modelField };
|
|
103
172
|
}
|
|
104
|
-
// Set association type for a field
|
|
173
|
+
// Set association type for a field, on a copy — the shared model attributes stay untouched
|
|
105
174
|
if (modelField && typeof modelField === "object") {
|
|
106
175
|
if (modelField.model) {
|
|
107
|
-
modelField
|
|
176
|
+
modelField = { ...modelField, type: "association" };
|
|
108
177
|
}
|
|
109
178
|
if (modelField.collection) {
|
|
110
|
-
modelField
|
|
179
|
+
modelField = { ...modelField, type: "association-many" };
|
|
111
180
|
}
|
|
112
181
|
}
|
|
113
|
-
// Getting base field config
|
|
114
|
-
let fldConfig = { key: key, title: key };
|
|
115
|
-
let associatedModelConfig = undefined;
|
|
116
182
|
// Action-specific config has priority over global; objects are merged
|
|
117
183
|
const globalFieldConfig = fieldsConfig[key];
|
|
118
184
|
const actionFieldConfig = actionConfig.fields?.[key];
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (!this.checkFieldAccess(key, normalizedFieldConfig)) {
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
fldConfig = { ...fldConfig, ...normalizedFieldConfig };
|
|
185
|
+
const rawFieldConfig = globalFieldConfig || actionFieldConfig
|
|
186
|
+
? { ...globalFieldConfig, ...actionFieldConfig }
|
|
187
|
+
: undefined;
|
|
188
|
+
let fldConfig = this.buildBaseFieldConfig(key, modelField, rawFieldConfig, primaryKey);
|
|
189
|
+
if (!fldConfig) {
|
|
190
|
+
return;
|
|
129
191
|
}
|
|
130
192
|
// Populate associated fields configuration if field is an association
|
|
193
|
+
let associatedModelConfig = undefined;
|
|
131
194
|
let populatedModelFieldsConfig = {};
|
|
132
195
|
if (modelField.type === "association" || modelField.type === "association-many") {
|
|
133
196
|
const modelName = modelField.model || modelField.collection;
|
|
134
|
-
const resolvedModelName =
|
|
197
|
+
const resolvedModelName = this.resolveRelationTarget(modelName, modelField.resourceName);
|
|
135
198
|
if (modelName && resolvedModelName) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return undefined;
|
|
199
|
+
if (!this.adminizer.accessRightsHelper.hasStaticPermission(modelCrudToken("read", resolvedModelName), this.user)) {
|
|
200
|
+
Adminizer.log.silly(`No access rights to read associated model: ${resolvedModelName}`);
|
|
201
|
+
return;
|
|
140
202
|
}
|
|
141
|
-
const model = this.adminizer.modelHandler.
|
|
203
|
+
const model = this.adminizer.modelHandler.getResource(resolvedModelName);
|
|
142
204
|
if (model) {
|
|
143
205
|
populatedModelFieldsConfig = this.getAssociatedFieldsConfig(resolvedModelName);
|
|
144
206
|
const modelCfg = this.getModelConfig(resolvedModelName);
|
|
@@ -186,22 +248,22 @@ export class DataAccessor {
|
|
|
186
248
|
Object.keys(fields).forEach(appendField);
|
|
187
249
|
return orderedFields;
|
|
188
250
|
}
|
|
189
|
-
getAssociatedFieldsConfig(
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
const modelConfig = this.getModelConfig(resolvedModelName);
|
|
251
|
+
getAssociatedFieldsConfig(resourceName) {
|
|
252
|
+
const model = this.adminizer.modelHandler.getResource(resourceName);
|
|
253
|
+
const modelConfig = this.getModelConfig(resourceName);
|
|
193
254
|
if (!model || !modelConfig) {
|
|
194
255
|
return undefined;
|
|
195
256
|
}
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
if (!this.adminizer.accessRightsHelper.
|
|
199
|
-
Adminizer.log.debug(`getAssociatedFieldsConfig > No access rights to
|
|
257
|
+
// Defense in depth: the caller checks read access too, but this method must stay
|
|
258
|
+
// safe on its own for any future call site.
|
|
259
|
+
if (!this.adminizer.accessRightsHelper.hasStaticPermission(modelCrudToken("read", resourceName), this.user)) {
|
|
260
|
+
Adminizer.log.debug(`getAssociatedFieldsConfig > No access rights to read model: ${resourceName}`);
|
|
200
261
|
return undefined;
|
|
201
262
|
}
|
|
202
263
|
const associatedFields = {};
|
|
203
264
|
// Get the main fields configuration
|
|
204
265
|
const fieldsConfig = modelConfig.fields || {};
|
|
266
|
+
const primaryKey = (model.primaryKey ?? 'id');
|
|
205
267
|
// Merge action-specific fields configuration if it exists
|
|
206
268
|
const addCfg = typeof modelConfig.add === "object" ? modelConfig.add : undefined;
|
|
207
269
|
const editCfg = typeof modelConfig.edit === "object" ? modelConfig.edit : undefined;
|
|
@@ -224,22 +286,14 @@ export class DataAccessor {
|
|
|
224
286
|
actionSpecificConfig = {};
|
|
225
287
|
break;
|
|
226
288
|
default:
|
|
227
|
-
throw `Action type error: unknown type [${this.action}]
|
|
289
|
+
throw new Error(`Action type error: unknown type [${this.action}]`);
|
|
228
290
|
}
|
|
229
291
|
const mergedFieldsConfig = { ...fieldsConfig, ...actionSpecificConfig };
|
|
230
292
|
// Loop through model attributes and apply access checks
|
|
231
293
|
Object.entries(model.attributes).forEach(([key, modelField]) => {
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
// If fieldConfig exists, normalize it and merge with the basic config
|
|
236
|
-
if (fieldConfig) {
|
|
237
|
-
const normalizedFieldConfig = this.adminizer.configHelper.normalizeFieldConfig(this.adminizer, fieldConfig, key, modelField);
|
|
238
|
-
if (!normalizedFieldConfig)
|
|
239
|
-
return;
|
|
240
|
-
if (!this.checkFieldAccess(key, normalizedFieldConfig))
|
|
241
|
-
return;
|
|
242
|
-
fldConfig = { ...fldConfig, ...normalizedFieldConfig };
|
|
294
|
+
const fldConfig = this.buildBaseFieldConfig(key, modelField, mergedFieldsConfig[key], primaryKey);
|
|
295
|
+
if (!fldConfig) {
|
|
296
|
+
return;
|
|
243
297
|
}
|
|
244
298
|
// Add the field to associatedFields regardless of config presence
|
|
245
299
|
associatedFields[key] = {
|
|
@@ -251,8 +305,27 @@ export class DataAccessor {
|
|
|
251
305
|
});
|
|
252
306
|
return associatedFields;
|
|
253
307
|
}
|
|
254
|
-
|
|
255
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Base config for a field: the normalized declared config merged over `{key, title}`,
|
|
310
|
+
* or `undefined` when the config is invalid or field access is denied.
|
|
311
|
+
*/
|
|
312
|
+
buildBaseFieldConfig(key, modelField, rawFieldConfig, primaryKey) {
|
|
313
|
+
let fldConfig = { key: key, title: key };
|
|
314
|
+
if (rawFieldConfig) {
|
|
315
|
+
const normalizedFieldConfig = this.adminizer.configHelper.normalizeFieldConfig(this.adminizer, rawFieldConfig, key, modelField);
|
|
316
|
+
if (!normalizedFieldConfig) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
if (!this.checkFieldAccess(key, normalizedFieldConfig, primaryKey)) {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
fldConfig = { ...fldConfig, ...normalizedFieldConfig };
|
|
323
|
+
}
|
|
324
|
+
return fldConfig;
|
|
325
|
+
}
|
|
326
|
+
checkFieldAccess(key, fieldConfig, primaryKey) {
|
|
327
|
+
// The primary key of the record's own model is always visible
|
|
328
|
+
if (primaryKey !== undefined && primaryKey === key) {
|
|
256
329
|
return true;
|
|
257
330
|
}
|
|
258
331
|
if (this.user.isAdministrator) {
|
|
@@ -262,12 +335,12 @@ export class DataAccessor {
|
|
|
262
335
|
// Check if `groupsAccessRights` is set in the fieldConfig
|
|
263
336
|
if (fieldConfig.groupsAccessRights) {
|
|
264
337
|
const allowedGroups = fieldConfig.groupsAccessRights.map((item) => item.toLowerCase());
|
|
265
|
-
return userGroups
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
// If no specific groups are allowed, deny access if the user is in "default user group"
|
|
269
|
-
return !userGroups || !userGroups.includes(this.adminizer.config.registration?.defaultUserGroup);
|
|
338
|
+
return Boolean(userGroups?.some(group => allowedGroups.includes(group)));
|
|
270
339
|
}
|
|
340
|
+
// No explicit list: the field is visible to everyone except members of the
|
|
341
|
+
// registration default group (both sides compared lowercased).
|
|
342
|
+
const defaultUserGroup = this.adminizer.config.registration?.defaultUserGroup?.toLowerCase();
|
|
343
|
+
return !userGroups || !defaultUserGroup || !userGroups.includes(defaultUserGroup);
|
|
271
344
|
}
|
|
272
345
|
getSelectedFields(select) {
|
|
273
346
|
if (!select) {
|
|
@@ -284,9 +357,29 @@ export class DataAccessor {
|
|
|
284
357
|
const populateConfig = criteria?.populate?.[fieldKey];
|
|
285
358
|
return populateConfig && populateConfig !== true ? populateConfig : undefined;
|
|
286
359
|
}
|
|
287
|
-
|
|
360
|
+
/** Administrators, and every user when authorization is switched off entirely. */
|
|
361
|
+
hasUnrestrictedAccess() {
|
|
288
362
|
return this.user.isAdministrator || this.adminizer.config.auth?.enable === false;
|
|
289
363
|
}
|
|
364
|
+
canUseSelectedFieldFallback() {
|
|
365
|
+
return this.hasUnrestrictedAccess();
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Primary key of a field's associated model, for the PK-always-visible rule on
|
|
369
|
+
* populated records. Cached per field config: `process()` asks once per associated
|
|
370
|
+
* item of every record, and the answer cannot change within an accessor's lifetime.
|
|
371
|
+
*/
|
|
372
|
+
associatedPrimaryKey(fieldConfig) {
|
|
373
|
+
if (this.associatedPrimaryKeyCache.has(fieldConfig)) {
|
|
374
|
+
return this.associatedPrimaryKeyCache.get(fieldConfig);
|
|
375
|
+
}
|
|
376
|
+
const modelName = fieldConfig.model?.model || fieldConfig.model?.collection;
|
|
377
|
+
const primaryKey = modelName
|
|
378
|
+
? this.getRelationModel(modelName, fieldConfig.model?.resourceName)?.primaryKey
|
|
379
|
+
: undefined;
|
|
380
|
+
this.associatedPrimaryKeyCache.set(fieldConfig, primaryKey);
|
|
381
|
+
return primaryKey;
|
|
382
|
+
}
|
|
290
383
|
/**
|
|
291
384
|
* Returns filtered record applying config from this.fields on this record
|
|
292
385
|
* @data - record from a specific model */
|
|
@@ -296,6 +389,10 @@ export class DataAccessor {
|
|
|
296
389
|
this.fields = this.getFieldsConfig();
|
|
297
390
|
}
|
|
298
391
|
const filteredRecord = {};
|
|
392
|
+
// No access rights to the model at all: fail closed with an empty record
|
|
393
|
+
if (!this.fields) {
|
|
394
|
+
return filteredRecord;
|
|
395
|
+
}
|
|
299
396
|
// Set the primary key value
|
|
300
397
|
const primaryKey = (this.modelResource.model.primaryKey ?? 'id');
|
|
301
398
|
filteredRecord[primaryKey] = record[primaryKey];
|
|
@@ -306,7 +403,7 @@ export class DataAccessor {
|
|
|
306
403
|
if (!fieldConfig)
|
|
307
404
|
continue;
|
|
308
405
|
// Check access to the field
|
|
309
|
-
if (this.checkFieldAccess(fieldKey, fieldConfig.config)) {
|
|
406
|
+
if (this.checkFieldAccess(fieldKey, fieldConfig.config, primaryKey)) {
|
|
310
407
|
const fieldType = fieldConfig.config.type;
|
|
311
408
|
const populateCriteria = this.getPopulateCriteria(criteria, fieldKey);
|
|
312
409
|
const selectedAssociatedFields = this.getSelectedFields(populateCriteria?.select);
|
|
@@ -319,7 +416,7 @@ export class DataAccessor {
|
|
|
319
416
|
if (Array.isArray(fieldValue)) {
|
|
320
417
|
// If the field value is an array of objects
|
|
321
418
|
filteredRecord[fieldKey] = (fieldValue.every(item => typeof item === 'object')
|
|
322
|
-
? fieldValue.map(associatedRecord => this.filterAssociatedRecord(associatedRecord, fieldConfig.populated, selectedAssociatedFields))
|
|
419
|
+
? fieldValue.map(associatedRecord => this.filterAssociatedRecord(associatedRecord, fieldConfig.populated, selectedAssociatedFields, this.associatedPrimaryKey(fieldConfig)))
|
|
323
420
|
: fieldValue); // If the array contains IDs, pass them as is (it can contain ids, because this function also can be called before saving something)
|
|
324
421
|
}
|
|
325
422
|
else {
|
|
@@ -328,30 +425,22 @@ export class DataAccessor {
|
|
|
328
425
|
}
|
|
329
426
|
}
|
|
330
427
|
// Handle single associations
|
|
331
|
-
else
|
|
428
|
+
else {
|
|
332
429
|
if (fieldValue && typeof fieldValue === 'object') {
|
|
333
430
|
// If the field value is an object
|
|
334
|
-
filteredRecord[fieldKey] = this.filterAssociatedRecord(fieldValue, fieldConfig.populated, selectedAssociatedFields);
|
|
431
|
+
filteredRecord[fieldKey] = this.filterAssociatedRecord(fieldValue, fieldConfig.populated, selectedAssociatedFields, this.associatedPrimaryKey(fieldConfig));
|
|
335
432
|
}
|
|
336
433
|
else {
|
|
337
434
|
// If the field value is an ID or null, pass it as is (it can contain id, because this function also can be called before saving something)
|
|
338
435
|
filteredRecord[fieldKey] = fieldValue;
|
|
339
436
|
}
|
|
340
437
|
}
|
|
341
|
-
// Handle cases where the field value is null or undefined
|
|
342
|
-
else if (fieldValue === undefined || fieldValue === null) {
|
|
343
|
-
filteredRecord[fieldKey] = null;
|
|
344
|
-
}
|
|
345
|
-
// Log an error for unexpected scenarios
|
|
346
|
-
else {
|
|
347
|
-
Adminizer.log.error(`Unexpected field configuration or value for: ${fieldConfig.model.model}.${fieldKey}`);
|
|
348
|
-
}
|
|
349
438
|
}
|
|
350
439
|
}
|
|
351
440
|
return filteredRecord;
|
|
352
441
|
}
|
|
353
442
|
/** Filters associated records (simplified process() function) */
|
|
354
|
-
filterAssociatedRecord(associatedRecord, associatedFieldsConfig, selectedFields) {
|
|
443
|
+
filterAssociatedRecord(associatedRecord, associatedFieldsConfig, selectedFields, associatedPrimaryKey) {
|
|
355
444
|
if (!associatedFieldsConfig && !selectedFields) {
|
|
356
445
|
return {};
|
|
357
446
|
}
|
|
@@ -362,7 +451,7 @@ export class DataAccessor {
|
|
|
362
451
|
}
|
|
363
452
|
const assocFieldConfig = associatedFieldsConfig?.[assocFieldKey];
|
|
364
453
|
const assocFieldValue = associatedRecord[assocFieldKey];
|
|
365
|
-
if (assocFieldConfig && this.checkFieldAccess(assocFieldKey, assocFieldConfig.config)) {
|
|
454
|
+
if (assocFieldConfig && this.checkFieldAccess(assocFieldKey, assocFieldConfig.config, associatedPrimaryKey)) {
|
|
366
455
|
filteredAssociatedRecord[assocFieldKey] = assocFieldValue;
|
|
367
456
|
continue;
|
|
368
457
|
}
|
|
@@ -376,167 +465,287 @@ export class DataAccessor {
|
|
|
376
465
|
processMany(records, criteria) {
|
|
377
466
|
return records.map(record => this.process(record, criteria));
|
|
378
467
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
else if (relationModel === 'group') {
|
|
407
|
-
// Filter by user's group membership if related to Group as a model
|
|
408
|
-
const userGroups = this.user.groups?.map((group) => group.id);
|
|
409
|
-
sanitizedCriteria = { ...sanitizedCriteria, [accessField]: { in: userGroups } };
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
/** Warning: collection relation access is not supported and needs adapter-level processing */
|
|
413
|
-
if (relationCollection) {
|
|
414
|
-
Adminizer.log.warn(`Collection relation is not supported and was not tested. You may have an error here: ${JSON.stringify(relation, null, 2)}`);
|
|
415
|
-
if (relationCollection === 'user') {
|
|
416
|
-
// Ensure user's ID is part of the associated collection to User
|
|
417
|
-
sanitizedCriteria = { ...sanitizedCriteria, [accessField]: { contains: this.user.id } };
|
|
418
|
-
}
|
|
419
|
-
else if (relationCollection === 'group') {
|
|
420
|
-
// Ensure user's groups intersect with the collection to Group
|
|
421
|
-
const userGroups = this.user.groups?.map((group) => group.id);
|
|
422
|
-
sanitizedCriteria = { ...sanitizedCriteria, [accessField]: { intersects: userGroups } };
|
|
423
|
-
}
|
|
424
|
-
}
|
|
468
|
+
/**
|
|
469
|
+
* {@link processMany} plus record-level access on populated associations.
|
|
470
|
+
*
|
|
471
|
+
* `sanitizeUserRelationAccess` confines the queried model only, while adapters
|
|
472
|
+
* populate associations regardless of record access — so a record the user may not reach can
|
|
473
|
+
* still be read through a relation of one they may. Records the target model's own rules
|
|
474
|
+
* keep out of reach are reduced to their primary key here, which costs one batched query
|
|
475
|
+
* per associated restricted model (memoized for the accessor's lifetime) — except the
|
|
476
|
+
* associations whose confinement {@link pushDownPopulateAccess} already compiled into
|
|
477
|
+
* the populate JOIN itself: those arrive pre-reduced and are skipped.
|
|
478
|
+
*
|
|
479
|
+
* Read paths only: write paths hand back the record the user just supplied.
|
|
480
|
+
*/
|
|
481
|
+
async processManyWithAccess(records, criteria) {
|
|
482
|
+
const processed = this.processMany(records, criteria);
|
|
483
|
+
// Administrators only: `auth.enable: false` does not lift record scoping
|
|
484
|
+
// (`RecordAccessResolver.buildWhere` exempts administrators alone), so the
|
|
485
|
+
// populated records of a restricted model must not escape those rules either.
|
|
486
|
+
if (this.populateAccessDisabled || !processed.length || this.user.isAdministrator) {
|
|
487
|
+
return processed;
|
|
488
|
+
}
|
|
489
|
+
for (const [fieldKey, fieldConfig] of this.recordAccessAssociationFields()) {
|
|
490
|
+
if (criteria?.populateOn?.[fieldKey]) {
|
|
491
|
+
// The access condition ran inside the populate JOIN; records out of
|
|
492
|
+
// reach already arrived as bare foreign keys.
|
|
493
|
+
continue;
|
|
425
494
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const intermediateModel = this.adminizer.modelHandler.model.get(intermediateRelation.model.toLowerCase());
|
|
437
|
-
if (!intermediateModel) {
|
|
438
|
-
throw new Error(`Intermediate model "${intermediateRelation.model}" not found`);
|
|
439
|
-
}
|
|
440
|
-
// Validate the `via` field in the intermediate model
|
|
441
|
-
const intermediateAttributes = intermediateModel.attributes;
|
|
442
|
-
const viaRelation = intermediateAttributes[via];
|
|
443
|
-
if (!viaRelation || this.resolveRelationTarget(viaRelation.model) !== 'user') {
|
|
444
|
-
throw new Error(`Unsupported or invalid via field "${via}" in intermediate model "${intermediateRelation.model}". ` +
|
|
445
|
-
`Currently, only relations to "User" are supported`);
|
|
446
|
-
}
|
|
447
|
-
// Fetch all intermediate records associated with the user
|
|
448
|
-
const intermediateRecords = await this.accessRelationModel(intermediateRelation.model).find({ where: { [via]: this.user.id } });
|
|
449
|
-
const intermediateIds = (intermediateRecords || []).map((r) => r.id);
|
|
450
|
-
// Filter main model by all matching intermediate record IDs
|
|
451
|
-
sanitizedCriteria = { ...sanitizedCriteria, [field]: { in: intermediateIds } };
|
|
495
|
+
const referenced = this.collectAssociatedIds(processed, fieldKey, fieldConfig);
|
|
496
|
+
if (!referenced.size) {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const visibleIds = await this.visibleAssociatedIds(fieldConfig, Array.from(referenced.values()));
|
|
500
|
+
if (visibleIds === undefined) {
|
|
501
|
+
continue; // the associated model carries no record access rules
|
|
502
|
+
}
|
|
503
|
+
for (const record of processed) {
|
|
504
|
+
this.reduceInvisibleAssociations(record, fieldKey, fieldConfig, visibleIds);
|
|
452
505
|
}
|
|
453
506
|
}
|
|
454
|
-
return
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
507
|
+
return processed;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Compiles the record-access confinement of single populated associations into the
|
|
511
|
+
* criteria (`populateOn`), so the adapter ANDs it into the populate JOIN's ON clause
|
|
512
|
+
* and {@link processManyWithAccess} skips the post-read query for those fields — the
|
|
513
|
+
* closed records arrive as bare foreign keys straight from the database. Only the
|
|
514
|
+
* associations the adapter declares pushdown-capable and whose access filter has an
|
|
515
|
+
* adapter-portable shape are compiled; the rest keep the post-read verification.
|
|
516
|
+
*
|
|
517
|
+
* Single associations only: a filtered-out belongsTo row still carries its foreign
|
|
518
|
+
* key, while a record dropped from a populated collection would lose its id — and an
|
|
519
|
+
* edit form saving that truncated collection would silently sever the links.
|
|
520
|
+
*
|
|
521
|
+
* Any incoming `populateOn` is discarded — this method is its sole author, because
|
|
522
|
+
* the verification skip above trusts the key.
|
|
523
|
+
*/
|
|
524
|
+
async pushDownPopulateAccess(criteria = {}) {
|
|
525
|
+
const { populateOn: _discarded, ...rest } = criteria;
|
|
526
|
+
const result = rest;
|
|
527
|
+
if (this.populateAccessDisabled || this.user.isAdministrator) {
|
|
528
|
+
return result;
|
|
529
|
+
}
|
|
530
|
+
const populateOn = {};
|
|
531
|
+
for (const [fieldKey, fieldConfig] of this.recordAccessAssociationFields()) {
|
|
532
|
+
if (fieldConfig.config?.type !== "association") {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (!this.modelResource.model?.canPushdownPopulateAccess?.(fieldKey)) {
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const resourceName = this.associatedResourceName(fieldConfig);
|
|
539
|
+
const accessor = resourceName ? this.associatedAccessor(resourceName) : undefined;
|
|
540
|
+
if (!accessor) {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
let accessWhere;
|
|
544
|
+
try {
|
|
545
|
+
accessWhere = await accessor.getRecordAccessWhere();
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
// Unresolvable access rules keep the post-read verification, which fails closed.
|
|
549
|
+
Adminizer.log.error(`Could not resolve the record access of associated model "${resourceName}"`, e);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (!hasCriteriaContent(accessWhere) || !isPushdownSafeWhere(accessWhere)) {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const joinWhere = mapAccessWhereToJoinColumns(accessWhere, this.adminizer.modelHandler.getResource(resourceName)?.attributes);
|
|
556
|
+
if (!joinWhere) {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
populateOn[fieldKey] = joinWhere;
|
|
560
|
+
}
|
|
561
|
+
return Object.keys(populateOn).length ? { ...result, populateOn } : result;
|
|
458
562
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
563
|
+
/**
|
|
564
|
+
* Turns the association verification of {@link processManyWithAccess} off for this accessor.
|
|
565
|
+
* Used for the accessors that perform that verification themselves: they only read ids,
|
|
566
|
+
* and two restricted models referencing each other sideways would otherwise recurse.
|
|
567
|
+
*/
|
|
568
|
+
disablePopulateAccess() {
|
|
569
|
+
this.populateAccessDisabled = true;
|
|
570
|
+
return this;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Association fields whose target model is access-restricted and whose content therefore
|
|
574
|
+
* needs verifying — except the edge pointing at this model's own graph parent, where
|
|
575
|
+
* the target is reachable by construction (a visible child hangs off a visible parent).
|
|
576
|
+
*/
|
|
577
|
+
recordAccessAssociationFields() {
|
|
578
|
+
if (this.recordAccessAssociationFieldsCache) {
|
|
579
|
+
return this.recordAccessAssociationFieldsCache;
|
|
580
|
+
}
|
|
581
|
+
// May run before the first process() call — build the fields config here, or an
|
|
582
|
+
// empty list would be computed and cached.
|
|
583
|
+
if (!this.fields) {
|
|
584
|
+
this.fields = this.getFieldsConfig();
|
|
585
|
+
}
|
|
586
|
+
const result = [];
|
|
587
|
+
for (const [fieldKey, fieldConfig] of Object.entries(this.fields ?? {})) {
|
|
588
|
+
const fieldType = fieldConfig.config?.type;
|
|
589
|
+
if (fieldType !== "association" && fieldType !== "association-many") {
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
const resourceName = this.associatedResourceName(fieldConfig);
|
|
593
|
+
if (!resourceName || !this.hasRecordAccessRules(resourceName)) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (isGraphParentEdge(this.adminizer, this.modelResource.name, fieldKey, resourceName)) {
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
result.push([fieldKey, fieldConfig]);
|
|
600
|
+
}
|
|
601
|
+
this.recordAccessAssociationFieldsCache = result;
|
|
602
|
+
return result;
|
|
603
|
+
}
|
|
604
|
+
/** True when the model confines its records to the user, by its own relation or the graph. */
|
|
605
|
+
hasRecordAccessRules(resourceName) {
|
|
606
|
+
const modelConfig = this.adminizer.config.models?.[resourceName];
|
|
607
|
+
if (modelConfig?.userAccessRelation) {
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
return AccessGraphResolver.covers(this.adminizer, resourceName);
|
|
611
|
+
}
|
|
612
|
+
associatedResourceName(fieldConfig) {
|
|
613
|
+
const modelName = fieldConfig.model?.model || fieldConfig.model?.collection;
|
|
614
|
+
return modelName ? this.resolveRelationTarget(modelName, fieldConfig.model?.resourceName) : undefined;
|
|
615
|
+
}
|
|
616
|
+
/** Primary keys of the populated records referenced by a field, keyed by their string form. */
|
|
617
|
+
collectAssociatedIds(records, fieldKey, fieldConfig) {
|
|
618
|
+
const primaryKey = this.associatedPrimaryKey(fieldConfig) ?? "id";
|
|
619
|
+
const ids = new Map();
|
|
620
|
+
for (const record of records) {
|
|
621
|
+
const value = record[fieldKey];
|
|
622
|
+
const items = Array.isArray(value) ? value : [value];
|
|
623
|
+
for (const item of items) {
|
|
624
|
+
// Bare ids need no verification: they expose nothing beyond the reference
|
|
625
|
+
// the parent record already carries.
|
|
626
|
+
if (!item || typeof item !== "object") {
|
|
627
|
+
continue;
|
|
470
628
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const relationModel = this.resolveRelationTarget(relation?.model);
|
|
475
|
-
if (relation && ['user', 'group'].includes(relationModel ?? "")) {
|
|
476
|
-
if (relationModel === 'user') {
|
|
477
|
-
if (!this.user.isAdministrator) {
|
|
478
|
-
updatedRecord[accessField] = this.user.id;
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
else if (relationModel === 'group') {
|
|
482
|
-
const userGroups = this.user.groups || [];
|
|
483
|
-
if (userGroups.length === 1) {
|
|
484
|
-
updatedRecord[accessField] = userGroups[0].id;
|
|
485
|
-
}
|
|
486
|
-
else {
|
|
487
|
-
throw new Error('Record cannot be saved because the user is associated with none or multiple groups.');
|
|
488
|
-
}
|
|
489
|
-
}
|
|
629
|
+
const id = item[primaryKey];
|
|
630
|
+
if (id !== null && id !== undefined) {
|
|
631
|
+
ids.set(String(id), id);
|
|
490
632
|
}
|
|
491
633
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
throw new Error(`Access denied: "${field}" does not belong to the current user`);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
634
|
+
}
|
|
635
|
+
return ids;
|
|
636
|
+
}
|
|
637
|
+
/** Replaces populated records the user may not reach with their bare primary key. */
|
|
638
|
+
reduceInvisibleAssociations(record, fieldKey, fieldConfig, visibleIds) {
|
|
639
|
+
const primaryKey = this.associatedPrimaryKey(fieldConfig) ?? "id";
|
|
640
|
+
const data = record;
|
|
641
|
+
const value = data[fieldKey];
|
|
642
|
+
const reduce = (item) => {
|
|
643
|
+
if (!item || typeof item !== "object") {
|
|
644
|
+
return item;
|
|
645
|
+
}
|
|
646
|
+
const id = item[primaryKey];
|
|
647
|
+
if (id === null || id === undefined || visibleIds.has(String(id))) {
|
|
648
|
+
return item;
|
|
649
|
+
}
|
|
650
|
+
return id;
|
|
651
|
+
};
|
|
652
|
+
data[fieldKey] = Array.isArray(value) ? value.map(reduce) : reduce(value);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The associated model's own view accessor, shared between the pushdown and the
|
|
656
|
+
* post-read verification so its access lookups resolve once per model.
|
|
657
|
+
*/
|
|
658
|
+
associatedAccessor(resourceName) {
|
|
659
|
+
let accessor = this.associatedAccessorCache.get(resourceName);
|
|
660
|
+
if (!accessor) {
|
|
661
|
+
const model = this.adminizer.modelHandler.getResource(resourceName);
|
|
662
|
+
if (!model) {
|
|
663
|
+
return undefined;
|
|
525
664
|
}
|
|
665
|
+
accessor = new DataAccessor(this.adminizer, this.user, {
|
|
666
|
+
name: resourceName,
|
|
667
|
+
config: this.adminizer.config.models?.[resourceName],
|
|
668
|
+
model,
|
|
669
|
+
uri: `${this.adminizer.config.routePrefix}/model/${resourceName}`
|
|
670
|
+
}, "view", this.recordAccessCache).disablePopulateAccess();
|
|
671
|
+
this.associatedAccessorCache.set(resourceName, accessor);
|
|
526
672
|
}
|
|
527
|
-
return
|
|
673
|
+
return accessor;
|
|
528
674
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
return
|
|
539
|
-
|
|
540
|
-
|
|
675
|
+
/**
|
|
676
|
+
* Subset of `ids` the user may reach in the associated model, or `undefined` when that
|
|
677
|
+
* model has no record access rules. The target's own accessor is reused across calls, so its
|
|
678
|
+
* membership lookups are resolved once.
|
|
679
|
+
*/
|
|
680
|
+
async visibleAssociatedIds(fieldConfig, ids) {
|
|
681
|
+
const resourceName = this.associatedResourceName(fieldConfig);
|
|
682
|
+
const accessor = resourceName ? this.associatedAccessor(resourceName) : undefined;
|
|
683
|
+
if (!resourceName || !accessor) {
|
|
684
|
+
return new Set();
|
|
685
|
+
}
|
|
686
|
+
const model = this.adminizer.modelHandler.getResource(resourceName);
|
|
687
|
+
if (!model) {
|
|
688
|
+
return new Set();
|
|
689
|
+
}
|
|
690
|
+
let accessWhere;
|
|
691
|
+
try {
|
|
692
|
+
accessWhere = await accessor.getRecordAccessWhere();
|
|
693
|
+
}
|
|
694
|
+
catch (e) {
|
|
695
|
+
// A misconfigured graph must not widen what the relation exposes.
|
|
696
|
+
Adminizer.log.error(`Could not resolve the record access of associated model "${resourceName}"`, e);
|
|
697
|
+
return new Set();
|
|
698
|
+
}
|
|
699
|
+
if (!hasCriteriaContent(accessWhere)) {
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
const primaryKey = (model.primaryKey ?? "id");
|
|
703
|
+
// The accessor is shared with the access resolution above, so `find` reuses its
|
|
704
|
+
// memoized membership lookups instead of querying them again.
|
|
705
|
+
const rows = await model.find({ where: { [primaryKey]: { in: ids } }, select: [primaryKey] }, accessor);
|
|
706
|
+
return new Set((rows ?? []).map((row) => String(row[primaryKey])));
|
|
707
|
+
}
|
|
708
|
+
async sanitizeUserRelationAccess(criteria = {}) {
|
|
709
|
+
const sanitizedCriteria = await this.getRecordAccessResolver().buildWhere();
|
|
710
|
+
// Both adapters read field conditions from `where` when it has keys and from the
|
|
711
|
+
// top level otherwise. Injecting `where` here would therefore make them ignore the
|
|
712
|
+
// top-level ones — a flat `{id}` update would silently widen to every record the
|
|
713
|
+
// user may reach — so the flat form is folded into `where` first.
|
|
714
|
+
// `populateOn` is discarded: only `pushDownPopulateAccess` may author it — the
|
|
715
|
+
// association verification trusts the key, so a smuggled one must not survive.
|
|
716
|
+
const { where, skip, limit, sort, select, populate, populateOn: _discarded, ...flat } = criteria;
|
|
717
|
+
// `sort` is an ordering directive only as a string; anything else is a model field.
|
|
718
|
+
if (sort !== undefined && typeof sort !== "string") {
|
|
719
|
+
flat.sort = sort;
|
|
720
|
+
}
|
|
721
|
+
const criteriaWhere = Object.keys(flat).length
|
|
722
|
+
? mergeCriteriaWhere(where, flat)
|
|
723
|
+
: where;
|
|
724
|
+
return {
|
|
725
|
+
...(skip !== undefined ? { skip } : {}),
|
|
726
|
+
...(limit !== undefined ? { limit } : {}),
|
|
727
|
+
...(typeof sort === "string" ? { sort } : {}),
|
|
728
|
+
...(select !== undefined ? { select } : {}),
|
|
729
|
+
...(populate !== undefined ? { populate } : {}),
|
|
730
|
+
where: mergeCriteriaWhere(criteriaWhere, sanitizedCriteria)
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
setUserRelationAccess(record) {
|
|
734
|
+
return this.getRecordAccessResolver().applyWriteRules(record);
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Where-fragment confining this model to the records the user may reach
|
|
738
|
+
* (`userAccessRelation` or accessGraph); `{}` means no record-level constraint.
|
|
739
|
+
*
|
|
740
|
+
* For surfaces that reference records without reading them through the model API —
|
|
741
|
+
* the change history, for one — and therefore cannot rely on
|
|
742
|
+
* {@link sanitizeUserRelationAccess} doing it for them.
|
|
743
|
+
*/
|
|
744
|
+
getRecordAccessWhere() {
|
|
745
|
+
return this.getRecordAccessResolver().buildWhere();
|
|
746
|
+
}
|
|
747
|
+
getRecordAccessResolver() {
|
|
748
|
+
this.recordAccessResolver ??= new RecordAccessResolver(this.adminizer, this.user, this.modelResource, this.actionVerb, this.recordAccessCache);
|
|
749
|
+
return this.recordAccessResolver;
|
|
541
750
|
}
|
|
542
751
|
}
|