@open-mercato/core 0.6.8-develop.7015.1.af90a2ddc7 → 0.6.8-develop.7016.1.4ed7b1e49d
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/api_keys/api/keys/route.js +1 -1
- package/dist/modules/api_keys/api/keys/route.js.map +2 -2
- package/dist/modules/api_keys/di.js +9 -0
- package/dist/modules/api_keys/di.js.map +7 -0
- package/dist/modules/api_keys/services/principalService.js +31 -0
- package/dist/modules/api_keys/services/principalService.js.map +7 -0
- package/dist/modules/attachments/di.js +11 -1
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/index.js.map +1 -1
- package/dist/modules/attachments/lib/attachment-service.js +313 -0
- package/dist/modules/attachments/lib/attachment-service.js.map +7 -0
- package/dist/modules/attachments/lib/scoped-upload-service.js +15 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
- package/dist/modules/attachments/lib/upload-limits.js +6 -2
- package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/di.js +35 -9
- package/dist/modules/auth/di.js.map +2 -2
- package/dist/modules/auth/services/principalService.js +200 -0
- package/dist/modules/auth/services/principalService.js.map +7 -0
- package/dist/modules/auth/services/rbacService.js +67 -24
- package/dist/modules/auth/services/rbacService.js.map +3 -3
- package/dist/modules/auth/services/roleOrganizationScope.js +41 -0
- package/dist/modules/auth/services/roleOrganizationScope.js.map +7 -0
- package/dist/modules/directory/di.js +9 -0
- package/dist/modules/directory/di.js.map +2 -2
- package/dist/modules/directory/services/organizationHierarchyService.js +29 -0
- package/dist/modules/directory/services/organizationHierarchyService.js.map +7 -0
- package/dist/modules/directory/services/organizationScopeService.js +56 -0
- package/dist/modules/directory/services/organizationScopeService.js.map +7 -0
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/attachments/route.js +2 -2
- package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +4 -0
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/api/keys/route.ts +3 -1
- package/src/modules/api_keys/di.ts +7 -0
- package/src/modules/api_keys/services/principalService.ts +33 -0
- package/src/modules/attachments/AGENTS.md +15 -0
- package/src/modules/attachments/di.ts +16 -0
- package/src/modules/attachments/i18n/de.json +5 -0
- package/src/modules/attachments/i18n/en.json +5 -0
- package/src/modules/attachments/i18n/es.json +5 -0
- package/src/modules/attachments/i18n/ko.json +5 -0
- package/src/modules/attachments/i18n/pl.json +5 -0
- package/src/modules/attachments/index.ts +10 -0
- package/src/modules/attachments/lib/attachment-service.ts +468 -0
- package/src/modules/attachments/lib/scoped-upload-service.ts +46 -0
- package/src/modules/attachments/lib/upload-limits.ts +6 -2
- package/src/modules/auth/data/entities.ts +4 -1
- package/src/modules/auth/di.ts +46 -16
- package/src/modules/auth/services/principalService.ts +266 -0
- package/src/modules/auth/services/rbacService.ts +110 -24
- package/src/modules/auth/services/roleOrganizationScope.ts +70 -0
- package/src/modules/directory/di.ts +14 -2
- package/src/modules/directory/services/organizationHierarchyService.ts +34 -0
- package/src/modules/directory/services/organizationScopeService.ts +85 -0
- package/src/modules/directory/utils/organizationScope.ts +4 -15
- package/src/modules/warranty_claims/api/portal/attachments/route.ts +2 -2
- package/src/modules/workflows/lib/activity-executor.ts +9 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { FilterQuery } from '@mikro-orm/core'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { sql } from 'kysely'
|
|
4
|
+
import type {
|
|
5
|
+
AuthPrincipalLabel,
|
|
6
|
+
AuthPrincipalRolePage,
|
|
7
|
+
AuthPrincipalService,
|
|
8
|
+
OrganizationHierarchyService,
|
|
9
|
+
PrincipalScope,
|
|
10
|
+
} from '@open-mercato/shared/lib/auth/principal-service'
|
|
11
|
+
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
12
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
13
|
+
import { Role, RoleAcl, User, UserRole } from '../data/entities'
|
|
14
|
+
import { listSuperAdminUserIds } from '../lib/grantChecks'
|
|
15
|
+
import { resolveRoleOrganizationScope, roleAclGrantsPrincipalOrganization } from './roleOrganizationScope'
|
|
16
|
+
|
|
17
|
+
const MAX_ROLE_PAGE_SIZE = 100
|
|
18
|
+
const MAX_ROLE_RESULTS = 1_000
|
|
19
|
+
|
|
20
|
+
type AuthPrincipalDatabase = {
|
|
21
|
+
roles: {
|
|
22
|
+
id: string
|
|
23
|
+
tenant_id: string
|
|
24
|
+
name: string
|
|
25
|
+
deleted_at: Date | null
|
|
26
|
+
}
|
|
27
|
+
role_acls: {
|
|
28
|
+
role_id: string
|
|
29
|
+
tenant_id: string
|
|
30
|
+
organizations_json: string[] | null
|
|
31
|
+
deleted_at: Date | null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeIds(ids: string[]): string[] {
|
|
36
|
+
return Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean)))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function relationId(value: unknown): string | null {
|
|
40
|
+
if (typeof value === 'string') return value.trim() || null
|
|
41
|
+
if (!value || typeof value !== 'object') return null
|
|
42
|
+
const id = (value as { id?: unknown }).id
|
|
43
|
+
return typeof id === 'string' && id.trim().length > 0 ? id.trim() : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class DefaultAuthPrincipalService implements AuthPrincipalService {
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly em: EntityManager,
|
|
49
|
+
private readonly organizationHierarchyService?: OrganizationHierarchyService,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
async principalExists(input: {
|
|
53
|
+
type: 'user' | 'role'
|
|
54
|
+
id: string
|
|
55
|
+
scope: PrincipalScope
|
|
56
|
+
}): Promise<boolean> {
|
|
57
|
+
if (input.type === 'role') {
|
|
58
|
+
return (await this.filterActiveRoleIds([input.id], input.scope)).includes(input.id)
|
|
59
|
+
}
|
|
60
|
+
return Boolean(await findOneWithDecryption(
|
|
61
|
+
this.em,
|
|
62
|
+
User,
|
|
63
|
+
{
|
|
64
|
+
id: input.id,
|
|
65
|
+
tenantId: input.scope.tenantId,
|
|
66
|
+
deletedAt: null,
|
|
67
|
+
$or: [{ organizationId: null }, { organizationId: input.scope.organizationId }],
|
|
68
|
+
} as FilterQuery<User>,
|
|
69
|
+
{ fields: ['id'] as const },
|
|
70
|
+
input.scope,
|
|
71
|
+
))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async resolveActiveUserRoleIds(userId: string, scope: PrincipalScope): Promise<string[]> {
|
|
75
|
+
const links = await findWithDecryption(
|
|
76
|
+
this.em,
|
|
77
|
+
UserRole,
|
|
78
|
+
{
|
|
79
|
+
user: userId,
|
|
80
|
+
deletedAt: null,
|
|
81
|
+
role: { tenantId: scope.tenantId, deletedAt: null },
|
|
82
|
+
} as FilterQuery<UserRole>,
|
|
83
|
+
{ fields: ['role'] as const },
|
|
84
|
+
scope,
|
|
85
|
+
)
|
|
86
|
+
return this.filterActiveRoleIds(
|
|
87
|
+
normalizeIds(links.flatMap((link) => {
|
|
88
|
+
const id = relationId(link.role)
|
|
89
|
+
return id ? [id] : []
|
|
90
|
+
})),
|
|
91
|
+
scope,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async filterActiveRoleIds(roleIds: string[], scope: PrincipalScope): Promise<string[]> {
|
|
96
|
+
const ids = normalizeIds(roleIds)
|
|
97
|
+
if (ids.length === 0) return []
|
|
98
|
+
const roles = await findWithDecryption(
|
|
99
|
+
this.em,
|
|
100
|
+
Role,
|
|
101
|
+
{ id: { $in: ids }, tenantId: scope.tenantId, deletedAt: null } as FilterQuery<Role>,
|
|
102
|
+
{ fields: ['id'] as const },
|
|
103
|
+
scope,
|
|
104
|
+
)
|
|
105
|
+
const activeIds = normalizeIds(roles.map((role) => role.id))
|
|
106
|
+
if (activeIds.length === 0) return []
|
|
107
|
+
const roleOrganizationScope = await resolveRoleOrganizationScope(
|
|
108
|
+
this.organizationHierarchyService,
|
|
109
|
+
scope.tenantId,
|
|
110
|
+
scope.organizationId,
|
|
111
|
+
)
|
|
112
|
+
const acls = await findWithDecryption(
|
|
113
|
+
this.em,
|
|
114
|
+
RoleAcl,
|
|
115
|
+
{
|
|
116
|
+
tenantId: scope.tenantId,
|
|
117
|
+
role: { $in: activeIds },
|
|
118
|
+
deletedAt: null,
|
|
119
|
+
} as FilterQuery<RoleAcl>,
|
|
120
|
+
{ fields: ['role', 'organizationsJson'] as const },
|
|
121
|
+
scope,
|
|
122
|
+
)
|
|
123
|
+
const eligibilityById = new Map<string, boolean>()
|
|
124
|
+
for (const acl of acls) {
|
|
125
|
+
const id = relationId(acl.role)
|
|
126
|
+
if (!id) continue
|
|
127
|
+
eligibilityById.set(
|
|
128
|
+
id,
|
|
129
|
+
eligibilityById.get(id) === true || roleAclGrantsPrincipalOrganization(acl, roleOrganizationScope),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
// A tenant role without an ACL remains a valid explicit-share principal.
|
|
133
|
+
// Only an existing ACL can restrict that role away from this organization.
|
|
134
|
+
return activeIds.filter((id) => eligibilityById.get(id) !== false)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async resolveLabels(input: {
|
|
138
|
+
type: 'user' | 'role'
|
|
139
|
+
ids: string[]
|
|
140
|
+
scope: PrincipalScope
|
|
141
|
+
}): Promise<AuthPrincipalLabel[]> {
|
|
142
|
+
const ids = normalizeIds(input.ids)
|
|
143
|
+
if (ids.length === 0) return []
|
|
144
|
+
if (input.type === 'role') {
|
|
145
|
+
const eligibleIds = await this.filterActiveRoleIds(ids, input.scope)
|
|
146
|
+
if (eligibleIds.length === 0) return []
|
|
147
|
+
const roles = await findWithDecryption(
|
|
148
|
+
this.em,
|
|
149
|
+
Role,
|
|
150
|
+
{ id: { $in: eligibleIds }, tenantId: input.scope.tenantId, deletedAt: null } as FilterQuery<Role>,
|
|
151
|
+
{ fields: ['id', 'name'] as const },
|
|
152
|
+
input.scope,
|
|
153
|
+
)
|
|
154
|
+
return roles.map((role) => ({ id: role.id, label: role.name, secondary: null }))
|
|
155
|
+
}
|
|
156
|
+
const users = await findWithDecryption(
|
|
157
|
+
this.em,
|
|
158
|
+
User,
|
|
159
|
+
{
|
|
160
|
+
id: { $in: ids },
|
|
161
|
+
tenantId: input.scope.tenantId,
|
|
162
|
+
deletedAt: null,
|
|
163
|
+
$or: [{ organizationId: null }, { organizationId: input.scope.organizationId }],
|
|
164
|
+
} as FilterQuery<User>,
|
|
165
|
+
{ fields: ['id', 'name', 'email'] as const },
|
|
166
|
+
input.scope,
|
|
167
|
+
)
|
|
168
|
+
return users.map((user) => ({
|
|
169
|
+
id: user.id,
|
|
170
|
+
label: user.name?.trim() || user.email,
|
|
171
|
+
secondary: user.name?.trim() && user.email !== user.name.trim() ? user.email : null,
|
|
172
|
+
}))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async queryActiveRolePage(input: {
|
|
176
|
+
scope: PrincipalScope
|
|
177
|
+
search?: string
|
|
178
|
+
excludedIds?: string[]
|
|
179
|
+
page: number
|
|
180
|
+
pageSize: number
|
|
181
|
+
}): Promise<AuthPrincipalRolePage> {
|
|
182
|
+
const page = Math.max(1, Math.floor(input.page))
|
|
183
|
+
const pageSize = Math.min(MAX_ROLE_PAGE_SIZE, Math.max(1, Math.floor(input.pageSize)))
|
|
184
|
+
const offset = (page - 1) * pageSize
|
|
185
|
+
const excludedIds = normalizeIds(input.excludedIds ?? [])
|
|
186
|
+
const roleOrganizationScope = await resolveRoleOrganizationScope(
|
|
187
|
+
this.organizationHierarchyService,
|
|
188
|
+
input.scope.tenantId,
|
|
189
|
+
input.scope.organizationId,
|
|
190
|
+
)
|
|
191
|
+
const db = this.em.getKysely<AuthPrincipalDatabase>()
|
|
192
|
+
let eligibleRoles = db
|
|
193
|
+
.selectFrom('roles as r')
|
|
194
|
+
.where('r.tenant_id', '=', input.scope.tenantId)
|
|
195
|
+
.where('r.deleted_at', 'is', null)
|
|
196
|
+
|
|
197
|
+
if (roleOrganizationScope) {
|
|
198
|
+
const allowedOrganizations = Array.from(roleOrganizationScope)
|
|
199
|
+
const organizationPredicates = [
|
|
200
|
+
sql<boolean>`ra.organizations_json is null`,
|
|
201
|
+
sql<boolean>`ra.organizations_json::jsonb @> ${JSON.stringify(['__all__'])}::jsonb`,
|
|
202
|
+
...allowedOrganizations.map((organizationId) => (
|
|
203
|
+
sql<boolean>`ra.organizations_json::jsonb @> ${JSON.stringify([organizationId])}::jsonb`
|
|
204
|
+
)),
|
|
205
|
+
]
|
|
206
|
+
const organizationAllowed = sql<boolean>`(${sql.join(organizationPredicates, sql` or `)})`
|
|
207
|
+
eligibleRoles = eligibleRoles.where(sql<boolean>`(
|
|
208
|
+
not exists (
|
|
209
|
+
select 1
|
|
210
|
+
from role_acls as ra_any
|
|
211
|
+
where ra_any.role_id = r.id
|
|
212
|
+
and ra_any.tenant_id = ${input.scope.tenantId}
|
|
213
|
+
and ra_any.deleted_at is null
|
|
214
|
+
)
|
|
215
|
+
or exists (
|
|
216
|
+
select 1
|
|
217
|
+
from role_acls as ra
|
|
218
|
+
where ra.role_id = r.id
|
|
219
|
+
and ra.tenant_id = ${input.scope.tenantId}
|
|
220
|
+
and ra.deleted_at is null
|
|
221
|
+
and ${organizationAllowed}
|
|
222
|
+
)
|
|
223
|
+
)`)
|
|
224
|
+
}
|
|
225
|
+
if (input.search?.trim()) {
|
|
226
|
+
const searchPattern = `%${escapeLikePattern(input.search.trim())}%`
|
|
227
|
+
eligibleRoles = eligibleRoles.where(sql<boolean>`r.name ilike ${searchPattern}`)
|
|
228
|
+
}
|
|
229
|
+
if (excludedIds.length > 0) {
|
|
230
|
+
eligibleRoles = eligibleRoles.where('r.id', 'not in', excludedIds)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const boundedRoleIds = eligibleRoles
|
|
234
|
+
.select('r.id')
|
|
235
|
+
.limit(MAX_ROLE_RESULTS)
|
|
236
|
+
.as('bounded_roles')
|
|
237
|
+
const countQuery = db
|
|
238
|
+
.selectFrom(boundedRoleIds)
|
|
239
|
+
.select(sql<string>`count(*)`.as('count'))
|
|
240
|
+
const pageQuery = eligibleRoles
|
|
241
|
+
.select(['r.id', 'r.name'])
|
|
242
|
+
.orderBy('r.id', 'asc')
|
|
243
|
+
.limit(offset < MAX_ROLE_RESULTS ? Math.min(pageSize, MAX_ROLE_RESULTS - offset) : 0)
|
|
244
|
+
.offset(Math.min(offset, MAX_ROLE_RESULTS))
|
|
245
|
+
|
|
246
|
+
const [rows, countRow] = await Promise.all([
|
|
247
|
+
pageQuery.execute() as Promise<Array<{ id: string; name: string }>>,
|
|
248
|
+
countQuery.executeTakeFirst() as Promise<{ count?: string | number } | undefined>,
|
|
249
|
+
])
|
|
250
|
+
const parsedTotal = Number(countRow?.count ?? 0)
|
|
251
|
+
const total = Number.isFinite(parsedTotal)
|
|
252
|
+
? Math.min(MAX_ROLE_RESULTS, Math.max(0, Math.floor(parsedTotal)))
|
|
253
|
+
: 0
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
items: rows.map((role) => ({ id: role.id, label: role.name, secondary: null })),
|
|
257
|
+
page,
|
|
258
|
+
pageSize,
|
|
259
|
+
total,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async listSuperAdminUserIds(tenantId: string): Promise<string[]> {
|
|
264
|
+
return Array.from(await listSuperAdminUserIds(this.em, tenantId))
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -4,12 +4,14 @@ import { getCurrentCacheTenant, runWithCacheTenant } from '@open-mercato/cache'
|
|
|
4
4
|
import { UserAcl, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
5
5
|
import { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'
|
|
6
6
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
7
|
+
import type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'
|
|
7
8
|
import { buildOrgScopeUserCacheTag, buildOrgScopeTenantCacheTag } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
8
9
|
import {
|
|
9
10
|
authorizeFeatures,
|
|
10
11
|
resolveEffectiveFeatures,
|
|
11
12
|
} from '@open-mercato/shared/security/featurePolicy'
|
|
12
13
|
import { filterGrantsByEnabledModules } from '@open-mercato/shared/security/enabledModulesRegistry'
|
|
14
|
+
import { resolveRoleOrganizationScope, roleAclAllowsOrganization } from './roleOrganizationScope'
|
|
13
15
|
|
|
14
16
|
interface AclData {
|
|
15
17
|
isSuperAdmin: boolean
|
|
@@ -29,12 +31,22 @@ function isAclData(value: unknown): value is AclData {
|
|
|
29
31
|
return true
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
function isRestrictedRoleAcl(acl: Pick<RoleAcl, 'organizationsJson'>): boolean {
|
|
35
|
+
return Array.isArray(acl.organizationsJson)
|
|
36
|
+
&& acl.organizationsJson.length > 0
|
|
37
|
+
&& !acl.organizationsJson.includes('__all__')
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
export class RbacService {
|
|
33
41
|
private cacheTtlMs: number = 5 * 60 * 1000 // 5 minutes default
|
|
34
42
|
private cache: CacheStrategy | null = null
|
|
35
43
|
private globalSuperAdminCache = new Map<string, boolean>()
|
|
36
44
|
|
|
37
|
-
constructor(
|
|
45
|
+
constructor(
|
|
46
|
+
private em: EntityManager,
|
|
47
|
+
cache?: CacheStrategy,
|
|
48
|
+
private readonly organizationHierarchyService?: OrganizationHierarchyService,
|
|
49
|
+
) {
|
|
38
50
|
this.cache = cache || null
|
|
39
51
|
}
|
|
40
52
|
|
|
@@ -51,13 +63,6 @@ export class RbacService {
|
|
|
51
63
|
return authorizeFeatures(required, { grantedFeatures: granted })
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
private roleAclAllowsOrganization(acl: RoleAcl, organizationId: string | null | undefined): boolean {
|
|
55
|
-
if (!organizationId) return true
|
|
56
|
-
const organizations = Array.isArray(acl.organizationsJson) ? acl.organizationsJson : null
|
|
57
|
-
if (!organizations || !organizations.length || organizations.includes('__all__')) return true
|
|
58
|
-
return organizations.includes(organizationId)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
66
|
private getCacheKey(userId: string, scope: { tenantId: string | null; organizationId: string | null }): string {
|
|
62
67
|
return `rbac:${userId}:${scope.tenantId || 'null'}:${scope.organizationId || 'null'}`
|
|
63
68
|
}
|
|
@@ -91,6 +96,10 @@ export class RbacService {
|
|
|
91
96
|
|
|
92
97
|
if (scope.tenantId) {
|
|
93
98
|
tags.push(this.getTenantTag(scope.tenantId))
|
|
99
|
+
// Scoped role projections depend on the selected organization's ancestor
|
|
100
|
+
// chain, so Directory hierarchy changes must evict them as well as the
|
|
101
|
+
// separately cached OrganizationScope entries.
|
|
102
|
+
tags.push(buildOrgScopeTenantCacheTag(scope.tenantId))
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
if (scope.organizationId) {
|
|
@@ -203,8 +212,10 @@ export class RbacService {
|
|
|
203
212
|
this.globalSuperAdminCache.set(userId, false)
|
|
204
213
|
return false
|
|
205
214
|
}
|
|
206
|
-
const
|
|
207
|
-
const result =
|
|
215
|
+
const roleSupers = await em.find(RoleAcl, { isSuperAdmin: true, role: { $in: roleIds as any } } as any)
|
|
216
|
+
const result = roleSupers.some((roleAcl) => (
|
|
217
|
+
!!roleAcl.isSuperAdmin && !isRestrictedRoleAcl(roleAcl)
|
|
218
|
+
))
|
|
208
219
|
this.globalSuperAdminCache.set(userId, result)
|
|
209
220
|
return result
|
|
210
221
|
}
|
|
@@ -239,6 +250,10 @@ export class RbacService {
|
|
|
239
250
|
const cached = await this.getFromCache(cacheKey)
|
|
240
251
|
if (cached) return cached
|
|
241
252
|
|
|
253
|
+
// Direct user-level super-admin grants and unrestricted role-level
|
|
254
|
+
// super-admin grants are global. Organization-restricted role grants are
|
|
255
|
+
// deliberately excluded by isGlobalSuperAdmin and continue through the
|
|
256
|
+
// scoped ACL projection below.
|
|
242
257
|
if (!userId.startsWith('api_key:')) {
|
|
243
258
|
if (await this.isGlobalSuperAdmin(userId)) {
|
|
244
259
|
const result = { isSuperAdmin: true, features: ['*'], organizations: null }
|
|
@@ -258,28 +273,71 @@ export class RbacService {
|
|
|
258
273
|
}
|
|
259
274
|
const tenantId = scope.tenantId || key.tenantId || null
|
|
260
275
|
const roleIds = Array.isArray(key.rolesJson) ? key.rolesJson.filter(Boolean) : []
|
|
276
|
+
const keyOrganizationId = typeof key.organizationId === 'string' && key.organizationId.trim().length > 0
|
|
277
|
+
? key.organizationId.trim()
|
|
278
|
+
: null
|
|
279
|
+
const evaluatedOrganizationId = scope.organizationId || keyOrganizationId
|
|
261
280
|
let isSuper = false
|
|
262
281
|
const features: string[] = []
|
|
263
|
-
let
|
|
282
|
+
let roleOrganizations: string[] | null = []
|
|
283
|
+
let hasApplicableRestrictedRole = false
|
|
264
284
|
if (tenantId && roleIds.length) {
|
|
285
|
+
const roleOrganizationScope = await resolveRoleOrganizationScope(
|
|
286
|
+
this.organizationHierarchyService,
|
|
287
|
+
tenantId,
|
|
288
|
+
evaluatedOrganizationId,
|
|
289
|
+
)
|
|
265
290
|
const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any)
|
|
266
291
|
for (const acl of racls) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
292
|
+
if (roleAclAllowsOrganization(acl, roleOrganizationScope)) {
|
|
293
|
+
isSuper = isSuper || !!acl.isSuperAdmin
|
|
294
|
+
if (Array.isArray(acl.featuresJson)) {
|
|
295
|
+
for (const f of acl.featuresJson) if (!features.includes(f)) features.push(f)
|
|
296
|
+
}
|
|
297
|
+
if (isRestrictedRoleAcl(acl)) hasApplicableRestrictedRole = true
|
|
270
298
|
}
|
|
271
|
-
if (
|
|
272
|
-
if (acl.organizationsJson == null) {
|
|
273
|
-
|
|
299
|
+
if (roleOrganizations !== null) {
|
|
300
|
+
if (acl.organizationsJson == null || (Array.isArray(acl.organizationsJson) && acl.organizationsJson.length === 0)) {
|
|
301
|
+
roleOrganizations = null
|
|
274
302
|
} else if (Array.isArray(acl.organizationsJson) && acl.organizationsJson.includes('__all__')) {
|
|
275
|
-
|
|
303
|
+
roleOrganizations = null
|
|
276
304
|
} else {
|
|
277
|
-
|
|
305
|
+
roleOrganizations = Array.from(new Set([
|
|
306
|
+
...roleOrganizations,
|
|
307
|
+
...(Array.isArray(acl.organizationsJson) ? acl.organizationsJson : []),
|
|
308
|
+
]))
|
|
278
309
|
}
|
|
279
310
|
}
|
|
280
311
|
}
|
|
312
|
+
if (
|
|
313
|
+
roleOrganizations !== null
|
|
314
|
+
&& evaluatedOrganizationId
|
|
315
|
+
&& roleOrganizationScope !== null
|
|
316
|
+
&& roleOrganizationScope.size > 0
|
|
317
|
+
&& hasApplicableRestrictedRole
|
|
318
|
+
&& !roleOrganizations.includes(evaluatedOrganizationId)
|
|
319
|
+
) {
|
|
320
|
+
roleOrganizations.push(evaluatedOrganizationId)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
let organizations = roleOrganizations
|
|
324
|
+
if (keyOrganizationId) {
|
|
325
|
+
const keyOrganizationScope = await resolveRoleOrganizationScope(
|
|
326
|
+
this.organizationHierarchyService,
|
|
327
|
+
tenantId,
|
|
328
|
+
keyOrganizationId,
|
|
329
|
+
)
|
|
330
|
+
organizations = roleOrganizations === null
|
|
331
|
+
|| roleOrganizations.some((organizationId) => keyOrganizationScope?.has(organizationId))
|
|
332
|
+
? [keyOrganizationId]
|
|
333
|
+
: []
|
|
281
334
|
}
|
|
282
|
-
|
|
335
|
+
// A role-level super-admin grant still respects the API key's effective
|
|
336
|
+
// organization allowlist. Represent it as a wildcard feature whenever
|
|
337
|
+
// the key is restricted so downstream super-admin shortcuts cannot
|
|
338
|
+
// bypass the key or role organization bounds.
|
|
339
|
+
if (isSuper && organizations !== null && !features.includes('*')) features.push('*')
|
|
340
|
+
const result = { isSuperAdmin: isSuper && organizations === null, features, organizations }
|
|
283
341
|
await this.setCache(cacheKey, result, userId, scope)
|
|
284
342
|
return result
|
|
285
343
|
}
|
|
@@ -326,18 +384,42 @@ export class RbacService {
|
|
|
326
384
|
let isSuper = false
|
|
327
385
|
const features: string[] = []
|
|
328
386
|
let organizations: string[] | null = []
|
|
387
|
+
let hasApplicableRestrictedRole = false
|
|
329
388
|
if (roleIds.length) {
|
|
389
|
+
const roleOrganizationScope = await resolveRoleOrganizationScope(
|
|
390
|
+
this.organizationHierarchyService,
|
|
391
|
+
tenantId,
|
|
392
|
+
scope.organizationId,
|
|
393
|
+
)
|
|
330
394
|
const racls = await em.find(RoleAcl, { tenantId, role: { $in: roleIds as any } } as any, {})
|
|
331
395
|
const roleAcls = Array.isArray(racls) ? racls : []
|
|
332
396
|
for (const r of roleAcls) {
|
|
333
|
-
|
|
334
|
-
|
|
397
|
+
if (roleAclAllowsOrganization(r, roleOrganizationScope)) {
|
|
398
|
+
isSuper = isSuper || !!r.isSuperAdmin
|
|
399
|
+
if (Array.isArray(r.featuresJson)) for (const f of r.featuresJson) if (!features.includes(f)) features.push(f)
|
|
400
|
+
if (isRestrictedRoleAcl(r)) hasApplicableRestrictedRole = true
|
|
401
|
+
}
|
|
335
402
|
if (organizations !== null) {
|
|
403
|
+
// For a user principal an empty allowlist is a deny-all scope (#4033), not
|
|
404
|
+
// "unrestricted": it must merge to an empty set so scoped reads and deletes
|
|
405
|
+
// fail closed. Only an absent allowlist or an explicit '__all__' widens to
|
|
406
|
+
// every organization. (The API-key branch above deliberately differs — an
|
|
407
|
+
// empty role list there means the key inherits the key's own scope.)
|
|
336
408
|
if (r.organizationsJson == null) organizations = null
|
|
337
409
|
else if (Array.isArray(r.organizationsJson) && r.organizationsJson.includes('__all__')) organizations = null
|
|
338
410
|
else organizations = Array.from(new Set([...(organizations || []), ...r.organizationsJson]))
|
|
339
411
|
}
|
|
340
412
|
}
|
|
413
|
+
if (
|
|
414
|
+
organizations !== null
|
|
415
|
+
&& scope.organizationId
|
|
416
|
+
&& roleOrganizationScope !== null
|
|
417
|
+
&& roleOrganizationScope.size > 0
|
|
418
|
+
&& hasApplicableRestrictedRole
|
|
419
|
+
&& !organizations.includes(scope.organizationId)
|
|
420
|
+
) {
|
|
421
|
+
organizations.push(scope.organizationId)
|
|
422
|
+
}
|
|
341
423
|
}
|
|
342
424
|
if (organizations && orgId && !organizations.includes(orgId) && !organizations.includes('__all__')) {
|
|
343
425
|
// Out-of-scope org; caller will enforce
|
|
@@ -361,12 +443,16 @@ export class RbacService {
|
|
|
361
443
|
if (!tenantId || !feature) return false
|
|
362
444
|
|
|
363
445
|
const em = this.em.fork()
|
|
446
|
+
const roleOrganizationScope = await resolveRoleOrganizationScope(
|
|
447
|
+
this.organizationHierarchyService,
|
|
448
|
+
tenantId,
|
|
449
|
+
opts?.organizationId,
|
|
450
|
+
)
|
|
364
451
|
const roleAcls = await em.find(RoleAcl, { tenantId, deletedAt: null } as any, {})
|
|
365
452
|
const list = Array.isArray(roleAcls) ? roleAcls : []
|
|
366
|
-
const organizationId = opts?.organizationId ?? null
|
|
367
453
|
|
|
368
454
|
for (const acl of list) {
|
|
369
|
-
if (!
|
|
455
|
+
if (!roleAclAllowsOrganization(acl, roleOrganizationScope)) continue
|
|
370
456
|
const grants = Array.isArray(acl.featuresJson) ? acl.featuresJson : []
|
|
371
457
|
if (authorizeFeatures([feature], {
|
|
372
458
|
grantedFeatures: grants,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'
|
|
2
|
+
import type { RoleAcl } from '../data/entities'
|
|
3
|
+
|
|
4
|
+
export type RoleOrganizationScope = ReadonlySet<string> | null
|
|
5
|
+
|
|
6
|
+
function normalizeId(value: unknown): string | null {
|
|
7
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function resolveRoleOrganizationScope(
|
|
11
|
+
hierarchyService: OrganizationHierarchyService | null | undefined,
|
|
12
|
+
tenantId: string | null | undefined,
|
|
13
|
+
organizationId: string | null | undefined,
|
|
14
|
+
): Promise<RoleOrganizationScope> {
|
|
15
|
+
const selectedId = normalizeId(organizationId)
|
|
16
|
+
if (!selectedId) return null
|
|
17
|
+
const normalizedTenantId = normalizeId(tenantId)
|
|
18
|
+
if (!normalizedTenantId) return new Set()
|
|
19
|
+
|
|
20
|
+
const ids = new Set<string>([selectedId])
|
|
21
|
+
// Auth remains independently usable when Directory is disabled: exact-org
|
|
22
|
+
// grants still apply, while parent-to-descendant expansion fails closed.
|
|
23
|
+
if (!hierarchyService) return ids
|
|
24
|
+
|
|
25
|
+
const ancestorIds = await hierarchyService.resolveAncestorIds({
|
|
26
|
+
tenantId: normalizedTenantId,
|
|
27
|
+
organizationId: selectedId,
|
|
28
|
+
})
|
|
29
|
+
// A registered Directory seam can prove an invalid/wrong-tenant selection.
|
|
30
|
+
// Fail closed for restricted roles in that case.
|
|
31
|
+
if (ancestorIds === null) return new Set()
|
|
32
|
+
for (const ancestorId of ancestorIds) {
|
|
33
|
+
const normalized = normalizeId(ancestorId)
|
|
34
|
+
if (normalized) ids.add(normalized)
|
|
35
|
+
}
|
|
36
|
+
return ids
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function roleAclAllowsOrganization(
|
|
40
|
+
acl: Pick<RoleAcl, 'organizationsJson'>,
|
|
41
|
+
scope: RoleOrganizationScope,
|
|
42
|
+
): boolean {
|
|
43
|
+
if (!scope) return true
|
|
44
|
+
const organizations = Array.isArray(acl.organizationsJson) ? acl.organizationsJson : null
|
|
45
|
+
if (!organizations || organizations.length === 0 || organizations.includes('__all__')) return true
|
|
46
|
+
return organizations.some((organizationId) => scope.has(organizationId))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Principal eligibility — which roles may be selected as, or resolved into, an explicit share
|
|
51
|
+
* target — follows the human-principal deny-all contract instead: an empty allowlist grants no
|
|
52
|
+
* organization reach, so such a role must not become a share principal here (#4033).
|
|
53
|
+
*
|
|
54
|
+
* This deliberately differs from `roleAclAllowsOrganization` above, which governs *feature*
|
|
55
|
+
* evaluation and keeps the wider "an empty list applies everywhere" meaning, and from the
|
|
56
|
+
* API-key projection, where an empty list means "inherit the key's own organization binding".
|
|
57
|
+
* Without this split a user who reaches an organization through a second role could inherit
|
|
58
|
+
* viewer/editor access from a share assigned to a deny-all role.
|
|
59
|
+
*/
|
|
60
|
+
export function roleAclGrantsPrincipalOrganization(
|
|
61
|
+
acl: Pick<RoleAcl, 'organizationsJson'>,
|
|
62
|
+
scope: RoleOrganizationScope,
|
|
63
|
+
): boolean {
|
|
64
|
+
if (!scope) return true
|
|
65
|
+
const organizations = Array.isArray(acl.organizationsJson) ? acl.organizationsJson : null
|
|
66
|
+
if (!organizations) return true
|
|
67
|
+
if (organizations.length === 0) return false
|
|
68
|
+
if (organizations.includes('__all__')) return true
|
|
69
|
+
return organizations.some((organizationId) => scope.has(organizationId))
|
|
70
|
+
}
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
+
import { asClass, asFunction } from 'awilix'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
1
3
|
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
|
+
import { DefaultOrganizationScopeService } from './services/organizationScopeService'
|
|
5
|
+
import { DefaultOrganizationHierarchyService } from './services/organizationHierarchyService'
|
|
6
|
+
|
|
7
|
+
type OrganizationScopeRbac = ConstructorParameters<typeof DefaultOrganizationScopeService>[1]
|
|
2
8
|
|
|
3
9
|
export function register(container: AppContainer) {
|
|
4
|
-
|
|
10
|
+
container.register({
|
|
11
|
+
organizationHierarchyService: asClass(DefaultOrganizationHierarchyService).scoped(),
|
|
12
|
+
organizationScopeService: asFunction((cradle: { em: EntityManager; rbacService: OrganizationScopeRbac }) =>
|
|
13
|
+
new DefaultOrganizationScopeService(cradle.em, cradle.rbacService, container),
|
|
14
|
+
)
|
|
15
|
+
.scoped()
|
|
16
|
+
.proxy(),
|
|
17
|
+
})
|
|
5
18
|
}
|
|
6
|
-
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { FilterQuery } from '@mikro-orm/core'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'
|
|
4
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
|
+
import { Organization } from '../data/entities'
|
|
6
|
+
|
|
7
|
+
function normalizeIds(values: unknown): string[] {
|
|
8
|
+
if (!Array.isArray(values)) return []
|
|
9
|
+
return Array.from(new Set(values
|
|
10
|
+
.map((value) => typeof value === 'string' ? value.trim() : '')
|
|
11
|
+
.filter(Boolean)))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class DefaultOrganizationHierarchyService implements OrganizationHierarchyService {
|
|
15
|
+
constructor(private readonly em: EntityManager) {}
|
|
16
|
+
|
|
17
|
+
async resolveAncestorIds(input: {
|
|
18
|
+
tenantId: string
|
|
19
|
+
organizationId: string
|
|
20
|
+
}): Promise<string[] | null> {
|
|
21
|
+
const organization = await findOneWithDecryption(
|
|
22
|
+
this.em,
|
|
23
|
+
Organization,
|
|
24
|
+
{
|
|
25
|
+
id: input.organizationId,
|
|
26
|
+
tenant: input.tenantId,
|
|
27
|
+
deletedAt: null,
|
|
28
|
+
} as FilterQuery<Organization>,
|
|
29
|
+
{ fields: ['id', 'ancestorIds'] },
|
|
30
|
+
{ tenantId: input.tenantId, organizationId: input.organizationId },
|
|
31
|
+
)
|
|
32
|
+
return organization ? normalizeIds(organization.ancestorIds) : null
|
|
33
|
+
}
|
|
34
|
+
}
|