@open-mercato/core 0.6.7-develop.6592.1.7f69fa2454 → 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.
Files changed (56) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/custom_entity/index.js +2 -0
  3. package/dist/generated/entities/custom_entity/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  7. package/dist/modules/attachments/lib/imageSafety.js.map +2 -2
  8. package/dist/modules/auth/api/features.js +14 -0
  9. package/dist/modules/auth/api/features.js.map +2 -2
  10. package/dist/modules/entities/api/entities.js +20 -0
  11. package/dist/modules/entities/api/entities.js.map +2 -2
  12. package/dist/modules/entities/api/entity-settings.js +89 -0
  13. package/dist/modules/entities/api/entity-settings.js.map +7 -0
  14. package/dist/modules/entities/api/records.js +46 -25
  15. package/dist/modules/entities/api/records.js.map +2 -2
  16. package/dist/modules/entities/backend/entities/user/[entityId]/page.js +13 -4
  17. package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
  18. package/dist/modules/entities/backend/entities/user/create/page.js +27 -2
  19. package/dist/modules/entities/backend/entities/user/create/page.js.map +2 -2
  20. package/dist/modules/entities/data/entities.js +4 -0
  21. package/dist/modules/entities/data/entities.js.map +2 -2
  22. package/dist/modules/entities/lib/entityAcl.js +17 -4
  23. package/dist/modules/entities/lib/entityAcl.js.map +3 -3
  24. package/dist/modules/entities/lib/install-from-ce.js +2 -0
  25. package/dist/modules/entities/lib/install-from-ce.js.map +2 -2
  26. package/dist/modules/entities/lib/recordFeatures.js +24 -0
  27. package/dist/modules/entities/lib/recordFeatures.js.map +7 -0
  28. package/dist/modules/entities/lib/register.js +3 -0
  29. package/dist/modules/entities/lib/register.js.map +2 -2
  30. package/dist/modules/entities/lib/restrictedEntityFeatures.js +62 -0
  31. package/dist/modules/entities/lib/restrictedEntityFeatures.js.map +7 -0
  32. package/dist/modules/entities/migrations/Migration20260716120000.js +13 -0
  33. package/dist/modules/entities/migrations/Migration20260716120000.js.map +7 -0
  34. package/generated/entities/custom_entity/index.ts +1 -0
  35. package/generated/entity-fields-registry.ts +1 -0
  36. package/package.json +8 -7
  37. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +2 -2
  38. package/src/modules/attachments/lib/imageSafety.ts +2 -2
  39. package/src/modules/auth/api/features.ts +17 -0
  40. package/src/modules/entities/api/entities.ts +28 -0
  41. package/src/modules/entities/api/entity-settings.ts +103 -0
  42. package/src/modules/entities/api/records.ts +70 -29
  43. package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +12 -3
  44. package/src/modules/entities/backend/entities/user/create/page.tsx +33 -1
  45. package/src/modules/entities/data/entities.ts +6 -0
  46. package/src/modules/entities/i18n/de.json +3 -0
  47. package/src/modules/entities/i18n/en.json +3 -0
  48. package/src/modules/entities/i18n/es.json +3 -0
  49. package/src/modules/entities/i18n/pl.json +3 -0
  50. package/src/modules/entities/lib/entityAcl.ts +27 -5
  51. package/src/modules/entities/lib/install-from-ce.ts +2 -0
  52. package/src/modules/entities/lib/recordFeatures.ts +48 -0
  53. package/src/modules/entities/lib/register.ts +4 -0
  54. package/src/modules/entities/lib/restrictedEntityFeatures.ts +98 -0
  55. package/src/modules/entities/migrations/.snapshot-open-mercato.json +10 -0
  56. package/src/modules/entities/migrations/Migration20260716120000.ts +13 -0
@@ -221,6 +221,8 @@
221
221
  "entities.userEntities.errors.entityIdRequired": "El ID de la entidad es obligatorio",
222
222
  "entities.userEntities.errors.loadFailed": "No se pudieron cargar las entidades",
223
223
  "entities.userEntities.flash.created": "Entidad creada",
224
+ "entities.userEntities.form.accessRestricted.help": "Requiere un permiso explícito por entidad para ver o gestionar los registros de esta entidad. Déjalo desactivado para permitir el acceso a cualquiera con el permiso general de registros.",
225
+ "entities.userEntities.form.accessRestricted.label": "Restringir acceso a registros",
224
226
  "entities.userEntities.form.defaultEditor.label": "Editor predeterminado (multilínea)",
225
227
  "entities.userEntities.form.defaultEditor.options.default": "Predeterminado (Markdown)",
226
228
  "entities.userEntities.form.defaultEditor.options.htmlRichText": "HTML enriquecido",
@@ -230,6 +232,7 @@
230
232
  "entities.userEntities.form.entityId.label": "ID de la entidad",
231
233
  "entities.userEntities.form.entityId.placeholder": "nombre_modulo:id_entidad",
232
234
  "entities.userEntities.form.label.label": "Etiqueta",
235
+ "entities.userEntities.form.loading": "Cargando…",
233
236
  "entities.userEntities.form.showInSidebar.label": "Mostrar en la barra lateral",
234
237
  "entities.userEntities.form.submit": "Crear",
235
238
  "entities.userEntities.form.title": "Crear entidad",
@@ -221,6 +221,8 @@
221
221
  "entities.userEntities.errors.entityIdRequired": "Identyfikator encji jest wymagany",
222
222
  "entities.userEntities.errors.loadFailed": "Nie udało się wczytać encji",
223
223
  "entities.userEntities.flash.created": "Encja została utworzona",
224
+ "entities.userEntities.form.accessRestricted.help": "Wymaga jawnego uprawnienia dla tej encji, aby przeglądać lub zarządzać jej rekordami. Pozostaw wyłączone, aby zezwolić na dostęp każdemu z ogólnym uprawnieniem do rekordów.",
225
+ "entities.userEntities.form.accessRestricted.label": "Ogranicz dostęp do rekordów",
224
226
  "entities.userEntities.form.defaultEditor.label": "Domyślny edytor (wiele linii)",
225
227
  "entities.userEntities.form.defaultEditor.options.default": "Domyślne (Markdown)",
226
228
  "entities.userEntities.form.defaultEditor.options.htmlRichText": "HTML Rich Text",
@@ -230,6 +232,7 @@
230
232
  "entities.userEntities.form.entityId.label": "Identyfikator encji",
231
233
  "entities.userEntities.form.entityId.placeholder": "module_name:entity_id",
232
234
  "entities.userEntities.form.label.label": "Etykieta",
235
+ "entities.userEntities.form.loading": "Ładowanie…",
233
236
  "entities.userEntities.form.showInSidebar.label": "Pokaż w panelu bocznym",
234
237
  "entities.userEntities.form.submit": "Utwórz",
235
238
  "entities.userEntities.form.title": "Utwórz encję",
@@ -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) return
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 args.rbac.loadAcl(args.auth.sub ?? '', {
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
+ }