@things-factory/auth-base 10.1.6 → 10.1.16
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/CHANGELOG.md +47 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/index.js +16 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/router/auth-private-process-router.js +20 -1
- package/dist-server/router/auth-private-process-router.js.map +1 -1
- package/dist-server/service/index.d.ts +3 -0
- package/dist-server/service/index.js +16 -11
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/privilege/privilege-axis.d.ts +72 -0
- package/dist-server/service/privilege/privilege-axis.js +188 -0
- package/dist-server/service/privilege/privilege-axis.js.map +1 -0
- package/dist-server/service/privilege/privilege-directive.d.ts +10 -41
- package/dist-server/service/privilege/privilege-directive.js +62 -74
- package/dist-server/service/privilege/privilege-directive.js.map +1 -1
- package/dist-server/service/role/role-mutation.js +2 -2
- package/dist-server/service/role/role-mutation.js.map +1 -1
- package/dist-server/service/role/role-query.d.ts +43 -1
- package/dist-server/service/role/role-query.js +76 -33
- package/dist-server/service/role/role-query.js.map +1 -1
- package/dist-server/service/role-template/index.d.ts +4 -0
- package/dist-server/service/role-template/index.js +9 -0
- package/dist-server/service/role-template/index.js.map +1 -0
- package/dist-server/service/role-template/role-template-mutation.d.ts +39 -0
- package/dist-server/service/role-template/role-template-mutation.js +124 -0
- package/dist-server/service/role-template/role-template-mutation.js.map +1 -0
- package/dist-server/service/role-template/role-template-query.d.ts +11 -0
- package/dist-server/service/role-template/role-template-query.js +79 -0
- package/dist-server/service/role-template/role-template-query.js.map +1 -0
- package/dist-server/service/role-template/role-template-types.d.ts +51 -0
- package/dist-server/service/role-template/role-template-types.js +71 -0
- package/dist-server/service/role-template/role-template-types.js.map +1 -0
- package/dist-server/service/role-template/role-template.d.ts +110 -0
- package/dist-server/service/role-template/role-template.js +147 -0
- package/dist-server/service/role-template/role-template.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/check-permission.js +11 -2
- package/dist-server/utils/check-permission.js.map +1 -1
- package/package.json +4 -4
- package/tests/domain-inheritance-sentinel.test.ts +93 -0
- package/tests/permission-gate.test.ts +16 -12
- package/tests/privilege-axis.test.ts +157 -0
- package/tests/privilege-directive.test.ts +70 -81
- package/tests/role-privileges-db.test.ts +98 -0
- package/tests/role-template-seed-db.test.ts +179 -0
- package/tests/role-template.test.ts +162 -0
|
@@ -15,8 +15,17 @@ async function checkPermission(privilegeObject, user, domain, unsafeIP, prohibit
|
|
|
15
15
|
}
|
|
16
16
|
return await user_js_1.User.hasPrivilege(privilege, category, domain, user);
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
/*
|
|
19
|
+
* Ownership does not count from an unsafe address, so a declaration that rests on
|
|
20
|
+
* ownership alone has nothing left to stand on here and the answer is no.
|
|
21
|
+
*
|
|
22
|
+
* This read `!domainOwnerGranted && !superUserGranted` until 2026-09-15, which answered
|
|
23
|
+
* **yes** for an object that named nothing at all — the same `{}` a safe address
|
|
24
|
+
* refused. An unsafe address was the more permissive of the two, which is backwards.
|
|
25
|
+
* `privilege-directive.ts` now refuses to build a declaration that names nothing, but
|
|
26
|
+
* this function is exported and called directly, so the rule is fixed here too.
|
|
27
|
+
*/
|
|
28
|
+
return false;
|
|
20
29
|
}
|
|
21
30
|
else {
|
|
22
31
|
if (!privilege || !category) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"check-permission.js","sourceRoot":"","sources":["../../server/utils/check-permission.ts"],"names":[],"mappings":";;AAIA,
|
|
1
|
+
{"version":3,"file":"check-permission.js","sourceRoot":"","sources":["../../server/utils/check-permission.ts"],"names":[],"mappings":";;AAIA,0CAwDC;AA1DD,qDAA8C;AAEvC,KAAK,UAAU,eAAe,CACnC,eAAgC,EAChC,IAAU,EACV,MAAc,EACd,QAAkB,EAClB,oBAAgE;IAEhE,IAAI,CAAC,eAAe,EAAE,CAAC;QACrB,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,EAAE,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,gBAAgB,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,eAAe,CAAA;IAEnG,IAAI,QAAQ,EAAE,CAAC;QACb,IAAI,SAAS,IAAI,QAAQ,EAAE,CAAC;YAC1B,8CAA8C;YAC9C,IAAI,CAAC,oBAAoB,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,QAAQ,IAAI,QAAQ,IAAI,EAAE,CAAC,SAAS,IAAI,SAAS,CAAC,EAAE,CAAC;gBAClG,OAAO,KAAK,CAAA;YACd,CAAC;YAED,OAAO,MAAM,cAAI,CAAC,YAAY,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,CAAA;QACnE,CAAC;QAED;;;;;;;;;WASG;QACH,OAAO,KAAK,CAAA;IACd,CAAC;SAAM,CAAC;QACN,IAAI,CAAC,SAAS,IAAI,CAAC,QAAQ,EAAE,CAAC;YAC5B,8DAA8D;YAC9D,OAAO,CACL,CAAC,kBAAkB,IAAI,CAAC,MAAM,OAAO,CAAC,kBAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC;gBACxE,CAAC,gBAAgB,IAAI,CAAC,MAAM,OAAO,CAAC,gBAAgB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,CACrE,CAAA;QACH,CAAC;QAED,IACE,CAAC,kBAAkB,IAAI,CAAC,MAAM,OAAO,CAAC,kBAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC;YACxE,CAAC,gBAAgB,IAAI,CAAC,MAAM,OAAO,CAAC,gBAAgB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,EACpE,CAAC;YACD,OAAO,IAAI,CAAA;QACb,CAAC;QAED,IAAI,CAAC,oBAAoB,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,QAAQ,IAAI,QAAQ,IAAI,EAAE,CAAC,SAAS,IAAI,SAAS,CAAC,EAAE,CAAC;YAClG,OAAO,KAAK,CAAA;QACd,CAAC;QAED,OAAO,MAAM,cAAI,CAAC,YAAY,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,CAAA;IACnE,CAAC;AACH,CAAC","sourcesContent":["import { Domain } from '@things-factory/shell'\nimport { PrivilegeObject } from '../service/privilege/privilege.js'\nimport { User } from '../service/user/user.js'\n\nexport async function checkPermission(\n privilegeObject: PrivilegeObject,\n user: User,\n domain: Domain,\n unsafeIP?: boolean,\n prohibitedPrivileges?: { category: string; privilege: string }[]\n): Promise<boolean> {\n if (!privilegeObject) {\n return true\n }\n\n const { owner: domainOwnerGranted, super: superUserGranted, category, privilege } = privilegeObject\n\n if (unsafeIP) {\n if (privilege && category) {\n // unsafeIP 상황에서는 ownership granted는 적용되지 않는다.\n if ((prohibitedPrivileges || []).find(pp => pp.category == category && pp.privilege == privilege)) {\n return false\n }\n\n return await User.hasPrivilege(privilege, category, domain, user)\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 false\n } else {\n if (!privilege || !category) {\n // privilege, category가 설정되지 않은 경우에는 ownership granted만을 적용한다.\n return (\n (domainOwnerGranted && (await process.domainOwnerGranted(domain, user))) ||\n (superUserGranted && (await process.superUserGranted(domain, user)))\n )\n }\n\n if (\n (domainOwnerGranted && (await process.domainOwnerGranted(domain, user))) ||\n (superUserGranted && (await process.superUserGranted(domain, user)))\n ) {\n return true\n }\n\n if ((prohibitedPrivileges || []).find(pp => pp.category == category && pp.privilege == privilege)) {\n return false\n }\n\n return await User.hasPrivilege(privilege, category, domain, user)\n }\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.16",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"@reduxjs/toolkit": "^2.2.5",
|
|
35
35
|
"@simplewebauthn/browser": "^13.0.0",
|
|
36
36
|
"@simplewebauthn/server": "^13.0.0",
|
|
37
|
-
"@things-factory/email-base": "^10.1.
|
|
37
|
+
"@things-factory/email-base": "^10.1.16",
|
|
38
38
|
"@things-factory/env": "^10.1.3",
|
|
39
|
-
"@things-factory/shell": "^10.1.
|
|
39
|
+
"@things-factory/shell": "^10.1.16",
|
|
40
40
|
"@things-factory/utils": "^10.1.3",
|
|
41
41
|
"@types/webappsec-credential-management": "^0.6.9",
|
|
42
42
|
"jsonwebtoken": "^9.0.0",
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
"passport-jwt": "^4.0.0",
|
|
49
49
|
"passport-local": "^1.0.0"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "aff86bc162b42c1d933711f7bc85a073f1ddcd29"
|
|
52
52
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `inherited: Only` binds when the domain has no parent.
|
|
3
|
+
*
|
|
4
|
+
* ── What was here until 2026-09-15 ───────────────────────────────────────────
|
|
5
|
+
* qb.andWhere(`${alias}.domain = :domain`, { domain: domain.parentId || 'Impossible' })
|
|
6
|
+
*
|
|
7
|
+
* "Match nothing" is the right intent — a root domain inherits from nobody — but it was
|
|
8
|
+
* spelled as a **value** bound to a uuid column. sqlite compares it as text and returns zero
|
|
9
|
+
* rows, which is the intended answer arrived at by accident, so it was green through three
|
|
10
|
+
* years of development. postgres parses it and refuses the whole query:
|
|
11
|
+
*
|
|
12
|
+
* invalid input syntax for type uuid: "Impossible" (observed 2026-09-15, path ["roles"])
|
|
13
|
+
*
|
|
14
|
+
* ── Why this test is written against the parameters, not the rows ────────────
|
|
15
|
+
* This harness is sqlite and cannot fail the way the server does; `tests/db.ts` says as much
|
|
16
|
+
* about column dialects. So it reads what TypeORM is about to **send** instead of what comes
|
|
17
|
+
* back. The defect lived in what we build, and what we build is the same on every driver —
|
|
18
|
+
* which makes this the way to reach the whole class our sqlite development cannot see.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase } from './db'
|
|
22
|
+
import { loadCompiled } from './compiled'
|
|
23
|
+
|
|
24
|
+
const { Role } = loadCompiled('service/role/role')
|
|
25
|
+
|
|
26
|
+
const shell = require('@things-factory/shell')
|
|
27
|
+
|
|
28
|
+
const ROOT = { id: '00000000-0000-4000-8000-000000000001', parentId: null }
|
|
29
|
+
const CHILD = { id: '00000000-0000-4000-8000-000000000002', parentId: ROOT.id }
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
await openAuthDatabase()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await closeAuthDatabase()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
function builderFor(domain: { id: string; parentId: string | null }, inherited: string) {
|
|
40
|
+
return shell.getQueryBuilderFromListParams({
|
|
41
|
+
repository: getRepository(Role),
|
|
42
|
+
params: { filters: [], inherited },
|
|
43
|
+
domain,
|
|
44
|
+
searchables: ['name', 'description']
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parametersFor(domain: { id: string; parentId: string | null }, inherited: string): any[] {
|
|
49
|
+
return builderFor(domain, inherited).getQueryAndParameters()[1]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function queryFor(domain: { id: string; parentId: string | null }, inherited: string): string {
|
|
53
|
+
return builderFor(domain, inherited).getQueryAndParameters()[0]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('inherited: Only', () => {
|
|
57
|
+
it('binds the parent id when there is one', () => {
|
|
58
|
+
expect(parametersFor(CHILD, 'Only')).toContain(ROOT.id)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('binds nothing at all when there is no parent', () => {
|
|
62
|
+
/*
|
|
63
|
+
* The sentinel is gone. "A root domain inherits from nobody" is a condition, and it is now
|
|
64
|
+
* written as one — `1 = 0` — rather than as a value that has to survive a uuid parser.
|
|
65
|
+
*/
|
|
66
|
+
expect(parametersFor(ROOT, 'Only')).not.toContain('Impossible')
|
|
67
|
+
|
|
68
|
+
const sql = queryFor(ROOT, 'Only')
|
|
69
|
+
|
|
70
|
+
expect(sql).toContain('1 = 0')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('refuses a value that is not one of the three', () => {
|
|
74
|
+
/*
|
|
75
|
+
* An empty result would be read as "nothing matched". This is a tenant isolation filter;
|
|
76
|
+
* a malfunction here has to be loud, and the enum means no current caller can reach it.
|
|
77
|
+
*/
|
|
78
|
+
expect(() => parametersFor(ROOT, 'Onyl')).toThrow(/unknown inherited value/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('binds nothing that is not a uuid for the other two scopes', () => {
|
|
82
|
+
/* None and Include are fine, which is why this went unnoticed: two of three scopes work. */
|
|
83
|
+
const uuidish = /^[0-9a-f-]{36}$/i
|
|
84
|
+
|
|
85
|
+
for (const scope of ['None', 'Include']) {
|
|
86
|
+
for (const parameter of parametersFor(ROOT, scope)) {
|
|
87
|
+
if (typeof parameter === 'string') {
|
|
88
|
+
expect(parameter).toMatch(uuidish)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -128,9 +128,11 @@ describe('이름이 없는 선언 — 소유권만으로 판정한다', () => {
|
|
|
128
128
|
expect(decision).toBeUndefined()
|
|
129
129
|
})
|
|
130
130
|
|
|
131
|
-
it('
|
|
132
|
-
/* `{}`
|
|
133
|
-
|
|
131
|
+
it('refuses an object that names nothing', async () => {
|
|
132
|
+
/* `{}` is not `!privilegeObject`, so it passes the first gate and falls out falsy here.
|
|
133
|
+
The unsafe-address block below asserts the same answer, which is the point: it used to
|
|
134
|
+
answer the opposite. `privilege-directive.ts` refuses to build this shape at all now,
|
|
135
|
+
but `checkPermission` is exported and reachable without it. */
|
|
134
136
|
expect(await checkPermission({}, USER, DOMAIN)).toBeFalsy()
|
|
135
137
|
})
|
|
136
138
|
})
|
|
@@ -187,21 +189,23 @@ describe('안전하지 않은 IP (unsafeIP)', () => {
|
|
|
187
189
|
})
|
|
188
190
|
|
|
189
191
|
/*
|
|
190
|
-
* ──
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
192
|
+
* ── An unsafe address is never the more permissive of the two ────────────────
|
|
193
|
+
* These two were marked 뒤집힘 and pinned the opposite answer until 2026-09-15. The rule
|
|
194
|
+
* read `return !domainOwnerGranted && !superUserGranted`: "ownership does not apply from an
|
|
195
|
+
* unsafe address" came out as the declaration inverted rather than as a refusal, so an
|
|
196
|
+
* object naming nothing opened from an unsafe address and closed from a safe one.
|
|
197
|
+
*
|
|
198
|
+
* The architect found the same asymmetry from the other side on 2026-09-15, looking at what
|
|
199
|
+
* a field would do if someone wrote `@privilege()` wanting "signed in is enough".
|
|
195
200
|
*/
|
|
196
|
-
it('
|
|
201
|
+
it('refuses a declaration resting on ownership alone', async () => {
|
|
197
202
|
withOwnership({ owner: true })
|
|
198
203
|
|
|
199
204
|
expect(await checkPermission({ owner: true }, USER, DOMAIN, true)).toBe(false)
|
|
200
205
|
})
|
|
201
206
|
|
|
202
|
-
it('
|
|
203
|
-
|
|
204
|
-
expect(await checkPermission({}, USER, DOMAIN, true)).toBe(true)
|
|
207
|
+
it('refuses an object that names nothing, the same as a safe address does', async () => {
|
|
208
|
+
expect(await checkPermission({}, USER, DOMAIN, true)).toBe(false)
|
|
205
209
|
expect(await checkPermission({}, USER, DOMAIN, false)).toBeFalsy()
|
|
206
210
|
})
|
|
207
211
|
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The axis vocabulary — who owns it, and what a value outside it does.
|
|
3
|
+
*
|
|
4
|
+
* The framework used to accept any string as an axis. `@privilege(privilege: "mutaiton")`
|
|
5
|
+
* registered a pair in `process['PRIVILEGES']` that nobody would ever be granted: a gate shut
|
|
6
|
+
* forever, with nothing anywhere to read. Closing the axis to `query | mutation` in the
|
|
7
|
+
* framework would have fixed that and broken dssp's 40 declarations, so the owner declares
|
|
8
|
+
* and the framework asks.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { loadCompiled } from './compiled'
|
|
12
|
+
|
|
13
|
+
const { declareAxes, declaredAxes, isDeclaredAxis, readOnlyAxes, readOnlyAxesAreKnown, DEFAULT_AXES } = loadCompiled(
|
|
14
|
+
'service/privilege/privilege-axis'
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
delete (process as any).PRIVILEGE_AXES
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
delete (process as any).PRIVILEGE_AXES
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('an installation that declares nothing', () => {
|
|
26
|
+
it('gets query and mutation', () => {
|
|
27
|
+
expect([...declaredAxes()].sort()).toEqual(['mutation', 'query'])
|
|
28
|
+
expect(DEFAULT_AXES).toEqual(['query', 'mutation'])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('still refuses a value outside them', () => {
|
|
32
|
+
/*
|
|
33
|
+
* The defaults are a vocabulary, not an off switch. An installation that forgot to declare
|
|
34
|
+
* is the common case, and it is the one that most needs the typo caught.
|
|
35
|
+
*/
|
|
36
|
+
expect(isDeclaredAxis('query')).toBe(true)
|
|
37
|
+
expect(isDeclaredAxis('mutaiton')).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('an installation that declares its own words', () => {
|
|
42
|
+
it('accepts them', () => {
|
|
43
|
+
declareAxes(['read', 'input', 'finalize'])
|
|
44
|
+
|
|
45
|
+
expect(isDeclaredAxis('read')).toBe(true)
|
|
46
|
+
expect(isDeclaredAxis('finalize')).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('replaces the defaults rather than adding to them', () => {
|
|
50
|
+
/*
|
|
51
|
+
* A house that says "my axes are read and input" has said what its axes are. Keeping
|
|
52
|
+
* query and mutation alongside would let a declaration use a word this installation
|
|
53
|
+
* does not actually mean, which is the thing being prevented.
|
|
54
|
+
*/
|
|
55
|
+
declareAxes(['read', 'input'])
|
|
56
|
+
|
|
57
|
+
expect(isDeclaredAxis('query')).toBe(false)
|
|
58
|
+
expect([...declaredAxes()].sort()).toEqual(['input', 'read'])
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('more than one module declaring', () => {
|
|
63
|
+
it('takes the union, because one installation mixes vocabularies', () => {
|
|
64
|
+
/*
|
|
65
|
+
* This is dssp: `project` is declared with query/mutation while `kpi` uses eight words of
|
|
66
|
+
* its own, in the same installation. Each module says what it uses.
|
|
67
|
+
*/
|
|
68
|
+
declareAxes(['query', 'mutation'])
|
|
69
|
+
declareAxes(['read', 'assessment'])
|
|
70
|
+
|
|
71
|
+
expect([...declaredAxes()].sort()).toEqual(['assessment', 'mutation', 'query', 'read'])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('does not treat the same axis twice as a conflict', () => {
|
|
75
|
+
/* Two modules reaching for `read` independently is the case this is built for. */
|
|
76
|
+
declareAxes(['read'])
|
|
77
|
+
declareAxes(['read'])
|
|
78
|
+
|
|
79
|
+
expect([...declaredAxes()]).toEqual(['read'])
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('what declareAxes refuses', () => {
|
|
84
|
+
it('refuses an empty list, which would refuse every declaration', () => {
|
|
85
|
+
expect(() => declareAxes([])).toThrow(/at least one axis/)
|
|
86
|
+
expect(() => declareAxes(undefined as any)).toThrow(/at least one axis/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('refuses a value that is not a word', () => {
|
|
90
|
+
expect(() => declareAxes([''])).toThrow(/non-empty strings/)
|
|
91
|
+
expect(() => declareAxes([' '])).toThrow(/non-empty strings/)
|
|
92
|
+
expect(() => declareAxes([null as any])).toThrow(/non-empty strings/)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('changes nothing when it refuses', () => {
|
|
96
|
+
declareAxes(['read'], { readOnly: ['read'] })
|
|
97
|
+
|
|
98
|
+
expect(() => declareAxes(['input', ''])).toThrow()
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
* Every value is checked before any is stored, so a refused call leaves the vocabulary as
|
|
102
|
+
* it was. An earlier draft added axes as it walked the list and stopped at the bad one,
|
|
103
|
+
* which left `input` declared by a call that threw.
|
|
104
|
+
*/
|
|
105
|
+
expect(isDeclaredAxis('input')).toBe(false)
|
|
106
|
+
expect(isDeclaredAxis('read')).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('refuses a read-only axis that is not in the same call', () => {
|
|
110
|
+
/*
|
|
111
|
+
* A typo here is the quietest kind: `readOnly: ['querry']` would mark nothing, VIEWER
|
|
112
|
+
* would lose every category of this module, and the screen would count the shortfall
|
|
113
|
+
* correctly and present it as fact.
|
|
114
|
+
*/
|
|
115
|
+
expect(() => declareAxes(['query', 'mutation'], { readOnly: ['querry'] })).toThrow(/not in the same call/)
|
|
116
|
+
expect(() => declareAxes(['read'], { readOnly: ['query'] })).toThrow(/Declared here: read/)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('which axes are read-only', () => {
|
|
121
|
+
it('defaults to query when nobody declared anything', () => {
|
|
122
|
+
/* The vocabulary and the answer to "which half is safe" travel together. */
|
|
123
|
+
expect([...readOnlyAxes()]).toEqual(['query'])
|
|
124
|
+
expect(readOnlyAxesAreKnown()).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('is unknown when a house declared its own words and did not say', () => {
|
|
128
|
+
declareAxes(['read', 'input', 'finalize'])
|
|
129
|
+
|
|
130
|
+
expect(readOnlyAxes().size).toBe(0)
|
|
131
|
+
expect(readOnlyAxesAreKnown()).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('takes the union across modules, like the axes themselves', () => {
|
|
135
|
+
declareAxes(['query', 'mutation'], { readOnly: ['query'] })
|
|
136
|
+
declareAxes(['read', 'finalize'], { readOnly: ['read'] })
|
|
137
|
+
|
|
138
|
+
expect([...readOnlyAxes()].sort()).toEqual(['query', 'read'])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('does not mean the operation is a @Query', () => {
|
|
142
|
+
/*
|
|
143
|
+
* `exportLabelStudioAnnotations` is declared `@Mutation` and gated on `label-studio:query`
|
|
144
|
+
* [읽었음 task-management.ts:200-204]. It calls an external API's export and returns what
|
|
145
|
+
* comes back; nothing in our database moves. The axis is the correct one, and a role
|
|
146
|
+
* holding only that axis still changes nothing.
|
|
147
|
+
*
|
|
148
|
+
* This test carries no assertion about that file — it cannot, from here. It exists so the
|
|
149
|
+
* next person reading `readOnly` finds the distinction spelled out beside the code rather
|
|
150
|
+
* than only in a comment they might skip.
|
|
151
|
+
*/
|
|
152
|
+
declareAxes(['query', 'mutation'], { readOnly: ['query'] })
|
|
153
|
+
|
|
154
|
+
expect(readOnlyAxes().has('query')).toBe(true)
|
|
155
|
+
expect(readOnlyAxes().has('mutation')).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
|
|
14
14
|
import { graphql } from 'graphql'
|
|
15
15
|
import { makeExecutableSchema } from '@graphql-tools/schema'
|
|
16
|
-
import { logger } from '@things-factory/env'
|
|
17
16
|
|
|
18
17
|
import { loadCompiled } from './compiled'
|
|
19
18
|
|
|
@@ -34,7 +33,6 @@ const typeDefs = `
|
|
|
34
33
|
guardedBoard: String @privilege(category: "board", privilege: "mutation")
|
|
35
34
|
ownershipOnly: String @privilege(domainOwnerGranted: true)
|
|
36
35
|
undeclared: String
|
|
37
|
-
moved: String @privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")
|
|
38
36
|
}
|
|
39
37
|
`
|
|
40
38
|
|
|
@@ -45,8 +43,7 @@ function buildSchema() {
|
|
|
45
43
|
Query: {
|
|
46
44
|
guardedBoard: () => 'board',
|
|
47
45
|
ownershipOnly: () => 'ownership',
|
|
48
|
-
undeclared: () => 'undeclared'
|
|
49
|
-
moved: () => 'moved'
|
|
46
|
+
undeclared: () => 'undeclared'
|
|
50
47
|
}
|
|
51
48
|
}
|
|
52
49
|
})
|
|
@@ -54,13 +51,6 @@ function buildSchema() {
|
|
|
54
51
|
return privilegeDirectiveResolver(schema)
|
|
55
52
|
}
|
|
56
53
|
|
|
57
|
-
/** 이 category 들만 가진 사람. `hasPrivilege(privilege, category, ...)` 순서를 그대로 받는다. */
|
|
58
|
-
function granted(...categories: string[]) {
|
|
59
|
-
User.hasPrivilege = jest.fn().mockImplementation(async (_privilege: string, category: string) =>
|
|
60
|
-
categories.includes(category)
|
|
61
|
-
)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
54
|
function run(schema: any, field: string, state: any = {}) {
|
|
65
55
|
return graphql({
|
|
66
56
|
schema,
|
|
@@ -173,98 +163,97 @@ describe('안전하지 않은 IP 가 이음매를 지나 게이트까지 간다'
|
|
|
173
163
|
})
|
|
174
164
|
})
|
|
175
165
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
* 역할 행에 베껴 넣으면 조용히 권한이 번지고 되돌릴 근거가 없어진다. 그래서 **행은 그대로 두고
|
|
181
|
-
* 문에서 한동안 받아 준다.**
|
|
182
|
-
*/
|
|
183
|
-
describe('옮기는 중인 권한', () => {
|
|
184
|
-
it('새 이름을 가졌으면 열린다', async () => {
|
|
185
|
-
granted('ai-assistant')
|
|
186
|
-
|
|
187
|
-
const result = await run(buildSchema(), 'moved')
|
|
166
|
+
describe('스키마를 지으면서 남기는 것', () => {
|
|
167
|
+
it('선언한 (category, privilege) 를 전역 레지스트리에 등록한다', () => {
|
|
168
|
+
/* 부팅 때 없는 권한 행을 만드는 근거다 — 마이그레이션도 수동 생성도 필요 없다. */
|
|
169
|
+
buildSchema()
|
|
188
170
|
|
|
189
|
-
expect(
|
|
190
|
-
expect(result.data).toEqual({ moved: 'moved' })
|
|
171
|
+
expect((process as any).PRIVILEGES['board mutation']).toEqual(['board', 'mutation'])
|
|
191
172
|
})
|
|
192
173
|
|
|
193
|
-
it('
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const result = await run(buildSchema(), 'moved')
|
|
174
|
+
it('필드 설명에 필요한 권한을 적어 둔다', async () => {
|
|
175
|
+
const schema = buildSchema()
|
|
176
|
+
const description = (schema.getQueryType() as any).getFields().guardedBoard.description
|
|
197
177
|
|
|
198
|
-
expect(
|
|
199
|
-
expect(result.data).toEqual({ moved: 'moved' })
|
|
178
|
+
expect(description).toContain('board:mutation')
|
|
200
179
|
})
|
|
180
|
+
})
|
|
201
181
|
|
|
202
|
-
|
|
203
|
-
|
|
182
|
+
/*
|
|
183
|
+
* ── A declaration that names nothing stops the boot ───────────────────────────
|
|
184
|
+
* Schema assembly is the only moment this can be caught. After it, an empty declaration is
|
|
185
|
+
* indistinguishable from a real one at the call site: the directive is present, the wrapper
|
|
186
|
+
* runs, and the answer comes back from `checkPermission` with nothing to look up.
|
|
187
|
+
*
|
|
188
|
+
* `@privilege()` was the worst of these — it answered by the caller's address, refusing from a
|
|
189
|
+
* safe one and passing everyone from an unsafe one, so whoever tried it learned the opposite
|
|
190
|
+
* lesson depending on where they sat (architect, 2026-09-15).
|
|
191
|
+
*
|
|
192
|
+
* [확인함] 2026-09-15 · six repositories · zero declarations of either shape, so refusing them
|
|
193
|
+
* breaks nothing that exists. The throw is for the one someone writes next.
|
|
194
|
+
*/
|
|
195
|
+
describe('a declaration that names nothing', () => {
|
|
196
|
+
function buildField(declaration: string) {
|
|
197
|
+
const schema = makeExecutableSchema({
|
|
198
|
+
typeDefs: [privilegeDirectiveTypeDefs, `type Query { field: String ${declaration} }`],
|
|
199
|
+
resolvers: { Query: { field: () => 'value' } }
|
|
200
|
+
})
|
|
204
201
|
|
|
205
|
-
|
|
202
|
+
return () => privilegeDirectiveResolver(schema)
|
|
203
|
+
}
|
|
206
204
|
|
|
207
|
-
|
|
208
|
-
expect(
|
|
209
|
-
/* 옛 이름을 말하면 관리자가 곧 사라질 권한을 부여한다. */
|
|
210
|
-
expect((result.errors![0].extensions as any).requires).toEqual({
|
|
211
|
-
category: 'ai-assistant',
|
|
212
|
-
privilege: 'mutation'
|
|
213
|
-
})
|
|
205
|
+
it('refuses an empty declaration, and names the field', () => {
|
|
206
|
+
expect(buildField('@privilege')).toThrow(/Query\.field declares nothing/)
|
|
214
207
|
})
|
|
215
208
|
|
|
216
|
-
it('
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
*/
|
|
221
|
-
granted('board-ai')
|
|
222
|
-
|
|
223
|
-
const result = await run(buildSchema(), 'moved', {
|
|
224
|
-
unsafeIP: true,
|
|
225
|
-
prohibitedPrivileges: [{ category: 'ai-assistant', privilege: 'mutation' }]
|
|
226
|
-
})
|
|
209
|
+
it('tells the writer what the two real shapes are', () => {
|
|
210
|
+
expect(buildField('@privilege')).toThrow(/domainOwnerGranted/)
|
|
211
|
+
expect(buildField('@privilege')).toThrow(/carries no directive at all/)
|
|
212
|
+
})
|
|
227
213
|
|
|
228
|
-
|
|
214
|
+
it('refuses a category with no privilege', () => {
|
|
215
|
+
expect(buildField('@privilege(category: "board")')).toThrow(/a category with no privilege/)
|
|
229
216
|
})
|
|
230
217
|
|
|
231
|
-
it('
|
|
232
|
-
|
|
218
|
+
it('refuses a privilege with no category', () => {
|
|
219
|
+
expect(buildField('@privilege(privilege: "mutation")')).toThrow(/a privilege with no category/)
|
|
220
|
+
})
|
|
233
221
|
|
|
234
|
-
|
|
222
|
+
/*
|
|
223
|
+
* ── An axis this installation does not declare ──────────────────────────────
|
|
224
|
+
* A typo used to be the quietest defect in the file. `privilege: "mutaiton"` registered a
|
|
225
|
+
* pair in `process['PRIVILEGES']` that no role would ever hold — the field was shut to
|
|
226
|
+
* everyone, permanently, and nothing said so. The framework does not own the word list
|
|
227
|
+
* (dssp declares eight of its own), so the check asks the installation.
|
|
228
|
+
*/
|
|
229
|
+
it('refuses an axis outside the declared vocabulary', () => {
|
|
230
|
+
expect(buildField('@privilege(category: "board", privilege: "mutaiton")')).toThrow(
|
|
231
|
+
/uses axis "mutaiton", which this installation does not declare/
|
|
232
|
+
)
|
|
233
|
+
})
|
|
235
234
|
|
|
236
|
-
|
|
235
|
+
it('tells the reader what is declared and where to declare more', () => {
|
|
236
|
+
const build = buildField('@privilege(category: "board", privilege: "approve")')
|
|
237
237
|
|
|
238
|
-
expect(
|
|
239
|
-
|
|
240
|
-
|
|
238
|
+
expect(build).toThrow(/Declared axes: mutation, query/)
|
|
239
|
+
/* The hook a module would reach for runs after the schema is built, so it must say so. */
|
|
240
|
+
expect(build).toThrow(/top level of the module's server\/index\.ts/)
|
|
241
|
+
expect(build).toThrow(/too late/)
|
|
241
242
|
})
|
|
242
|
-
})
|
|
243
243
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
/* 부팅 때 없는 권한 행을 만드는 근거다 — 마이그레이션도 수동 생성도 필요 없다. */
|
|
247
|
-
buildSchema()
|
|
244
|
+
it('accepts an axis once the installation declares it', () => {
|
|
245
|
+
const { declareAxes } = loadCompiled('service/privilege/privilege-axis')
|
|
248
246
|
|
|
249
|
-
expect((
|
|
250
|
-
})
|
|
247
|
+
expect(buildField('@privilege(category: "kpi", privilege: "read")')).toThrow()
|
|
251
248
|
|
|
252
|
-
|
|
253
|
-
/*
|
|
254
|
-
* `isDeprecatedPrivilege` 는 「레지스트리에 없으면 유령」으로 판정한다
|
|
255
|
-
* (`privilege-deprecation.ts`). 옛 쌍을 등록하면 관리자 목록에서 옛 행이 정상으로 보여
|
|
256
|
-
* **별칭을 지울 시점을 아무도 못 본다.** 이 시험이 그 신호를 지킨다.
|
|
257
|
-
*/
|
|
258
|
-
buildSchema()
|
|
249
|
+
declareAxes(['query', 'mutation', 'read'])
|
|
259
250
|
|
|
260
|
-
expect((
|
|
261
|
-
expect((process as any).PRIVILEGES['board-ai mutation']).toBeUndefined()
|
|
251
|
+
expect(buildField('@privilege(category: "kpi", privilege: "read")')).not.toThrow()
|
|
262
252
|
})
|
|
263
253
|
|
|
264
|
-
it('
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
expect(description).toContain('board:mutation')
|
|
254
|
+
it('still builds the two shapes that do name something', () => {
|
|
255
|
+
expect(buildField('@privilege(category: "board", privilege: "mutation")')).not.toThrow()
|
|
256
|
+
expect(buildField('@privilege(domainOwnerGranted: true)')).not.toThrow()
|
|
257
|
+
expect(buildField('@privilege(superUserGranted: true)')).not.toThrow()
|
|
269
258
|
})
|
|
270
259
|
})
|