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.
Files changed (232) hide show
  1. package/assets/abnfDiagram-VCTEODGH-BTgLIXrb.js +1 -0
  2. package/assets/add-C8V8qjvm.js +1 -0
  3. package/assets/add-group-Ezq_Ocot.js +1 -0
  4. package/assets/add-user-CluNf2QG.js +1 -0
  5. package/assets/ai-assistant/agent.es.js +28464 -0
  6. package/assets/app-C96pcs-J.js +138 -0
  7. package/assets/app-DPmI-MvN.css +1 -0
  8. package/assets/arc-G6n0TtRf.js +1 -0
  9. package/assets/architectureDiagram-5GKGNRK7-TQ4_iSAG.js +36 -0
  10. package/assets/asmcrypto-DZeQH0LC.js +1 -0
  11. package/assets/blockDiagram-I7D4REHJ-1q050zQd.js +129 -0
  12. package/assets/c4Diagram-7LVT6UL2-BXn0hW62.js +38 -0
  13. package/assets/{catalog-dk6a0I1u.js → catalog-Cpx4OH_r.js} +3 -3
  14. package/assets/channel-JZzXP_E5.js +1 -0
  15. package/assets/chunk-2Q5K7J3B-BSnocdBz.js +1 -0
  16. package/assets/chunk-5VM5RSS4-BvfoMSpq.js +15 -0
  17. package/assets/chunk-F27PBJKO-CMyFcBlX.js +1 -0
  18. package/assets/chunk-IMKFNOWR--8Y02hwt.js +231 -0
  19. package/assets/chunk-JWPE2WC7-C18m6v8d.js +1 -0
  20. package/assets/chunk-POPQ4Y6H-DzoTZe0a.js +1 -0
  21. package/assets/chunk-SVP7TREG-Ce98IXh7.js +88 -0
  22. package/assets/chunk-TICWLB2K-BeLoUlaL.js +206 -0
  23. package/assets/chunk-XXDRQBXY-DCZSZ1aW.js +1 -0
  24. package/assets/classDiagram-ZZMXUADV-jA4zeKxs.js +1 -0
  25. package/assets/classDiagram-v2-VYDZK3BY-jA4zeKxs.js +1 -0
  26. package/assets/controls/ckeditor.es.js +9382 -9320
  27. package/assets/controls/handsontable.es.js +11971 -11938
  28. package/assets/controls/jsoneditor.es.js +10442 -10233
  29. package/assets/controls/toast-ui.es.js +1308 -1279
  30. package/assets/cose-bilkent-JH36ORCC-Cy9np608.js +1 -0
  31. package/assets/cynefin-OW5HDTMX-Ba6uP79R.js +166 -0
  32. package/assets/cynefinDiagram-5FMLGOSQ-DhMBBq7T.js +62 -0
  33. package/assets/cytoscape.esm-DzSz-X2X.js +321 -0
  34. package/assets/dagre-GXQ25YYZ-BdAnQUCV.js +4 -0
  35. package/assets/dashboard-BFVFBIio.js +3 -0
  36. package/assets/debounce-B3mXI_sx.js +1 -0
  37. package/assets/defaultLocale-DX6XiGOO.js +1 -0
  38. package/assets/diagram-S7CK7UJ4-BDYUGbNl.js +30 -0
  39. package/assets/diagram-UQ7AKVKN-PURK0-X-.js +41 -0
  40. package/assets/diagram-VSXAHHWV-DxHdFxl9.js +3 -0
  41. package/assets/diagram-VX7I27RA-Vz8xmeYc.js +24 -0
  42. package/assets/diagram-Z3DM3KII-CFXUpBMy.js +24 -0
  43. package/assets/docs-DU3h-HtS.js +1 -0
  44. package/assets/ebnfDiagram-PWID7BFC-BXNvxMGP.js +1 -0
  45. package/assets/erDiagram-RLTQ6QDP-DN0GgZEI.js +99 -0
  46. package/assets/flowDiagram-HODETNUW-DXdnwgBv.js +168 -0
  47. package/assets/ganttDiagram-EL5Y4UJY-Cv8_dxX3.js +292 -0
  48. package/assets/gitGraphDiagram-WWUBYQGX-BO8T858E.js +106 -0
  49. package/assets/history-BmwWpckq.js +1 -0
  50. package/assets/infoDiagram-27XIBGKW-DMSJ1RNc.js +2 -0
  51. package/assets/init-Gi6I4Gst.js +1 -0
  52. package/assets/init-user-BIdB8U_4.js +1 -0
  53. package/assets/isObject-DL-BuaBl.js +1 -0
  54. package/assets/ishikawaDiagram-5VMMS53U-_tLJESbl.js +70 -0
  55. package/assets/journeyDiagram-3NMN7TZE-DMMhdo-_.js +139 -0
  56. package/assets/kanban-definition-UXKFOSKX-CxQrB4t6.js +89 -0
  57. package/assets/katex-HP8lGamR.js +257 -0
  58. package/assets/layout-_oS_xxCJ.js +1 -0
  59. package/assets/linear-VLYuNK3x.js +1 -0
  60. package/assets/{list-CcMtS4hD.js → list-YTyJQtiR.js} +2 -2
  61. package/assets/login-B9GlhjZh.js +1 -0
  62. package/assets/manifest.json +834 -58
  63. package/assets/media-manager-Bb1VWlzd.js +6 -0
  64. package/assets/mermaid.core-BknV2vft.js +310 -0
  65. package/assets/mindmap-definition-YA3MSWOX-DBjjjDq4.js +96 -0
  66. package/assets/module-DQ1NK8Xg.js +1 -0
  67. package/assets/notification-CgNOsXvn.js +20 -0
  68. package/assets/ordinal-Cboi1Yqb.js +1 -0
  69. package/assets/pegDiagram-XKGWAZYB-uRznTaO4.js +1 -0
  70. package/assets/pieDiagram-E7YTZNPT-D1TkCH8I.js +39 -0
  71. package/assets/quadrantDiagram-AXDQQJYC-BofJ36Jz.js +7 -0
  72. package/assets/railroadDiagram-O6MQD6OU-e1ZM2IT1.js +1 -0
  73. package/assets/register-DjTtMayF.js +1 -0
  74. package/assets/requirementDiagram-BXWQKSXE-DQWArRbb.js +84 -0
  75. package/assets/ru-CqAtka69.js +1 -0
  76. package/assets/sankeyDiagram-P5KCCOFB-fVOu2jzi.js +40 -0
  77. package/assets/sequenceDiagram-WJ2MYXX4-Dj24cqpu.js +162 -0
  78. package/assets/sizeCapture-INFHLROL-CsApVrVi.js +1 -0
  79. package/assets/stateDiagram-D77RDMKH-IU2Szlid.js +1 -0
  80. package/assets/stateDiagram-v2-MP3YSRHH-B-EW3cIC.js +1 -0
  81. package/assets/swimlanes-42K2YHIH-97rBqTR-.js +1 -0
  82. package/assets/swimlanesDiagram-VR7AAH4N-CqG2iydK.js +8 -0
  83. package/assets/timeline-definition-24CTP7MA-DSgRzx1V.js +120 -0
  84. package/assets/toNumber-DZb-r9sy.js +1 -0
  85. package/assets/use-appearance-CXz2xWgT.js +1 -0
  86. package/assets/user-filters-list-BAR4CBN_.js +1 -0
  87. package/assets/vennDiagram-4TSXK5OY-BqMksFxE.js +34 -0
  88. package/assets/wardleyDiagram-VM6X3IG4-BMmxRODC.js +78 -0
  89. package/assets/welcome-CPXT7_4b.js +1 -0
  90. package/assets/with-app-layout-C6dqetfx.js +31 -0
  91. package/assets/xychartDiagram-S5SC5T6Z-3xRuF0l4.js +7 -0
  92. package/controllers/access-rights/permissionOptions.d.ts +2 -0
  93. package/controllers/access-rights/permissionOptions.js +23 -0
  94. package/controllers/add.js +2 -2
  95. package/controllers/addGroup.js +15 -6
  96. package/controllers/ai/AiAgentController.d.ts +66 -0
  97. package/controllers/ai/AiAgentController.js +435 -0
  98. package/controllers/ai/AiAssistantController.d.ts +0 -4
  99. package/controllers/ai/AiAssistantController.js +4 -78
  100. package/controllers/catalog/FrontendCatalogAdapter.js +7 -8
  101. package/controllers/docs/index.d.ts +22 -0
  102. package/controllers/docs/index.js +126 -0
  103. package/controllers/edit.js +2 -2
  104. package/controllers/editGroup.js +15 -6
  105. package/controllers/filter-fields/savedFilters.js +1 -1
  106. package/controllers/history-actions/HistoryController.js +7 -7
  107. package/controllers/inlineUpdate.js +10 -3
  108. package/controllers/list.js +1 -1
  109. package/controllers/login.js +1 -1
  110. package/controllers/notifications/NotificationController.js +14 -8
  111. package/controllers/view.js +3 -3
  112. package/controllers/widgets/filterInfo.js +1 -1
  113. package/helpers/accessRightsHelper.d.ts +63 -2
  114. package/helpers/accessRightsHelper.js +202 -13
  115. package/helpers/assetVersionHelper.d.ts +14 -0
  116. package/helpers/assetVersionHelper.js +42 -0
  117. package/helpers/configHelper.js +19 -18
  118. package/helpers/controllerHelper.d.ts +1 -0
  119. package/helpers/controllerHelper.js +25 -15
  120. package/helpers/fieldsHelper.d.ts +2 -1
  121. package/helpers/fieldsHelper.js +11 -5
  122. package/helpers/inertiaActionsHelper.d.ts +1 -1
  123. package/helpers/inertiaActionsHelper.js +4 -5
  124. package/helpers/inertiaAddHelper.d.ts +1 -1
  125. package/helpers/inertiaAddHelper.js +7 -6
  126. package/helpers/inertiaGroupHelper.js +10 -2
  127. package/helpers/inertiaListHelper.d.ts +1 -1
  128. package/helpers/inertiaListHelper.js +7 -8
  129. package/helpers/inertiaMenuHelper.d.ts +7 -2
  130. package/helpers/inertiaMenuHelper.js +12 -27
  131. package/helpers/menuHelper.d.ts +11 -1
  132. package/helpers/menuHelper.js +12 -0
  133. package/helpers/modelResourceHelper.d.ts +9 -0
  134. package/helpers/modelResourceHelper.js +43 -0
  135. package/helpers/navigationAccessHelper.d.ts +13 -0
  136. package/helpers/navigationAccessHelper.js +45 -0
  137. package/index.d.ts +11 -0
  138. package/index.js +11 -0
  139. package/interfaces/adminpanelConfig.d.ts +160 -2
  140. package/interfaces/queryCriteria.d.ts +13 -0
  141. package/interfaces/types.d.ts +169 -0
  142. package/lib/Adminizer.d.ts +8 -0
  143. package/lib/Adminizer.js +30 -2
  144. package/lib/DataAccessor.d.ts +100 -3
  145. package/lib/DataAccessor.js +454 -245
  146. package/lib/access-graph/AccessGraphResolver.d.ts +136 -0
  147. package/lib/access-graph/AccessGraphResolver.js +605 -0
  148. package/lib/access-graph/RecordAccessCache.d.ts +23 -0
  149. package/lib/access-graph/RecordAccessCache.js +30 -0
  150. package/lib/access-graph/RecordAccessResolver.d.ts +83 -0
  151. package/lib/access-graph/RecordAccessResolver.js +295 -0
  152. package/lib/access-graph/shared.d.ts +60 -0
  153. package/lib/access-graph/shared.js +121 -0
  154. package/lib/admin-links/AdminLinkHandler.d.ts +72 -0
  155. package/lib/admin-links/AdminLinkHandler.js +183 -0
  156. package/lib/ai-assistant/AbstractAiConversationHistoryService.d.ts +39 -0
  157. package/lib/ai-assistant/AbstractAiConversationHistoryService.js +98 -0
  158. package/lib/ai-assistant/AbstractAiModelService.d.ts +139 -2
  159. package/lib/ai-assistant/AbstractAiModelService.js +154 -1
  160. package/lib/ai-assistant/AiAgentRunManager.d.ts +35 -0
  161. package/lib/ai-assistant/AiAgentRunManager.js +109 -0
  162. package/lib/ai-assistant/AiAssistantAgentSkillHandler.d.ts +61 -0
  163. package/lib/ai-assistant/AiAssistantAgentSkillHandler.js +90 -0
  164. package/lib/ai-assistant/AiAssistantHandler.d.ts +1 -0
  165. package/lib/ai-assistant/AiAssistantHandler.js +4 -0
  166. package/lib/ai-assistant/AiAssistantUiMethodHandler.d.ts +60 -0
  167. package/lib/ai-assistant/AiAssistantUiMethodHandler.js +144 -0
  168. package/lib/ai-assistant/builtinAgentSkills.d.ts +9 -0
  169. package/lib/ai-assistant/builtinAgentSkills.js +259 -0
  170. package/lib/ai-assistant/jsonSafe.d.ts +13 -0
  171. package/lib/ai-assistant/jsonSafe.js +81 -0
  172. package/lib/app-manager/AdminizerApp.d.ts +40 -1
  173. package/lib/app-manager/AppManager.js +48 -42
  174. package/lib/app-manager/ConfigLayerHandler.js +4 -0
  175. package/lib/docs/AbstractDocumentation.d.ts +100 -0
  176. package/lib/docs/AbstractDocumentation.js +108 -0
  177. package/lib/docs/DocumentationHandler.d.ts +100 -0
  178. package/lib/docs/DocumentationHandler.js +198 -0
  179. package/lib/docs/FileDocumentation.d.ts +47 -0
  180. package/lib/docs/FileDocumentation.js +189 -0
  181. package/lib/docs/docsAgentSkills.d.ts +10 -0
  182. package/lib/docs/docsAgentSkills.js +93 -0
  183. package/lib/filters/FilterService.js +4 -3
  184. package/lib/history-actions/AbstractHistoryAdapter.d.ts +26 -1
  185. package/lib/history-actions/AbstractHistoryAdapter.js +155 -25
  186. package/lib/media-manager/helpers/MediaManagerHelper.js +9 -7
  187. package/lib/model/AbstractModel.d.ts +48 -0
  188. package/lib/model/AbstractModel.js +14 -4
  189. package/lib/model/ModelHandler.d.ts +61 -2
  190. package/lib/model/ModelHandler.js +136 -8
  191. package/lib/model/adapter/sequelize.d.ts +40 -1
  192. package/lib/model/adapter/sequelize.js +243 -77
  193. package/lib/model/adapter/typeorm.d.ts +16 -0
  194. package/lib/model/adapter/typeorm.js +136 -9
  195. package/lib/notifications/GeneralNotificationService.js +6 -5
  196. package/lib/notifications/SystemNotificationService.js +1 -1
  197. package/lib/ui-i18n/uiTranslationKeys.d.ts +2 -1
  198. package/lib/ui-i18n/uiTranslationKeys.js +42 -1
  199. package/lib/widgets/widgetHandler.js +10 -6
  200. package/models/Group.d.ts +2 -1
  201. package/package.json +35 -23
  202. package/policies/authPolicies.js +6 -6
  203. package/policies/permissionResolvers.d.ts +1 -0
  204. package/policies/permissionResolvers.js +3 -0
  205. package/system/Router.js +21 -3
  206. package/system/bindAccessRights.js +13 -3
  207. package/system/bindDocs.d.ts +13 -0
  208. package/system/bindDocs.js +77 -0
  209. package/system/bindInertia.js +30 -7
  210. package/system/buildInternalModelAccess.d.ts +9 -0
  211. package/system/buildInternalModelAccess.js +37 -4
  212. package/system/defaults.js +7 -1
  213. package/system/validateSystemModels.js +29 -9
  214. package/assets/add-CBlLpLr0.js +0 -1
  215. package/assets/add-group-H0IKIsNm.js +0 -1
  216. package/assets/add-user-CoTLmxtF.js +0 -1
  217. package/assets/app-DNXiApCk.css +0 -1
  218. package/assets/app-layout-xxJpLAvb.js +0 -1
  219. package/assets/app.js +0 -138
  220. package/assets/dashboard-DVJS5EPP.js +0 -3
  221. package/assets/debounce-BMmXVQ06.js +0 -1
  222. package/assets/history-bWRuzbye.js +0 -1
  223. package/assets/init-user-8aUohVDq.js +0 -1
  224. package/assets/login-CO4laf6m.js +0 -1
  225. package/assets/media-manager-BDbpSSjH.js +0 -6
  226. package/assets/module-CqO_BKsH.js +0 -1
  227. package/assets/notification-B5l2wwmi.js +0 -20
  228. package/assets/register-DBYIMXGQ.js +0 -1
  229. package/assets/ru-rYKdQbWF.js +0 -1
  230. package/assets/use-appearance-_EbbjHTh.js +0 -1
  231. package/assets/user-filters-list-C7kMKN5f.js +0 -1
  232. package/assets/welcome-6z-7sXnQ.js +0 -1
