@things-factory/auth-base 10.1.106 → 10.1.107
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/ai-entry/access-assistant.d.ts +50 -0
- package/dist-server/ai-entry/access-assistant.js +145 -0
- package/dist-server/ai-entry/access-assistant.js.map +1 -0
- package/dist-server/ai-entry/access-entry-targets.d.ts +32 -0
- package/dist-server/ai-entry/access-entry-targets.js +196 -0
- package/dist-server/ai-entry/access-entry-targets.js.map +1 -0
- package/dist-server/ai-entry/entry-target-shape.d.ts +82 -0
- package/dist-server/ai-entry/entry-target-shape.js +3 -0
- package/dist-server/ai-entry/entry-target-shape.js.map +1 -0
- package/dist-server/controllers/access-request.d.ts +4 -0
- package/dist-server/controllers/access-request.js +49 -9
- package/dist-server/controllers/access-request.js.map +1 -1
- package/dist-server/index.d.ts +3 -0
- package/dist-server/index.js +3 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/access-request/access-request-query.d.ts +5 -0
- package/dist-server/service/access-request/access-request-query.js +15 -0
- package/dist-server/service/access-request/access-request-query.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/open-fields.js +1 -0
- package/dist-server/service/open-fields.js.map +1 -1
- package/dist-server/service/privilege/privilege-directive.js +2 -2
- package/dist-server/service/privilege/privilege-directive.js.map +1 -1
- package/dist-server/service/privilege/privilege-rejection.d.ts +3 -1
- package/dist-server/service/privilege/privilege-rejection.js +2 -10
- package/dist-server/service/privilege/privilege-rejection.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/tests/access-assistant-db.test.ts +155 -0
- package/tests/access-entry-targets-db.test.ts +138 -0
- package/tests/access-request-db.test.ts +40 -3
- package/tests/access-request-pg.test.ts +72 -0
- package/tests/privilege-directive.test.ts +18 -0
- package/translations/en.json +3 -1
- package/translations/ja.json +3 -1
- package/translations/ko.json +3 -1
- package/translations/ms.json +3 -1
- package/translations/zh.json +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.107",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"passport-jwt": "^4.0.0",
|
|
49
49
|
"passport-local": "^1.0.0"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "65a3d5889e687d8122a65f029b926b1a4769d1c0"
|
|
52
52
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the assistant reads to draft access decisions (ADR-0103 decision 6).
|
|
3
|
+
*
|
|
4
|
+
* Two facts are held here. The read tool answers only this domain, only to someone who may decide, never the
|
|
5
|
+
* caller's own requests. And a pending request handed over as the screen's context does not reach the model's
|
|
6
|
+
* prompt - which is why the tool exists; if that ever changes, this file says so.
|
|
7
|
+
*/
|
|
8
|
+
import { registryAssistantChat } from '../../ai-client-base/server/service/assistant/registry-assistant'
|
|
9
|
+
import { clearToolRegistry, registerToolCategory } from '../../ai-client-base/server/service/tool-registry/index.js'
|
|
10
|
+
|
|
11
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
12
|
+
import { loadCompiled } from './compiled'
|
|
13
|
+
|
|
14
|
+
import uiEn from '../../auth-ui/translations/en.json'
|
|
15
|
+
import uiKo from '../../auth-ui/translations/ko.json'
|
|
16
|
+
|
|
17
|
+
const { Role } = loadCompiled('service/role/role')
|
|
18
|
+
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
19
|
+
const { User } = loadCompiled('service/user/user')
|
|
20
|
+
const { DomainOwner } = loadCompiled('service/domain-owner/domain-owner')
|
|
21
|
+
const { AccessRequest } = loadCompiled('service/access-request/access-request')
|
|
22
|
+
const access = loadCompiled('controllers/access-request')
|
|
23
|
+
const assistant = loadCompiled('ai-entry/access-assistant')
|
|
24
|
+
const targets = loadCompiled('ai-entry/access-entry-targets')
|
|
25
|
+
|
|
26
|
+
let domain: any
|
|
27
|
+
let other: any
|
|
28
|
+
let kim: any
|
|
29
|
+
let lee: any
|
|
30
|
+
let park: any
|
|
31
|
+
let owner: any
|
|
32
|
+
let worker: any
|
|
33
|
+
let otherAdmin: any
|
|
34
|
+
|
|
35
|
+
async function makeUser(name: string, domains: any[], roles: any[] = []) {
|
|
36
|
+
return await getRepository(User).save({
|
|
37
|
+
username: name, email: `${name}@example.com`, name, salt: User.generateSalt(), status: 'activated', domains, roles
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ask = (user: any, privilege = 'mutation', inDomain = domain) =>
|
|
42
|
+
access.requestAccess({ category: 'warehouse', privilege, reason: 'needed for work' }, { state: { domain: inDomain, user, afterCommit: [] } } as any)
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
await openAuthDatabase()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await closeAuthDatabase()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
await resetAuthDatabase()
|
|
54
|
+
;(process as any).domainOwnerGranted = async (d: any, u: any) =>
|
|
55
|
+
!!(await getRepository(DomainOwner).findOne({ where: { domain: { id: d.id }, user: { id: u.id } } }))
|
|
56
|
+
;(process as any).superUserGranted = async () => false
|
|
57
|
+
|
|
58
|
+
domain = await getRepository(Domain).findOneBy({ id: (await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })).id })
|
|
59
|
+
other = await getRepository(Domain).findOneBy({ id: (await getRepository(Domain).save({ name: 'Other', subdomain: 'other' })).id })
|
|
60
|
+
|
|
61
|
+
const mutation = await getRepository(Privilege).save({ name: 'mutation', category: 'warehouse' })
|
|
62
|
+
await getRepository(Privilege).save({ name: 'query', category: 'warehouse' })
|
|
63
|
+
const grant = await getRepository(Privilege).save({ name: 'mutation', category: 'user' })
|
|
64
|
+
await getRepository(Role).save({ name: 'inbound', domain, privileges: [mutation] })
|
|
65
|
+
const admins = await getRepository(Role).save({ name: 'admins', domain, privileges: [grant] })
|
|
66
|
+
const otherAdmins = await getRepository(Role).save({ name: 'admins', domain: other, privileges: [grant] })
|
|
67
|
+
|
|
68
|
+
kim = await makeUser('kim', [domain])
|
|
69
|
+
lee = await makeUser('lee', [domain])
|
|
70
|
+
park = await makeUser('park', [domain], [admins])
|
|
71
|
+
owner = await makeUser('owner', [domain])
|
|
72
|
+
worker = await makeUser('worker', [domain])
|
|
73
|
+
otherAdmin = await makeUser('otheradmin', [other], [otherAdmins])
|
|
74
|
+
await getRepository(DomainOwner).save({ domain, user: owner })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
delete (process as any).domainOwnerGranted
|
|
79
|
+
delete (process as any).superUserGranted
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('listPendingAccessRequests', () => {
|
|
83
|
+
it('★ a decider reads this domain\'s waiting requests with the roles that grant each', async () => {
|
|
84
|
+
await ask(kim)
|
|
85
|
+
const answer = await assistant.pendingRequestsToDecide({ domain, user: park })
|
|
86
|
+
expect(answer.requests).toEqual([
|
|
87
|
+
expect.objectContaining({ requester: 'kim', privilege: 'warehouse:mutation', roles: [{ name: 'inbound', privilegeCount: 1 }] })
|
|
88
|
+
])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('★ another domain\'s requests are not read - even by a decider there', async () => {
|
|
92
|
+
await ask(kim)
|
|
93
|
+
const there = await assistant.pendingRequestsToDecide({ domain: other, user: otherAdmin })
|
|
94
|
+
expect(there.requests).toEqual([])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('★ someone who may not decide is refused - nothing is read', async () => {
|
|
98
|
+
await ask(kim)
|
|
99
|
+
expect(await assistant.pendingRequestsToDecide({ domain, user: worker })).toEqual({ refused: 'not-a-decider' })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('★ the caller\'s own requests are left out - they cannot decide them', async () => {
|
|
103
|
+
await ask(kim)
|
|
104
|
+
await ask(owner, 'query')
|
|
105
|
+
const answer = await assistant.pendingRequestsToDecide({ domain, user: owner })
|
|
106
|
+
expect(answer.requests.map((r: any) => r.requester)).toEqual(['kim'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('decided and expired requests are left out', async () => {
|
|
110
|
+
const decided = await ask(kim)
|
|
111
|
+
await access.decideAccessRequest({ requestId: decided.id, decision: 'DENY', reason: 'no' }, { state: { domain, user: park, afterCommit: [] } } as any)
|
|
112
|
+
const late = await ask(lee)
|
|
113
|
+
await getRepository(AccessRequest).update({ id: late.id }, { expiresAt: new Date(Date.now() - 1000) })
|
|
114
|
+
expect((await assistant.pendingRequestsToDecide({ domain, user: park })).requests).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('the declarations fit together', () => {
|
|
119
|
+
afterEach(() => clearToolRegistry())
|
|
120
|
+
|
|
121
|
+
it('the category registers, the mode names it and the decision target, the words exist', () => {
|
|
122
|
+
const category = assistant.accessToolCategory()
|
|
123
|
+
expect(() => registerToolCategory(category)).not.toThrow()
|
|
124
|
+
const mode = assistant.accessAssistantMode()
|
|
125
|
+
expect(mode.toolCategories).toEqual([category.name])
|
|
126
|
+
expect(mode.entryTargets).toEqual([targets.AUTH_ACCESS_DECISION])
|
|
127
|
+
expect(mode.subjects).toEqual([assistant.ACCESS_REQUESTS_SUBJECT])
|
|
128
|
+
for (const dict of [uiEn, uiKo] as Record<string, string>[])
|
|
129
|
+
for (const key of [mode.labelKey, mode.placeholderKey]) expect([key, typeof dict[key]]).toEqual([key, 'string'])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('★ requests handed over as the screen\'s context do not reach the model\'s prompt - the tool is how it reads them', async () => {
|
|
133
|
+
registerToolCategory(assistant.accessToolCategory())
|
|
134
|
+
const seen: any[] = []
|
|
135
|
+
const client = {
|
|
136
|
+
chatWithTools: async (messages: any, tools: any[], options: any) => {
|
|
137
|
+
seen.push({ messages, tools, options })
|
|
138
|
+
return { text: 'ok', toolCalls: [], stopReason: 'end_turn' }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await registryAssistantChat({
|
|
142
|
+
client,
|
|
143
|
+
messages: [{ role: 'user', content: 'sort out the access requests' }],
|
|
144
|
+
systemPrompt: assistant.accessAssistantMode().systemPrompt,
|
|
145
|
+
toolCategories: [assistant.AUTH_ACCESS_TOOLS],
|
|
146
|
+
hostContext: { pendingRequests: ['MARKER-kim-warehouse-mutation'] },
|
|
147
|
+
state: { domain, user: park },
|
|
148
|
+
toolAccess: async () => true
|
|
149
|
+
} as any)
|
|
150
|
+
|
|
151
|
+
expect(JSON.stringify(seen[0].options)).not.toContain('MARKER-kim')
|
|
152
|
+
expect(JSON.stringify(seen[0].messages)).not.toContain('MARKER-kim')
|
|
153
|
+
expect(seen[0].tools.map((t: any) => t.name)).toEqual(['listPendingAccessRequests'])
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The access request's AI 입력 declarations (ADR-0103 decision 4).
|
|
3
|
+
*
|
|
4
|
+
* The model drafts and a person presses; the card's check is the review the list screen asks. That last point is
|
|
5
|
+
* what this file holds above all: the card's findings for a decision equal `reviewDecision` for the same decision.
|
|
6
|
+
*
|
|
7
|
+
* The declarations are read from the compiled package so their check reaches the same entities as the database.
|
|
8
|
+
* The assistant's own validation is asked through its source file, which needs only `graphql`.
|
|
9
|
+
*/
|
|
10
|
+
import { parse } from 'graphql'
|
|
11
|
+
|
|
12
|
+
import { clearEntryTargets, declareEntryTarget } from '../../ai-assistant/server/service/data-entry/entry-target'
|
|
13
|
+
|
|
14
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
15
|
+
import { loadCompiled } from './compiled'
|
|
16
|
+
|
|
17
|
+
import uiEn from '../../auth-ui/translations/en.json'
|
|
18
|
+
import uiKo from '../../auth-ui/translations/ko.json'
|
|
19
|
+
|
|
20
|
+
const { Role } = loadCompiled('service/role/role')
|
|
21
|
+
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
22
|
+
const { User } = loadCompiled('service/user/user')
|
|
23
|
+
const access = loadCompiled('controllers/access-request')
|
|
24
|
+
const targets = loadCompiled('ai-entry/access-entry-targets')
|
|
25
|
+
|
|
26
|
+
const rootFields = (document: string) =>
|
|
27
|
+
((parse(document).definitions[0] as any).selectionSet.selections as any[]).map(one => one.name.value)
|
|
28
|
+
|
|
29
|
+
const row = (values: Record<string, unknown>, index = 0) => ({ index, values, sources: [], issues: [] })
|
|
30
|
+
|
|
31
|
+
describe('the declarations', () => {
|
|
32
|
+
afterEach(() => clearEntryTargets())
|
|
33
|
+
|
|
34
|
+
it('are accepted by the assistant as declared', () => {
|
|
35
|
+
for (const target of targets.accessEntryTargets()) expect(() => declareEntryTarget(target)).not.toThrow()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('★ the decision card writes through decideAccessRequest - the person presses it; nothing else is a door', () => {
|
|
39
|
+
const decision = targets.accessDecisionEntryTarget()
|
|
40
|
+
expect(rootFields(decision.mutation)).toEqual(['decideAccessRequest'])
|
|
41
|
+
expect(rootFields(decision.fields.find((f: any) => f.name === 'request').reference.query)).toEqual(['accessRequests'])
|
|
42
|
+
expect(rootFields(targets.accessRequestEntryTarget().mutation)).toEqual(['requestAccess'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('a denial carries no role, a grant carries its role id', () => {
|
|
46
|
+
expect(targets.decisionOf(row({ request: { id: 'q' }, decision: 'DENY', role: { id: 'r' }, reason: 'no' }))).toEqual({
|
|
47
|
+
requestId: 'q',
|
|
48
|
+
decision: 'DENY',
|
|
49
|
+
roleId: undefined,
|
|
50
|
+
reason: 'no'
|
|
51
|
+
})
|
|
52
|
+
expect(targets.decisionOf(row({ request: { id: 'q' }, decision: 'GRANT', role: { id: 'r' } })).roleId).toBe('r')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('a request is named by person or privilege; only pending ones; several stay several', () => {
|
|
56
|
+
const items = [
|
|
57
|
+
{ id: '1', state: 'PENDING', category: 'warehouse', privilege: 'mutation', requester: { name: '김민수' } },
|
|
58
|
+
{ id: '2', state: 'PENDING', category: 'board', privilege: 'query', requester: { name: '김민지' } },
|
|
59
|
+
{ id: '3', state: 'GRANTED', category: 'warehouse', privilege: 'mutation', requester: { name: '김민수' } }
|
|
60
|
+
]
|
|
61
|
+
expect(targets.requestsNamed(items, '김민수').map((r: any) => r.id)).toEqual(['1'])
|
|
62
|
+
expect(targets.requestsNamed(items, '김민').map((r: any) => r.id)).toEqual(['1', '2'])
|
|
63
|
+
expect(targets.requestsNamed(items, 'board:query').map((r: any) => r.label)).toEqual(['김민지 · board:query'])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('every word the card shows is in the screen words', () => {
|
|
67
|
+
const decision = targets.accessDecisionEntryTarget()
|
|
68
|
+
const keys = [
|
|
69
|
+
decision.labelKey,
|
|
70
|
+
targets.accessRequestEntryTarget().labelKey,
|
|
71
|
+
...[...decision.fields, ...targets.accessRequestEntryTarget().fields].map((f: any) => f.labelKey),
|
|
72
|
+
'access-request.decision.grant',
|
|
73
|
+
'access-request.decision.deny',
|
|
74
|
+
'access-request.issue.not-by-role'
|
|
75
|
+
]
|
|
76
|
+
for (const dict of [uiEn, uiKo] as Record<string, string>[]) for (const key of keys) expect([key, typeof dict[key]]).toEqual([key, 'string'])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('the card says what the screen says', () => {
|
|
81
|
+
let domain: any
|
|
82
|
+
let kim: any
|
|
83
|
+
let park: any
|
|
84
|
+
let inbound: any
|
|
85
|
+
let manager: any
|
|
86
|
+
|
|
87
|
+
beforeAll(async () => {
|
|
88
|
+
await openAuthDatabase()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
afterAll(async () => {
|
|
92
|
+
await closeAuthDatabase()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
await resetAuthDatabase()
|
|
97
|
+
const saved = await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })
|
|
98
|
+
domain = await getRepository(Domain).findOneBy({ id: saved.id })
|
|
99
|
+
const mutation = await getRepository(Privilege).save({ name: 'mutation', category: 'warehouse' })
|
|
100
|
+
const query = await getRepository(Privilege).save({ name: 'query', category: 'warehouse' })
|
|
101
|
+
const grant = await getRepository(Privilege).save({ name: 'mutation', category: 'user' })
|
|
102
|
+
inbound = await getRepository(Role).save({ name: 'inbound', domain, privileges: [mutation] })
|
|
103
|
+
manager = await getRepository(Role).save({ name: 'manager', domain, privileges: [mutation, query, grant] })
|
|
104
|
+
const admins = await getRepository(Role).save({ name: 'admins', domain, privileges: [grant] })
|
|
105
|
+
const make = (name: string, roles: any[]) =>
|
|
106
|
+
getRepository(User).save({ username: name, email: `${name}@example.com`, name, salt: User.generateSalt(), status: 'activated', domains: [domain], roles })
|
|
107
|
+
kim = await make('kim', [])
|
|
108
|
+
park = await make('park', [admins])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const state = (user: any) => ({ domain, user })
|
|
112
|
+
|
|
113
|
+
it('★ the card\'s findings are reviewDecision\'s, for grant and for deny', async () => {
|
|
114
|
+
const request = await access.requestAccess({ category: 'warehouse', privilege: 'mutation', reason: 'inbound' }, { state: { ...state(kim), afterCommit: [] } } as any)
|
|
115
|
+
const rows = [
|
|
116
|
+
row({ request: { id: request.id }, decision: 'GRANT', role: { id: manager.id } }, 0),
|
|
117
|
+
row({ request: { id: request.id }, decision: 'GRANT', role: { id: inbound.id } }, 1),
|
|
118
|
+
row({ request: { id: request.id }, decision: 'DENY', reason: '' }, 2)
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
const card = await targets.accessDecisionEntryTarget().check(rows, { state: state(park) })
|
|
122
|
+
|
|
123
|
+
for (const [index, one] of rows.entries()) {
|
|
124
|
+
const screen = await access.reviewDecision(targets.decisionOf(one), { state: state(park) } as any)
|
|
125
|
+
expect(card[index].map((issue: any) => [issue.code, issue.blocking])).toEqual(screen.map((issue: any) => [issue.code, issue.blocking]))
|
|
126
|
+
}
|
|
127
|
+
expect(card[0].map((issue: any) => issue.code)).toContain('role-grants-admin')
|
|
128
|
+
expect(card[2].map((issue: any) => issue.code)).toEqual(['deny-needs-reason'])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('★ the request card refuses what the door would - already held', async () => {
|
|
132
|
+
const lee = await getRepository(User).save({
|
|
133
|
+
username: 'lee', email: 'lee@example.com', name: 'lee', salt: User.generateSalt(), status: 'activated', domains: [domain], roles: [inbound]
|
|
134
|
+
})
|
|
135
|
+
const [issues] = await targets.accessRequestEntryTarget().check([row({ category: 'warehouse', privilege: 'mutation' })], { state: state(lee) })
|
|
136
|
+
expect(issues).toEqual([expect.objectContaining({ code: 'already-holds', blocking: true })])
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -100,6 +100,7 @@ describe('asking', () => {
|
|
|
100
100
|
expect(request).toMatchObject({ state: 'PENDING', category: 'warehouse', privilege: 'mutation', count: 1, reason: 'inbound from today' })
|
|
101
101
|
expect(context.notify).toHaveBeenCalledTimes(1)
|
|
102
102
|
expect([...context.notify.mock.calls[0][0].receivers].sort()).toEqual([owner.id, park.id].sort())
|
|
103
|
+
expect(context.notify.mock.calls[0][0].url).toBe(`access-requests?id=${request.id}`)
|
|
103
104
|
})
|
|
104
105
|
|
|
105
106
|
it('★ asking again adds to the pending one - no second row, nobody told again', async () => {
|
|
@@ -125,7 +126,7 @@ describe('asking', () => {
|
|
|
125
126
|
})
|
|
126
127
|
|
|
127
128
|
it('a privilege nobody declared is not asked for', async () => {
|
|
128
|
-
await expect(access.requestAccess({ category: 'nothing', privilege: 'here' }, contextOf(kim))).rejects.toMatchObject({
|
|
129
|
+
await expect(access.requestAccess({ category: 'nothing', privilege: 'here', reason: 'x' }, contextOf(kim))).rejects.toMatchObject({
|
|
129
130
|
code: 'access-request-unknown-privilege'
|
|
130
131
|
})
|
|
131
132
|
})
|
|
@@ -155,7 +156,9 @@ describe('deciding', () => {
|
|
|
155
156
|
|
|
156
157
|
expect(decided).toMatchObject({ state: 'GRANTED', grantedRole: { id: inbound.id } })
|
|
157
158
|
expect(await holds(kim, 'warehouse', 'mutation')).toBe(true)
|
|
158
|
-
expect(context.notify).toHaveBeenCalledWith(
|
|
159
|
+
expect(context.notify).toHaveBeenCalledWith(
|
|
160
|
+
expect.objectContaining({ receivers: [kim.id], title: 'access-request.notify.granted.title', url: 'my-access-requests' })
|
|
161
|
+
)
|
|
159
162
|
})
|
|
160
163
|
|
|
161
164
|
it('★ deny keeps the reason and tells the requester', async () => {
|
|
@@ -217,7 +220,7 @@ describe('deciding', () => {
|
|
|
217
220
|
|
|
218
221
|
it('★ a grant closes the same person\'s other requests it now answers', async () => {
|
|
219
222
|
const first = await access.requestAccess(ASK, contextOf(kim))
|
|
220
|
-
const second = await access.requestAccess({ category: 'warehouse', privilege: 'query' }, contextOf(kim))
|
|
223
|
+
const second = await access.requestAccess({ category: 'warehouse', privilege: 'query', reason: 'checking stock' }, contextOf(kim))
|
|
221
224
|
await access.decideAccessRequest({ requestId: first.id, decision: 'GRANT', roleId: manager.id }, contextOf(owner))
|
|
222
225
|
|
|
223
226
|
expect((await getRepository(AccessRequest).findOneBy({ id: second.id })).state).toBe('GRANTED')
|
|
@@ -234,3 +237,37 @@ describe('the review as the screen asks it', () => {
|
|
|
234
237
|
expect(issues.map((i: any) => i.code).sort()).toEqual(['decider-lacks-it', 'role-grants-admin', 'role-grants-more', 'smaller-role-exists'])
|
|
235
238
|
})
|
|
236
239
|
})
|
|
240
|
+
|
|
241
|
+
describe('asking is not a reflex (questions Q9)', () => {
|
|
242
|
+
it('★ no reason, no request - blank is no reason', async () => {
|
|
243
|
+
for (const reason of [undefined, '', ' ']) {
|
|
244
|
+
await expect(access.requestAccess({ ...ASK, reason }, contextOf(kim))).rejects.toMatchObject({ code: 'access-request-needs-reason' })
|
|
245
|
+
}
|
|
246
|
+
expect(await getRepository(AccessRequest).count()).toBe(0)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('★ denied lately: the same words are refused, new words are taken', async () => {
|
|
250
|
+
const first = await access.requestAccess(ASK, contextOf(kim))
|
|
251
|
+
await access.decideAccessRequest({ requestId: first.id, decision: 'DENY', reason: 'IT only' }, contextOf(park))
|
|
252
|
+
|
|
253
|
+
await expect(access.requestAccess(ASK, contextOf(kim))).rejects.toMatchObject({
|
|
254
|
+
code: 'access-request-same-reason-as-denied',
|
|
255
|
+
params: { reason: 'IT only' }
|
|
256
|
+
})
|
|
257
|
+
const again = await access.requestAccess({ ...ASK, reason: 'moved to the inbound team this week' }, contextOf(kim))
|
|
258
|
+
expect(again.state).toBe('PENDING')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('a denial older than seven days holds nothing against the same words', async () => {
|
|
262
|
+
const first = await access.requestAccess(ASK, contextOf(kim))
|
|
263
|
+
await access.decideAccessRequest({ requestId: first.id, decision: 'DENY', reason: 'not now' }, contextOf(park))
|
|
264
|
+
await getRepository(AccessRequest).update({ id: first.id }, { decidedAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) })
|
|
265
|
+
expect((await access.requestAccess(ASK, contextOf(kim))).state).toBe('PENDING')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('★ the popup can say how many people a request reaches - not who', async () => {
|
|
269
|
+
const { AccessRequestQuery } = loadCompiled('service/access-request/access-request-query')
|
|
270
|
+
expect(await new AccessRequestQuery().accessRequestDeciderCount(contextOf(kim))).toBe(2)
|
|
271
|
+
expect(await new AccessRequestQuery().accessRequestDeciderCount(contextOf(park))).toBe(1)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The access request under real concurrency - postgres only (`AUTH_TEST_PG`). sqlite holds one connection and
|
|
3
|
+
* cannot show two transactions racing (feedback: verify concurrency on postgres).
|
|
4
|
+
*
|
|
5
|
+
* Each call runs in its own transaction, as the `@transaction` door gives it.
|
|
6
|
+
*/
|
|
7
|
+
import { authDataSource, closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
8
|
+
import { loadCompiled } from './compiled'
|
|
9
|
+
|
|
10
|
+
const { Role } = loadCompiled('service/role/role')
|
|
11
|
+
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
12
|
+
const { User } = loadCompiled('service/user/user')
|
|
13
|
+
const { AccessRequest } = loadCompiled('service/access-request/access-request')
|
|
14
|
+
const access = loadCompiled('controllers/access-request')
|
|
15
|
+
|
|
16
|
+
const onPostgres = process.env.AUTH_TEST_PG ? describe : describe.skip
|
|
17
|
+
|
|
18
|
+
onPostgres('on postgres', () => {
|
|
19
|
+
let domain: any
|
|
20
|
+
let kim: any
|
|
21
|
+
let park: any
|
|
22
|
+
let owner: any
|
|
23
|
+
let inbound: any
|
|
24
|
+
|
|
25
|
+
const inTransaction = <T>(user: any, run: (context: any) => Promise<T>) =>
|
|
26
|
+
authDataSource().transaction(tx => run({ state: { domain, user, tx, afterCommit: [] }, t: (key: string) => key }))
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
await openAuthDatabase()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
await closeAuthDatabase()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
await resetAuthDatabase()
|
|
38
|
+
domain = await getRepository(Domain).findOneBy({ id: (await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })).id })
|
|
39
|
+
const mutation = await getRepository(Privilege).save({ name: 'mutation', category: 'warehouse' })
|
|
40
|
+
const grant = await getRepository(Privilege).save({ name: 'mutation', category: 'user' })
|
|
41
|
+
inbound = await getRepository(Role).save({ name: 'inbound', domain, privileges: [mutation] })
|
|
42
|
+
const admins = await getRepository(Role).save({ name: 'admins', domain, privileges: [grant] })
|
|
43
|
+
const make = (name: string, roles: any[] = []) =>
|
|
44
|
+
getRepository(User).save({ username: name, email: `${name}@example.com`, name, salt: User.generateSalt(), status: 'activated', domains: [domain], roles })
|
|
45
|
+
kim = await make('kim')
|
|
46
|
+
park = await make('park', [admins])
|
|
47
|
+
owner = await make('owner', [admins])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('★ two people deciding at once, each in a transaction - exactly one wins, and the loser leaves nothing', async () => {
|
|
51
|
+
const request = await inTransaction(kim, context => access.requestAccess({ category: 'warehouse', privilege: 'mutation', reason: 'inbound' }, context))
|
|
52
|
+
|
|
53
|
+
const results = await Promise.allSettled([
|
|
54
|
+
inTransaction(owner, context => access.decideAccessRequest({ requestId: request.id, decision: 'DENY', reason: 'no' }, context)),
|
|
55
|
+
inTransaction(park, context => access.decideAccessRequest({ requestId: request.id, decision: 'GRANT', roleId: inbound.id }, context))
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
expect(results.map(r => r.status).sort()).toEqual(['fulfilled', 'rejected'])
|
|
59
|
+
const stored = await getRepository(AccessRequest).findOne({ where: { id: request.id }, relations: ['grantedRole'] })
|
|
60
|
+
const holds = await User.hasPrivilege('mutation', 'warehouse', domain, kim)
|
|
61
|
+
/* The role is given exactly when the stored decision is a grant - a losing grant does not leave the role behind. */
|
|
62
|
+
expect(holds).toBe(stored.state === 'GRANTED')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('★ the same person asking twice at once - one pending request, not two', async () => {
|
|
66
|
+
await Promise.allSettled([
|
|
67
|
+
inTransaction(kim, context => access.requestAccess({ category: 'warehouse', privilege: 'mutation', reason: 'inbound' }, context)),
|
|
68
|
+
inTransaction(kim, context => access.requestAccess({ category: 'warehouse', privilege: 'mutation', reason: 'inbound' }, context))
|
|
69
|
+
])
|
|
70
|
+
expect(await getRepository(AccessRequest).count({ where: { state: 'PENDING' } })).toBe(1)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -312,3 +312,21 @@ describe('거절이 이유를 싣는다 (ADR-0103 결정 1)', () => {
|
|
|
312
312
|
expect((result.errors![0].extensions as any).because).toBe('address')
|
|
313
313
|
})
|
|
314
314
|
})
|
|
315
|
+
|
|
316
|
+
describe('거절이 무엇이 막혔는지도 싣는다 — 변경인가 조회인가 (질문지 Q9 · 4)', () => {
|
|
317
|
+
it('★ 조회가 막히면 operation: query', async () => {
|
|
318
|
+
const result = await run(buildSchema(), 'guardedBoard')
|
|
319
|
+
expect((result.errors![0].extensions as any).operation).toBe('query')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('★ 변경이 막히면 operation: mutation — 사람이 누른 일이다', async () => {
|
|
323
|
+
const schema = privilegeDirectiveResolver(
|
|
324
|
+
makeExecutableSchema({
|
|
325
|
+
typeDefs: [privilegeDirectiveTypeDefs, 'type Query { ok: String } type Mutation { saveBoard: String @privilege(category: "board", privilege: "mutation") }'],
|
|
326
|
+
resolvers: { Query: { ok: () => 'ok' }, Mutation: { saveBoard: () => 'saved' } }
|
|
327
|
+
})
|
|
328
|
+
)
|
|
329
|
+
const result = await graphql({ schema, source: 'mutation { saveBoard }', contextValue: { state: { domain: DOMAIN, user: USER } } })
|
|
330
|
+
expect((result.errors![0].extensions as any)).toMatchObject({ because: 'role', operation: 'mutation' })
|
|
331
|
+
})
|
|
332
|
+
})
|
package/translations/en.json
CHANGED
|
@@ -247,5 +247,7 @@
|
|
|
247
247
|
"access-request.notify.granted.title": "access granted",
|
|
248
248
|
"access-request.notify.granted.body": "you now have {privilege} (role: {role}).",
|
|
249
249
|
"access-request.notify.denied.title": "access request denied",
|
|
250
|
-
"access-request.notify.denied.body": "{privilege}: {reason}"
|
|
250
|
+
"access-request.notify.denied.body": "{privilege}: {reason}",
|
|
251
|
+
"error.access-request-needs-reason": "write why you need it.",
|
|
252
|
+
"error.access-request-same-reason-as-denied": "this was denied lately for the same reason ({reason}). write what has changed."
|
|
251
253
|
}
|
package/translations/ja.json
CHANGED
|
@@ -247,5 +247,7 @@
|
|
|
247
247
|
"access-request.notify.granted.title": "権限が付与されました",
|
|
248
248
|
"access-request.notify.granted.body": "{privilege} 権限が付与されました(ロール: {role})。",
|
|
249
249
|
"access-request.notify.denied.title": "権限リクエストが拒否されました",
|
|
250
|
-
"access-request.notify.denied.body": "{privilege}: {reason}"
|
|
250
|
+
"access-request.notify.denied.body": "{privilege}: {reason}",
|
|
251
|
+
"error.access-request-needs-reason": "必要な理由を書いてください。",
|
|
252
|
+
"error.access-request-same-reason-as-denied": "同じ理由で最近拒否されました({reason})。何が変わったかを書いてください。"
|
|
251
253
|
}
|
package/translations/ko.json
CHANGED
|
@@ -247,5 +247,7 @@
|
|
|
247
247
|
"access-request.notify.granted.title": "권한을 받았습니다",
|
|
248
248
|
"access-request.notify.granted.body": "{privilege} 권한을 받았습니다(역할: {role}).",
|
|
249
249
|
"access-request.notify.denied.title": "권한 요청이 거부되었습니다",
|
|
250
|
-
"access-request.notify.denied.body": "{privilege}: {reason}"
|
|
250
|
+
"access-request.notify.denied.body": "{privilege}: {reason}",
|
|
251
|
+
"error.access-request-needs-reason": "필요한 까닭을 적어 주세요.",
|
|
252
|
+
"error.access-request-same-reason-as-denied": "같은 까닭으로 최근에 거부되었습니다({reason}). 무엇이 달라졌는지 적어 주세요."
|
|
251
253
|
}
|
package/translations/ms.json
CHANGED
|
@@ -247,5 +247,7 @@
|
|
|
247
247
|
"access-request.notify.granted.title": "akses diberikan",
|
|
248
248
|
"access-request.notify.granted.body": "anda kini mempunyai {privilege} (peranan: {role}).",
|
|
249
249
|
"access-request.notify.denied.title": "permintaan akses ditolak",
|
|
250
|
-
"access-request.notify.denied.body": "{privilege}: {reason}"
|
|
250
|
+
"access-request.notify.denied.body": "{privilege}: {reason}",
|
|
251
|
+
"error.access-request-needs-reason": "tulis sebab anda memerlukannya.",
|
|
252
|
+
"error.access-request-same-reason-as-denied": "ini ditolak baru-baru ini atas sebab yang sama ({reason}). tulis apa yang telah berubah."
|
|
251
253
|
}
|
package/translations/zh.json
CHANGED
|
@@ -247,5 +247,7 @@
|
|
|
247
247
|
"access-request.notify.granted.title": "权限已授予",
|
|
248
248
|
"access-request.notify.granted.body": "您已获得 {privilege} 权限(角色: {role})。",
|
|
249
249
|
"access-request.notify.denied.title": "权限请求被拒绝",
|
|
250
|
-
"access-request.notify.denied.body": "{privilege}: {reason}"
|
|
250
|
+
"access-request.notify.denied.body": "{privilege}: {reason}",
|
|
251
|
+
"error.access-request-needs-reason": "请填写需要的理由。",
|
|
252
|
+
"error.access-request-same-reason-as-denied": "最近以相同理由被拒绝({reason})。请写明有何变化。"
|
|
251
253
|
}
|