@things-factory/auth-base 10.1.79 → 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.
Files changed (71) hide show
  1. package/dist-client/tsconfig.tsbuildinfo +1 -1
  2. package/dist-server/ai-entry/access-assistant.d.ts +50 -0
  3. package/dist-server/ai-entry/access-assistant.js +145 -0
  4. package/dist-server/ai-entry/access-assistant.js.map +1 -0
  5. package/dist-server/ai-entry/access-entry-targets.d.ts +32 -0
  6. package/dist-server/ai-entry/access-entry-targets.js +196 -0
  7. package/dist-server/ai-entry/access-entry-targets.js.map +1 -0
  8. package/dist-server/ai-entry/entry-target-shape.d.ts +82 -0
  9. package/dist-server/ai-entry/entry-target-shape.js +3 -0
  10. package/dist-server/ai-entry/entry-target-shape.js.map +1 -0
  11. package/dist-server/controllers/access-request.d.ts +57 -0
  12. package/dist-server/controllers/access-request.js +373 -0
  13. package/dist-server/controllers/access-request.js.map +1 -0
  14. package/dist-server/index.d.ts +3 -0
  15. package/dist-server/index.js +3 -0
  16. package/dist-server/index.js.map +1 -1
  17. package/dist-server/service/access-request/access-request-mutation.d.ts +17 -0
  18. package/dist-server/service/access-request/access-request-mutation.js +65 -0
  19. package/dist-server/service/access-request/access-request-mutation.js.map +1 -0
  20. package/dist-server/service/access-request/access-request-query.d.ts +16 -0
  21. package/dist-server/service/access-request/access-request-query.js +100 -0
  22. package/dist-server/service/access-request/access-request-query.js.map +1 -0
  23. package/dist-server/service/access-request/access-request-types.d.ts +32 -0
  24. package/dist-server/service/access-request/access-request-types.js +115 -0
  25. package/dist-server/service/access-request/access-request-types.js.map +1 -0
  26. package/dist-server/service/access-request/access-request.d.ts +33 -0
  27. package/dist-server/service/access-request/access-request.js +160 -0
  28. package/dist-server/service/access-request/access-request.js.map +1 -0
  29. package/dist-server/service/access-request/index.d.ts +5 -0
  30. package/dist-server/service/access-request/index.js +9 -0
  31. package/dist-server/service/access-request/index.js.map +1 -0
  32. package/dist-server/service/index.d.ts +3 -1
  33. package/dist-server/service/index.js +42 -37
  34. package/dist-server/service/index.js.map +1 -1
  35. package/dist-server/service/open-fields.js +5 -1
  36. package/dist-server/service/open-fields.js.map +1 -1
  37. package/dist-server/service/privilege/privilege-directive.js +6 -6
  38. package/dist-server/service/privilege/privilege-directive.js.map +1 -1
  39. package/dist-server/service/privilege/privilege-rejection.d.ts +10 -2
  40. package/dist-server/service/privilege/privilege-rejection.js +23 -10
  41. package/dist-server/service/privilege/privilege-rejection.js.map +1 -1
  42. package/dist-server/service/user/user-mutation.js +5 -4
  43. package/dist-server/service/user/user-mutation.js.map +1 -1
  44. package/dist-server/service/user/user-query.js +5 -17
  45. package/dist-server/service/user/user-query.js.map +1 -1
  46. package/dist-server/tsconfig.tsbuildinfo +1 -1
  47. package/dist-server/utils/access-request-review.d.ts +69 -0
  48. package/dist-server/utils/access-request-review.js +119 -0
  49. package/dist-server/utils/access-request-review.js.map +1 -0
  50. package/dist-server/utils/assignable-roles.d.ts +23 -0
  51. package/dist-server/utils/assignable-roles.js +89 -0
  52. package/dist-server/utils/assignable-roles.js.map +1 -0
  53. package/dist-server/utils/check-permission.d.ts +29 -2
  54. package/dist-server/utils/check-permission.js +48 -14
  55. package/dist-server/utils/check-permission.js.map +1 -1
  56. package/package.json +4 -4
  57. package/tests/access-assistant-db.test.ts +155 -0
  58. package/tests/access-entry-targets-db.test.ts +138 -0
  59. package/tests/access-request-db.test.ts +273 -0
  60. package/tests/access-request-pg.test.ts +72 -0
  61. package/tests/access-request-review.test.ts +155 -0
  62. package/tests/assignable-roles-db.test.ts +141 -0
  63. package/tests/permission-gate.test.ts +5 -7
  64. package/tests/privilege-directive.test.ts +39 -0
  65. package/tests/privilege-rejection.test.ts +32 -0
  66. package/tests/refusal-cause.test.ts +80 -0
  67. package/translations/en.json +28 -1
  68. package/translations/ja.json +28 -1
  69. package/translations/ko.json +28 -1
  70. package/translations/ms.json +28 -1
  71. package/translations/zh.json +28 -1