@@ -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
- const MODEL_TOKEN_SUFFIX = "model";
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 || Object.getOwnPropertySymbols(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
- ...criteriaWhere,
25
- ...sanitizedCriteria
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
- constructor(adminizer, user, modelResource, action) {
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(modelName) {
50
- const resolvedModelName = this.adminizer.modelHandler.resolveModelName(modelName);
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
- accessRelationModel(modelName) {
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.resolveModelName(modelName).toLowerCase();
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 tokenId = `${this.actionVerb}-${this.modelResource.model.modelname}-${MODEL_TOKEN_SUFFIX}`;
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 undefined;
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.type = "association";
176
+ modelField = { ...modelField, type: "association" };
108
177
  }
109
178
  if (modelField.collection) {
110
- modelField.type = "association-many";
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
- if (globalFieldConfig || actionFieldConfig) {
120
- const rawFieldConfig = { ...globalFieldConfig, ...actionFieldConfig };
121
- const normalizedFieldConfig = this.adminizer.configHelper.normalizeFieldConfig(this.adminizer, rawFieldConfig, key, modelField);
122
- if (!normalizedFieldConfig) {
123
- return;
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 = modelName ? this.adminizer.modelHandler.resolveModelName(modelName) : undefined;
197
+ const resolvedModelName = this.resolveRelationTarget(modelName, modelField.resourceName);
135
198
  if (modelName && resolvedModelName) {
136
- const tokenId = `read-${resolvedModelName}-${MODEL_TOKEN_SUFFIX}`;
137
- if (!this.adminizer.accessRightsHelper.hasPermission(tokenId, this.user)) {
138
- Adminizer.log.silly(`No access rights to model: ${this.modelResource.model.modelname}`);
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.model.get(modelName);
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(modelName) {
190
- const resolvedModelName = this.adminizer.modelHandler.resolveModelName(modelName);
191
- const model = this.adminizer.modelHandler.model.get(resolvedModelName);
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
- // Check if user has access to the associated model
197
- const tokenId = `read-${resolvedModelName}-${MODEL_TOKEN_SUFFIX}`;
198
- if (!this.adminizer.accessRightsHelper.hasPermission(tokenId, this.user)) {
199
- Adminizer.log.debug(`getAssociatedFieldsConfig > No access rights to ${this.actionVerb} model: ${resolvedModelName}`);
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 fieldConfig = mergedFieldsConfig[key];
233
- // Creating a basic config
234
- let fldConfig = { key: key, title: key };
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
- checkFieldAccess(key, fieldConfig) {
255
- if (this.modelResource.model.primaryKey === key) {
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 && userGroups.some(group => allowedGroups.includes(group));
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
- canUseSelectedFieldFallback() {
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 if (fieldType === 'association') {
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
- async sanitizeUserRelationAccess(criteria = {}) {
380
- let sanitizedCriteria = {};
381
- // Retrieve model configuration from adminpanel config
382
- const modelName = this.modelResource.model.modelname;
383
- const modelConfig = this.modelResource.config;
384
- // Check if the model has `userAccessRelation` configured
385
- if (!this.user.isAdministrator && modelConfig && modelConfig.userAccessRelation) {
386
- Adminizer.log.warn(`[userAccessRelation] Model "${modelName}" has userAccessRelation="${modelConfig.userAccessRelation}". ` +
387
- `User "${this.user.login}" (id: ${this.user.id}) will only see records they own.`);
388
- // Get access field from userAccessRelation
389
- const userAccessRelation = modelConfig.userAccessRelation;
390
- if (typeof userAccessRelation === 'string') {
391
- let accessField = userAccessRelation;
392
- // Check if the relation points to `User` or `Group` in the model's attributes
393
- const modelAttributes = this.modelResource.model.attributes;
394
- const relation = modelAttributes[accessField];
395
- const relationModel = this.resolveRelationTarget(relation?.model);
396
- const relationCollection = this.resolveRelationTarget(relation?.collection);
397
- if (!relation || !['user', 'group'].includes(relationModel ?? relationCollection ?? "")) {
398
- throw new Error(`Invalid userAccessRelation configuration for model ${modelName}`);
399
- }
400
- // Determine if the current user matches the access criteria
401
- if (relationModel) {
402
- if (relationModel === 'user') {
403
- // Filter by the user's ID if related to User as a model
404
- sanitizedCriteria = { ...sanitizedCriteria, [accessField]: this.user.id };
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
- else if (typeof userAccessRelation === 'object' && userAccessRelation !== null) {
427
- // If userAccessRelation is an object
428
- const { field, via } = userAccessRelation;
429
- // Get attributes of the current model and validate the intermediate relation
430
- const modelAttributes = this.modelResource.model.attributes;
431
- const intermediateRelation = modelAttributes[field];
432
- if (!intermediateRelation || !intermediateRelation.model) {
433
- throw new Error(`Invalid intermediate relation configuration for field "${field}" in model ${modelName}`);
434
- }
435
- // Retrieve the intermediate model
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
- ...criteria,
456
- where: mergeCriteriaWhere(criteria.where, sanitizedCriteria)
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
- async setUserRelationAccess(record) {
460
- let updatedRecord = { ...record };
461
- // Check if model has `userAccessRelation` configured
462
- if (this.modelResource.config && this.modelResource.config.userAccessRelation) {
463
- // Get access field from userAccessRelation
464
- const userAccessRelation = this.modelResource.config.userAccessRelation;
465
- if (typeof userAccessRelation === 'string') {
466
- let accessField = userAccessRelation;
467
- // only admin can set user access relation manually
468
- if (updatedRecord[accessField] && !this.user.isAdministrator) {
469
- delete updatedRecord[accessField];
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
- // Check if the relation points to `User` or `Group` in the model's attributes
472
- const modelAttributes = this.modelResource.model.attributes;
473
- const relation = modelAttributes[accessField];
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
- else if (typeof userAccessRelation === 'object' && userAccessRelation !== null) {
493
- // If userAccessRelation is an object
494
- const { field, via } = userAccessRelation;
495
- // For non-admins: validate that the chosen intermediate record belongs to the user
496
- if (updatedRecord[field] && !this.user.isAdministrator) {
497
- if (!via) {
498
- throw new Error(`Invalid userAccessRelation configuration: "via" is required`);
499
- }
500
- const modelAttributes = this.modelResource.model.attributes;
501
- const intermediateRelation = modelAttributes[field];
502
- if (!intermediateRelation || !intermediateRelation.model) {
503
- throw new Error(`Invalid intermediate relation configuration for field "${field}" in model ${this.modelResource.model.modelname}`);
504
- }
505
- const intermediateModel = this.adminizer.modelHandler.model.get(intermediateRelation.model.toLowerCase());
506
- if (!intermediateModel) {
507
- throw new Error(`Intermediate model "${intermediateRelation.model}" not found`);
508
- }
509
- // Validate the `via` field in the intermediate model
510
- const intermediateAttributes = intermediateModel.attributes;
511
- const viaRelation = intermediateAttributes[via];
512
- if (!viaRelation || this.resolveRelationTarget(viaRelation.model) !== 'user') {
513
- throw new Error(`Unsupported or invalid via field "${via}" in intermediate model "${intermediateRelation.model}". ` +
514
- `Currently, only relations to "User" are supported`);
515
- }
516
- const intermediatePk = (intermediateModel.primaryKey ?? 'id');
517
- const chosenId = typeof updatedRecord[field] === 'object'
518
- ? updatedRecord[field][intermediatePk]
519
- : updatedRecord[field];
520
- const record = await this.accessRelationModel(intermediateRelation.model).findOne({ where: { [intermediatePk]: chosenId, [via]: this.user.id } });
521
- if (!record) {
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 updatedRecord;
673
+ return accessor;
528
674
  }
529
- }
530
- function getTokenAction(apAction) {
531
- switch (apAction) {
532
- case "add":
533
- return "create";
534
- case "list":
535
- case "view":
536
- return "read";
537
- case "edit":
538
- return "update";
539
- case "remove":
540
- return "delete";
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
  }