@things-factory/auth-base 10.0.0 → 10.0.4

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.
Files changed (36) hide show
  1. package/dist-server/index.d.ts +3 -0
  2. package/dist-server/index.js +3 -0
  3. package/dist-server/index.js.map +1 -1
  4. package/dist-server/middlewares/domain-authenticate-middleware.js +26 -29
  5. package/dist-server/middlewares/domain-authenticate-middleware.js.map +1 -1
  6. package/dist-server/service/domain-generator/domain-generator-mutation.js +8 -4
  7. package/dist-server/service/domain-generator/domain-generator-mutation.js.map +1 -1
  8. package/dist-server/service/index.d.ts +1 -1
  9. package/dist-server/service/user/user-mutation.js +9 -5
  10. package/dist-server/service/user/user-mutation.js.map +1 -1
  11. package/dist-server/service/user/user-query.js +4 -1
  12. package/dist-server/service/user/user-query.js.map +1 -1
  13. package/dist-server/service/user/user.d.ts +12 -0
  14. package/dist-server/service/user/user.js +22 -9
  15. package/dist-server/service/user/user.js.map +1 -1
  16. package/dist-server/tsconfig.tsbuildinfo +1 -1
  17. package/dist-server/utils/can-user-checkin-domain.d.ts +15 -0
  18. package/dist-server/utils/can-user-checkin-domain.js +28 -0
  19. package/dist-server/utils/can-user-checkin-domain.js.map +1 -0
  20. package/dist-server/utils/checkin-domain-facts.d.ts +37 -0
  21. package/dist-server/utils/checkin-domain-facts.js +223 -0
  22. package/dist-server/utils/checkin-domain-facts.js.map +1 -0
  23. package/dist-server/utils/checkin-domain-rule.d.ts +72 -0
  24. package/dist-server/utils/checkin-domain-rule.js +76 -0
  25. package/dist-server/utils/checkin-domain-rule.js.map +1 -0
  26. package/dist-server/utils/domain-ownership.d.ts +36 -0
  27. package/dist-server/utils/domain-ownership.js +64 -0
  28. package/dist-server/utils/domain-ownership.js.map +1 -0
  29. package/dist-server/utils/get-user-domains.d.ts +16 -0
  30. package/dist-server/utils/get-user-domains.js +47 -89
  31. package/dist-server/utils/get-user-domains.js.map +1 -1
  32. package/package.json +4 -4
  33. package/spec/unit/checkin-domain.spec.ts +307 -0
  34. package/spec/unit/checkin-privilege.spec.ts +205 -0
  35. package/spec/unit/domain-ownership.spec.ts +188 -0
  36. package/tests/checkin-domain-rule.test.ts +128 -0
