@things-factory/auth-base 10.1.192 → 10.1.198

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