@crombolo/api-management 0.0.0-stage → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/authorization.ts +453 -0
- package/src/handlers/content-type.ts +784 -0
- package/src/handlers/data-type.ts +267 -0
- package/src/handlers/dictionary.ts +245 -0
- package/src/handlers/document-blueprint.ts +193 -0
- package/src/handlers/document.ts +961 -0
- package/src/handlers/editor-services.ts +59 -0
- package/src/handlers/element.ts +492 -0
- package/src/handlers/file-systems.ts +207 -0
- package/src/handlers/import.ts +46 -0
- package/src/handlers/localization.ts +116 -0
- package/src/handlers/log-viewer.ts +125 -0
- package/src/handlers/manifest.ts +15 -0
- package/src/handlers/media.ts +453 -0
- package/src/handlers/member-group.ts +87 -0
- package/src/handlers/member.ts +189 -0
- package/src/handlers/models-builder.ts +29 -0
- package/src/handlers/package.ts +189 -0
- package/src/handlers/public-access.ts +107 -0
- package/src/handlers/published-cache.ts +27 -0
- package/src/handlers/redirects.ts +78 -0
- package/src/handlers/references.ts +44 -0
- package/src/handlers/server.ts +25 -0
- package/src/handlers/settings-stubs.ts +76 -0
- package/src/handlers/start-node-trees.ts +84 -0
- package/src/handlers/template-query.ts +191 -0
- package/src/handlers/template.ts +150 -0
- package/src/handlers/temporary-file.ts +62 -0
- package/src/handlers/user.ts +604 -0
- package/src/index.ts +17 -0
- package/src/ports-content.ts +564 -0
- package/src/ports-files.ts +51 -0
- package/src/ports-logs.ts +38 -0
- package/src/ports-members.ts +111 -0
- package/src/ports-models-builder.ts +26 -0
- package/src/ports-packages.ts +58 -0
- package/src/ports-users.ts +230 -0
- package/src/ports.ts +225 -0
- package/src/router.ts +216 -0
- package/src/routes.ts +99 -0
- package/src/server-events.ts +71 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kineco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
crombolo
|
|
2
|
+
Copyright (c) 2026 Kineco
|
|
3
|
+
|
|
4
|
+
This product includes third-party software. crombolo is an independent project.
|
|
5
|
+
It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
|
|
6
|
+
the Umbraco trademark.
|
|
7
|
+
|
|
8
|
+
There are three places third-party material is redistributed, rather than merely
|
|
9
|
+
depended on:
|
|
10
|
+
|
|
11
|
+
@crombolo/backoffice-dist the built Umbraco backoffice and the browser
|
|
12
|
+
packages bundled into it, produced by
|
|
13
|
+
`bun run vendor:backoffice`
|
|
14
|
+
@crombolo/backoffice-dist three CSS files copied unmodified from the Umbraco
|
|
15
|
+
client source tree
|
|
16
|
+
@crombolo/contracts the Umbraco Management API OpenAPI document,
|
|
17
|
+
copied unmodified
|
|
18
|
+
|
|
19
|
+
No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
|
|
20
|
+
favicon and the installer illustration are deliberately not carried in this
|
|
21
|
+
repository: MIT grants copyright permission and says nothing about trademarks, so
|
|
22
|
+
crombolo's own marks are served at those paths instead. See `BRANDED_ASSETS` in
|
|
23
|
+
`@crombolo/backoffice-host`.
|
|
24
|
+
|
|
25
|
+
The Umbraco backoffice
|
|
26
|
+
----------------------
|
|
27
|
+
@umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
|
|
28
|
+
@umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
|
|
29
|
+
|
|
30
|
+
Static files from the Umbraco client source tree
|
|
31
|
+
------------------------------------------------
|
|
32
|
+
css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
|
|
33
|
+
— copied unmodified from Umbraco-CMS release-18.2.0 — MIT
|
|
34
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
35
|
+
Shipped in @crombolo/backoffice-dist under `upstream-static/`.
|
|
36
|
+
|
|
37
|
+
`rte-content.css` itself carries Tiptap's default editor styles, copied from
|
|
38
|
+
tiptap v2.11.7 (MIT); the file records this in its own header.
|
|
39
|
+
|
|
40
|
+
The Umbraco Management API contract
|
|
41
|
+
-----------------------------------
|
|
42
|
+
OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
|
|
43
|
+
(src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
|
|
44
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
45
|
+
Shipped in @crombolo/contracts, which also ships the TypeScript types generated
|
|
46
|
+
from it.
|
|
47
|
+
|
|
48
|
+
Browser runtime dependencies bundled into the backoffice build
|
|
49
|
+
--------------------------------------------------------------
|
|
50
|
+
@heximal/expressions 0.1.5 — BSD-3-Clause
|
|
51
|
+
@microsoft/signalr 10.0.11 — MIT
|
|
52
|
+
@tiptap/* 3.31.3 — MIT
|
|
53
|
+
diff 9.0.0 — BSD-3-Clause
|
|
54
|
+
dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
|
|
55
|
+
element-internals-polyfill 3.0.2 — MIT
|
|
56
|
+
lit 3.3.3 — BSD-3-Clause
|
|
57
|
+
luxon 3.7.2 — MIT
|
|
58
|
+
marked 18.0.14 — MIT
|
|
59
|
+
monaco-editor 0.57.0 — MIT
|
|
60
|
+
rxjs 7.8.2 — Apache-2.0
|
|
61
|
+
uuid 14.0.2 — MIT
|
|
62
|
+
|
|
63
|
+
dompurify is offered under either licence; Apache-2.0 is elected here, so no
|
|
64
|
+
MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
|
|
65
|
+
Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
|
|
66
|
+
section 4(d) adds nothing to this file.
|
|
67
|
+
|
|
68
|
+
Each package's own licence text ships inside its npm distribution and applies
|
|
69
|
+
unchanged to the copies bundled here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/api-management",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "Umbraco-compatible Management API handlers for crombolo.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Crombolo",
|
|
7
|
+
"homepage": "https://github.com/kineco-au/crombolo-cms#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kineco-au/crombolo-cms.git",
|
|
11
|
+
"directory": "packages/api-management"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kineco-au/crombolo-cms/issues"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"bun": ">=1.3.0"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"NOTICE"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@crombolo/core": "0.9.1",
|
|
32
|
+
"@crombolo/contracts": "0.9.1"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization for every Management API call, as Umbraco's policies apply it:
|
|
3
|
+
* first the sections an operation's controller demands (any one of a set, and
|
|
4
|
+
* every set), then, for documents and media, the caller's start nodes and the
|
|
5
|
+
* permission verbs the operation needs on the nodes it names. Users and user
|
|
6
|
+
* groups carry rules about the people involved, which their port enforces.
|
|
7
|
+
* See docs/04-backoffice-hosting.md for the table's provenance.
|
|
8
|
+
*/
|
|
9
|
+
import type { OperationInfo } from '@crombolo/contracts'
|
|
10
|
+
import {
|
|
11
|
+
type AppAlias,
|
|
12
|
+
DocumentPermissions as D,
|
|
13
|
+
hasPathAccess,
|
|
14
|
+
permissionsForPath,
|
|
15
|
+
type StartNodes,
|
|
16
|
+
sectionKey,
|
|
17
|
+
} from '@crombolo/core'
|
|
18
|
+
import type { Principal } from './router.ts'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Operations behind Umbraco's `DenyLocalLoginIfConfigured` policy: the contract
|
|
22
|
+
* marks them secured, but while local login is allowed the policy lets anyone
|
|
23
|
+
* through, since the login page calls them before anyone has signed in.
|
|
24
|
+
*/
|
|
25
|
+
export const LOCAL_LOGIN_OPERATIONS: ReadonlySet<string> = new Set([
|
|
26
|
+
'GetSecurityConfiguration',
|
|
27
|
+
'PostSecurityForgotPassword',
|
|
28
|
+
'PostSecurityForgotPasswordReset',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The sections a caller holds, normalised the one way.
|
|
33
|
+
*
|
|
34
|
+
* Aliases arrive either bare or as manifest aliases depending on where they were
|
|
35
|
+
* read, so every check goes through this rather than comparing what it was given.
|
|
36
|
+
*/
|
|
37
|
+
export const sectionsOf = (principal: Principal): Set<string> =>
|
|
38
|
+
new Set(principal.allowedSections.map(sectionKey))
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a caller holds a section, named in either vocabulary. For the routes
|
|
42
|
+
* outside the contract — the plugin endpoints — which have no operation for
|
|
43
|
+
* `authorize` to look up but must answer to the same rule as the operations they
|
|
44
|
+
* stand next to.
|
|
45
|
+
*/
|
|
46
|
+
export const hasSection = (principal: Principal, section: string): boolean =>
|
|
47
|
+
sectionsOf(principal).has(sectionKey(section))
|
|
48
|
+
|
|
49
|
+
/** A node's ancestor keys then its own, root first, and whether it is in the recycle bin. */
|
|
50
|
+
export interface NodeChain {
|
|
51
|
+
chain: string[]
|
|
52
|
+
trashed: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Where nodes sit, for start-node and per-node permission checks. */
|
|
56
|
+
export interface NodeLookup {
|
|
57
|
+
documents(keys: readonly string[]): Promise<Map<string, NodeChain>>
|
|
58
|
+
media(keys: readonly string[]): Promise<Map<string, NodeChain>>
|
|
59
|
+
/** Every document below `key`. */
|
|
60
|
+
documentDescendants(key: string): Promise<NodeChain[]>
|
|
61
|
+
/** The document a version belongs to. */
|
|
62
|
+
versionDocument(versionId: string): Promise<string | undefined>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type Sections = readonly AppAlias[]
|
|
66
|
+
|
|
67
|
+
const CONTENT: Sections = ['content']
|
|
68
|
+
const MEDIA: Sections = ['media']
|
|
69
|
+
const SETTINGS: Sections = ['settings']
|
|
70
|
+
const USERS: Sections = ['users']
|
|
71
|
+
const MEMBERS: Sections = ['members']
|
|
72
|
+
const TRANSLATION: Sections = ['translation']
|
|
73
|
+
const LIBRARY: Sections = ['library']
|
|
74
|
+
const PACKAGES: Sections = ['packages']
|
|
75
|
+
/** SectionAccessFor{Content,Media,Element}Tree: pickers open these trees from anywhere. */
|
|
76
|
+
const ANY_TREE: Sections = [
|
|
77
|
+
'content',
|
|
78
|
+
'media',
|
|
79
|
+
'users',
|
|
80
|
+
'settings',
|
|
81
|
+
'packages',
|
|
82
|
+
'members',
|
|
83
|
+
'library',
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
const TREES: Record<string, Sections> = {
|
|
87
|
+
document: ANY_TREE,
|
|
88
|
+
media: ANY_TREE,
|
|
89
|
+
element: ANY_TREE,
|
|
90
|
+
'document-type': SETTINGS,
|
|
91
|
+
'document-blueprint': SETTINGS,
|
|
92
|
+
'data-type': SETTINGS,
|
|
93
|
+
'media-type': SETTINGS,
|
|
94
|
+
'member-type': ['settings', 'members'],
|
|
95
|
+
template: ['settings', 'content'],
|
|
96
|
+
dictionary: ['translation', 'settings'],
|
|
97
|
+
'partial-view': SETTINGS,
|
|
98
|
+
script: SETTINGS,
|
|
99
|
+
stylesheet: SETTINGS,
|
|
100
|
+
'relation-type': SETTINGS,
|
|
101
|
+
'static-file': ['settings', 'content', 'media', 'members'],
|
|
102
|
+
'member-group': MEMBERS,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const SETTINGS_AREAS = new Set([
|
|
106
|
+
'log-viewer',
|
|
107
|
+
'health-check',
|
|
108
|
+
'health-check-group',
|
|
109
|
+
'indexer',
|
|
110
|
+
'searcher',
|
|
111
|
+
'models-builder',
|
|
112
|
+
'profiling',
|
|
113
|
+
'published-cache',
|
|
114
|
+
'telemetry',
|
|
115
|
+
'webhook',
|
|
116
|
+
'partial-view',
|
|
117
|
+
'script',
|
|
118
|
+
'stylesheet',
|
|
119
|
+
'relation-type',
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
/** Type actions only the Settings section may take, beyond every write. */
|
|
123
|
+
const TYPE_ADMIN_ACTIONS = new Set([
|
|
124
|
+
'configuration',
|
|
125
|
+
'available-compositions',
|
|
126
|
+
'composition-references',
|
|
127
|
+
'export',
|
|
128
|
+
'import',
|
|
129
|
+
'copy',
|
|
130
|
+
'move',
|
|
131
|
+
'template',
|
|
132
|
+
'folder',
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The sections an operation demands: every set must be satisfied, each by any
|
|
137
|
+
* one of its sections; the string `admin` requires the admin group. Operations
|
|
138
|
+
* absent here need only a signed-in backoffice user.
|
|
139
|
+
*/
|
|
140
|
+
export function sectionRequirements(operation: OperationInfo): Array<Sections | 'admin'> {
|
|
141
|
+
const [area = '', second = '', third = ''] = operation.path
|
|
142
|
+
.replace(/^\/umbraco\/management\/api\/v1\//, '')
|
|
143
|
+
.split('/')
|
|
144
|
+
const write = operation.method !== 'get'
|
|
145
|
+
const action = second.startsWith('{') ? third : second
|
|
146
|
+
const typeAdmin = write || TYPE_ADMIN_ACTIONS.has(action)
|
|
147
|
+
if (SETTINGS_AREAS.has(area)) return [SETTINGS]
|
|
148
|
+
switch (area) {
|
|
149
|
+
case 'tree':
|
|
150
|
+
return TREES[second] ? [TREES[second]] : []
|
|
151
|
+
case 'recycle-bin':
|
|
152
|
+
return [second === 'media' ? MEDIA : second === 'element' ? LIBRARY : CONTENT]
|
|
153
|
+
case 'collection':
|
|
154
|
+
return [second === 'media' ? MEDIA : CONTENT]
|
|
155
|
+
case 'filter':
|
|
156
|
+
if (second === 'data-type') return [['content', 'settings']]
|
|
157
|
+
if (second === 'user' || second === 'user-group') return [USERS]
|
|
158
|
+
if (second === 'member') return [MEMBERS]
|
|
159
|
+
return []
|
|
160
|
+
case 'document':
|
|
161
|
+
case 'document-version':
|
|
162
|
+
case 'redirect-management':
|
|
163
|
+
case 'relation':
|
|
164
|
+
case 'oembed':
|
|
165
|
+
case 'dynamic-root':
|
|
166
|
+
return [CONTENT]
|
|
167
|
+
case 'document-type':
|
|
168
|
+
if (second === 'folder') return [SETTINGS]
|
|
169
|
+
if (third === 'blueprint') return [['content', 'library', 'settings'], CONTENT]
|
|
170
|
+
return typeAdmin
|
|
171
|
+
? [['content', 'library', 'settings'], SETTINGS]
|
|
172
|
+
: [['content', 'library', 'settings']]
|
|
173
|
+
case 'document-blueprint':
|
|
174
|
+
if (second === 'from-document' || third === 'scaffold') return [CONTENT]
|
|
175
|
+
if (!write && second.startsWith('{') && third === '') return [['content', 'settings']]
|
|
176
|
+
return [SETTINGS]
|
|
177
|
+
case 'data-type':
|
|
178
|
+
if (second === 'folder') return [SETTINGS]
|
|
179
|
+
return typeAdmin
|
|
180
|
+
? [['content', 'library', 'media', 'members', 'settings'], SETTINGS]
|
|
181
|
+
: [['content', 'library', 'media', 'members', 'settings']]
|
|
182
|
+
case 'media':
|
|
183
|
+
return [MEDIA]
|
|
184
|
+
case 'media-type':
|
|
185
|
+
if (second === 'folder') return [SETTINGS]
|
|
186
|
+
return typeAdmin ? [['media', 'settings'], SETTINGS] : [['media', 'settings']]
|
|
187
|
+
case 'member-type':
|
|
188
|
+
if (second === 'folder') return [SETTINGS]
|
|
189
|
+
return typeAdmin ? [['settings', 'members'], SETTINGS] : [['settings', 'members']]
|
|
190
|
+
case 'member':
|
|
191
|
+
case 'member-group':
|
|
192
|
+
return [MEMBERS]
|
|
193
|
+
case 'template':
|
|
194
|
+
return write ? [['settings', 'content'], SETTINGS] : [['settings', 'content']]
|
|
195
|
+
case 'language':
|
|
196
|
+
return write ? [SETTINGS] : []
|
|
197
|
+
case 'dictionary':
|
|
198
|
+
return [TRANSLATION]
|
|
199
|
+
case 'user':
|
|
200
|
+
return second === 'current' ? [] : [USERS]
|
|
201
|
+
case 'user-group':
|
|
202
|
+
return [USERS]
|
|
203
|
+
case 'element':
|
|
204
|
+
case 'element-version':
|
|
205
|
+
return [LIBRARY]
|
|
206
|
+
case 'package':
|
|
207
|
+
return [PACKAGES]
|
|
208
|
+
case 'upgrade':
|
|
209
|
+
return ['admin']
|
|
210
|
+
case 'server':
|
|
211
|
+
return second === 'upgrade-check' ? ['admin'] : []
|
|
212
|
+
default:
|
|
213
|
+
return []
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Where a rule finds the node it checks: a path or query parameter, a body field, or a fixed place. */
|
|
218
|
+
type NodeRef =
|
|
219
|
+
| { param: string }
|
|
220
|
+
| { query: string; orRoot?: true }
|
|
221
|
+
| { body: readonly string[] }
|
|
222
|
+
| { version: string }
|
|
223
|
+
| 'root'
|
|
224
|
+
| 'bin'
|
|
225
|
+
|
|
226
|
+
interface DocumentRule {
|
|
227
|
+
verbs: readonly string[]
|
|
228
|
+
node: NodeRef
|
|
229
|
+
/** Every descendant too. */
|
|
230
|
+
branch?: boolean
|
|
231
|
+
/** Also check the cultures the body names. */
|
|
232
|
+
cultures?: boolean
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const id: NodeRef = { param: 'id' }
|
|
236
|
+
const DOCUMENT_RULES: Record<string, readonly DocumentRule[]> = {
|
|
237
|
+
GetDocumentById: [{ verbs: [D.Read], node: id }],
|
|
238
|
+
GetDocumentByIdPublished: [{ verbs: [D.Read], node: id }],
|
|
239
|
+
GetDocumentByIdAuditLog: [{ verbs: [D.Read], node: id }],
|
|
240
|
+
GetDocumentByIdDomains: [{ verbs: [D.Read], node: id }],
|
|
241
|
+
GetDocumentByIdNotifications: [{ verbs: [D.Read], node: id }],
|
|
242
|
+
PutDocumentByIdNotifications: [{ verbs: [D.Read], node: id }],
|
|
243
|
+
GetDocumentByIdAvailableSegmentOptions: [{ verbs: [D.Read], node: id }],
|
|
244
|
+
GetDocumentByIdPreviewUrl: [{ verbs: [D.Read], node: id }],
|
|
245
|
+
GetDocumentVersion: [{ verbs: [D.Read], node: { query: 'documentId' } }],
|
|
246
|
+
// The list view: it answers with the children's own property values, so it is
|
|
247
|
+
// a read of the branch and not merely of the tree, and Umbraco filters it per
|
|
248
|
+
// item (ContentListViewService.FilterAuthorizedKeysAsync). Requiring Read on
|
|
249
|
+
// the parent is the same boundary expressed once: a caller who cannot reach the
|
|
250
|
+
// parent's path cannot reach anything below it either.
|
|
251
|
+
GetCollectionDocumentById: [{ verbs: [D.Read], node: id }],
|
|
252
|
+
PostDocument: [{ verbs: [D.Create], node: { body: ['parent', 'id'] } }],
|
|
253
|
+
PostDocumentValidate: [{ verbs: [D.Create], node: { body: ['parent', 'id'] } }],
|
|
254
|
+
PutDocumentById: [{ verbs: [D.Update], node: id }],
|
|
255
|
+
PutDocumentByIdValidate: [{ verbs: [D.Update], node: id }],
|
|
256
|
+
PostDocumentCreateAndPublish: [
|
|
257
|
+
{ verbs: [D.Publish], node: { body: ['parent', 'id'] }, cultures: true },
|
|
258
|
+
],
|
|
259
|
+
PutDocumentByIdUpdateAndPublish: [{ verbs: [D.Publish], node: id, cultures: true }],
|
|
260
|
+
PutDocumentByIdPublish: [{ verbs: [D.Publish], node: id, cultures: true }],
|
|
261
|
+
PutDocumentByIdPublishWithDescendants: [
|
|
262
|
+
{ verbs: [D.Publish], node: id, branch: true, cultures: true },
|
|
263
|
+
],
|
|
264
|
+
PutDocumentByIdUnpublish: [{ verbs: [D.Unpublish], node: id, cultures: true }],
|
|
265
|
+
DeleteDocumentById: [{ verbs: [D.Delete], node: id }],
|
|
266
|
+
PutDocumentByIdMoveToRecycleBin: [{ verbs: [D.Delete], node: id }],
|
|
267
|
+
PutDocumentByIdMove: [
|
|
268
|
+
{ verbs: [D.Move], node: id },
|
|
269
|
+
{ verbs: [D.Create], node: { body: ['target', 'id'] } },
|
|
270
|
+
],
|
|
271
|
+
PostDocumentByIdCopy: [
|
|
272
|
+
{ verbs: [D.Duplicate], node: id },
|
|
273
|
+
{ verbs: [D.Create], node: { body: ['target', 'id'] } },
|
|
274
|
+
],
|
|
275
|
+
PutDocumentSort: [
|
|
276
|
+
{ verbs: [D.Sort], node: { body: ['parent', 'id'] } },
|
|
277
|
+
{ verbs: [D.Sort], node: { body: ['sorting', '*', 'id'] } },
|
|
278
|
+
],
|
|
279
|
+
PutDocumentByIdSortChildren: [{ verbs: [D.Sort], node: id }],
|
|
280
|
+
PutDocumentRootSortChildren: [{ verbs: [D.Sort], node: 'root' }],
|
|
281
|
+
GetDocumentByIdPublicAccess: [{ verbs: [D.PublicAccess], node: id }],
|
|
282
|
+
PostDocumentByIdPublicAccess: [{ verbs: [D.PublicAccess], node: id }],
|
|
283
|
+
PutDocumentByIdPublicAccess: [{ verbs: [D.PublicAccess], node: id }],
|
|
284
|
+
DeleteDocumentByIdPublicAccess: [{ verbs: [D.PublicAccess], node: id }],
|
|
285
|
+
PutDocumentByIdDomains: [{ verbs: [D.CultureAndHostnames], node: id }],
|
|
286
|
+
PostDocumentVersionByIdRollback: [{ verbs: [D.Rollback], node: { version: 'id' } }],
|
|
287
|
+
PostDocumentBlueprintFromDocument: [
|
|
288
|
+
{ verbs: [D.CreateBlueprint], node: { body: ['document', 'id'] } },
|
|
289
|
+
],
|
|
290
|
+
DeleteRecycleBinDocument: [{ verbs: [D.Delete], node: 'bin' }],
|
|
291
|
+
DeleteRecycleBinDocumentById: [{ verbs: [D.Delete], node: 'bin' }],
|
|
292
|
+
PutRecycleBinDocumentByIdRestore: [{ verbs: [D.Move], node: 'bin' }],
|
|
293
|
+
GetRecycleBinDocumentByIdOriginalParent: [{ verbs: [D.Read], node: 'bin' }],
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Media carries no verbs: a rule names the nodes whose path the caller must be able to reach. */
|
|
297
|
+
const MEDIA_RULES: Record<string, readonly NodeRef[]> = {
|
|
298
|
+
GetMediaById: [id],
|
|
299
|
+
// As for documents above. `id` is a query parameter here and omitting it means
|
|
300
|
+
// the root, so the rule says so rather than leaving the absent case unchecked.
|
|
301
|
+
GetCollectionMedia: [{ query: 'id', orRoot: true }],
|
|
302
|
+
PutMediaById: [id],
|
|
303
|
+
DeleteMediaById: [id],
|
|
304
|
+
GetMediaByIdAuditLog: [id],
|
|
305
|
+
PutMediaByIdMove: [id, { body: ['target', 'id'] }],
|
|
306
|
+
PutMediaByIdMoveToRecycleBin: [id],
|
|
307
|
+
PutMediaByIdSortChildren: [id],
|
|
308
|
+
PutMediaByIdValidate: [id],
|
|
309
|
+
PostMedia: [{ body: ['parent', 'id'] }],
|
|
310
|
+
PostMediaValidate: [{ body: ['parent', 'id'] }],
|
|
311
|
+
PutMediaSort: [{ body: ['parent', 'id'] }, { body: ['sorting', '*', 'id'] }],
|
|
312
|
+
PutMediaRootSortChildren: ['root'],
|
|
313
|
+
DeleteRecycleBinMedia: ['bin'],
|
|
314
|
+
DeleteRecycleBinMediaById: ['bin'],
|
|
315
|
+
PutRecycleBinMediaByIdRestore: ['bin'],
|
|
316
|
+
GetRecycleBinMediaByIdOriginalParent: ['bin'],
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Values at `path` in `body`; `*` walks every element of an array. A missing or null step means the root. */
|
|
320
|
+
function pick(body: unknown, path: readonly string[]): Array<string | null> {
|
|
321
|
+
let current: unknown[] = [body]
|
|
322
|
+
for (const step of path) {
|
|
323
|
+
const next: unknown[] = []
|
|
324
|
+
for (const value of current) {
|
|
325
|
+
if (value === null || value === undefined) {
|
|
326
|
+
next.push(null)
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
if (step === '*') {
|
|
330
|
+
if (Array.isArray(value)) next.push(...value)
|
|
331
|
+
} else next.push((value as Record<string, unknown>)[step] ?? null)
|
|
332
|
+
}
|
|
333
|
+
current = next
|
|
334
|
+
}
|
|
335
|
+
return current.map((v) => (typeof v === 'string' ? v : null))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function culturesIn(body: unknown): string[] {
|
|
339
|
+
const b = (body ?? {}) as Record<string, unknown>
|
|
340
|
+
const out: string[] = []
|
|
341
|
+
for (const field of ['cultures', 'culturesToPublish'])
|
|
342
|
+
if (Array.isArray(b[field]))
|
|
343
|
+
out.push(...((b[field] as unknown[]).filter((c) => typeof c === 'string') as string[]))
|
|
344
|
+
if (Array.isArray(b.publishSchedules))
|
|
345
|
+
for (const schedule of b.publishSchedules as Array<Record<string, unknown>>)
|
|
346
|
+
if (typeof schedule?.culture === 'string') out.push(schedule.culture)
|
|
347
|
+
return out
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Whether the user may work in every culture named. */
|
|
351
|
+
export function hasCultureAccess(principal: Principal, cultures: readonly string[]): boolean {
|
|
352
|
+
if (principal.hasAccessToAllLanguages) return true
|
|
353
|
+
const allowed = new Set(principal.languages.map((l) => l.toLowerCase()))
|
|
354
|
+
return cultures.every((c) => allowed.has(c.toLowerCase()))
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Root and the recycle bin are reached only with root access, and hold the groups' default verbs. */
|
|
358
|
+
const fixedPlaceAllows = (start: StartNodes, principal: Principal, verbs: readonly string[]) => {
|
|
359
|
+
if (!start.root) return false
|
|
360
|
+
const granted = permissionsForPath(principal.groups, [])
|
|
361
|
+
return verbs.every((v) => granted.has(v))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function resolveKeys(
|
|
365
|
+
ref: NodeRef,
|
|
366
|
+
params: Readonly<Record<string, string>>,
|
|
367
|
+
url: URL,
|
|
368
|
+
body: () => Promise<unknown>,
|
|
369
|
+
nodes: NodeLookup,
|
|
370
|
+
): Promise<Array<string | 'root' | 'bin'>> {
|
|
371
|
+
if (ref === 'root' || ref === 'bin') return [ref]
|
|
372
|
+
if ('param' in ref) return params[ref.param] ? [params[ref.param] as string] : []
|
|
373
|
+
if ('query' in ref) {
|
|
374
|
+
const value = url.searchParams.get(ref.query)
|
|
375
|
+
if (value) return [value]
|
|
376
|
+
// An absent parameter means the root for an operation that says so, rather
|
|
377
|
+
// than nothing to check — otherwise omitting it is how the check is skipped.
|
|
378
|
+
return ref.orRoot ? ['root'] : []
|
|
379
|
+
}
|
|
380
|
+
if ('version' in ref) {
|
|
381
|
+
const document = params[ref.version]
|
|
382
|
+
? await nodes.versionDocument(params[ref.version] as string)
|
|
383
|
+
: undefined
|
|
384
|
+
return document ? [document] : []
|
|
385
|
+
}
|
|
386
|
+
return pick(await body(), ref.body).map((key) => key ?? 'root')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Evaluates the operation's section requirements, then its node rules. */
|
|
390
|
+
export async function authorize(
|
|
391
|
+
operation: OperationInfo,
|
|
392
|
+
params: Readonly<Record<string, string>>,
|
|
393
|
+
request: Request,
|
|
394
|
+
principal: Principal,
|
|
395
|
+
nodes: NodeLookup | undefined,
|
|
396
|
+
): Promise<boolean> {
|
|
397
|
+
const sections = sectionsOf(principal)
|
|
398
|
+
for (const requirement of sectionRequirements(operation)) {
|
|
399
|
+
if (requirement === 'admin') {
|
|
400
|
+
if (!principal.isAdmin) return false
|
|
401
|
+
} else if (!requirement.some((s) => sections.has(s))) return false
|
|
402
|
+
}
|
|
403
|
+
// Administrators are not exempt: their group holds every verb and the root, as in Umbraco
|
|
404
|
+
if (!nodes) return true
|
|
405
|
+
|
|
406
|
+
const url = new URL(request.url)
|
|
407
|
+
let parsed: Promise<unknown> | undefined
|
|
408
|
+
const body = () => {
|
|
409
|
+
parsed ??= request
|
|
410
|
+
.clone()
|
|
411
|
+
.json()
|
|
412
|
+
.catch(() => undefined)
|
|
413
|
+
return parsed
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const rule of DOCUMENT_RULES[operation.operationId] ?? []) {
|
|
417
|
+
const start = principal.startNodes.document
|
|
418
|
+
const keys = await resolveKeys(rule.node, params, url, body, nodes)
|
|
419
|
+
const real = keys.filter((k): k is string => k !== 'root' && k !== 'bin')
|
|
420
|
+
const found = await nodes.documents(real)
|
|
421
|
+
for (const key of keys) {
|
|
422
|
+
if (key === 'root' || key === 'bin') {
|
|
423
|
+
if (!fixedPlaceAllows(start, principal, rule.verbs)) return false
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
const node = found.get(key.toLowerCase())
|
|
427
|
+
// A missing node is not denied: the handler answers 404
|
|
428
|
+
if (!node) continue
|
|
429
|
+
const targets = rule.branch ? [node, ...(await nodes.documentDescendants(key))] : [node]
|
|
430
|
+
for (const target of targets) {
|
|
431
|
+
if (!hasPathAccess(start, target.chain, target.trashed)) return false
|
|
432
|
+
const granted = permissionsForPath(principal.groups, target.chain)
|
|
433
|
+
if (!rule.verbs.every((v) => granted.has(v))) return false
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (rule.cultures && !hasCultureAccess(principal, culturesIn(await body()))) return false
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
for (const ref of MEDIA_RULES[operation.operationId] ?? []) {
|
|
440
|
+
const start = principal.startNodes.media
|
|
441
|
+
const keys = await resolveKeys(ref, params, url, body, nodes)
|
|
442
|
+
const found = await nodes.media(keys.filter((k): k is string => k !== 'root' && k !== 'bin'))
|
|
443
|
+
for (const key of keys) {
|
|
444
|
+
if (key === 'root' || key === 'bin') {
|
|
445
|
+
if (!start.root) return false
|
|
446
|
+
continue
|
|
447
|
+
}
|
|
448
|
+
const node = found.get(key.toLowerCase())
|
|
449
|
+
if (node && !hasPathAccess(start, node.chain, node.trashed)) return false
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return true
|
|
453
|
+
}
|