@things-factory/auth-base 10.1.7 → 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 +31 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/index.js +8 -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 +2 -1
- package/dist-server/service/index.js +1 -0
- 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.js +48 -0
- package/dist-server/service/privilege/privilege-directive.js.map +1 -1
- package/dist-server/service/role-template/index.d.ts +1 -1
- package/dist-server/service/role-template/role-template.js +24 -6
- 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.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 +79 -0
- package/tests/role-template.test.ts +36 -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
|
+
})
|
|
@@ -178,3 +178,82 @@ describe('스키마를 지으면서 남기는 것', () => {
|
|
|
178
178
|
expect(description).toContain('board:mutation')
|
|
179
179
|
})
|
|
180
180
|
})
|
|
181
|
+
|
|
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
|
+
})
|
|
201
|
+
|
|
202
|
+
return () => privilegeDirectiveResolver(schema)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
it('refuses an empty declaration, and names the field', () => {
|
|
206
|
+
expect(buildField('@privilege')).toThrow(/Query\.field declares nothing/)
|
|
207
|
+
})
|
|
208
|
+
|
|
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
|
+
})
|
|
213
|
+
|
|
214
|
+
it('refuses a category with no privilege', () => {
|
|
215
|
+
expect(buildField('@privilege(category: "board")')).toThrow(/a category with no privilege/)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('refuses a privilege with no category', () => {
|
|
219
|
+
expect(buildField('@privilege(privilege: "mutation")')).toThrow(/a privilege with no category/)
|
|
220
|
+
})
|
|
221
|
+
|
|
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
|
+
})
|
|
234
|
+
|
|
235
|
+
it('tells the reader what is declared and where to declare more', () => {
|
|
236
|
+
const build = buildField('@privilege(category: "board", privilege: "approve")')
|
|
237
|
+
|
|
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/)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('accepts an axis once the installation declares it', () => {
|
|
245
|
+
const { declareAxes } = loadCompiled('service/privilege/privilege-axis')
|
|
246
|
+
|
|
247
|
+
expect(buildField('@privilege(category: "kpi", privilege: "read")')).toThrow()
|
|
248
|
+
|
|
249
|
+
declareAxes(['query', 'mutation', 'read'])
|
|
250
|
+
|
|
251
|
+
expect(buildField('@privilege(category: "kpi", privilege: "read")')).not.toThrow()
|
|
252
|
+
})
|
|
253
|
+
|
|
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()
|
|
258
|
+
})
|
|
259
|
+
})
|
|
@@ -17,7 +17,11 @@ const {
|
|
|
17
17
|
unknownTemplateGrants
|
|
18
18
|
} = loadCompiled('service/role-template/role-template')
|
|
19
19
|
|
|
20
|
+
const { declareAxes } = loadCompiled('service/privilege/privilege-axis')
|
|
21
|
+
|
|
20
22
|
beforeEach(() => {
|
|
23
|
+
/* Nobody declared, so the defaults apply — query/mutation with query read-only. */
|
|
24
|
+
delete (process as any).PRIVILEGE_AXES
|
|
21
25
|
;(process as any)['ROLE_TEMPLATES'] = {}
|
|
22
26
|
;(process as any).PRIVILEGES = {
|
|
23
27
|
'board query': ['board', 'query'],
|
|
@@ -118,6 +122,38 @@ describe('the two templates the framework computes', () => {
|
|
|
118
122
|
expect(viewer.grants.find(grant => grant.category === 'board')!.axes).toEqual(['query'])
|
|
119
123
|
})
|
|
120
124
|
|
|
125
|
+
it('builds the viewer from the declared read-only axes, not from the word "query"', () => {
|
|
126
|
+
/*
|
|
127
|
+
* A house that spells its read axis `read` gets a viewer over `read`. Matching the literal
|
|
128
|
+
* `query` was right here and wrong there — dssp's `kpi` has eight axes and none of them is
|
|
129
|
+
* `query`, so its main module would have gone missing from VIEWER without a word anywhere.
|
|
130
|
+
*/
|
|
131
|
+
declareAxes(['read', 'input'], { readOnly: ['read'] })
|
|
132
|
+
;(process as any).PRIVILEGES = {
|
|
133
|
+
'kpi read': ['kpi', 'read'],
|
|
134
|
+
'kpi input': ['kpi', 'input']
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const viewer = computedRoleTemplates().find(one => one.id === 'framework.viewer')!
|
|
138
|
+
|
|
139
|
+
expect(viewer.grants).toEqual([{ category: 'kpi', axes: ['read'] }])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('does not offer a viewer at all when nobody said which axes are read-only', () => {
|
|
143
|
+
/*
|
|
144
|
+
* ⚠ The point of this one. Offering an empty VIEWER would let an administrator pick it,
|
|
145
|
+
* get a role, and open nothing — and the role list would count that zero correctly and
|
|
146
|
+
* present it as fact. "We do not know" is not "there are none", so the template is
|
|
147
|
+
* withheld and `reportDeclaredAxes` says so at boot.
|
|
148
|
+
*/
|
|
149
|
+
declareAxes(['read', 'input'])
|
|
150
|
+
|
|
151
|
+
const ids = computedRoleTemplates().map(one => one.id)
|
|
152
|
+
|
|
153
|
+
expect(ids).toEqual(['framework.administrator'])
|
|
154
|
+
expect(ids).not.toContain('framework.viewer')
|
|
155
|
+
})
|
|
156
|
+
|
|
121
157
|
it('offers the computed two alongside whatever applications declared', () => {
|
|
122
158
|
registerRoleTemplate({ id: 'plant.z', name: 'Z', grants: [] })
|
|
123
159
|
|