@open-mercato/core 0.5.1-develop.3045.b4b3320cc2 → 0.6.0
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/AGENTS.md +21 -1
- package/dist/modules/api_keys/api/keys/route.js +9 -0
- package/dist/modules/api_keys/api/keys/route.js.map +2 -2
- package/dist/modules/audit_logs/services/accessLogService.js +13 -0
- package/dist/modules/audit_logs/services/accessLogService.js.map +3 -3
- package/dist/modules/audit_logs/services/actionLogService.js +6 -5
- package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
- package/dist/modules/auth/api/roles/acl/route.js +27 -37
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +41 -28
- package/dist/modules/auth/api/users/route.js.map +3 -3
- package/dist/modules/auth/lib/grantChecks.js +160 -0
- package/dist/modules/auth/lib/grantChecks.js.map +7 -0
- package/dist/modules/configs/cli.js +11 -0
- package/dist/modules/configs/cli.js.map +2 -2
- package/dist/modules/configs/lib/touchGeneratedBarrels.js +46 -0
- package/dist/modules/configs/lib/touchGeneratedBarrels.js.map +7 -0
- package/dist/modules/customers/api/activities/route.js +1 -52
- package/dist/modules/customers/api/activities/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/counts/route.js +2 -1
- package/dist/modules/customers/api/interactions/counts/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/route.js +21 -1
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +7 -3
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +5 -1
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +7 -3
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesCard.js +62 -6
- package/dist/modules/customers/components/detail/ActivitiesCard.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +21 -6
- package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesSection.js +37 -5
- package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityCard.js +69 -17
- package/dist/modules/customers/components/detail/ActivityCard.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityHistorySection.js +94 -34
- package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityLogTab.js +3 -1
- package/dist/modules/customers/components/detail/ActivityLogTab.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityTimeline.js +41 -8
- package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityTimelineFilters.js +19 -6
- package/dist/modules/customers/components/detail/ActivityTimelineFilters.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityTypeSelector.js +4 -3
- package/dist/modules/customers/components/detail/ActivityTypeSelector.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +80 -12
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +65 -10
- package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +10 -5
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
- package/dist/modules/customers/data/validators.js +74 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/legacyActivityBridge.js +61 -0
- package/dist/modules/customers/lib/legacyActivityBridge.js.map +7 -0
- package/dist/modules/integrations/data/validators.js +2 -2
- package/dist/modules/integrations/data/validators.js.map +2 -2
- package/dist/modules/integrations/lib/credentials-service.js +12 -1
- package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
- package/dist/modules/messages/commands/actions.js +29 -14
- package/dist/modules/messages/commands/actions.js.map +2 -2
- package/dist/modules/messages/lib/actions.js +24 -4
- package/dist/modules/messages/lib/actions.js.map +2 -2
- package/dist/modules/sales/api/documents/factory.js +49 -36
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/package.json +9 -10
- package/src/modules/api_keys/api/keys/route.ts +9 -0
- package/src/modules/audit_logs/services/accessLogService.ts +20 -0
- package/src/modules/audit_logs/services/actionLogService.ts +13 -5
- package/src/modules/auth/api/roles/acl/route.ts +32 -46
- package/src/modules/auth/api/users/route.ts +48 -33
- package/src/modules/auth/lib/grantChecks.ts +234 -0
- package/src/modules/configs/cli.ts +11 -0
- package/src/modules/configs/lib/touchGeneratedBarrels.ts +61 -0
- package/src/modules/customers/api/activities/route.ts +1 -76
- package/src/modules/customers/api/interactions/counts/route.ts +2 -1
- package/src/modules/customers/api/interactions/route.ts +28 -1
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +13 -3
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +14 -2
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +13 -3
- package/src/modules/customers/components/detail/ActivitiesCard.tsx +92 -5
- package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +38 -6
- package/src/modules/customers/components/detail/ActivitiesSection.tsx +37 -3
- package/src/modules/customers/components/detail/ActivityCard.tsx +79 -14
- package/src/modules/customers/components/detail/ActivityHistorySection.tsx +102 -33
- package/src/modules/customers/components/detail/ActivityLogTab.tsx +7 -1
- package/src/modules/customers/components/detail/ActivityTimeline.tsx +39 -5
- package/src/modules/customers/components/detail/ActivityTimelineFilters.tsx +29 -7
- package/src/modules/customers/components/detail/ActivityTypeSelector.tsx +3 -2
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +96 -13
- package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +50 -4
- package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +21 -5
- package/src/modules/customers/data/validators.ts +85 -2
- package/src/modules/customers/i18n/de.json +11 -0
- package/src/modules/customers/i18n/en.json +11 -0
- package/src/modules/customers/i18n/es.json +11 -0
- package/src/modules/customers/i18n/pl.json +11 -0
- package/src/modules/customers/lib/legacyActivityBridge.ts +106 -0
- package/src/modules/integrations/data/validators.ts +8 -6
- package/src/modules/integrations/lib/credentials-service.ts +15 -1
- package/src/modules/messages/commands/actions.ts +28 -13
- package/src/modules/messages/lib/actions.ts +34 -3
- package/src/modules/sales/api/documents/factory.ts +55 -38
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type AccessLogListQuery,
|
|
9
9
|
} from '@open-mercato/core/modules/audit_logs/data/validators'
|
|
10
10
|
import { resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
|
|
11
|
+
import { parseDecryptedFieldValue } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
|
|
11
12
|
import { E } from '#generated/entities.ids.generated'
|
|
12
13
|
|
|
13
14
|
const CORE_RESOURCE_KINDS = new Set<string>(['auth.user', 'auth.role'])
|
|
@@ -190,6 +191,25 @@ export class AccessLogService {
|
|
|
190
191
|
},
|
|
191
192
|
)
|
|
192
193
|
|
|
194
|
+
// Encrypted jsonb columns (`fields_json`, `context_json`) come back as raw
|
|
195
|
+
// JSON strings from the encryption subscriber after issue #1810 follow-up
|
|
196
|
+
// (entity-field decryption no longer auto-parses). Restore the structured
|
|
197
|
+
// shape on read so API consumers see typed objects/arrays.
|
|
198
|
+
for (const item of items) {
|
|
199
|
+
const rawFieldsJson = (item as { fieldsJson?: unknown }).fieldsJson
|
|
200
|
+
if (typeof rawFieldsJson === 'string') {
|
|
201
|
+
const parsed = parseDecryptedFieldValue(rawFieldsJson)
|
|
202
|
+
item.fieldsJson = Array.isArray(parsed) ? (parsed as string[]) : null
|
|
203
|
+
}
|
|
204
|
+
const rawContextJson = (item as { contextJson?: unknown }).contextJson
|
|
205
|
+
if (typeof rawContextJson === 'string') {
|
|
206
|
+
const parsed = parseDecryptedFieldValue(rawContextJson)
|
|
207
|
+
item.contextJson = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
208
|
+
? (parsed as Record<string, unknown>)
|
|
209
|
+
: null
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
193
213
|
const totalPages = Math.max(1, Math.ceil((total || 0) / (pageSize || 1)))
|
|
194
214
|
return { items, total, page, pageSize, totalPages }
|
|
195
215
|
}
|
|
@@ -151,15 +151,23 @@ export class ActionLogService {
|
|
|
151
151
|
...decrypted,
|
|
152
152
|
} as Record<string, unknown>
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
// Audit log jsonb columns are encrypted as JSON-stringified payloads. After the
|
|
155
|
+
// service-level `decryptEntityPayload` call (which now returns raw strings —
|
|
156
|
+
// see issue #1810 follow-up), reattach the structured shape via
|
|
157
|
+
// `parseDecryptedFieldValue` before running the recursive `deepDecrypt` walk
|
|
158
|
+
// so nested encrypted strings inside payload objects/arrays are handled.
|
|
159
|
+
const restoreJson = (value: unknown): unknown =>
|
|
160
|
+
typeof value === 'string' ? parseDecryptedFieldValue(value) : value
|
|
161
|
+
|
|
162
|
+
merged.changesJson = deepDecrypt(restoreJson(merged.changesJson ?? merged.changes_json ?? entry.changesJson ?? entry.changes_json))
|
|
155
163
|
merged.changes_json = merged.changesJson
|
|
156
|
-
merged.snapshotBefore = deepDecrypt(merged.snapshotBefore ?? merged.snapshot_before ?? entry.snapshotBefore ?? entry.snapshot_before)
|
|
164
|
+
merged.snapshotBefore = deepDecrypt(restoreJson(merged.snapshotBefore ?? merged.snapshot_before ?? entry.snapshotBefore ?? entry.snapshot_before))
|
|
157
165
|
merged.snapshot_before = merged.snapshotBefore
|
|
158
|
-
merged.snapshotAfter = deepDecrypt(merged.snapshotAfter ?? merged.snapshot_after ?? entry.snapshotAfter ?? entry.snapshot_after)
|
|
166
|
+
merged.snapshotAfter = deepDecrypt(restoreJson(merged.snapshotAfter ?? merged.snapshot_after ?? entry.snapshotAfter ?? entry.snapshot_after))
|
|
159
167
|
merged.snapshot_after = merged.snapshotAfter
|
|
160
|
-
merged.commandPayload = deepDecrypt(merged.commandPayload ?? merged.command_payload ?? entry.commandPayload ?? entry.command_payload)
|
|
168
|
+
merged.commandPayload = deepDecrypt(restoreJson(merged.commandPayload ?? merged.command_payload ?? entry.commandPayload ?? entry.command_payload))
|
|
161
169
|
merged.command_payload = merged.commandPayload
|
|
162
|
-
merged.contextJson = deepDecrypt(merged.contextJson ?? merged.context_json ?? entry.contextJson ?? entry.context_json)
|
|
170
|
+
merged.contextJson = deepDecrypt(restoreJson(merged.contextJson ?? merged.context_json ?? entry.contextJson ?? entry.context_json))
|
|
163
171
|
merged.context_json = merged.contextJson
|
|
164
172
|
|
|
165
173
|
return merged as T
|
|
@@ -4,11 +4,12 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
6
|
import { logCrudAccess } from '@open-mercato/shared/lib/crud/factory'
|
|
7
|
-
import {
|
|
7
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
8
8
|
import { RoleAcl, Role } from '@open-mercato/core/modules/auth/data/entities'
|
|
9
9
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
10
10
|
import { resolveIsSuperAdmin } from '@open-mercato/core/modules/auth/lib/tenantAccess'
|
|
11
11
|
import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
12
|
+
import { assertActorCanGrantAcl, normalizeGrantFeatureList } from '@open-mercato/core/modules/auth/lib/grantChecks'
|
|
12
13
|
|
|
13
14
|
type TaggableCache = { deleteByTags?: (tags: string[]) => Promise<void> | void }
|
|
14
15
|
|
|
@@ -132,12 +133,6 @@ export async function PUT(req: Request) {
|
|
|
132
133
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
const actorAcl = auth.sub
|
|
136
|
-
? await rbacService.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
|
|
137
|
-
: null
|
|
138
|
-
const actorIsSuperAdmin = !!actorAcl?.isSuperAdmin
|
|
139
|
-
|
|
140
|
-
const requestedFeatures = normalizeFeatureList(parsed.data.features)
|
|
141
136
|
let acl = await em.findOne(RoleAcl, { role, tenantId: targetTenantId })
|
|
142
137
|
if (!acl) {
|
|
143
138
|
acl = em.create(RoleAcl, {
|
|
@@ -149,27 +144,35 @@ export async function PUT(req: Request) {
|
|
|
149
144
|
}
|
|
150
145
|
|
|
151
146
|
const existingIsSuperAdmin = !!acl.isSuperAdmin
|
|
147
|
+
const existingFeatures = normalizeGrantFeatureList(acl.featuresJson)
|
|
148
|
+
const existingOrganizations = normalizeOrganizations(acl.organizationsJson)
|
|
152
149
|
const requestedIsSuperAdmin = parsed.data.isSuperAdmin ?? existingIsSuperAdmin
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
150
|
+
const requestedFeatures = parsed.data.features === undefined
|
|
151
|
+
? existingFeatures
|
|
152
|
+
: normalizeGrantFeatureList(parsed.data.features)
|
|
153
|
+
const requestedOrganizations = parsed.data.organizations === undefined
|
|
154
|
+
? existingOrganizations
|
|
155
|
+
: normalizeOrganizations(parsed.data.organizations)
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await assertActorCanGrantAcl({
|
|
159
|
+
em,
|
|
160
|
+
rbacService,
|
|
161
|
+
actorUserId: auth.sub,
|
|
162
|
+
tenantId: targetTenantId,
|
|
163
|
+
organizationId: auth.orgId ?? null,
|
|
164
|
+
isSuperAdmin: requestedIsSuperAdmin,
|
|
165
|
+
features: requestedFeatures,
|
|
166
|
+
organizations: requestedOrganizations,
|
|
167
|
+
})
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
|
|
170
|
+
throw err
|
|
164
171
|
}
|
|
165
172
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (parsed.data.organizations !== undefined) acl.organizationsJson = parsed.data.organizations
|
|
171
|
-
acl.isSuperAdmin = effectiveIsSuperAdmin
|
|
172
|
-
acl.featuresJson = effectiveFeatures
|
|
173
|
+
acl.organizationsJson = requestedOrganizations
|
|
174
|
+
acl.isSuperAdmin = requestedIsSuperAdmin
|
|
175
|
+
acl.featuresJson = requestedFeatures
|
|
173
176
|
await em.persist(acl).flush()
|
|
174
177
|
|
|
175
178
|
// Invalidate cache for all users in this tenant since role ACL changed
|
|
@@ -184,30 +187,13 @@ export async function PUT(req: Request) {
|
|
|
184
187
|
|
|
185
188
|
return NextResponse.json({
|
|
186
189
|
ok: true,
|
|
187
|
-
sanitized:
|
|
190
|
+
sanitized: false,
|
|
188
191
|
})
|
|
189
192
|
}
|
|
190
193
|
|
|
191
|
-
function
|
|
192
|
-
if (!Array.isArray(
|
|
193
|
-
|
|
194
|
-
for (const value of features) {
|
|
195
|
-
if (typeof value !== 'string') continue
|
|
196
|
-
const trimmed = value.trim()
|
|
197
|
-
if (!trimmed) continue
|
|
198
|
-
dedup.add(trimmed)
|
|
199
|
-
}
|
|
200
|
-
return Array.from(dedup)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function sanitizeTenantFeatures(features: string[]): string[] {
|
|
204
|
-
return features.filter((feature) => !isTenantRestrictedFeature(feature))
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function isTenantRestrictedFeature(feature: string): boolean {
|
|
208
|
-
if (feature === '*' || feature === 'directory.*') return true
|
|
209
|
-
if (feature.startsWith('directory.tenants')) return true
|
|
210
|
-
return false
|
|
194
|
+
function normalizeOrganizations(organizations: unknown): string[] | null {
|
|
195
|
+
if (!Array.isArray(organizations)) return null
|
|
196
|
+
return normalizeGrantFeatureList(organizations)
|
|
211
197
|
}
|
|
212
198
|
|
|
213
199
|
export const openApi: OpenApiRouteDoc = {
|
|
@@ -3,17 +3,18 @@ import { NextResponse } from 'next/server'
|
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
5
5
|
import { logCrudAccess, makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
6
|
-
import {
|
|
6
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
7
7
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
8
8
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
9
9
|
import { User, Role, UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
10
|
-
import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
10
|
+
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
11
11
|
import { Organization, Tenant } from '@open-mercato/core/modules/directory/data/entities'
|
|
12
12
|
import { E } from '#generated/entities.ids.generated'
|
|
13
13
|
import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
14
14
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
15
15
|
import { userCrudEvents, userCrudIndexer } from '@open-mercato/core/modules/auth/commands/users'
|
|
16
|
-
import {
|
|
16
|
+
import { assertActorCanGrantRoleTokens } from '@open-mercato/core/modules/auth/lib/grantChecks'
|
|
17
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
17
18
|
import { buildPasswordSchema } from '@open-mercato/shared/lib/auth/passwordPolicy'
|
|
18
19
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
19
20
|
import { resolveSearchConfig } from '@open-mercato/shared/lib/search/config'
|
|
@@ -102,7 +103,7 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
|
|
|
102
103
|
schema: rawBodySchema,
|
|
103
104
|
mapInput: async ({ parsed, ctx }) => {
|
|
104
105
|
if (ctx.request) {
|
|
105
|
-
await assertCanAssignRoles(ctx.request, parsed.roles)
|
|
106
|
+
await assertCanAssignRoles(ctx.request, parsed.roles, parsed)
|
|
106
107
|
}
|
|
107
108
|
return parsed
|
|
108
109
|
},
|
|
@@ -117,7 +118,7 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
|
|
|
117
118
|
schema: rawBodySchema,
|
|
118
119
|
mapInput: async ({ parsed, ctx }) => {
|
|
119
120
|
if (ctx.request) {
|
|
120
|
-
await assertCanAssignRoles(ctx.request, parsed.roles)
|
|
121
|
+
await assertCanAssignRoles(ctx.request, parsed.roles, parsed)
|
|
121
122
|
}
|
|
122
123
|
return parsed
|
|
123
124
|
},
|
|
@@ -371,14 +372,10 @@ export async function GET(req: Request) {
|
|
|
371
372
|
}
|
|
372
373
|
|
|
373
374
|
export const POST = async (req: Request) => {
|
|
374
|
-
const body = await req.clone().json().catch(() => ({}))
|
|
375
|
-
await assertCanAssignRoles(req, body?.roles)
|
|
376
375
|
return crud.POST(req)
|
|
377
376
|
}
|
|
378
377
|
|
|
379
378
|
export const PUT = async (req: Request) => {
|
|
380
|
-
const body = await req.clone().json().catch(() => ({}))
|
|
381
|
-
await assertCanAssignRoles(req, body?.roles)
|
|
382
379
|
return crud.PUT(req)
|
|
383
380
|
}
|
|
384
381
|
|
|
@@ -414,35 +411,53 @@ async function findUserIdsBySearchTokens(
|
|
|
414
411
|
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
415
412
|
}
|
|
416
413
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
async function assertCanAssignRoles(req: Request, roles: unknown) {
|
|
414
|
+
async function assertCanAssignRoles(req: Request, roles: unknown, payload: Record<string, unknown>) {
|
|
420
415
|
if (!Array.isArray(roles)) return
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
416
|
+
const auth = await getAuthFromRequest(req)
|
|
417
|
+
if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
|
|
418
|
+
const container = await createRequestContainer()
|
|
419
|
+
const em = container.resolve('em') as EntityManager
|
|
420
|
+
const tenantId = await resolveTargetTenantIdForRoleGrant(em, payload, auth.tenantId ?? null)
|
|
421
|
+
await assertActorCanGrantRoleTokens({
|
|
422
|
+
em,
|
|
423
|
+
rbacService: container.resolve('rbacService') as RbacService,
|
|
424
|
+
actorUserId: auth.sub,
|
|
425
|
+
tenantId,
|
|
426
|
+
organizationId: auth.orgId ?? null,
|
|
427
|
+
roleTokens: roles,
|
|
428
|
+
})
|
|
429
|
+
}
|
|
425
430
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
431
|
+
async function resolveTargetTenantIdForRoleGrant(
|
|
432
|
+
em: EntityManager,
|
|
433
|
+
payload: Record<string, unknown>,
|
|
434
|
+
fallbackTenantId: string | null,
|
|
435
|
+
): Promise<string | null> {
|
|
436
|
+
const organizationId = typeof payload.organizationId === 'string' ? payload.organizationId : null
|
|
437
|
+
if (organizationId) {
|
|
438
|
+
const organization = await findOneWithDecryption(
|
|
439
|
+
em,
|
|
440
|
+
Organization,
|
|
441
|
+
{ id: organizationId },
|
|
442
|
+
{ populate: ['tenant'] },
|
|
443
|
+
{ tenantId: null, organizationId },
|
|
444
|
+
)
|
|
445
|
+
return organization?.tenant?.id ? String(organization.tenant.id) : fallbackTenantId
|
|
435
446
|
}
|
|
436
|
-
if (!hasSuperAdmin) return
|
|
437
447
|
|
|
438
|
-
const
|
|
439
|
-
if (
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
448
|
+
const userId = typeof payload.id === 'string' ? payload.id : null
|
|
449
|
+
if (userId) {
|
|
450
|
+
const user = await findOneWithDecryption(
|
|
451
|
+
em,
|
|
452
|
+
User,
|
|
453
|
+
{ id: userId, deletedAt: null },
|
|
454
|
+
{},
|
|
455
|
+
{ tenantId: null, organizationId: null },
|
|
456
|
+
)
|
|
457
|
+
return user?.tenantId ? String(user.tenantId) : fallbackTenantId
|
|
445
458
|
}
|
|
459
|
+
|
|
460
|
+
return fallbackTenantId
|
|
446
461
|
}
|
|
447
462
|
|
|
448
463
|
export const openApi: OpenApiRouteDoc = {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
|
|
2
|
+
import { CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
|
|
3
|
+
import { hasFeature } from '@open-mercato/shared/security/features'
|
|
4
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
|
+
import { Role, RoleAcl } from '@open-mercato/core/modules/auth/data/entities'
|
|
6
|
+
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
7
|
+
|
|
8
|
+
type ActorAcl = {
|
|
9
|
+
isSuperAdmin: boolean
|
|
10
|
+
features: string[]
|
|
11
|
+
organizations: string[] | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type GrantCheckContext = {
|
|
15
|
+
em: EntityManager
|
|
16
|
+
rbacService: RbacService
|
|
17
|
+
actorUserId: string | null | undefined
|
|
18
|
+
tenantId: string | null | undefined
|
|
19
|
+
organizationId?: string | null | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type RoleGrantCheckInput = GrantCheckContext & {
|
|
23
|
+
roles: Role[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type RoleTokenGrantCheckInput = GrantCheckContext & {
|
|
27
|
+
roleTokens: unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type FeatureGrantCheckInput = GrantCheckContext & {
|
|
31
|
+
features: unknown
|
|
32
|
+
isSuperAdmin?: boolean
|
|
33
|
+
organizations?: string[] | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
37
|
+
|
|
38
|
+
export async function assertActorCanGrantRoleTokens(input: RoleTokenGrantCheckInput): Promise<Role[]> {
|
|
39
|
+
const tokens = normalizeStringList(input.roleTokens)
|
|
40
|
+
if (!tokens.length) return []
|
|
41
|
+
|
|
42
|
+
const tenantId = normalizeNullableString(input.tenantId)
|
|
43
|
+
const roles = await resolveRolesForGrant(input.em, tokens, tenantId)
|
|
44
|
+
await assertActorCanGrantRoles({ ...input, tenantId, roles })
|
|
45
|
+
return roles
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function assertActorCanGrantRoles(input: RoleGrantCheckInput): Promise<void> {
|
|
49
|
+
if (!input.roles.length) return
|
|
50
|
+
|
|
51
|
+
const tenantId = normalizeNullableString(input.tenantId)
|
|
52
|
+
const actorAcl = await loadActorAcl({ ...input, tenantId })
|
|
53
|
+
if (actorAcl.isSuperAdmin) return
|
|
54
|
+
|
|
55
|
+
if (!tenantId) {
|
|
56
|
+
throw forbidden('Tenant context is required to grant roles.')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const role of input.roles) {
|
|
60
|
+
const roleTenantId = normalizeNullableString(role.tenantId)
|
|
61
|
+
if (roleTenantId !== tenantId) {
|
|
62
|
+
throw forbidden('Cannot grant a role outside the target tenant.')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const acl = await findOneWithDecryption(
|
|
66
|
+
input.em,
|
|
67
|
+
RoleAcl,
|
|
68
|
+
{ role, tenantId } as FilterQuery<RoleAcl>,
|
|
69
|
+
{},
|
|
70
|
+
{ tenantId, organizationId: null },
|
|
71
|
+
)
|
|
72
|
+
if (!acl) continue
|
|
73
|
+
|
|
74
|
+
assertActorCanGrantAclSnapshot(actorAcl, {
|
|
75
|
+
isSuperAdmin: !!acl.isSuperAdmin,
|
|
76
|
+
features: normalizeStringList(acl.featuresJson),
|
|
77
|
+
organizations: normalizeOrganizationList(acl.organizationsJson),
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function assertActorCanGrantAcl(input: FeatureGrantCheckInput): Promise<void> {
|
|
83
|
+
const actorAcl = await loadActorAcl(input)
|
|
84
|
+
if (actorAcl.isSuperAdmin) return
|
|
85
|
+
|
|
86
|
+
const tenantId = normalizeNullableString(input.tenantId)
|
|
87
|
+
if (!tenantId) {
|
|
88
|
+
throw forbidden('Tenant context is required to grant ACL features.')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
assertActorCanGrantAclSnapshot(actorAcl, {
|
|
92
|
+
isSuperAdmin: !!input.isSuperAdmin,
|
|
93
|
+
features: normalizeStringList(input.features),
|
|
94
|
+
organizations: input.organizations === undefined ? undefined : normalizeOrganizationList(input.organizations),
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function normalizeGrantFeatureList(features: unknown): string[] {
|
|
99
|
+
return normalizeStringList(features)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function loadActorAcl(input: GrantCheckContext): Promise<ActorAcl> {
|
|
103
|
+
const actorUserId = normalizeNullableString(input.actorUserId)
|
|
104
|
+
if (!actorUserId) throw forbidden('Not authorized to grant ACL privileges.')
|
|
105
|
+
|
|
106
|
+
const acl = await input.rbacService.loadAcl(actorUserId, {
|
|
107
|
+
tenantId: normalizeNullableString(input.tenantId),
|
|
108
|
+
organizationId: normalizeNullableString(input.organizationId),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
isSuperAdmin: !!acl?.isSuperAdmin,
|
|
113
|
+
features: normalizeStringList(acl?.features),
|
|
114
|
+
organizations: normalizeOrganizationList(acl?.organizations),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function resolveRolesForGrant(
|
|
119
|
+
em: EntityManager,
|
|
120
|
+
roleTokens: string[],
|
|
121
|
+
tenantId: string | null,
|
|
122
|
+
): Promise<Role[]> {
|
|
123
|
+
const roles: Role[] = []
|
|
124
|
+
const missingRoles: string[] = []
|
|
125
|
+
|
|
126
|
+
for (const token of roleTokens) {
|
|
127
|
+
const role = await resolveRoleForGrant(em, token, tenantId)
|
|
128
|
+
if (!role) {
|
|
129
|
+
missingRoles.push(token)
|
|
130
|
+
} else {
|
|
131
|
+
roles.push(role)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (missingRoles.length) {
|
|
136
|
+
const labels = missingRoles.map((role) => `"${role}"`).join(', ')
|
|
137
|
+
throw new CrudHttpError(400, { error: `Role(s) not found: ${labels}` })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return roles
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function resolveRoleForGrant(
|
|
144
|
+
em: EntityManager,
|
|
145
|
+
token: string,
|
|
146
|
+
tenantId: string | null,
|
|
147
|
+
): Promise<Role | null> {
|
|
148
|
+
const where: Record<string, unknown> = UUID_RE.test(token)
|
|
149
|
+
? { id: token, deletedAt: null }
|
|
150
|
+
: { name: token, deletedAt: null }
|
|
151
|
+
if (tenantId) where.tenantId = tenantId
|
|
152
|
+
return findOneWithDecryption(
|
|
153
|
+
em,
|
|
154
|
+
Role,
|
|
155
|
+
where as FilterQuery<Role>,
|
|
156
|
+
{},
|
|
157
|
+
{ tenantId, organizationId: null },
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function assertActorCanGrantAclSnapshot(
|
|
162
|
+
actorAcl: ActorAcl,
|
|
163
|
+
requested: {
|
|
164
|
+
isSuperAdmin: boolean
|
|
165
|
+
features: string[]
|
|
166
|
+
organizations?: string[] | null
|
|
167
|
+
},
|
|
168
|
+
): void {
|
|
169
|
+
if (requested.isSuperAdmin) {
|
|
170
|
+
throw forbidden('Only super administrators can grant super admin access.')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const actorGrantableFeatures = actorAcl.features.filter((grant) => grant !== '*')
|
|
174
|
+
for (const feature of requested.features) {
|
|
175
|
+
if (feature === '*') {
|
|
176
|
+
throw forbidden('Only super administrators can grant global wildcard access.')
|
|
177
|
+
}
|
|
178
|
+
if (isWildcardFeature(feature)) {
|
|
179
|
+
if (!hasFeature(actorGrantableFeatures, feature)) {
|
|
180
|
+
throw forbidden(`Cannot grant feature wildcard ${feature}.`)
|
|
181
|
+
}
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (!hasFeature(actorGrantableFeatures, feature)) {
|
|
185
|
+
throw forbidden(`Cannot grant feature ${feature}.`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (requested.organizations !== undefined) {
|
|
190
|
+
assertActorCanGrantOrganizations(actorAcl.organizations, requested.organizations)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function assertActorCanGrantOrganizations(
|
|
195
|
+
actorOrganizations: string[] | null,
|
|
196
|
+
requestedOrganizations: string[] | null,
|
|
197
|
+
): void {
|
|
198
|
+
if (actorOrganizations === null || actorOrganizations.includes('__all__')) return
|
|
199
|
+
|
|
200
|
+
if (requestedOrganizations === null || requestedOrganizations.includes('__all__')) {
|
|
201
|
+
throw forbidden('Cannot grant unrestricted organization access.')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const organizationId of requestedOrganizations) {
|
|
205
|
+
if (!actorOrganizations.includes(organizationId)) {
|
|
206
|
+
throw forbidden('Cannot grant organization access outside actor scope.')
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeStringList(values: unknown): string[] {
|
|
212
|
+
if (!Array.isArray(values)) return []
|
|
213
|
+
const dedup = new Set<string>()
|
|
214
|
+
for (const value of values) {
|
|
215
|
+
if (typeof value !== 'string') continue
|
|
216
|
+
const trimmed = value.trim()
|
|
217
|
+
if (!trimmed) continue
|
|
218
|
+
dedup.add(trimmed)
|
|
219
|
+
}
|
|
220
|
+
return Array.from(dedup)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeOrganizationList(values: unknown): string[] | null {
|
|
224
|
+
if (values === null || values === undefined) return null
|
|
225
|
+
return normalizeStringList(values)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normalizeNullableString(value: unknown): string | null {
|
|
229
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isWildcardFeature(feature: string): boolean {
|
|
233
|
+
return feature.endsWith('.*')
|
|
234
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
previewCachePurge,
|
|
13
13
|
type CachePurgeRequest,
|
|
14
14
|
} from './lib/cache-cli'
|
|
15
|
+
import { touchGeneratedBarrels } from './lib/touchGeneratedBarrels'
|
|
15
16
|
|
|
16
17
|
type ParsedArgs = Record<string, string | boolean>
|
|
17
18
|
|
|
@@ -256,6 +257,16 @@ async function runStructuralCachePurge(args: ParsedArgs) {
|
|
|
256
257
|
pattern: 'nav:*',
|
|
257
258
|
}
|
|
258
259
|
await runCachePurge(nextArgs)
|
|
260
|
+
const quiet = flagEnabled(args, 'quiet')
|
|
261
|
+
try {
|
|
262
|
+
touchGeneratedBarrels({ quiet })
|
|
263
|
+
} catch (err) {
|
|
264
|
+
if (!quiet) {
|
|
265
|
+
console.warn(
|
|
266
|
+
`[structural] failed to touch generated barrels: ${(err as Error).message ?? err}`,
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
259
270
|
}
|
|
260
271
|
|
|
261
272
|
function envDisablesAutoIndexing(): boolean {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const GENERATED_DIR_RELATIVE = path.join('.mercato', 'generated')
|
|
5
|
+
const TOUCHABLE_PATTERN = /\.generated(?:\.[a-z0-9]+)?(?:\.ts|\.checksum)$/i
|
|
6
|
+
const MAX_PARENT_WALK = 5
|
|
7
|
+
|
|
8
|
+
export type TouchGeneratedBarrelsOptions = {
|
|
9
|
+
cwd?: string
|
|
10
|
+
quiet?: boolean
|
|
11
|
+
log?: (message: string) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type TouchGeneratedBarrelsResult = {
|
|
15
|
+
generatedDir: string | null
|
|
16
|
+
files: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function findGeneratedDir(startDir: string): string | null {
|
|
20
|
+
let current = path.resolve(startDir)
|
|
21
|
+
for (let depth = 0; depth <= MAX_PARENT_WALK; depth += 1) {
|
|
22
|
+
const candidate = path.join(current, GENERATED_DIR_RELATIVE)
|
|
23
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
|
24
|
+
return candidate
|
|
25
|
+
}
|
|
26
|
+
const parent = path.dirname(current)
|
|
27
|
+
if (parent === current) break
|
|
28
|
+
current = parent
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function touchGeneratedBarrels(
|
|
34
|
+
options: TouchGeneratedBarrelsOptions = {},
|
|
35
|
+
): TouchGeneratedBarrelsResult {
|
|
36
|
+
const cwd = options.cwd ?? process.cwd()
|
|
37
|
+
const log = options.log ?? ((message: string) => console.log(message))
|
|
38
|
+
const quiet = options.quiet === true
|
|
39
|
+
|
|
40
|
+
const generatedDir = findGeneratedDir(cwd)
|
|
41
|
+
if (!generatedDir) {
|
|
42
|
+
return { generatedDir: null, files: [] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const touched: string[] = []
|
|
46
|
+
const entries = fs.readdirSync(generatedDir, { withFileTypes: true })
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (!entry.isFile()) continue
|
|
49
|
+
if (!TOUCHABLE_PATTERN.test(entry.name)) continue
|
|
50
|
+
const filePath = path.join(generatedDir, entry.name)
|
|
51
|
+
const contents = fs.readFileSync(filePath)
|
|
52
|
+
fs.writeFileSync(filePath, contents)
|
|
53
|
+
touched.push(filePath)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!quiet && touched.length > 0) {
|
|
57
|
+
log(`🔁 [structural] touched ${touched.length} generated barrel(s) → ${generatedDir}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { generatedDir, files: touched }
|
|
61
|
+
}
|