adminizer 5.0.0-build.8 → 5.1.0-build.24

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-DFY3Pztx.js +1 -0
  2. package/assets/add-31tS-g4V.js +1 -0
  3. package/assets/add-group-Cf3zT7WE.js +1 -0
  4. package/assets/add-user-C62wOna-.js +1 -0
  5. package/assets/ai-assistant/agent.es.js +28464 -0
  6. package/assets/app-DPmI-MvN.css +1 -0
  7. package/assets/app-vdHbjOQX.js +138 -0
  8. package/assets/arc-DP4o1zz3.js +1 -0
  9. package/assets/architectureDiagram-5GKGNRK7-BjXW5Zu0.js +36 -0
  10. package/assets/asmcrypto-DZeQH0LC.js +1 -0
  11. package/assets/blockDiagram-I7D4REHJ-BUPnDwN0.js +129 -0
  12. package/assets/c4Diagram-7LVT6UL2-Cmb_dAhd.js +38 -0
  13. package/assets/{catalog-BEvJZYJP.js → catalog-C-y5rMaG.js} +3 -3
  14. package/assets/channel-CkJRUsq_.js +1 -0
  15. package/assets/chunk-2Q5K7J3B-CmS-dWwQ.js +1 -0
  16. package/assets/chunk-5VM5RSS4-Cqs2Ow64.js +15 -0
  17. package/assets/chunk-F27PBJKO-BoMCpJA1.js +1 -0
  18. package/assets/chunk-IMKFNOWR-DlDcPk1x.js +231 -0
  19. package/assets/chunk-JWPE2WC7-DzL_SU8s.js +1 -0
  20. package/assets/chunk-POPQ4Y6H-Cwq2-Vb8.js +1 -0
  21. package/assets/chunk-SVP7TREG-BfRHY5FU.js +88 -0
  22. package/assets/chunk-TICWLB2K-CRP7rHkW.js +206 -0
  23. package/assets/chunk-XXDRQBXY-Bg2jg-98.js +1 -0
  24. package/assets/classDiagram-ZZMXUADV-D4EF3HGj.js +1 -0
  25. package/assets/classDiagram-v2-VYDZK3BY-D4EF3HGj.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-CCJ0SGyO.js +1 -0
  31. package/assets/cynefin-OW5HDTMX-B2Jx5fw3.js +166 -0
  32. package/assets/cynefinDiagram-5FMLGOSQ-DuK0Lb62.js +62 -0
  33. package/assets/cytoscape.esm-DzSz-X2X.js +321 -0
  34. package/assets/dagre-GXQ25YYZ-DnOYexET.js +4 -0
  35. package/assets/dashboard-X8k4X6Sw.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-DDgPBHsK.js +30 -0
  39. package/assets/diagram-UQ7AKVKN-qlL0GZs8.js +41 -0
  40. package/assets/diagram-VSXAHHWV-CQbiU1_c.js +3 -0
  41. package/assets/diagram-VX7I27RA-DFDI9_au.js +24 -0
  42. package/assets/diagram-Z3DM3KII-B5C9dtmJ.js +24 -0
  43. package/assets/docs-BiZOD7PP.js +1 -0
  44. package/assets/ebnfDiagram-PWID7BFC-CLMUsXEI.js +1 -0
  45. package/assets/erDiagram-RLTQ6QDP-ax9Xol5-.js +99 -0
  46. package/assets/flowDiagram-HODETNUW-CvaH7l3S.js +168 -0
  47. package/assets/ganttDiagram-EL5Y4UJY-Cga5U19j.js +292 -0
  48. package/assets/gitGraphDiagram-WWUBYQGX-B6o1T3-d.js +106 -0
  49. package/assets/history-CVBJUNSQ.js +1 -0
  50. package/assets/infoDiagram-27XIBGKW-BpWxgBCE.js +2 -0
  51. package/assets/init-Gi6I4Gst.js +1 -0
  52. package/assets/init-user-xSvnFTF0.js +1 -0
  53. package/assets/isObject-DL-BuaBl.js +1 -0
  54. package/assets/ishikawaDiagram-5VMMS53U-zE83db4H.js +70 -0
  55. package/assets/journeyDiagram-3NMN7TZE-DZNVFqrX.js +139 -0
  56. package/assets/kanban-definition-UXKFOSKX-CqxNgk27.js +89 -0
  57. package/assets/katex-HP8lGamR.js +257 -0
  58. package/assets/layout-CN3kt_jt.js +1 -0
  59. package/assets/linear-nvkaQ6BA.js +1 -0
  60. package/assets/{list-DhK0McVn.js → list-CrIU2eZz.js} +2 -2
  61. package/assets/login-CX9xVjCn.js +1 -0
  62. package/assets/manifest.json +834 -58
  63. package/assets/media-manager-BfYftLIH.js +6 -0
  64. package/assets/mermaid.core-C0V1NaVJ.js +310 -0
  65. package/assets/mindmap-definition-YA3MSWOX-Bh7_d9f1.js +96 -0
  66. package/assets/module-DPEvnad3.js +1 -0
  67. package/assets/notification-Dqpt5AIZ.js +20 -0
  68. package/assets/ordinal-Cboi1Yqb.js +1 -0
  69. package/assets/pegDiagram-XKGWAZYB-DMnjm6Z1.js +1 -0
  70. package/assets/pieDiagram-E7YTZNPT-D-RWCgYb.js +39 -0
  71. package/assets/quadrantDiagram-AXDQQJYC-BNBGP7RM.js +7 -0
  72. package/assets/railroadDiagram-O6MQD6OU-BIjVPT01.js +1 -0
  73. package/assets/register-CF4KQ97e.js +1 -0
  74. package/assets/requirementDiagram-BXWQKSXE-VPrtGdjM.js +84 -0
  75. package/assets/ru-CFJ4fysR.js +1 -0
  76. package/assets/sankeyDiagram-P5KCCOFB-ct9mLF3U.js +40 -0
  77. package/assets/sequenceDiagram-WJ2MYXX4-TC06Mbmw.js +162 -0
  78. package/assets/sizeCapture-INFHLROL-BMMwriCf.js +1 -0
  79. package/assets/stateDiagram-D77RDMKH-DtwYRPCX.js +1 -0
  80. package/assets/stateDiagram-v2-MP3YSRHH-C1yB0O5U.js +1 -0
  81. package/assets/swimlanes-42K2YHIH-DE3hRbbr.js +1 -0
  82. package/assets/swimlanesDiagram-VR7AAH4N-Ce4l1sQD.js +8 -0
  83. package/assets/timeline-definition-24CTP7MA-N7s_hfjj.js +120 -0
  84. package/assets/toNumber-DZb-r9sy.js +1 -0
  85. package/assets/use-appearance-DYTIKpT2.js +1 -0
  86. package/assets/user-filters-list-fR0YNJ3l.js +1 -0
  87. package/assets/vennDiagram-4TSXK5OY-Dj5bFQkR.js +34 -0
  88. package/assets/wardleyDiagram-VM6X3IG4-83ax25Z6.js +78 -0
  89. package/assets/welcome-v11MTsRK.js +1 -0
  90. package/assets/with-app-layout-CH6RIib1.js +31 -0
  91. package/assets/xychartDiagram-S5SC5T6Z-CW9Br5Xp.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 +30 -3
  114. package/helpers/accessRightsHelper.js +140 -12
  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 +29 -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 +31 -2
  173. package/lib/app-manager/AppManager.js +43 -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 +50 -2
  190. package/lib/model/ModelHandler.js +117 -8
  191. package/lib/model/adapter/sequelize.d.ts +43 -3
  192. package/lib/model/adapter/sequelize.js +317 -138
  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 +8 -0
  211. package/system/buildInternalModelAccess.js +36 -4
  212. package/system/defaults.js +7 -1
  213. package/system/validateSystemModels.js +29 -9
  214. package/assets/add-DED_wpga.js +0 -1
  215. package/assets/add-group-CL80RdYb.js +0 -1
  216. package/assets/add-user-BRmwvQB8.js +0 -1
  217. package/assets/app-DNXiApCk.css +0 -1
  218. package/assets/app-layout-86yxiZOy.js +0 -1
  219. package/assets/app.js +0 -138
  220. package/assets/dashboard-ZP6K2s_8.js +0 -3
  221. package/assets/debounce-BMmXVQ06.js +0 -1
  222. package/assets/history-M61Bcv02.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-DfBneaUl.js +0 -1
  227. package/assets/notification-CVnQf3ef.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-DXhGLFTn.js +0 -1
  232. package/assets/welcome-CLxvXkcg.js +0 -1
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Record-level access for models declaring `userAccessRelation` — builds the read
3
+ * filter (`buildWhere`) and applies the write rules (`applyWriteRules`) for its three
4
+ * forms: string (own User/Group column), `{field, via}` (intermediate model) and
5
+ * `{field, through, via[, group]}` (membership). The accessGraph wins over all three:
6
+ * a model it covers is filtered by the graph and its own relation is ignored.
7
+ * One instance is held per `DataAccessor` (model + action), so the memoization here
8
+ * and in the embedded `AccessGraphResolver` lives as long as the owning accessor —
9
+ * never longer than the request, though a request may hold several accessors.
10
+ */
11
+ import { ModelResource } from "../../interfaces/types.js";
12
+ import { CriteriaWhere } from "../../interfaces/queryCriteria.js";
13
+ import { Adminizer } from "../Adminizer.js";
14
+ import { User } from "../../models/User.js";
15
+ import { RecordAccessCache } from "./RecordAccessCache.js";
16
+ export declare class RecordAccessResolver {
17
+ private readonly adminizer;
18
+ private readonly user;
19
+ private readonly modelResource;
20
+ private readonly actionVerb;
21
+ private readonly cache;
22
+ private accessGraphResolver?;
23
+ private resolvedMembershipMemo?;
24
+ private membershipTargetIdsMemo?;
25
+ constructor(adminizer: Adminizer, user: User, modelResource: ModelResource, actionVerb: string, cache?: RecordAccessCache);
26
+ /**
27
+ * Where-fragment confining the model to the current user's records;
28
+ * `{}` means no constraint (administrators, models without record access rules).
29
+ */
30
+ buildWhere(): Promise<CriteriaWhere>;
31
+ /**
32
+ * Write rules for the outgoing record: stamp the access column (string form,
33
+ * creation only) or validate that the chosen relation belongs to the user.
34
+ * Applied on create and update alike — an update may not move a record into
35
+ * a graph or membership target the user cannot reach.
36
+ */
37
+ applyWriteRules<T>(record: T): Promise<Partial<T>>;
38
+ private userAccessRelation;
39
+ private buildStringFormWhere;
40
+ private buildViaFormWhere;
41
+ /**
42
+ * String form: only an administrator — or a holder of the model's ownership-transfer
43
+ * token — may set the access column explicitly. For everyone else it is stamped from
44
+ * the current user at creation; an update never re-stamps, so a record cannot change
45
+ * owner through the panel.
46
+ */
47
+ private applyStringFormWrite;
48
+ /**
49
+ * Membership form: the chosen target must be one the user has a membership row
50
+ * for (with `group` declared, one whose group carries this action's CRUD token).
51
+ */
52
+ private validateMembershipWrite;
53
+ /** Via form: the chosen intermediate record must belong to the user. */
54
+ private validateViaWrite;
55
+ /**
56
+ * Structural resolution of the `{field, via}` form, shared by the read filter
57
+ * and the write validation so the two paths cannot diverge.
58
+ */
59
+ private resolveViaDeclaration;
60
+ /**
61
+ * Resolves the membership (`through`) form: `field` must point at the target
62
+ * model; the rest of the declaration is resolved by the shared routine.
63
+ */
64
+ private resolveMembershipDeclaration;
65
+ private computeMembershipDeclaration;
66
+ /**
67
+ * Target ids the current user reaches through membership rows; with a `group`
68
+ * declared, only memberships whose group carries this action's CRUD token count.
69
+ */
70
+ private findMembershipTargetIds;
71
+ /**
72
+ * Administrators, plus holders of the model's ownership-transfer token: they may point
73
+ * a record at a target of their choosing instead of the one they belong to. The token is
74
+ * registered only for models declaring `userAccessRelation` and granted to nobody by
75
+ * default, so out of the box this is administrators only.
76
+ */
77
+ private mayTransferOwnership;
78
+ private userGroupIds;
79
+ private resolveRelationTarget;
80
+ private getRelationModel;
81
+ private accessRelationModel;
82
+ private getAccessGraphResolver;
83
+ }
@@ -0,0 +1,295 @@
1
+ import { Adminizer } from "../Adminizer.js";
2
+ import { AccessGraphResolver } from "./AccessGraphResolver.js";
3
+ import { RecordAccessCache } from "./RecordAccessCache.js";
4
+ import { grantsToken, membershipTargetIds, modelCrudToken, ownershipTransferToken, relationValueId, resolveMembership, } from "./shared.js";
5
+ export class RecordAccessResolver {
6
+ adminizer;
7
+ user;
8
+ modelResource;
9
+ actionVerb;
10
+ cache;
11
+ accessGraphResolver;
12
+ // The resolver is fixed to one user/model/verb, so single keyless lazy caches
13
+ // suffice: a write both validates and re-filters, which would otherwise resolve
14
+ // the declaration and query the membership rows twice. A rejection stays cached
15
+ // for the resolver's lifetime — same as the AccessGraphResolver memo.
16
+ resolvedMembershipMemo;
17
+ membershipTargetIdsMemo;
18
+ constructor(adminizer, user, modelResource, actionVerb, cache = new RecordAccessCache()) {
19
+ this.adminizer = adminizer;
20
+ this.user = user;
21
+ this.modelResource = modelResource;
22
+ this.actionVerb = actionVerb;
23
+ this.cache = cache;
24
+ }
25
+ /**
26
+ * Where-fragment confining the model to the current user's records;
27
+ * `{}` means no constraint (administrators, models without record access rules).
28
+ */
29
+ async buildWhere() {
30
+ if (this.user.isAdministrator) {
31
+ return {};
32
+ }
33
+ if (AccessGraphResolver.covers(this.adminizer, this.modelResource.name)) {
34
+ // The graph wins over the model's own `userAccessRelation`, which is ignored
35
+ // for as long as a graph covers the model (a warning is logged when the graph
36
+ // compiles). Letting a node opt out would break the premise `isGraphParentEdge`
37
+ // rests on — a node is visible only because its graph parent is, which is why
38
+ // the populate verification of that edge may be skipped. An opted-out node
39
+ // stays reachable by its own rule while the premise no longer holds, and the
40
+ // parent record leaks through the association.
41
+ return this.getAccessGraphResolver().buildWhere(this.modelResource.name);
42
+ }
43
+ const userAccessRelation = this.userAccessRelation();
44
+ if (userAccessRelation) {
45
+ Adminizer.log.debug(`[userAccessRelation] Model "${this.modelResource.name}" is record-restricted: ` +
46
+ `user "${this.user.login}" (id: ${this.user.id}) only sees records they own.`);
47
+ if (typeof userAccessRelation === "string") {
48
+ return this.buildStringFormWhere(userAccessRelation);
49
+ }
50
+ if (userAccessRelation.through) {
51
+ // Membership form: filter by the targets the user has a membership row for
52
+ const membership = this.resolveMembershipDeclaration(userAccessRelation);
53
+ const targetIds = await this.findMembershipTargetIds(membership);
54
+ return { [userAccessRelation.field]: { in: targetIds } };
55
+ }
56
+ return this.buildViaFormWhere(userAccessRelation);
57
+ }
58
+ return {};
59
+ }
60
+ /**
61
+ * Write rules for the outgoing record: stamp the access column (string form,
62
+ * creation only) or validate that the chosen relation belongs to the user.
63
+ * Applied on create and update alike — an update may not move a record into
64
+ * a graph or membership target the user cannot reach.
65
+ */
66
+ async applyWriteRules(record) {
67
+ const updatedRecord = { ...record };
68
+ if (!this.user.isAdministrator && AccessGraphResolver.covers(this.adminizer, this.modelResource.name)) {
69
+ // Same precedence as the read filter: the graph wins, so the model's own write
70
+ // rules (including the string form's owner stamping) do not run for a graph node.
71
+ await this.getAccessGraphResolver().validateWriteRelation(this.modelResource.name, updatedRecord);
72
+ return updatedRecord;
73
+ }
74
+ const userAccessRelation = this.userAccessRelation();
75
+ if (userAccessRelation) {
76
+ if (typeof userAccessRelation === "string") {
77
+ this.applyStringFormWrite(updatedRecord, userAccessRelation);
78
+ }
79
+ else if (userAccessRelation.through) {
80
+ await this.validateMembershipWrite(updatedRecord, userAccessRelation);
81
+ }
82
+ else {
83
+ await this.validateViaWrite(updatedRecord, userAccessRelation);
84
+ }
85
+ }
86
+ return updatedRecord;
87
+ }
88
+ userAccessRelation() {
89
+ const value = this.modelResource.config?.userAccessRelation;
90
+ if (typeof value === "string" || (typeof value === "object" && value !== null)) {
91
+ return value;
92
+ }
93
+ return undefined;
94
+ }
95
+ buildStringFormWhere(accessField) {
96
+ const modelName = this.modelResource.name;
97
+ const relation = this.modelResource.model.attributes[accessField];
98
+ const relationModel = this.resolveRelationTarget(relation?.model, relation?.resourceName);
99
+ const relationCollection = this.resolveRelationTarget(relation?.collection, relation?.resourceName);
100
+ if (!relation || !["User", "Group"].includes(relationModel ?? relationCollection ?? "")) {
101
+ throw new Error(`Invalid userAccessRelation configuration for model ${modelName}`);
102
+ }
103
+ if (relationModel === "User") {
104
+ return { [accessField]: this.user.id };
105
+ }
106
+ if (relationModel === "Group") {
107
+ // Absent groups must fail closed: an empty IN list matches nothing, while
108
+ // `in: undefined` would be dropped by the adapter and widen the query.
109
+ return { [accessField]: { in: this.userGroupIds() } };
110
+ }
111
+ /** Warning: collection relation access is not supported and needs adapter-level processing */
112
+ Adminizer.log.warn(`Collection relation is not supported and was not tested. You may have an error here: ${JSON.stringify(relation, null, 2)}`);
113
+ if (relationCollection === "User") {
114
+ // Ensure user's ID is part of the associated collection to User
115
+ return { [accessField]: { contains: this.user.id } };
116
+ }
117
+ // Ensure user's groups intersect with the collection to Group
118
+ return { [accessField]: { intersects: this.userGroupIds() } };
119
+ }
120
+ async buildViaFormWhere({ field, via }) {
121
+ const { intermediateRelation, intermediatePk } = this.resolveViaDeclaration(field, via);
122
+ // Fetch all intermediate records associated with the user
123
+ const intermediateRecords = await this.accessRelationModel(intermediateRelation.model, intermediateRelation.resourceName).find({ where: { [via]: this.user.id } });
124
+ const intermediateIds = (intermediateRecords || []).map((r) => r[intermediatePk]);
125
+ return { [field]: { in: intermediateIds } };
126
+ }
127
+ /**
128
+ * String form: only an administrator — or a holder of the model's ownership-transfer
129
+ * token — may set the access column explicitly. For everyone else it is stamped from
130
+ * the current user at creation; an update never re-stamps, so a record cannot change
131
+ * owner through the panel.
132
+ */
133
+ applyStringFormWrite(updatedRecord, accessField) {
134
+ const mayTransfer = this.mayTransferOwnership();
135
+ if (updatedRecord[accessField] !== undefined && !mayTransfer) {
136
+ delete updatedRecord[accessField];
137
+ }
138
+ const relation = this.modelResource.model.attributes[accessField];
139
+ const relationModel = this.resolveRelationTarget(relation?.model, relation?.resourceName);
140
+ if (!relation || !["User", "Group"].includes(relationModel ?? "")) {
141
+ return;
142
+ }
143
+ if (mayTransfer || this.actionVerb !== "create") {
144
+ // The access column is never stamped for these callers. Leaving it empty is
145
+ // legal but produces a record no restricted user will ever see again.
146
+ if (this.actionVerb === "create" && updatedRecord[accessField] === undefined) {
147
+ Adminizer.log.warn(`[userAccessRelation] Model "${this.modelResource.name}" is created without "${accessField}": ` +
148
+ `the record stays out of reach and will be visible to administrators only`);
149
+ }
150
+ return;
151
+ }
152
+ if (relationModel === "User") {
153
+ updatedRecord[accessField] = this.user.id;
154
+ return;
155
+ }
156
+ const userGroups = this.user.groups || [];
157
+ if (userGroups.length === 1) {
158
+ updatedRecord[accessField] = userGroups[0].id;
159
+ }
160
+ else {
161
+ throw new Error("Record cannot be saved because the user is associated with none or multiple groups.");
162
+ }
163
+ }
164
+ /**
165
+ * Membership form: the chosen target must be one the user has a membership row
166
+ * for (with `group` declared, one whose group carries this action's CRUD token).
167
+ */
168
+ async validateMembershipWrite(updatedRecord, userAccessRelation) {
169
+ const { field } = userAccessRelation;
170
+ const value = updatedRecord[field];
171
+ if (value === undefined || value === null || this.mayTransferOwnership()) {
172
+ return;
173
+ }
174
+ const membership = this.resolveMembershipDeclaration(userAccessRelation);
175
+ const targetIds = await this.findMembershipTargetIds(membership);
176
+ const chosenId = relationValueId(value, membership.targetPrimaryKey);
177
+ if (!targetIds.some((id) => String(id) === String(chosenId))) {
178
+ throw new Error(`Access denied: "${field}" does not belong to the current user`);
179
+ }
180
+ }
181
+ /** Via form: the chosen intermediate record must belong to the user. */
182
+ async validateViaWrite(updatedRecord, { field, via }) {
183
+ const value = updatedRecord[field];
184
+ if (value === undefined || value === null || this.mayTransferOwnership()) {
185
+ return;
186
+ }
187
+ const { intermediateRelation, intermediatePk } = this.resolveViaDeclaration(field, via);
188
+ const chosenId = relationValueId(value, intermediatePk);
189
+ const record = await this.accessRelationModel(intermediateRelation.model, intermediateRelation.resourceName).findOne({ where: { [intermediatePk]: chosenId, [via]: this.user.id } });
190
+ if (!record) {
191
+ throw new Error(`Access denied: "${field}" does not belong to the current user`);
192
+ }
193
+ }
194
+ /**
195
+ * Structural resolution of the `{field, via}` form, shared by the read filter
196
+ * and the write validation so the two paths cannot diverge.
197
+ */
198
+ resolveViaDeclaration(field, via) {
199
+ if (!via) {
200
+ throw new Error(`Invalid userAccessRelation configuration: "via" is required`);
201
+ }
202
+ const intermediateRelation = this.modelResource.model.attributes[field];
203
+ if (!intermediateRelation || !intermediateRelation.model) {
204
+ throw new Error(`Invalid intermediate relation configuration for field "${field}" in model ${this.modelResource.name}`);
205
+ }
206
+ const intermediateModel = this.getRelationModel(intermediateRelation.model, intermediateRelation.resourceName);
207
+ if (!intermediateModel) {
208
+ throw new Error(`Intermediate model "${intermediateRelation.model}" not found`);
209
+ }
210
+ const viaRelation = intermediateModel.attributes[via];
211
+ if (!viaRelation || this.resolveRelationTarget(viaRelation.model, viaRelation.resourceName) !== "User") {
212
+ throw new Error(`Unsupported or invalid via field "${via}" in intermediate model "${intermediateRelation.model}". ` +
213
+ `Currently, only relations to "User" are supported`);
214
+ }
215
+ return {
216
+ intermediateRelation,
217
+ intermediateModel,
218
+ intermediatePk: (intermediateModel.primaryKey ?? "id"),
219
+ };
220
+ }
221
+ /**
222
+ * Resolves the membership (`through`) form: `field` must point at the target
223
+ * model; the rest of the declaration is resolved by the shared routine.
224
+ */
225
+ resolveMembershipDeclaration(userAccessRelation) {
226
+ return this.resolvedMembershipMemo ??= this.computeMembershipDeclaration(userAccessRelation);
227
+ }
228
+ computeMembershipDeclaration(userAccessRelation) {
229
+ const modelName = this.modelResource.name;
230
+ const { field } = userAccessRelation;
231
+ const targetRelation = this.modelResource.model.attributes[field];
232
+ if (!targetRelation || !targetRelation.model) {
233
+ throw new Error(`Invalid intermediate relation configuration for field "${field}" in model ${modelName}`);
234
+ }
235
+ const targetResource = this.resolveRelationTarget(targetRelation.model, targetRelation.resourceName);
236
+ if (!targetResource) {
237
+ throw new Error(`Relation model "${targetRelation.model}" was not found`);
238
+ }
239
+ const membership = resolveMembership(userAccessRelation, targetResource, this.adminizer.modelHandler, (message) => new Error(message.charAt(0).toUpperCase() + message.slice(1)));
240
+ return {
241
+ ...membership,
242
+ targetPrimaryKey: (this.getRelationModel(targetRelation.model, targetRelation.resourceName)?.primaryKey ?? "id"),
243
+ };
244
+ }
245
+ /**
246
+ * Target ids the current user reaches through membership rows; with a `group`
247
+ * declared, only memberships whose group carries this action's CRUD token count.
248
+ */
249
+ findMembershipTargetIds(membership) {
250
+ const key = `membership:${String(this.user.id)}:${this.modelResource.name}:${this.actionVerb}:${membership.through}`;
251
+ return this.membershipTargetIdsMemo ??= this.cache.resolve(key, () => membershipTargetIds((resourceName) => this.adminizer.modelHandler.internal("data-accessor").get(resourceName), membership, this.user.id, membership.targetPrimaryKey, modelCrudToken(this.actionVerb, this.modelResource.name)));
252
+ }
253
+ /**
254
+ * Administrators, plus holders of the model's ownership-transfer token: they may point
255
+ * a record at a target of their choosing instead of the one they belong to. The token is
256
+ * registered only for models declaring `userAccessRelation` and granted to nobody by
257
+ * default, so out of the box this is administrators only.
258
+ */
259
+ mayTransferOwnership() {
260
+ if (this.user.isAdministrator) {
261
+ return true;
262
+ }
263
+ // Matched against the group grants directly, like the graph's `bypassToken`:
264
+ // `hasStaticPermission` answers "yes" to everything once `auth.enable` is false,
265
+ // which would drop the write rules for every user of an unauthenticated panel.
266
+ const tokenId = ownershipTransferToken(this.modelResource.name);
267
+ return (this.user.groups ?? []).some((group) => grantsToken(group.tokens ?? [], tokenId));
268
+ }
269
+ userGroupIds() {
270
+ return this.user.groups?.map((group) => group.id) ?? [];
271
+ }
272
+ resolveRelationTarget(modelName, resourceName) {
273
+ if (!modelName) {
274
+ return undefined;
275
+ }
276
+ return this.adminizer.modelHandler.resolveAssociationResource(modelName, resourceName);
277
+ }
278
+ getRelationModel(modelName, resourceName) {
279
+ const resolvedResourceName = this.resolveRelationTarget(modelName, resourceName);
280
+ return resolvedResourceName
281
+ ? this.adminizer.modelHandler.getResource(resolvedResourceName)
282
+ : undefined;
283
+ }
284
+ accessRelationModel(modelName, resourceName) {
285
+ const resolvedResourceName = this.resolveRelationTarget(modelName, resourceName);
286
+ if (!resolvedResourceName) {
287
+ throw new Error(`Relation model "${modelName}" was not found`);
288
+ }
289
+ return this.adminizer.modelHandler.internal("data-accessor").get(resolvedResourceName);
290
+ }
291
+ getAccessGraphResolver() {
292
+ this.accessGraphResolver ??= new AccessGraphResolver(this.adminizer, this.user, this.actionVerb, this.cache);
293
+ return this.accessGraphResolver;
294
+ }
295
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared primitives of record-level access control, used by both
3
+ * `RecordAccessResolver` (the `userAccessRelation` forms) and `AccessGraphResolver`
4
+ * (access graphs): CRUD token naming, the `Group.tokens` grant comparison and
5
+ * membership (`through`/`via`[/`group`]) declaration resolution.
6
+ */
7
+ import { ActionType } from "../../interfaces/adminpanelConfig.js";
8
+ import type { ModelHandler } from "../model/ModelHandler.js";
9
+ export declare const MODEL_TOKEN_SUFFIX = "model";
10
+ /** CRUD token id of a verb on a model; registered token ids are lowercased. */
11
+ export declare function modelCrudToken(actionVerb: string, modelName: string): string;
12
+ /**
13
+ * Token letting its holder set the access relation of a record explicitly — the panel
14
+ * equivalent of transferring ownership. Registered only for models that declare
15
+ * `userAccessRelation`, and granted to nobody by default: without it a non-administrator
16
+ * can neither stamp a foreign owner on create nor move a record out of reach.
17
+ */
18
+ export declare function ownershipTransferToken(modelName: string): string;
19
+ /** Maps a panel action to its CRUD token verb. */
20
+ export declare function getTokenAction(apAction: ActionType): "create" | "delete" | "update" | "read";
21
+ /** A relation value may arrive populated (object) or as a bare id. */
22
+ export declare function relationValueId(value: unknown, primaryKey?: string): unknown;
23
+ /**
24
+ * Like `hasAssignedPermission`, but case-insensitive for plain string grants —
25
+ * registered token ids are lowercased.
26
+ */
27
+ export declare function grantsToken(grants: unknown[], tokenId: string): boolean;
28
+ /** Resolved membership declaration: the `through` model with its relation aliases and FK columns. */
29
+ export interface ResolvedMembership {
30
+ through: string;
31
+ /** Association alias in the membership model pointing at the membership target. */
32
+ targetAlias: string;
33
+ /** Its foreign-key column — plain (unpopulated) rows carry the value there. */
34
+ targetFk: string;
35
+ viaAlias: string;
36
+ groupAlias?: string;
37
+ groupFk?: string;
38
+ }
39
+ /** Minimal model access the shared membership queries need (an internal-scope accessor). */
40
+ export type MembershipModelSource = (resourceName: string) => {
41
+ find(criteria: unknown): Promise<Record<string, unknown>[] | null | undefined>;
42
+ };
43
+ /**
44
+ * Resolves a membership declaration against the registered models: `through` must
45
+ * carry exactly one relation to `targetResource`, a `via` relation to User and,
46
+ * optionally, a `group` relation to Group. `formatError` wraps the failure text into
47
+ * the caller's error shape (userAccessRelation vs accessGraph wording).
48
+ */
49
+ export declare function resolveMembership(declaration: {
50
+ through?: string;
51
+ via?: string;
52
+ group?: string;
53
+ }, targetResource: string, modelHandler: ModelHandler, formatError: (message: string) => Error): ResolvedMembership;
54
+ /**
55
+ * Target ids the user reaches through membership rows; with a `group` declared,
56
+ * only memberships whose group carries `grantedTokenId` count.
57
+ */
58
+ export declare function membershipTargetIds(getModel: MembershipModelSource, membership: ResolvedMembership, userId: unknown, targetPrimaryKey: string, grantedTokenId: string): Promise<unknown[]>;
59
+ /** Ids of the given groups whose token list carries `tokenId`. */
60
+ export declare function groupsGrantingToken(getModel: MembershipModelSource, groupIds: unknown[], tokenId: string): Promise<Set<string>>;
@@ -0,0 +1,121 @@
1
+ import { parseGroupPermissionGrant } from "../../helpers/accessRightsHelper.js";
2
+ export const MODEL_TOKEN_SUFFIX = "model";
3
+ /** CRUD token id of a verb on a model; registered token ids are lowercased. */
4
+ export function modelCrudToken(actionVerb, modelName) {
5
+ return `${actionVerb}-${modelName}-${MODEL_TOKEN_SUFFIX}`.toLowerCase();
6
+ }
7
+ /**
8
+ * Token letting its holder set the access relation of a record explicitly — the panel
9
+ * equivalent of transferring ownership. Registered only for models that declare
10
+ * `userAccessRelation`, and granted to nobody by default: without it a non-administrator
11
+ * can neither stamp a foreign owner on create nor move a record out of reach.
12
+ */
13
+ export function ownershipTransferToken(modelName) {
14
+ return `transfer-${modelName}-ownership`.toLowerCase();
15
+ }
16
+ /** Maps a panel action to its CRUD token verb. */
17
+ export function getTokenAction(apAction) {
18
+ switch (apAction) {
19
+ case "add":
20
+ return "create";
21
+ case "list":
22
+ case "view":
23
+ return "read";
24
+ case "edit":
25
+ return "update";
26
+ case "remove":
27
+ return "delete";
28
+ }
29
+ }
30
+ /** A relation value may arrive populated (object) or as a bare id. */
31
+ export function relationValueId(value, primaryKey = "id") {
32
+ if (value && typeof value === "object") {
33
+ return value[primaryKey];
34
+ }
35
+ return value;
36
+ }
37
+ /**
38
+ * Like `hasAssignedPermission`, but case-insensitive for plain string grants —
39
+ * registered token ids are lowercased.
40
+ */
41
+ export function grantsToken(grants, tokenId) {
42
+ return grants.some((grant) => (typeof grant === "string" && grant.toLowerCase() === tokenId) ||
43
+ parseGroupPermissionGrant(grant)?.tokenId === tokenId);
44
+ }
45
+ /**
46
+ * Resolves a membership declaration against the registered models: `through` must
47
+ * carry exactly one relation to `targetResource`, a `via` relation to User and,
48
+ * optionally, a `group` relation to Group. `formatError` wraps the failure text into
49
+ * the caller's error shape (userAccessRelation vs accessGraph wording).
50
+ */
51
+ export function resolveMembership(declaration, targetResource, modelHandler, formatError) {
52
+ const { through, via, group } = declaration;
53
+ if (!through) {
54
+ throw formatError(`membership "through" is required`);
55
+ }
56
+ if (!via) {
57
+ throw formatError(`membership "via" is required`);
58
+ }
59
+ const throughRecord = modelHandler.getResourceRecord(through);
60
+ if (!throughRecord) {
61
+ throw formatError(`membership model "${through}" not found`);
62
+ }
63
+ const resolveTarget = (attribute) => attribute?.model ? modelHandler.resolveAssociationResource(attribute.model, attribute.resourceName) : undefined;
64
+ const throughAttributes = throughRecord.model.attributes;
65
+ const viaRelation = throughAttributes[via];
66
+ if (resolveTarget(viaRelation) !== "User") {
67
+ throw formatError(`unsupported or invalid via field "${via}" in membership model "${through}". ` +
68
+ `Currently, only relations to "User" are supported`);
69
+ }
70
+ if (group) {
71
+ const groupRelation = throughAttributes[group];
72
+ if (resolveTarget(groupRelation) !== "Group") {
73
+ throw formatError(`invalid group field "${group}" in membership model "${through}": a relation to "Group" is required`);
74
+ }
75
+ }
76
+ const targetAliases = Object.entries(throughAttributes)
77
+ .filter(([key, attribute]) => key !== via && key !== group && resolveTarget(attribute) === targetResource)
78
+ .map(([key]) => key);
79
+ if (targetAliases.length !== 1) {
80
+ throw formatError(`cannot resolve the target relation in membership model "${through}": ` +
81
+ `expected exactly one relation to "${targetResource}", found ${targetAliases.length}`);
82
+ }
83
+ const targetAlias = targetAliases[0];
84
+ return {
85
+ through: throughRecord.name,
86
+ targetAlias,
87
+ targetFk: throughAttributes[targetAlias].via ?? targetAlias,
88
+ viaAlias: via,
89
+ groupAlias: group,
90
+ groupFk: group ? (throughAttributes[group].via ?? group) : undefined,
91
+ };
92
+ }
93
+ /**
94
+ * Target ids the user reaches through membership rows; with a `group` declared,
95
+ * only memberships whose group carries `grantedTokenId` count.
96
+ */
97
+ export async function membershipTargetIds(getModel, membership, userId, targetPrimaryKey, grantedTokenId) {
98
+ const rows = await getModel(membership.through).find({ where: { [membership.viaAlias]: userId } }) ?? [];
99
+ const membershipGroupId = (row) => relationValueId(row[membership.groupFk] ?? row[membership.groupAlias]);
100
+ let allowedRows = rows;
101
+ if (membership.groupAlias) {
102
+ const groupIds = rows
103
+ .map(membershipGroupId)
104
+ .filter((id) => id !== null && id !== undefined);
105
+ const grantedGroupIds = await groupsGrantingToken(getModel, Array.from(new Set(groupIds)), grantedTokenId);
106
+ allowedRows = rows.filter((row) => grantedGroupIds.has(String(membershipGroupId(row))));
107
+ }
108
+ return Array.from(new Set(allowedRows
109
+ .map((row) => relationValueId(row[membership.targetFk] ?? row[membership.targetAlias], targetPrimaryKey))
110
+ .filter((id) => id !== null && id !== undefined)));
111
+ }
112
+ /** Ids of the given groups whose token list carries `tokenId`. */
113
+ export async function groupsGrantingToken(getModel, groupIds, tokenId) {
114
+ if (!groupIds.length) {
115
+ return new Set();
116
+ }
117
+ const groups = (await getModel("Group").find({ where: { id: { in: groupIds } } }) ?? []);
118
+ return new Set(groups
119
+ .filter((group) => grantsToken(group.tokens ?? [], tokenId))
120
+ .map((group) => String(group.id)));
121
+ }
@@ -0,0 +1,72 @@
1
+ import type { User } from '../../models/User.js';
2
+ import type { Adminizer } from '../Adminizer.js';
3
+ export interface AdminLink {
4
+ id: string;
5
+ type: string;
6
+ name: string;
7
+ link: string;
8
+ title?: string;
9
+ section?: string;
10
+ accessRightsToken?: string;
11
+ }
12
+ export interface ResolvedAdminLink extends AdminLink {
13
+ owner: string;
14
+ title: string;
15
+ }
16
+ /** A placeholder of an admin link template, e.g. `id` in `/model/Test/edit/:id`. */
17
+ export interface AdminLinkTemplateParam {
18
+ name: string;
19
+ description?: string;
20
+ }
21
+ /**
22
+ * A parametrized admin page, e.g. `/admin/model/Test/edit/:id`. Templates are
23
+ * what makes a record page reachable: the concrete URL only exists once the
24
+ * caller knows the record id.
25
+ */
26
+ export interface AdminLinkTemplate {
27
+ id: string;
28
+ title: string;
29
+ /** Absolute admin path with `:param` placeholders. */
30
+ template: string;
31
+ description?: string;
32
+ section?: string;
33
+ accessRightsToken?: string;
34
+ /** Placeholder descriptions; placeholders absent here are still required. */
35
+ params?: AdminLinkTemplateParam[];
36
+ }
37
+ export interface ResolvedAdminLinkTemplate extends AdminLinkTemplate {
38
+ owner: string;
39
+ params: AdminLinkTemplateParam[];
40
+ }
41
+ /** Server-side registry of standalone admin pages contributed by applications. */
42
+ export declare class AdminLinkHandler {
43
+ private readonly adminizer;
44
+ private readonly links;
45
+ private readonly templates;
46
+ constructor(adminizer: Adminizer);
47
+ add(link: AdminLink, owner?: string): string;
48
+ remove(id: string, owner?: string): boolean;
49
+ list(user: User, type?: string): Promise<ResolvedAdminLink[]>;
50
+ resolve(user: User, type: string, name: string): Promise<ResolvedAdminLink | undefined>;
51
+ addTemplate(template: AdminLinkTemplate, owner?: string): string;
52
+ removeTemplate(id: string, owner?: string): boolean;
53
+ /**
54
+ * Every parametrized page this user may open: model record pages, catalog
55
+ * items, templates registered by apps and any navigation link that carries
56
+ * placeholders of its own.
57
+ */
58
+ listTemplates(user: User): Promise<ResolvedAdminLinkTemplate[]>;
59
+ /**
60
+ * Turns a template id (or its title) plus placeholder values into a real
61
+ * admin URL, rejecting templates this user may not open.
62
+ */
63
+ resolveTemplate(user: User, id: string, params?: Record<string, unknown>): Promise<string>;
64
+ /** Fills the placeholders of a template with url-encoded values. */
65
+ buildHref(template: ResolvedAdminLinkTemplate, params?: Record<string, unknown>): string;
66
+ /** True for paths that change data and therefore must never be navigated to. */
67
+ isDestructivePath(path: string): boolean;
68
+ /** Templates derived from the current configuration, catalogs and navigation. */
69
+ private builtinTemplates;
70
+ private describeParams;
71
+ private slug;
72
+ }