@@ -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('소유자가 아니면 닫힌다 — 다만 돌려주는 값이 `false` 가 아니라 `undefined` 다', async () => {
121
- /* 선언한 타입은 `Promise<boolean>` 인데 실제로는 `(true && false) || (undefined && …)`
122
- = `undefined` 가 나온다. 지금 모든 소비처가 `if (await checkPermission(…))` 로 받으므로
123
- 거동은 같다 — 그러나 시그니처가 사실과 다르고, `=== false` 로 받는 소비처가 하나
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).toBeFalsy()
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,42 @@ 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
+ })
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
+ })
@@ -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
+ })
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Why the gate said no (ADR-0103 decision 1).
3
+ *
4
+ * The four refusals used to arrive as one sentence. Only one of them is opened by a role, so a screen
5
+ * that offers "request access" for all four sends the admin a request they cannot answer - the person is
6
+ * given the role and is refused again. `judgePermission` says which it was; `checkPermission` stays a boolean.
7
+ */
8
+
9
+ import { loadCompiled } from './compiled'
10
+
11
+ const { judgePermission, checkPermission } = loadCompiled('utils/check-permission')
12
+ const { User } = loadCompiled('service/user/user')
13
+
14
+ const DOMAIN = { id: 'domain-1' } as any
15
+ const USER = { id: 'user-1' } as any
16
+ const NAMED = { category: 'warehouse', privilege: 'mutation' }
17
+ const OWNED = { ...NAMED, owner: true }
18
+
19
+ function given({ role = false, owner = false } = {}) {
20
+ User.hasPrivilege = jest.fn().mockResolvedValue(role)
21
+ ;(process as any).domainOwnerGranted = jest.fn().mockResolvedValue(owner)
22
+ ;(process as any).superUserGranted = jest.fn().mockResolvedValue(false)
23
+ }
24
+
25
+ afterEach(() => {
26
+ delete (process as any).domainOwnerGranted
27
+ delete (process as any).superUserGranted
28
+ })
29
+
30
+ describe('a refusal says which of the four it was', () => {
31
+ it('★ no role holds it: role', async () => {
32
+ given()
33
+ expect(await judgePermission(NAMED, USER, DOMAIN)).toEqual({ permitted: false, because: 'role' })
34
+ expect(await judgePermission(NAMED, USER, DOMAIN, true)).toEqual({ permitted: false, because: 'role' })
35
+ })
36
+
37
+ it('★ withheld by the address list: address - even with the role', async () => {
38
+ given({ role: true })
39
+ const prohibited = [{ ...NAMED, by: 'address' }]
40
+ expect(await judgePermission(NAMED, USER, DOMAIN, true, prohibited)).toEqual({ permitted: false, because: 'address' })
41
+ })
42
+
43
+ it('★ withheld by the license: license - even with the role', async () => {
44
+ given({ role: true })
45
+ const prohibited = [{ ...NAMED, by: 'license' }]
46
+ expect(await judgePermission(NAMED, USER, DOMAIN, false, prohibited)).toEqual({ permitted: false, because: 'license' })
47
+ })
48
+
49
+ it('★ a prohibition that does not say who made it: unknown - the cause is not made up', async () => {
50
+ given({ role: true })
51
+ expect(await judgePermission(NAMED, USER, DOMAIN, false, [NAMED])).toEqual({ permitted: false, because: 'unknown' })
52
+ })
53
+
54
+ it('★ only owners may: ownership', async () => {
55
+ given()
56
+ expect(await judgePermission({ owner: true }, USER, DOMAIN)).toEqual({ permitted: false, because: 'ownership' })
57
+ })
58
+
59
+ it('★ ownership asked from an unsafe address: address', async () => {
60
+ given({ owner: true })
61
+ expect(await judgePermission({ owner: true }, USER, DOMAIN, true)).toEqual({ permitted: false, because: 'address' })
62
+ })
63
+
64
+ it('★ an owner without the role, from an unsafe address: address - from a safe one they pass', async () => {
65
+ given({ owner: true })
66
+ expect(await judgePermission(OWNED, USER, DOMAIN, true)).toEqual({ permitted: false, because: 'address' })
67
+ expect(await judgePermission(OWNED, USER, DOMAIN, false)).toEqual({ permitted: true })
68
+ })
69
+
70
+ it('an unsafe address whose ownership cannot be asked still refuses as role - it refused before', async () => {
71
+ User.hasPrivilege = jest.fn().mockResolvedValue(false)
72
+ expect(await judgePermission(OWNED, USER, DOMAIN, true)).toEqual({ permitted: false, because: 'role' })
73
+ })
74
+
75
+ it('checkPermission answers the same, as a boolean', async () => {
76
+ given({ role: true })
77
+ expect(await checkPermission(NAMED, USER, DOMAIN)).toBe(true)
78
+ expect(await checkPermission(NAMED, USER, DOMAIN, false, [{ ...NAMED, by: 'license' }])).toBe(false)
79
+ })
80
+ })
@@ -27,6 +27,11 @@
27
27
  "error.username already exists": "username already used by another user",
28
28
  "error.x is not a member of y": "{x} is not a member of {y}",
29
29
  "error.x privilege required": "unauthorized! {x} privilege required",
30
+ "error.role-not-assignable-here": "this domain cannot assign the role(s) {roles}.",
31
+ "error.x not allowed from this address": "unauthorized! {x} is not allowed from this address",
32
+ "error.x not licensed": "unauthorized! {x} is not licensed on this installation",
33
+ "error.x refused for this request": "unauthorized! {x} is refused for this request",
34
+ "error.ownership not allowed from this address": "unauthorized! ownership does not count from this address",
30
35
  "field.appliance_id": "appliance id",
31
36
  "field.brand": "brand",
32
37
  "field.password": "password",
@@ -222,5 +227,27 @@
222
227
  "text.user inactivated successfully": "user inactivated successfully",
223
228
  "text.user registered successfully": "user registered successfully. find your email to activate account",
224
229
  "text.verification email sent": "verification email sent",
225
- "privilege.description": "{name} {category}"
230
+ "privilege.description": "{name} {category}",
231
+ "error.access-request-names-nothing": "an access request names a category and a privilege.",
232
+ "error.access-request-unknown-privilege": "there is no privilege {privilege}.",
233
+ "error.access-request-already-holds": "you already have {privilege}.",
234
+ "error.access-request-not-by-role": "a role would not open {privilege} here, so it cannot be requested.",
235
+ "error.access-request-not-withdrawable": "there is no pending request of yours to withdraw.",
236
+ "error.access-decision-access-request-not-found": "the access request is not found in this domain.",
237
+ "error.access-decision-request-not-pending": "the request has already been decided, withdrawn or has expired.",
238
+ "error.access-decision-own-request": "you cannot decide your own request.",
239
+ "error.access-decision-requester-unavailable": "the requester is locked, inactive or no longer in this domain.",
240
+ "error.access-decision-deny-needs-reason": "a denial needs a reason.",
241
+ "error.access-decision-already-holds": "the requester already has this privilege.",
242
+ "error.access-decision-role-required": "choose the role to grant.",
243
+ "error.access-decision-role-not-assignable": "this domain cannot assign that role.",
244
+ "error.access-decision-role-does-not-grant": "that role does not hold the requested privilege.",
245
+ "access-request.notify.asked.title": "access request",
246
+ "access-request.notify.asked.body": "{name} asks for {privilege}.",
247
+ "access-request.notify.granted.title": "access granted",
248
+ "access-request.notify.granted.body": "you now have {privilege} (role: {role}).",
249
+ "access-request.notify.denied.title": "access request denied",
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."
226
253
  }
@@ -27,6 +27,11 @@
27
27
  "error.username already exists": "ユーザー名はすでに他のユーザーによって使用されています.",
28
28
  "error.x is not a member of y": "{x}は{y}のメンバーではありません.",
29
29
  "error.x privilege required": "権限がありません。{x} 権限が必要です。",
30
+ "error.role-not-assignable-here": "この$t(term.domain)では付与できないロールです: {roles}",
31
+ "error.x not allowed from this address": "権限がありません。このアドレスからは {x} 権限を使えません。",
32
+ "error.x not licensed": "権限がありません。このインストールのライセンスには {x} 権限がありません。",
33
+ "error.x refused for this request": "権限がありません。このリクエストでは {x} 権限が許可されていません。",
34
+ "error.ownership not allowed from this address": "権限がありません。このアドレスからは所有者権限が適用されません。",
30
35
  "field.appliance_id": "器具ID",
31
36
  "field.brand": "ブランド",
32
37
  "field.password": "パスワード",
@@ -222,5 +227,27 @@
222
227
  "privilege.category.user": "ユーザー",
223
228
  "privilege.category.webhook": "Webhook",
224
229
  "privilege.category.work-shift": "勤務交代",
225
- "privilege.category.worker": "作業者"
230
+ "privilege.category.worker": "作業者",
231
+ "error.access-request-names-nothing": "権限リクエストにはカテゴリーと権限が必要です。",
232
+ "error.access-request-unknown-privilege": "{privilege} という権限はありません。",
233
+ "error.access-request-already-holds": "すでに {privilege} 権限があります。",
234
+ "error.access-request-not-by-role": "{privilege} はここではロールで開けないため、リクエストできません。",
235
+ "error.access-request-not-withdrawable": "取り下げられる保留中のリクエストがありません。",
236
+ "error.access-decision-access-request-not-found": "この$t(term.domain)にその権限リクエストはありません。",
237
+ "error.access-decision-request-not-pending": "すでに決定済み、取り下げ済み、または期限切れのリクエストです。",
238
+ "error.access-decision-own-request": "自分のリクエストは決定できません。",
239
+ "error.access-decision-requester-unavailable": "リクエスト者がロック中、無効、またはこの$t(term.domain)に所属していません。",
240
+ "error.access-decision-deny-needs-reason": "拒否には理由が必要です。",
241
+ "error.access-decision-already-holds": "リクエスト者はすでにその権限を持っています。",
242
+ "error.access-decision-role-required": "付与するロールを選んでください。",
243
+ "error.access-decision-role-not-assignable": "この$t(term.domain)では付与できないロールです。",
244
+ "error.access-decision-role-does-not-grant": "そのロールにはリクエストされた権限がありません。",
245
+ "access-request.notify.asked.title": "権限リクエスト",
246
+ "access-request.notify.asked.body": "{name} さんが {privilege} 権限をリクエストしました。",
247
+ "access-request.notify.granted.title": "権限が付与されました",
248
+ "access-request.notify.granted.body": "{privilege} 権限が付与されました(ロール: {role})。",
249
+ "access-request.notify.denied.title": "権限リクエストが拒否されました",
250
+ "access-request.notify.denied.body": "{privilege}: {reason}",
251
+ "error.access-request-needs-reason": "必要な理由を書いてください。",
252
+ "error.access-request-same-reason-as-denied": "同じ理由で最近拒否されました({reason})。何が変わったかを書いてください。"
226
253
  }
