@things-factory/auth-base 10.1.31 → 10.1.33

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 (108) hide show
  1. package/dist-server/controllers/invitation.d.ts +47 -10
  2. package/dist-server/controllers/invitation.js +159 -113
  3. package/dist-server/controllers/invitation.js.map +1 -1
  4. package/dist-server/controllers/profile.d.ts +1 -0
  5. package/dist-server/router/auth-public-process-router.js +36 -13
  6. package/dist-server/router/auth-public-process-router.js.map +1 -1
  7. package/dist-server/router/oauth2/oauth2-router.js +7 -2
  8. package/dist-server/router/oauth2/oauth2-router.js.map +1 -1
  9. package/dist-server/router/oauth2/oauth2-server.js +4 -4
  10. package/dist-server/router/oauth2/oauth2-server.js.map +1 -1
  11. package/dist-server/service/app-binding/app-binding-mutation.d.ts +27 -0
  12. package/dist-server/service/app-binding/app-binding-mutation.js +39 -6
  13. package/dist-server/service/app-binding/app-binding-mutation.js.map +1 -1
  14. package/dist-server/service/app-binding/app-binding-query.js +14 -7
  15. package/dist-server/service/app-binding/app-binding-query.js.map +1 -1
  16. package/dist-server/service/app-binding/app-binding.d.ts +9 -0
  17. package/dist-server/service/app-binding/app-binding.js +10 -1
  18. package/dist-server/service/app-binding/app-binding.js.map +1 -1
  19. package/dist-server/service/appliance/appliance-mutation.js +34 -1
  20. package/dist-server/service/appliance/appliance-mutation.js.map +1 -1
  21. package/dist-server/service/appliance/appliance-query.d.ts +2 -0
  22. package/dist-server/service/appliance/appliance-query.js +48 -0
  23. package/dist-server/service/appliance/appliance-query.js.map +1 -1
  24. package/dist-server/service/appliance/appliance.d.ts +1 -0
  25. package/dist-server/service/appliance/appliance.js +33 -3
  26. package/dist-server/service/appliance/appliance.js.map +1 -1
  27. package/dist-server/service/application/application-mutation.js +51 -6
  28. package/dist-server/service/application/application-mutation.js.map +1 -1
  29. package/dist-server/service/application/application.d.ts +9 -3
  30. package/dist-server/service/application/application.js +24 -8
  31. package/dist-server/service/application/application.js.map +1 -1
  32. package/dist-server/service/auth-provider/auth-provider-mutation.js +5 -0
  33. package/dist-server/service/auth-provider/auth-provider-mutation.js.map +1 -1
  34. package/dist-server/service/domain-generator/domain-generator-mutation.js +3 -0
  35. package/dist-server/service/domain-generator/domain-generator-mutation.js.map +1 -1
  36. package/dist-server/service/invitation/invitation-mutation.d.ts +17 -15
  37. package/dist-server/service/invitation/invitation-mutation.js +83 -56
  38. package/dist-server/service/invitation/invitation-mutation.js.map +1 -1
  39. package/dist-server/service/invitation/invitation-query.d.ts +20 -5
  40. package/dist-server/service/invitation/invitation-query.js +60 -19
  41. package/dist-server/service/invitation/invitation-query.js.map +1 -1
  42. package/dist-server/service/invitation/invitation.d.ts +14 -2
  43. package/dist-server/service/invitation/invitation.js +77 -8
  44. package/dist-server/service/invitation/invitation.js.map +1 -1
  45. package/dist-server/service/login-history/login-history-query.js +3 -0
  46. package/dist-server/service/login-history/login-history-query.js.map +1 -1
  47. package/dist-server/service/role/role-mutation.js +14 -15
  48. package/dist-server/service/role/role-mutation.js.map +1 -1
  49. package/dist-server/service/role/role-query.js +26 -4
  50. package/dist-server/service/role/role-query.js.map +1 -1
  51. package/dist-server/service/role/role-types.d.ts +2 -0
  52. package/dist-server/service/role/role-types.js +8 -0
  53. package/dist-server/service/role/role-types.js.map +1 -1
  54. package/dist-server/service/role-template/role-template-mutation.d.ts +17 -1
  55. package/dist-server/service/role-template/role-template-mutation.js +14 -3
  56. package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
  57. package/dist-server/service/user/user-mutation.js +1 -0
  58. package/dist-server/service/user/user-mutation.js.map +1 -1
  59. package/dist-server/service/user/user.d.ts +1 -0
  60. package/dist-server/service/user/user.js +19 -4
  61. package/dist-server/service/user/user.js.map +1 -1
  62. package/dist-server/templates/invitation-email.d.ts +2 -1
  63. package/dist-server/templates/invitation-email.js +16 -4
  64. package/dist-server/templates/invitation-email.js.map +1 -1
  65. package/dist-server/tsconfig.tsbuildinfo +1 -1
  66. package/dist-server/utils/credential-at-rest.d.ts +28 -0
  67. package/dist-server/utils/credential-at-rest.js +36 -0
  68. package/dist-server/utils/credential-at-rest.js.map +1 -0
  69. package/dist-server/utils/credential-features.d.ts +35 -0
  70. package/dist-server/utils/credential-features.js +45 -0
  71. package/dist-server/utils/credential-features.js.map +1 -0
  72. package/dist-server/utils/credential-serial-rule.d.ts +41 -0
  73. package/dist-server/utils/credential-serial-rule.js +43 -0
  74. package/dist-server/utils/credential-serial-rule.js.map +1 -0
  75. package/dist-server/utils/invitation-state.d.ts +52 -0
  76. package/dist-server/utils/invitation-state.js +76 -0
  77. package/dist-server/utils/invitation-state.js.map +1 -0
  78. package/dist-server/utils/refuse-role-name.d.ts +42 -0
  79. package/dist-server/utils/refuse-role-name.js +76 -0
  80. package/dist-server/utils/refuse-role-name.js.map +1 -0
  81. package/dist-server/utils/role-name-standing.d.ts +58 -0
  82. package/dist-server/utils/role-name-standing.js +72 -0
  83. package/dist-server/utils/role-name-standing.js.map +1 -0
  84. package/package.json +4 -4
  85. package/tests/app-binding-delete-db.test.ts +188 -0
  86. package/tests/appliance-credential-state-db.test.ts +207 -0
  87. package/tests/appliance-delete-db.test.ts +119 -0
  88. package/tests/appliance-revoke-db.test.ts +73 -28
  89. package/tests/appliance-schema.test.ts +129 -0
  90. package/tests/application-delete-db.test.ts +190 -0
  91. package/tests/application-token-payload-db.test.ts +185 -0
  92. package/tests/checkin-privilege-seam-db.test.ts +158 -0
  93. package/tests/credential-at-rest-db.test.ts +246 -0
  94. package/tests/credential-features-off-db.test.ts +189 -0
  95. package/tests/credential-serial-rule.test.ts +84 -0
  96. package/tests/invitation-db.test.ts +416 -0
  97. package/tests/invitation-state.test.ts +92 -0
  98. package/tests/open-doors.test.ts +132 -0
  99. package/tests/role-inheritance-db.test.ts +286 -0
  100. package/tests/role-mutation-db.test.ts +21 -7
  101. package/tests/role-name-lookup-sentinel.test.ts +79 -0
  102. package/tests/role-name-standing.test.ts +83 -0
  103. package/tests/token-issuance.test.ts +7 -3
  104. package/translations/en.json +2 -0
  105. package/translations/ja.json +2 -0
  106. package/translations/ko.json +2 -0
  107. package/translations/ms.json +2 -0
  108. package/translations/zh.json +2 -0
