@things-factory/auth-base 10.1.6 → 10.1.16

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 (46) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist-client/tsconfig.tsbuildinfo +1 -1
  3. package/dist-server/index.js +16 -0
  4. package/dist-server/index.js.map +1 -1
  5. package/dist-server/router/auth-private-process-router.js +20 -1
  6. package/dist-server/router/auth-private-process-router.js.map +1 -1
  7. package/dist-server/service/index.d.ts +3 -0
  8. package/dist-server/service/index.js +16 -11
  9. package/dist-server/service/index.js.map +1 -1
  10. package/dist-server/service/privilege/privilege-axis.d.ts +72 -0
  11. package/dist-server/service/privilege/privilege-axis.js +188 -0
  12. package/dist-server/service/privilege/privilege-axis.js.map +1 -0
  13. package/dist-server/service/privilege/privilege-directive.d.ts +10 -41
  14. package/dist-server/service/privilege/privilege-directive.js +62 -74
  15. package/dist-server/service/privilege/privilege-directive.js.map +1 -1
  16. package/dist-server/service/role/role-mutation.js +2 -2
  17. package/dist-server/service/role/role-mutation.js.map +1 -1
  18. package/dist-server/service/role/role-query.d.ts +43 -1
  19. package/dist-server/service/role/role-query.js +76 -33
  20. package/dist-server/service/role/role-query.js.map +1 -1
  21. package/dist-server/service/role-template/index.d.ts +4 -0
  22. package/dist-server/service/role-template/index.js +9 -0
  23. package/dist-server/service/role-template/index.js.map +1 -0
  24. package/dist-server/service/role-template/role-template-mutation.d.ts +39 -0
  25. package/dist-server/service/role-template/role-template-mutation.js +124 -0
  26. package/dist-server/service/role-template/role-template-mutation.js.map +1 -0
  27. package/dist-server/service/role-template/role-template-query.d.ts +11 -0
  28. package/dist-server/service/role-template/role-template-query.js +79 -0
  29. package/dist-server/service/role-template/role-template-query.js.map +1 -0
  30. package/dist-server/service/role-template/role-template-types.d.ts +51 -0
  31. package/dist-server/service/role-template/role-template-types.js +71 -0
  32. package/dist-server/service/role-template/role-template-types.js.map +1 -0
  33. package/dist-server/service/role-template/role-template.d.ts +110 -0
  34. package/dist-server/service/role-template/role-template.js +147 -0
  35. package/dist-server/service/role-template/role-template.js.map +1 -0
  36. package/dist-server/tsconfig.tsbuildinfo +1 -1
  37. package/dist-server/utils/check-permission.js +11 -2
  38. package/dist-server/utils/check-permission.js.map +1 -1
  39. package/package.json +4 -4
  40. package/tests/domain-inheritance-sentinel.test.ts +93 -0
  41. package/tests/permission-gate.test.ts +16 -12
  42. package/tests/privilege-axis.test.ts +157 -0
  43. package/tests/privilege-directive.test.ts +70 -81
  44. package/tests/role-privileges-db.test.ts +98 -0
  45. package/tests/role-template-seed-db.test.ts +179 -0
  46. package/tests/role-template.test.ts +162 -0
@@ -18,6 +18,7 @@ import { loadCompiled } from './compiled'
18
18
 
19
19
  const { Role } = loadCompiled('service/role/role')
20
20
  const { Privilege } = loadCompiled('service/privilege/privilege')
21
+ const { User } = loadCompiled('service/user/user')
21
22
  const { RoleQuery } = loadCompiled('service/role/role-query')
22
23
 
23
24
  const query = new RoleQuery()
@@ -38,6 +39,10 @@ function ask(roleId: string, domain: any) {
38
39
  return query.rolePrivileges(roleId, { state: { domain } } as any)
39
40
  }
40
41
 
42
+ function askUserRoles(userId: string, domain: any) {
43
+ return query.userRoles(userId, { state: { domain } } as any)
44
+ }
45
+
41
46
  beforeAll(async () => {
42
47
  await openAuthDatabase()
43
48
  })