@@ -27,6 +27,11 @@
27
27
  "error.username already exists": "사용자 아이디가 이미 사용되고 있습니다.",
28
28
  "error.x is not a member of y": "{x}은(는) {y}의 멤버가 아닙니다.",
29
29
  "error.x privilege required": "권한이 없습니다. {x} 권한이 필요합니다.",
30
+ "error.role-not-assignable-here": "이 $t(term.domain)에서 줄 수 없는 역할입니다: {roles}",
31
+ "error.x not allowed from this address": "권한이 없습니다. 이 주소에서는 {x} 권한을 쓸 수 없습니다.",
32
+ "error.x not licensed": "권한이 없습니다. 이 설치의 라이선스에는 {x} 권한이 없습니다.",
33
+ "error.x refused for this request": "권한이 없습니다. 이 요청에는 {x} 권한이 허용되지 않습니다.",
34
+ "error.ownership not allowed from this address": "권한이 없습니다. 이 주소에서는 소유자 권한이 적용되지 않습니다.",
30
35
  "field.appliance_id": "기구 아이디",
31
36
  "field.brand": "브랜드",
32
37
  "field.password": "비밀번호",
@@ -222,5 +227,27 @@
222
227
  "text.user inactivated successfully": "사용자가 성공적으로 비활성화되었습니다.",
