@things-factory/auth-base 10.1.192 → 10.1.202

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 (128) hide show
  1. package/dist-client/tsconfig.tsbuildinfo +1 -1
  2. package/dist-server/ai-entry/access-assistant.js +4 -3
  3. package/dist-server/ai-entry/access-assistant.js.map +1 -1
  4. package/dist-server/controllers/access-request.d.ts +8 -2
  5. package/dist-server/controllers/access-request.js +33 -6
  6. package/dist-server/controllers/access-request.js.map +1 -1
  7. package/dist-server/controllers/signin.d.ts +5 -0
  8. package/dist-server/controllers/signin.js +25 -0
  9. package/dist-server/controllers/signin.js.map +1 -1
  10. package/dist-server/index.js +7 -0
  11. package/dist-server/index.js.map +1 -1
  12. package/dist-server/middlewares/audit-scope-middleware.d.ts +7 -0
  13. package/dist-server/middlewares/audit-scope-middleware.js +15 -0
  14. package/dist-server/middlewares/audit-scope-middleware.js.map +1 -0
  15. package/dist-server/middlewares/index.js +3 -0
  16. package/dist-server/middlewares/index.js.map +1 -1
  17. package/dist-server/role-tasks.d.ts +2 -0
  18. package/dist-server/role-tasks.js +20 -0
  19. package/dist-server/role-tasks.js.map +1 -0
  20. package/dist-server/router/auth-checkin-router.js +3 -5
  21. package/dist-server/router/auth-checkin-router.js.map +1 -1
  22. package/dist-server/router/auth-public-process-router.js +7 -1
  23. package/dist-server/router/auth-public-process-router.js.map +1 -1
  24. package/dist-server/router/site-root-router.d.ts +2 -0
  25. package/dist-server/router/site-root-router.js +2 -0
  26. package/dist-server/router/site-root-router.js.map +1 -1
  27. package/dist-server/service/access-request/access-request-query.d.ts +7 -2
  28. package/dist-server/service/access-request/access-request-query.js +25 -4
  29. package/dist-server/service/access-request/access-request-query.js.map +1 -1
  30. package/dist-server/service/access-request/access-request-types.d.ts +7 -0
  31. package/dist-server/service/access-request/access-request-types.js +27 -1
  32. package/dist-server/service/access-request/access-request-types.js.map +1 -1
  33. package/dist-server/service/access-request/access-request.d.ts +1 -0
  34. package/dist-server/service/access-request/access-request.js +5 -0
  35. package/dist-server/service/access-request/access-request.js.map +1 -1
  36. package/dist-server/service/audit/audit-action.d.ts +36 -0
  37. package/dist-server/service/audit/audit-action.js +137 -0
  38. package/dist-server/service/audit/audit-action.js.map +1 -0
  39. package/dist-server/service/audit/audit-kinds.d.ts +19 -0
  40. package/dist-server/service/audit/audit-kinds.js +70 -0
  41. package/dist-server/service/audit/audit-kinds.js.map +1 -0
  42. package/dist-server/service/audit/audit-query.d.ts +11 -0
  43. package/dist-server/service/audit/audit-query.js +295 -0
  44. package/dist-server/service/audit/audit-query.js.map +1 -0
  45. package/dist-server/service/audit/audit-recorder.d.ts +77 -0
  46. package/dist-server/service/audit/audit-recorder.js +247 -0
  47. package/dist-server/service/audit/audit-recorder.js.map +1 -0
  48. package/dist-server/service/audit/audit-request.d.ts +28 -0
  49. package/dist-server/service/audit/audit-request.js +96 -0
  50. package/dist-server/service/audit/audit-request.js.map +1 -0
  51. package/dist-server/service/audit/audit-retention.d.ts +12 -0
  52. package/dist-server/service/audit/audit-retention.js +42 -0
  53. package/dist-server/service/audit/audit-retention.js.map +1 -0
  54. package/dist-server/service/audit/audit-subscriber.d.ts +39 -0
  55. package/dist-server/service/audit/audit-subscriber.js +191 -0
  56. package/dist-server/service/audit/audit-subscriber.js.map +1 -0
  57. package/dist-server/service/audit/audit-target.d.ts +19 -0
  58. package/dist-server/service/audit/audit-target.js +46 -0
  59. package/dist-server/service/audit/audit-target.js.map +1 -0
  60. package/dist-server/service/audit/audit-types.d.ts +44 -0
  61. package/dist-server/service/audit/audit-types.js +161 -0
  62. package/dist-server/service/audit/audit-types.js.map +1 -0
  63. package/dist-server/service/audit/index.d.ts +6 -0
  64. package/dist-server/service/audit/index.js +11 -0
  65. package/dist-server/service/audit/index.js.map +1 -0
  66. package/dist-server/service/domain-owner/domain-owner-mutation.js +3 -0
  67. package/dist-server/service/domain-owner/domain-owner-mutation.js.map +1 -1
  68. package/dist-server/service/index.d.ts +9 -2
  69. package/dist-server/service/index.js +19 -4
  70. package/dist-server/service/index.js.map +1 -1
  71. package/dist-server/service/open-fields.js +2 -1
  72. package/dist-server/service/open-fields.js.map +1 -1
  73. package/dist-server/service/privilege/privilege-directive.js +25 -1
  74. package/dist-server/service/privilege/privilege-directive.js.map +1 -1
  75. package/dist-server/service/role/role-mutation.js +8 -1
  76. package/dist-server/service/role/role-mutation.js.map +1 -1
  77. package/dist-server/service/role-template/role-task.d.ts +81 -0
  78. package/dist-server/service/role-template/role-task.js +129 -0
  79. package/dist-server/service/role-template/role-task.js.map +1 -0
  80. package/dist-server/service/role-template/role-template-gap-resolver.d.ts +9 -5
  81. package/dist-server/service/role-template/role-template-gap-resolver.js +41 -13
  82. package/dist-server/service/role-template/role-template-gap-resolver.js.map +1 -1
  83. package/dist-server/service/role-template/role-template-mutation.js +10 -1
  84. package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
  85. package/dist-server/service/role-template/role-template-query.js +4 -2
  86. package/dist-server/service/role-template/role-template-query.js.map +1 -1
  87. package/dist-server/service/role-template/role-template-types.d.ts +24 -0
  88. package/dist-server/service/role-template/role-template-types.js +63 -1
  89. package/dist-server/service/role-template/role-template-types.js.map +1 -1
  90. package/dist-server/service/role-template/role-template.d.ts +63 -10
  91. package/dist-server/service/role-template/role-template.js +92 -18
  92. package/dist-server/service/role-template/role-template.js.map +1 -1
  93. package/dist-server/tsconfig.tsbuildinfo +1 -1
  94. package/dist-server/utils/access-request-review.d.ts +12 -2
  95. package/dist-server/utils/access-request-review.js +13 -3
  96. package/dist-server/utils/access-request-review.js.map +1 -1
  97. package/dist-server/utils/check-permission.d.ts +6 -0
  98. package/dist-server/utils/check-permission.js +23 -8
  99. package/dist-server/utils/check-permission.js.map +1 -1
  100. package/package.json +3 -2
  101. package/tests/access-assistant-db.test.ts +1 -1
  102. package/tests/access-request-db.test.ts +71 -0
  103. package/tests/access-request-review.test.ts +30 -0
  104. package/tests/audit-db.test.ts +394 -0
  105. package/tests/auth-base-role-tasks.test.ts +58 -0
  106. package/tests/list-inheritance-db.test.ts +0 -7
  107. package/tests/open-doors.test.ts +2 -2
  108. package/tests/refusal-cause.test.ts +1 -1
  109. package/tests/role-task.test.ts +168 -0
  110. package/tests/role-template-gap-db.test.ts +77 -2
  111. package/translations/en.json +14 -1
  112. package/translations/es.json +14 -1
  113. package/translations/ja.json +14 -1
  114. package/translations/ko.json +14 -1
  115. package/translations/ms.json +14 -1
  116. package/translations/zh.json +14 -1
  117. package/dist-server/service/login-history/index.d.ts +0 -4
  118. package/dist-server/service/login-history/index.js +0 -8
  119. package/dist-server/service/login-history/index.js.map +0 -1
  120. package/dist-server/service/login-history/login-history-query.d.ts +0 -10
  121. package/dist-server/service/login-history/login-history-query.js +0 -80
  122. package/dist-server/service/login-history/login-history-query.js.map +0 -1
  123. package/dist-server/service/login-history/login-history-type.d.ts +0 -5
  124. package/dist-server/service/login-history/login-history-type.js +0 -21
  125. package/dist-server/service/login-history/login-history-type.js.map +0 -1
  126. package/dist-server/service/login-history/login-history.d.ts +0 -12
  127. package/dist-server/service/login-history/login-history.js +0 -58
  128. package/dist-server/service/login-history/login-history.js.map +0 -1
