@things-factory/auth-base 10.1.218 → 10.1.226
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/controllers/confirm-identity.d.ts +8 -0
- package/dist-server/controllers/confirm-identity.js +33 -0
- package/dist-server/controllers/confirm-identity.js.map +1 -0
- package/dist-server/controllers/signin.d.ts +5 -0
- package/dist-server/controllers/signin.js +38 -31
- package/dist-server/controllers/signin.js.map +1 -1
- package/dist-server/router/webauthn-router.js +20 -1
- package/dist-server/router/webauthn-router.js.map +1 -1
- package/dist-server/service/audit/audit-kinds.js +2 -0
- package/dist-server/service/audit/audit-kinds.js.map +1 -1
- package/dist-server/service/identity-confirmation/identity-confirmation.d.ts +18 -0
- package/dist-server/service/identity-confirmation/identity-confirmation.js +84 -0
- package/dist-server/service/identity-confirmation/identity-confirmation.js.map +1 -0
- package/dist-server/service/index.d.ts +1 -0
- package/dist-server/service/index.js +3 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/privilege/privilege-directive.js +17 -1
- package/dist-server/service/privilege/privilege-directive.js.map +1 -1
- package/dist-server/service/role/role-mutation.js +5 -0
- package/dist-server/service/role/role-mutation.js.map +1 -1
- package/dist-server/service/role/role-types.d.ts +1 -0
- package/dist-server/service/role/role-types.js +4 -0
- package/dist-server/service/role/role-types.js.map +1 -1
- package/dist-server/service/role/role.d.ts +10 -0
- package/dist-server/service/role/role.js +5 -0
- package/dist-server/service/role/role.js.map +1 -1
- package/dist-server/service/role-template/role-template-gap-resolver.d.ts +2 -2
- package/dist-server/service/role-template/role-template-gap-resolver.js +7 -13
- package/dist-server/service/role-template/role-template-gap-resolver.js.map +1 -1
- package/dist-server/service/role-template/role-template-mutation.d.ts +7 -4
- package/dist-server/service/role-template/role-template-mutation.js +15 -15
- package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
- package/dist-server/service/role-template/role-template-types.d.ts +3 -3
- package/dist-server/service/role-template/role-template-types.js.map +1 -1
- package/dist-server/service/role-template/role-template.d.ts +14 -15
- package/dist-server/service/role-template/role-template.js +8 -8
- package/dist-server/service/role-template/role-template.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/access-token-cookie.js +3 -0
- package/dist-server/utils/access-token-cookie.js.map +1 -1
- package/package.json +2 -2
- package/tests/confirm-identity-db.test.ts +85 -0
- package/tests/identity-confirmation.test.ts +146 -0
- package/tests/role-template-gap-db.test.ts +58 -7
- package/tests/role-template-seed-db.test.ts +5 -4
- package/translations/en.json +2 -1
- package/translations/es.json +2 -1
- package/translations/ja.json +2 -1
- package/translations/ko.json +2 -1
- package/translations/ms.json +2 -1
- package/translations/zh.json +2 -1
|
@@ -6,6 +6,7 @@ exports.setSessionAccessToken = setSessionAccessToken;
|
|
|
6
6
|
exports.clearAccessTokenCookie = clearAccessTokenCookie;
|
|
7
7
|
const env_1 = require("@things-factory/env");
|
|
8
8
|
const max_age_js_1 = require("../constants/max-age.js");
|
|
9
|
+
const identity_confirmation_js_1 = require("../service/identity-confirmation/identity-confirmation.js");
|
|
9
10
|
const accessTokenCookieKey = env_1.config.get('accessTokenCookieKey', 'access_token');
|
|
10
11
|
function getAccessTokenCookie(context) {
|
|
11
12
|
return context?.cookies?.get(accessTokenCookieKey);
|
|
@@ -37,6 +38,8 @@ function clearAccessTokenCookie(context) {
|
|
|
37
38
|
sameSite: 'Lax'
|
|
38
39
|
};
|
|
39
40
|
context.cookies.set(accessTokenCookieKey, '', cookie);
|
|
41
|
+
/* A confirmation that it was the person ends with the sign-in it confirmed (ADR-0168). */
|
|
42
|
+
(0, identity_confirmation_js_1.forgetConfirmation)(context);
|
|
40
43
|
/*
|
|
41
44
|
* TODO clear i18next cookie as well - need to support domain
|
|
42
45
|
* https://github.com/hatiolab/things-factory/issues/70
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access-token-cookie.js","sourceRoot":"","sources":["../../server/utils/access-token-cookie.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"access-token-cookie.js","sourceRoot":"","sources":["../../server/utils/access-token-cookie.ts"],"names":[],"mappings":";;AAMA,oDAEC;AAED,oDAWC;AAED,sDASC;AAED,wDAkBC;AApDD,6CAA4C;AAC5C,wDAAiD;AACjD,wGAA8F;AAE9F,MAAM,oBAAoB,GAAG,YAAM,CAAC,GAAG,CAAC,sBAAsB,EAAE,cAAc,CAAC,CAAA;AAE/E,SAAgB,oBAAoB,CAAC,OAAO;IAC1C,OAAO,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC,oBAAoB,CAAC,CAAA;AACpD,CAAC;AAED,SAAgB,oBAAoB,CAAC,OAAO,EAAE,KAAK;IACjD,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAA;IAE1B,IAAI,MAAM,GAAG;QACX,MAAM;QACN,QAAQ,EAAE,IAAI;QACd,MAAM,EAAE,oBAAO;QACf,QAAQ,EAAE,KAAK;KAChB,CAAA;IAED,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,oBAAoB,EAAE,KAAK,EAAE,MAAM,CAAC,CAAA;AAC1D,CAAC;AAED,SAAgB,qBAAqB,CAAC,OAAO;IAC3C,0FAA0F;IAC1F,MAAM,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;IAE9B,OAAO,CAAC,OAAO,GAAG;QAChB,EAAE,EAAE,IAAI,CAAC,EAAE;QACX,QAAQ,EAAE,IAAI,CAAC,IAAI;QACnB,MAAM,EAAE,IAAI,CAAC,KAAK;KACnB,CAAA;AACH,CAAC;AAED,SAAgB,sBAAsB,CAAC,OAAO;IAC5C,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAA;IAE1B,IAAI,MAAM,GAAG;QACX,MAAM;QACN,QAAQ,EAAE,IAAI;QACd,QAAQ,EAAE,KAAK;KAChB,CAAA;IAED,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,oBAAoB,EAAE,EAAE,EAAE,MAAM,CAAC,CAAA;IACrD,0FAA0F;IAC1F,IAAA,6CAAkB,EAAC,OAAO,CAAC,CAAA;IAC3B;;;OAGG;IACH,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,EAAE,EAAE,EAAE,MAAM,CAAC,CAAA;IAC1C,OAAO,CAAC,OAAO,GAAG,IAAI,CAAA;AACxB,CAAC","sourcesContent":["import { config } from '@things-factory/env'\nimport { MAX_AGE } from '../constants/max-age.js'\nimport { forgetConfirmation } from '../service/identity-confirmation/identity-confirmation.js'\n\nconst accessTokenCookieKey = config.get('accessTokenCookieKey', 'access_token')\n\nexport function getAccessTokenCookie(context) {\n return context?.cookies?.get(accessTokenCookieKey)\n}\n\nexport function setAccessTokenCookie(context, token) {\n const { secure } = context\n\n var cookie = {\n secure,\n httpOnly: true,\n maxAge: MAX_AGE,\n sameSite: 'Lax'\n }\n\n context.cookies.set(accessTokenCookieKey, token, cookie)\n}\n\nexport function setSessionAccessToken(context) {\n /* koa-session 을 사용하는 경우에는, cookie 직접 설정이 작동되지 않는다. 그런 경우에는 session에 설정해서 cookie를 변경한다. */\n const { user } = context.state\n\n context.session = {\n id: user.id,\n userType: user.type,\n status: user.state\n }\n}\n\nexport function clearAccessTokenCookie(context) {\n const { secure } = context\n\n var cookie = {\n secure,\n httpOnly: true,\n sameSite: 'Lax'\n }\n\n context.cookies.set(accessTokenCookieKey, '', cookie)\n /* A confirmation that it was the person ends with the sign-in it confirmed (ADR-0168). */\n forgetConfirmation(context)\n /*\n * TODO clear i18next cookie as well - need to support domain\n * https://github.com/hatiolab/things-factory/issues/70\n */\n context.cookies.set('i18next', '', cookie)\n context.session = null\n}\n"]}
|
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.226",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -50,5 +50,5 @@
|
|
|
50
50
|
"passport-jwt": "^4.0.0",
|
|
51
51
|
"passport-local": "^1.0.0"
|
|
52
52
|
},
|
|
53
|
-
"gitHead": "
|
|
53
|
+
"gitHead": "abc5cf99effa2de579a1cbfda74ddc5eb41dba50"
|
|
54
54
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confirming it is still the signed-in person (ADR-0168) by password, through the real controller on the sqlite
|
|
3
|
+
* harness: a miss counts toward the same lock as signing in and leaves no confirmation; a match leaves one; both
|
|
4
|
+
* are audited as `auth.confirm-identity`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase, authDataSource } from './db'
|
|
8
|
+
import { loadCompiled } from './compiled'
|
|
9
|
+
|
|
10
|
+
const { User } = loadCompiled('service/user/user')
|
|
11
|
+
const { AuditAction } = loadCompiled('service/audit/audit-action')
|
|
12
|
+
const { flushAudit } = loadCompiled('service/audit/audit-recorder')
|
|
13
|
+
const { confirmIdentity } = loadCompiled('controllers/confirm-identity')
|
|
14
|
+
const { verifyPassword } = loadCompiled('controllers/signin')
|
|
15
|
+
const { isRecentlyConfirmed } = loadCompiled('service/identity-confirmation/identity-confirmation')
|
|
16
|
+
|
|
17
|
+
const PASSWORD = 'Passw0rd!'
|
|
18
|
+
|
|
19
|
+
let domain: any
|
|
20
|
+
let kim: any
|
|
21
|
+
|
|
22
|
+
function request(user: any) {
|
|
23
|
+
const jar = new Map<string, string>()
|
|
24
|
+
return {
|
|
25
|
+
state: { user, domain, tx: authDataSource().manager },
|
|
26
|
+
cookies: { get: (name: string) => jar.get(name) || undefined, set: (name: string, value: string) => jar.set(name, value) }
|
|
27
|
+
} as any
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const byPassword = (context: any, password: string) => confirmIdentity('password', context, (user: any) => verifyPassword(user, password, context))
|
|
31
|
+
|
|
32
|
+
async function audits() {
|
|
33
|
+
await flushAudit()
|
|
34
|
+
const rows = await getRepository(AuditAction).find({ where: { kind: 'auth.confirm-identity' }, order: { createdAt: 'ASC' } })
|
|
35
|
+
return rows.map(row => [row.outcome, row.code, row.detail?.method])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
await openAuthDatabase()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
await closeAuthDatabase()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
await resetAuthDatabase()
|
|
48
|
+
domain = await getRepository(Domain).save({ name: 'office', subdomain: 'office' })
|
|
49
|
+
const salt = User.generateSalt()
|
|
50
|
+
kim = await getRepository(User).save({
|
|
51
|
+
username: 'kim',
|
|
52
|
+
email: 'kim@example.com',
|
|
53
|
+
name: 'kim',
|
|
54
|
+
salt,
|
|
55
|
+
password: User.encode(PASSWORD, salt),
|
|
56
|
+
status: 'activated',
|
|
57
|
+
domains: [domain]
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('leaves a confirmation for the person when the password matches', async () => {
|
|
62
|
+
const context = request(kim)
|
|
63
|
+
await byPassword(context, PASSWORD)
|
|
64
|
+
|
|
65
|
+
expect(isRecentlyConfirmed(context, kim.id)).toBe(true)
|
|
66
|
+
expect(await audits()).toEqual([['ok', null, 'password']])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('a miss leaves none, counts toward the lock, and is audited with its code', async () => {
|
|
70
|
+
const context = request(kim)
|
|
71
|
+
await expect(byPassword(context, 'wrong')).rejects.toMatchObject({ errorCode: 'password-not-matched' })
|
|
72
|
+
|
|
73
|
+
expect(isRecentlyConfirmed(context, kim.id)).toBe(false)
|
|
74
|
+
expect((await getRepository(User).findOneBy({ id: kim.id })).failCount).toBe(1)
|
|
75
|
+
expect(await audits()).toEqual([['error', 'password-not-matched', 'password']])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('the fifth miss locks the account, and a locked account confirms nothing', async () => {
|
|
79
|
+
for (let i = 0; i < 4; i++) await expect(byPassword(request(kim), 'wrong')).rejects.toMatchObject({ errorCode: 'password-not-matched' })
|
|
80
|
+
await expect(byPassword(request(kim), 'wrong')).rejects.toMatchObject({ errorCode: 'user-locked' })
|
|
81
|
+
|
|
82
|
+
const context = request(kim)
|
|
83
|
+
await expect(byPassword(context, PASSWORD)).rejects.toMatchObject({ errorCode: 'user-locked' })
|
|
84
|
+
expect(isRecentlyConfirmed(context, kim.id)).toBe(false)
|
|
85
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@recentConfirmation` (ADR-0168) - the seam. A door that asks for a recent confirmation opens only when the
|
|
3
|
+
* person confirmed in this browser within the last ten minutes, as themself; passing does not lengthen it. Built as SDL the way
|
|
4
|
+
* `privilege-directive.test.ts` builds its schema - the wrapper sees only the field, `context.state` and the cookies.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { graphql } from 'graphql'
|
|
8
|
+
import { makeExecutableSchema } from '@graphql-tools/schema'
|
|
9
|
+
import jwt from 'jsonwebtoken'
|
|
10
|
+
|
|
11
|
+
import { loadCompiled } from './compiled'
|
|
12
|
+
|
|
13
|
+
const { privilegeDirectiveTypeDefs, privilegeDirectiveResolver } = loadCompiled('service/privilege/privilege-directive')
|
|
14
|
+
const confirmation = loadCompiled('service/identity-confirmation/identity-confirmation')
|
|
15
|
+
const { SECRET } = loadCompiled('utils/get-secret')
|
|
16
|
+
const { User } = loadCompiled('service/user/user')
|
|
17
|
+
|
|
18
|
+
const DOMAIN = { id: 'domain-1', subdomain: 'office' }
|
|
19
|
+
const KIM = { id: 'kim', domains: [DOMAIN] }
|
|
20
|
+
const LEE = { id: 'lee', domains: [DOMAIN] }
|
|
21
|
+
|
|
22
|
+
const typeDefs = `
|
|
23
|
+
type Query {
|
|
24
|
+
myPayslips: String @privilege(category: "office-self", privilege: "query") @recentConfirmation
|
|
25
|
+
myLeave: String @privilege(category: "office-self", privilege: "query")
|
|
26
|
+
}
|
|
27
|
+
`
|
|
28
|
+
|
|
29
|
+
function buildSchema(defs = typeDefs) {
|
|
30
|
+
return privilegeDirectiveResolver(
|
|
31
|
+
makeExecutableSchema({
|
|
32
|
+
typeDefs: [privilegeDirectiveTypeDefs, confirmation.recentConfirmationDirectiveTypeDefs, defs],
|
|
33
|
+
resolvers: { Query: { myPayslips: () => 'slips', myLeave: () => 'leave' } }
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A browser: the cookies it holds between requests. */
|
|
39
|
+
function browser() {
|
|
40
|
+
const jar = new Map<string, string>()
|
|
41
|
+
const options = new Map<string, any>()
|
|
42
|
+
return {
|
|
43
|
+
jar,
|
|
44
|
+
options,
|
|
45
|
+
cookies: {
|
|
46
|
+
get: (name: string) => jar.get(name) || undefined,
|
|
47
|
+
set: (name: string, value: string, option: any) => (jar.set(name, value), options.set(name, option))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function run(schema: any, field: string, user: any, at: ReturnType<typeof browser>) {
|
|
53
|
+
return graphql({ schema, source: `{ ${field} }`, contextValue: { state: { domain: DOMAIN, user }, cookies: at.cookies } })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const refusalOf = (result: any) => result.errors?.[0]?.extensions?.refusal?.code
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
User.hasPrivilege = jest.fn().mockResolvedValue(true)
|
|
60
|
+
;(process as any).domainOwnerGranted = jest.fn().mockResolvedValue(false)
|
|
61
|
+
;(process as any).superUserGranted = jest.fn().mockResolvedValue(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
delete (process as any).domainOwnerGranted
|
|
66
|
+
delete (process as any).superUserGranted
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('refuses a door that asks for it when the person has not confirmed, with a code the screen can name', async () => {
|
|
70
|
+
const result = await run(buildSchema(), 'myPayslips', KIM, browser())
|
|
71
|
+
|
|
72
|
+
expect(result.data?.myPayslips ?? null).toBeNull()
|
|
73
|
+
expect(refusalOf(result)).toBe('identity-confirmation-required')
|
|
74
|
+
expect(result.errors?.[0]?.extensions?.code).toBe('IDENTITY_CONFIRMATION_REQUIRED')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('opens it after the person confirmed, and does not lengthen the confirmation as it passes', async () => {
|
|
78
|
+
const at = browser()
|
|
79
|
+
confirmation.rememberConfirmation({ cookies: at.cookies }, KIM.id)
|
|
80
|
+
const given = at.jar.get('identity_confirmed')
|
|
81
|
+
|
|
82
|
+
await new Promise(resolve => setTimeout(resolve, 1100))
|
|
83
|
+
const result = await run(buildSchema(), 'myPayslips', KIM, at)
|
|
84
|
+
|
|
85
|
+
expect(result.errors).toBeUndefined()
|
|
86
|
+
expect(result.data).toEqual({ myPayslips: 'slips' })
|
|
87
|
+
expect(at.jar.get('identity_confirmed')).toBe(given)
|
|
88
|
+
expect(at.options.get('identity_confirmed')).toMatchObject({ httpOnly: true, maxAge: 10 * 60 * 1000 })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('holds for ten minutes from the moment it was given, and says until when', () => {
|
|
92
|
+
const at = browser()
|
|
93
|
+
const before = Date.now()
|
|
94
|
+
confirmation.rememberConfirmation({ cookies: at.cookies }, KIM.id)
|
|
95
|
+
|
|
96
|
+
const until = confirmation.confirmedUntil({ cookies: at.cookies }, KIM.id).getTime()
|
|
97
|
+
expect(until).toBeGreaterThanOrEqual(Math.floor(before / 1000) * 1000 + 10 * 60 * 1000)
|
|
98
|
+
expect(until).toBeLessThanOrEqual(Date.now() + 10 * 60 * 1000)
|
|
99
|
+
expect(confirmation.confirmedUntil({ cookies: at.cookies }, LEE.id)).toBeNull()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("does not open on another person's confirmation", async () => {
|
|
103
|
+
const at = browser()
|
|
104
|
+
confirmation.rememberConfirmation({ cookies: at.cookies }, LEE.id)
|
|
105
|
+
|
|
106
|
+
expect(refusalOf(await run(buildSchema(), 'myPayslips', KIM, at))).toBe('identity-confirmation-required')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does not open on an expired confirmation, a forged one, or one let go', async () => {
|
|
110
|
+
const expired = browser()
|
|
111
|
+
expired.jar.set('identity_confirmed', jwt.sign({ sub: KIM.id, purpose: 'identity-confirmation' }, SECRET, { expiresIn: -10 }))
|
|
112
|
+
expect(refusalOf(await run(buildSchema(), 'myPayslips', KIM, expired))).toBe('identity-confirmation-required')
|
|
113
|
+
|
|
114
|
+
const forged = browser()
|
|
115
|
+
forged.jar.set('identity_confirmed', jwt.sign({ sub: KIM.id, purpose: 'identity-confirmation' }, 'another secret', { expiresIn: 300 }))
|
|
116
|
+
expect(refusalOf(await run(buildSchema(), 'myPayslips', KIM, forged))).toBe('identity-confirmation-required')
|
|
117
|
+
|
|
118
|
+
const signedIn = browser()
|
|
119
|
+
signedIn.jar.set('identity_confirmed', jwt.sign({ sub: KIM.id }, SECRET, { expiresIn: 300 }))
|
|
120
|
+
expect(refusalOf(await run(buildSchema(), 'myPayslips', KIM, signedIn))).toBe('identity-confirmation-required')
|
|
121
|
+
|
|
122
|
+
const leftAlone = browser()
|
|
123
|
+
confirmation.rememberConfirmation({ cookies: leftAlone.cookies }, KIM.id)
|
|
124
|
+
confirmation.forgetConfirmation({ cookies: leftAlone.cookies })
|
|
125
|
+
expect(refusalOf(await run(buildSchema(), 'myPayslips', KIM, leftAlone))).toBe('identity-confirmation-required')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('asks the privilege first - a person without it is refused for the privilege, confirmed or not', async () => {
|
|
129
|
+
User.hasPrivilege = jest.fn().mockResolvedValue(false)
|
|
130
|
+
const at = browser()
|
|
131
|
+
confirmation.rememberConfirmation({ cookies: at.cookies }, KIM.id)
|
|
132
|
+
|
|
133
|
+
const result = await run(buildSchema(), 'myPayslips', KIM, at)
|
|
134
|
+
expect(result.errors?.[0]?.extensions?.code).not.toBe('IDENTITY_CONFIRMATION_REQUIRED')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('leaves a door that does not ask alone', async () => {
|
|
138
|
+
const result = await run(buildSchema(), 'myLeave', KIM, browser())
|
|
139
|
+
|
|
140
|
+
expect(result.errors).toBeUndefined()
|
|
141
|
+
expect(result.data).toEqual({ myLeave: 'leave' })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('refuses to build a door that asks for the confirmation without a privilege beside it', () => {
|
|
145
|
+
expect(() => buildSchema('type Query { myPayslips: String @recentConfirmation myLeave: String }')).toThrow('has no @privilege beside it')
|
|
146
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What a role lacks against
|
|
2
|
+
* What a role lacks against its base template - against a real database (ADR-0075 decision 1, amended 2026-10-26:
|
|
3
|
+
* a role keeps one base template, `Role.templateId`, and is never paired by name).
|
|
3
4
|
*
|
|
4
5
|
* A template is a seed and nothing pushes its later grants into roles already standing. When
|
|
5
6
|
* operato-plant's templates gained ai-assistant, the roles in miratek stayed without it and only
|
|
@@ -14,12 +15,16 @@ const { Role } = loadCompiled('service/role/role')
|
|
|
14
15
|
const { Privilege } = loadCompiled('service/privilege/privilege')
|
|
15
16
|
const { User } = loadCompiled('service/user/user')
|
|
16
17
|
const { RoleTemplateMutation } = loadCompiled('service/role-template/role-template-mutation')
|
|
18
|
+
const { RoleMutation } = loadCompiled('service/role/role-mutation')
|
|
17
19
|
const { RoleTemplateGapResolver } = loadCompiled('service/role-template/role-template-gap-resolver')
|
|
18
20
|
const { registerRoleTemplate } = loadCompiled('service/role-template/role-template')
|
|
19
21
|
const { registerRoleTask } = loadCompiled('service/role-template/role-task')
|
|
20
22
|
|
|
21
23
|
const mutation = new RoleTemplateMutation()
|
|
22
|
-
const gapOf = (role: any
|
|
24
|
+
const gapOf = (role: any) => new RoleTemplateGapResolver().templateGap(role)
|
|
25
|
+
const reread = (role: any) => getRepository(Role).findOneBy({ id: role.id })
|
|
26
|
+
/* The role screen's base template field saves through `updateRole`. */
|
|
27
|
+
const baseOn = (role: any, templateId: string | null) => new RoleMutation().updateRole(role.id, { templateId } as any, { ...context(), t: (key: string) => key } as any)
|
|
23
28
|
const coverageOf = (role: any) => new RoleTemplateGapResolver().taskCoverage(role)
|
|
24
29
|
|
|
25
30
|
let domain: any
|
|
@@ -107,13 +112,52 @@ describe('the gap between a role and its template', () => {
|
|
|
107
112
|
expect((await gapOf(role)).missingCount).toBe(0)
|
|
108
113
|
})
|
|
109
114
|
|
|
110
|
-
it('★
|
|
115
|
+
it('★ a role made under a name of its own is still based on the template', async () => {
|
|
111
116
|
declareTemplate(['query'])
|
|
112
117
|
const role = await mutation.createRoleFromTemplate('plant.qi', 'NIGHT-INSPECTOR', context())
|
|
118
|
+
declareTemplate(['query', 'mutation'])
|
|
119
|
+
|
|
120
|
+
expect((await reread(role)).templateId).toBe('plant.qi')
|
|
121
|
+
expect(await gapOf(await reread(role))).toMatchObject({ templateId: 'plant.qi', missingCount: 1 })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('★ is null for a role that is based on no template, even when a template carries its name', async () => {
|
|
125
|
+
declareTemplate(['query'])
|
|
126
|
+
const role = await getRepository(Role).save({ domain, name: 'QUALITY-INSPECTOR', privileges: [] })
|
|
113
127
|
|
|
114
128
|
expect(await gapOf(role)).toBeNull()
|
|
115
129
|
})
|
|
116
130
|
|
|
131
|
+
it('★ refuses to top up a role based on another template, and changes nothing', async () => {
|
|
132
|
+
declareTemplate(['query'])
|
|
133
|
+
registerRoleTemplate({ id: 'plant.qm', name: 'QUALITY-MANAGER', grants: [{ category: 'board', axes: ['query', 'mutation'] }] })
|
|
134
|
+
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
135
|
+
|
|
136
|
+
await expect(mutation.createRoleFromTemplate('plant.qm', 'QUALITY-INSPECTOR', context())).rejects.toThrow(/based on role template "plant.qi", not "plant.qm"/)
|
|
137
|
+
|
|
138
|
+
const stored = await getRepository(Role).findOne({ where: { id: role.id }, relations: ['privileges'] })
|
|
139
|
+
expect(stored.templateId).toBe('plant.qi')
|
|
140
|
+
expect(stored.privileges.map((p: any) => p.name)).toEqual(['query'])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('★ tops up a standing role that has no base and makes the template its base', async () => {
|
|
144
|
+
declareTemplate(['query', 'mutation'])
|
|
145
|
+
const role = await getRepository(Role).save({ domain, name: 'QUALITY-INSPECTOR', privileges: [] })
|
|
146
|
+
|
|
147
|
+
await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
148
|
+
|
|
149
|
+
expect((await reread(role)).templateId).toBe('plant.qi')
|
|
150
|
+
expect((await gapOf(await reread(role))).missingCount).toBe(0)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('★ is null when the base is a template this installation no longer declares', async () => {
|
|
154
|
+
declareTemplate(['query'])
|
|
155
|
+
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
156
|
+
;(process as any)['ROLE_TEMPLATES'] = {}
|
|
157
|
+
|
|
158
|
+
expect(await gapOf(await reread(role))).toBeNull()
|
|
159
|
+
})
|
|
160
|
+
|
|
117
161
|
it('does not take away what the administrator added beyond the template', async () => {
|
|
118
162
|
declareTemplate(['query'])
|
|
119
163
|
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
@@ -131,19 +175,26 @@ describe('the gap between a role and its template', () => {
|
|
|
131
175
|
expect(gap.extraCount).toBe(1)
|
|
132
176
|
})
|
|
133
177
|
|
|
134
|
-
it('★ lays a role
|
|
178
|
+
it('★ lays a role named in the site\'s own words against the base the administrator gives it', async () => {
|
|
135
179
|
declareTemplate(['query', 'mutation'])
|
|
136
180
|
const role = await getRepository(Role).save({ domain, name: '품질검사', privileges: [await getRepository(Privilege).findOneBy({ category: 'board', name: 'query' })] })
|
|
137
181
|
|
|
138
182
|
expect(await gapOf(role)).toBeNull()
|
|
139
|
-
|
|
183
|
+
|
|
184
|
+
await baseOn(role, 'plant.qi')
|
|
185
|
+
expect(await gapOf(await reread(role))).toMatchObject({ templateId: 'plant.qi', missing: [{ category: 'board', axes: ['mutation'] }], extraCount: 0 })
|
|
186
|
+
|
|
187
|
+
await baseOn(role, null)
|
|
188
|
+
expect((await reread(role)).templateId).toBeNull()
|
|
189
|
+
expect(await gapOf(await reread(role))).toBeNull()
|
|
140
190
|
})
|
|
141
191
|
|
|
142
|
-
it('refuses a template id that does not exist rather than
|
|
192
|
+
it('refuses a base template id that does not exist rather than storing it', async () => {
|
|
143
193
|
declareTemplate(['query'])
|
|
144
194
|
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
145
195
|
|
|
146
|
-
await expect(
|
|
196
|
+
await expect(baseOn(role, 'plant.nobody')).rejects.toThrow(/no role template "plant.nobody"/)
|
|
197
|
+
expect((await reread(role)).templateId).toBe('plant.qi')
|
|
147
198
|
})
|
|
148
199
|
})
|
|
149
200
|
|
|
@@ -119,7 +119,8 @@ describe('seeding', () => {
|
|
|
119
119
|
expect(await getRepository(Role).count()).toBe(1)
|
|
120
120
|
})
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
/* A role has one base template (ADR-0075 decision 1, amended 2026-10-26) - two apps declaring one name no longer union. */
|
|
123
|
+
it('refuses a second template onto a role of the same name based on the first', async () => {
|
|
123
124
|
registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
|
|
124
125
|
registerRoleTemplate({
|
|
125
126
|
id: 'twin.qi',
|
|
@@ -127,10 +128,10 @@ describe('seeding', () => {
|
|
|
127
128
|
grants: [{ category: 'board', axes: ['mutation'] }]
|
|
128
129
|
})
|
|
129
130
|
|
|
130
|
-
await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
131
|
-
|
|
131
|
+
const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
|
|
132
|
+
await expect(mutation.createRoleFromTemplate('twin.qi', undefined, context())).rejects.toThrow(/based on role template "plant.qi", not "twin.qi"/)
|
|
132
133
|
|
|
133
|
-
expect(await privilegesOf(role.id)).toEqual(['board:
|
|
134
|
+
expect(await privilegesOf(role.id)).toEqual(['board:query'])
|
|
134
135
|
expect(await getRepository(Role).count()).toBe(1)
|
|
135
136
|
})
|
|
136
137
|
|
package/translations/en.json
CHANGED
|
@@ -266,5 +266,6 @@
|
|
|
266
266
|
"privilege.allows.audit.query": "see everyone’s sign-ins, privilege use and refusals, and role changes",
|
|
267
267
|
"task.auth-base.audit-reading.name": "Reading the audit log",
|
|
268
268
|
"task.auth-base.audit-reading.description": "Reads everyone's sign-ins, privilege use and refusals, and role changes in the domain.",
|
|
269
|
-
"error.access-request-task-mismatch": "the work you picked does not include the refused privilege."
|
|
269
|
+
"error.access-request-task-mismatch": "the work you picked does not include the refused privilege.",
|
|
270
|
+
"error.identity confirmation required": "confirm it is you to see this"
|
|
270
271
|
}
|
package/translations/es.json
CHANGED
|
@@ -266,5 +266,6 @@
|
|
|
266
266
|
"privilege.allows.audit.query": "ver los inicios de sesión, el uso y rechazo de privilegios y los cambios de rol de todos",
|
|
267
267
|
"task.auth-base.audit-reading.name": "Consultar el registro de auditoría",
|
|
268
268
|
"task.auth-base.audit-reading.description": "Lee los inicios de sesión, el uso y rechazo de privilegios y los cambios de rol de todos en el dominio.",
|
|
269
|
-
"error.access-request-task-mismatch": "la tarea elegida no incluye el privilegio rechazado."
|
|
269
|
+
"error.access-request-task-mismatch": "la tarea elegida no incluye el privilegio rechazado.",
|
|
270
|
+
"error.identity confirmation required": "confirme que es usted para ver esto"
|
|
270
271
|
}
|
package/translations/ja.json
CHANGED
|
@@ -266,5 +266,6 @@
|
|
|
266
266
|
"privilege.allows.audit.query": "全ユーザーのログイン、権限の使用と拒否、ロール変更の記録を見ることができます。",
|
|
267
267
|
"task.auth-base.audit-reading.name": "監査ログの閲覧",
|
|
268
268
|
"task.auth-base.audit-reading.description": "全ユーザーのログイン、権限の使用と拒否、ロール変更の記録を読む。",
|
|
269
|
-
"error.access-request-task-mismatch": "選んだ業務に拒否された権限が含まれていません。"
|
|
269
|
+
"error.access-request-task-mismatch": "選んだ業務に拒否された権限が含まれていません。",
|
|
270
|
+
"error.identity confirmation required": "本人確認が必要です。"
|
|
270
271
|
}
|
package/translations/ko.json
CHANGED
|
@@ -266,5 +266,6 @@
|
|
|
266
266
|
"privilege.allows.audit.query": "모든 사용자의 로그인, 권한 사용과 거절, 역할 변경 기록을 볼 수 있습니다.",
|
|
267
267
|
"task.auth-base.audit-reading.name": "감사 기록 열람",
|
|
268
268
|
"task.auth-base.audit-reading.description": "모든 사용자의 로그인, 권한 사용과 거절, 역할 변경 기록을 읽는다.",
|
|
269
|
-
"error.access-request-task-mismatch": "선택한 업무에 접근이 거부된 권한이 포함되어 있지 않습니다."
|
|
269
|
+
"error.access-request-task-mismatch": "선택한 업무에 접근이 거부된 권한이 포함되어 있지 않습니다.",
|
|
270
|
+
"error.identity confirmation required": "본인 확인이 필요합니다."
|
|
270
271
|
}
|
package/translations/ms.json
CHANGED
|
@@ -266,5 +266,6 @@
|
|
|
266
266
|
"privilege.allows.audit.query": "boleh melihat log masuk, penggunaan dan penolakan keistimewaan, serta perubahan peranan semua orang.",
|
|
267
267
|
"task.auth-base.audit-reading.name": "Membaca log audit",
|
|
268
268
|
"task.auth-base.audit-reading.description": "Membaca log masuk, penggunaan dan penolakan keistimewaan, serta perubahan peranan semua orang dalam domain.",
|
|
269
|
-
"error.access-request-task-mismatch": "tugas yang dipilih tidak termasuk keistimewaan yang ditolak."
|
|
269
|
+
"error.access-request-task-mismatch": "tugas yang dipilih tidak termasuk keistimewaan yang ditolak.",
|
|
270
|
+
"error.identity confirmation required": "sahkan ini anda untuk melihat ini"
|
|
270
271
|
}
|
package/translations/zh.json
CHANGED
|
@@ -266,5 +266,6 @@
|
|
|
266
266
|
"privilege.allows.audit.query": "可以查看所有用户的登录、权限使用与拒绝以及角色变更记录。",
|
|
267
267
|
"task.auth-base.audit-reading.name": "查看审计日志",
|
|
268
268
|
"task.auth-base.audit-reading.description": "读取所有用户的登录、权限使用与拒绝以及角色变更记录。",
|
|
269
|
-
"error.access-request-task-mismatch": "所选业务不包含被拒绝的权限。"
|
|
269
|
+
"error.access-request-task-mismatch": "所选业务不包含被拒绝的权限。",
|
|
270
|
+
"error.identity confirmation required": "需要确认身份。"
|
|
270
271
|
}
|