@things-factory/auth-base 10.1.188 → 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.
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/controllers/signin.d.ts +5 -0
- package/dist-server/controllers/signin.js +25 -0
- package/dist-server/controllers/signin.js.map +1 -1
- package/dist-server/controllers/verification.d.ts +5 -1
- package/dist-server/controllers/verification.js +11 -6
- package/dist-server/controllers/verification.js.map +1 -1
- package/dist-server/index.js +7 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/middlewares/audit-scope-middleware.d.ts +7 -0
- package/dist-server/middlewares/audit-scope-middleware.js +15 -0
- package/dist-server/middlewares/audit-scope-middleware.js.map +1 -0
- package/dist-server/middlewares/authenticate-401-middleware.js +4 -0
- package/dist-server/middlewares/authenticate-401-middleware.js.map +1 -1
- package/dist-server/middlewares/index.js +3 -0
- package/dist-server/middlewares/index.js.map +1 -1
- package/dist-server/role-tasks.d.ts +2 -0
- package/dist-server/role-tasks.js +20 -0
- package/dist-server/role-tasks.js.map +1 -0
- package/dist-server/router/auth-checkin-router.js +3 -5
- package/dist-server/router/auth-checkin-router.js.map +1 -1
- package/dist-server/router/auth-public-process-router.js +26 -14
- package/dist-server/router/auth-public-process-router.js.map +1 -1
- package/dist-server/router/site-root-router.d.ts +2 -0
- package/dist-server/router/site-root-router.js +2 -0
- package/dist-server/router/site-root-router.js.map +1 -1
- package/dist-server/service/access-request/index.d.ts +1 -1
- package/dist-server/service/audit/audit-action.d.ts +36 -0
- package/dist-server/service/audit/audit-action.js +137 -0
- package/dist-server/service/audit/audit-action.js.map +1 -0
- package/dist-server/service/audit/audit-kinds.d.ts +19 -0
- package/dist-server/service/audit/audit-kinds.js +70 -0
- package/dist-server/service/audit/audit-kinds.js.map +1 -0
- package/dist-server/service/audit/audit-query.d.ts +11 -0
- package/dist-server/service/audit/audit-query.js +295 -0
- package/dist-server/service/audit/audit-query.js.map +1 -0
- package/dist-server/service/audit/audit-recorder.d.ts +77 -0
- package/dist-server/service/audit/audit-recorder.js +247 -0
- package/dist-server/service/audit/audit-recorder.js.map +1 -0
- package/dist-server/service/audit/audit-request.d.ts +28 -0
- package/dist-server/service/audit/audit-request.js +96 -0
- package/dist-server/service/audit/audit-request.js.map +1 -0
- package/dist-server/service/audit/audit-retention.d.ts +12 -0
- package/dist-server/service/audit/audit-retention.js +42 -0
- package/dist-server/service/audit/audit-retention.js.map +1 -0
- package/dist-server/service/audit/audit-subscriber.d.ts +39 -0
- package/dist-server/service/audit/audit-subscriber.js +191 -0
- package/dist-server/service/audit/audit-subscriber.js.map +1 -0
- package/dist-server/service/audit/audit-target.d.ts +19 -0
- package/dist-server/service/audit/audit-target.js +46 -0
- package/dist-server/service/audit/audit-target.js.map +1 -0
- package/dist-server/service/audit/audit-types.d.ts +44 -0
- package/dist-server/service/audit/audit-types.js +161 -0
- package/dist-server/service/audit/audit-types.js.map +1 -0
- package/dist-server/service/audit/index.d.ts +6 -0
- package/dist-server/service/audit/index.js +11 -0
- package/dist-server/service/audit/index.js.map +1 -0
- package/dist-server/service/domain-owner/domain-owner-mutation.js +3 -0
- package/dist-server/service/domain-owner/domain-owner-mutation.js.map +1 -1
- package/dist-server/service/index.d.ts +9 -2
- package/dist-server/service/index.js +19 -4
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/open-fields.js +1 -1
- package/dist-server/service/open-fields.js.map +1 -1
- package/dist-server/service/privilege/privilege-directive.js +25 -1
- package/dist-server/service/privilege/privilege-directive.js.map +1 -1
- package/dist-server/service/role/role-mutation.js +8 -1
- package/dist-server/service/role/role-mutation.js.map +1 -1
- package/dist-server/service/role-template/index.d.ts +1 -1
- package/dist-server/service/role-template/role-task.d.ts +74 -0
- package/dist-server/service/role-template/role-task.js +113 -0
- package/dist-server/service/role-template/role-task.js.map +1 -0
- package/dist-server/service/role-template/role-template-gap-resolver.d.ts +9 -5
- package/dist-server/service/role-template/role-template-gap-resolver.js +41 -13
- package/dist-server/service/role-template/role-template-gap-resolver.js.map +1 -1
- package/dist-server/service/role-template/role-template-mutation.js +10 -1
- package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
- package/dist-server/service/role-template/role-template-query.js +4 -2
- package/dist-server/service/role-template/role-template-query.js.map +1 -1
- package/dist-server/service/role-template/role-template-types.d.ts +24 -0
- package/dist-server/service/role-template/role-template-types.js +63 -1
- package/dist-server/service/role-template/role-template-types.js.map +1 -1
- package/dist-server/service/role-template/role-template.d.ts +63 -10
- package/dist-server/service/role-template/role-template.js +92 -18
- package/dist-server/service/role-template/role-template.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/check-permission.d.ts +6 -0
- package/dist-server/utils/check-permission.js +23 -8
- package/dist-server/utils/check-permission.js.map +1 -1
- package/package.json +3 -2
- package/tests/activate-page-case.test.ts +53 -0
- package/tests/audit-db.test.ts +394 -0
- package/tests/auth-base-role-tasks.test.ts +58 -0
- package/tests/list-inheritance-db.test.ts +0 -7
- package/tests/mail-request-answers-db.test.ts +139 -0
- package/tests/open-doors.test.ts +2 -2
- package/tests/refusal-cause.test.ts +1 -1
- package/tests/role-task.test.ts +168 -0
- package/tests/role-template-gap-db.test.ts +77 -2
- package/translations/en.json +16 -3
- package/translations/es.json +16 -3
- package/translations/ja.json +16 -3
- package/translations/ko.json +16 -3
- package/translations/ms.json +16 -3
- package/translations/zh.json +16 -3
|
@@ -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
|
-
/*
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
78
|
+
const basis = await ownershipBasis();
|
|
79
|
+
return basis ? granted(basis) : refused('ownership');
|
|
66
80
|
}
|
|
67
|
-
|
|
68
|
-
|
|
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)) ?
|
|
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":";;
|
|
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.
|
|
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": "
|
|
53
|
+
"gitHead": "68f884a78ca3a779c2b057ba5b8f6e03f477d701"
|
|
53
54
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { loadCompiled } from './compiled'
|
|
2
|
+
|
|
3
|
+
const { authenticate401Middleware } = loadCompiled('middlewares/authenticate-401-middleware')
|
|
4
|
+
const { AuthError } = loadCompiled('errors/auth-error')
|
|
5
|
+
const CODES = loadCompiled('constants/error-code')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One page, `auth-activate`, answers a locked account and one not yet confirmed. It said "your
|
|
9
|
+
* account is not activated" to both: the reason was in `message`, and the page never read it. A
|
|
10
|
+
* locked person was not told why, nor that the unlock mail had already gone.
|
|
11
|
+
*
|
|
12
|
+
* The page tells the two apart by `errorCode`, so that is what this holds - for both cases.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
async function pageFor(errorCode: string) {
|
|
16
|
+
let rendered: any
|
|
17
|
+
|
|
18
|
+
const context: any = {
|
|
19
|
+
t: (key: string) => key,
|
|
20
|
+
method: 'GET',
|
|
21
|
+
header: { accept: 'text/html' },
|
|
22
|
+
path: '/',
|
|
23
|
+
originalUrl: '/',
|
|
24
|
+
query: {},
|
|
25
|
+
request: { body: {} },
|
|
26
|
+
render: async (view: string, options: any) => {
|
|
27
|
+
rendered = { view, ...options }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await authenticate401Middleware(context, async () => {
|
|
32
|
+
throw new AuthError({ errorCode, detail: { username: 'kim', email: 'kim@example.com' } })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
return rendered
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe.each([CODES.USER_LOCKED, CODES.USER_NOT_ACTIVATED])('%s', errorCode => {
|
|
39
|
+
it('goes to the activate page with its code', async () => {
|
|
40
|
+
const page = await pageFor(errorCode)
|
|
41
|
+
|
|
42
|
+
expect(page.pageElement).toBe('auth-activate')
|
|
43
|
+
expect(page.data.errorCode).toBe(errorCode)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('the page reads the locked code by the spelling the server sends', () => {
|
|
48
|
+
const { readFileSync } = require('fs')
|
|
49
|
+
const { join } = require('path')
|
|
50
|
+
const page = readFileSync(join(__dirname, '..', '..', 'auth-ui', 'client', 'entries', 'auth', 'activate.ts'), 'utf-8')
|
|
51
|
+
|
|
52
|
+
expect(page).toContain(`this.data?.errorCode === '${CODES.USER_LOCKED}'`)
|
|
53
|
+
})
|
|
@@ -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
|
+
})
|