@things-factory/board-import 10.1.25 → 10.1.27
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/dist-server/service/import-tools.d.ts +14 -0
- package/dist-server/service/import-tools.js +38 -4
- package/dist-server/service/import-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +7 -7
- package/server/service/import-tools-privilege.test.ts +102 -0
- package/server/service/import-tools.ts +43 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/board-import",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.27",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -23,13 +23,13 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@operato/graphql": "^10.0.0",
|
|
26
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
27
|
-
"@things-factory/attachment-base": "^10.1.
|
|
28
|
-
"@things-factory/auth-base": "^10.1.
|
|
29
|
-
"@things-factory/board-service": "^10.1.
|
|
26
|
+
"@things-factory/ai-client-base": "^10.1.26",
|
|
27
|
+
"@things-factory/attachment-base": "^10.1.27",
|
|
28
|
+
"@things-factory/auth-base": "^10.1.27",
|
|
29
|
+
"@things-factory/board-service": "^10.1.27",
|
|
30
30
|
"@things-factory/env": "^10.1.20",
|
|
31
|
-
"@things-factory/shell": "^10.1.
|
|
31
|
+
"@things-factory/shell": "^10.1.27",
|
|
32
32
|
"dxf-parser": "^1.1.2"
|
|
33
33
|
},
|
|
34
|
-
"gitHead": "
|
|
34
|
+
"gitHead": "d04dc762cacf2aac8ba0b0f5d768eca365fc09da"
|
|
35
35
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The import tools ask for what their GraphQL doors ask for.
|
|
3
|
+
*
|
|
4
|
+
* These tools reach the same service functions as `importBoardAsync` / `importSession` /
|
|
5
|
+
* `materializeImportSession` in import-session-resolver.ts. They used to run with no check, so a
|
|
6
|
+
* caller holding only the conversation privilege could create a Board through a chat.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
|
|
11
|
+
const mockCheckPermission = jest.fn()
|
|
12
|
+
jest.mock('@things-factory/auth-base', () => ({ checkPermission: (...a: any[]) => mockCheckPermission(...a) }), {
|
|
13
|
+
virtual: true
|
|
14
|
+
})
|
|
15
|
+
const mockMaterializeFromSession = jest.fn(async () => ({ id: 'b1', name: 'New', state: 'draft' }))
|
|
16
|
+
jest.mock('./import-session/materialize-from-session', () => ({ materializeFromSession: (...a: any[]) => (mockMaterializeFromSession as any)(...a) }))
|
|
17
|
+
const mockStartImportJobAsync = jest.fn(async () => ({ id: 's1', status: 'pending' }))
|
|
18
|
+
const mockFetchImportSession = jest.fn(async () => ({ id: 's1', status: 'completed' }))
|
|
19
|
+
jest.mock('./import-session/import-actions', () => ({
|
|
20
|
+
startImportJobAsync: (...a: any[]) => (mockStartImportJobAsync as any)(...a),
|
|
21
|
+
fetchImportSession: (...a: any[]) => (mockFetchImportSession as any)(...a),
|
|
22
|
+
describeAdapter: (a: any) => a
|
|
23
|
+
}))
|
|
24
|
+
jest.mock('./import-session/import-session', () => ({ ImportSession: class ImportSession {} }))
|
|
25
|
+
jest.mock('@things-factory/board-service', () => ({ Board: class Board {}, Group: class Group {} }), { virtual: true })
|
|
26
|
+
jest.mock('@things-factory/shell', () => ({ getRepository: () => ({}) }), { virtual: true })
|
|
27
|
+
|
|
28
|
+
import { clearToolRegistry, findToolSpec } from '@things-factory/ai-client-base'
|
|
29
|
+
import { IMPORT_TOOL_PRIVILEGE, registerBoardImportTools } from './import-tools'
|
|
30
|
+
|
|
31
|
+
const state = { domain: { id: 'd1' }, user: { id: 'u1' }, unsafeIP: false, prohibitedPrivileges: [{ category: 'x', privilege: 'y' }] }
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
clearToolRegistry()
|
|
35
|
+
registerBoardImportTools()
|
|
36
|
+
jest.clearAllMocks()
|
|
37
|
+
})
|
|
38
|
+
afterEach(() => clearToolRegistry())
|
|
39
|
+
|
|
40
|
+
const ARGS: Record<keyof typeof IMPORT_TOOL_PRIVILEGE, any> = {
|
|
41
|
+
importBoardAsync: { attachmentId: 'a1' },
|
|
42
|
+
getImportSession: { sessionId: 's1' },
|
|
43
|
+
materializeImportSession: { sessionId: 's1', name: 'New' }
|
|
44
|
+
}
|
|
45
|
+
const EFFECT: Record<keyof typeof IMPORT_TOOL_PRIVILEGE, jest.Mock> = {
|
|
46
|
+
importBoardAsync: mockStartImportJobAsync,
|
|
47
|
+
getImportSession: mockFetchImportSession,
|
|
48
|
+
materializeImportSession: mockMaterializeFromSession
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe.each(Object.keys(IMPORT_TOOL_PRIVILEGE) as (keyof typeof IMPORT_TOOL_PRIVILEGE)[])('%s', tool => {
|
|
52
|
+
test('refuses, and does nothing, when the caller lacks the privilege', async () => {
|
|
53
|
+
mockCheckPermission.mockResolvedValue(false)
|
|
54
|
+
const spec: any = findToolSpec(tool)
|
|
55
|
+
await expect(spec.builder(ARGS[tool], { state })).rejects.toThrow(
|
|
56
|
+
new RegExp(`${IMPORT_TOOL_PRIVILEGE[tool].category}:${IMPORT_TOOL_PRIVILEGE[tool].privilege}`)
|
|
57
|
+
)
|
|
58
|
+
expect(EFFECT[tool]).not.toHaveBeenCalled()
|
|
59
|
+
if (tool === 'materializeImportSession') expect(mockFetchImportSession).not.toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('asks auth for exactly the declared privilege, with the request state', async () => {
|
|
63
|
+
mockCheckPermission.mockResolvedValue(true)
|
|
64
|
+
const spec: any = findToolSpec(tool)
|
|
65
|
+
await spec.builder(ARGS[tool], { state })
|
|
66
|
+
expect(mockCheckPermission).toHaveBeenCalledWith(
|
|
67
|
+
IMPORT_TOOL_PRIVILEGE[tool],
|
|
68
|
+
state.user,
|
|
69
|
+
state.domain,
|
|
70
|
+
state.unsafeIP,
|
|
71
|
+
state.prohibitedPrivileges
|
|
72
|
+
)
|
|
73
|
+
expect(EFFECT[tool]).toHaveBeenCalled()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('declares the same privilege on the spec, for doors that decide what to offer', () => {
|
|
77
|
+
const spec: any = findToolSpec(tool)
|
|
78
|
+
expect(spec.privilege).toBe(IMPORT_TOOL_PRIVILEGE[tool])
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('the table says what the GraphQL doors say', () => {
|
|
83
|
+
const resolver = readFileSync(join(__dirname, 'import-session', 'import-session-resolver.ts'), 'utf8')
|
|
84
|
+
const DOOR: Record<keyof typeof IMPORT_TOOL_PRIVILEGE, string> = {
|
|
85
|
+
importBoardAsync: 'importBoardAsync',
|
|
86
|
+
getImportSession: 'importSession',
|
|
87
|
+
materializeImportSession: 'materializeImportSession'
|
|
88
|
+
}
|
|
89
|
+
for (const [tool, door] of Object.entries(DOOR) as [keyof typeof IMPORT_TOOL_PRIVILEGE, string][]) {
|
|
90
|
+
const m = resolver.match(
|
|
91
|
+
new RegExp(`@Directive\\('@privilege\\(category: "([^"]+)", privilege: "([^"]+)"\\)'\\)\\s*async ${door}\\(`)
|
|
92
|
+
)
|
|
93
|
+
expect(m).not.toBeNull()
|
|
94
|
+
expect({ category: m![1], privilege: m![2] }).toEqual(IMPORT_TOOL_PRIVILEGE[tool])
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('every tool that touches a domain is in the table', () => {
|
|
99
|
+
const source = readFileSync(join(__dirname, 'import-tools.ts'), 'utf8')
|
|
100
|
+
const bare = source.match(/= requireDomainContext\(ctx\?\.state\)/g) ?? []
|
|
101
|
+
expect(bare).toEqual([])
|
|
102
|
+
})
|
|
@@ -45,7 +45,10 @@ function getDeps(): any {
|
|
|
45
45
|
const actionsMod = require('./import-session/import-actions')
|
|
46
46
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
47
47
|
const materializeMod = require('./import-session/materialize-from-session')
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
49
|
+
const authMod = require('@things-factory/auth-base')
|
|
48
50
|
_depsCache = {
|
|
51
|
+
checkPermission: authMod.checkPermission,
|
|
49
52
|
ImportSession: isMod.ImportSession,
|
|
50
53
|
Board: bsMod.Board,
|
|
51
54
|
Group: bsMod.Group,
|
|
@@ -72,6 +75,40 @@ function requireDomainContext(state: any): { domain: any; user: any } {
|
|
|
72
75
|
return { domain: state.domain, user: state.user }
|
|
73
76
|
}
|
|
74
77
|
|
|
78
|
+
/*
|
|
79
|
+
* A tool is a second entrance to a door that already exists, so it asks for what that door asks for.
|
|
80
|
+
*
|
|
81
|
+
* `materializeImportSession` and `importBoardAsync` sit behind `board-import:mutation` as GraphQL
|
|
82
|
+
* mutations, and `importSession` behind `board-import:query` (import-session-resolver.ts). The tools
|
|
83
|
+
* called the same service functions with no check at all, so whoever could open a conversation —
|
|
84
|
+
* `ai-assistant:mutation` — could create a Board through it.
|
|
85
|
+
*
|
|
86
|
+
* The check lives in the builder rather than in whichever chat door dispatches the tool: there is
|
|
87
|
+
* more than one such door, and the effect has to be guarded wherever it is reached from. The same
|
|
88
|
+
* object is also declared on the spec as `privilege`, which is what a door reads to decide whether
|
|
89
|
+
* to offer the tool at all; declaring and enforcing from one constant keeps the two from drifting.
|
|
90
|
+
*/
|
|
91
|
+
export const IMPORT_TOOL_PRIVILEGE = {
|
|
92
|
+
importBoardAsync: { category: 'board-import', privilege: 'mutation' },
|
|
93
|
+
getImportSession: { category: 'board-import', privilege: 'query' },
|
|
94
|
+
materializeImportSession: { category: 'board-import', privilege: 'mutation' }
|
|
95
|
+
} as const
|
|
96
|
+
|
|
97
|
+
type GuardedImportTool = keyof typeof IMPORT_TOOL_PRIVILEGE
|
|
98
|
+
|
|
99
|
+
async function requireImportPrivilege(tool: GuardedImportTool, state: any): Promise<{ domain: any; user: any }> {
|
|
100
|
+
const { domain, user } = requireDomainContext(state)
|
|
101
|
+
const required = IMPORT_TOOL_PRIVILEGE[tool]
|
|
102
|
+
const allowed = await getDeps().checkPermission(required, user, domain, state.unsafeIP, state.prohibitedPrivileges)
|
|
103
|
+
if (!allowed) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`[board-import] '${tool}' needs ${required.category}:${required.privilege}. ` +
|
|
106
|
+
'Being allowed to talk to the assistant does not open it.'
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
return { domain, user }
|
|
110
|
+
}
|
|
111
|
+
|
|
75
112
|
const importToolsCategory: ToolCategory = {
|
|
76
113
|
name: 'board-import',
|
|
77
114
|
description: 'Drawing/CAD/IFC/image → Board 변환 파이프라인 (zero-to-twin).',
|
|
@@ -112,6 +149,7 @@ const importToolsCategory: ToolCategory = {
|
|
|
112
149
|
{
|
|
113
150
|
kind: 'external',
|
|
114
151
|
name: 'importBoardAsync',
|
|
152
|
+
privilege: IMPORT_TOOL_PRIVILEGE.importBoardAsync,
|
|
115
153
|
description:
|
|
116
154
|
'Attachment(도면/조감도/사진) 을 비동기로 보드 모델로 변환 시작. 즉시 ImportSession 을 ' +
|
|
117
155
|
'반환하고 백그라운드에서 진행. status 가 completed 가 될 때까지 getImportSession 으로 ' +
|
|
@@ -141,7 +179,7 @@ const importToolsCategory: ToolCategory = {
|
|
|
141
179
|
required: ['attachmentId']
|
|
142
180
|
},
|
|
143
181
|
builder: async (args, ctx) => {
|
|
144
|
-
const { domain, user } =
|
|
182
|
+
const { domain, user } = await requireImportPrivilege('importBoardAsync', ctx?.state)
|
|
145
183
|
const session = await getDeps().startImportJobAsync(
|
|
146
184
|
{
|
|
147
185
|
attachmentId: args.attachmentId,
|
|
@@ -163,6 +201,7 @@ const importToolsCategory: ToolCategory = {
|
|
|
163
201
|
{
|
|
164
202
|
kind: 'read',
|
|
165
203
|
name: 'getImportSession',
|
|
204
|
+
privilege: IMPORT_TOOL_PRIVILEGE.getImportSession,
|
|
166
205
|
description:
|
|
167
206
|
'ImportSession 의 진행/결과 조회. status 가 queued/parsing/mapping/assembling/binding 인 ' +
|
|
168
207
|
'동안은 progress 와 message 를 반환, completed 면 result.boardModel 까지 포함. failed 면 ' +
|
|
@@ -175,7 +214,7 @@ const importToolsCategory: ToolCategory = {
|
|
|
175
214
|
required: ['sessionId']
|
|
176
215
|
},
|
|
177
216
|
builder: async (args, ctx) => {
|
|
178
|
-
const { domain, user } =
|
|
217
|
+
const { domain, user } = await requireImportPrivilege('getImportSession', ctx?.state)
|
|
179
218
|
const session = await getDeps().fetchImportSession(
|
|
180
219
|
args.sessionId,
|
|
181
220
|
{ domain, user },
|
|
@@ -198,6 +237,7 @@ const importToolsCategory: ToolCategory = {
|
|
|
198
237
|
{
|
|
199
238
|
kind: 'external',
|
|
200
239
|
name: 'materializeImportSession',
|
|
240
|
+
privilege: IMPORT_TOOL_PRIVILEGE.materializeImportSession,
|
|
201
241
|
description:
|
|
202
242
|
'완료된 ImportSession 의 결과 boardModel 을 새 Board entity 로 영속화. status 가 ' +
|
|
203
243
|
'completed 인 세션만 처리. 결과 Board 는 state="draft" 로 생성되므로 사용자가 검수 후 ' +
|
|
@@ -221,7 +261,7 @@ const importToolsCategory: ToolCategory = {
|
|
|
221
261
|
required: ['sessionId', 'name']
|
|
222
262
|
},
|
|
223
263
|
builder: async (args, ctx) => {
|
|
224
|
-
const { domain, user } =
|
|
264
|
+
const { domain, user } = await requireImportPrivilege('materializeImportSession', ctx?.state)
|
|
225
265
|
const session = await getDeps().fetchImportSession(
|
|
226
266
|
args.sessionId,
|
|
227
267
|
{ domain, user },
|