223
228
  "text.user registered successfully": "사용자가 성공적으로 등록되었습니다. 확인 이메일을 통해서 계정을 활성화하시기 바랍니다.",
224
229
  "text.verification email sent": "확인 이메일을 보냈습니다.",
225
- "privilege.description": "{category} {name}"
230
+ "privilege.description": "{category} {name}",
231
+ "error.access-request-names-nothing": "권한 요청에는 범주와 권한이 있어야 합니다.",
232
+ "error.access-request-unknown-privilege": "{privilege} 권한이 없습니다.",
233
+ "error.access-request-already-holds": "이미 {privilege} 권한이 있습니다.",
234
+ "error.access-request-not-by-role": "{privilege} 권한은 여기서 역할로 열리지 않아 요청할 수 없습니다.",
235
+ "error.access-request-not-withdrawable": "거둘 수 있는 내 대기 중 요청이 없습니다.",
236
+ "error.access-decision-access-request-not-found": "이 $t(term.domain)에서 그 권한 요청을 찾을 수 없습니다.",
237
+ "error.access-decision-request-not-pending": "이미 정해졌거나, 거두었거나, 만료된 요청입니다.",
238
+ "error.access-decision-own-request": "자기 요청은 정할 수 없습니다.",
239
+ "error.access-decision-requester-unavailable": "요청자가 잠겼거나, 비활성이거나, 이 $t(term.domain)에서 빠졌습니다.",
240
+ "error.access-decision-deny-needs-reason": "거부에는 까닭이 있어야 합니다.",
241
+ "error.access-decision-already-holds": "요청자가 이미 그 권한을 가지고 있습니다.",
242
+ "error.access-decision-role-required": "부여할 역할을 고르세요.",
243
+ "error.access-decision-role-not-assignable": "이 $t(term.domain)에서 줄 수 없는 역할입니다.",
244
+ "error.access-decision-role-does-not-grant": "그 역할에는 요청한 권한이 없습니다.",
245
+ "access-request.notify.asked.title": "권한 요청",
246
+ "access-request.notify.asked.body": "{name} 님이 {privilege} 권한을 요청했습니다.",
247
+ "access-request.notify.granted.title": "권한을 받았습니다",
248
+ "access-request.notify.granted.body": "{privilege} 권한을 받았습니다(역할: {role}).",
249
+ "access-request.notify.denied.title": "권한 요청이 거부되었습니다",
250
+ "access-request.notify.denied.body": "{privilege}: {reason}",
251
+ "error.access-request-needs-reason": "필요한 까닭을 적어 주세요.",
252
+ "error.access-request-same-reason-as-denied": "같은 까닭으로 최근에 거부되었습니다({reason}). 무엇이 달라졌는지 적어 주세요."
226
253
  }
