@things-factory/auth-base 10.1.260 → 10.1.261

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.
@@ -38,3 +38,10 @@ export type PermissionJudgement = {
38
38
  */
39
39
  export declare function judgePermission(privilegeObject: PrivilegeObject, user: User, domain: Domain, unsafeIP?: boolean, prohibitedPrivileges?: ProhibitedPrivilege[]): Promise<PermissionJudgement>;
40
40
  export declare function checkPermission(privilegeObject: PrivilegeObject, user: User, domain: Domain, unsafeIP?: boolean, prohibitedPrivileges?: ProhibitedPrivilege[]): Promise<boolean>;
41
+ /**
42
+ * Whether the request holds `category:privilege`, asked as the doors ask it (`domainOwnerGranted: true`) - the answer
43
+ * a code path needs when one door serves people of different reach (a manager every row, anyone else their own).
44
+ * Asked once per request: a field resolver runs for every row of a list, and the answer is kept on `context.state`.
45
+ * No domain or no user holds nothing.
46
+ */
47
+ export declare function requestHolds(context: any, category: string, privilege?: 'query' | 'mutation'): Promise<boolean>;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.judgePermission = judgePermission;
4
4
  exports.checkPermission = checkPermission;
5
+ exports.requestHolds = requestHolds;
5
6
  const user_js_1 = require("../service/user/user.js");
6
7
  /* A gate that declares nothing - nothing opened it. */
7
8
  const PERMITTED = { permitted: true };
@@ -92,4 +93,21 @@ async function judgePermission(privilegeObject, user, domain, unsafeIP, prohibit
92
93
  async function checkPermission(privilegeObject, user, domain, unsafeIP, prohibitedPrivileges) {
93
94
  return (await judgePermission(privilegeObject, user, domain, unsafeIP, prohibitedPrivileges)).permitted;
94
95
  }
96
+ /**
97
+ * Whether the request holds `category:privilege`, asked as the doors ask it (`domainOwnerGranted: true`) - the answer
98
+ * a code path needs when one door serves people of different reach (a manager every row, anyone else their own).
99
+ * Asked once per request: a field resolver runs for every row of a list, and the answer is kept on `context.state`.
100
+ * No domain or no user holds nothing.
101
+ */
102
+ async function requestHolds(context, category, privilege = 'mutation') {
103
+ const state = context?.state;
104
+ const { domain, user, unsafeIP, prohibitedPrivileges } = state ?? {};
105
+ if (!domain || !user)
106
+ return false;
107
+ const asked = (state.__privilegesHeld ??= new Map());
108
+ const key = `${category}:${privilege}`;
109
+ if (!asked.has(key))
110
+ asked.set(key, checkPermission({ category, privilege, owner: true }, user, domain, unsafeIP, prohibitedPrivileges));
111
+ return await asked.get(key);
112
+ }
95
113
  //# sourceMappingURL=check-permission.js.map
@@ -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\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"]}
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\n/**\n * Whether the request holds `category:privilege`, asked as the doors ask it (`domainOwnerGranted: true`) - the answer\n * a code path needs when one door serves people of different reach (a manager every row, anyone else their own).\n * Asked once per request: a field resolver runs for every row of a list, and the answer is kept on `context.state`.\n * No domain or no user holds nothing.\n */\nexport async function requestHolds(context: any, category: string, privilege: 'query' | 'mutation' = 'mutation'): Promise<boolean> {\n const state = context?.state\n const { domain, user, unsafeIP, prohibitedPrivileges } = state ?? {}\n if (!domain || !user) return false\n const asked: Map<string, Promise<boolean>> = (state.__privilegesHeld ??= new Map())\n const key = `${category}:${privilege}`\n if (!asked.has(key)) asked.set(key, checkPermission({ category, privilege, owner: true } as any, user, domain, unsafeIP, prohibitedPrivileges))\n return await asked.get(key)!\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/auth-base",
3
- "version": "10.1.260",
3
+ "version": "10.1.261",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -35,10 +35,10 @@
35
35
  "@reduxjs/toolkit": "^2.2.5",
36
36
  "@simplewebauthn/browser": "^13.0.0",
37
37
  "@simplewebauthn/server": "^13.0.0",
38
- "@things-factory/email-base": "^10.1.260",
38
+ "@things-factory/email-base": "^10.1.261",
39
39
  "@things-factory/env": "^10.1.149",
40
- "@things-factory/shell": "^10.1.260",
41
- "@things-factory/task-lease": "^10.1.260",
40
+ "@things-factory/shell": "^10.1.261",
41
+ "@things-factory/task-lease": "^10.1.261",
42
42
  "@things-factory/utils": "^10.1.179",
43
43
  "@types/webappsec-credential-management": "^0.6.9",
44
44
  "jsonwebtoken": "^9.0.0",
@@ -50,5 +50,5 @@
50
50
  "passport-jwt": "^4.0.0",
51
51
  "passport-local": "^1.0.0"
52
52
  },
53
- "gitHead": "72de583ba0394dcf8195ef723b0ee4217507e0e5"
53
+ "gitHead": "06537757f8708e6a511c6232e8792f18fdd6a986"
54
54
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `requestHolds` - whether the request holds a privilege, asked as the doors ask it and once per request.
3
+ */
4
+
5
+ import { loadCompiled } from './compiled'
6
+
7
+ const { requestHolds } = loadCompiled('utils/check-permission')
8
+ const { User } = loadCompiled('service/user/user')
9
+
10
+ const DOMAIN = { id: 'domain-1', subdomain: 'plant-a' } as any
11
+ const USER = { id: 'user-1', domains: [DOMAIN] } as any
12
+
13
+ beforeEach(() => {
14
+ ;(process as any).domainOwnerGranted = jest.fn().mockResolvedValue(false)
15
+ ;(process as any).superUserGranted = jest.fn().mockResolvedValue(false)
16
+ })
17
+
18
+ test('a role that holds the privilege answers yes, and the database is asked once per request', async () => {
19
+ User.hasPrivilege = jest.fn().mockResolvedValue(true)
20
+ const context = { state: { domain: DOMAIN, user: USER } }
21
+ expect(await requestHolds(context, 'course', 'query')).toBe(true)
22
+ expect(await requestHolds(context, 'course', 'query')).toBe(true)
23
+ expect(User.hasPrivilege).toHaveBeenCalledTimes(1)
24
+ })
25
+
26
+ test('each privilege is asked on its own - query and mutation are two answers', async () => {
27
+ User.hasPrivilege = jest.fn().mockImplementation(async (privilege: string) => privilege === 'query')
28
+ const context = { state: { domain: DOMAIN, user: USER } }
29
+ expect(await requestHolds(context, 'course', 'query')).toBe(true)
30
+ expect(await requestHolds(context, 'course', 'mutation')).toBe(false)
31
+ expect(await requestHolds(context, 'learning', 'query')).toBe(true)
32
+ expect(User.hasPrivilege).toHaveBeenCalledTimes(3)
33
+ })
34
+
35
+ test('the domain owner holds it, as `domainOwnerGranted: true` doors let them in', async () => {
36
+ User.hasPrivilege = jest.fn().mockResolvedValue(false)
37
+ ;(process as any).domainOwnerGranted = jest.fn().mockResolvedValue(true)
38
+ expect(await requestHolds({ state: { domain: DOMAIN, user: USER } }, 'course', 'mutation')).toBe(true)
39
+ })
40
+
41
+ test('a privilege prohibited for the request is not held', async () => {
42
+ User.hasPrivilege = jest.fn().mockResolvedValue(true)
43
+ const state = { domain: DOMAIN, user: USER, prohibitedPrivileges: [{ category: 'course', privilege: 'mutation', by: 'license' }] }
44
+ expect(await requestHolds({ state }, 'course', 'mutation')).toBe(false)
45
+ })
46
+
47
+ test('no domain or no user holds nothing', async () => {
48
+ User.hasPrivilege = jest.fn().mockResolvedValue(true)
49
+ expect(await requestHolds({ state: { user: USER } }, 'course')).toBe(false)
50
+ expect(await requestHolds({ state: { domain: DOMAIN } }, 'course')).toBe(false)
51
+ expect(await requestHolds(undefined, 'course')).toBe(false)
52
+ expect(User.hasPrivilege).not.toHaveBeenCalled()
53
+ })