@open-mercato/core 0.6.7-develop.6593.1.0ecf630f1e → 0.6.7-develop.6594.1.5581f765f4
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/generated/entities/custom_entity/index.js +2 -0
- package/dist/generated/entities/custom_entity/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/auth/api/features.js +14 -0
- package/dist/modules/auth/api/features.js.map +2 -2
- package/dist/modules/entities/api/entities.js +20 -0
- package/dist/modules/entities/api/entities.js.map +2 -2
- package/dist/modules/entities/api/entity-settings.js +89 -0
- package/dist/modules/entities/api/entity-settings.js.map +7 -0
- package/dist/modules/entities/api/records.js +46 -25
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/page.js +13 -4
- package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/create/page.js +27 -2
- package/dist/modules/entities/backend/entities/user/create/page.js.map +2 -2
- package/dist/modules/entities/data/entities.js +4 -0
- package/dist/modules/entities/data/entities.js.map +2 -2
- package/dist/modules/entities/lib/entityAcl.js +17 -4
- package/dist/modules/entities/lib/entityAcl.js.map +3 -3
- package/dist/modules/entities/lib/install-from-ce.js +2 -0
- package/dist/modules/entities/lib/install-from-ce.js.map +2 -2
- package/dist/modules/entities/lib/recordFeatures.js +24 -0
- package/dist/modules/entities/lib/recordFeatures.js.map +7 -0
- package/dist/modules/entities/lib/register.js +3 -0
- package/dist/modules/entities/lib/register.js.map +2 -2
- package/dist/modules/entities/lib/restrictedEntityFeatures.js +62 -0
- package/dist/modules/entities/lib/restrictedEntityFeatures.js.map +7 -0
- package/dist/modules/entities/migrations/Migration20260716120000.js +13 -0
- package/dist/modules/entities/migrations/Migration20260716120000.js.map +7 -0
- package/generated/entities/custom_entity/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/modules/auth/api/features.ts +17 -0
- package/src/modules/entities/api/entities.ts +28 -0
- package/src/modules/entities/api/entity-settings.ts +103 -0
- package/src/modules/entities/api/records.ts +70 -29
- package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +12 -3
- package/src/modules/entities/backend/entities/user/create/page.tsx +33 -1
- package/src/modules/entities/data/entities.ts +6 -0
- package/src/modules/entities/i18n/de.json +3 -0
- package/src/modules/entities/i18n/en.json +3 -0
- package/src/modules/entities/i18n/es.json +3 -0
- package/src/modules/entities/i18n/pl.json +3 -0
- package/src/modules/entities/lib/entityAcl.ts +27 -5
- package/src/modules/entities/lib/install-from-ce.ts +2 -0
- package/src/modules/entities/lib/recordFeatures.ts +48 -0
- package/src/modules/entities/lib/register.ts +4 -0
- package/src/modules/entities/lib/restrictedEntityFeatures.ts +98 -0
- package/src/modules/entities/migrations/.snapshot-open-mercato.json +10 -0
- package/src/modules/entities/migrations/Migration20260716120000.ts +13 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
2
2
|
import { hasAllFeatures } from '@open-mercato/shared/security/features'
|
|
3
3
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
4
|
+
import { deriveCustomEntityRecordFeature } from './recordFeatures'
|
|
4
5
|
|
|
5
6
|
export type EntityAclRequirement = {
|
|
6
7
|
view: string[]
|
|
@@ -80,6 +81,10 @@ type AssertEntityAclArgs = {
|
|
|
80
81
|
entityId: string
|
|
81
82
|
action: 'view' | 'manage'
|
|
82
83
|
isCustomEntity: boolean
|
|
84
|
+
// Set for a custom entity flagged `access_restricted`. When true, the coarse
|
|
85
|
+
// route-level entities.records.* feature is no longer sufficient — the caller
|
|
86
|
+
// must additionally hold the synthesized per-entity feature.
|
|
87
|
+
isRestricted?: boolean
|
|
83
88
|
rbac: RbacService
|
|
84
89
|
}
|
|
85
90
|
|
|
@@ -87,15 +92,32 @@ function forbiddenEntityAccess(): CrudHttpError {
|
|
|
87
92
|
return new CrudHttpError(403, { error: 'Forbidden' })
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
async function loadActorAcl(args: AssertEntityAclArgs) {
|
|
96
|
+
return args.rbac.loadAcl(args.auth.sub ?? '', {
|
|
97
|
+
tenantId: args.auth.tenantId ?? null,
|
|
98
|
+
organizationId: args.auth.orgId ?? null,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
90
102
|
export async function assertEntityAclForRequest(args: AssertEntityAclArgs): Promise<void> {
|
|
91
|
-
if (args.isCustomEntity)
|
|
103
|
+
if (args.isCustomEntity) {
|
|
104
|
+
// Unrestricted custom entities keep the historical behavior: the coarse
|
|
105
|
+
// entities.records.view/.manage route guard is the whole authorization.
|
|
106
|
+
if (!args.isRestricted) return
|
|
107
|
+
|
|
108
|
+
const acl = await loadActorAcl(args)
|
|
109
|
+
if (acl?.isSuperAdmin) return
|
|
110
|
+
|
|
111
|
+
const required = deriveCustomEntityRecordFeature(args.entityId, args.action)
|
|
112
|
+
if (!hasAllFeatures(acl?.features, [required])) {
|
|
113
|
+
throw forbiddenEntityAccess()
|
|
114
|
+
}
|
|
115
|
+
return
|
|
116
|
+
}
|
|
92
117
|
|
|
93
118
|
const requirement = resolveEntityAclRequirement(args.entityId)
|
|
94
119
|
|
|
95
|
-
const acl = await
|
|
96
|
-
tenantId: args.auth.tenantId ?? null,
|
|
97
|
-
organizationId: args.auth.orgId ?? null,
|
|
98
|
-
})
|
|
120
|
+
const acl = await loadActorAcl(args)
|
|
99
121
|
|
|
100
122
|
if (acl?.isSuperAdmin) return
|
|
101
123
|
|
|
@@ -148,6 +148,7 @@ function buildChecksumPayload(params: {
|
|
|
148
148
|
labelField: spec?.labelField ?? null,
|
|
149
149
|
defaultEditor: spec?.defaultEditor ?? null,
|
|
150
150
|
showInSidebar: spec?.showInSidebar ?? false,
|
|
151
|
+
accessRestricted: spec?.accessRestricted ?? false,
|
|
151
152
|
fields: fields.map((f) => normalizeField(f)),
|
|
152
153
|
}
|
|
153
154
|
}
|
|
@@ -244,6 +245,7 @@ export async function installCustomEntitiesFromModules(
|
|
|
244
245
|
organizationId: null,
|
|
245
246
|
tenantId: scope.tenantId,
|
|
246
247
|
showInSidebar: spec?.showInSidebar ?? false,
|
|
248
|
+
accessRestricted: spec?.accessRestricted ?? false,
|
|
247
249
|
labelField: spec?.labelField ?? null,
|
|
248
250
|
defaultEditor: spec?.defaultEditor ?? null,
|
|
249
251
|
isActive: true,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Pure, dependency-free helpers describing the ACL features that gate
|
|
2
|
+
// custom-entity records. Kept import-free so both the enforcement path
|
|
3
|
+
// (`entityAcl.ts`) and the feature catalog (`auth/api/features.ts`) can derive
|
|
4
|
+
// the SAME feature id without coupling to each other or to the ORM.
|
|
5
|
+
//
|
|
6
|
+
// Coarse (route-level) features stay entity-agnostic:
|
|
7
|
+
// entities.records.view — read any non-restricted custom entity's records
|
|
8
|
+
// entities.records.manage — write any non-restricted custom entity's records
|
|
9
|
+
//
|
|
10
|
+
// A custom entity flagged `access_restricted` ALSO requires a synthesized
|
|
11
|
+
// per-entity feature keyed on its immutable entityId:
|
|
12
|
+
// entities.records.<entityId>.view
|
|
13
|
+
// entities.records.<entityId>.manage
|
|
14
|
+
//
|
|
15
|
+
// entityId always matches `^[a-z0-9_]+:[a-z0-9_]+$` (it contains a `:` and no
|
|
16
|
+
// `.`), so it can never collide with the coarse `view`/`manage` segments and
|
|
17
|
+
// stays a single dotted segment for the wildcard matcher.
|
|
18
|
+
|
|
19
|
+
export type RecordsAction = 'view' | 'manage'
|
|
20
|
+
|
|
21
|
+
export const RECORDS_VIEW_FEATURE = 'entities.records.view'
|
|
22
|
+
export const RECORDS_MANAGE_FEATURE = 'entities.records.manage'
|
|
23
|
+
|
|
24
|
+
export function coarseRecordFeature(action: RecordsAction): string {
|
|
25
|
+
return action === 'manage' ? RECORDS_MANAGE_FEATURE : RECORDS_VIEW_FEATURE
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function deriveCustomEntityRecordFeature(entityId: string, action: RecordsAction): string {
|
|
29
|
+
return `entities.records.${entityId}.${action}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SynthesizedRecordFeature = {
|
|
33
|
+
id: string
|
|
34
|
+
action: RecordsAction
|
|
35
|
+
dependsOn: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The per-entity features an admin can grant for a restricted custom entity.
|
|
39
|
+
// `dependsOn` mirrors the coarse prerequisite composition: the per-entity grant
|
|
40
|
+
// is meaningful only alongside the coarse route-level feature.
|
|
41
|
+
export function synthesizedRecordFeatures(entityId: string): SynthesizedRecordFeature[] {
|
|
42
|
+
const view = deriveCustomEntityRecordFeature(entityId, 'view')
|
|
43
|
+
const manage = deriveCustomEntityRecordFeature(entityId, 'manage')
|
|
44
|
+
return [
|
|
45
|
+
{ id: view, action: 'view', dependsOn: [RECORDS_VIEW_FEATURE] },
|
|
46
|
+
{ id: manage, action: 'manage', dependsOn: [view, RECORDS_MANAGE_FEATURE] },
|
|
47
|
+
]
|
|
48
|
+
}
|
|
@@ -7,6 +7,7 @@ export type UpsertEntityOptions = {
|
|
|
7
7
|
organizationId?: string | null
|
|
8
8
|
tenantId?: string | null
|
|
9
9
|
showInSidebar?: boolean
|
|
10
|
+
accessRestricted?: boolean
|
|
10
11
|
labelField?: string | null
|
|
11
12
|
defaultEditor?: string | null
|
|
12
13
|
isActive?: boolean
|
|
@@ -29,6 +30,7 @@ export async function upsertCustomEntity(em: EntityManager, entityId: string, op
|
|
|
29
30
|
description: opts.description ?? null,
|
|
30
31
|
isActive: opts.isActive ?? true,
|
|
31
32
|
showInSidebar: !!opts.showInSidebar,
|
|
33
|
+
accessRestricted: !!opts.accessRestricted,
|
|
32
34
|
labelField: opts.labelField ?? null,
|
|
33
35
|
defaultEditor: opts.defaultEditor ?? null,
|
|
34
36
|
}
|
|
@@ -40,6 +42,7 @@ export async function upsertCustomEntity(em: EntityManager, entityId: string, op
|
|
|
40
42
|
ent.description = desired.description
|
|
41
43
|
ent.isActive = desired.isActive
|
|
42
44
|
ent.showInSidebar = desired.showInSidebar
|
|
45
|
+
ent.accessRestricted = desired.accessRestricted
|
|
43
46
|
ent.labelField = desired.labelField
|
|
44
47
|
ent.defaultEditor = desired.defaultEditor
|
|
45
48
|
ent.updatedAt = new Date()
|
|
@@ -51,6 +54,7 @@ export async function upsertCustomEntity(em: EntityManager, entityId: string, op
|
|
|
51
54
|
if ((ent.description ?? null) !== desired.description) return true
|
|
52
55
|
if ((ent.isActive ?? true) !== desired.isActive) return true
|
|
53
56
|
if ((ent.showInSidebar ?? false) !== desired.showInSidebar) return true
|
|
57
|
+
if ((ent.accessRestricted ?? false) !== desired.accessRestricted) return true
|
|
54
58
|
if ((ent.labelField ?? null) !== desired.labelField) return true
|
|
55
59
|
if ((ent.defaultEditor ?? null) !== desired.defaultEditor) return true
|
|
56
60
|
if (ent.deletedAt != null) return true
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { getModules } from '@open-mercato/shared/lib/i18n/server'
|
|
2
|
+
import { synthesizedRecordFeatures } from './recordFeatures'
|
|
3
|
+
|
|
4
|
+
export type SynthesizedFeatureItem = {
|
|
5
|
+
id: string
|
|
6
|
+
title: string
|
|
7
|
+
module: string
|
|
8
|
+
dependsOn?: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type RestrictedEntity = { entityId: string; label: string }
|
|
12
|
+
|
|
13
|
+
type DeclaredEntities = { restricted: RestrictedEntity[]; ids: Set<string> }
|
|
14
|
+
|
|
15
|
+
// Reads module-declared (ce.ts) custom entities. `restricted` holds those flagged
|
|
16
|
+
// accessRestricted; `ids` holds ALL declared ids so the DB path can defer to the
|
|
17
|
+
// declaration — the declared registry is authoritative for its own ids, matching
|
|
18
|
+
// the records-route enforcement precedence (declared wins over the DB row).
|
|
19
|
+
function declaredEntities(): DeclaredEntities {
|
|
20
|
+
const restricted: RestrictedEntity[] = []
|
|
21
|
+
const ids = new Set<string>()
|
|
22
|
+
try {
|
|
23
|
+
const mods = getModules() as Array<{
|
|
24
|
+
customEntities?: Array<{ id?: string; label?: string; accessRestricted?: boolean }>
|
|
25
|
+
}>
|
|
26
|
+
for (const mod of mods || []) {
|
|
27
|
+
for (const spec of mod?.customEntities ?? []) {
|
|
28
|
+
if (!spec?.id) continue
|
|
29
|
+
ids.add(spec.id)
|
|
30
|
+
if (spec.accessRestricted === true) restricted.push({ entityId: spec.id, label: spec.label || spec.id })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
return { restricted, ids }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Reads user-registered custom entities flagged access_restricted for the given
|
|
38
|
+
// tenant scope. Uses the raw table (not the ORM class) to keep this helper cheap
|
|
39
|
+
// and avoid pulling entity metadata into unrelated callers.
|
|
40
|
+
async function registeredRestrictedEntities(em: any, tenantId: string): Promise<RestrictedEntity[]> {
|
|
41
|
+
try {
|
|
42
|
+
const db = em.getKysely()
|
|
43
|
+
const rows = await db
|
|
44
|
+
.selectFrom('custom_entities' as any)
|
|
45
|
+
.select(['entity_id' as any, 'label' as any])
|
|
46
|
+
.where('access_restricted' as any, '=', true)
|
|
47
|
+
.where('deleted_at' as any, 'is', null as any)
|
|
48
|
+
.where((eb: any) =>
|
|
49
|
+
eb.or([
|
|
50
|
+
eb('tenant_id' as any, '=', tenantId),
|
|
51
|
+
eb('tenant_id' as any, 'is', null as any),
|
|
52
|
+
]),
|
|
53
|
+
)
|
|
54
|
+
.execute()
|
|
55
|
+
return (rows as Array<{ entity_id?: unknown; label?: unknown }>).map((row) => ({
|
|
56
|
+
entityId: String(row.entity_id ?? ''),
|
|
57
|
+
label: typeof row.label === 'string' && row.label.length ? row.label : String(row.entity_id ?? ''),
|
|
58
|
+
})).filter((row) => row.entityId.length > 0)
|
|
59
|
+
} catch {
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Feature-catalog contribution for restricted custom entities: two synthesized
|
|
66
|
+
* per-entity features (view/manage) per restricted entity in the tenant scope,
|
|
67
|
+
* so admins can grant them in the Role/User ACL editor. Fails safe (returns what
|
|
68
|
+
* it can) — never throws — so the static feature catalog is unaffected on error.
|
|
69
|
+
*/
|
|
70
|
+
export async function synthesizeRestrictedEntityFeatures(
|
|
71
|
+
em: any,
|
|
72
|
+
tenantId: string | null | undefined,
|
|
73
|
+
): Promise<SynthesizedFeatureItem[]> {
|
|
74
|
+
if (!tenantId) return []
|
|
75
|
+
const declared = declaredEntities()
|
|
76
|
+
const byEntityId = new Map<string, RestrictedEntity>()
|
|
77
|
+
for (const entity of declared.restricted) byEntityId.set(entity.entityId, entity)
|
|
78
|
+
// DB rows are authoritative only for ids the declared registry does not own, so
|
|
79
|
+
// the catalog and the records-route enforcement agree on the same source.
|
|
80
|
+
for (const entity of await registeredRestrictedEntities(em, tenantId)) {
|
|
81
|
+
if (declared.ids.has(entity.entityId)) continue
|
|
82
|
+
byEntityId.set(entity.entityId, entity)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const items: SynthesizedFeatureItem[] = []
|
|
86
|
+
for (const { entityId, label } of byEntityId.values()) {
|
|
87
|
+
for (const feature of synthesizedRecordFeatures(entityId)) {
|
|
88
|
+
const verb = feature.action === 'manage' ? 'Manage records' : 'View records'
|
|
89
|
+
items.push({
|
|
90
|
+
id: feature.id,
|
|
91
|
+
title: `${verb}: ${label}`,
|
|
92
|
+
module: 'entities',
|
|
93
|
+
dependsOn: feature.dependsOn,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return items
|
|
98
|
+
}
|
|
@@ -71,6 +71,16 @@
|
|
|
71
71
|
"default": "false",
|
|
72
72
|
"mappedType": "boolean"
|
|
73
73
|
},
|
|
74
|
+
"access_restricted": {
|
|
75
|
+
"name": "access_restricted",
|
|
76
|
+
"type": "boolean",
|
|
77
|
+
"unsigned": false,
|
|
78
|
+
"autoincrement": false,
|
|
79
|
+
"primary": false,
|
|
80
|
+
"nullable": false,
|
|
81
|
+
"default": "false",
|
|
82
|
+
"mappedType": "boolean"
|
|
83
|
+
},
|
|
74
84
|
"organization_id": {
|
|
75
85
|
"name": "organization_id",
|
|
76
86
|
"type": "uuid",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20260716120000 extends Migration {
|
|
4
|
+
|
|
5
|
+
override async up(): Promise<void> {
|
|
6
|
+
this.addSql(`alter table "custom_entities" add column "access_restricted" boolean not null default false;`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
override async down(): Promise<void> {
|
|
10
|
+
this.addSql(`alter table "custom_entities" drop column "access_restricted";`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
}
|