@things-factory/auth-base 10.1.4 → 10.1.6
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/auth.d.ts +9 -1
- package/dist-client/auth.js +12 -3
- package/dist-client/auth.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/controllers/profile.d.ts +1 -0
- package/dist-server/controllers/signin.js +33 -12
- package/dist-server/controllers/signin.js.map +1 -1
- package/dist-server/controllers/unlock-user.js +21 -2
- package/dist-server/controllers/unlock-user.js.map +1 -1
- package/dist-server/router/auth-public-process-router.js +32 -5
- package/dist-server/router/auth-public-process-router.js.map +1 -1
- package/dist-server/service/privilege/privilege-directive.d.ts +46 -0
- package/dist-server/service/privilege/privilege-directive.js +87 -6
- package/dist-server/service/privilege/privilege-directive.js.map +1 -1
- package/dist-server/service/role/index.d.ts +2 -1
- package/dist-server/service/role/index.js +2 -1
- package/dist-server/service/role/index.js.map +1 -1
- package/dist-server/service/role/role-mutation.d.ts +17 -1
- package/dist-server/service/role/role-mutation.js +126 -7
- package/dist-server/service/role/role-mutation.js.map +1 -1
- package/dist-server/service/role/role-privilege-query.d.ts +14 -0
- package/dist-server/service/role/role-privilege-query.js +37 -0
- package/dist-server/service/role/role-privilege-query.js.map +1 -0
- package/dist-server/service/role/role-query.d.ts +18 -0
- package/dist-server/service/role/role-query.js +38 -22
- package/dist-server/service/role/role-query.js.map +1 -1
- package/dist-server/service/user/user.d.ts +1 -0
- package/dist-server/service/user/user.js +55 -13
- package/dist-server/service/user/user.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/utils/account-lock-rule.d.ts +79 -0
- package/dist-server/utils/account-lock-rule.js +114 -0
- package/dist-server/utils/account-lock-rule.js.map +1 -0
- package/dist-server/utils/lock-duration.d.ts +3 -0
- package/dist-server/utils/lock-duration.js +36 -0
- package/dist-server/utils/lock-duration.js.map +1 -0
- package/package.json +2 -2
- package/tests/account-lock-db.test.ts +216 -0
- package/tests/account-lock-rule.test.ts +141 -0
- package/tests/compiled.ts +41 -0
- package/tests/has-privilege-db.test.ts +198 -0
- package/tests/lock-recovery.test.ts +204 -0
- package/tests/password-hashing.test.ts +131 -0
- package/tests/password-persistence.test.ts +147 -0
- package/tests/permission-gate.test.ts +226 -0
- package/tests/privilege-directive.test.ts +270 -0
- package/tests/role-mutation-db.test.ts +263 -0
- package/tests/role-privileges-db.test.ts +128 -0
- package/tests/signin-policy.test.ts +200 -0
- package/tests/token-issuance.test.ts +240 -0
- package/translations/en.json +161 -1
- package/translations/ko.json +161 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 비밀번호 변경이 **DB 에 무엇을 남기나** — 실 엔티티로 저장하고 읽어서 확인한다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 이것이 첫 DB 시험인가 ────────────────────────────────────────────────
|
|
5
|
+
* 「비밀번호를 바꾸면 다른 기기의 세션이 끊겨야 한다」를 세우려면 그 판단의 근거가 필요하고,
|
|
6
|
+
* 후보는 `User.passwordUpdatedAt` 이다. 그런데 그 칸을 쓰는 코드가 이 모양이다:
|
|
7
|
+
*
|
|
8
|
+
* await repository.save({ ...user, passwordUpdatedAt: new Date() }) // change-pwd.ts:79
|
|
9
|
+
*
|
|
10
|
+
* 이 저장소는 **읽어 온 엔티티를 펼쳐 `save` 하면 칸이 조용히 안 써지는** 함정을 이미 한 번
|
|
11
|
+
* 겪었다(도메인 시각대 — 로그는 「두었습니다」, `select` 는 `None`). 그러니 그 칸을 근거로
|
|
12
|
+
* 삼기 전에 **읽어서 확인해야 한다.** 코드도 로그도 근거가 아니다.
|
|
13
|
+
*
|
|
14
|
+
* 여기서는 실제 컨트롤러(`changePwd`)를 그대로 태운다 — 칸을 직접 써 보는 것이 아니라,
|
|
15
|
+
* 화면이 부르는 그 경로가 무엇을 남기는지를 본다.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
19
|
+
import { loadCompiled } from './compiled'
|
|
20
|
+
|
|
21
|
+
const { User } = loadCompiled('service/user/user')
|
|
22
|
+
const { changePwd } = loadCompiled('controllers/change-pwd')
|
|
23
|
+
|
|
24
|
+
const CURRENT = 'Passw0rd!'
|
|
25
|
+
const NEXT = 'N3xtPass!'
|
|
26
|
+
|
|
27
|
+
/** `changePwd` 가 컨텍스트에서 실제로 읽는 것만 담는다 — 도메인과 언어뿐이다. */
|
|
28
|
+
function contextFor(domain: any) {
|
|
29
|
+
return { state: { domain }, lng: 'en-US' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function seedUser(domain: any) {
|
|
33
|
+
const salt = User.generateSalt()
|
|
34
|
+
|
|
35
|
+
return await getRepository(User).save({
|
|
36
|
+
username: 'kim',
|
|
37
|
+
email: 'kim@example.com',
|
|
38
|
+
name: 'Kim',
|
|
39
|
+
salt,
|
|
40
|
+
password: User.encode(CURRENT, salt),
|
|
41
|
+
status: 'activated',
|
|
42
|
+
domains: [domain]
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let domain: any
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
await openAuthDatabase()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
await resetAuthDatabase()
|
|
54
|
+
domain = await getRepository(Domain).save({ name: 'plant-a', subdomain: 'plant-a' })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await closeAuthDatabase()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('changePwd 가 DB 에 남기는 것', () => {
|
|
62
|
+
it('새 비밀번호로 로그인할 수 있게 된다', async () => {
|
|
63
|
+
const user = await seedUser(domain)
|
|
64
|
+
|
|
65
|
+
await changePwd({ id: user.id }, CURRENT, NEXT, NEXT, contextFor(domain))
|
|
66
|
+
|
|
67
|
+
const stored = await getRepository(User).findOneBy({ id: user.id })
|
|
68
|
+
expect(User.verify(stored.password, NEXT, stored.salt)).toBe(true)
|
|
69
|
+
expect(User.verify(stored.password, CURRENT, stored.salt)).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('`passwordUpdatedAt` 이 실제로 써진다 — 펼쳐 넣은 칸이 살아남는가', async () => {
|
|
73
|
+
/*
|
|
74
|
+
* 이 시험이 이 파일의 이유다. 세션 무효화가 이 칸을 근거로 삼을 수 있는지가 여기서 갈린다.
|
|
75
|
+
* 반환값이나 로그가 아니라 **다시 읽은 행**을 본다.
|
|
76
|
+
*/
|
|
77
|
+
const user = await seedUser(domain)
|
|
78
|
+
expect(user.passwordUpdatedAt).toBeFalsy() /* 새로 만든 행은 `null` 이다 */
|
|
79
|
+
|
|
80
|
+
const before = Date.now()
|
|
81
|
+
await changePwd({ id: user.id }, CURRENT, NEXT, NEXT, contextFor(domain))
|
|
82
|
+
const after = Date.now()
|
|
83
|
+
|
|
84
|
+
const stored = await getRepository(User).findOneBy({ id: user.id })
|
|
85
|
+
|
|
86
|
+
expect(stored.passwordUpdatedAt).toBeTruthy()
|
|
87
|
+
|
|
88
|
+
const written = new Date(stored.passwordUpdatedAt).getTime()
|
|
89
|
+
expect(written).toBeGreaterThanOrEqual(before - 1000)
|
|
90
|
+
expect(written).toBeLessThanOrEqual(after + 1000)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('토큰을 돌려준다 — 그리고 그 토큰의 `iat` 이 칸보다 앞선다', async () => {
|
|
94
|
+
/*
|
|
95
|
+
* `token-issuance.test.ts` 가 초 절삭을 단위로 보였고, 여기서는 **실제 경로**에서 같은 일이
|
|
96
|
+
* 일어나는 것을 본다: `changePwd` 는 칸을 쓴 뒤(:79) 토큰을 발급한다(:99). 그래서
|
|
97
|
+
* `iat * 1000 < passwordUpdatedAt` 로 옛 세션을 끊으면 **방금 받은 이 토큰도 끊긴다.**
|
|
98
|
+
*/
|
|
99
|
+
const jwt = require('jsonwebtoken')
|
|
100
|
+
const user = await seedUser(domain)
|
|
101
|
+
|
|
102
|
+
const token = await changePwd({ id: user.id }, CURRENT, NEXT, NEXT, contextFor(domain))
|
|
103
|
+
const stored = await getRepository(User).findOneBy({ id: user.id })
|
|
104
|
+
|
|
105
|
+
const { iat } = jwt.decode(token) as any
|
|
106
|
+
expect(iat * 1000).toBeLessThan(new Date(stored.passwordUpdatedAt).getTime())
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('현재 비밀번호가 틀리면 아무것도 바꾸지 않는다', async () => {
|
|
110
|
+
const user = await seedUser(domain)
|
|
111
|
+
|
|
112
|
+
await expect(changePwd({ id: user.id }, 'WrongPass!', NEXT, NEXT, contextFor(domain))).rejects.toThrow()
|
|
113
|
+
|
|
114
|
+
const stored = await getRepository(User).findOneBy({ id: user.id })
|
|
115
|
+
expect(User.verify(stored.password, CURRENT, stored.salt)).toBe(true)
|
|
116
|
+
expect(stored.passwordUpdatedAt).toBeFalsy()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('확인 비밀번호가 다르면 거절한다', async () => {
|
|
120
|
+
const user = await seedUser(domain)
|
|
121
|
+
|
|
122
|
+
await expect(changePwd({ id: user.id }, CURRENT, NEXT, 'Different1!', contextFor(domain))).rejects.toThrow()
|
|
123
|
+
|
|
124
|
+
const stored = await getRepository(User).findOneBy({ id: user.id })
|
|
125
|
+
expect(User.verify(stored.password, CURRENT, stored.salt)).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('새 비밀번호가 규칙에 안 맞으면 거절한다', async () => {
|
|
129
|
+
const user = await seedUser(domain)
|
|
130
|
+
|
|
131
|
+
await expect(changePwd({ id: user.id }, CURRENT, 'short', 'short', contextFor(domain))).rejects.toThrow()
|
|
132
|
+
|
|
133
|
+
const stored = await getRepository(User).findOneBy({ id: user.id })
|
|
134
|
+
expect(User.verify(stored.password, CURRENT, stored.salt)).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('salt 는 그대로 두고 해시만 바꾼다', async () => {
|
|
138
|
+
/* salt 를 함께 갈면 비밀번호 이력 대조(`User.verify(h.password, …, h.salt)`)가 옛 salt 를
|
|
139
|
+
들고 있어야 성립한다. 지금 어느 쪽인지를 고정해 둔다. */
|
|
140
|
+
const user = await seedUser(domain)
|
|
141
|
+
|
|
142
|
+
await changePwd({ id: user.id }, CURRENT, NEXT, NEXT, contextFor(domain))
|
|
143
|
+
|
|
144
|
+
const stored = await getRepository(User).findOneBy({ id: user.id })
|
|
145
|
+
expect(stored.salt).toBe(user.salt)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 인가 게이트(`checkPermission`) 판정 시험.
|
|
3
|
+
*
|
|
4
|
+
* 이 함수가 플랫폼의 **모든** 권한 판정의 마지막 자리다 — `@privilege` 지시자와
|
|
5
|
+
* `auth-private-process-router`·`integration-base` 의 시나리오 실행이 모두 여기로 모인다.
|
|
6
|
+
* 그런데 저장소의 시험 316개 중 이 함수를 부르는 것이 없었다. 리빌딩 전에 **지금 무엇을 하는지**
|
|
7
|
+
* 를 고정한다.
|
|
8
|
+
*
|
|
9
|
+
* ## 이 시험이 고정하는 사실 중 둘은 「옳다」가 아니라 「지금 그렇다」다
|
|
10
|
+
*
|
|
11
|
+
* ① **선언이 없으면 통과한다** — 게이트가 opt-in 이라는 뜻이고, 지시자를 잊은 리졸버는
|
|
12
|
+
* 조용히 열린다(무선언 mutation 509개). 「닫힘을 기본으로」는 결정 대기 사항이다.
|
|
13
|
+
* ② **unsafeIP + 이름 없는 선언**의 규칙이 뒤집혀 있다 — 안전하지 않은 IP 에서 *더* 열린다.
|
|
14
|
+
* 아래 해당 케이스에 그 자리를 표시해 두었다. 정책이 정해지면 시험을 먼저 바꾼다.
|
|
15
|
+
*
|
|
16
|
+
* 두 사실은 정책 결정(`docs/design/auth-rebuild.md` §1 · §7)이 나오기 전에는 바꾸지 않는다.
|
|
17
|
+
* 시험이 지금 거동을 붙잡고 있으므로, 바꿀 때 무엇이 달라지는지 보인다.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { loadCompiled } from './compiled'
|
|
21
|
+
|
|
22
|
+
const { checkPermission } = loadCompiled('utils/check-permission')
|
|
23
|
+
const { User } = loadCompiled('service/user/user')
|
|
24
|
+
|
|
25
|
+
const DOMAIN = { id: 'domain-1', subdomain: 'plant-a' } as any
|
|
26
|
+
const USER = { id: 'user-1', domains: [DOMAIN] } as any
|
|
27
|
+
|
|
28
|
+
/** 역할 기반 판정(`User.hasPrivilege`)은 DB 를 본다 — 판정 규칙만 보려고 그 답을 고정한다. */
|
|
29
|
+
function withRolePrivilege(answer: boolean) {
|
|
30
|
+
User.hasPrivilege = jest.fn().mockResolvedValue(answer)
|
|
31
|
+
return User.hasPrivilege
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 소유권 판정은 `process` 에 실린다(`domain-authenticate-middleware` 가 설치한다).
|
|
36
|
+
* 게이트가 **자기 안에 없는 것에 의존**한다는 사실 자체가 아래 마지막 케이스의 주제다.
|
|
37
|
+
*/
|
|
38
|
+
function withOwnership({ owner = false, superUser = false } = {}) {
|
|
39
|
+
;(process as any).domainOwnerGranted = jest.fn().mockResolvedValue(owner)
|
|
40
|
+
;(process as any).superUserGranted = jest.fn().mockResolvedValue(superUser)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
withOwnership()
|
|
45
|
+
withRolePrivilege(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
delete (process as any).domainOwnerGranted
|
|
50
|
+
delete (process as any).superUserGranted
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('선언이 없는 자리', () => {
|
|
54
|
+
it('선언이 없으면 통과한다 — 게이트는 opt-in 이다', async () => {
|
|
55
|
+
for (const nothing of [undefined, null]) {
|
|
56
|
+
expect(await checkPermission(nothing, USER, DOMAIN)).toBe(true)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('사용자도 도메인도 없어도 통과한다 — 선언이 없으면 아무것도 보지 않는다', async () => {
|
|
61
|
+
expect(await checkPermission(undefined, undefined, undefined)).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('이름이 있는 권한 (category + privilege)', () => {
|
|
66
|
+
const NAMED = { category: 'board', privilege: 'mutation' }
|
|
67
|
+
|
|
68
|
+
it('역할이 그 권한을 가지면 통과한다', async () => {
|
|
69
|
+
const hasPrivilege = withRolePrivilege(true)
|
|
70
|
+
|
|
71
|
+
expect(await checkPermission(NAMED, USER, DOMAIN)).toBe(true)
|
|
72
|
+
expect(hasPrivilege).toHaveBeenCalledWith('mutation', 'board', DOMAIN, USER)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('역할이 없으면 거절한다', async () => {
|
|
76
|
+
expect(await checkPermission(NAMED, USER, DOMAIN)).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('소유권이 선언되지 않았으면 소유자여도 역할로만 판정한다', async () => {
|
|
80
|
+
withOwnership({ owner: true, superUser: true })
|
|
81
|
+
|
|
82
|
+
/* 선언에 owner/super 가 없다 — 우회는 **선언한 자리에서만** 일어난다. */
|
|
83
|
+
expect(await checkPermission(NAMED, USER, DOMAIN)).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('소유권 우회 (domainOwnerGranted / superUserGranted)', () => {
|
|
88
|
+
const OWNER_GRANTED = { category: 'board', privilege: 'mutation', owner: true }
|
|
89
|
+
const SUPER_GRANTED = { category: 'board', privilege: 'mutation', super: true }
|
|
90
|
+
|
|
91
|
+
it('도메인 소유자는 역할 없이 통과한다', async () => {
|
|
92
|
+
withOwnership({ owner: true })
|
|
93
|
+
const hasPrivilege = withRolePrivilege(false)
|
|
94
|
+
|
|
95
|
+
expect(await checkPermission(OWNER_GRANTED, USER, DOMAIN)).toBe(true)
|
|
96
|
+
/* 역할은 아예 보지 않는다 — 그래서 admin 계정으로는 거절 경로를 볼 수 없다. */
|
|
97
|
+
expect(hasPrivilege).not.toHaveBeenCalled()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('수퍼유저는 역할 없이 통과한다', async () => {
|
|
101
|
+
withOwnership({ superUser: true })
|
|
102
|
+
|
|
103
|
+
expect(await checkPermission(SUPER_GRANTED, USER, DOMAIN)).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('선언이 owner 만인데 수퍼유저이면 우회하지 않는다', async () => {
|
|
107
|
+
withOwnership({ superUser: true })
|
|
108
|
+
|
|
109
|
+
expect(await checkPermission(OWNER_GRANTED, USER, DOMAIN)).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('이름이 없는 선언 — 소유권만으로 판정한다', () => {
|
|
114
|
+
it('소유권만 선언한 자리는 소유자에게 열린다', async () => {
|
|
115
|
+
withOwnership({ owner: true })
|
|
116
|
+
|
|
117
|
+
expect(await checkPermission({ owner: true }, USER, DOMAIN)).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('소유자가 아니면 닫힌다 — 다만 돌려주는 값이 `false` 가 아니라 `undefined` 다', async () => {
|
|
121
|
+
/* 선언한 타입은 `Promise<boolean>` 인데 실제로는 `(true && false) || (undefined && …)`
|
|
122
|
+
= `undefined` 가 나온다. 지금 모든 소비처가 `if (await checkPermission(…))` 로 받으므로
|
|
123
|
+
거동은 같다 — 그러나 시그니처가 사실과 다르고, `=== false` 로 받는 소비처가 하나
|
|
124
|
+
생기는 순간 조용히 통과한다. 그래서 값 자체를 고정한다. */
|
|
125
|
+
const decision = await checkPermission({ owner: true }, USER, DOMAIN)
|
|
126
|
+
|
|
127
|
+
expect(decision).toBeFalsy()
|
|
128
|
+
expect(decision).toBeUndefined()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('아무것도 선언하지 않은 빈 선언은 닫힌다', async () => {
|
|
132
|
+
/* `{}` 는 `!privilegeObject` 가 아니므로 첫 관문을 통과하고, 여기서 falsy 로 떨어진다.
|
|
133
|
+
같은 `{}` 가 unsafeIP 에서는 **열린다** — 아래 unsafeIP 절이 그 비대칭을 붙잡는다. */
|
|
134
|
+
expect(await checkPermission({}, USER, DOMAIN)).toBeFalsy()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('금지 목록 (prohibitedPrivileges)', () => {
|
|
139
|
+
const NAMED = { category: 'board', privilege: 'mutation' }
|
|
140
|
+
const PROHIBITED = [{ category: 'board', privilege: 'mutation' }]
|
|
141
|
+
|
|
142
|
+
it('금지된 권한은 역할이 있어도 거절한다', async () => {
|
|
143
|
+
withRolePrivilege(true)
|
|
144
|
+
|
|
145
|
+
expect(await checkPermission(NAMED, USER, DOMAIN, false, PROHIBITED)).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('다른 권한의 금지는 영향이 없다', async () => {
|
|
149
|
+
withRolePrivilege(true)
|
|
150
|
+
|
|
151
|
+
expect(
|
|
152
|
+
await checkPermission(NAMED, USER, DOMAIN, false, [{ category: 'board', privilege: 'query' }])
|
|
153
|
+
).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('소유권 우회가 금지 목록보다 먼저 판정된다 — 금지 목록은 소유자를 막지 못한다', async () => {
|
|
157
|
+
withOwnership({ owner: true })
|
|
158
|
+
|
|
159
|
+
expect(
|
|
160
|
+
await checkPermission({ ...NAMED, owner: true }, USER, DOMAIN, false, PROHIBITED)
|
|
161
|
+
).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('안전하지 않은 IP (unsafeIP)', () => {
|
|
166
|
+
const NAMED = { category: 'board', privilege: 'mutation' }
|
|
167
|
+
|
|
168
|
+
it('이름이 있는 권한은 소유권을 무시하고 역할로만 판정한다', async () => {
|
|
169
|
+
withOwnership({ owner: true, superUser: true })
|
|
170
|
+
withRolePrivilege(false)
|
|
171
|
+
|
|
172
|
+
expect(await checkPermission({ ...NAMED, owner: true, super: true }, USER, DOMAIN, true)).toBe(false)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('역할이 있으면 안전하지 않은 IP 에서도 통과한다', async () => {
|
|
176
|
+
withRolePrivilege(true)
|
|
177
|
+
|
|
178
|
+
expect(await checkPermission(NAMED, USER, DOMAIN, true)).toBe(true)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('금지 목록은 안전하지 않은 IP 에서 그대로 적용된다', async () => {
|
|
182
|
+
withRolePrivilege(true)
|
|
183
|
+
|
|
184
|
+
expect(
|
|
185
|
+
await checkPermission(NAMED, USER, DOMAIN, true, [{ category: 'board', privilege: 'mutation' }])
|
|
186
|
+
).toBe(false)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
/*
|
|
190
|
+
* ── 여기서부터가 뒤집힌 자리다 ─────────────────────────────────────────────
|
|
191
|
+
* 규칙은 `return !domainOwnerGranted && !superUserGranted` (check-permission.ts:29).
|
|
192
|
+
* 주석은 「unsafeIP 상황에서는 ownership granted 는 적용되지 않는다」고 적혀 있는데,
|
|
193
|
+
* 「적용하지 않는다」의 결과가 **거절**이 아니라 **선언을 뒤집은 값**이다.
|
|
194
|
+
* 아래 두 케이스는 그 결과를 그대로 고정한다 — 정책(§7)이 정해지면 시험을 먼저 바꾼다.
|
|
195
|
+
*/
|
|
196
|
+
it('[뒤집힘] 소유권을 선언한 자리는 안전하지 않은 IP 에서 닫힌다', async () => {
|
|
197
|
+
withOwnership({ owner: true })
|
|
198
|
+
|
|
199
|
+
expect(await checkPermission({ owner: true }, USER, DOMAIN, true)).toBe(false)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('[뒤집힘] 아무것도 선언하지 않은 빈 선언은 안전하지 않은 IP 에서 오히려 열린다', async () => {
|
|
203
|
+
/* 안전한 IP 에서는 같은 `{}` 가 닫힌다. 「없음」 기본값이 안전하지 않은 쪽으로 기운다. */
|
|
204
|
+
expect(await checkPermission({}, USER, DOMAIN, true)).toBe(true)
|
|
205
|
+
expect(await checkPermission({}, USER, DOMAIN, false)).toBeFalsy()
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('게이트가 자기 밖에 의존하는 것', () => {
|
|
210
|
+
it('소유권 판정자가 설치되지 않은 경로에서 부르면 던진다', async () => {
|
|
211
|
+
delete (process as any).domainOwnerGranted
|
|
212
|
+
|
|
213
|
+
/* `domain-authenticate-middleware` 가 `process` 에 심는다. 미들웨어를 타지 않는
|
|
214
|
+
호출 경로(작업 스케줄러·구독 밖 내부 호출)에서 소유권을 선언한 자리를 판정하면
|
|
215
|
+
판정이 아니라 예외가 된다. */
|
|
216
|
+
await expect(checkPermission({ owner: true }, USER, DOMAIN)).rejects.toThrow()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('소유권을 선언하지 않은 자리는 판정자가 없어도 동작한다', async () => {
|
|
220
|
+
delete (process as any).domainOwnerGranted
|
|
221
|
+
delete (process as any).superUserGranted
|
|
222
|
+
withRolePrivilege(true)
|
|
223
|
+
|
|
224
|
+
expect(await checkPermission({ category: 'board', privilege: 'mutation' }, USER, DOMAIN)).toBe(true)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@privilege` 지시자 — **이음매** 시험.
|
|
3
|
+
*
|
|
4
|
+
* `permission-gate.test.ts` 는 판정 규칙을 본다. 그런데 규칙이 옳아도 **부르는 자리**가 틀리면
|
|
5
|
+
* 화면은 열린다. 그 자리가 이 지시자다: 스키마의 모든 필드를 훑어 게이트를 감싸고, 거절을
|
|
6
|
+
* `extensions` 와 함께 던진다. 지시자 안에 인라인으로 있던 문장 만들기는 이미 떼어냈는데
|
|
7
|
+
* (`privilege-rejection.ts`), **감싸는 행위 자체**에는 시험이 없었다.
|
|
8
|
+
*
|
|
9
|
+
* 그래서 여기서는 실제로 스키마를 짓고 쿼리를 태운다. type-graphql 리졸버는 jest 에서 들 수
|
|
10
|
+
* 없으므로(tests/compiled.ts) SDL 로 같은 모양을 만든다 — 지시자가 보는 것은 필드 설정과
|
|
11
|
+
* `context.state` 뿐이므로 판정 경로는 동일하다.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { graphql } from 'graphql'
|
|
15
|
+
import { makeExecutableSchema } from '@graphql-tools/schema'
|
|
16
|
+
import { logger } from '@things-factory/env'
|
|
17
|
+
|
|
18
|
+
import { loadCompiled } from './compiled'
|
|
19
|
+
|
|
20
|
+
const { privilegeDirectiveTypeDefs, privilegeDirectiveResolver } = loadCompiled(
|
|
21
|
+
'service/privilege/privilege-directive'
|
|
22
|
+
)
|
|
23
|
+
const { User } = loadCompiled('service/user/user')
|
|
24
|
+
|
|
25
|
+
const DOMAIN = { id: 'domain-1', subdomain: 'plant-a' }
|
|
26
|
+
const USER = { id: 'user-1', domains: [DOMAIN] }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 지시자가 붙은 필드와 붙지 않은 필드를 나란히 둔다 — 이 대비가 「기본값이 열려 있다」를
|
|
30
|
+
* 눈으로 보여 준다.
|
|
31
|
+
*/
|
|
32
|
+
const typeDefs = `
|
|
33
|
+
type Query {
|
|
34
|
+
guardedBoard: String @privilege(category: "board", privilege: "mutation")
|
|
35
|
+
ownershipOnly: String @privilege(domainOwnerGranted: true)
|
|
36
|
+
undeclared: String
|
|
37
|
+
moved: String @privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")
|
|
38
|
+
}
|
|
39
|
+
`
|
|
40
|
+
|
|
41
|
+
function buildSchema() {
|
|
42
|
+
const schema = makeExecutableSchema({
|
|
43
|
+
typeDefs: [privilegeDirectiveTypeDefs, typeDefs],
|
|
44
|
+
resolvers: {
|
|
45
|
+
Query: {
|
|
46
|
+
guardedBoard: () => 'board',
|
|
47
|
+
ownershipOnly: () => 'ownership',
|
|
48
|
+
undeclared: () => 'undeclared',
|
|
49
|
+
moved: () => 'moved'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return privilegeDirectiveResolver(schema)
|
|
55
|
+
}
|
|
56
|
+
|
|
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
|
+
function run(schema: any, field: string, state: any = {}) {
|
|
65
|
+
return graphql({
|
|
66
|
+
schema,
|
|
67
|
+
source: `{ ${field} }`,
|
|
68
|
+
contextValue: { state: { domain: DOMAIN, user: USER, ...state } }
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function withOwnership({ owner = false, superUser = false } = {}) {
|
|
73
|
+
;(process as any).domainOwnerGranted = jest.fn().mockResolvedValue(owner)
|
|
74
|
+
;(process as any).superUserGranted = jest.fn().mockResolvedValue(superUser)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
withOwnership()
|
|
79
|
+
User.hasPrivilege = jest.fn().mockResolvedValue(false)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
delete (process as any).domainOwnerGranted
|
|
84
|
+
delete (process as any).superUserGranted
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('선언하지 않은 필드', () => {
|
|
88
|
+
it('아무 권한 없이 열린다 — 지시자를 잊으면 게이트가 없다', async () => {
|
|
89
|
+
const result = await run(buildSchema(), 'undeclared')
|
|
90
|
+
|
|
91
|
+
expect(result.errors).toBeUndefined()
|
|
92
|
+
expect(result.data).toEqual({ undeclared: 'undeclared' })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('사용자가 없어도 열린다 — 게이트가 아예 감싸지지 않았다', async () => {
|
|
96
|
+
const result = await run(buildSchema(), 'undeclared', { user: undefined })
|
|
97
|
+
|
|
98
|
+
expect(result.errors).toBeUndefined()
|
|
99
|
+
expect(result.data).toEqual({ undeclared: 'undeclared' })
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('선언한 필드', () => {
|
|
104
|
+
it('역할이 있으면 값을 준다', async () => {
|
|
105
|
+
User.hasPrivilege = jest.fn().mockResolvedValue(true)
|
|
106
|
+
|
|
107
|
+
const result = await run(buildSchema(), 'guardedBoard')
|
|
108
|
+
|
|
109
|
+
expect(result.errors).toBeUndefined()
|
|
110
|
+
expect(result.data).toEqual({ guardedBoard: 'board' })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('역할이 없으면 값을 주지 않고 거절한다', async () => {
|
|
114
|
+
const result = await run(buildSchema(), 'guardedBoard')
|
|
115
|
+
|
|
116
|
+
expect(result.data).toEqual({ guardedBoard: null })
|
|
117
|
+
expect(result.errors).toHaveLength(1)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('거절에 무엇이 필요했는지를 구조로 싣는다 — 화면이 문장을 파싱하지 않는다', async () => {
|
|
121
|
+
const result = await run(buildSchema(), 'guardedBoard')
|
|
122
|
+
const { code, requires } = result.errors![0].extensions as any
|
|
123
|
+
|
|
124
|
+
expect(code).toBe('FORBIDDEN')
|
|
125
|
+
expect(requires).toEqual({ category: 'board', privilege: 'mutation' })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('요청의 언어로 거절한다', async () => {
|
|
129
|
+
const schema = buildSchema()
|
|
130
|
+
const result = await graphql({
|
|
131
|
+
schema,
|
|
132
|
+
source: '{ guardedBoard }',
|
|
133
|
+
contextValue: {
|
|
134
|
+
state: { domain: DOMAIN, user: USER },
|
|
135
|
+
t: (key: string, params: any) => `${params.x} 권한이 필요합니다.`
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
expect(result.errors![0].message).toBe('board:mutation 권한이 필요합니다.')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('소유권만 선언한 필드', () => {
|
|
144
|
+
it('소유자에게 열린다', async () => {
|
|
145
|
+
withOwnership({ owner: true })
|
|
146
|
+
|
|
147
|
+
const result = await run(buildSchema(), 'ownershipOnly')
|
|
148
|
+
|
|
149
|
+
expect(result.errors).toBeUndefined()
|
|
150
|
+
expect(result.data).toEqual({ ownershipOnly: 'ownership' })
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('소유자가 아니면 거절하고, 필요한 것을 `ownership` 으로 말한다', async () => {
|
|
154
|
+
const result = await run(buildSchema(), 'ownershipOnly')
|
|
155
|
+
|
|
156
|
+
expect(result.errors).toHaveLength(1)
|
|
157
|
+
expect((result.errors![0].extensions as any).requires).toBe('ownership')
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('안전하지 않은 IP 가 이음매를 지나 게이트까지 간다', () => {
|
|
162
|
+
it('금지 목록이 실려 판정을 바꾼다', async () => {
|
|
163
|
+
User.hasPrivilege = jest.fn().mockResolvedValue(true)
|
|
164
|
+
|
|
165
|
+
const allowed = await run(buildSchema(), 'guardedBoard', { unsafeIP: true })
|
|
166
|
+
expect(allowed.errors).toBeUndefined()
|
|
167
|
+
|
|
168
|
+
const prohibited = await run(buildSchema(), 'guardedBoard', {
|
|
169
|
+
unsafeIP: true,
|
|
170
|
+
prohibitedPrivileges: [{ category: 'board', privilege: 'mutation' }]
|
|
171
|
+
})
|
|
172
|
+
expect(prohibited.errors).toHaveLength(1)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 권한 이름을 옮기는 중.
|
|
178
|
+
*
|
|
179
|
+
* 옛 이름을 받아 주지 않으면 옮긴 그날 그 기능을 쓰던 사람들이 전부 막힌다. 그런데 옛 부여를
|
|
180
|
+
* 역할 행에 베껴 넣으면 조용히 권한이 번지고 되돌릴 근거가 없어진다. 그래서 **행은 그대로 두고
|
|
181
|
+
* 문에서 한동안 받아 준다.**
|
|
182
|
+
*/
|
|
183
|
+
describe('옮기는 중인 권한', () => {
|
|
184
|
+
it('새 이름을 가졌으면 열린다', async () => {
|
|
185
|
+
granted('ai-assistant')
|
|
186
|
+
|
|
187
|
+
const result = await run(buildSchema(), 'moved')
|
|
188
|
+
|
|
189
|
+
expect(result.errors).toBeUndefined()
|
|
190
|
+
expect(result.data).toEqual({ moved: 'moved' })
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('새 이름이 없고 옛 이름만 있어도 열린다 — 옮긴 날 아무도 막히지 않는다', async () => {
|
|
194
|
+
granted('board-ai')
|
|
195
|
+
|
|
196
|
+
const result = await run(buildSchema(), 'moved')
|
|
197
|
+
|
|
198
|
+
expect(result.errors).toBeUndefined()
|
|
199
|
+
expect(result.data).toEqual({ moved: 'moved' })
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('둘 다 없으면 거절하고, 필요한 것은 새 이름으로 말한다', async () => {
|
|
203
|
+
granted()
|
|
204
|
+
|
|
205
|
+
const result = await run(buildSchema(), 'moved')
|
|
206
|
+
|
|
207
|
+
expect(result.data).toEqual({ moved: null })
|
|
208
|
+
expect(result.errors).toHaveLength(1)
|
|
209
|
+
/* 옛 이름을 말하면 관리자가 곧 사라질 권한을 부여한다. */
|
|
210
|
+
expect((result.errors![0].extensions as any).requires).toEqual({
|
|
211
|
+
category: 'ai-assistant',
|
|
212
|
+
privilege: 'mutation'
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('금지 목록이 새 쌍을 막고 있으면 옛 이름으로도 열리지 않는다', async () => {
|
|
217
|
+
/*
|
|
218
|
+
* 금지 목록은 옛 이름을 모른다. 별칭이 금지를 되돌리면 「이 IP 에서는 이것을 못 한다」는
|
|
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
|
+
})
|
|
227
|
+
|
|
228
|
+
expect(result.errors).toHaveLength(1)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('옛 이름을 부른 것을 로그에 남긴다 — 누가 기대고 있는지가 부여의 근거다', async () => {
|
|
232
|
+
granted('board-ai')
|
|
233
|
+
|
|
234
|
+
const warn = jest.spyOn(logger, 'warn')
|
|
235
|
+
|
|
236
|
+
await run(buildSchema(), 'moved')
|
|
237
|
+
|
|
238
|
+
expect(warn).toHaveBeenCalled()
|
|
239
|
+
expect(warn.mock.calls[0][0]).toContain('board-ai:mutation')
|
|
240
|
+
warn.mockRestore()
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
describe('스키마를 지으면서 남기는 것', () => {
|
|
245
|
+
it('선언한 (category, privilege) 를 전역 레지스트리에 등록한다', () => {
|
|
246
|
+
/* 부팅 때 없는 권한 행을 만드는 근거다 — 마이그레이션도 수동 생성도 필요 없다. */
|
|
247
|
+
buildSchema()
|
|
248
|
+
|
|
249
|
+
expect((process as any).PRIVILEGES['board mutation']).toEqual(['board', 'mutation'])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('옛 이름은 등록하지 않는다 — 그래서 옛 권한 행이 유령으로 보인다', () => {
|
|
253
|
+
/*
|
|
254
|
+
* `isDeprecatedPrivilege` 는 「레지스트리에 없으면 유령」으로 판정한다
|
|
255
|
+
* (`privilege-deprecation.ts`). 옛 쌍을 등록하면 관리자 목록에서 옛 행이 정상으로 보여
|
|
256
|
+
* **별칭을 지울 시점을 아무도 못 본다.** 이 시험이 그 신호를 지킨다.
|
|
257
|
+
*/
|
|
258
|
+
buildSchema()
|
|
259
|
+
|
|
260
|
+
expect((process as any).PRIVILEGES['ai-assistant mutation']).toEqual(['ai-assistant', 'mutation'])
|
|
261
|
+
expect((process as any).PRIVILEGES['board-ai mutation']).toBeUndefined()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('필드 설명에 필요한 권한을 적어 둔다', async () => {
|
|
265
|
+
const schema = buildSchema()
|
|
266
|
+
const description = (schema.getQueryType() as any).getFields().guardedBoard.description
|
|
267
|
+
|
|
268
|
+
expect(description).toContain('board:mutation')
|
|
269
|
+
})
|
|
270
|
+
})
|