@@ -0,0 +1,15 @@
1
+ import { User } from '../service/user/user.js';
2
+ import { CheckinDomainCandidate, CheckinGrant } from './checkin-domain-rule.js';
3
+ /**
4
+ * 사용자가 이 도메인에 체크인할 수 있는 근거를 반환한다 (없으면 빈 배열).
5
+ *
6
+ * 매 요청 통과하는 도메인 인증 경로용. 도메인 전체 목록(`getUserDomains`)을 계산하지 않고
7
+ * 대상 도메인 하나만 판정하므로, 통상적인 경우 병렬 2회 조회로 끝난다.
8
+ *
9
+ * @param domain 상속·소유권 판정에 `parentId`·`owner` 를 사용하므로 온전히 적재된 도메인이어야
10
+ * 한다 (`context.state.domain`). 필드가 누락된 부분 객체를 넘기면 그 경로는
11
+ * 성립하지 않는 것으로 취급된다(fail-closed).
12
+ */
13
+ export declare function checkinGrantsForDomain(user: User, domain: CheckinDomainCandidate): Promise<CheckinGrant[]>;
14
+ /** 사용자가 이 도메인에 체크인할 수 있는지. */
15
+ export declare function canUserCheckinDomain(user: User, domain: CheckinDomainCandidate): Promise<boolean>;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkinGrantsForDomain = checkinGrantsForDomain;
4
+ exports.canUserCheckinDomain = canUserCheckinDomain;
5
+ const checkin_domain_facts_js_1 = require("./checkin-domain-facts.js");
6
+ const checkin_domain_rule_js_1 = require("./checkin-domain-rule.js");
7
+ /**
8
+ * 사용자가 이 도메인에 체크인할 수 있는 근거를 반환한다 (없으면 빈 배열).
9
+ *
10
+ * 매 요청 통과하는 도메인 인증 경로용. 도메인 전체 목록(`getUserDomains`)을 계산하지 않고
11
+ * 대상 도메인 하나만 판정하므로, 통상적인 경우 병렬 2회 조회로 끝난다.
12
+ *
13
+ * @param domain 상속·소유권 판정에 `parentId`·`owner` 를 사용하므로 온전히 적재된 도메인이어야
14
+ * 한다 (`context.state.domain`). 필드가 누락된 부분 객체를 넘기면 그 경로는
15
+ * 성립하지 않는 것으로 취급된다(fail-closed).
16
+ */
17
+ async function checkinGrantsForDomain(user, domain) {
18
+ if (!user?.id || !domain?.id) {
19
+ return [];
20
+ }
21
+ const facts = await (0, checkin_domain_facts_js_1.collectCheckinFactsForDomain)(user, domain, facts => !(0, checkin_domain_rule_js_1.checkinGrantsOf)(domain, facts).length);
22
+ return (0, checkin_domain_rule_js_1.checkinGrantsOf)(domain, facts);
23
+ }
24
+ /** 사용자가 이 도메인에 체크인할 수 있는지. */
25
+ async function canUserCheckinDomain(user, domain) {
26
+ return (await checkinGrantsForDomain(user, domain)).length > 0;
27
+ }
28
+ //# sourceMappingURL=can-user-checkin-domain.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"can-user-checkin-domain.js","sourceRoot":"","sources":["../../server/utils/can-user-checkin-domain.ts"],"names":[],"mappings":";;AAcA,wDAQC;AAGD,oDAEC;AA1BD,uEAAwE;AACxE,qEAAgG;AAEhG;;;;;;;;;GASG;AACI,KAAK,UAAU,sBAAsB,CAAC,IAAU,EAAE,MAA8B;IACrF,IAAI,CAAC,IAAI,EAAE,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE,EAAE,CAAC;QAC7B,OAAO,EAAE,CAAA;IACX,CAAC;IAED,MAAM,KAAK,GAAG,MAAM,IAAA,sDAA4B,EAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC,IAAA,wCAAe,EAAC,MAAM,EAAE,KAAK,CAAC,CAAC,MAAM,CAAC,CAAA;IAE/G,OAAO,IAAA,wCAAe,EAAC,MAAM,EAAE,KAAK,CAAC,CAAA;AACvC,CAAC;AAED,8BAA8B;AACvB,KAAK,UAAU,oBAAoB,CAAC,IAAU,EAAE,MAA8B;IACnF,OAAO,CAAC,MAAM,sBAAsB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAA;AAChE,CAAC","sourcesContent":["import { User } from '../service/user/user.js'\nimport { collectCheckinFactsForDomain } from './checkin-domain-facts.js'\nimport { CheckinDomainCandidate, CheckinGrant, checkinGrantsOf } from './checkin-domain-rule.js'\n\n/**\n * 사용자가 이 도메인에 체크인할 수 있는 근거를 반환한다 (없으면 빈 배열).\n *\n * 매 요청 통과하는 도메인 인증 경로용. 도메인 전체 목록(`getUserDomains`)을 계산하지 않고\n * 대상 도메인 하나만 판정하므로, 통상적인 경우 병렬 2회 조회로 끝난다.\n *\n * @param domain 상속·소유권 판정에 `parentId`·`owner` 를 사용하므로 온전히 적재된 도메인이어야\n * 한다 (`context.state.domain`). 필드가 누락된 부분 객체를 넘기면 그 경로는\n * 성립하지 않는 것으로 취급된다(fail-closed).\n */\nexport async function checkinGrantsForDomain(user: User, domain: CheckinDomainCandidate): Promise<CheckinGrant[]> {\n if (!user?.id || !domain?.id) {\n return []\n }\n\n const facts = await collectCheckinFactsForDomain(user, domain, facts => !checkinGrantsOf(domain, facts).length)\n\n return checkinGrantsOf(domain, facts)\n}\n\n/** 사용자가 이 도메인에 체크인할 수 있는지. */\nexport async function canUserCheckinDomain(user: User, domain: CheckinDomainCandidate): Promise<boolean> {\n return (await checkinGrantsForDomain(user, domain)).length > 0\n}\n"]}
@@ -0,0 +1,37 @@
1
+ import { Domain } from '@things-factory/shell';
2
+ import { User } from '../service/user/user.js';
3
+ import { CheckinDomainCandidate, CheckinFacts } from './checkin-domain-rule.js';
4
+ /**
5
+ * 도메인 하나에 대한 판정용 사실 수집 (핫패스).
6
+ *
7
+ * 통상적인 통과(그 도메인의 멤버로서 역할을 부여받은 사용자)는 **1회 조회**로 끝난다.
8
+ * 파트너십·소유권 조회는 그것만으로 근거가 서지 않을 때만 병렬로 수행한다.
9
+ *
10
+ * @param domain 판정 대상 도메인. 상속·소유권 판정에 `parentId`·`owner` 가 필요하므로
11
+ * `context.state.domain` 처럼 온전히 적재된 도메인을 넘겨야 한다.
12
+ * @param stillNeeded 1차 사실만으로 근거가 섰는지 판단하는 콜백 (규칙 함수를 주입해 규칙의
13
+ * 단일 정의를 유지한다).
14
+ */
15
+ export declare function collectCheckinFactsForDomain(user: User, domain: CheckinDomainCandidate, stillNeeded: (facts: CheckinFacts) => boolean): Promise<CheckinFacts>;
16
+ /** 전체 목록 판정용 사실 + 판정 대상 도메인(행까지 적재된 상태). */
17
+ export type UserCheckinCandidates = {
18
+ facts: CheckinFacts;
19
+ candidates: Domain[];
20
+ };
21
+ /**
22
+ * 사용자가 체크인할 수 있는 도메인 목록을 계산하기 위한 사실 + 후보 도메인 수집.
23
+ * 로그인·체크인 화면 등 목록이 필요한 지점에서만 쓴다 (매 요청 경로에서는
24
+ * `collectCheckinFactsForDomain` 을 쓴다).
25
+ *
26
+ * 조회 설계:
27
+ * - 경로별(소속·소유·파트너) 조회가 **도메인 행을 직접** 반환한다. 후보 id 를 모은 뒤 다시
28
+ * 적재하는 단계가 없어져 왕복이 한 묶음으로 줄었다.
29
+ * - 네 조회는 서로 의존하지 않으므로 한 번에 병렬로 던진다.
30
+ * - 관계를 적재한 엔티티(`findOne({relations})`)는 쓰지 않는다 — 판정에 필요한 것은 id 뿐인데
31
+ * 엔티티 적재는 역할·도메인 전체를 hydration 한다.
32
+ * - 판정 자체는 하지 않는다. 어떤 후보가 실제로 체크인 가능한지는 `checkinGrantsOf` 한 곳에서만
33
+ * 결정한다.
34
+ */
35
+ export declare function collectCheckinCandidates(user: User): Promise<UserCheckinCandidates>;
36
+ /** 사용자가 보유한 역할 중 주어진 권한을 가진 역할이 정의된 도메인 id 집합. */
37
+ export declare function findRoleDomainIdsWithPrivilege(user: User, privilege: string, category: string): Promise<Set<string>>;
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectCheckinFactsForDomain = collectCheckinFactsForDomain;
4
+ exports.collectCheckinCandidates = collectCheckinCandidates;
5
+ exports.findRoleDomainIdsWithPrivilege = findRoleDomainIdsWithPrivilege;
6
+ const typeorm_1 = require("typeorm");
7
+ const shell_1 = require("@things-factory/shell");
8
+ const domain_owner_js_1 = require("../service/domain-owner/domain-owner.js");
9
+ const granted_role_js_1 = require("../service/granted-role/granted-role.js");
10
+ const role_js_1 = require("../service/role/role.js");
11
+ const user_js_1 = require("../service/user/user.js");
12
+ const domain_ownership_js_1 = require("./domain-ownership.js");
13
+ /**
14
+ * 체크인 판정(`checkin-domain-rule.ts`)에 필요한 사실을 데이터베이스에서 수집한다.
15
+ *
16
+ * 판정 규칙과 조회를 분리한 이유:
17
+ * - 규칙은 순수 함수로 두어 시나리오를 빠짐없이 단위 테스트할 수 있게 한다.
18
+ * - 조회는 목적에 따라 두 가지 비용 프로파일을 갖는다. 도메인 하나에 대한 가부 판정(매 요청
19
+ * 통과하는 핫패스)과, 체크인 화면용 전체 목록 계산(로그인·부트스트랩 시점)이다.
20
+ *
21
+ * 모든 조회는 TypeORM 리포지토리/쿼리빌더 API 만 사용한다 (raw SQL·DB 고유 문법 없음).
22
+ * `IN` 절은 빈 배열이면 일부 드라이버에서 문법 오류가 되므로 항상 길이를 먼저 확인한다.
23
+ */
24
+ /**
25
+ * "이 도메인의 멤버로서 부여받은 역할" 이 정의된 도메인 id.
26
+ *
27
+ * 멤버십(`users_domains`)과 역할 보유(`users_roles`)를 한 번의 조회로 함께 확인한다 —
28
+ * 매 요청 통과하는 판정이라 왕복 횟수를 최소화한다. 결과가 비어 있으면 "멤버가 아님" 과
29
+ * "부여된 역할이 없음" 을 구분할 수 없지만, 두 경우 모두 이 경로로는 체크인할 수 없으므로
30
+ * 판정에는 차이가 없다.
31
+ */
32
+ async function findGrantedRoleDomainIds(user, domainId, roleDomainIds) {
33
+ if (!roleDomainIds.length) {
34
+ return [];
35
+ }
36
+ const rows = await (0, shell_1.getRepository)(user_js_1.User)
37
+ .createQueryBuilder('USER')
38
+ .innerJoin('USER.domains', 'MEMBER_DOMAIN', 'MEMBER_DOMAIN.id = :domainId', { domainId })
39
+ .innerJoin('USER.roles', 'ROLE')
40
+ .innerJoin('ROLE.domain', 'ROLE_DOMAIN')
41
+ .where('USER.id = :userId', { userId: user.id })
42
+ .andWhere('ROLE_DOMAIN.id IN (:...roleDomainIds)', { roleDomainIds })
43
+ .select('ROLE_DOMAIN.id', 'roleDomainId')
44
+ .distinct(true)
45
+ .getRawMany();
46
+ return [...new Set(rows.map(row => row.roleDomainId).filter(Boolean))];
47
+ }
48
+ /**
49
+ * 파트너십 경로 — 이 도메인에 정의된 역할이 사용자의 소속 도메인에 grant 되어 있는지.
50
+ * (공급자 도메인이 자기 역할을 고객 도메인에 grant 하고, 고객 사용자가 그 역할을 부여받는 구조)
51
+ */
52
+ async function isPartnerGrantedDomain(user, domainId) {
53
+ return await (0, shell_1.getRepository)(granted_role_js_1.GrantedRole)
54
+ .createQueryBuilder('GRANTED_ROLE')
55
+ .innerJoin('GRANTED_ROLE.role', 'ROLE')
56
+ .innerJoin('ROLE.users', 'ROLE_USER')
57
+ .innerJoin('ROLE_USER.domains', 'MEMBER_DOMAIN')
58
+ .where('ROLE_USER.id = :userId', { userId: user.id })
59
+ .andWhere('ROLE.domain = :domainId', { domainId })
60
+ .andWhere('GRANTED_ROLE.domain = MEMBER_DOMAIN.id')
61
+ .getExists();
62
+ }
63
+ /**
64
+ * 도메인 하나에 대한 판정용 사실 수집 (핫패스).
65
+ *
66
+ * 통상적인 통과(그 도메인의 멤버로서 역할을 부여받은 사용자)는 **1회 조회**로 끝난다.
67
+ * 파트너십·소유권 조회는 그것만으로 근거가 서지 않을 때만 병렬로 수행한다.
68
+ *
69
+ * @param domain 판정 대상 도메인. 상속·소유권 판정에 `parentId`·`owner` 가 필요하므로
70
+ * `context.state.domain` 처럼 온전히 적재된 도메인을 넘겨야 한다.
71
+ * @param stillNeeded 1차 사실만으로 근거가 섰는지 판단하는 콜백 (규칙 함수를 주입해 규칙의
72
+ * 단일 정의를 유지한다).
73
+ */
74
+ async function collectCheckinFactsForDomain(user, domain, stillNeeded) {
75
+ /* 상속은 부모 1단까지 — shell 의 `applyDomainInheritanceFilter` 규약과 같다. */
76
+ const inheritableDomainIds = [domain.id, domain.parentId].filter(Boolean);
77
+ const grantedRoleDomainIds = await findGrantedRoleDomainIds(user, domain.id, inheritableDomainIds);
78
+ const facts = {
79
+ userId: user.id,
80
+ memberDomainIds: grantedRoleDomainIds.length ? [domain.id] : [],
81
+ roleDomainIds: grantedRoleDomainIds,
82
+ partnerRoleDomainIds: [],
83
+ ownedDomainIds: []
84
+ };
85
+ if (!stillNeeded(facts)) {
86
+ return facts;
87
+ }
88
+ const [partner, owner] = await Promise.all([
89
+ isPartnerGrantedDomain(user, domain.id),
90
+ (0, domain_ownership_js_1.isDomainOwner)(domain, user)
91
+ ]);
92
+ return {
93
+ ...facts,
94
+ /* 파트너 조회가 "이 도메인에 정의된 역할 보유" 까지 함께 확인하므로 역할 보유 사실도 채운다. */
95
+ roleDomainIds: partner ? [...new Set([...grantedRoleDomainIds, domain.id])] : grantedRoleDomainIds,
96
+ partnerRoleDomainIds: partner ? [domain.id] : [],
97
+ ownedDomainIds: owner ? [domain.id] : []
98
+ };
99
+ }
100
+ /**
101
+ * 사용자가 체크인할 수 있는 도메인 목록을 계산하기 위한 사실 + 후보 도메인 수집.
102
+ * 로그인·체크인 화면 등 목록이 필요한 지점에서만 쓴다 (매 요청 경로에서는
103
+ * `collectCheckinFactsForDomain` 을 쓴다).
104
+ *
105
+ * 조회 설계:
106
+ * - 경로별(소속·소유·파트너) 조회가 **도메인 행을 직접** 반환한다. 후보 id 를 모은 뒤 다시
107
+ * 적재하는 단계가 없어져 왕복이 한 묶음으로 줄었다.
108
+ * - 네 조회는 서로 의존하지 않으므로 한 번에 병렬로 던진다.
109
+ * - 관계를 적재한 엔티티(`findOne({relations})`)는 쓰지 않는다 — 판정에 필요한 것은 id 뿐인데
110
+ * 엔티티 적재는 역할·도메인 전체를 hydration 한다.
111
+ * - 판정 자체는 하지 않는다. 어떤 후보가 실제로 체크인 가능한지는 `checkinGrantsOf` 한 곳에서만
112
+ * 결정한다.
113
+ */
114
+ async function collectCheckinCandidates(user) {
115
+ const [memberDomains, roleDomainIds, ownedDomains, partnerRoleDomainIds] = await Promise.all([
116
+ findMemberDomains(user),
117
+ findRoleDomainIds(user),
118
+ findOwnedDomains(user),
119
+ findPartnerRoleDomainIds(user)
120
+ ]);
121
+ /* 파트너 경로로 열린 도메인은 소속 도메인이 아니므로 별도로 적재한다 (해당 사용자가 있을 때만). */
122
+ const partnerDomains = partnerRoleDomainIds.size
123
+ ? await (0, shell_1.getRepository)(shell_1.Domain).find({ where: { id: (0, typeorm_1.In)([...partnerRoleDomainIds]) } })
124
+ : [];
125
+ const candidateById = new Map();
126
+ for (const domain of [...memberDomains, ...ownedDomains, ...partnerDomains]) {
127
+ candidateById.set(domain.id, domain);
128
+ }
129
+ return {
130
+ facts: {
131
+ userId: user.id,
132
+ memberDomainIds: new Set(memberDomains.map((domain) => domain.id)),
133
+ roleDomainIds,
134
+ partnerRoleDomainIds,
135
+ ownedDomainIds: new Set(ownedDomains.map((domain) => domain.id))
136
+ },
137
+ candidates: [...candidateById.values()]
138
+ };
139
+ }
140
+ /** 사용자가 소속된 도메인 (`users_domains`). */
141
+ async function findMemberDomains(user) {
142
+ return await (0, shell_1.getRepository)(shell_1.Domain)
143
+ .createQueryBuilder('DOMAIN')
144
+ .where(qb => {
145
+ const subQuery = qb
146
+ .subQuery()
147
+ .select('MEMBER_DOMAIN.id')
148
+ .from(user_js_1.User, 'MEMBER_USER')
149
+ .innerJoin('MEMBER_USER.domains', 'MEMBER_DOMAIN')
150
+ .where('MEMBER_USER.id = :userId')
151
+ .getQuery();
152
+ return 'DOMAIN.id IN ' + subQuery;
153
+ })
154
+ .setParameter('userId', user.id)
155
+ .getMany();
156
+ }
157
+ /** 사용자가 소유한 도메인 — 대표 오너 캐시 컬럼과 `DomainOwner` 테이블 양쪽. */
158
+ async function findOwnedDomains(user) {
159
+ // 주의: `Domain.owner` 는 varchar(사용자 id 문자열 캐시)이고 `DomainOwner.user` 는 uuid FK 다.
160
+ // 두 비교에 **동일 파라미터를 재사용하면** Postgres 가 준비된 구문의 파라미터를 하나의
161
+ // 타입으로만 추론하다 `operator does not exist: uuid = text` 로 실패한다. → 값은 같아도
162
+ // owner(varchar) 는 :ownerId, user_id(uuid) 는 :userId 로 **파라미터를 분리**한다.
163
+ return await (0, shell_1.getRepository)(shell_1.Domain)
164
+ .createQueryBuilder('DOMAIN')
165
+ .where('DOMAIN.owner = :ownerId')
166
+ .orWhere(qb => {
167
+ const subQuery = qb
168
+ .subQuery()
169
+ .select('OWNED_DOMAIN.id')
170
+ .from(domain_owner_js_1.DomainOwner, 'DOMAIN_OWNER')
171
+ .innerJoin('DOMAIN_OWNER.domain', 'OWNED_DOMAIN')
172
+ .where('DOMAIN_OWNER.user = :userId')
173
+ .getQuery();
174
+ return 'DOMAIN.id IN ' + subQuery;
175
+ })
176
+ .setParameter('ownerId', user.id)
177
+ .setParameter('userId', user.id)
178
+ .getMany();
179
+ }
180
+ /** 사용자가 보유한 역할이 정의된 도메인 id. */
181
+ async function findRoleDomainIds(user) {
182
+ const rows = await (0, shell_1.getRepository)(role_js_1.Role)
183
+ .createQueryBuilder('ROLE')
184
+ .innerJoin('ROLE.users', 'ROLE_USER')
185
+ .innerJoin('ROLE.domain', 'ROLE_DOMAIN')
186
+ .where('ROLE_USER.id = :userId', { userId: user.id })
187
+ .select('ROLE_DOMAIN.id', 'roleDomainId')
188
+ .distinct(true)
189
+ .getRawMany();
190
+ return new Set(rows.map(row => row.roleDomainId).filter(Boolean));
191
+ }
192
+ /**
193
+ * 파트너십 경로로 열린 도메인 id — 사용자가 보유한 역할 중, 그 역할이 사용자의 소속 도메인에
194
+ * grant 된 것들의 정의 도메인. 역할 보유·grant·소속을 한 조회에서 함께 확인한다.
195
+ */
196
+ async function findPartnerRoleDomainIds(user) {
197
+ const rows = await (0, shell_1.getRepository)(granted_role_js_1.GrantedRole)
198
+ .createQueryBuilder('GRANTED_ROLE')
199
+ .innerJoin('GRANTED_ROLE.role', 'ROLE')
200
+ .innerJoin('ROLE.domain', 'ROLE_DOMAIN')
201
+ .innerJoin('ROLE.users', 'ROLE_USER')
202
+ .innerJoin('ROLE_USER.domains', 'MEMBER_DOMAIN')
203
+ .where('ROLE_USER.id = :userId', { userId: user.id })
204
+ .andWhere('GRANTED_ROLE.domain = MEMBER_DOMAIN.id')
205
+ .select('ROLE_DOMAIN.id', 'roleDomainId')
206
+ .distinct(true)
207
+ .getRawMany();
208
+ return new Set(rows.map(row => row.roleDomainId).filter(Boolean));
209
+ }
210
+ /** 사용자가 보유한 역할 중 주어진 권한을 가진 역할이 정의된 도메인 id 집합. */
211
+ async function findRoleDomainIdsWithPrivilege(user, privilege, category) {
212
+ const roles = await (0, shell_1.getRepository)(role_js_1.Role)
213
+ .createQueryBuilder('ROLE')
214
+ .innerJoin('ROLE.users', 'ROLE_USER')
215
+ .innerJoin('ROLE.privileges', 'PRIVILEGE')
216
+ .innerJoinAndSelect('ROLE.domain', 'ROLE_DOMAIN')
217
+ .where('ROLE_USER.id = :userId', { userId: user.id })
218
+ .andWhere('PRIVILEGE.name = :privilege', { privilege })
219
+ .andWhere('PRIVILEGE.category = :category', { category })
220
+ .getMany();
221
+ return new Set(roles.map((role) => role.domain?.id).filter(Boolean));
222
+ }
223
+ //# sourceMappingURL=checkin-domain-facts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checkin-domain-facts.js","sourceRoot":"","sources":["../../server/utils/checkin-domain-facts.ts"],"names":[],"mappings":"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","sourcesContent":["import { In } from 'typeorm'\n\nimport { Domain, getRepository } from '@things-factory/shell'\n\nimport { DomainOwner } from '../service/domain-owner/domain-owner.js'\nimport { GrantedRole } from '../service/granted-role/granted-role.js'\nimport { Role } from '../service/role/role.js'\nimport { User } from '../service/user/user.js'\nimport { CheckinDomainCandidate, CheckinFacts } from './checkin-domain-rule.js'\nimport { isDomainOwner } from './domain-ownership.js'\n\n/**\n * 체크인 판정(`checkin-domain-rule.ts`)에 필요한 사실을 데이터베이스에서 수집한다.\n *\n * 판정 규칙과 조회를 분리한 이유:\n * - 규칙은 순수 함수로 두어 시나리오를 빠짐없이 단위 테스트할 수 있게 한다.\n * - 조회는 목적에 따라 두 가지 비용 프로파일을 갖는다. 도메인 하나에 대한 가부 판정(매 요청\n * 통과하는 핫패스)과, 체크인 화면용 전체 목록 계산(로그인·부트스트랩 시점)이다.\n *\n * 모든 조회는 TypeORM 리포지토리/쿼리빌더 API 만 사용한다 (raw SQL·DB 고유 문법 없음).\n * `IN` 절은 빈 배열이면 일부 드라이버에서 문법 오류가 되므로 항상 길이를 먼저 확인한다.\n */\n\n/**\n * \"이 도메인의 멤버로서 부여받은 역할\" 이 정의된 도메인 id.\n *\n * 멤버십(`users_domains`)과 역할 보유(`users_roles`)를 한 번의 조회로 함께 확인한다 —\n * 매 요청 통과하는 판정이라 왕복 횟수를 최소화한다. 결과가 비어 있으면 \"멤버가 아님\" 과\n * \"부여된 역할이 없음\" 을 구분할 수 없지만, 두 경우 모두 이 경로로는 체크인할 수 없으므로\n * 판정에는 차이가 없다.\n */\nasync function findGrantedRoleDomainIds(user: User, domainId: string, roleDomainIds: string[]): Promise<string[]> {\n if (!roleDomainIds.length) {\n return []\n }\n\n const rows = await getRepository(User)\n .createQueryBuilder('USER')\n .innerJoin('USER.domains', 'MEMBER_DOMAIN', 'MEMBER_DOMAIN.id = :domainId', { domainId })\n .innerJoin('USER.roles', 'ROLE')\n .innerJoin('ROLE.domain', 'ROLE_DOMAIN')\n .where('USER.id = :userId', { userId: user.id })\n .andWhere('ROLE_DOMAIN.id IN (:...roleDomainIds)', { roleDomainIds })\n .select('ROLE_DOMAIN.id', 'roleDomainId')\n .distinct(true)\n .getRawMany()\n\n return [...new Set<string>(rows.map(row => row.roleDomainId).filter(Boolean))]\n}\n\n/**\n * 파트너십 경로 — 이 도메인에 정의된 역할이 사용자의 소속 도메인에 grant 되어 있는지.\n * (공급자 도메인이 자기 역할을 고객 도메인에 grant 하고, 고객 사용자가 그 역할을 부여받는 구조)\n */\nasync function isPartnerGrantedDomain(user: User, domainId: string): Promise<boolean> {\n return await getRepository(GrantedRole)\n .createQueryBuilder('GRANTED_ROLE')\n .innerJoin('GRANTED_ROLE.role', 'ROLE')\n .innerJoin('ROLE.users', 'ROLE_USER')\n .innerJoin('ROLE_USER.domains', 'MEMBER_DOMAIN')\n .where('ROLE_USER.id = :userId', { userId: user.id })\n .andWhere('ROLE.domain = :domainId', { domainId })\n .andWhere('GRANTED_ROLE.domain = MEMBER_DOMAIN.id')\n .getExists()\n}\n\n/**\n * 도메인 하나에 대한 판정용 사실 수집 (핫패스).\n *\n * 통상적인 통과(그 도메인의 멤버로서 역할을 부여받은 사용자)는 **1회 조회**로 끝난다.\n * 파트너십·소유권 조회는 그것만으로 근거가 서지 않을 때만 병렬로 수행한다.\n *\n * @param domain 판정 대상 도메인. 상속·소유권 판정에 `parentId`·`owner` 가 필요하므로\n * `context.state.domain` 처럼 온전히 적재된 도메인을 넘겨야 한다.\n * @param stillNeeded 1차 사실만으로 근거가 섰는지 판단하는 콜백 (규칙 함수를 주입해 규칙의\n * 단일 정의를 유지한다).\n */\nexport async function collectCheckinFactsForDomain(\n user: User,\n domain: CheckinDomainCandidate,\n stillNeeded: (facts: CheckinFacts) => boolean\n): Promise<CheckinFacts> {\n /* 상속은 부모 1단까지 — shell 의 `applyDomainInheritanceFilter` 규약과 같다. */\n const inheritableDomainIds = [domain.id, domain.parentId].filter(Boolean) as string[]\n\n const grantedRoleDomainIds = await findGrantedRoleDomainIds(user, domain.id, inheritableDomainIds)\n\n const facts: CheckinFacts = {\n userId: user.id,\n memberDomainIds: grantedRoleDomainIds.length ? [domain.id] : [],\n roleDomainIds: grantedRoleDomainIds,\n partnerRoleDomainIds: [],\n ownedDomainIds: []\n }\n\n if (!stillNeeded(facts)) {\n return facts\n }\n\n const [partner, owner] = await Promise.all([\n isPartnerGrantedDomain(user, domain.id),\n isDomainOwner(domain, user)\n ])\n\n return {\n ...facts,\n /* 파트너 조회가 \"이 도메인에 정의된 역할 보유\" 까지 함께 확인하므로 역할 보유 사실도 채운다. */\n roleDomainIds: partner ? [...new Set([...grantedRoleDomainIds, domain.id])] : grantedRoleDomainIds,\n partnerRoleDomainIds: partner ? [domain.id] : [],\n ownedDomainIds: owner ? [domain.id] : []\n }\n}\n\n/** 전체 목록 판정용 사실 + 판정 대상 도메인(행까지 적재된 상태). */\nexport type UserCheckinCandidates = {\n facts: CheckinFacts\n candidates: Domain[]\n}\n\n/**\n * 사용자가 체크인할 수 있는 도메인 목록을 계산하기 위한 사실 + 후보 도메인 수집.\n * 로그인·체크인 화면 등 목록이 필요한 지점에서만 쓴다 (매 요청 경로에서는\n * `collectCheckinFactsForDomain` 을 쓴다).\n *\n * 조회 설계:\n * - 경로별(소속·소유·파트너) 조회가 **도메인 행을 직접** 반환한다. 후보 id 를 모은 뒤 다시\n * 적재하는 단계가 없어져 왕복이 한 묶음으로 줄었다.\n * - 네 조회는 서로 의존하지 않으므로 한 번에 병렬로 던진다.\n * - 관계를 적재한 엔티티(`findOne({relations})`)는 쓰지 않는다 — 판정에 필요한 것은 id 뿐인데\n * 엔티티 적재는 역할·도메인 전체를 hydration 한다.\n * - 판정 자체는 하지 않는다. 어떤 후보가 실제로 체크인 가능한지는 `checkinGrantsOf` 한 곳에서만\n * 결정한다.\n */\nexport async function collectCheckinCandidates(user: User): Promise<UserCheckinCandidates> {\n const [memberDomains, roleDomainIds, ownedDomains, partnerRoleDomainIds] = await Promise.all([\n findMemberDomains(user),\n findRoleDomainIds(user),\n findOwnedDomains(user),\n findPartnerRoleDomainIds(user)\n ])\n\n /* 파트너 경로로 열린 도메인은 소속 도메인이 아니므로 별도로 적재한다 (해당 사용자가 있을 때만). */\n const partnerDomains: Domain[] = partnerRoleDomainIds.size\n ? await getRepository(Domain).find({ where: { id: In([...partnerRoleDomainIds]) } })\n : []\n\n const candidateById = new Map<string, Domain>()\n for (const domain of [...memberDomains, ...ownedDomains, ...partnerDomains]) {\n candidateById.set(domain.id, domain)\n }\n\n return {\n facts: {\n userId: user.id,\n memberDomainIds: new Set<string>(memberDomains.map((domain: Domain) => domain.id)),\n roleDomainIds,\n partnerRoleDomainIds,\n ownedDomainIds: new Set<string>(ownedDomains.map((domain: Domain) => domain.id))\n },\n candidates: [...candidateById.values()]\n }\n}\n\n/** 사용자가 소속된 도메인 (`users_domains`). */\nasync function findMemberDomains(user: User): Promise<Domain[]> {\n return await getRepository(Domain)\n .createQueryBuilder('DOMAIN')\n .where(qb => {\n const subQuery = qb\n .subQuery()\n .select('MEMBER_DOMAIN.id')\n .from(User, 'MEMBER_USER')\n .innerJoin('MEMBER_USER.domains', 'MEMBER_DOMAIN')\n .where('MEMBER_USER.id = :userId')\n .getQuery()\n return 'DOMAIN.id IN ' + subQuery\n })\n .setParameter('userId', user.id)\n .getMany()\n}\n\n/** 사용자가 소유한 도메인 — 대표 오너 캐시 컬럼과 `DomainOwner` 테이블 양쪽. */\nasync function findOwnedDomains(user: User): Promise<Domain[]> {\n // 주의: `Domain.owner` 는 varchar(사용자 id 문자열 캐시)이고 `DomainOwner.user` 는 uuid FK 다.\n // 두 비교에 **동일 파라미터를 재사용하면** Postgres 가 준비된 구문의 파라미터를 하나의\n // 타입으로만 추론하다 `operator does not exist: uuid = text` 로 실패한다. → 값은 같아도\n // owner(varchar) 는 :ownerId, user_id(uuid) 는 :userId 로 **파라미터를 분리**한다.\n return await getRepository(Domain)\n .createQueryBuilder('DOMAIN')\n .where('DOMAIN.owner = :ownerId')\n .orWhere(qb => {\n const subQuery = qb\n .subQuery()\n .select('OWNED_DOMAIN.id')\n .from(DomainOwner, 'DOMAIN_OWNER')\n .innerJoin('DOMAIN_OWNER.domain', 'OWNED_DOMAIN')\n .where('DOMAIN_OWNER.user = :userId')\n .getQuery()\n return 'DOMAIN.id IN ' + subQuery\n })\n .setParameter('ownerId', user.id)\n .setParameter('userId', user.id)\n .getMany()\n}\n\n/** 사용자가 보유한 역할이 정의된 도메인 id. */\nasync function findRoleDomainIds(user: User): Promise<Set<string>> {\n const rows = await getRepository(Role)\n .createQueryBuilder('ROLE')\n .innerJoin('ROLE.users', 'ROLE_USER')\n .innerJoin('ROLE.domain', 'ROLE_DOMAIN')\n .where('ROLE_USER.id = :userId', { userId: user.id })\n .select('ROLE_DOMAIN.id', 'roleDomainId')\n .distinct(true)\n .getRawMany()\n\n return new Set<string>(rows.map(row => row.roleDomainId).filter(Boolean))\n}\n\n/**\n * 파트너십 경로로 열린 도메인 id — 사용자가 보유한 역할 중, 그 역할이 사용자의 소속 도메인에\n * grant 된 것들의 정의 도메인. 역할 보유·grant·소속을 한 조회에서 함께 확인한다.\n */\nasync function findPartnerRoleDomainIds(user: User): Promise<Set<string>> {\n const rows = await getRepository(GrantedRole)\n .createQueryBuilder('GRANTED_ROLE')\n .innerJoin('GRANTED_ROLE.role', 'ROLE')\n .innerJoin('ROLE.domain', 'ROLE_DOMAIN')\n .innerJoin('ROLE.users', 'ROLE_USER')\n .innerJoin('ROLE_USER.domains', 'MEMBER_DOMAIN')\n .where('ROLE_USER.id = :userId', { userId: user.id })\n .andWhere('GRANTED_ROLE.domain = MEMBER_DOMAIN.id')\n .select('ROLE_DOMAIN.id', 'roleDomainId')\n .distinct(true)\n .getRawMany()\n\n return new Set<string>(rows.map(row => row.roleDomainId).filter(Boolean))\n}\n\n/** 사용자가 보유한 역할 중 주어진 권한을 가진 역할이 정의된 도메인 id 집합. */\nexport async function findRoleDomainIdsWithPrivilege(\n user: User,\n privilege: string,\n category: string\n): Promise<Set<string>> {\n const roles: Role[] = await getRepository(Role)\n .createQueryBuilder('ROLE')\n .innerJoin('ROLE.users', 'ROLE_USER')\n .innerJoin('ROLE.privileges', 'PRIVILEGE')\n .innerJoinAndSelect('ROLE.domain', 'ROLE_DOMAIN')\n .where('ROLE_USER.id = :userId', { userId: user.id })\n .andWhere('PRIVILEGE.name = :privilege', { privilege })\n .andWhere('PRIVILEGE.category = :category', { category })\n .getMany()\n\n return new Set<string>(roles.map((role: Role) => role.domain?.id).filter(Boolean))\n}\n"]}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 사용자가 어떤 도메인에 체크인할 수 있는지를 판정하는 규칙 (순수 함수).
3
+ *
4
+ * ## 왜 "역할이 정의된 도메인" 이 아닌가
5
+ *
6
+ * 역할(Role)은 도메인에 정의되지만, 자식 도메인은 부모 도메인의 역할을 **상속**한다
7
+ * (단일 소스: `applyDomainInheritanceFilter` — 상속은 부모 1단까지).
8
+ * 따라서 부모 도메인에 정의된 역할을 자식 도메인 화면에서 사용자에게 부여하는 것이 정상 동작이며,
9
+ * 이때 체크인 대상은 **역할을 부여한 도메인(자식)** 이지 역할을 정의한 도메인(부모)이 아니다.
10
+ *
11
+ * `users_roles` 조인 테이블에는 부여 도메인이 기록되지 않으므로, 부여 도메인은
12
+ * `users_domains`(멤버십)로 복원한다. 역할 부여 mutation(`updateUserRoles`)이
13
+ * "대상 사용자가 현재 도메인의 멤버여야 함"을 강제하기 때문에 이 복원은 성립한다.
14
+ *
15
+ * ## 멤버십을 AND 조건으로 거는 이유
16
+ *
17
+ * 부모 도메인의 역할은 모든 자식 도메인이 상속한다. 멤버십 없이 "부모 역할 보유"만으로 체크인을
18
+ * 허용하면, 한 자식 도메인용으로 부여한 역할이 형제 자식 도메인 전체에 대한 접근권이 되어
19
+ * 테넌트 격리가 깨진다.
20
+ *
21
+ * ## 파트너십 경로
22
+ *
23
+ * 파트너십(`GrantedRole`)은 공급자 도메인이 자기 역할을 고객 도메인에 grant 하고, 고객 도메인
24
+ * 관리자가 그 역할을 자기 사용자에게 부여하는 구조다. 이 사용자는 공급자 도메인의 멤버가 아니지만
25
+ * 공급자 도메인에 체크인해야 한다. 그래서 멤버십 대신 "내 소속 도메인에 grant 된 역할" 을 근거로
26
+ * 삼는 별도 경로를 둔다.
27
+ */
28
+ /** 판정 대상 도메인 — 판정에 필요한 최소 필드만 요구한다. */
29
+ export type CheckinDomainCandidate = {
30
+ id: string;
31
+ parentId?: string | null;
32
+ /** 하위 호환 단일 owner 캐시 컬럼 (권위 있는 소유권은 `DomainOwner` 테이블). */
33
+ owner?: string | null;
34
+ };
35
+ /** 판정에 필요한 사용자 측 사실들 — 조회는 호출자(`get-user-domains`)가 담당한다. */
36
+ export type CheckinFacts = {
37
+ userId: string;
38
+ /** `users_domains` — 사용자가 멤버인 도메인. 역할이 "부여된" 도메인의 근거. */
39
+ memberDomainIds: Iterable<string>;
40
+ /** 사용자가 보유한 역할이 "정의된" 도메인 (role.domainId). */
41
+ roleDomainIds: Iterable<string>;
42
+ /** `GrantedRole` 로 사용자의 소속 도메인에 grant 된 역할이 정의된 도메인. */
43
+ partnerRoleDomainIds?: Iterable<string>;
44
+ /** `DomainOwner` 테이블 기준 사용자가 소유한 도메인. */
45
+ ownedDomainIds?: Iterable<string>;
46
+ };
47
+ /**
48
+ * 체크인이 허용되는 근거.
49
+ * `roleDomainId` 는 그 근거가 된 역할이 정의된 도메인으로, 권한(privilege) 판정에서
50
+ * "어느 도메인의 역할을 근거로 들어왔는지" 를 다시 확인하는 데 쓴다.
51
+ */
52
+ export type CheckinGrant = {
53
+ via: 'owner';
54
+ } | {
55
+ via: 'role';
56
+ roleDomainId: string;
57
+ } | {
58
+ via: 'inherited-role';
59
+ roleDomainId: string;
60
+ } | {
61
+ via: 'partner';
62
+ roleDomainId: string;
63
+ };
64
+ /**
65
+ * 도메인에 대한 체크인 근거를 모두 반환한다. 비어 있으면 체크인 불가.
66
+ *
67
+ * 근거를 하나만 반환하지 않는 이유: 같은 도메인이 소유권·자기 역할·상속 역할 중 둘 이상으로
68
+ * 정당화될 수 있고, 권한 판정은 그중 "권한을 가진 역할" 을 근거로 삼아야 하기 때문이다.
69
+ */
70
+ export declare function checkinGrantsOf(domain: CheckinDomainCandidate, facts: CheckinFacts): CheckinGrant[];
71
+ /** 체크인 가능 여부. */
72
+ export declare function isCheckinAllowed(domain: CheckinDomainCandidate, facts: CheckinFacts): boolean;
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ /**
3
+ * 사용자가 어떤 도메인에 체크인할 수 있는지를 판정하는 규칙 (순수 함수).
4
+ *
5
+ * ## 왜 "역할이 정의된 도메인" 이 아닌가
6
+ *
7
+ * 역할(Role)은 도메인에 정의되지만, 자식 도메인은 부모 도메인의 역할을 **상속**한다
8
+ * (단일 소스: `applyDomainInheritanceFilter` — 상속은 부모 1단까지).
9
+ * 따라서 부모 도메인에 정의된 역할을 자식 도메인 화면에서 사용자에게 부여하는 것이 정상 동작이며,
10
+ * 이때 체크인 대상은 **역할을 부여한 도메인(자식)** 이지 역할을 정의한 도메인(부모)이 아니다.
11
+ *
12
+ * `users_roles` 조인 테이블에는 부여 도메인이 기록되지 않으므로, 부여 도메인은
13
+ * `users_domains`(멤버십)로 복원한다. 역할 부여 mutation(`updateUserRoles`)이
14
+ * "대상 사용자가 현재 도메인의 멤버여야 함"을 강제하기 때문에 이 복원은 성립한다.
15
+ *
16
+ * ## 멤버십을 AND 조건으로 거는 이유
17
+ *
18
+ * 부모 도메인의 역할은 모든 자식 도메인이 상속한다. 멤버십 없이 "부모 역할 보유"만으로 체크인을
19
+ * 허용하면, 한 자식 도메인용으로 부여한 역할이 형제 자식 도메인 전체에 대한 접근권이 되어
20
+ * 테넌트 격리가 깨진다.
21
+ *
22
+ * ## 파트너십 경로
23
+ *
24
+ * 파트너십(`GrantedRole`)은 공급자 도메인이 자기 역할을 고객 도메인에 grant 하고, 고객 도메인
25
+ * 관리자가 그 역할을 자기 사용자에게 부여하는 구조다. 이 사용자는 공급자 도메인의 멤버가 아니지만
26
+ * 공급자 도메인에 체크인해야 한다. 그래서 멤버십 대신 "내 소속 도메인에 grant 된 역할" 을 근거로
27
+ * 삼는 별도 경로를 둔다.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.checkinGrantsOf = checkinGrantsOf;
31
+ exports.isCheckinAllowed = isCheckinAllowed;
32
+ function normalize(facts) {
33
+ return {
34
+ userId: facts.userId,
35
+ memberDomainIds: new Set(facts.memberDomainIds || []),
36
+ roleDomainIds: new Set(facts.roleDomainIds || []),
37
+ partnerRoleDomainIds: new Set(facts.partnerRoleDomainIds || []),
38
+ ownedDomainIds: new Set(facts.ownedDomainIds || [])
39
+ };
40
+ }
41
+ /**
42
+ * 도메인에 대한 체크인 근거를 모두 반환한다. 비어 있으면 체크인 불가.
43
+ *
44
+ * 근거를 하나만 반환하지 않는 이유: 같은 도메인이 소유권·자기 역할·상속 역할 중 둘 이상으로
45
+ * 정당화될 수 있고, 권한 판정은 그중 "권한을 가진 역할" 을 근거로 삼아야 하기 때문이다.
46
+ */
47
+ function checkinGrantsOf(domain, facts) {
48
+ if (!domain?.id) {
49
+ return [];
50
+ }
51
+ const { userId, memberDomainIds, roleDomainIds, partnerRoleDomainIds, ownedDomainIds } = normalize(facts);
52
+ const grants = [];
53
+ if ((domain.owner && domain.owner === userId) || ownedDomainIds.has(domain.id)) {
54
+ grants.push({ via: 'owner' });
55
+ }
56
+ if (memberDomainIds.has(domain.id)) {
57
+ /* 이 도메인에 정의된 역할을 이 도메인에서 부여받았다. */
58
+ if (roleDomainIds.has(domain.id)) {
59
+ grants.push({ via: 'role', roleDomainId: domain.id });
60
+ }
61
+ /* 부모 도메인에 정의된 역할(상속)을 이 도메인에서 부여받았다. */
62
+ if (domain.parentId && roleDomainIds.has(domain.parentId)) {
63
+ grants.push({ via: 'inherited-role', roleDomainId: domain.parentId });
64
+ }
65
+ }
66
+ /* 파트너십으로 이 도메인의 역할이 내 소속 도메인에 grant 된 경우 — 멤버십을 요구하지 않는다. */
67
+ if (partnerRoleDomainIds.has(domain.id) && roleDomainIds.has(domain.id)) {
68
+ grants.push({ via: 'partner', roleDomainId: domain.id });
69
+ }
70
+ return grants;
71
+ }
72
+ /** 체크인 가능 여부. */
73
+ function isCheckinAllowed(domain, facts) {
74
+ return checkinGrantsOf(domain, facts).length > 0;
75
+ }
76
+ //# sourceMappingURL=checkin-domain-rule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checkin-domain-rule.js","sourceRoot":"","sources":["../../server/utils/checkin-domain-rule.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;;AA0DH,0CA8BC;AAGD,4CAEC;AAnDD,SAAS,SAAS,CAAC,KAAmB;IACpC,OAAO;QACL,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,eAAe,EAAE,IAAI,GAAG,CAAC,KAAK,CAAC,eAAe,IAAI,EAAE,CAAC;QACrD,aAAa,EAAE,IAAI,GAAG,CAAC,KAAK,CAAC,aAAa,IAAI,EAAE,CAAC;QACjD,oBAAoB,EAAE,IAAI,GAAG,CAAC,KAAK,CAAC,oBAAoB,IAAI,EAAE,CAAC;QAC/D,cAAc,EAAE,IAAI,GAAG,CAAC,KAAK,CAAC,cAAc,IAAI,EAAE,CAAC;KACpD,CAAA;AACH,CAAC;AAED;;;;;GAKG;AACH,SAAgB,eAAe,CAAC,MAA8B,EAAE,KAAmB;IACjF,IAAI,CAAC,MAAM,EAAE,EAAE,EAAE,CAAC;QAChB,OAAO,EAAE,CAAA;IACX,CAAC;IAED,MAAM,EAAE,MAAM,EAAE,eAAe,EAAE,aAAa,EAAE,oBAAoB,EAAE,cAAc,EAAE,GAAG,SAAS,CAAC,KAAK,CAAC,CAAA;IACzG,MAAM,MAAM,GAAmB,EAAE,CAAA;IAEjC,IAAI,CAAC,MAAM,CAAC,KAAK,IAAI,MAAM,CAAC,KAAK,KAAK,MAAM,CAAC,IAAI,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC;QAC/E,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,CAAA;IAC/B,CAAC;IAED,IAAI,eAAe,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC;QACnC,mCAAmC;QACnC,IAAI,aAAa,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC;YACjC,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,CAAA;QACvD,CAAC;QAED,wCAAwC;QACxC,IAAI,MAAM,CAAC,QAAQ,IAAI,aAAa,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC1D,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAA;QACvE,CAAC;IACH,CAAC;IAED,6DAA6D;IAC7D,IAAI,oBAAoB,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,aAAa,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC;QACxE,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,CAAA;IAC1D,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC;AAED,iBAAiB;AACjB,SAAgB,gBAAgB,CAAC,MAA8B,EAAE,KAAmB;IAClF,OAAO,eAAe,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,MAAM,GAAG,CAAC,CAAA;AAClD,CAAC","sourcesContent":["/**\n * 사용자가 어떤 도메인에 체크인할 수 있는지를 판정하는 규칙 (순수 함수).\n *\n * ## 왜 \"역할이 정의된 도메인\" 이 아닌가\n *\n * 역할(Role)은 도메인에 정의되지만, 자식 도메인은 부모 도메인의 역할을 **상속**한다\n * (단일 소스: `applyDomainInheritanceFilter` — 상속은 부모 1단까지).\n * 따라서 부모 도메인에 정의된 역할을 자식 도메인 화면에서 사용자에게 부여하는 것이 정상 동작이며,\n * 이때 체크인 대상은 **역할을 부여한 도메인(자식)** 이지 역할을 정의한 도메인(부모)이 아니다.\n *\n * `users_roles` 조인 테이블에는 부여 도메인이 기록되지 않으므로, 부여 도메인은\n * `users_domains`(멤버십)로 복원한다. 역할 부여 mutation(`updateUserRoles`)이\n * \"대상 사용자가 현재 도메인의 멤버여야 함\"을 강제하기 때문에 이 복원은 성립한다.\n *\n * ## 멤버십을 AND 조건으로 거는 이유\n *\n * 부모 도메인의 역할은 모든 자식 도메인이 상속한다. 멤버십 없이 \"부모 역할 보유\"만으로 체크인을\n * 허용하면, 한 자식 도메인용으로 부여한 역할이 형제 자식 도메인 전체에 대한 접근권이 되어\n * 테넌트 격리가 깨진다.\n *\n * ## 파트너십 경로\n *\n * 파트너십(`GrantedRole`)은 공급자 도메인이 자기 역할을 고객 도메인에 grant 하고, 고객 도메인\n * 관리자가 그 역할을 자기 사용자에게 부여하는 구조다. 이 사용자는 공급자 도메인의 멤버가 아니지만\n * 공급자 도메인에 체크인해야 한다. 그래서 멤버십 대신 \"내 소속 도메인에 grant 된 역할\" 을 근거로\n * 삼는 별도 경로를 둔다.\n */\n\n/** 판정 대상 도메인 — 판정에 필요한 최소 필드만 요구한다. */\nexport type CheckinDomainCandidate = {\n id: string\n parentId?: string | null\n /** 하위 호환 단일 owner 캐시 컬럼 (권위 있는 소유권은 `DomainOwner` 테이블). */\n owner?: string | null\n}\n\n/** 판정에 필요한 사용자 측 사실들 — 조회는 호출자(`get-user-domains`)가 담당한다. */\nexport type CheckinFacts = {\n userId: string\n /** `users_domains` — 사용자가 멤버인 도메인. 역할이 \"부여된\" 도메인의 근거. */\n memberDomainIds: Iterable<string>\n /** 사용자가 보유한 역할이 \"정의된\" 도메인 (role.domainId). */\n roleDomainIds: Iterable<string>\n /** `GrantedRole` 로 사용자의 소속 도메인에 grant 된 역할이 정의된 도메인. */\n partnerRoleDomainIds?: Iterable<string>\n /** `DomainOwner` 테이블 기준 사용자가 소유한 도메인. */\n ownedDomainIds?: Iterable<string>\n}\n\n/**\n * 체크인이 허용되는 근거.\n * `roleDomainId` 는 그 근거가 된 역할이 정의된 도메인으로, 권한(privilege) 판정에서\n * \"어느 도메인의 역할을 근거로 들어왔는지\" 를 다시 확인하는 데 쓴다.\n */\nexport type CheckinGrant =\n | { via: 'owner' }\n | { via: 'role'; roleDomainId: string }\n | { via: 'inherited-role'; roleDomainId: string }\n | { via: 'partner'; roleDomainId: string }\n\ntype NormalizedFacts = {\n userId: string\n memberDomainIds: Set<string>\n roleDomainIds: Set<string>\n partnerRoleDomainIds: Set<string>\n ownedDomainIds: Set<string>\n}\n\nfunction normalize(facts: CheckinFacts): NormalizedFacts {\n return {\n userId: facts.userId,\n memberDomainIds: new Set(facts.memberDomainIds || []),\n roleDomainIds: new Set(facts.roleDomainIds || []),\n partnerRoleDomainIds: new Set(facts.partnerRoleDomainIds || []),\n ownedDomainIds: new Set(facts.ownedDomainIds || [])\n }\n}\n\n/**\n * 도메인에 대한 체크인 근거를 모두 반환한다. 비어 있으면 체크인 불가.\n *\n * 근거를 하나만 반환하지 않는 이유: 같은 도메인이 소유권·자기 역할·상속 역할 중 둘 이상으로\n * 정당화될 수 있고, 권한 판정은 그중 \"권한을 가진 역할\" 을 근거로 삼아야 하기 때문이다.\n */\nexport function checkinGrantsOf(domain: CheckinDomainCandidate, facts: CheckinFacts): CheckinGrant[] {\n if (!domain?.id) {\n return []\n }\n\n const { userId, memberDomainIds, roleDomainIds, partnerRoleDomainIds, ownedDomainIds } = normalize(facts)\n const grants: CheckinGrant[] = []\n\n if ((domain.owner && domain.owner === userId) || ownedDomainIds.has(domain.id)) {\n grants.push({ via: 'owner' })\n }\n\n if (memberDomainIds.has(domain.id)) {\n /* 이 도메인에 정의된 역할을 이 도메인에서 부여받았다. */\n if (roleDomainIds.has(domain.id)) {\n grants.push({ via: 'role', roleDomainId: domain.id })\n }\n\n /* 부모 도메인에 정의된 역할(상속)을 이 도메인에서 부여받았다. */\n if (domain.parentId && roleDomainIds.has(domain.parentId)) {\n grants.push({ via: 'inherited-role', roleDomainId: domain.parentId })\n }\n }\n\n /* 파트너십으로 이 도메인의 역할이 내 소속 도메인에 grant 된 경우 — 멤버십을 요구하지 않는다. */\n if (partnerRoleDomainIds.has(domain.id) && roleDomainIds.has(domain.id)) {\n grants.push({ via: 'partner', roleDomainId: domain.id })\n }\n\n return grants\n}\n\n/** 체크인 가능 여부. */\nexport function isCheckinAllowed(domain: CheckinDomainCandidate, facts: CheckinFacts): boolean {\n return checkinGrantsOf(domain, facts).length > 0\n}\n"]}
@@ -0,0 +1,36 @@
1
+ import { Domain } from '@things-factory/shell';
2
+ import { EntityManager } from 'typeorm';
3
+ import { User } from '../service/user/user.js';
4
+ /**
5
+ * 도메인 소유권(= 도메인 관리자) 판정의 단일 소스.
6
+ *
7
+ * 소유권은 두 경로 중 하나로 인식된다:
8
+ * 1) `DomainOwner` 테이블 엔트리 — 권위 있는 정보. 한 도메인에 여러 명 등록 가능.
9
+ * 2) `Domain.owner` 컬럼 — 하위 호환 + "대표 오너" 표시용 캐시.
10
+ *
11
+ * 도메인 범위의 권한 판정은 둘 중 하나만 맞으면 오너로 인정해야 한다. 컬럼 한 개만 보면
12
+ * 테이블로만 등록된 공동 오너가 권한을 잃는다.
13
+ *
14
+ * 예외: 전역 특권인 수퍼유저(`process.superUserGranted`)는 의도적으로 SYSTEM 도메인의
15
+ * **대표 오너(`Domain.owner` 컬럼)** 한 명으로 제한한다. SYSTEM 도메인의 공동 오너는
16
+ * SYSTEM 도메인의 오너로서만 권한을 갖는다.
17
+ */
18
+ /** 소유권 판정에 필요한 최소 필드 (`owner` 는 대표 오너 캐시로, 없을 수도 있다). */
19
+ export type DomainOwnershipTarget = {
20
+ id: string;
21
+ owner?: string | null;
22
+ };
23
+ export declare function isDomainOwner(domain: DomainOwnershipTarget | null | undefined, user: Pick<User, 'id'> | null | undefined, tx?: EntityManager): Promise<boolean>;
24
+ /**
25
+ * 도메인의 모든 오너 사용자 id. 사용자 목록에 오너 여부를 표시할 때처럼
26
+ * 여러 사용자를 한 번에 판정해야 하는 곳에서 쓴다 (사용자마다 조회하지 않도록).
27
+ */
28
+ export declare function findDomainOwnerUserIds(domain: DomainOwnershipTarget | null | undefined, tx?: EntityManager): Promise<Set<string>>;
29
+ /**
30
+ * 대표 오너 캐시(`Domain.owner`)와 `DomainOwner` 테이블을 함께 갱신한다.
31
+ * 도메인 생성·오너 이관처럼 컬럼만 세팅해 두면 두 곳이 어긋나는 경로에서 쓴다.
32
+ *
33
+ * 기존 오너 엔트리는 제거하지 않는다 — 오너 제거는 `removeDomainOwner` 의 책임이고
34
+ * 거기에 "마지막 오너 보호" 규칙이 있다.
35
+ */
36
+ export declare function setPrimaryDomainOwner(domain: Pick<Domain, 'id'>, ownerUserId: string, grantedBy?: Pick<User, 'id'> | null, tx?: EntityManager): Promise<void>;
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isDomainOwner = isDomainOwner;
4
+ exports.findDomainOwnerUserIds = findDomainOwnerUserIds;
5
+ exports.setPrimaryDomainOwner = setPrimaryDomainOwner;
6
+ const shell_1 = require("@things-factory/shell");
7
+ const domain_owner_js_1 = require("../service/domain-owner/domain-owner.js");
8
+ async function isDomainOwner(domain, user, tx) {
9
+ if (!domain?.id || !user?.id) {
10
+ return false;
11
+ }
12
+ /* 대표 오너 캐시 — 조회 없이 판정되는 경로. */
13
+ if (domain.owner === user.id) {
14
+ return true;
15
+ }
16
+ return await (0, shell_1.getRepository)(domain_owner_js_1.DomainOwner, tx)
17
+ .createQueryBuilder('DOMAIN_OWNER')
18
+ .where('DOMAIN_OWNER.domain = :domainId', { domainId: domain.id })
19
+ .andWhere('DOMAIN_OWNER.user = :userId', { userId: user.id })
20
+ .getExists();
21
+ }
22
+ /**
23
+ * 도메인의 모든 오너 사용자 id. 사용자 목록에 오너 여부를 표시할 때처럼
24
+ * 여러 사용자를 한 번에 판정해야 하는 곳에서 쓴다 (사용자마다 조회하지 않도록).
25
+ */
26
+ async function findDomainOwnerUserIds(domain, tx) {
27
+ if (!domain?.id) {
28
+ return new Set();
29
+ }
30
+ const ownerships = await (0, shell_1.getRepository)(domain_owner_js_1.DomainOwner, tx).find({
31
+ where: { domain: { id: domain.id } },
32
+ relations: ['user']
33
+ });
34
+ const ownerIds = new Set(ownerships.map((ownership) => ownership.user?.id || ownership.userId).filter(Boolean));
35
+ if (domain.owner) {
36
+ ownerIds.add(domain.owner);
37
+ }
38
+ return ownerIds;
39
+ }
40
+ /**
41
+ * 대표 오너 캐시(`Domain.owner`)와 `DomainOwner` 테이블을 함께 갱신한다.
42
+ * 도메인 생성·오너 이관처럼 컬럼만 세팅해 두면 두 곳이 어긋나는 경로에서 쓴다.
43
+ *
44
+ * 기존 오너 엔트리는 제거하지 않는다 — 오너 제거는 `removeDomainOwner` 의 책임이고
45
+ * 거기에 "마지막 오너 보호" 규칙이 있다.
46
+ */
47
+ async function setPrimaryDomainOwner(domain, ownerUserId, grantedBy, tx) {
48
+ if (!domain?.id || !ownerUserId) {
49
+ throw new Error('domain and owner user are required to set the primary domain owner');
50
+ }
51
+ const ownerRepository = (0, shell_1.getRepository)(domain_owner_js_1.DomainOwner, tx);
52
+ const existing = await ownerRepository.findOne({
53
+ where: { domain: { id: domain.id }, user: { id: ownerUserId } }
54
+ });
55
+ if (!existing) {
56
+ await ownerRepository.save({
57
+ domain: { id: domain.id },
58
+ user: { id: ownerUserId },
59
+ grantedBy: grantedBy?.id ? { id: grantedBy.id } : null
60
+ });
61
+ }
62
+ await (0, shell_1.getRepository)(shell_1.Domain, tx).update(domain.id, { owner: ownerUserId });
63
+ }
64
+ //# sourceMappingURL=domain-ownership.js.map