@@ -126,3 +131,96 @@ describe('부러지지 않는다', () => {
126
131
  expect(await ask("' OR 1=1 --", child)).toEqual([])
127
132
  })
128
133
  })
134
+
135
+ /**
136
+ * 목록이 쓰는 **수**.
137
+ *
138
+ * 목록이 「이 역할이 무엇을 여나」를 말하려고 권한 **행**을 받고 있었다 — 역할 20개에
139
+ * 5,731 bytes · 69ms 대 518 bytes · 7ms(UX 레인 실측). 행을 안 받고 수를 받는다.
140
+ */
141
+ describe('권한 수', () => {
142
+ it('가진 것만 센다', async () => {
143
+ const role = await getRepository(Role).save({ name: 'viewer', domain: child, privileges: [boardQuery] })
144
+
145
+ expect(await query.privilegeCount(role)).toBe(1)
146
+ })
147
+
148
+ it('하나도 없으면 0 이다 — 「모른다」가 아니라 0 이다', async () => {
149
+ const role = await getRepository(Role).save({ name: 'blank', domain: child, privileges: [] })
150
+
151
+ expect(await query.privilegeCount(role)).toBe(0)
152
+ })
153
+
154
+ it('다른 역할의 권한을 세지 않는다', async () => {
155
+ await getRepository(Role).save({ name: 'other', domain: child, privileges: [boardQuery, boardMutation] })
156
+ const mine = await getRepository(Role).save({ name: 'mine', domain: child, privileges: [boardQuery] })
157
+
158
+ expect(await query.privilegeCount(mine)).toBe(1)
159
+ })
160
+ })
161
+
162
+ /**
163
+ * 한 사람의 역할 — `rolePrivileges` 와 **같은 결함을 갖고 있던** 자리.
164
+ *
165
+ * 원시 SQL 로 `userId` 를 이어 붙이고, MSSQL 에 없는 `THEN true` 를 쓰고, `FROM roles` 로
166
+ * **도메인을 아예 안 봤다.** 한 자리를 고쳤으면 두 번째 자리도 재야 한다.
167
+ */
168
+ describe('사용자의 역할', () => {
169
+ async function makeUser(roles: any[]) {
170
+ const salt = User.generateSalt()
171
+ return await getRepository(User).save({
172
+ username: `u-${Math.random().toString(36).slice(2, 8)}`,
173
+ email: `u-${Math.random().toString(36).slice(2, 8)}@example.com`,
174
+ name: 'Kim',
175
+ salt,
176
+ status: 'activated',
177
+ domains: [child],
178
+ roles
179
+ })
180
+ }
181
+
182
+ it('부여할 수 있는 전체를 주고, 가진 것에만 표시한다', async () => {
183
+ const held = await getRepository(Role).save({ name: 'held', domain: child })
184
+ await getRepository(Role).save({ name: 'free', domain: child })
185
+ const user = await makeUser([held])
186
+
187
+ const rows = await askUserRoles(user.id, child)
188
+
189
+ expect(rows).toHaveLength(2)
190
+ expect(rows.find(row => row.id === held.id).assigned).toBe(true)
191
+ expect(rows.find(row => row.name === 'free').assigned).toBe(false)
192
+ })
193
+
194
+ it('부모 도메인의 역할도 준다 — 상속이 부여 범위다', async () => {
195
+ await getRepository(Role).save({ name: 'shared', domain: parent })
196
+ const user = await makeUser([])
197
+
198
+ expect((await askUserRoles(user.id, child)).map((row: any) => row.name)).toEqual(['shared'])
199
+ })
200
+
201
+ it('남의 도메인 역할은 주지 않는다 — 예전에는 전부 줬다', async () => {
202
+ await getRepository(Role).save({ name: 'theirs', domain: stranger })
203
+ await getRepository(Role).save({ name: 'mine', domain: child })
204
+ const user = await makeUser([])
205
+
206
+ expect((await askUserRoles(user.id, child)).map((row: any) => row.name)).toEqual(['mine'])
207
+ })
208
+
209
+ it('없는 사용자에게도 목록을 준다 — 가진 것이 0 인 것과 같다', async () => {
210
+ await getRepository(Role).save({ name: 'mine', domain: child })
211
+
212
+ const rows = await askUserRoles('00000000-0000-0000-0000-000000000000', child)
213
+
214
+ expect(rows).toHaveLength(1)
215
+ expect(rows[0].assigned).toBe(false)
216
+ })
217
+
218
+ it('id 자리에 SQL 을 넣어도 던지지 않는다', async () => {
219
+ await getRepository(Role).save({ name: 'mine', domain: child })
220
+
221
+ const rows = await askUserRoles("' OR 1=1 --", child)
222
+
223
+ expect(rows).toHaveLength(1)
224
+ expect(rows[0].assigned).toBe(false)
225
+ })
226
+ })
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Seeding a role from a template — against a real database.
3
+ *
4
+ * `role-template.test.ts` covers what the registry accepts. What it cannot cover is the
5
+ * part that touches rows, and that is where the rules with teeth are:
6
+ *
7
+ * - the plant names the seat, not the template;
8
+ * - an existing name is reused rather than duplicated, because approval lines hold a
9
+ * role's id and a second row would leave them pointing at the one nobody edits;
10
+ * - two templates on one name make the union, and privileges are never taken away;
11
+ * - a privilege this installation does not declare is skipped, never invented.
12
+ */
13
+
14
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
15
+ import { authDataSource } from './db'
16
+ import { loadCompiled } from './compiled'
17
+
18
+ const { Role } = loadCompiled('service/role/role')
19
+ const { Privilege } = loadCompiled('service/privilege/privilege')
20
+ const { User } = loadCompiled('service/user/user')
21
+ const { RoleTemplateMutation } = loadCompiled('service/role-template/role-template-mutation')
22
+ const { registerRoleTemplate } = loadCompiled('service/role-template/role-template')
23
+
24
+ const mutation = new RoleTemplateMutation()
25
+
26
+ let domain: any
27
+ let actor: any
28
+ let boardQuery: any
29
+ let boardMutation: any
30
+
31
+ function context() {
32
+ return {
33
+ state: { domain, user: actor, tx: authDataSource().manager }
34
+ } as any
35
+ }
36
+
37
+ async function privilegesOf(id: string): Promise<string[]> {
38
+ const role = await getRepository(Role).findOne({ where: { id }, relations: ['privileges'] })
39
+
40
+ return (role!.privileges || []).map((one: any) => `${one.category}:${one.name}`).sort()
41
+ }
42
+
43
+ beforeAll(async () => {
44
+ await openAuthDatabase()
45
+ })
46
+
47
+ afterAll(async () => {
48
+ await closeAuthDatabase()
49
+ })
50
+
51
+ beforeEach(async () => {
52
+ await resetAuthDatabase()
53
+ ;(process as any)['ROLE_TEMPLATES'] = {}
54
+
55
+ const saved = await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })
56
+ domain = await getRepository(Domain).findOneBy({ id: saved.id })
57
+
58
+ const privilegeRepository = getRepository(Privilege)
59
+ boardQuery = await privilegeRepository.save({ name: 'query', category: 'board' })
60
+ boardMutation = await privilegeRepository.save({ name: 'mutation', category: 'board' })
61
+
62
+ /* The gate reads this; seeding reads the rows above. Both have to agree. */
63
+ ;(process as any).PRIVILEGES = {
64
+ 'board query': ['board', 'query'],
65
+ 'board mutation': ['board', 'mutation']
66
+ }
67
+
68
+ actor = await getRepository(User).save({
69
+ username: 'admin',
70
+ email: 'admin@example.com',
71
+ name: 'Admin',
72
+ salt: User.generateSalt(),
73
+ status: 'activated',
74
+ domains: [domain]
75
+ })
76
+ })
77
+
78
+ describe('seeding', () => {
79
+ it('creates the role with the privileges the template names', async () => {
80
+ registerRoleTemplate({
81
+ id: 'plant.qi',
82
+ name: 'QUALITY-INSPECTOR',
83
+ grants: [{ category: 'board', axes: ['query'] }]
84
+ })
85
+
86
+ const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
87
+
88
+ expect(role.name).toBe('QUALITY-INSPECTOR')
89
+ expect(await privilegesOf(role.id)).toEqual(['board:query'])
90
+ })
91
+
92
+ it('lets the plant name the seat itself', async () => {
93
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
94
+
95
+ const role = await mutation.createRoleFromTemplate('plant.qi', '품질검사원', context())
96
+
97
+ expect(role.name).toBe('품질검사원')
98
+ expect(await privilegesOf(role.id)).toEqual(['board:query'])
99
+ })
100
+
101
+ it('makes a second seat from one template when the name differs — day shift and night shift', async () => {
102
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
103
+
104
+ const day = await mutation.createRoleFromTemplate('plant.qi', 'QI-DAY', context())
105
+ const night = await mutation.createRoleFromTemplate('plant.qi', 'QI-NIGHT', context())
106
+
107
+ expect(day.id).not.toBe(night.id)
108
+ expect(await privilegesOf(day.id)).toEqual(['board:query'])
109
+ expect(await privilegesOf(night.id)).toEqual(['board:query'])
110
+ })
111
+
112
+ it('reuses an existing role of that name instead of creating a second one', async () => {
113
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
114
+
115
+ const existing = await getRepository(Role).save({ name: 'QUALITY-INSPECTOR', domain, privileges: [] })
116
+ const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
117
+
118
+ expect(role.id).toBe(existing.id)
119
+ expect(await getRepository(Role).count()).toBe(1)
120
+ })
121
+
122
+ it('adds the union when two applications declare the same seat', async () => {
123
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
124
+ registerRoleTemplate({
125
+ id: 'twin.qi',
126
+ name: 'QUALITY-INSPECTOR',
127
+ grants: [{ category: 'board', axes: ['mutation'] }]
128
+ })
129
+
130
+ await mutation.createRoleFromTemplate('plant.qi', undefined, context())
131
+ const role = await mutation.createRoleFromTemplate('twin.qi', undefined, context())
132
+
133
+ expect(await privilegesOf(role.id)).toEqual(['board:mutation', 'board:query'])
134
+ expect(await getRepository(Role).count()).toBe(1)
135
+ })
136
+
137
+ it('leaves privileges the administrator added, and takes nothing away', async () => {
138
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
139
+
140
+ const existing = await getRepository(Role).save({
141
+ name: 'QUALITY-INSPECTOR',
142
+ domain,
143
+ privileges: [boardMutation]
144
+ })
145
+
146
+ await mutation.createRoleFromTemplate('plant.qi', undefined, context())
147
+
148
+ expect(await privilegesOf(existing.id)).toEqual(['board:mutation', 'board:query'])
149
+ })
150
+
151
+ it('skips a privilege this installation does not declare rather than inventing the row', async () => {
152
+ registerRoleTemplate({
153
+ id: 'plant.x',
154
+ name: 'X',
155
+ grants: [
156
+ { category: 'board', axes: ['query'] },
157
+ { category: 'board-ai', axes: ['query'] }
158
+ ]
159
+ })
160
+
161
+ const role = await mutation.createRoleFromTemplate('plant.x', undefined, context())
162
+
163
+ expect(await privilegesOf(role.id)).toEqual(['board:query'])
164
+ expect(await getRepository(Privilege).count()).toBe(2)
165
+ })
166
+
167
+ it('refuses a template this installation does not have', async () => {
168
+ await expect(mutation.createRoleFromTemplate('plant.missing', undefined, context())).rejects.toThrow(
169
+ /no role template/
170
+ )
171
+ })
172
+
173
+ it('seeds the framework-computed administrator without any application declaring it', async () => {
174
+ const role = await mutation.createRoleFromTemplate('framework.administrator', undefined, context())
175
+
176
+ expect(role.name).toBe('ADMINISTRATOR')
177
+ expect(await privilegesOf(role.id)).toEqual(['board:mutation', 'board:query'])
178
+ })
179
+ })
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Role templates — the registry and what it refuses.
3
+ *
4
+ * The refusals are the interesting part. A template that registers happily and then grants
5
+ * nothing is the quiet failure this design exists to avoid, so the checks that make noise
6
+ * are the ones pinned here.
7
+ */
8
+
9
+ import { loadCompiled } from './compiled'
10
+
11
+ const {
12
+ registerRoleTemplate,
13
+ roleTemplates,
14
+ roleTemplateById,
15
+ computedRoleTemplates,
16
+ allRoleTemplates,
17
+ unknownTemplateGrants
18
+ } = loadCompiled('service/role-template/role-template')
19
+
20
+ const { declareAxes } = loadCompiled('service/privilege/privilege-axis')
21
+
22
+ beforeEach(() => {
23
+ /* Nobody declared, so the defaults apply — query/mutation with query read-only. */
24
+ delete (process as any).PRIVILEGE_AXES
25
+ ;(process as any)['ROLE_TEMPLATES'] = {}
26
+ ;(process as any).PRIVILEGES = {
27
+ 'board query': ['board', 'query'],
28
+ 'board mutation': ['board', 'mutation'],
29
+ 'order query': ['order', 'query'],
30
+ 'worklist query': ['worklist', 'query'],
31
+ 'worklist mutation': ['worklist', 'mutation']
32
+ }
33
+ })
34
+
35
+ describe('declaring a template', () => {
36
+ it('can be found again by id', () => {
37
+ registerRoleTemplate({
38
+ id: 'plant.production-lead',
39
+ name: 'PRODUCTION-LEAD',
40
+ grants: [{ category: 'worklist', axes: ['query', 'mutation'] }]
41
+ })
42
+
43
+ expect(roleTemplateById('plant.production-lead')!.name).toBe('PRODUCTION-LEAD')
44
+ expect(roleTemplates()).toHaveLength(1)
45
+ })
46
+ })
47
+
48
+ describe('what registration refuses', () => {
49
+ it('refuses a template with no grants written at all', () => {
50
+ expect(() => registerRoleTemplate({ id: 'x', name: 'X' } as any)).toThrow(/no grants/)
51
+ })
52
+
53
+ it('accepts an empty list — a seat that opens nothing on purpose, for approval routing', () => {
54
+ registerRoleTemplate({ id: 'twin.quality-inspector', name: 'QUALITY-INSPECTOR', grants: [] })
55
+
56
+ expect(roleTemplateById('twin.quality-inspector')!.grants).toEqual([])
57
+ })
58
+
59
+ it('refuses a grant with no axes', () => {
60
+ expect(() => registerRoleTemplate({ id: 'x', name: 'X', grants: [{ category: 'board', axes: [] }] })).toThrow(
61
+ /no axes/
62
+ )
63
+ })
64
+
65
+ it('refuses the same id twice', () => {
66
+ registerRoleTemplate({ id: 'x', name: 'X', grants: [] })
67
+
68
+ expect(() => registerRoleTemplate({ id: 'x', name: 'Y', grants: [] })).toThrow(/declared twice/)
69
+ })
70
+
71
+ it('accepts the same role name from two applications — one seat, declared twice', () => {
72
+ registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
73
+ registerRoleTemplate({ id: 'twin.qi', name: 'QUALITY-INSPECTOR', grants: [] })
74
+
75
+ expect(roleTemplates().filter(one => one.name === 'QUALITY-INSPECTOR')).toHaveLength(2)
76
+ })
77
+ })
78
+
79
+ describe('names this installation does not declare', () => {
80
+ it('points at grants that would silently open nothing after a category rename', () => {
81
+ registerRoleTemplate({
82
+ id: 'plant.x',
83
+ name: 'X',
84
+ grants: [
85
+ { category: 'board', axes: ['query'] },
86
+ { category: 'board-ai', axes: ['query', 'mutation'] }
87
+ ]
88
+ })
89
+
90
+ expect(unknownTemplateGrants(roleTemplateById('plant.x')!)).toEqual(['board-ai:query', 'board-ai:mutation'])
91
+ })
92
+
93
+ it('reports nothing when every named privilege is installed', () => {
94
+ registerRoleTemplate({ id: 'plant.y', name: 'Y', grants: [{ category: 'order', axes: ['query'] }] })
95
+
96
+ expect(unknownTemplateGrants(roleTemplateById('plant.y')!)).toEqual([])
97
+ })
98
+ })
99
+
100
+ describe('the two templates the framework computes', () => {
101
+ it('gives the viewer every category that declares a query axis', () => {
102
+ const viewer = computedRoleTemplates().find(one => one.id === 'framework.viewer')!
103
+
104
+ expect(viewer.grants.map(grant => grant.category).sort()).toEqual(['board', 'order', 'worklist'])
105
+ expect(viewer.grants.every(grant => grant.axes.join() === 'query')).toBe(true)
106
+ })
107
+
108
+ it('gives the administrator everything declared', () => {
109
+ const administrator = computedRoleTemplates().find(one => one.id === 'framework.administrator')!
110
+ const pairs = administrator.grants.flatMap(grant => grant.axes.map(axis => `${grant.category}:${axis}`)).sort()
111
+
112
+ expect(pairs).toEqual(['board:mutation', 'board:query', 'order:query', 'worklist:mutation', 'worklist:query'])
113
+ })
114
+
115
+ it('widens only the administrator when a new axis is declared', () => {
116
+ ;(process as any).PRIVILEGES['board execute'] = ['board', 'execute']
117
+
118
+ const administrator = computedRoleTemplates().find(one => one.id === 'framework.administrator')!
119
+ const viewer = computedRoleTemplates().find(one => one.id === 'framework.viewer')!
120
+
121
+ expect(administrator.grants.find(grant => grant.category === 'board')!.axes).toContain('execute')
122
+ expect(viewer.grants.find(grant => grant.category === 'board')!.axes).toEqual(['query'])
123
+ })
124
+
125
+ it('builds the viewer from the declared read-only axes, not from the word "query"', () => {
126
+ /*
127
+ * A house that spells its read axis `read` gets a viewer over `read`. Matching the literal
128
+ * `query` was right here and wrong there — dssp's `kpi` has eight axes and none of them is
129
+ * `query`, so its main module would have gone missing from VIEWER without a word anywhere.
130
+ */
131
+ declareAxes(['read', 'input'], { readOnly: ['read'] })
132
+ ;(process as any).PRIVILEGES = {
133
+ 'kpi read': ['kpi', 'read'],
134
+ 'kpi input': ['kpi', 'input']
135
+ }
136
+
137
+ const viewer = computedRoleTemplates().find(one => one.id === 'framework.viewer')!
138
+
139
+ expect(viewer.grants).toEqual([{ category: 'kpi', axes: ['read'] }])
140
+ })
141
+
142
+ it('does not offer a viewer at all when nobody said which axes are read-only', () => {
143
+ /*
144
+ * ⚠ The point of this one. Offering an empty VIEWER would let an administrator pick it,
145
+ * get a role, and open nothing — and the role list would count that zero correctly and
146
+ * present it as fact. "We do not know" is not "there are none", so the template is
147
+ * withheld and `reportDeclaredAxes` says so at boot.
148
+ */
149
+ declareAxes(['read', 'input'])
150
+
151
+ const ids = computedRoleTemplates().map(one => one.id)
152
+
153
+ expect(ids).toEqual(['framework.administrator'])
154
+ expect(ids).not.toContain('framework.viewer')
155
+ })
156
+
157
+ it('offers the computed two alongside whatever applications declared', () => {
158
+ registerRoleTemplate({ id: 'plant.z', name: 'Z', grants: [] })
159
+
160
+ expect(allRoleTemplates().map(one => one.id)).toEqual(['framework.viewer', 'framework.administrator', 'plant.z'])
161
+ })
162
+ })