@things-factory/auth-base 10.1.47 → 10.1.106
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-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/controllers/access-request.d.ts +53 -0
- package/dist-server/controllers/access-request.js +333 -0
- package/dist-server/controllers/access-request.js.map +1 -0
- package/dist-server/service/access-request/access-request-mutation.d.ts +17 -0
- package/dist-server/service/access-request/access-request-mutation.js +65 -0
- package/dist-server/service/access-request/access-request-mutation.js.map +1 -0
- package/dist-server/service/access-request/access-request-query.d.ts +11 -0
- package/dist-server/service/access-request/access-request-query.js +85 -0
- package/dist-server/service/access-request/access-request-query.js.map +1 -0
- package/dist-server/service/access-request/access-request-types.d.ts +32 -0
- package/dist-server/service/access-request/access-request-types.js +115 -0
- package/dist-server/service/access-request/access-request-types.js.map +1 -0
- package/dist-server/service/access-request/access-request.d.ts +33 -0
- package/dist-server/service/access-request/access-request.js +160 -0
- package/dist-server/service/access-request/access-request.js.map +1 -0
- package/dist-server/service/access-request/index.d.ts +5 -0
- package/dist-server/service/access-request/index.js +9 -0
- package/dist-server/service/access-request/index.js.map +1 -0
- package/dist-server/service/index.d.ts +3 -1
- package/dist-server/service/index.js +42 -37
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/open-fields.js +4 -1
- package/dist-server/service/open-fields.js.map +1 -1
- package/dist-server/service/privilege/privilege-directive.js +6 -6
- package/dist-server/service/privilege/privilege-directive.js.map +1 -1
- package/dist-server/service/privilege/privilege-rejection.d.ts +8 -2
- package/dist-server/service/privilege/privilege-rejection.js +27 -6
- package/dist-server/service/privilege/privilege-rejection.js.map +1 -1
- package/dist-server/service/user/user-mutation.js +5 -4
- package/dist-server/service/user/user-mutation.js.map +1 -1
- package/dist-server/service/user/user-query.js +5 -17
- package/dist-server/service/user/user-query.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/access-request-review.d.ts +69 -0
- package/dist-server/utils/access-request-review.js +119 -0
- package/dist-server/utils/access-request-review.js.map +1 -0
- package/dist-server/utils/assignable-roles.d.ts +23 -0
- package/dist-server/utils/assignable-roles.js +89 -0
- package/dist-server/utils/assignable-roles.js.map +1 -0
- package/dist-server/utils/check-permission.d.ts +29 -2
- package/dist-server/utils/check-permission.js +48 -14
- package/dist-server/utils/check-permission.js.map +1 -1
- package/package.json +4 -4
- package/tests/access-request-db.test.ts +236 -0
- package/tests/access-request-review.test.ts +155 -0
- package/tests/assignable-roles-db.test.ts +141 -0
- package/tests/permission-gate.test.ts +5 -7
- package/tests/privilege-directive.test.ts +21 -0
- package/tests/privilege-rejection.test.ts +32 -0
- package/tests/refusal-cause.test.ts +80 -0
- package/translations/en.json +26 -1
- package/translations/ja.json +26 -1
- package/translations/ko.json +26 -1
- package/translations/ms.json +26 -1
- package/translations/zh.json +26 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking, deciding, telling - the access request end to end on the database (ADR-0103).
|
|
3
|
+
*
|
|
4
|
+
* The controller is called with a request state as the doors receive it. `notify` is a spy: the notification
|
|
5
|
+
* module is optional and the row is the record.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
9
|
+
import { loadCompiled } from './compiled'
|
|
10
|
+
|
|
11
|
+
const { Role } = loadCompiled('service/role/role')
|
|
12
|
+
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
13
|
+
const { User } = loadCompiled('service/user/user')
|
|
14
|
+
const { DomainOwner } = loadCompiled('service/domain-owner/domain-owner')
|
|
15
|
+
const { AccessRequest } = loadCompiled('service/access-request/access-request')
|
|
16
|
+
const access = loadCompiled('controllers/access-request')
|
|
17
|
+
|
|
18
|
+
let domain: any
|
|
19
|
+
let other: any
|
|
20
|
+
let warehouseMutation: any
|
|
21
|
+
let warehouseQuery: any
|
|
22
|
+
let userMutation: any
|
|
23
|
+
let inbound: any
|
|
24
|
+
let manager: any
|
|
25
|
+
let admins: any
|
|
26
|
+
let kim: any
|
|
27
|
+
let park: any
|
|
28
|
+
let owner: any
|
|
29
|
+
|
|
30
|
+
async function makeUser(name: string, domains: any[], roles: any[] = [], over: any = {}) {
|
|
31
|
+
const tag = Math.random().toString(36).slice(2, 8)
|
|
32
|
+
return await getRepository(User).save({
|
|
33
|
+
username: `${name}-${tag}`,
|
|
34
|
+
email: `${name}-${tag}@example.com`,
|
|
35
|
+
name,
|
|
36
|
+
salt: User.generateSalt(),
|
|
37
|
+
status: 'activated',
|
|
38
|
+
domains,
|
|
39
|
+
roles,
|
|
40
|
+
...over
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function contextOf(user: any, over: any = {}) {
|
|
45
|
+
const notify = jest.fn().mockResolvedValue(undefined)
|
|
46
|
+
const afterCommit: any[] = []
|
|
47
|
+
return {
|
|
48
|
+
state: { domain, user, afterCommit, notify, ...over },
|
|
49
|
+
t: (key: string) => key,
|
|
50
|
+
afterCommit,
|
|
51
|
+
notify
|
|
52
|
+
} as any
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function commit(context: any) {
|
|
56
|
+
for (const step of context.afterCommit) await step.run()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function holds(user: any, category: string, privilege: string) {
|
|
60
|
+
return await User.hasPrivilege(privilege, category, domain, user)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
await openAuthDatabase()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
afterAll(async () => {
|
|
68
|
+
await closeAuthDatabase()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
await resetAuthDatabase()
|
|
73
|
+
|
|
74
|
+
const saved = await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })
|
|
75
|
+
domain = await getRepository(Domain).findOneBy({ id: saved.id })
|
|
76
|
+
other = await getRepository(Domain).save({ name: 'Other', subdomain: 'other' })
|
|
77
|
+
|
|
78
|
+
warehouseMutation = await getRepository(Privilege).save({ name: 'mutation', category: 'warehouse' })
|
|
79
|
+
warehouseQuery = await getRepository(Privilege).save({ name: 'query', category: 'warehouse' })
|
|
80
|
+
userMutation = await getRepository(Privilege).save({ name: 'mutation', category: 'user' })
|
|
81
|
+
|
|
82
|
+
inbound = await getRepository(Role).save({ name: 'inbound', domain, privileges: [warehouseMutation] })
|
|
83
|
+
manager = await getRepository(Role).save({ name: 'manager', domain, privileges: [warehouseMutation, warehouseQuery, userMutation] })
|
|
84
|
+
admins = await getRepository(Role).save({ name: 'admins', domain, privileges: [userMutation, warehouseQuery] })
|
|
85
|
+
|
|
86
|
+
kim = await makeUser('kim', [domain])
|
|
87
|
+
park = await makeUser('park', [domain], [admins])
|
|
88
|
+
owner = await makeUser('owner', [domain])
|
|
89
|
+
await getRepository(DomainOwner).save({ domain, user: owner })
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const ASK = { category: 'warehouse', privilege: 'mutation', operationName: 'updateInboundPlan', reason: 'inbound from today' }
|
|
93
|
+
|
|
94
|
+
describe('asking', () => {
|
|
95
|
+
it('★ a request is a row - pending, fourteen days, and the people who decide are told once', async () => {
|
|
96
|
+
const context = contextOf(kim)
|
|
97
|
+
const request = await access.requestAccess(ASK, context)
|
|
98
|
+
await commit(context)
|
|
99
|
+
|
|
100
|
+
expect(request).toMatchObject({ state: 'PENDING', category: 'warehouse', privilege: 'mutation', count: 1, reason: 'inbound from today' })
|
|
101
|
+
expect(context.notify).toHaveBeenCalledTimes(1)
|
|
102
|
+
expect([...context.notify.mock.calls[0][0].receivers].sort()).toEqual([owner.id, park.id].sort())
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('★ asking again adds to the pending one - no second row, nobody told again', async () => {
|
|
106
|
+
await access.requestAccess(ASK, contextOf(kim))
|
|
107
|
+
const again = contextOf(kim)
|
|
108
|
+
const merged = await access.requestAccess({ ...ASK, reason: 'still needed' }, again)
|
|
109
|
+
await commit(again)
|
|
110
|
+
|
|
111
|
+
expect(merged.count).toBe(2)
|
|
112
|
+
expect(merged.reason).toBe('still needed')
|
|
113
|
+
expect(await getRepository(AccessRequest).count()).toBe(1)
|
|
114
|
+
expect(again.notify).not.toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('★ a privilege the caller holds is not asked for', async () => {
|
|
118
|
+
const kimWithRole = await makeUser('lee', [domain], [inbound])
|
|
119
|
+
await expect(access.requestAccess(ASK, contextOf(kimWithRole))).rejects.toMatchObject({ code: 'access-request-already-holds' })
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('★ refused by the license, not by a role - not taken, whatever the screen sent', async () => {
|
|
123
|
+
const context = contextOf(kim, { prohibitedPrivileges: [{ category: 'warehouse', privilege: 'mutation', by: 'license' }] })
|
|
124
|
+
await expect(access.requestAccess(ASK, context)).rejects.toMatchObject({ code: 'access-request-not-by-role', params: { because: 'license' } })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('a privilege nobody declared is not asked for', async () => {
|
|
128
|
+
await expect(access.requestAccess({ category: 'nothing', privilege: 'here' }, contextOf(kim))).rejects.toMatchObject({
|
|
129
|
+
code: 'access-request-unknown-privilege'
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('the requester withdraws their own, and nobody else can', async () => {
|
|
134
|
+
const request = await access.requestAccess(ASK, contextOf(kim))
|
|
135
|
+
await expect(access.withdrawAccessRequest(request.id, contextOf(park))).rejects.toMatchObject({ code: 'access-request-not-withdrawable' })
|
|
136
|
+
expect((await access.withdrawAccessRequest(request.id, contextOf(kim))).state).toBe('WITHDRAWN')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('who decides', () => {
|
|
141
|
+
it('★ owners and holders of user:mutation in this domain, never the requester', async () => {
|
|
142
|
+
const outsider = await makeUser('outsider', [other], [admins])
|
|
143
|
+
const ids = await access.findDeciderIds(domain, park.id)
|
|
144
|
+
expect(ids).toEqual([owner.id].sort())
|
|
145
|
+
expect(ids).not.toContain(outsider.id)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('deciding', () => {
|
|
150
|
+
it('★ grant puts the requester into the role - they pass the gate on the next request', async () => {
|
|
151
|
+
const request = await access.requestAccess(ASK, contextOf(kim))
|
|
152
|
+
const context = contextOf(park)
|
|
153
|
+
const decided = await access.decideAccessRequest({ requestId: request.id, decision: 'GRANT', roleId: inbound.id }, context)
|
|
154
|
+
await commit(context)
|
|
155
|
+
|
|
156
|
+
expect(decided).toMatchObject({ state: 'GRANTED', grantedRole: { id: inbound.id } })
|
|
157
|
+
expect(await holds(kim, 'warehouse', 'mutation')).toBe(true)
|
|
158
|
+
expect(context.notify).toHaveBeenCalledWith(expect.objectContaining({ receivers: [kim.id], title: 'access-request.notify.granted.title' }))
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('★ deny keeps the reason and tells the requester', async () => {
|
|
162
|
+
const request = await access.requestAccess(ASK, contextOf(kim))
|
|
163
|
+
const context = contextOf(park)
|
|
164
|
+
const decided = await access.decideAccessRequest({ requestId: request.id, decision: 'DENY', reason: 'IT only' }, context)
|
|
165
|
+
await commit(context)
|
|
166
|
+
|
|
167
|
+
expect(decided).toMatchObject({ state: 'DENIED', decisionReason: 'IT only' })
|
|
168
|
+
expect(await holds(kim, 'warehouse', 'mutation')).toBe(false)
|
|
169
|
+
expect(context.notify).toHaveBeenCalledWith(expect.objectContaining({ title: 'access-request.notify.denied.title' }))
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('★ the second decision is refused - the first one stands', async () => {
|
|
173
|
+
const request = await access.requestAccess(ASK, contextOf(kim))
|
|
174
|
+
await access.decideAccessRequest({ requestId: request.id, decision: 'DENY', reason: 'no' }, contextOf(owner))
|
|
175
|
+
await expect(access.decideAccessRequest({ requestId: request.id, decision: 'GRANT', roleId: inbound.id }, contextOf(park))).rejects.toMatchObject({
|
|
176
|
+
code: 'access-decision-request-not-pending'
|
|
177
|
+
})
|
|
178
|
+
expect(await holds(kim, 'warehouse', 'mutation')).toBe(false)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('★ two people deciding at once - exactly one wins (both reviews read PENDING before either writes)', async () => {
|
|
182
|
+
const request = await access.requestAccess(ASK, contextOf(kim))
|
|
183
|
+
const results = await Promise.allSettled([
|
|
184
|
+
access.decideAccessRequest({ requestId: request.id, decision: 'DENY', reason: 'no' }, contextOf(owner)),
|
|
185
|
+
access.decideAccessRequest({ requestId: request.id, decision: 'GRANT', roleId: inbound.id }, contextOf(park))
|
|
186
|
+
])
|
|
187
|
+
|
|
188
|
+
expect(results.map(r => r.status).sort()).toEqual(['fulfilled', 'rejected'])
|
|
189
|
+
const lost = results.find(r => r.status === 'rejected') as PromiseRejectedResult
|
|
190
|
+
expect(lost.reason).toMatchObject({ code: 'access-decision-request-not-pending' })
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('★ the review blocks inside the door too - a role without the privilege is refused', async () => {
|
|
194
|
+
const request = await access.requestAccess(ASK, contextOf(kim))
|
|
195
|
+
await expect(access.decideAccessRequest({ requestId: request.id, decision: 'GRANT', roleId: admins.id }, contextOf(park))).rejects.toMatchObject({
|
|
196
|
+
code: 'access-decision-role-does-not-grant'
|
|
197
|
+
})
|
|
198
|
+
expect((await getRepository(AccessRequest).findOneBy({ id: request.id })).state).toBe('PENDING')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('★ another tenant\'s role is refused - it is not this domain\'s to give', async () => {
|
|
202
|
+
const foreign = await getRepository(Role).save({ name: 'foreign', domain: other, privileges: [warehouseMutation] })
|
|
203
|
+
const request = await access.requestAccess(ASK, contextOf(kim))
|
|
204
|
+
await expect(access.decideAccessRequest({ requestId: request.id, decision: 'GRANT', roleId: foreign.id }, contextOf(park))).rejects.toMatchObject({
|
|
205
|
+
code: 'access-decision-role-not-assignable'
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('a request of another domain is not found from this one', async () => {
|
|
210
|
+
const request = await access.requestAccess(ASK, contextOf(kim))
|
|
211
|
+
const stranger = await makeUser('stranger', [other], [admins])
|
|
212
|
+
const fromThere = contextOf(stranger, { domain: await getRepository(Domain).findOneBy({ id: other.id }) })
|
|
213
|
+
await expect(access.decideAccessRequest({ requestId: request.id, decision: 'DENY', reason: 'x' }, fromThere)).rejects.toMatchObject({
|
|
214
|
+
code: 'access-decision-access-request-not-found'
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('★ a grant closes the same person\'s other requests it now answers', async () => {
|
|
219
|
+
const first = await access.requestAccess(ASK, contextOf(kim))
|
|
220
|
+
const second = await access.requestAccess({ category: 'warehouse', privilege: 'query' }, contextOf(kim))
|
|
221
|
+
await access.decideAccessRequest({ requestId: first.id, decision: 'GRANT', roleId: manager.id }, contextOf(owner))
|
|
222
|
+
|
|
223
|
+
expect((await getRepository(AccessRequest).findOneBy({ id: second.id })).state).toBe('GRANTED')
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('the review as the screen asks it', () => {
|
|
228
|
+
it('★ candidates smallest first, and the review of a big role names what else it gives', async () => {
|
|
229
|
+
const request = await access.requestAccess(ASK, contextOf(kim))
|
|
230
|
+
const candidates = await access.rolesGranting('warehouse', 'mutation', domain)
|
|
231
|
+
expect(candidates.map((c: any) => c.name)).toEqual(['inbound', 'manager'])
|
|
232
|
+
|
|
233
|
+
const issues = await access.reviewDecision({ requestId: request.id, decision: 'GRANT', roleId: manager.id }, contextOf(park))
|
|
234
|
+
expect(issues.map((i: any) => i.code).sort()).toEqual(['decider-lacks-it', 'role-grants-admin', 'role-grants-more', 'smaller-role-exists'])
|
|
235
|
+
})
|
|
236
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The access decision review - pure (ADR-0103, docs/design/access-request.md §6-1).
|
|
3
|
+
*
|
|
4
|
+
* The list screen, the AI 입력 draft card and the decision door all read this one function, so what it says
|
|
5
|
+
* is what every one of them says. Each code is held here on its own.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
effectiveState,
|
|
10
|
+
expiryFrom,
|
|
11
|
+
isBlocked,
|
|
12
|
+
reviewAccessDecision,
|
|
13
|
+
smallestRole,
|
|
14
|
+
type DecisionFacts
|
|
15
|
+
} from '../server/utils/access-request-review.js'
|
|
16
|
+
|
|
17
|
+
const NOW = new Date('2026-10-02T09:00:00Z')
|
|
18
|
+
const ASKED = { category: 'warehouse', privilege: 'mutation' }
|
|
19
|
+
const SEE = { category: 'warehouse', privilege: 'query' }
|
|
20
|
+
const GRANT_ROLES = { category: 'user', privilege: 'mutation' }
|
|
21
|
+
|
|
22
|
+
function facts(over: Partial<DecisionFacts> = {}): DecisionFacts {
|
|
23
|
+
return {
|
|
24
|
+
request: { state: 'PENDING', requesterId: 'kim', ...ASKED, expiresAt: expiryFrom(NOW) },
|
|
25
|
+
deciderId: 'park',
|
|
26
|
+
decision: 'GRANT',
|
|
27
|
+
reason: null,
|
|
28
|
+
role: { id: 'r-inbound', name: 'inbound', privileges: [ASKED] },
|
|
29
|
+
requester: { active: true, inDomain: true },
|
|
30
|
+
requesterHoldsIt: false,
|
|
31
|
+
candidates: [{ id: 'r-inbound', name: 'inbound', privilegeCount: 1 }],
|
|
32
|
+
deciderPrivileges: [ASKED, SEE, GRANT_ROLES],
|
|
33
|
+
deciderIsOwner: false,
|
|
34
|
+
deniedBefore: null,
|
|
35
|
+
now: NOW,
|
|
36
|
+
...over
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const codes = (f: DecisionFacts) => reviewAccessDecision(f).map(issue => `${issue.blocking ? '⛔' : '⚠'}${issue.code}`)
|
|
41
|
+
|
|
42
|
+
describe('a clean grant', () => {
|
|
43
|
+
it('★ the smallest role that holds exactly what was asked passes with nothing to say', () => {
|
|
44
|
+
expect(reviewAccessDecision(facts())).toEqual([])
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('what keeps a decision from being made', () => {
|
|
49
|
+
it('★ no such request', () => {
|
|
50
|
+
expect(codes(facts({ request: null }))).toEqual(['⛔access-request-not-found'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('★ decided, withdrawn or expired already - another person may have decided a moment ago', () => {
|
|
54
|
+
for (const state of ['GRANTED', 'DENIED', 'WITHDRAWN', 'EXPIRED'] as const) {
|
|
55
|
+
expect(codes(facts({ request: { ...facts().request!, state } }))).toContain('⛔request-not-pending')
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('★ a pending request past its time is expired whatever the column says', () => {
|
|
60
|
+
const late = facts({ request: { ...facts().request!, expiresAt: new Date(NOW.getTime() - 1) } })
|
|
61
|
+
expect(codes(late)).toContain('⛔request-not-pending')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('★ the decider is the requester', () => {
|
|
65
|
+
expect(codes(facts({ deciderId: 'kim' }))).toContain('⛔own-request')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('★ the requester is locked, inactive or gone from the domain', () => {
|
|
69
|
+
expect(codes(facts({ requester: { active: false, inDomain: true } }))).toContain('⛔requester-unavailable')
|
|
70
|
+
expect(codes(facts({ requester: { active: true, inDomain: false } }))).toContain('⛔requester-unavailable')
|
|
71
|
+
expect(codes(facts({ requester: null }))).toContain('⛔requester-unavailable')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('★ a denial without a reason', () => {
|
|
75
|
+
expect(codes(facts({ decision: 'DENY', reason: ' ', role: null }))).toEqual(['⛔deny-needs-reason'])
|
|
76
|
+
expect(codes(facts({ decision: 'DENY', reason: 'IT only', role: null }))).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('★ the requester holds it already', () => {
|
|
80
|
+
expect(codes(facts({ requesterHoldsIt: true }))).toContain('⛔already-holds')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('★ no role chosen, or one this domain cannot assign', () => {
|
|
84
|
+
expect(codes(facts({ role: null }))).toEqual(['⛔role-required'])
|
|
85
|
+
expect(codes(facts({ role: null, roleNamedButNotAssignable: true }))).toEqual(['⛔role-not-assignable'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('★ the role does not hold the asked privilege', () => {
|
|
89
|
+
expect(codes(facts({ role: { id: 'r-viewer', name: 'viewer', privileges: [SEE] } }))).toEqual(['⛔role-does-not-grant'])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('what the person deciding is told', () => {
|
|
94
|
+
const manager = { id: 'r-manager', name: 'manager', privileges: [ASKED, SEE, GRANT_ROLES] }
|
|
95
|
+
const candidates = [
|
|
96
|
+
{ id: 'r-inbound', name: 'inbound', privilegeCount: 1 },
|
|
97
|
+
{ id: 'r-manager', name: 'manager', privilegeCount: 3 }
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
it('★ the role gives more than was asked - named', () => {
|
|
101
|
+
const issues = reviewAccessDecision(facts({ role: manager, candidates }))
|
|
102
|
+
expect(issues.find(i => i.code === 'role-grants-more')).toMatchObject({
|
|
103
|
+
blocking: false,
|
|
104
|
+
params: { count: 2, privileges: 'warehouse:query, user:mutation' }
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('★ the role hands on the power to give roles', () => {
|
|
109
|
+
expect(codes(facts({ role: manager, candidates }))).toContain('⚠role-grants-admin')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('asking for the role-giving power itself is not warned as a side effect', () => {
|
|
113
|
+
const asked = facts({
|
|
114
|
+
request: { ...facts().request!, ...GRANT_ROLES },
|
|
115
|
+
role: { id: 'r-admin', name: 'admin', privileges: [GRANT_ROLES] },
|
|
116
|
+
candidates: [{ id: 'r-admin', name: 'admin', privilegeCount: 1 }]
|
|
117
|
+
})
|
|
118
|
+
expect(codes(asked)).not.toContain('⚠role-grants-admin')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('★ a smaller role grants it too - its name', () => {
|
|
122
|
+
const issues = reviewAccessDecision(facts({ role: manager, candidates }))
|
|
123
|
+
expect(issues.find(i => i.code === 'smaller-role-exists')).toMatchObject({ params: { role: 'inbound', count: 1 } })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('★ the role gives what the decider does not have - told, not refused (Q6)', () => {
|
|
127
|
+
const issues = reviewAccessDecision(facts({ role: manager, candidates, deciderPrivileges: [ASKED] }))
|
|
128
|
+
expect(issues.find(i => i.code === 'decider-lacks-it')).toMatchObject({ blocking: false, params: { privileges: 'warehouse:query, user:mutation' } })
|
|
129
|
+
expect(isBlocked(issues)).toBe(false)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('an owner is not told they lack anything', () => {
|
|
133
|
+
expect(codes(facts({ role: manager, candidates, deciderPrivileges: [], deciderIsOwner: true }))).not.toContain('⚠decider-lacks-it')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('★ denied before - by whom, when, why', () => {
|
|
137
|
+
const issues = reviewAccessDecision(facts({ deniedBefore: { by: 'Lee', at: '2026-09-01T00:00:00Z', reason: 'IT only' } }))
|
|
138
|
+
expect(issues).toEqual([
|
|
139
|
+
{ code: 'denied-before', blocking: false, field: 'request', params: { by: 'Lee', at: '2026-09-01T00:00:00.000Z', reason: 'IT only' } }
|
|
140
|
+
])
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('helpers', () => {
|
|
145
|
+
it('the smallest role, ties by name - the same answer every time', () => {
|
|
146
|
+
expect(smallestRole([{ name: 'b', privilegeCount: 2 }, { name: 'a', privilegeCount: 2 }, { name: 'c', privilegeCount: 5 }])?.name).toBe('a')
|
|
147
|
+
expect(smallestRole([])).toBeUndefined()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('a request waits fourteen days', () => {
|
|
151
|
+
expect(expiryFrom(NOW).toISOString()).toBe('2026-10-16T09:00:00.000Z')
|
|
152
|
+
expect(effectiveState('PENDING', null, NOW)).toBe('PENDING')
|
|
153
|
+
expect(effectiveState('GRANTED', new Date(0), NOW)).toBe('GRANTED')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Putting a person into roles - one function, the domain's scope, ids found again (ADR-0103 decision 2).
|
|
3
|
+
*
|
|
4
|
+
* `updateUserRoles` concatenated the client's `selectedRoles` onto the user as sent and removed every held role
|
|
5
|
+
* whose id was in `availableRoles`. A role of another tenant went in; another tenant's role id in
|
|
6
|
+
* `availableRoles` took that role away from the person in the other domain. The access request grants through
|
|
7
|
+
* the same function, so both doors are held here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
11
|
+
import { loadCompiled } from './compiled'
|
|
12
|
+
|
|
13
|
+
const { Role } = loadCompiled('service/role/role')
|
|
14
|
+
const { GrantedRole } = loadCompiled('service/granted-role/granted-role')
|
|
15
|
+
const { User } = loadCompiled('service/user/user')
|
|
16
|
+
const { UserMutation } = loadCompiled('service/user/user-mutation')
|
|
17
|
+
const { assignRoles } = loadCompiled('utils/assignable-roles')
|
|
18
|
+
|
|
19
|
+
let parent: any
|
|
20
|
+
let child: any
|
|
21
|
+
let stranger: any
|
|
22
|
+
|
|
23
|
+
async function loadDomain(id: string) {
|
|
24
|
+
return await getRepository(Domain).findOneBy({ id })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function makeUser(domains: any[], roles: any[]) {
|
|
28
|
+
const tag = Math.random().toString(36).slice(2, 8)
|
|
29
|
+
return await getRepository(User).save({
|
|
30
|
+
username: `u-${tag}`,
|
|
31
|
+
email: `u-${tag}@example.com`,
|
|
32
|
+
name: 'Kim',
|
|
33
|
+
salt: User.generateSalt(),
|
|
34
|
+
status: 'activated',
|
|
35
|
+
domains,
|
|
36
|
+
roles
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function heldRoleNames(user: any) {
|
|
41
|
+
const read = await getRepository(User).findOne({ where: { id: user.id }, relations: ['roles'] })
|
|
42
|
+
return read.roles.map((role: any) => role.name).sort()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function updateUserRoles(user: any, available: any[], selected: any[], domain: any) {
|
|
46
|
+
return new UserMutation().updateUserRoles(
|
|
47
|
+
user.username,
|
|
48
|
+
available.map(role => ({ id: role.id })),
|
|
49
|
+
selected.map(role => ({ id: role.id })),
|
|
50
|
+
{ state: { domain }, t: (key: string) => key } as any
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
await openAuthDatabase()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterAll(async () => {
|
|
59
|
+
await closeAuthDatabase()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
beforeEach(async () => {
|
|
63
|
+
await resetAuthDatabase()
|
|
64
|
+
|
|
65
|
+
const domainRepository = getRepository(Domain)
|
|
66
|
+
const savedParent = await domainRepository.save({ name: 'HQ', subdomain: 'hq' })
|
|
67
|
+
const savedChild = await domainRepository.save({ name: 'Plant A', subdomain: 'plant-a', parent: savedParent })
|
|
68
|
+
const savedStranger = await domainRepository.save({ name: 'Other', subdomain: 'other' })
|
|
69
|
+
|
|
70
|
+
parent = await loadDomain(savedParent.id)
|
|
71
|
+
child = await loadDomain(savedChild.id)
|
|
72
|
+
stranger = await loadDomain(savedStranger.id)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('what a domain can put a person into', () => {
|
|
76
|
+
it('its own role, its parent role and a role granted to it - all three', async () => {
|
|
77
|
+
const own = await getRepository(Role).save({ name: 'operator', domain: child })
|
|
78
|
+
const inherited = await getRepository(Role).save({ name: 'viewer', domain: parent })
|
|
79
|
+
const granted = await getRepository(Role).save({ name: 'partner', domain: stranger })
|
|
80
|
+
await getRepository(GrantedRole).save({ role: granted, domain: child })
|
|
81
|
+
const user = await makeUser([child], [])
|
|
82
|
+
|
|
83
|
+
const { added } = await assignRoles(user, { add: [own.id, inherited.id, granted.id] }, child)
|
|
84
|
+
|
|
85
|
+
expect(added.map((role: any) => role.name).sort()).toEqual(['operator', 'partner', 'viewer'])
|
|
86
|
+
expect(await heldRoleNames(user)).toEqual(['operator', 'partner', 'viewer'])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('★ another tenant\'s role is refused by name, and nothing is saved', async () => {
|
|
90
|
+
const own = await getRepository(Role).save({ name: 'operator', domain: child })
|
|
91
|
+
const foreign = await getRepository(Role).save({ name: 'auditor', domain: stranger })
|
|
92
|
+
const user = await makeUser([child], [])
|
|
93
|
+
|
|
94
|
+
await expect(assignRoles(user, { add: [own.id, foreign.id] }, child)).rejects.toMatchObject({
|
|
95
|
+
code: 'role-not-assignable-here',
|
|
96
|
+
params: { roles: foreign.id }
|
|
97
|
+
})
|
|
98
|
+
expect(await heldRoleNames(user)).toEqual([])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('a role already held is not added twice and is not reported as added', async () => {
|
|
102
|
+
const own = await getRepository(Role).save({ name: 'operator', domain: child })
|
|
103
|
+
const user = await makeUser([child], [own])
|
|
104
|
+
|
|
105
|
+
const { added } = await assignRoles(user, { add: [own.id] }, child)
|
|
106
|
+
|
|
107
|
+
expect(added).toEqual([])
|
|
108
|
+
expect(await heldRoleNames(user)).toEqual(['operator'])
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('updateUserRoles goes through the same function', () => {
|
|
113
|
+
it('★ a foreign role in selectedRoles is refused - it used to be attached as sent', async () => {
|
|
114
|
+
const foreign = await getRepository(Role).save({ name: 'auditor', domain: stranger })
|
|
115
|
+
const user = await makeUser([child], [])
|
|
116
|
+
|
|
117
|
+
/* The method is called directly, so the Refusal arrives before SpeakRefusals turns it into a sentence. */
|
|
118
|
+
await expect(updateUserRoles(user, [], [foreign], child)).rejects.toMatchObject({ code: 'role-not-assignable-here' })
|
|
119
|
+
expect(await heldRoleNames(user)).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('★ a foreign role in availableRoles stays with the person - this domain cannot take it', async () => {
|
|
123
|
+
const own = await getRepository(Role).save({ name: 'operator', domain: child })
|
|
124
|
+
const theirs = await getRepository(Role).save({ name: 'auditor', domain: stranger })
|
|
125
|
+
const user = await makeUser([child, stranger], [own, theirs])
|
|
126
|
+
|
|
127
|
+
await updateUserRoles(user, [own, theirs], [], child)
|
|
128
|
+
|
|
129
|
+
expect(await heldRoleNames(user)).toEqual(['auditor'])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('within the scope it replaces as before - remove the shown, add the chosen', async () => {
|
|
133
|
+
const a = await getRepository(Role).save({ name: 'a', domain: child })
|
|
134
|
+
const b = await getRepository(Role).save({ name: 'b', domain: child })
|
|
135
|
+
const user = await makeUser([child], [a])
|
|
136
|
+
|
|
137
|
+
await updateUserRoles(user, [a, b], [b], child)
|
|
138
|
+
|
|
139
|
+
expect(await heldRoleNames(user)).toEqual(['b'])
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -117,15 +117,13 @@ describe('이름이 없는 선언 — 소유권만으로 판정한다', () => {
|
|
|
117
117
|
expect(await checkPermission({ owner: true }, USER, DOMAIN)).toBe(true)
|
|
118
118
|
})
|
|
119
119
|
|
|
120
|
-
it('소유자가 아니면 닫힌다 —
|
|
121
|
-
/*
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
생기는 순간 조용히 통과한다. 그래서 값 자체를 고정한다. */
|
|
120
|
+
it('소유자가 아니면 닫힌다 — 값은 `false` 다', async () => {
|
|
121
|
+
/* 2026-10-02 까지는 `(true && false) || (undefined && …)` = `undefined` 가 나왔다. 선언한 타입은
|
|
122
|
+
`Promise<boolean>` 이었고, `=== false` 로 받는 소비처가 생기는 순간 조용히 통과할 자리였다.
|
|
123
|
+
판정이 이유를 내게 되면서(`judgePermission`, ADR-0103) 값이 타입과 같아졌다. */
|
|
125
124
|
const decision = await checkPermission({ owner: true }, USER, DOMAIN)
|
|
126
125
|
|
|
127
|
-
expect(decision).
|
|
128
|
-
expect(decision).toBeUndefined()
|
|
126
|
+
expect(decision).toBe(false)
|
|
129
127
|
})
|
|
130
128
|
|
|
131
129
|
it('refuses an object that names nothing', async () => {
|
|
@@ -291,3 +291,24 @@ describe('a declaration that names nothing', () => {
|
|
|
291
291
|
expect(buildField('@privilege(superUserGranted: true)')).not.toThrow()
|
|
292
292
|
})
|
|
293
293
|
})
|
|
294
|
+
|
|
295
|
+
describe('거절이 이유를 싣는다 (ADR-0103 결정 1)', () => {
|
|
296
|
+
it('★ 역할이 없으면 because: role — 화면이 「권한 요청」을 띄운다', async () => {
|
|
297
|
+
const result = await run(buildSchema(), 'guardedBoard')
|
|
298
|
+
expect((result.errors![0].extensions as any).because).toBe('role')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('★ 라이선스가 막으면 역할이 있어도 because: license — 요청할 것이 아니다', async () => {
|
|
302
|
+
User.hasPrivilege = jest.fn().mockResolvedValue(true)
|
|
303
|
+
const result = await run(buildSchema(), 'guardedBoard', {
|
|
304
|
+
prohibitedPrivileges: [{ category: 'board', privilege: 'mutation', by: 'license' }]
|
|
305
|
+
})
|
|
306
|
+
expect((result.errors![0].extensions as any).because).toBe('license')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('★ 보호 주소 밖에서 소유자 전용이면 because: address', async () => {
|
|
310
|
+
withOwnership({ owner: true })
|
|
311
|
+
const result = await run(buildSchema(), 'ownershipOnly', { unsafeIP: true })
|
|
312
|
+
expect((result.errors![0].extensions as any).because).toBe('address')
|
|
313
|
+
})
|
|
314
|
+
})
|
|
@@ -70,3 +70,35 @@ describe('privilege rejection', () => {
|
|
|
70
70
|
expect(privilegeRejection({}, undefined, 'mutation').extensions.requires).toBe('ownership')
|
|
71
71
|
})
|
|
72
72
|
})
|
|
73
|
+
|
|
74
|
+
describe('privilege rejection carries why (ADR-0103 decision 1)', () => {
|
|
75
|
+
it('★ the cause goes in extensions.because - a screen offers a request only for role', () => {
|
|
76
|
+
expect(privilegeRejection(undefined, 'twin', 'mutation', 'license').extensions).toEqual({
|
|
77
|
+
code: 'FORBIDDEN',
|
|
78
|
+
requires: { category: 'twin', privilege: 'mutation' },
|
|
79
|
+
because: 'license'
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('a caller that does not say keeps the old meaning - named is role, unnamed is ownership', () => {
|
|
84
|
+
expect(privilegeRejection(undefined, 'twin', 'mutation').extensions.because).toBe('role')
|
|
85
|
+
expect(privilegeRejection(undefined).extensions.because).toBe('ownership')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('★ each cause has its own sentence - an address refusal does not say a privilege is missing', () => {
|
|
89
|
+
const address = privilegeRejection(undefined, 'twin', 'mutation', 'address').message
|
|
90
|
+
const license = privilegeRejection(undefined, 'twin', 'mutation', 'license').message
|
|
91
|
+
const role = privilegeRejection(undefined, 'twin', 'mutation', 'role').message
|
|
92
|
+
expect(new Set([address, license, role]).size).toBe(3)
|
|
93
|
+
expect(address).toContain('address')
|
|
94
|
+
expect(license).toContain('licensed')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('every sentence key is in all five languages', () => {
|
|
98
|
+
const keys = ['error.x not allowed from this address', 'error.x not licensed', 'error.x refused for this request', 'error.ownership not allowed from this address']
|
|
99
|
+
for (const lng of ['en', 'ko', 'ja', 'zh', 'ms']) {
|
|
100
|
+
const dict = require(`../translations/${lng}.json`)
|
|
101
|
+
for (const key of keys) expect([lng, key, typeof dict[key]]).toEqual([lng, key, 'string'])
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
})
|