@@ -0,0 +1,158 @@
1
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
2
+ import { loadCompiled } from './compiled'
3
+
4
+ const { Privilege } = loadCompiled('service/privilege/privilege')
5
+ const { Role } = loadCompiled('service/role/role')
6
+ const { User, UserStatus } = loadCompiled('service/user/user')
7
+ const { canUserCheckinDomain } = loadCompiled('utils/can-user-checkin-domain')
8
+
9
+ /**
10
+ * The seam between "may this person come in" and "may this person do that".
11
+ *
12
+ * ── The two rules are not the same rule ─────────────────────────────────────
13
+ * Coming in (`checkin-domain-rule.ts`) asks for **membership AND a role**, or a partner grant,
14
+ * or ownership. The membership half is load-bearing and the file says why: a role defined in a
15
+ * parent domain is inherited by every child, so "holds a parent role" on its own would make a
16
+ * role granted for one child an entry pass to all its siblings.
17
+ *
18
+ * Acting (`User.hasPrivilege`) asks only for a role in this domain or its parent. There is no
19
+ * membership condition. That is on purpose and written down — the comment above it delegates
20
+ * the sibling problem to the checkin gate.
21
+ *
22
+ * ── So what does this file do ───────────────────────────────────────────────
23
+ * It writes down the one line where the two answers differ, which the specs never had:
24
+ *
25
+ * not a member + holds a parent role checkin ✗ privilege ○
26
+ *
27
+ * `spec/unit/checkin-privilege.spec.ts` opens by talking about "a user who checked in to a
28
+ * child domain", and then every one of its ten fixtures makes that user a member — the variable
29
+ * is even called `member`. The premise it names is never the thing it varies, so the gap sits
30
+ * outside all of it.
31
+ *
32
+ * ── Why this is pinned green rather than written red ────────────────────────
33
+ * Both halves are what the code does today, and today it does not leak: every server caller of
34
+ * `hasPrivilege` sits behind `domainAuthenticateMiddleware`, which runs the checkin gate first
35
+ * (measured 2026-09-20 across all nineteen call sites — board-service and figure-service routers
36
+ * are on `domainPrivateRouter`, the rest arrive through GraphQL or the subscription middleware).
37
+ *
38
+ * So this is one layer of depth, not an open door. A red test would mean a broken build for a
39
+ * decision nobody has taken yet. What this does instead is make the gap impossible to close or
40
+ * widen without saying so: **when the privilege layer gains the membership condition, the second
41
+ * assertion here flips**, and that is the axis the fix splits.
42
+ *
43
+ * ⚠ sqlite (`tests/db.ts`).
44
+ */
45
+
46
+ let parent: any
47
+ let child: any
48
+ let sibling: any
49
+ let outsider: any
50
+
51
+ /** `context.state.domain` carries `parentId`; the repository does not fill it in. */
52
+ async function withParentId(domain: any, parentId: string | null) {
53
+ return { ...(await getRepository(Domain).findOneBy({ id: domain.id })), parentId }
54
+ }
55
+
56
+ beforeAll(async () => {
57
+ await openAuthDatabase()
58
+ })
59
+
60
+ afterAll(async () => {
61
+ await closeAuthDatabase()
62
+ })
63
+
64
+ beforeEach(async () => {
65
+ await resetAuthDatabase()
66
+
67
+ const p = await getRepository(Domain).save({ name: 'system', subdomain: 'system' })
68
+ const c = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme', parent: p })
69
+ const s = await getRepository(Domain).save({ name: 'beta', subdomain: 'beta', parent: p })
70
+ const o = await getRepository(Domain).save({ name: 'gamma', subdomain: 'gamma' })
71
+
72
+ parent = await withParentId(p, null)
73
+ child = await withParentId(c, p.id)
74
+ sibling = await withParentId(s, p.id)
75
+ outsider = await withParentId(o, null)
76
+ })
77
+
78
+ async function inheritedRoleHolder(memberOf: any[]) {
79
+ const seeing = await getRepository(Privilege).save({ name: 'query', category: 'board' })
80
+ const role = await getRepository(Role).save({
81
+ name: 'viewer',
82
+ domain: { id: parent.id },
83
+ privileges: [seeing]
84
+ })
85
+
86
+ const person = await getRepository(User).save({
87
+ username: 'kim@acme.z',
88
+ email: 'kim@acme.z',
89
+ name: 'kim',
90
+ status: UserStatus.ACTIVATED,
91
+ domains: memberOf.map(domain => ({ id: domain.id })),
92
+ roles: [role]
93
+ })
94
+
95
+ return await getRepository(User).findOne({ where: { id: person.id }, relations: ['domains', 'roles'] })
96
+ }
97
+
98
+ describe('a member of this domain holding an inherited role', () => {
99
+ it('comes in, and may act', async () => {
100
+ /* The case the inheritance exists for, and the one the specs cover ten times over. */
101
+ const person = await inheritedRoleHolder([child])
102
+
103
+ expect(await canUserCheckinDomain(person, child)).toBe(true)
104
+ expect(await User.hasPrivilege('query', 'board', child, person)).toBe(true)
105
+ })
106
+ })
107
+
108
+ describe('⚠ someone who is not a member but holds the parent role', () => {
109
+ it('does not come in', async () => {
110
+ /*
111
+ * The membership half doing its job. Without it, a role granted inside one child would be
112
+ * an entry pass to every sibling of that child.
113
+ */
114
+ const person = await inheritedRoleHolder([sibling])
115
+
116
+ expect(await canUserCheckinDomain(person, child)).toBe(false)
117
+ })
118
+
119
+ it('⚠ but would be allowed to act, if anything asked', async () => {
120
+ /*
121
+ * ⚠ **This is the gap, written down.** The privilege layer does not ask about membership, so
122
+ * it answers yes for a person the door would have turned away.
123
+ *
124
+ * It costs nothing today because nothing reaches `hasPrivilege` without the door having run
125
+ * first. It is one layer rather than two, and this line is what a second layer would change.
126
+ */
127
+ const person = await inheritedRoleHolder([sibling])
128
+
129
+ expect(await User.hasPrivilege('query', 'board', child, person)).toBe(true)
130
+ })
131
+
132
+ it('holds for someone with no membership at all', async () => {
133
+ /* Not an artefact of being a member of a sibling — belonging nowhere reads the same. */
134
+ const person = await inheritedRoleHolder([])
135
+
136
+ expect(await canUserCheckinDomain(person, child)).toBe(false)
137
+ expect(await User.hasPrivilege('query', 'board', child, person)).toBe(true)
138
+ })
139
+ })
140
+
141
+ describe('what the gap does not extend to', () => {
142
+ it('a domain that is not below the role domain', async () => {
143
+ /*
144
+ * The privilege layer is loose about membership, not about inheritance. A role defined in
145
+ * `system` reaches `system` and its children, and `gamma` is neither.
146
+ */
147
+ const person = await inheritedRoleHolder([outsider])
148
+
149
+ expect(await canUserCheckinDomain(person, outsider)).toBe(false)
150
+ expect(await User.hasPrivilege('query', 'board', outsider, person)).toBe(false)
151
+ })
152
+
153
+ it('a privilege the role does not carry', async () => {
154
+ const person = await inheritedRoleHolder([sibling])
155
+
156
+ expect(await User.hasPrivilege('mutation', 'board', child, person)).toBe(false)
157
+ })
158
+ })
@@ -0,0 +1,246 @@
1
+ import { config } from '@things-factory/env'
2
+
3
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
4
+ import { loadCompiled } from './compiled'
5
+
6
+ const { Appliance } = loadCompiled('service/appliance/appliance')
7
+ const { Application } = loadCompiled('service/application/application')
8
+ const { User, UserStatus } = loadCompiled('service/user/user')
9
+
10
+ /**
11
+ * Storing a credential as ciphertext, on the installations that asked for it (ADR-0085).
12
+ *
13
+ * ── The three states that have to hold ──────────────────────────────────────
14
+ *
15
+ * off writes plaintext, reads plaintext an untouched deployment
16
+ * on writes ciphertext, reads either the rows convert one at a time
17
+ * on then off writes plaintext again, reads either turning it off loses nothing
18
+ *
19
+ * The third is the one that makes the switch safe to try. If reading followed the switch, a
20
+ * deployment that turned it on and then changed its mind would lose every row written while it
21
+ * was on — the values would still be in the table and would read as nothing.
22
+ *
23
+ * These go through the repository rather than calling the transformer, so what is checked is
24
+ * what a column actually does. The raw column is read with a second query that bypasses the
25
+ * entity, which is the only way to see which shape is on disk.
26
+ *
27
+ * ⚠ sqlite (`tests/db.ts`).
28
+ */
29
+
30
+ /** What `shell`'s transformer writes: 32 hex characters of IV, a colon, then hex. */
31
+ const CIPHERTEXT = /^[0-9a-f]{32}:(?:[0-9a-f]{2})+$/
32
+
33
+ const configGet = config.get
34
+
35
+ function encryptionOff() {
36
+ config.get = configGet
37
+ }
38
+
39
+ function encryptionOn() {
40
+ config.get = ((key: string, fallback?: any) =>
41
+ key === 'credential/encryptAtRest' ? true : configGet(key, fallback)) as typeof config.get
42
+ }
43
+
44
+ let domain: any
45
+
46
+ /** The bytes on disk, read without the entity so the transformer does not run. */
47
+ async function storedAccessToken(id: string): Promise<string> {
48
+ const [row] = await getRepository(Appliance).query('select access_token from appliances where id = ?', [id])
49
+
50
+ return row?.access_token
51
+ }
52
+
53
+ async function storedAppSecret(id: string): Promise<string> {
54
+ const [row] = await getRepository(Application).query('select app_secret from applications where id = ?', [id])
55
+
56
+ return row?.app_secret
57
+ }
58
+
59
+ /** Application declares six columns non-null; only appSecret matters here. */
60
+ async function saveApplication(appSecret: string) {
61
+ return await getRepository(Application).save({
62
+ domain,
63
+ name: 'billing',
64
+ email: 'billing@acme.z',
65
+ url: 'https://billing.example.com',
66
+ redirectUrl: 'https://billing.example.com/callback',
67
+ status: 0,
68
+ type: 'OTHERS',
69
+ appSecret
70
+ })
71
+ }
72
+
73
+ async function saveAppliance(name: string, accessToken: string) {
74
+ return await getRepository(Appliance).save({
75
+ domain,
76
+ name,
77
+ serialNo: `${name}-SN`,
78
+ brand: 'Acme',
79
+ model: 'X1',
80
+ accessToken
81
+ })
82
+ }
83
+
84
+ beforeAll(async () => {
85
+ await openAuthDatabase()
86
+ })
87
+
88
+ afterAll(async () => {
89
+ encryptionOff()
90
+ await closeAuthDatabase()
91
+ })
92
+
93
+ beforeEach(async () => {
94
+ encryptionOff()
95
+ await resetAuthDatabase()
96
+
97
+ domain = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme' })
98
+ })
99
+
100
+ describe('with the switch off — an installation that configured nothing', () => {
101
+ it('stores the token as it always did', async () => {
102
+ const saved = await saveAppliance('gate-a', 'plain-token-value')
103
+
104
+ expect(await storedAccessToken(saved.id)).toEqual('plain-token-value')
105
+ })
106
+
107
+ it('reads back what it wrote', async () => {
108
+ const saved = await saveAppliance('gate-b', 'plain-token-value')
109
+
110
+ expect((await getRepository(Appliance).findOneBy({ id: saved.id })).accessToken).toEqual('plain-token-value')
111
+ })
112
+
113
+ it('stores an application secret as it always did', async () => {
114
+ const saved = await saveApplication('plain-secret')
115
+
116
+ expect(await storedAppSecret(saved.id)).toEqual('plain-secret')
117
+ expect((await getRepository(Application).findOneBy({ id: saved.id })).appSecret).toEqual('plain-secret')
118
+ })
119
+ })
120
+
121
+ describe('with the switch on', () => {
122
+ beforeEach(() => encryptionOn())
123
+
124
+ it('stores the token as ciphertext', async () => {
125
+ const saved = await saveAppliance('gate-c', 'secret-token-value')
126
+ const onDisk = await storedAccessToken(saved.id)
127
+
128
+ expect(onDisk).toMatch(CIPHERTEXT)
129
+ expect(onDisk).not.toContain('secret-token-value')
130
+ })
131
+
132
+ it('reads it back as what was written', async () => {
133
+ const saved = await saveAppliance('gate-d', 'secret-token-value')
134
+
135
+ expect((await getRepository(Appliance).findOneBy({ id: saved.id })).accessToken).toEqual('secret-token-value')
136
+ })
137
+
138
+ it('stores an application secret as ciphertext', async () => {
139
+ const saved = await saveApplication('secret-value')
140
+
141
+ expect(await storedAppSecret(saved.id)).toMatch(CIPHERTEXT)
142
+ expect((await getRepository(Application).findOneBy({ id: saved.id })).appSecret).toEqual('secret-value')
143
+ })
144
+
145
+ it('does not make two rows with the same secret look the same at rest', async () => {
146
+ /* The IV is per write. Without it, matching rows would say which appliances share a token. */
147
+ const a = await saveAppliance('gate-e', 'same-token')
148
+ const b = await saveAppliance('gate-f', 'same-token')
149
+
150
+ expect(await storedAccessToken(a.id)).not.toEqual(await storedAccessToken(b.id))
151
+ })
152
+ })
153
+
154
+ describe('turning it on over rows that are already there', () => {
155
+ it('still reads a row written before the switch', async () => {
156
+ /*
157
+ * ⚠ The case that lets the switch be turned on at all. Every row in the table is plaintext
158
+ * on the day it goes on. If reading them answered nothing, the credentials would vanish from
159
+ * the screens while sitting in the table.
160
+ */
161
+ const saved = await saveAppliance('gate-g', 'written-before')
162
+
163
+ encryptionOn()
164
+
165
+ expect((await getRepository(Appliance).findOneBy({ id: saved.id })).accessToken).toEqual('written-before')
166
+ expect(await storedAccessToken(saved.id)).toEqual('written-before')
167
+ })
168
+
169
+ it('converts that row the next time it is written', async () => {
170
+ /* One row at a time, as each is touched. There is no pass over the table. */
171
+ const saved = await saveAppliance('gate-h', 'written-before')
172
+
173
+ encryptionOn()
174
+ await getRepository(Appliance).save({ ...saved, accessToken: 'written-after' })
175
+
176
+ expect(await storedAccessToken(saved.id)).toMatch(CIPHERTEXT)
177
+ expect((await getRepository(Appliance).findOneBy({ id: saved.id })).accessToken).toEqual('written-after')
178
+ })
179
+
180
+ it('leaves untouched rows alone', async () => {
181
+ const converted = await saveAppliance('gate-i', 'one')
182
+ const untouched = await saveAppliance('gate-j', 'two')
183
+
184
+ encryptionOn()
185
+ await getRepository(Appliance).save({ ...converted, accessToken: 'one-again' })
186
+
187
+ expect(await storedAccessToken(converted.id)).toMatch(CIPHERTEXT)
188
+ expect(await storedAccessToken(untouched.id)).toEqual('two')
189
+
190
+ /* And both still read. That is the state a half-converted installation is in. */
191
+ expect((await getRepository(Appliance).findOneBy({ id: converted.id })).accessToken).toEqual('one-again')
192
+ expect((await getRepository(Appliance).findOneBy({ id: untouched.id })).accessToken).toEqual('two')
193
+ })
194
+ })
195
+
196
+ describe('turning it off after it has been on', () => {
197
+ it('keeps reading the rows written while it was on', async () => {
198
+ /*
199
+ * ⚠ What makes the switch safe to try. The switch governs writing only — if it governed
200
+ * reading, turning it off would strand every row written while it was on.
201
+ */
202
+ encryptionOn()
203
+ const saved = await saveAppliance('gate-k', 'written-while-on')
204
+
205
+ expect(await storedAccessToken(saved.id)).toMatch(CIPHERTEXT)
206
+
207
+ encryptionOff()
208
+
209
+ expect((await getRepository(Appliance).findOneBy({ id: saved.id })).accessToken).toEqual('written-while-on')
210
+ })
211
+
212
+ it('writes plaintext again from then on', async () => {
213
+ encryptionOn()
214
+ const saved = await saveAppliance('gate-l', 'written-while-on')
215
+
216
+ encryptionOff()
217
+ await getRepository(Appliance).save({ ...saved, accessToken: 'written-while-off' })
218
+
219
+ expect(await storedAccessToken(saved.id)).toEqual('written-while-off')
220
+ })
221
+ })
222
+
223
+ describe('what this does not touch', () => {
224
+ it('leaves User.password alone', async () => {
225
+ /*
226
+ * ⚠ Deliberate, and the reason is in `credential-at-rest.ts`. That column holds an HMAC for a
227
+ * person and a token for a machine, and a column transformer cannot tell them apart.
228
+ * Encrypting everybody's password hash to reach the machines' tokens is the wrong trade; the
229
+ * machine credential needs a column of its own (§8 step 4).
230
+ */
231
+ encryptionOn()
232
+
233
+ const user = await getRepository(User).save({
234
+ username: 'operator@acme.z',
235
+ email: 'operator@acme.z',
236
+ name: 'operator',
237
+ status: UserStatus.ACTIVATED,
238
+ password: 'hashed-value',
239
+ domains: [domain]
240
+ })
241
+
242
+ const [row] = await getRepository(User).query('select password from users where id = ?', [user.id])
243
+
244
+ expect(row.password).toEqual('hashed-value')
245
+ })
246
+ })
@@ -0,0 +1,189 @@
1
+ import { config } from '@things-factory/env'
2
+
3
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
4
+ import { loadCompiled } from './compiled'
5
+
6
+ const jwt = require('jsonwebtoken')
7
+
8
+ const { User, UserStatus } = loadCompiled('service/user/user')
9
+ const { ApplianceMutation } = loadCompiled('service/appliance/appliance-mutation')
10
+
11
+ const mutation = new ApplianceMutation()
12
+
13
+ /**
14
+ * What an installation that configures nothing sees (ADR-0085 decisions 1 and 2).
15
+ *
16
+ * ── Why this file exists ────────────────────────────────────────────────────
17
+ * An appliance token lives a year, and the ones in the field were issued by the version running
18
+ * now. The whole design rests on one claim: **taking this release changes nothing until the
19
+ * installation asks for it.** That claim is easy to state and easy to break — a door that
20
+ * forgets its switch, a serial that starts filling in on its own — so it is held here rather
21
+ * than in prose.
22
+ *
23
+ * These cases run with no config set at all, which is what `config.get(key, false)` sees in a
24
+ * deployment whose config file was never touched.
25
+ *
26
+ * `appliance-revoke-db.test.ts` holds the other side: what the features do once they are on.
27
+ */
28
+
29
+ let domain: any
30
+ let actor: any
31
+
32
+ function context(d: any = domain) {
33
+ return {
34
+ state: { domain: d, user: actor, tx: undefined },
35
+ throw: (code: number, message: string) => {
36
+ const error: any = new Error(message)
37
+ error.status = code
38
+ throw error
39
+ }
40
+ } as any
41
+ }
42
+
43
+ async function shadowUserOf(applianceId: string) {
44
+ return await getRepository(User).findOneBy({ reference: applianceId, userType: 'appliance' })
45
+ }
46
+
47
+ beforeAll(async () => {
48
+ await openAuthDatabase()
49
+ })
50
+
51
+ afterAll(async () => {
52
+ await closeAuthDatabase()
53
+ })
54
+
55
+ beforeEach(async () => {
56
+ await resetAuthDatabase()
57
+
58
+ domain = await getRepository(Domain).save({ name: 'acme', subdomain: 'acme' })
59
+ actor = await getRepository(User).save({
60
+ username: 'operator@acme.z',
61
+ email: 'operator@acme.z',
62
+ name: 'operator',
63
+ status: UserStatus.ACTIVATED,
64
+ domains: [domain]
65
+ })
66
+ })
67
+
68
+ describe('an installation that configures neither feature', () => {
69
+ it('reads both switches as off', () => {
70
+ /*
71
+ * The mechanism the rest of this file depends on. `config.get` answers the default when the
72
+ * key is absent, so "never configured" and "off" are one thing.
73
+ */
74
+ expect(config.get('credential/encryptAtRest', false)).toBeFalsy()
75
+ expect(config.get('credential/revocable', false)).toBeFalsy()
76
+ })
77
+
78
+ it('has no revocation door', async () => {
79
+ /*
80
+ * ⚠ The case the switch exists for. `revokeApplianceCredential` shipped before this design
81
+ * and went out without a switch; it belongs behind one now, and an installation that never
82
+ * asked for it must not find it.
83
+ */
84
+ const appliance = await mutation.createAppliance(
85
+ { name: 'gate-a', serialNo: 'SN-A', brand: 'Acme', model: 'X1' },
86
+ context()
87
+ )
88
+
89
+ await expect(mutation.revokeApplianceCredential(appliance.id, context())).rejects.toThrow(/not enabled/)
90
+ })
91
+
92
+ it('leaves the appliance untouched when the door refuses', async () => {
93
+ /* A refusal that half-applied would be worse than no door at all. */
94
+ const appliance = await mutation.createAppliance(
95
+ { name: 'gate-b', serialNo: 'SN-B', brand: 'Acme', model: 'X1' },
96
+ context()
97
+ )
98
+ const issued = await mutation.generateApplianceSecret(appliance.id, context())
99
+
100
+ await expect(mutation.revokeApplianceCredential(issued.id, context())).rejects.toThrow()
101
+
102
+ const appuser = await shadowUserOf(issued.id)
103
+
104
+ expect(appuser!.status).toEqual(UserStatus.ACTIVATED)
105
+ expect(appuser!.password).toEqual(issued.accessToken)
106
+ })
107
+
108
+ it('still issues a working credential', async () => {
109
+ /*
110
+ * Registering and issuing are not behind either switch — they are what the product already
111
+ * does. Only revocation and encryption are new.
112
+ */
113
+ const appliance = await mutation.createAppliance(
114
+ { name: 'gate-c', serialNo: 'SN-C', brand: 'Acme', model: 'X1' },
115
+ context()
116
+ )
117
+ const issued = await mutation.generateApplianceSecret(appliance.id, context())
118
+
119
+ expect(issued.accessToken).toBeTruthy()
120
+ await expect(User.checkAuth(jwt.decode(issued.accessToken))).resolves.toBeTruthy()
121
+ })
122
+
123
+ it('stores the credential as it always did', async () => {
124
+ /*
125
+ * ⚠ Encryption is a write-side switch. With it off the value goes in as it always has, so a
126
+ * deployment that takes this release and configures nothing keeps rows in the same shape.
127
+ */
128
+ const appliance = await mutation.createAppliance(
129
+ { name: 'gate-d', serialNo: 'SN-D', brand: 'Acme', model: 'X1' },
130
+ context()
131
+ )
132
+ const issued = await mutation.generateApplianceSecret(appliance.id, context())
133
+
134
+ const appuser = await shadowUserOf(issued.id)
135
+
136
+ expect(appuser!.password).toEqual(issued.accessToken)
137
+ expect(appuser!.password.split('.')).toHaveLength(3)
138
+ })
139
+ })
140
+
141
+ describe('a token issued before this release', () => {
142
+ /*
143
+ * Such a token carries no credential serial, because the column did not exist when it was
144
+ * signed. Those are the tokens already in the field, and they are the ones that must not be
145
+ * cut off by taking this release.
146
+ */
147
+
148
+ it('carries no serial, and is still accepted', async () => {
149
+ const appliance = await mutation.createAppliance(
150
+ { name: 'gate-e', serialNo: 'SN-E', brand: 'Acme', model: 'X1' },
151
+ context()
152
+ )
153
+ const issued = await mutation.generateApplianceSecret(appliance.id, context())
154
+
155
+ /* Put the subject back the way this release found it: issued, but with no serial recorded. */
156
+ const appuser = await shadowUserOf(issued.id)
157
+ await getRepository(User).save({ ...appuser, credentialSerial: null })
158
+
159
+ const legacyToken = { ...jwt.decode(issued.accessToken) }
160
+ delete legacyToken.cs
161
+
162
+ await expect(User.checkAuth(legacyToken)).resolves.toBeTruthy()
163
+ })
164
+
165
+ it('is accepted even beside a subject that has moved on', async () => {
166
+ /*
167
+ * Two appliances in one installation, one converted and one not. The check has to be per
168
+ * subject — a single flag for the whole installation would cut off everything at once.
169
+ */
170
+ const a = await mutation.generateApplianceSecret(
171
+ (await mutation.createAppliance({ name: 'gate-f', serialNo: 'SN-F', brand: 'Acme', model: 'X1' }, context())).id,
172
+ context()
173
+ )
174
+ const b = await mutation.generateApplianceSecret(
175
+ (await mutation.createAppliance({ name: 'gate-g', serialNo: 'SN-G', brand: 'Acme', model: 'X1' }, context())).id,
176
+ context()
177
+ )
178
+
179
+ const legacy = await shadowUserOf(a.id)
180
+ await getRepository(User).save({ ...legacy, credentialSerial: null })
181
+
182
+ const legacyToken = { ...jwt.decode(a.accessToken) }
183
+ delete legacyToken.cs
184
+
185
+ await expect(User.checkAuth(legacyToken)).resolves.toBeTruthy()
186
+ await expect(User.checkAuth(jwt.decode(b.accessToken))).resolves.toBeTruthy()
187
+ expect((await shadowUserOf(b.id))!.credentialSerial).toEqual(1)
188
+ })
189
+ })
@@ -0,0 +1,84 @@
1
+ import { credentialSerialMatches } from '../server/utils/credential-serial-rule'
2
+
3
+ /**
4
+ * The one line in `checkAuth` that can refuse a token whose signature is good.
5
+ *
6
+ * Two things have to stay true at once, and they pull in opposite directions:
7
+ *
8
+ * a token issued before this release must pass — or taking the release cuts off the field
9
+ * a token from an earlier generation must not — or revocation does not revoke
10
+ *
11
+ * Both hang on what an absent value means on each side, which is why the cases below are mostly
12
+ * about absence.
13
+ */
14
+
15
+ describe('a subject that has never been through the new path', () => {
16
+ it('accepts a token that carries no serial', () => {
17
+ /*
18
+ * ⚠ The tokens already in the field. They were signed before the column existed, and the
19
+ * subjects they name have nothing recorded. If this refused, every appliance would stop on
20
+ * the day the release lands.
21
+ */
22
+ expect(credentialSerialMatches({ credentialSerial: null }, {})).toBe(true)
23
+ expect(credentialSerialMatches({ credentialSerial: undefined }, {})).toBe(true)
24
+ expect(credentialSerialMatches({}, {})).toBe(true)
25
+ })
26
+
27
+ it('accepts a token that carries one anyway', () => {
28
+ /* Nothing to compare against. The subject decides whether the rule applies, not the token. */
29
+ expect(credentialSerialMatches({ credentialSerial: null }, { cs: 7 })).toBe(true)
30
+ })
31
+
32
+ it('does not fall over on a missing subject or missing claims', () => {
33
+ expect(credentialSerialMatches(undefined as any, {})).toBe(true)
34
+ expect(credentialSerialMatches({ credentialSerial: null }, undefined as any)).toBe(true)
35
+ })
36
+ })
37
+
38
+ describe('a subject that has moved on', () => {
39
+ it('accepts the generation it is on', () => {
40
+ expect(credentialSerialMatches({ credentialSerial: 3 }, { cs: 3 })).toBe(true)
41
+ })
42
+
43
+ it('refuses an earlier generation', () => {
44
+ /* ⚠ What revocation rests on. Without this the old token keeps working. */
45
+ expect(credentialSerialMatches({ credentialSerial: 3 }, { cs: 2 })).toBe(false)
46
+ })
47
+
48
+ it('refuses a later one too', () => {
49
+ /* Not a thing we issue, so it is a token from somewhere we did not sign it. */
50
+ expect(credentialSerialMatches({ credentialSerial: 3 }, { cs: 4 })).toBe(false)
51
+ })
52
+
53
+ it('refuses a token with no serial at all', () => {
54
+ /*
55
+ * ⚠ The case the whole design turns on. Once a subject has a serial, the tokens it had
56
+ * before that — the ones with nothing in them — are the revoked ones.
57
+ */
58
+ expect(credentialSerialMatches({ credentialSerial: 1 }, {})).toBe(false)
59
+ expect(credentialSerialMatches({ credentialSerial: 1 }, { cs: null })).toBe(false)
60
+ })
61
+
62
+ it('refuses the first generation once it has moved past it', () => {
63
+ expect(credentialSerialMatches({ credentialSerial: 2 }, { cs: 1 })).toBe(false)
64
+ })
65
+ })
66
+
67
+ describe('what it compares', () => {
68
+ it('does not take a string for the number', () => {
69
+ /*
70
+ * A claim arrives from a decoded token, so its type is whatever was signed. `'3'` is not the
71
+ * third generation — leaning the other way would let a shaped-but-wrong claim through.
72
+ */
73
+ expect(credentialSerialMatches({ credentialSerial: 3 }, { cs: '3' as any })).toBe(false)
74
+ })
75
+
76
+ it('treats generation zero as a generation', () => {
77
+ /*
78
+ * We start at 1, so 0 should not appear. But if it ever did, zero is a value and not an
79
+ * absence — reading it as absent would let everything through for that subject.
80
+ */
81
+ expect(credentialSerialMatches({ credentialSerial: 0 }, { cs: 0 })).toBe(true)
82
+ expect(credentialSerialMatches({ credentialSerial: 0 }, {})).toBe(false)
83
+ })
84
+ })