@things-factory/auth-base 10.1.34 → 10.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-client/auth.d.ts +8 -0
- package/dist-client/auth.js +26 -0
- package/dist-client/auth.js.map +1 -1
- package/dist-client/domain-required.d.ts +54 -0
- package/dist-client/domain-required.js +91 -0
- package/dist-client/domain-required.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/controllers/invitation.d.ts +26 -0
- package/dist-server/controllers/invitation.js +12 -1
- package/dist-server/controllers/invitation.js.map +1 -1
- package/dist-server/service/app-binding/app-binding-query.d.ts +17 -0
- package/dist-server/service/app-binding/app-binding-query.js +32 -2
- package/dist-server/service/app-binding/app-binding-query.js.map +1 -1
- package/dist-server/service/index.d.ts +7 -2
- package/dist-server/service/index.js +3 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/open-fields.d.ts +9 -0
- package/dist-server/service/open-fields.js +24 -0
- package/dist-server/service/open-fields.js.map +1 -0
- package/dist-server/service/privilege/open-field-rule.d.ts +93 -0
- package/dist-server/service/privilege/open-field-rule.js +81 -0
- package/dist-server/service/privilege/open-field-rule.js.map +1 -0
- package/dist-server/service/privilege/privilege-directive.d.ts +9 -1
- package/dist-server/service/privilege/privilege-directive.js +177 -95
- package/dist-server/service/privilege/privilege-directive.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/tests/app-binding-db.test.ts +48 -0
- package/tests/domain-required.test.ts +127 -0
- package/tests/invitation-route.test.ts +97 -0
- package/tests/open-doors.test.ts +13 -21
- package/tests/open-fields-enforcement.test.ts +215 -0
- package/translations/en.json +0 -1
- package/translations/ja.json +5 -6
- package/translations/ko.json +6 -7
- package/translations/ms.json +0 -1
- package/translations/zh.json +5 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.38",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"@reduxjs/toolkit": "^2.2.5",
|
|
35
35
|
"@simplewebauthn/browser": "^13.0.0",
|
|
36
36
|
"@simplewebauthn/server": "^13.0.0",
|
|
37
|
-
"@things-factory/email-base": "^10.1.
|
|
37
|
+
"@things-factory/email-base": "^10.1.38",
|
|
38
38
|
"@things-factory/env": "^10.1.20",
|
|
39
|
-
"@things-factory/shell": "^10.1.
|
|
39
|
+
"@things-factory/shell": "^10.1.38",
|
|
40
40
|
"@things-factory/utils": "^10.1.20",
|
|
41
41
|
"@types/webappsec-credential-management": "^0.6.9",
|
|
42
42
|
"jsonwebtoken": "^9.0.0",
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"passport-jwt": "^4.0.0",
|
|
49
49
|
"passport-local": "^1.0.0"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "b947349fe1c27d11038b592ceadaba336f14785b"
|
|
52
52
|
}
|
|
@@ -160,3 +160,51 @@ describe('the list', () => {
|
|
|
160
160
|
expect(items).toHaveLength(1)
|
|
161
161
|
})
|
|
162
162
|
})
|
|
163
|
+
|
|
164
|
+
describe('the free-text term', () => {
|
|
165
|
+
/*
|
|
166
|
+
* The screen now sends the term itself (`ListParam.search`) and lets the server say where it
|
|
167
|
+
* looks. This door builds with the older `buildQuery`, which only understood search *filters* -
|
|
168
|
+
* so a term that arrived here was dropped, and the whole list came back as if it were the answer.
|
|
169
|
+
* A person searching for a binding's name read that as "not here".
|
|
170
|
+
*/
|
|
171
|
+
it('narrows the list to the bindings that match', async () => {
|
|
172
|
+
await makeUser(ours, 'erp-bridge', 'application')
|
|
173
|
+
await makeUser(ours, 'label-printer', 'application')
|
|
174
|
+
await makeUser(ours, 'erp-archive', 'application')
|
|
175
|
+
|
|
176
|
+
const { items, total } = await query.appBindings({ search: 'erp' } as any, context(ours))
|
|
177
|
+
|
|
178
|
+
expect(items.map((one: any) => one.username).sort()).toEqual(['erp-archive', 'erp-bridge'])
|
|
179
|
+
expect(total).toBe(2)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('looks in the email too', async () => {
|
|
183
|
+
await makeUser(ours, 'scanner-1', 'application')
|
|
184
|
+
await makeUser(ours, 'scanner-2', 'application')
|
|
185
|
+
|
|
186
|
+
const { items } = await query.appBindings({ search: 'scanner-2@example' } as any, context(ours))
|
|
187
|
+
|
|
188
|
+
expect(items.map((one: any) => one.username)).toEqual(['scanner-2'])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('still answers only for this domain when a term is given', async () => {
|
|
192
|
+
/* A term must not widen the tenant: the OR of the search sits inside the domain condition. */
|
|
193
|
+
await makeUser(ours, 'erp-ours', 'application')
|
|
194
|
+
await makeUser(theirs, 'erp-theirs', 'application')
|
|
195
|
+
|
|
196
|
+
const { items } = await query.appBindings({ search: 'erp' } as any, context(ours))
|
|
197
|
+
|
|
198
|
+
expect(items.map((one: any) => one.username)).toEqual(['erp-ours'])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('an empty term is no term', async () => {
|
|
202
|
+
await makeUser(ours, 'app-one', 'application')
|
|
203
|
+
await makeUser(ours, 'app-two', 'application')
|
|
204
|
+
|
|
205
|
+
const { total } = await query.appBindings({ search: ' ' } as any, context(ours))
|
|
206
|
+
|
|
207
|
+
expect(total).toBe(2)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { decideDomainRedirect, DOMAIN_REDIRECT_WINDOW_MS, domainRequiredTarget } from '../client/domain-required'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a screen that came up before the shell settled on a domain is sent.
|
|
5
|
+
*
|
|
6
|
+
* Every query on such a screen answers 403, and "try again" answers 403 again, forever. The shell
|
|
7
|
+
* sends the reader to check-in - the same place the server already sends an HTML request - and
|
|
8
|
+
* never from check-in or sign-in themselves, where it would go round in a circle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
describe('domainRequiredTarget', () => {
|
|
12
|
+
test('a screen without a domain segment goes to check-in, carrying where it was', () => {
|
|
13
|
+
const target = new URL(domainRequiredTarget('http://localhost:4000/activity-catalog')!)
|
|
14
|
+
|
|
15
|
+
expect(target.pathname).toBe('/auth/checkin')
|
|
16
|
+
expect(target.searchParams.get('redirect_to')).toBe('http://localhost:4000/activity-catalog')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('a screen on a domain the reader may not enter also goes to check-in - the server names why', () => {
|
|
20
|
+
const target = new URL(domainRequiredTarget('http://localhost:4000/domain/other/activity-catalog')!)
|
|
21
|
+
|
|
22
|
+
expect(target.pathname).toBe('/auth/checkin')
|
|
23
|
+
expect(target.searchParams.get('redirect_to')).toBe('http://localhost:4000/domain/other/activity-catalog')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('the query string and hash of the screen survive the round trip', () => {
|
|
27
|
+
const from = 'http://localhost:4000/activity-detail?name=MES%20Disposition%20Approval#line'
|
|
28
|
+
const target = new URL(domainRequiredTarget(from)!)
|
|
29
|
+
|
|
30
|
+
expect(target.searchParams.get('redirect_to')).toBe(from)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('it stays on the same origin', () => {
|
|
34
|
+
expect(new URL(domainRequiredTarget('https://plant.example.com/x')!).origin).toBe('https://plant.example.com')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('check-in itself is never sent to check-in', () => {
|
|
38
|
+
expect(domainRequiredTarget('http://localhost:4000/auth/checkin?redirect_to=%2F')).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('sign-in and the other auth screens are left alone', () => {
|
|
42
|
+
expect(domainRequiredTarget('http://localhost:4000/auth/signin')).toBeNull()
|
|
43
|
+
expect(domainRequiredTarget('http://localhost:4000/auth')).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('a path that only starts with the letters auth is not an auth screen', () => {
|
|
47
|
+
expect(domainRequiredTarget('http://localhost:4000/authority-list')).not.toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('something that is not an address is not guessed at', () => {
|
|
51
|
+
expect(domainRequiredTarget('')).toBeNull()
|
|
52
|
+
expect(domainRequiredTarget('not a url')).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* A 403 is not only "no domain". The shell also answers 403 to an address on the site's IP block list
|
|
57
|
+
* (shell/server/middlewares/domain-middleware.ts). That reader has a domain; check-in sends them
|
|
58
|
+
* straight back and the screen gets 403 again. Without this guard the tab goes round forever.
|
|
59
|
+
*/
|
|
60
|
+
describe('after one round trip it stops', () => {
|
|
61
|
+
const now = 1_000_000_000
|
|
62
|
+
const here = 'http://localhost:4000/domain/miratek/activity-catalog'
|
|
63
|
+
|
|
64
|
+
test('sent a moment ago: not sent again, whatever the reason for the 403', () => {
|
|
65
|
+
expect(domainRequiredTarget(here, now - 2_000, now)).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('it is the time that counts, not the address - the server changed the address on the way back', () => {
|
|
69
|
+
expect(domainRequiredTarget('http://localhost:4000/activity-catalog', now - 2_000, now)).toBeNull()
|
|
70
|
+
expect(domainRequiredTarget(here, now - 2_000, now)).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('once the window has passed, a new 403 is a new case and is sent', () => {
|
|
74
|
+
expect(domainRequiredTarget(here, now - DOMAIN_REDIRECT_WINDOW_MS, now)).not.toBeNull()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('never sent before: sent', () => {
|
|
78
|
+
expect(domainRequiredTarget(here, null, now)).not.toBeNull()
|
|
79
|
+
expect(domainRequiredTarget(here, undefined, now)).not.toBeNull()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('a stamp from the future (a clock that moved back) does not block for ever', () => {
|
|
83
|
+
expect(domainRequiredTarget(here, now + 60_000, now)).not.toBeNull()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/*
|
|
88
|
+
* The stamp is the only thing that knows "we just came back". A browser that blocks storage would lose it,
|
|
89
|
+
* and a reader on the IP block list would go round again. So storage that does not work means: do not send.
|
|
90
|
+
*/
|
|
91
|
+
describe('decideDomainRedirect - the stamp must work or nothing is sent', () => {
|
|
92
|
+
const now = 1_000_000_000
|
|
93
|
+
const here = 'http://localhost:4000/activity-catalog'
|
|
94
|
+
const store = (saved: string | null = null) => {
|
|
95
|
+
const box = { value: saved, written: [] as string[] }
|
|
96
|
+
return { box, stamp: { read: () => box.value, write: (v: string) => { box.written.push(v); box.value = v } } }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('first time: sent, and the stamp is written before leaving', () => {
|
|
100
|
+
const { box, stamp } = store()
|
|
101
|
+
expect(decideDomainRedirect(here, stamp, now)).not.toBeNull()
|
|
102
|
+
expect(box.written).toEqual([String(now)])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('just came back: not sent, and the stamp is left as it was', () => {
|
|
106
|
+
const { box, stamp } = store(String(now - 2_000))
|
|
107
|
+
expect(decideDomainRedirect(here, stamp, now)).toBeNull()
|
|
108
|
+
expect(box.written).toEqual([])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('storage that cannot be read: not sent', () => {
|
|
112
|
+
const stamp = { read: () => { throw new Error('blocked') }, write: () => {} }
|
|
113
|
+
expect(decideDomainRedirect(here, stamp, now)).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('storage that cannot be written: not sent - a trip we cannot remember could loop', () => {
|
|
117
|
+
const stamp = { read: () => null, write: () => { throw new Error('quota') } }
|
|
118
|
+
expect(decideDomainRedirect(here, stamp, now)).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a stamp that is not a number is treated as none, and the trip goes ahead', () => {
|
|
122
|
+
const { stamp } = store('garbage')
|
|
123
|
+
expect(decideDomainRedirect(here, stamp, now)).not.toBeNull()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
|
|
4
|
+
import { loadCompiled } from './compiled'
|
|
5
|
+
|
|
6
|
+
const { authPublicProcessRouter } = loadCompiled('router/auth-public-process-router')
|
|
7
|
+
const { ACCEPT_INVITATION_PATH } = loadCompiled('controllers/invitation')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The link in each mail lands on a route that exists.
|
|
11
|
+
*
|
|
12
|
+
* ── Why this file exists ────────────────────────────────────────────────────
|
|
13
|
+
* The invitation mail pointed at `/auth/accept/<token>` — a path with no route anywhere — so the
|
|
14
|
+
* invitee fell through to the private history fallback and the domain gate turned them away for
|
|
15
|
+
* not being a member yet. That was found and fixed on 2026-09-20, and the fix made the same
|
|
16
|
+
* mistake again: the new route was declared as `/accept-invitation` on a router whose prefix is
|
|
17
|
+
* `/auth`, and the mail and the page both used `/accept-invitation`. It shipped that way.
|
|
18
|
+
*
|
|
19
|
+
* Every test of the invitation called the controller directly, so none of them ever went through
|
|
20
|
+
* a route. This one reads the router's own table — the paths koa-router resolved, prefix included
|
|
21
|
+
* — and holds each mail's link against it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type Layer = { path: string; methods: string[] }
|
|
25
|
+
|
|
26
|
+
const layers: Layer[] = authPublicProcessRouter.stack.map((layer: any) => ({
|
|
27
|
+
path: layer.path,
|
|
28
|
+
methods: layer.methods
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
function answers(method: string, path: string): boolean {
|
|
32
|
+
return layers.some(layer => layer.methods.includes(method) && matches(layer.path, path))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A declared path against a concrete one: `:param` matches one segment. */
|
|
36
|
+
function matches(declared: string, actual: string): boolean {
|
|
37
|
+
const a = declared.split('/')
|
|
38
|
+
const b = actual.split('/')
|
|
39
|
+
|
|
40
|
+
return a.length === b.length && a.every((part, i) => part.startsWith(':') || part === b[i])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('the invitation link', () => {
|
|
44
|
+
it('the page answers where the mail points', () => {
|
|
45
|
+
/* ⚠ The case that shipped broken. */
|
|
46
|
+
expect(answers('GET', ACCEPT_INVITATION_PATH)).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('the page posts where the router listens', () => {
|
|
50
|
+
expect(answers('POST', ACCEPT_INVITATION_PATH)).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('carries the prefix the router adds', () => {
|
|
54
|
+
/*
|
|
55
|
+
* Declaring `/accept-invitation` on this router makes it `/auth/accept-invitation`. The spelling
|
|
56
|
+
* without the prefix is exactly the one that went out, so it is named here as the wrong one.
|
|
57
|
+
*/
|
|
58
|
+
expect(ACCEPT_INVITATION_PATH.startsWith('/auth/')).toBe(true)
|
|
59
|
+
expect(answers('GET', '/accept-invitation')).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('is the path the client page uses too', () => {
|
|
63
|
+
const page = readFileSync(
|
|
64
|
+
join(__dirname, '..', '..', 'auth-ui', 'client', 'components', 'accept-invitation.ts'),
|
|
65
|
+
'utf-8'
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
expect(page).toContain(`return '${ACCEPT_INVITATION_PATH}'`)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('the other links sent by mail', () => {
|
|
73
|
+
/*
|
|
74
|
+
* Same class, older links. Each controller builds its URL from a literal; every literal has to be
|
|
75
|
+
* a route this router answers. Read from the source because they are not exported — the point is
|
|
76
|
+
* that the link and the route are two spellings of one thing and nothing else ties them.
|
|
77
|
+
*/
|
|
78
|
+
const CONTROLLERS = join(__dirname, '..', 'server', 'controllers')
|
|
79
|
+
|
|
80
|
+
const linked = readdirSync(CONTROLLERS)
|
|
81
|
+
.filter(file => file.endsWith('.ts'))
|
|
82
|
+
.flatMap(file =>
|
|
83
|
+
[...readFileSync(join(CONTROLLERS, file), 'utf-8').matchAll(/new URL\(`(\/auth\/[^`?$]*)/g)].map(m => ({
|
|
84
|
+
file,
|
|
85
|
+
path: m[1].replace(/\/$/, '/:token')
|
|
86
|
+
}))
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
it('finds the links it means to check', () => {
|
|
90
|
+
/* A scan that matches nothing passes whatever the links say. */
|
|
91
|
+
expect(linked.map(l => l.file).sort()).toEqual(['reset-password.ts', 'unlock-user.ts', 'verification.ts'])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it.each(linked.map(l => [l.file, l.path]))('%s → %s', (_file, path) => {
|
|
95
|
+
expect(answers('GET', path)).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
})
|
package/tests/open-doors.test.ts
CHANGED
|
@@ -30,27 +30,18 @@ import { loadCompiled } from './compiled'
|
|
|
30
30
|
const { schema: declared } = loadCompiled('service/index')
|
|
31
31
|
const { privilegeDirectiveResolver, privilegeOfField } = loadCompiled('service/privilege/privilege-directive')
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
* Open on purpose
|
|
35
|
-
*
|
|
33
|
+
/*
|
|
34
|
+
* Open on purpose — read from the module's own declaration (`server/service/open-fields.ts`), which is
|
|
35
|
+
* also what the directive enforces against. It used to be a copy kept here, and two copies of one
|
|
36
|
+
* list are two lists.
|
|
36
37
|
*/
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
checkUserBelongsDomain: 'whether the caller belongs to this domain',
|
|
45
|
-
checkResettablePasswordToDefault: 'about the caller\'s own password',
|
|
46
|
-
checkDefaultPassword: 'whether the caller is still on the default password',
|
|
47
|
-
|
|
48
|
-
/* Neither tenant data nor about a person. */
|
|
49
|
-
passwordRule:
|
|
50
|
-
'the installation\'s password policy. The change-password screen needs it before the caller has any privilege — and after a forced reset they may have none',
|
|
51
|
-
authProviderTypes: 'which provider types this build supports. A catalogue, not a tenant\'s configuration'
|
|
52
|
-
},
|
|
53
|
-
Mutation: {}
|
|
38
|
+
const { openFields: declaration } = loadCompiled('service/open-fields')
|
|
39
|
+
|
|
40
|
+
const OPEN_BY_DECISION: Record<string, Record<string, string>> = { Query: {}, Mutation: {} }
|
|
41
|
+
|
|
42
|
+
for (const [where, reason] of Object.entries(declaration.byDesign as Record<string, string>)) {
|
|
43
|
+
const [typeName, field] = where.split('.')
|
|
44
|
+
OPEN_BY_DECISION[typeName][field] = reason
|
|
54
45
|
}
|
|
55
46
|
|
|
56
47
|
let schema: GraphQLSchema
|
|
@@ -58,7 +49,8 @@ let schema: GraphQLSchema
|
|
|
58
49
|
beforeAll(async () => {
|
|
59
50
|
schema = await buildSchema({ resolvers: declared.resolverClasses, validate: false })
|
|
60
51
|
|
|
61
|
-
|
|
52
|
+
/* No modules: this file measures what is open, not what would be refused. */
|
|
53
|
+
privilegeDirectiveResolver(schema, { modules: [] })
|
|
62
54
|
})
|
|
63
55
|
|
|
64
56
|
function openFields(typeName: 'Query' | 'Mutation'): string[] {
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { buildSchema } from 'type-graphql'
|
|
2
|
+
import { graphql, GraphQLSchema } from 'graphql'
|
|
3
|
+
import { makeExecutableSchema } from '@graphql-tools/schema'
|
|
4
|
+
|
|
5
|
+
import { loadCompiled } from './compiled'
|
|
6
|
+
|
|
7
|
+
const { schema: declared } = loadCompiled('service/index')
|
|
8
|
+
const { privilegeDirectiveResolver, privilegeDirectiveTypeDefs } = loadCompiled('service/privilege/privilege-directive')
|
|
9
|
+
const { coverageOf, judgeUndeclared } = loadCompiled('service/privilege/open-field-rule')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A field that declares no `@privilege` and that its module does not list — what happens to it
|
|
13
|
+
* (`docs/design/auth-rebuild.md` §8 step 1; question sheet Q1 · Q2 · Q5).
|
|
14
|
+
*
|
|
15
|
+
* ── The four answers ────────────────────────────────────────────────────────
|
|
16
|
+
* byDesign open, and the module says why
|
|
17
|
+
* notYetDeclared open, and on the module's list to close
|
|
18
|
+
* unlisted the module opted in and says nothing: boot refuses in development, the field
|
|
19
|
+
* refuses in production
|
|
20
|
+
* not opted in the module declared no lists at all: exactly as before
|
|
21
|
+
*
|
|
22
|
+
* The last one is deliberate. The four products serve resolvers of their own from another
|
|
23
|
+
* repository, and enforcing on them the day this lands would stop all four from booting.
|
|
24
|
+
*
|
|
25
|
+
* These build auth-base's real schema from the `resolverClasses` the server boots from, and vary
|
|
26
|
+
* only the module's declaration — so what is tested is the directive against real fields, not
|
|
27
|
+
* against a model of them.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const MODULE = 'auth-base'
|
|
31
|
+
const REAL = declared.openFields
|
|
32
|
+
|
|
33
|
+
function modules(openFields: any) {
|
|
34
|
+
return [{ name: MODULE, schema: { resolverClasses: declared.resolverClasses, openFields } }]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** auth-base's declaration with one reason taken out — the shape of a field somebody forgot to list. */
|
|
38
|
+
function without(where: string) {
|
|
39
|
+
const byDesign = { ...REAL.byDesign }
|
|
40
|
+
delete byDesign[where]
|
|
41
|
+
return { ...REAL, byDesign }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function built(): Promise<GraphQLSchema> {
|
|
45
|
+
return await buildSchema({ resolvers: declared.resolverClasses, validate: false })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function refusalCodeOf(promise: Promise<any>): Promise<string> {
|
|
49
|
+
const result = await promise
|
|
50
|
+
return result?.errors?.[0]?.extensions?.refusal?.code || result?.errors?.[0]?.extensions?.code || 'no error'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('who is covered', () => {
|
|
54
|
+
it('attributes each root field to the module whose class declared it', async () => {
|
|
55
|
+
const coverage = coverageOf(modules(REAL))
|
|
56
|
+
|
|
57
|
+
expect(coverage.get('Query.myRoles')).toEqual({ module: MODULE, listed: 'by-design' })
|
|
58
|
+
expect(coverage.get('Mutation.createRole')).toEqual({ module: MODULE, listed: null })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('covers nothing for a module that declared no lists', () => {
|
|
62
|
+
/* ⚠ The line that keeps the four products booting. */
|
|
63
|
+
expect(coverageOf(modules(undefined)).size).toBe(0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('does not cover a field of a class the module did not name', () => {
|
|
67
|
+
const coverage = coverageOf([{ name: 'other', schema: { resolverClasses: [], openFields: {} } }])
|
|
68
|
+
|
|
69
|
+
expect(judgeUndeclared('Query.myRoles', coverage)).toEqual('not-opted-in')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('the verdicts', () => {
|
|
74
|
+
const coverage = () =>
|
|
75
|
+
coverageOf(modules({ byDesign: { 'Query.myRoles': 'mine' }, notYetDeclared: ['Query.passwordRule'] }))
|
|
76
|
+
|
|
77
|
+
it('open by design', () => {
|
|
78
|
+
expect(judgeUndeclared('Query.myRoles', coverage())).toEqual('open-by-design')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('open, not yet declared', () => {
|
|
82
|
+
expect(judgeUndeclared('Query.passwordRule', coverage())).toEqual('open-not-yet-declared')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('unlisted', () => {
|
|
86
|
+
expect(judgeUndeclared('Query.hasPrivilege', coverage())).toEqual('unlisted')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('not opted in', () => {
|
|
90
|
+
expect(judgeUndeclared('Query.somethingElse', coverage())).toEqual('not-opted-in')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('in development — the schema refuses to build', () => {
|
|
95
|
+
it('builds with the declaration auth-base ships', async () => {
|
|
96
|
+
/* Every undeclared field of auth-base is in `byDesign`; nothing is unlisted today. */
|
|
97
|
+
const schema = await built()
|
|
98
|
+
|
|
99
|
+
expect(() => privilegeDirectiveResolver(schema, { modules: modules(REAL), mode: 'boot' })).not.toThrow()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('names the field that is unlisted', async () => {
|
|
103
|
+
/* ⚠ The case the step exists for: a field arrived and nobody said who may call it. */
|
|
104
|
+
const schema = await built()
|
|
105
|
+
|
|
106
|
+
expect(() => privilegeDirectiveResolver(schema, { modules: modules(without('Query.myRoles')), mode: 'boot' })).toThrow(
|
|
107
|
+
/Query\.myRoles/
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('names every unlisted field at once, not the first', async () => {
|
|
112
|
+
const schema = await built()
|
|
113
|
+
const declaration = without('Query.myRoles')
|
|
114
|
+
delete declaration.byDesign['Query.passwordRule']
|
|
115
|
+
|
|
116
|
+
let message = ''
|
|
117
|
+
try {
|
|
118
|
+
privilegeDirectiveResolver(schema, { modules: modules(declaration), mode: 'boot' })
|
|
119
|
+
} catch (e: any) {
|
|
120
|
+
message = e.message
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
expect(message).toMatch(/2 field\(s\)/)
|
|
124
|
+
expect(message).toMatch(/Query\.myRoles/)
|
|
125
|
+
expect(message).toMatch(/Query\.passwordRule/)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('accepts the field once it is listed as not yet declared', async () => {
|
|
129
|
+
const schema = await built()
|
|
130
|
+
const declaration = { ...without('Query.myRoles'), notYetDeclared: ['Query.myRoles'] }
|
|
131
|
+
|
|
132
|
+
expect(() => privilegeDirectiveResolver(schema, { modules: modules(declaration), mode: 'boot' })).not.toThrow()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('leaves a module that declared no lists exactly as it was', async () => {
|
|
136
|
+
const schema = await built()
|
|
137
|
+
|
|
138
|
+
expect(() => privilegeDirectiveResolver(schema, { modules: modules(undefined), mode: 'boot' })).not.toThrow()
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('in production — only that field refuses', () => {
|
|
143
|
+
it('still builds', async () => {
|
|
144
|
+
const schema = await built()
|
|
145
|
+
|
|
146
|
+
expect(() =>
|
|
147
|
+
privilegeDirectiveResolver(schema, { modules: modules(without('Query.passwordRule')), mode: 'request' })
|
|
148
|
+
).not.toThrow()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('refuses the unlisted field, with a name the screen can read', async () => {
|
|
152
|
+
/*
|
|
153
|
+
* ⚠ Never the other way. An unlisted field in production answers nobody — wrong in the worst case
|
|
154
|
+
* means one field refuses, never one field opens.
|
|
155
|
+
*/
|
|
156
|
+
const schema = privilegeDirectiveResolver(await built(), {
|
|
157
|
+
modules: modules(without('Query.passwordRule')),
|
|
158
|
+
mode: 'request'
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
expect(await refusalCodeOf(graphql({ schema, source: '{ passwordRule { lowerCase } }', contextValue: { state: {} } }))).toEqual(
|
|
162
|
+
'field-declares-no-privilege'
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('leaves a listed field answering', async () => {
|
|
167
|
+
const schema = privilegeDirectiveResolver(await built(), {
|
|
168
|
+
modules: modules(without('Query.myRoles')),
|
|
169
|
+
mode: 'request'
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const result = await graphql({ schema, source: '{ passwordRule { lowerCase } }', contextValue: { state: {} } })
|
|
173
|
+
|
|
174
|
+
expect(result.errors?.[0]?.extensions?.refusal).toBeUndefined()
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('a subscription is stopped where it opens', () => {
|
|
179
|
+
/*
|
|
180
|
+
* ⚠ The directive used to wrap `resolve` only. A declared subscription then connected for anyone
|
|
181
|
+
* and failed on every event — it leaked nothing and refused nothing. Built from SDL because
|
|
182
|
+
* auth-base has no subscription of its own; the directive sees the same field configuration.
|
|
183
|
+
*/
|
|
184
|
+
const typeDefs = `
|
|
185
|
+
type Query { ok: Boolean }
|
|
186
|
+
type Subscription {
|
|
187
|
+
guarded: String @privilege(category: "board", privilege: "query")
|
|
188
|
+
}
|
|
189
|
+
`
|
|
190
|
+
|
|
191
|
+
function schemaWith(subscribe: () => any) {
|
|
192
|
+
return privilegeDirectiveResolver(
|
|
193
|
+
makeExecutableSchema({
|
|
194
|
+
typeDefs: [privilegeDirectiveTypeDefs, typeDefs],
|
|
195
|
+
resolvers: { Query: { ok: () => true }, Subscription: { guarded: { subscribe, resolve: (v: any) => v } } }
|
|
196
|
+
}),
|
|
197
|
+
{ modules: [] }
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
it('refuses at subscribe for somebody without the privilege', async () => {
|
|
202
|
+
let opened = false
|
|
203
|
+
const schema = schemaWith(() => {
|
|
204
|
+
opened = true
|
|
205
|
+
return (async function* () {})()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const field = schema.getSubscriptionType()!.getFields().guarded as any
|
|
209
|
+
|
|
210
|
+
await expect(
|
|
211
|
+
field.subscribe({}, {}, { state: { user: { id: 'u', domains: [] }, domain: { id: 'd' } } }, {} as any)
|
|
212
|
+
).rejects.toBeTruthy()
|
|
213
|
+
expect(opened).toBe(false)
|
|
214
|
+
})
|
|
215
|
+
})
|
package/translations/en.json
CHANGED
package/translations/ja.json
CHANGED
|
@@ -2,20 +2,20 @@
|
|
|
2
2
|
"error.auth error": "認証エラー。{message}",
|
|
3
3
|
"error.authn verification failed": "ユーザー認証に失敗しました。",
|
|
4
4
|
"error.confirm password not matched": "新しいパスワードと確認パスワードが一致しません.",
|
|
5
|
-
"error.domain mismatch": "
|
|
5
|
+
"error.domain mismatch": "証明書の$t(term.domain)と現在の$t(term.domain)が一致しません.",
|
|
6
6
|
"error.domain not allowed": "'{subdomain}' 領域はこのユーザに許可されていません.",
|
|
7
7
|
"error.email already exists": "メールはすでに他のユーザーによって使用されています.",
|
|
8
8
|
"error.failed to find x": "{x}が見つかりません.",
|
|
9
9
|
"error.invalid username": "無効なユーザー名: {username}",
|
|
10
|
-
"error.ownership required": "
|
|
10
|
+
"error.ownership required": "権限がありません。$t(term.domain)またはシステムの所有者権限が必要です。",
|
|
11
11
|
"error.password should be supported": "初期パスワードまたはデフォルトパスワードがサポートされるべきです",
|
|
12
12
|
"error.password should match the rule": "パスワードは次の規則を守らなければなりません. {rule}",
|
|
13
13
|
"error.password used in the past": "過去に使用されたパスワードです.",
|
|
14
|
-
"error.role-name-stands-inherited": "{name} ロールは {domain}
|
|
15
|
-
"error.role-name-taken-here": "
|
|
14
|
+
"error.role-name-stands-inherited": "{name} ロールは {domain} に定義されており、ここからも見えています。この$t(term.domain)に同じ名前で別に立てるつもりの場合のみ作成します。",
|
|
15
|
+
"error.role-name-taken-here": "この$t(term.domain)には既に {name} ロールがあります。",
|
|
16
16
|
"error.subdomain not found": "サブドメインが見つかりません.",
|
|
17
17
|
"error.token or password is invalid": "トークンまたはパスワードが無効です.",
|
|
18
|
-
"error.unavailable-domain": "
|
|
18
|
+
"error.unavailable-domain": "使用できない$t(term.domain)です.",
|
|
19
19
|
"error.user credential not found": "ユーザー資格情報が見つかりません. 生体認証を使用するにはデバイスを登録する必要があります.",
|
|
20
20
|
"error.user credential registration failed": "登録を完了できませんでした。デバイスでキャンセルされたか、サーバーに到達できませんでした。",
|
|
21
21
|
"error.user credential registration not allowed": "このデバイスは登録できませんでした。登録がタイムアウトしたか、すでに登録済みです。",
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
"field.model": "モデル",
|
|
36
36
|
"field.password": "パスワード",
|
|
37
37
|
"field.privileges": "権限",
|
|
38
|
-
"field.roles": "ルール",
|
|
39
38
|
"field.token": "トークン",
|
|
40
39
|
"field.user": "ユーザー",
|
|
41
40
|
"field.user_account": "ユーザー アカウント",
|
package/translations/ko.json
CHANGED
|
@@ -2,20 +2,20 @@
|
|
|
2
2
|
"error.auth error": "인증 오류. {message}",
|
|
3
3
|
"error.authn verification failed": "사용자 자격 증명을 실패하였습니다.",
|
|
4
4
|
"error.confirm password not matched": "새 비밀번호와 확인 비밀번호가 일치하지 않습니다.",
|
|
5
|
-
"error.domain mismatch": "인증서의
|
|
5
|
+
"error.domain mismatch": "인증서의 $t(term.domain)과 현재 $t(term.domain)이 일치하지 않습니다.",
|
|
6
6
|
"error.domain not allowed": "'{subdomain}' 영역은 이 사용자에게 허가되지 않았습니다.",
|
|
7
7
|
"error.email already exists": "이메일이 이미 사용되고 있습니다.",
|
|
8
8
|
"error.failed to find x": "{x}을(를) 찾을 수 없습니다.",
|
|
9
9
|
"error.invalid username": "유효하지 않은 사용자 이름: {username}",
|
|
10
|
-
"error.ownership required": "권한이 없습니다.
|
|
10
|
+
"error.ownership required": "권한이 없습니다. $t(term.domain) 또는 시스템 소유자 권한이 필요합니다.",
|
|
11
11
|
"error.password should be supported": "초기 비밀번호나 디폴트 비밀번호가 제공되어야 합니다.",
|
|
12
12
|
"error.password should match the rule": "비밀번호는 다음 규칙을 지켜야 합니다. {rule}",
|
|
13
13
|
"error.password used in the past": "과거에 사용된 비밀번호입니다.",
|
|
14
|
-
"error.role-name-stands-inherited": "{name} 역할이 {domain} 에 정의되어 여기에서도 보입니다. 이
|
|
15
|
-
"error.role-name-taken-here": "이
|
|
14
|
+
"error.role-name-stands-inherited": "{name} 역할이 {domain} 에 정의되어 여기에서도 보입니다. 이 $t(term.domain)에 같은 이름으로 따로 세우려는 것이 맞을 때만 세웁니다.",
|
|
15
|
+
"error.role-name-taken-here": "이 $t(term.domain)에 이미 {name} 역할이 있습니다.",
|
|
16
16
|
"error.subdomain not found": "서브도메인을 찾을 수 없습니다.",
|
|
17
17
|
"error.token or password is invalid": "토큰 또는 비밀번호가 유효하지 않습니다.",
|
|
18
|
-
"error.unavailable-domain": "사용할 수 없는
|
|
18
|
+
"error.unavailable-domain": "사용할 수 없는 $t(term.domain)입니다.",
|
|
19
19
|
"error.user credential not found": "사용자 자격 증명을 찾을 수 없습니다. 바이오메트릭 인증을 사용하기 위해서는 먼저 기기를 등록해야 합니다.",
|
|
20
20
|
"error.user credential registration failed": "등록을 마치지 못했습니다. 기기에서 취소했거나 서버에 닿지 못했습니다.",
|
|
21
21
|
"error.user credential registration not allowed": "이 기기를 등록할 수 없습니다. 등록 시간이 초과되었거나, 이미 등록된 기기입니다.",
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
"field.model": "모델",
|
|
36
36
|
"field.password": "비밀번호",
|
|
37
37
|
"field.privileges": "권한",
|
|
38
|
-
"field.roles": "규칙",
|
|
39
38
|
"field.token": "토큰",
|
|
40
39
|
"field.user": "사용자",
|
|
41
40
|
"field.user_account": "사용자 계정",
|
|
@@ -115,7 +114,7 @@
|
|
|
115
114
|
"privilege.allows.organization.mutation": "조직 구조를 고칠 수 있습니다.",
|
|
116
115
|
"privilege.allows.organization.query": "조직 구조를 볼 수 있습니다.",
|
|
117
116
|
"privilege.allows.partner.mutation": "협력사를 등록하고 연결을 끊을 수 있습니다.",
|
|
118
|
-
"privilege.allows.partner.query": "협력사와 그
|
|
117
|
+
"privilege.allows.partner.query": "협력사와 그 $t(term.domain) 연결을 볼 수 있습니다.",
|
|
119
118
|
"privilege.allows.privilege.query": "이 시스템에 선언된 권한 목록을 볼 수 있습니다.",
|
|
120
119
|
"privilege.allows.process-instance.query": "프로세스가 어디까지 돌았는지 볼 수 있습니다.",
|
|
121
120
|
"privilege.allows.process.mutation": "프로세스를 만들고 단계를 고칠 수 있습니다.",
|