@@ -0,0 +1,394 @@
1
+ /**
2
+ * The audit record (ADR-0141) - what it keeps, and what it never keeps.
3
+ *
4
+ * The doors run through the real `@privilege` wrapper on an SDL schema (the shape
5
+ * `privilege-directive.test.ts` uses - type-graphql resolvers cannot be loaded under jest), inside a
6
+ * real audit scope, against the sqlite harness. Grants run through the real subscribers.
7
+ */
8
+
9
+ import { graphql } from 'graphql'
10
+ import { makeExecutableSchema } from '@graphql-tools/schema'
11
+
12
+ import { authDataSource, closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
13
+ import { loadCompiled } from './compiled'
14
+
15
+ const { privilegeDirectiveTypeDefs, privilegeDirectiveResolver } = loadCompiled('service/privilege/privilege-directive')
16
+ const { auditTargetDirectiveTypeDefs } = loadCompiled('service/audit/audit-target')
17
+ const { runInAuditScope, recordAuditAction, flushAudit, auditHealth } = loadCompiled('service/audit/audit-recorder')
18
+ const { AuditAction } = loadCompiled('service/audit/audit-action')
19
+ const { AuditRequest } = loadCompiled('service/audit/audit-request')
20
+ const { AuditQuery } = loadCompiled('service/audit/audit-query')
21
+ const { purgeExpiredAudit } = loadCompiled('service/audit/audit-retention')
22
+ const { User } = loadCompiled('service/user/user')
23
+ const { Role } = loadCompiled('service/role/role')
24
+ const { Privilege } = loadCompiled('service/privilege/privilege')
25
+ const { signin } = loadCompiled('controllers/signin')
26
+
27
+ const PASSWORD = 'Passw0rd!'
28
+
29
+ let domain: any
30
+ let other: any
31
+ let kim: any
32
+ let lee: any
33
+
34
+ const typeDefs = `
35
+ type Item { id: String }
36
+ type Query {
37
+ payslip(employeeId: String): String
38
+ @privilege(category: "payroll", privilege: "query")
39
+ @auditTarget(kind: "Employee", arg: "employeeId")
40
+ payslips: [Item] @privilege(category: "payroll", privilege: "query")
41
+ broken: String @privilege(category: "payroll", privilege: "query")
42
+ ownersOnly: String @privilege(superUserGranted: true)
43
+ }
44
+ `
45
+
46
+ function buildSchema() {
47
+ const schema = makeExecutableSchema({
48
+ typeDefs: [privilegeDirectiveTypeDefs, auditTargetDirectiveTypeDefs, typeDefs],
49
+ resolvers: {
50
+ Query: {
51
+ payslip: (_: any, { employeeId }: any) => `pay of ${employeeId}`,
52
+ payslips: () => [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
53
+ broken: () => {
54
+ const error: any = new Error('salary 4,200,000 for kim')
55
+ error.extensions = { code: 'INTERNAL_SERVER_ERROR' }
56
+ throw error
57
+ },
58
+ ownersOnly: () => 'ok'
59
+ }
60
+ }
61
+ })
62
+ return privilegeDirectiveResolver(schema, { modules: [], mode: 'boot' })
63
+ }
64
+
65
+ /** A koa-like request: what the recorder reads from it and nothing more. */
66
+ function koaContext(user: any, query: string, at = domain) {
67
+ return {
68
+ method: 'POST',
69
+ path: '/graphql',
70
+ state: { domain: at, user },
71
+ req: { headers: { 'x-forwarded-for': '203.0.113.7, 10.0.0.1' }, socket: { remoteAddress: '10.0.0.1' } },
72
+ request: { header: { 'user-agent': 'Mozilla/5.0 test' }, body: { query, operationName: 'Pay' } }
73
+ }
74
+ }
75
+
76
+ /** One request through the schema, as the auth middleware runs it. */
77
+ async function request(user: any, query: string, at = domain) {
78
+ const context = koaContext(user, query, at)
79
+ const result = await runInAuditScope(context, 'graphql', () => graphql({ schema: buildSchema(), source: query, contextValue: context }))
80
+ await flushAudit()
81
+ return result
82
+ }
83
+
84
+ const actions = (where: any = {}) => getRepository(AuditAction).find({ where, relations: ['actor', 'domain'] })
85
+
86
+ async function seedUser(name: string, overrides: any = {}) {
87
+ const salt = User.generateSalt()
88
+ return await getRepository(User).save({
89
+ username: name,
90
+ email: `${name}@example.com`,
91
+ name,
92
+ salt,
93
+ password: User.encode(PASSWORD, salt),
94
+ status: 'activated',
95
+ domains: [domain],
96
+ ...overrides
97
+ })
98
+ }
99
+
100
+ function allow(granted: boolean, { superUser = false } = {}) {
101
+ User.hasPrivilege = jest.fn().mockResolvedValue(granted)
102
+ ;(process as any).domainOwnerGranted = jest.fn().mockResolvedValue(false)
103
+ ;(process as any).superUserGranted = jest.fn().mockResolvedValue(superUser)
104
+ }
105
+
106
+ const originalHasPrivilege = User.hasPrivilege
107
+
108
+ beforeAll(async () => {
109
+ await openAuthDatabase()
110
+ })
111
+
112
+ beforeEach(async () => {
113
+ await resetAuthDatabase()
114
+ User.hasPrivilege = originalHasPrivilege
115
+ domain = await getRepository(Domain).save({ name: 'office', subdomain: 'office' })
116
+ other = await getRepository(Domain).save({ name: 'plant', subdomain: 'plant' })
117
+ kim = await seedUser('kim')
118
+ lee = await seedUser('lee')
119
+ })
120
+
121
+ afterEach(() => {
122
+ delete (process as any).domainOwnerGranted
123
+ delete (process as any).superUserGranted
124
+ })
125
+
126
+ afterAll(async () => {
127
+ await closeAuthDatabase()
128
+ })
129
+
130
+ describe('a request is one line, its doors are rows (§3.1 · §3.2)', () => {
131
+ it('equal checks in one request become one row with a count, keeping each target', async () => {
132
+ allow(true)
133
+ await request(kim, '{ a: payslip(employeeId: "emp-one") b: payslip(employeeId: "emp-two") c: payslip(employeeId: "emp-one") }')
134
+
135
+ const [line] = await getRepository(AuditRequest).find({ relations: ['actor', 'domain'] })
136
+ expect(line).toMatchObject({ channel: 'graphql', operationType: 'query', operationName: 'Pay', fields: 'payslip', ip: '203.0.113.7', userAgent: 'Mozilla/5.0 test' })
137
+ /* The root fields by schema name - the arguments asked never reach the line. */
138
+ expect(JSON.stringify(line)).not.toMatch(/emp-one|emp-two/)
139
+ expect(line.actor.id).toBe(kim.id)
140
+ expect(line.domain.id).toBe(domain.id)
141
+
142
+ const [check] = await actions({ kind: 'door.check' })
143
+ expect(check).toMatchObject({ door: 'payroll:query', decision: 'granted', basis: 'role', count: 3, targetKind: 'Employee', targetRole: 'requested', requestId: line.id })
144
+ expect([...check.targetIds].sort()).toEqual(['emp-one', 'emp-two'])
145
+
146
+ const [result] = await actions({ kind: 'door.result' })
147
+ expect(result).toMatchObject({ outcome: 'ok', count: 3 })
148
+ })
149
+
150
+ it('a check-in outside GraphQL is a line under the site and person its event named', async () => {
151
+ const context = { method: 'GET', path: '/auth/checkin/office', state: {}, req: { headers: {}, socket: { remoteAddress: '10.0.0.9' } }, request: { header: {} }, _matchedRoute: '/auth/checkin/:subdomain?' }
152
+ await runInAuditScope(context, 'http', () => recordAuditAction({ kind: 'auth.checkin', outcome: 'ok' }, { actorId: kim.id, domainId: domain.id }))
153
+ await flushAudit()
154
+
155
+ const [line] = await getRepository(AuditRequest).find({ relations: ['actor', 'domain'] })
156
+ expect(line).toMatchObject({ channel: 'http', route: '/auth/checkin/:subdomain?', ip: '10.0.0.9' })
157
+ expect(line.actor.id).toBe(kim.id)
158
+ expect(line.domain.id).toBe(domain.id)
159
+ })
160
+
161
+ it('an http request that recorded nothing leaves no line', async () => {
162
+ await runInAuditScope({ method: 'GET', path: '/theme.css', state: {} }, 'http', async () => undefined)
163
+ await flushAudit()
164
+ expect(await getRepository(AuditRequest).count()).toBe(0)
165
+ })
166
+
167
+ it('a list counts what it returned', async () => {
168
+ allow(true)
169
+ await request(kim, '{ payslips { id } }')
170
+
171
+ const [result] = await actions({ kind: 'door.result' })
172
+ expect(result.returned).toBe(3)
173
+ })
174
+
175
+ it('a refusal is a check with its cause and no result', async () => {
176
+ allow(false)
177
+ await request(kim, '{ payslip(employeeId: "e9") }')
178
+
179
+ const rows = await actions()
180
+ expect(rows).toHaveLength(1)
181
+ expect(rows[0]).toMatchObject({ kind: 'door.check', decision: 'denied', basis: 'role', targetIds: ['e9'] })
182
+ })
183
+
184
+ it('passing the door is not doing the work - an error is a result with a code, never the message', async () => {
185
+ allow(true)
186
+ await request(kim, '{ broken }')
187
+
188
+ const [result] = await actions({ kind: 'door.result' })
189
+ expect(result).toMatchObject({ outcome: 'error', code: 'INTERNAL_SERVER_ERROR' })
190
+ expect(JSON.stringify(await actions())).not.toMatch(/4,200,000|salary/)
191
+ })
192
+
193
+ it('a pass that rested on superuser says so', async () => {
194
+ allow(false, { superUser: true })
195
+ await request(kim, '{ ownersOnly }')
196
+
197
+ const [check] = await actions({ kind: 'door.check' })
198
+ expect(check).toMatchObject({ door: 'super', decision: 'granted', basis: 'superuser' })
199
+ })
200
+ })
201
+
202
+ describe('who may do what is recorded where it is saved (§3.3 · §4-1)', () => {
203
+ async function grant(user: any, roles: any[]) {
204
+ const holder = await getRepository(User).findOne({ where: { id: user.id }, relations: ['roles'] })
205
+ holder.roles = roles
206
+ return await getRepository(User).save(holder)
207
+ }
208
+
209
+ it('a role given and taken back', async () => {
210
+ const role = await getRepository(Role).save({ name: 'payroll', domain })
211
+
212
+ await runInAuditScope(koaContext(lee, ''), 'http', () => grant(kim, [role]))
213
+ await runInAuditScope(koaContext(lee, ''), 'http', () => grant(kim, []))
214
+ await flushAudit()
215
+
216
+ const granted = await actions({ kind: 'role.grant' })
217
+ expect(granted).toHaveLength(1)
218
+ expect(granted[0]).toMatchObject({ targetKind: 'User', targetIds: [kim.id], objectKind: 'Role', objectId: role.id })
219
+ expect(granted[0].actor.id).toBe(lee.id)
220
+ expect(granted[0].domain.id).toBe(domain.id)
221
+
222
+ expect(await actions({ kind: 'role.revoke' })).toHaveLength(1)
223
+ })
224
+
225
+ it('a privilege added to a role, and removed', async () => {
226
+ const privilege = await getRepository(Privilege).save({ name: 'query', category: 'payroll' })
227
+ const role = await getRepository(Role).save({ name: 'payroll', domain, privileges: [privilege] })
228
+ role.privileges = []
229
+ await getRepository(Role).save(role)
230
+
231
+ expect(await actions({ kind: 'role.privilege.add' })).toMatchObject([{ targetIds: [role.id], objectKind: 'Privilege', objectId: privilege.id }])
232
+ expect(await actions({ kind: 'role.privilege.remove' })).toHaveLength(1)
233
+ })
234
+
235
+ it('a status change keeps the states, nothing else', async () => {
236
+ kim.status = 'inactive'
237
+ await getRepository(User).save(kim)
238
+
239
+ const [row] = await actions({ kind: 'user.status' })
240
+ expect(row.detail).toEqual({ from: 'activated', to: 'inactive' })
241
+ })
242
+
243
+ it('a grant rolled back leaves no record', async () => {
244
+ const role = await getRepository(Role).save({ name: 'payroll', domain })
245
+
246
+ await expect(
247
+ authDataSource().transaction(async manager => {
248
+ const holder = await manager.getRepository(User).findOne({ where: { id: kim.id }, relations: ['roles'] })
249
+ holder.roles = [role]
250
+ await manager.getRepository(User).save(holder)
251
+ throw new Error('the change failed')
252
+ })
253
+ ).rejects.toThrow('the change failed')
254
+
255
+ expect(await actions({ kind: 'role.grant' })).toHaveLength(0)
256
+ })
257
+
258
+ it('a grant whose record cannot be written does not happen', async () => {
259
+ const role = await getRepository(Role).save({ name: 'payroll', domain })
260
+ const table = authDataSource().getMetadata(AuditAction).tableName
261
+ await authDataSource().query(`ALTER TABLE ${table} RENAME TO ${table}_away`)
262
+
263
+ try {
264
+ await expect(grant(kim, [role])).rejects.toThrow()
265
+ } finally {
266
+ await authDataSource().query(`ALTER TABLE ${table}_away RENAME TO ${table}`)
267
+ }
268
+
269
+ const holder = await getRepository(User).findOne({ where: { id: kim.id }, relations: ['roles'] })
270
+ expect(holder.roles).toHaveLength(0)
271
+ })
272
+ })
273
+
274
+ describe('only the fields it allows (§3.4)', () => {
275
+ it('a detail key the kind did not register is refused', async () => {
276
+ await expect(recordAuditAction({ kind: 'user.status', detail: { salary: 4200000 } })).rejects.toThrow(/does not allow detail "salary"/)
277
+ })
278
+
279
+ it('a kind nobody registered is refused', async () => {
280
+ await expect(recordAuditAction({ kind: 'payroll.view' })).rejects.toThrow(/not registered/)
281
+ })
282
+ })
283
+
284
+ describe('signing in (§3.3)', () => {
285
+ it('a success, a wrong password, an account that does not exist', async () => {
286
+ await signin({ username: 'kim', password: PASSWORD })
287
+ await expect(signin({ username: 'kim', password: 'Wr0ngPass!' })).rejects.toThrow()
288
+ await expect(signin({ username: 'nobody', password: PASSWORD })).rejects.toThrow()
289
+ await flushAudit()
290
+
291
+ const rows = await getRepository(AuditAction).find({ where: { kind: 'auth.signin' }, relations: ['actor'], order: { createdAt: 'ASC' } })
292
+ expect(rows.map(row => [row.outcome, row.code, row.actor?.id ?? null])).toEqual([
293
+ ['ok', null, kim.id],
294
+ ['error', 'password-not-matched', kim.id],
295
+ ['error', 'user not found', null]
296
+ ])
297
+ })
298
+ })
299
+
300
+ describe('a write that fails is counted apart and does not stop the answer (§4-4 · §4-5)', () => {
301
+ it('the request is answered and the failure counted', async () => {
302
+ allow(true)
303
+ const before = auditHealth().failures
304
+ const table = authDataSource().getMetadata(AuditRequest).tableName
305
+ await authDataSource().query(`ALTER TABLE ${table} RENAME TO ${table}_away`)
306
+
307
+ try {
308
+ const result = await request(kim, '{ payslip(employeeId: "e1") }')
309
+ expect(result.data).toEqual({ payslip: 'pay of e1' })
310
+ } finally {
311
+ await authDataSource().query(`ALTER TABLE ${table}_away RENAME TO ${table}`)
312
+ }
313
+
314
+ expect(auditHealth().failures).toBe(before + 1)
315
+ expect(auditHealth().lastFailedAt).toBeInstanceOf(Date)
316
+ })
317
+ })
318
+
319
+ describe('reading the audit (§5 · §6)', () => {
320
+ const day = 24 * 60 * 60 * 1000
321
+ const from = new Date(Date.now() - day)
322
+ const to = new Date(Date.now() + day)
323
+ const context = (user = lee, at = domain) => ({ state: { domain: at, user } })
324
+
325
+ it("each member's checks, audit reads left out, and only this domain's", async () => {
326
+ allow(true)
327
+ await request(kim, '{ payslip(employeeId: "e1") }')
328
+ await request(kim, '{ payslips { id } }', other)
329
+ allow(false)
330
+ await request(kim, '{ payslip(employeeId: "e2") }')
331
+ await recordAuditAction({ kind: 'door.check', door: 'audit:query', decision: 'granted' }, { actorId: kim.id, domainId: domain.id })
332
+ await recordAuditAction({ kind: 'auth.checkin', outcome: 'ok' }, { actorId: kim.id, domainId: domain.id })
333
+
334
+ const people = await new AuditQuery().auditPeople(from, to, context())
335
+ const row = people.find((one: any) => one.user.id === kim.id)
336
+ expect(row).toMatchObject({ granted: 1, denied: 1, checkins: 1 })
337
+ expect(row.lastCheckinAt).toBeInstanceOf(Date)
338
+ /* The moment itself - a driver's zone-less text read as local time was hours off. */
339
+ expect(Math.abs(row.lastCheckinAt.getTime() - Date.now())).toBeLessThan(60_000)
340
+ expect(people.find((one: any) => one.user.id === lee.id)).toMatchObject({ granted: 0, denied: 0, checkins: 0 })
341
+ })
342
+
343
+ it('every declared door, one with no record in the period too', async () => {
344
+ await getRepository(Privilege).save([
345
+ { name: 'query', category: 'payroll' },
346
+ { name: 'mutation', category: 'payroll' },
347
+ { name: 'query', category: 'audit' }
348
+ ])
349
+ allow(true)
350
+ await request(kim, '{ payslip(employeeId: "e1") }')
351
+
352
+ const doors = await new AuditQuery().auditDoors(from, to, context())
353
+ expect(doors.map((one: any) => [one.door, one.people, one.granted])).toEqual([
354
+ ['payroll:mutation', 0, 0],
355
+ ['payroll:query', 1, 1]
356
+ ])
357
+ })
358
+
359
+ it('records narrow by outcome, and never reach another domain', async () => {
360
+ allow(true)
361
+ await request(kim, '{ payslip(employeeId: "e1") }')
362
+ await request(kim, '{ payslip(employeeId: "e1") }', other)
363
+ allow(false)
364
+ await request(lee, '{ payslip(employeeId: "e2") }')
365
+
366
+ const all = await new AuditQuery().auditRecords({ from, to }, context())
367
+ expect(all.total).toBe(2)
368
+
369
+ const denied = await new AuditQuery().auditRecords({ from, to, outcome: 'denied' }, context())
370
+ expect(denied.items.map((one: any) => one.request.actor.id)).toEqual([lee.id])
371
+ expect(denied.items[0].actions.map((one: any) => one.kind)).toEqual(['door.check'])
372
+ })
373
+
374
+ it('my record is what I did, whatever is asked', async () => {
375
+ await recordAuditAction({ kind: 'auth.checkin', outcome: 'ok' }, { actorId: kim.id, domainId: domain.id })
376
+ await recordAuditAction(
377
+ { kind: 'role.grant', targetKind: 'User', targetIds: [kim.id], objectKind: 'Role', objectId: 'r' },
378
+ { actorId: lee.id, domainId: domain.id }
379
+ )
380
+
381
+ const mine = await new AuditQuery().myAuditActions(undefined, 10, context(kim))
382
+ expect(mine.map((one: any) => one.kind)).toEqual(['auth.checkin'])
383
+ })
384
+ })
385
+
386
+ describe('retention (§7)', () => {
387
+ it('removes what is past a year and keeps the rest', async () => {
388
+ await recordAuditAction({ kind: 'auth.checkin', outcome: 'ok' }, { actorId: kim.id, domainId: domain.id })
389
+ const later = new Date(Date.now() + 366 * 24 * 60 * 60 * 1000)
390
+
391
+ expect(await purgeExpiredAudit(new Date())).toEqual({ requests: 0, actions: 0 })
392
+ expect(await purgeExpiredAudit(later)).toEqual({ requests: 0, actions: 1 })
393
+ })
394
+ })
@@ -0,0 +1,58 @@
1
+ /**
2
+ * auth-base's own tasks (ADR-0139 decision 11) - each names doors declared where auth-base runs
3
+ * (its own server or shell's, which it depends on), and says what it is in all six languages.
4
+ *
5
+ * A task naming a category nobody declares is a definition error only when an application builds a
6
+ * seat from it; a task with no words shows its id on the role screen. Both are caught here, where
7
+ * the task is written.
8
+ */
9
+
10
+ import { readdirSync, readFileSync } from 'fs'
11
+ import { join } from 'path'
12
+
13
+ import { loadCompiled } from './compiled'
14
+
15
+ const { ROLE_TASKS } = loadCompiled('role-tasks')
16
+
17
+ const LANGUAGES = ['ko', 'en', 'ja', 'zh', 'es', 'ms']
18
+ const SERVERS = [join(__dirname, '..', 'server'), join(__dirname, '..', '..', 'shell', 'server')]
19
+
20
+ /** Every `@privilege(category: "x", privilege: "y")` written in auth-base's and shell's server source. */
21
+ function declaredDoors(): Set<string> {
22
+ const doors = new Set<string>()
23
+ const walk = (dir: string) => {
24
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
25
+ const path = join(dir, entry.name)
26
+ if (entry.isDirectory()) walk(path)
27
+ else if (entry.name.endsWith('.ts')) {
28
+ const text = readFileSync(path, 'utf8')
29
+ for (const match of text.matchAll(/@privilege\(category: \\?"([^"\\]+)\\?", privilege: \\?"([^"\\]+)\\?"/g)) {
30
+ doors.add(`${match[1]}:${match[2]}`)
31
+ }
32
+ }
33
+ }
34
+ }
35
+ SERVERS.forEach(walk)
36
+ return doors
37
+ }
38
+
39
+ describe("auth-base's tasks", () => {
40
+ const doors = declaredDoors()
41
+
42
+ it.each(ROLE_TASKS.map((task: any) => [task.id, task]))('%s names doors declared where auth-base runs', (_id, task: any) => {
43
+ for (const grant of task.grants) {
44
+ for (const axis of grant.axes) expect(doors).toContain(`${grant.category}:${axis}`)
45
+ }
46
+ })
47
+
48
+ it.each(LANGUAGES)('%s says what each task is', language => {
49
+ const words = JSON.parse(readFileSync(join(__dirname, '..', 'translations', `${language}.json`), 'utf8'))
50
+ for (const task of ROLE_TASKS) {
51
+ expect({ task: task.id, name: !!words[`task.${task.id}.name`], description: !!words[`task.${task.id}.description`] }).toEqual({
52
+ task: task.id,
53
+ name: true,
54
+ description: true
55
+ })
56
+ }
57
+ })
58
+ })
@@ -24,8 +24,6 @@ const { InvitationStatus } = loadCompiled('utils/invitation-state')
24
24
  const { InvitationQuery } = loadCompiled('service/invitation/invitation-query')
25
25
  const { Partner } = loadCompiled('service/partner/partner')
26
26
  const { PartnerQuery } = loadCompiled('service/partner/partner-query')
27
- const { LoginHistory } = loadCompiled('service/login-history/login-history')
28
- const { LoginHistoryQuery } = loadCompiled('service/login-history/login-history-query')
29
27
 
30
28
  let parent: any
31
29
  let child: any
@@ -119,11 +117,6 @@ const LISTS: [string, () => Promise<any>, (params: any, context: any) => Promise
119
117
  'partners',
120
118
  () => getRepository(Partner).save({ domain: parent, partnerDomain, requestedAt: new Date() }),
121
119
  (params, context) => new PartnerQuery().partners(params, context)
122
- ],
123
- [
124
- 'login histories',
125
- () => LoginHistory.stamp(parent, parentUser, '10.0.0.1'),
126
- (params, context) => new LoginHistoryQuery().loginHistories(params, context)
127
120
  ]
128
121
  ]
129
122
 
@@ -80,7 +80,7 @@ describe('the doors that were closed', () => {
80
80
  * signs in, so adding one is not an edit to a settings row
81
81
  * domainUserRoleRegister makes a domain, its roles and its users. `domainRegister`
82
82
  * beside it makes only a domain and is super-user only
83
- * loginHistories everybody's sign-ins
83
+ * loginHistories everybody's sign-ins - now the audit's, behind `audit:query` (ADR-0141)
84
84
  * invitation / invitations and neither of them scopes by tenant at all
85
85
  * sendInvitation / cancelInvitation / inviteUser
86
86
  */
@@ -96,7 +96,7 @@ describe('the doors that were closed', () => {
96
96
  'cancelInvitation',
97
97
  'inviteUser'
98
98
  ],
99
- Query: ['loginHistories', 'invitation', 'invitations']
99
+ Query: ['auditRecords', 'auditPeople', 'auditDoors', 'auditHealth', 'invitation', 'invitations']
100
100
  }
101
101
 
102
102
  it.each(Object.entries(CLOSED).flatMap(([t, fields]) => fields.map(f => [t, f])))('%s.%s', (typeName, field) => {
@@ -64,7 +64,7 @@ describe('a refusal says which of the four it was', () => {
64
64
  it('★ an owner without the role, from an unsafe address: address - from a safe one they pass', async () => {
65
65
  given({ owner: true })
66
66
  expect(await judgePermission(OWNED, USER, DOMAIN, true)).toEqual({ permitted: false, because: 'address' })
67
- expect(await judgePermission(OWNED, USER, DOMAIN, false)).toEqual({ permitted: true })
67
+ expect(await judgePermission(OWNED, USER, DOMAIN, false)).toEqual({ permitted: true, by: 'domain-owner' })
68
68
  })
69
69
 
70
70
  it('an unsafe address whose ownership cannot be asked still refuses as role - it refused before', async () => {
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Role tasks — the registry, what it refuses, and how a role is laid against tasks (ADR-0139 decision 11).
3
+ *
4
+ * The comparison is pure: a role's held privileges, the registered tasks, the declared privileges. The role screen
5
+ * draws exactly this, so what "all", "some", "none" and "broken" mean is pinned here.
6
+ */
7
+
8
+ import { loadCompiled } from './compiled'
9
+
10
+ const { registerRoleTask, roleTasks, roleTaskById, mergeGrants, taskCoverageOf, undeclaredTaskGrants } = loadCompiled('service/role-template/role-task')
11
+ const { registerRoleTemplate, templateGrantsOf, templateDefinitionErrors, unknownTemplateGrants } = loadCompiled('service/role-template/role-template')
12
+
13
+ const DECLARED = {
14
+ 'board query': ['board', 'query'],
15
+ 'board mutation': ['board', 'mutation'],
16
+ 'order query': ['order', 'query'],
17
+ 'worklist query': ['worklist', 'query'],
18
+ 'worklist mutation': ['worklist', 'mutation']
19
+ }
20
+
21
+ beforeEach(() => {
22
+ ;(process as any)['ROLE_TASKS'] = {}
23
+ ;(process as any)['ROLE_TEMPLATES'] = {}
24
+ ;(process as any).PRIVILEGES = { ...DECLARED }
25
+ })
26
+
27
+ const held = (...pairs: string[]) => new Set(pairs.map(pair => pair.replace(':', ' ')))
28
+
29
+ describe('declaring a task', () => {
30
+ it('can be found again by id', () => {
31
+ registerRoleTask({ id: 'board.reading', grants: [{ category: 'board', axes: ['query'] }] })
32
+
33
+ expect(roleTaskById('board.reading')).toEqual({ id: 'board.reading', grants: [{ category: 'board', axes: ['query'] }] })
34
+ expect(roleTasks()).toHaveLength(1)
35
+ })
36
+
37
+ it.each([['reading'], [''], ['.reading']])('refuses the id "%s" - the declaring module comes first', id => {
38
+ expect(() => registerRoleTask({ id, grants: [{ category: 'board', axes: ['query'] }] })).toThrow(/<module>.<work>/)
39
+ })
40
+
41
+ it('refuses a task that holds no privileges', () => {
42
+ expect(() => registerRoleTask({ id: 'board.nothing', grants: [] })).toThrow(/holds no privileges/)
43
+ })
44
+
45
+ it('refuses a grant with no axes - axes are listed one by one', () => {
46
+ expect(() => registerRoleTask({ id: 'board.reading', grants: [{ category: 'board', axes: [] }] })).toThrow(/one by one/)
47
+ })
48
+
49
+ it('refuses the same task declared twice - one piece of work is one task', () => {
50
+ registerRoleTask({ id: 'board.reading', grants: [{ category: 'board', axes: ['query'] }] })
51
+
52
+ expect(() => registerRoleTask({ id: 'board.reading', grants: [{ category: 'order', axes: ['query'] }] })).toThrow(/declared twice/)
53
+ })
54
+
55
+ it('holds a privilege written twice once', () => {
56
+ registerRoleTask({ id: 'board.reading', grants: [{ category: 'board', axes: ['query'] }, { category: 'board', axes: ['query'] }] })
57
+
58
+ expect(roleTaskById('board.reading').grants).toEqual([{ category: 'board', axes: ['query'] }])
59
+ })
60
+
61
+ it('★ lets two tasks share a privilege - that is not a duplicate', () => {
62
+ registerRoleTask({ id: 'board.reading', grants: [{ category: 'board', axes: ['query'] }] })
63
+ registerRoleTask({ id: 'order.reading', grants: [{ category: 'order', axes: ['query'] }, { category: 'board', axes: ['query'] }] })
64
+
65
+ expect(roleTasks()).toHaveLength(2)
66
+ })
67
+
68
+ it('keeps a change-only grant as written - nothing adds query to it', () => {
69
+ registerRoleTask({ id: 'worklist.putting-up', grants: [{ category: 'worklist', axes: ['mutation'] }] })
70
+
71
+ expect(roleTaskById('worklist.putting-up').grants).toEqual([{ category: 'worklist', axes: ['mutation'] }])
72
+ })
73
+ })
74
+
75
+ describe('a template built from tasks', () => {
76
+ beforeEach(() => {
77
+ registerRoleTask({ id: 'board.reading', grants: [{ category: 'board', axes: ['query'] }] })
78
+ registerRoleTask({ id: 'order.reading', grants: [{ category: 'order', axes: ['query'] }, { category: 'board', axes: ['query'] }] })
79
+ })
80
+
81
+ it('★ holds the union of its tasks and its own grants, each privilege once', () => {
82
+ registerRoleTemplate({ id: 'plant.reader', name: 'READER', tasks: ['board.reading', 'order.reading'], grants: [{ category: 'board', axes: ['mutation', 'query'] }] })
83
+
84
+ expect(templateGrantsOf({ id: 'plant.reader', name: 'READER', tasks: ['board.reading', 'order.reading'], grants: [{ category: 'board', axes: ['mutation', 'query'] }] })).toEqual([
85
+ { category: 'board', axes: ['query', 'mutation'] },
86
+ { category: 'order', axes: ['query'] }
87
+ ])
88
+ })
89
+
90
+ it('may be written with tasks and no grants', () => {
91
+ expect(() => registerRoleTemplate({ id: 'plant.reader', name: 'READER', tasks: ['board.reading'] })).not.toThrow()
92
+ })
93
+
94
+ it('still refuses a template with neither - leaving both out is forgetting', () => {
95
+ expect(() => registerRoleTemplate({ id: 'plant.reader', name: 'READER' } as any)).toThrow(/has no grants/)
96
+ })
97
+
98
+ it('★ names an unregistered task as a definition error', () => {
99
+ expect(templateDefinitionErrors({ id: 'plant.reader', name: 'READER', tasks: ['board.reading', 'board.writting'] })).toEqual(['task "board.writting" is not registered'])
100
+ })
101
+
102
+ it('★ names a task privilege this installation does not declare as a definition error', () => {
103
+ registerRoleTask({ id: 'board.exporting', grants: [{ category: 'board-export', axes: ['query'] }] })
104
+
105
+ expect(undeclaredTaskGrants(roleTaskById('board.exporting'))).toEqual(['board-export:query'])
106
+ expect(templateDefinitionErrors({ id: 'plant.reader', name: 'READER', tasks: ['board.exporting'] })).toEqual([
107
+ 'task "board.exporting" names board-export:query, which this installation does not declare'
108
+ ])
109
+ })
110
+
111
+ it("keeps a template's own undeclared grants as before - skipped, not a definition error", () => {
112
+ const template = { id: 'plant.reader', name: 'READER', grants: [{ category: 'kpi', axes: ['query'] }] }
113
+
114
+ expect(templateDefinitionErrors(template)).toEqual([])
115
+ expect(unknownTemplateGrants(template)).toEqual(['kpi:query'])
116
+ })
117
+ })
118
+
119
+ describe('a role laid against the tasks', () => {
120
+ beforeEach(() => {
121
+ registerRoleTask({ id: 'board.reading', grants: [{ category: 'board', axes: ['query'] }] })
122
+ registerRoleTask({ id: 'board.keeping', grants: [{ category: 'board', axes: ['query', 'mutation'] }] })
123
+ registerRoleTask({ id: 'order.reading', grants: [{ category: 'order', axes: ['query'] }, { category: 'board', axes: ['query'] }] })
124
+ })
125
+
126
+ const statuses = (coverage: any) => Object.fromEntries(coverage.tasks.map((task: any) => [task.taskId, task.status]))
127
+
128
+ it('★ says all, some and none by what the role holds of each task', () => {
129
+ const coverage = taskCoverageOf(held('board:query'))
130
+
131
+ expect(statuses(coverage)).toEqual({ 'board.reading': 'all', 'board.keeping': 'some', 'order.reading': 'some' })
132
+ expect(coverage.tasks.find((task: any) => task.taskId === 'board.keeping').missing).toEqual([{ category: 'board', axes: ['mutation'] }])
133
+ })
134
+
135
+ it('★ a shared privilege alone makes every task that names it "some" - reported, not supplied', () => {
136
+ const coverage = taskCoverageOf(held('board:query'))
137
+
138
+ expect(coverage.tasks.find((task: any) => task.taskId === 'order.reading')).toMatchObject({
139
+ status: 'some',
140
+ held: [{ category: 'board', axes: ['query'] }],
141
+ missing: [{ category: 'order', axes: ['query'] }]
142
+ })
143
+ })
144
+
145
+ it('says none for a role that holds nothing of a task', () => {
146
+ expect(statuses(taskCoverageOf(held('worklist:query')))).toEqual({ 'board.reading': 'none', 'board.keeping': 'none', 'order.reading': 'none' })
147
+ })
148
+
149
+ it('★ keeps what no task names apart - and leaves out what nothing declares any more', () => {
150
+ const coverage = taskCoverageOf(held('board:query', 'worklist:query', 'worklist:mutation', 'retired:query'))
151
+
152
+ expect(coverage.unclassified).toEqual([{ category: 'worklist', axes: ['mutation', 'query'] }])
153
+ })
154
+
155
+ it('★ says broken for a task naming an undeclared privilege, whatever the role holds', () => {
156
+ registerRoleTask({ id: 'board.exporting', grants: [{ category: 'board', axes: ['query'] }, { category: 'board-export', axes: ['query'] }] })
157
+
158
+ const task = taskCoverageOf(held('board:query')).tasks.find((one: any) => one.taskId === 'board.exporting')
159
+ expect(task).toMatchObject({ status: 'broken', module: 'board', undeclared: [{ category: 'board-export', axes: ['query'] }] })
160
+ })
161
+
162
+ it('merges grants by category in the order first written', () => {
163
+ expect(mergeGrants([[{ category: 'b', axes: ['x'] }], [{ category: 'a', axes: ['y'] }, { category: 'b', axes: ['z', 'x'] }]])).toEqual([
164
+ { category: 'b', axes: ['x', 'z'] },
165
+ { category: 'a', axes: ['y'] }
166
+ ])
167
+ })
168
+ })