@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.
Files changed (52) hide show
  1. package/dist-client/tsconfig.tsbuildinfo +1 -1
  2. package/dist-server/controllers/confirm-identity.d.ts +8 -0
  3. package/dist-server/controllers/confirm-identity.js +33 -0
  4. package/dist-server/controllers/confirm-identity.js.map +1 -0
  5. package/dist-server/controllers/signin.d.ts +5 -0
  6. package/dist-server/controllers/signin.js +38 -31
  7. package/dist-server/controllers/signin.js.map +1 -1
  8. package/dist-server/router/webauthn-router.js +20 -1
  9. package/dist-server/router/webauthn-router.js.map +1 -1
  10. package/dist-server/service/audit/audit-kinds.js +2 -0
  11. package/dist-server/service/audit/audit-kinds.js.map +1 -1
  12. package/dist-server/service/identity-confirmation/identity-confirmation.d.ts +18 -0
  13. package/dist-server/service/identity-confirmation/identity-confirmation.js +84 -0
  14. package/dist-server/service/identity-confirmation/identity-confirmation.js.map +1 -0
  15. package/dist-server/service/index.d.ts +1 -0
  16. package/dist-server/service/index.js +3 -1
  17. package/dist-server/service/index.js.map +1 -1
  18. package/dist-server/service/privilege/privilege-directive.js +17 -1
  19. package/dist-server/service/privilege/privilege-directive.js.map +1 -1
  20. package/dist-server/service/role/role-mutation.js +5 -0
  21. package/dist-server/service/role/role-mutation.js.map +1 -1
  22. package/dist-server/service/role/role-types.d.ts +1 -0
  23. package/dist-server/service/role/role-types.js +4 -0
  24. package/dist-server/service/role/role-types.js.map +1 -1
  25. package/dist-server/service/role/role.d.ts +10 -0
  26. package/dist-server/service/role/role.js +5 -0
  27. package/dist-server/service/role/role.js.map +1 -1
  28. package/dist-server/service/role-template/role-template-gap-resolver.d.ts +2 -2
  29. package/dist-server/service/role-template/role-template-gap-resolver.js +7 -13
  30. package/dist-server/service/role-template/role-template-gap-resolver.js.map +1 -1
  31. package/dist-server/service/role-template/role-template-mutation.d.ts +7 -4
  32. package/dist-server/service/role-template/role-template-mutation.js +15 -15
  33. package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
  34. package/dist-server/service/role-template/role-template-types.d.ts +3 -3
  35. package/dist-server/service/role-template/role-template-types.js.map +1 -1
  36. package/dist-server/service/role-template/role-template.d.ts +14 -15
  37. package/dist-server/service/role-template/role-template.js +8 -8
  38. package/dist-server/service/role-template/role-template.js.map +1 -1
  39. package/dist-server/tsconfig.tsbuildinfo +1 -1
  40. package/dist-server/utils/access-token-cookie.js +3 -0
  41. package/dist-server/utils/access-token-cookie.js.map +1 -1
  42. package/package.json +2 -2
  43. package/tests/confirm-identity-db.test.ts +85 -0
  44. package/tests/identity-confirmation.test.ts +146 -0
  45. package/tests/role-template-gap-db.test.ts +58 -7
  46. package/tests/role-template-seed-db.test.ts +5 -4
  47. package/translations/en.json +2 -1
  48. package/translations/es.json +2 -1
  49. package/translations/ja.json +2 -1
  50. package/translations/ko.json +2 -1
  51. package/translations/ms.json +2 -1
  52. 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":";;AAKA,oDAEC;AAED,oDAWC;AAED,sDASC;AAED,wDAgBC;AAjDD,6CAA4C;AAC5C,wDAAiD;AAEjD,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;;;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'\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 /*\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"]}
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.218",
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": "b4e60fdfe307cecebb2264267f616749a502d5fe"
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 the template of its name - against a real database (ADR-0075 decision 1).
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, templateId?: string) => new RoleTemplateGapResolver().templateGap(role, templateId)
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('★ is null for a role no template names - a renamed role belongs to its administrator', async () => {
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 against a template the administrator picks, whatever the role is called', async () => {
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
- expect(await gapOf(role, 'plant.qi')).toMatchObject({ templateId: 'plant.qi', missing: [{ category: 'board', axes: ['mutation'] }], extraCount: 0 })
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 answering null', async () => {
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(gapOf(role, 'plant.nobody')).rejects.toThrow(/no role template "plant.nobody"/)
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
- it('adds the union when two applications declare the same seat', async () => {
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
- const role = await mutation.createRoleFromTemplate('twin.qi', undefined, context())
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:mutation', 'board:query'])
134
+ expect(await privilegesOf(role.id)).toEqual(['board:query'])
134
135
  expect(await getRepository(Role).count()).toBe(1)
135
136
  })
136
137
 
@@ -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
  }
@@ -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
  }
@@ -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
  }
@@ -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
  }
@@ -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
  }
@@ -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
  }