@@ -27,6 +27,11 @@
27
27
  "error.username already exists": "Nama pengguna telah digunakan oleh pengguna lain",
28
28
  "error.x is not a member of y": "{x} bukan ahli {y}",
29
29
  "error.x privilege required": "tidak dibenarkan! kebenaran {x} diperlukan",
30
+ "error.role-not-assignable-here": "$t(term.domain) ini tidak boleh memberikan peranan: {roles}",
31
+ "error.x not allowed from this address": "tidak dibenarkan! kebenaran {x} tidak dibenarkan dari alamat ini",
32
+ "error.x not licensed": "tidak dibenarkan! kebenaran {x} tiada dalam lesen pemasangan ini",
33
+ "error.x refused for this request": "tidak dibenarkan! kebenaran {x} ditolak untuk permintaan ini",
34
+ "error.ownership not allowed from this address": "tidak dibenarkan! kebenaran pemilik tidak terpakai dari alamat ini",
30
35
  "field.appliance_id": "Perkakas",
31
36
  "field.brand": "Jenama",
32
37
  "field.password": "Kata laluan",
@@ -222,5 +227,27 @@
222
227
  "privilege.category.user": "pengguna",
223
228
  "privilege.category.webhook": "Webhook",
224
229
  "privilege.category.work-shift": "syif kerja",
225
- "privilege.category.worker": "pekerja"
230
+ "privilege.category.worker": "pekerja",
231
+ "error.access-request-names-nothing": "permintaan akses perlu menamakan kategori dan kebenaran.",
232
+ "error.access-request-unknown-privilege": "tiada kebenaran {privilege}.",
233
+ "error.access-request-already-holds": "anda sudah mempunyai {privilege}.",
234
+ "error.access-request-not-by-role": "{privilege} tidak dibuka oleh peranan di sini, jadi ia tidak boleh dipohon.",
235
+ "error.access-request-not-withdrawable": "tiada permintaan anda yang menunggu untuk ditarik balik.",
236
+ "error.access-decision-access-request-not-found": "permintaan akses tidak ditemui dalam $t(term.domain) ini.",
237
+ "error.access-decision-request-not-pending": "permintaan ini sudah diputuskan, ditarik balik atau tamat tempoh.",
238
+ "error.access-decision-own-request": "anda tidak boleh memutuskan permintaan anda sendiri.",
239
+ "error.access-decision-requester-unavailable": "peminta dikunci, tidak aktif atau tiada lagi dalam $t(term.domain) ini.",
240
+ "error.access-decision-deny-needs-reason": "penolakan memerlukan sebab.",
241
+ "error.access-decision-already-holds": "peminta sudah mempunyai kebenaran ini.",
242
+ "error.access-decision-role-required": "pilih peranan untuk diberikan.",
243
+ "error.access-decision-role-not-assignable": "$t(term.domain) ini tidak boleh memberikan peranan itu.",
244
+ "error.access-decision-role-does-not-grant": "peranan itu tidak memegang kebenaran yang dipohon.",
245
+ "access-request.notify.asked.title": "permintaan akses",
246
+ "access-request.notify.asked.body": "{name} memohon {privilege}.",
247
+ "access-request.notify.granted.title": "akses diberikan",
248
+ "access-request.notify.granted.body": "anda kini mempunyai {privilege} (peranan: {role}).",
249
+ "access-request.notify.denied.title": "permintaan akses ditolak",
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."
226
253
  }