@open-mercato/shared 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6784.1.f80b9afce5
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 +18 -3
- package/dist/lib/commands/command-bus.js +5 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +2 -2
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +2 -11
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +4 -2
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-runner.js +2 -2
- package/dist/lib/crud/interceptor-runner.js.map +2 -2
- package/dist/lib/crud/mutation-guard-registry.js +2 -2
- package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
- package/dist/lib/crud/types.js +4 -0
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/consistency.js +19 -0
- package/dist/lib/data/consistency.js.map +7 -0
- package/dist/lib/data/engine.js +37 -11
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/security/enabledModulesRegistry.js +56 -11
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/dist/security/featurePolicy.js +62 -0
- package/dist/security/featurePolicy.js.map +7 -0
- package/package.json +6 -2
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
- package/src/lib/commands/command-bus.ts +5 -1
- package/src/lib/commands/command-interceptor-runner.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +18 -0
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
- package/src/lib/crud/enricher-runner.ts +2 -11
- package/src/lib/crud/factory.ts +2 -0
- package/src/lib/crud/interceptor-runner.ts +2 -2
- package/src/lib/crud/mutation-guard-registry.ts +2 -2
- package/src/lib/crud/types.ts +3 -0
- package/src/lib/data/__tests__/consistency.test.ts +38 -0
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
- package/src/lib/data/consistency.ts +17 -0
- package/src/lib/data/engine.ts +50 -16
- package/src/modules/customer-auth.ts +1 -0
- package/src/modules/navigation/backendChrome.ts +1 -0
- package/src/security/__tests__/featurePolicy.test.ts +166 -0
- package/src/security/enabledModulesRegistry.ts +64 -12
- package/src/security/featurePolicy.ts +89 -0
package/src/lib/data/engine.ts
CHANGED
|
@@ -6,11 +6,12 @@ import { setRecordCustomFields } from '@open-mercato/core/modules/entities/lib/h
|
|
|
6
6
|
import { validateCustomFieldValuesServer } from '@open-mercato/core/modules/entities/lib/validation'
|
|
7
7
|
import { sanitizeCustomFieldHtmlRichTextValuesServer } from '@open-mercato/core/modules/entities/lib/htmlRichTextSanitizer'
|
|
8
8
|
import type { EventBus } from '@open-mercato/events/types'
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
import {
|
|
10
|
+
CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
|
|
11
|
+
type CrudEventAction,
|
|
12
|
+
type CrudEventsConfig,
|
|
13
|
+
type CrudIndexerConfig,
|
|
14
|
+
type CrudEntityIdentifiers,
|
|
14
15
|
} from '../crud/types'
|
|
15
16
|
import type { BulkImportSuppression } from '../commands/types'
|
|
16
17
|
import { CrudHttpError } from '../crud/errors'
|
|
@@ -18,6 +19,7 @@ import { resolveRegisteredEntityTableName } from '../query/engine'
|
|
|
18
19
|
import { getEntityIds } from '../encryption/entityIds'
|
|
19
20
|
import { normalizeCustomFieldValues } from '../custom-fields/normalize'
|
|
20
21
|
import { parseBooleanToken } from '../boolean'
|
|
22
|
+
import { isReadProjectionAlwaysConsistent } from './consistency'
|
|
21
23
|
import { isEventDeclared } from '../../modules/events'
|
|
22
24
|
import { createLogger } from '../logger'
|
|
23
25
|
|
|
@@ -598,7 +600,7 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
598
600
|
if (events && !suppress?.skipEvents) {
|
|
599
601
|
const eventName = `${events.module}.${events.entity}.${action}`
|
|
600
602
|
warnIfUndeclaredEvent(eventName, 'emitOrmEntityEvent')
|
|
601
|
-
const
|
|
603
|
+
const builtPayload = events.buildPayload
|
|
602
604
|
? events.buildPayload(ctx)
|
|
603
605
|
: {
|
|
604
606
|
id: ctx.identifiers.id,
|
|
@@ -606,6 +608,20 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
606
608
|
tenantId: ctx.identifiers.tenantId,
|
|
607
609
|
...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}),
|
|
608
610
|
}
|
|
611
|
+
// A configured indexer means this data-engine call owns the query-index
|
|
612
|
+
// decision, including an explicit skipReindex suppression. Mark object
|
|
613
|
+
// payloads so the legacy domain-event bridge does not enqueue the same
|
|
614
|
+
// record a second time. Keep the marker non-enumerable so client broadcasts
|
|
615
|
+
// and persisted domain payloads retain their existing public shape.
|
|
616
|
+
// Primitive custom payloads cannot be bridged in any case because they do
|
|
617
|
+
// not expose the record id.
|
|
618
|
+
const payload = indexer && builtPayload && typeof builtPayload === 'object' && !Array.isArray(builtPayload)
|
|
619
|
+
? Object.defineProperty(
|
|
620
|
+
{ ...(builtPayload as Record<string, unknown>) },
|
|
621
|
+
CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
|
|
622
|
+
{ value: true, enumerable: false },
|
|
623
|
+
)
|
|
624
|
+
: builtPayload
|
|
609
625
|
try {
|
|
610
626
|
await bus.emitEvent(eventName, payload, {
|
|
611
627
|
persistent: !!events.persistent,
|
|
@@ -618,6 +634,7 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
618
634
|
}
|
|
619
635
|
|
|
620
636
|
if (indexer && !suppress?.skipReindex) {
|
|
637
|
+
const alwaysConsistent = isReadProjectionAlwaysConsistent()
|
|
621
638
|
const resolveCoverageBaseDelta = (): number | undefined => {
|
|
622
639
|
if (action === 'created') return 1
|
|
623
640
|
if (action === 'deleted') return -1
|
|
@@ -643,9 +660,14 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
643
660
|
// returns. The subscriber removes the projection row + tokens synchronously and
|
|
644
661
|
// defers the coverage recompute + fulltext delete, so this stays bounded.
|
|
645
662
|
// Errors are logged, not thrown — index drift never fails the originating write.
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
663
|
+
// Always-consistent mode rethrows so drift is loud and retryable.
|
|
664
|
+
if (alwaysConsistent) {
|
|
665
|
+
await bus.emitEvent('query_index.delete_one', enrichedPayload, { rethrowHandlerErrors: true })
|
|
666
|
+
} else {
|
|
667
|
+
await bus.emitEvent('query_index.delete_one', enrichedPayload).catch((err: unknown) => {
|
|
668
|
+
logger.error('query_index.delete_one emit failed', { err })
|
|
669
|
+
})
|
|
670
|
+
}
|
|
649
671
|
} else {
|
|
650
672
|
const payload = indexer.buildUpsertPayload
|
|
651
673
|
? indexer.buildUpsertPayload(ctx)
|
|
@@ -663,18 +685,27 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
663
685
|
// (see delete_one above). The subscriber updates `entity_indexes` synchronously
|
|
664
686
|
// and defers the heavy token-reindex pipeline (build doc + encrypt + decrypt +
|
|
665
687
|
// tokenize + DELETE + chunked INSERT) so write latency stays bounded.
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
}
|
|
688
|
+
if (alwaysConsistent) {
|
|
689
|
+
await bus.emitEvent('query_index.upsert_one', enrichedPayload, { rethrowHandlerErrors: true })
|
|
690
|
+
} else {
|
|
691
|
+
await bus.emitEvent('query_index.upsert_one', enrichedPayload).catch((err: unknown) => {
|
|
692
|
+
logger.error('query_index.upsert_one emit failed', { err })
|
|
693
|
+
})
|
|
694
|
+
}
|
|
669
695
|
}
|
|
670
696
|
|
|
671
|
-
if (shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
|
|
672
|
-
|
|
697
|
+
if (alwaysConsistent || shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
|
|
698
|
+
const coveragePayload = {
|
|
673
699
|
entityType: indexer.entityType,
|
|
674
700
|
tenantId: ctx.identifiers.tenantId ?? null,
|
|
675
701
|
organizationId: null,
|
|
676
702
|
delayMs: 0,
|
|
677
|
-
}
|
|
703
|
+
}
|
|
704
|
+
if (alwaysConsistent) {
|
|
705
|
+
await bus.emitEvent('query_index.coverage.refresh', coveragePayload, { rethrowHandlerErrors: true })
|
|
706
|
+
} else {
|
|
707
|
+
void bus.emitEvent('query_index.coverage.refresh', coveragePayload).catch(() => undefined)
|
|
708
|
+
}
|
|
678
709
|
}
|
|
679
710
|
}
|
|
680
711
|
}
|
|
@@ -739,7 +770,10 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
739
770
|
indexer: entry.indexer as CrudIndexerConfig<unknown>,
|
|
740
771
|
suppress,
|
|
741
772
|
})
|
|
742
|
-
} catch {
|
|
773
|
+
} catch (error) {
|
|
774
|
+
if (isReadProjectionAlwaysConsistent()) {
|
|
775
|
+
throw error
|
|
776
|
+
}
|
|
743
777
|
// best-effort; continue with remaining side effects
|
|
744
778
|
}
|
|
745
779
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { Module } from '../../modules/registry'
|
|
2
|
+
import { getModules } from '../../lib/modules/registry'
|
|
3
|
+
import {
|
|
4
|
+
applyAclFeatureOverrides,
|
|
5
|
+
resetModuleContractOverridesForTests,
|
|
6
|
+
} from '../../modules/overrides'
|
|
7
|
+
import {
|
|
8
|
+
authorizeFeatures,
|
|
9
|
+
getRemovedAclFeatureIds,
|
|
10
|
+
isAclFeatureRemoved,
|
|
11
|
+
resolveEffectiveFeatures,
|
|
12
|
+
} from '../featurePolicy'
|
|
13
|
+
|
|
14
|
+
jest.mock('../../lib/modules/registry', () => ({
|
|
15
|
+
getModules: jest.fn(),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
const mockGetModules = jest.mocked(getModules)
|
|
19
|
+
|
|
20
|
+
const modules: Module[] = [
|
|
21
|
+
{
|
|
22
|
+
id: 'auth',
|
|
23
|
+
features: [
|
|
24
|
+
{ id: 'auth.users.view', title: 'View users', module: 'auth' },
|
|
25
|
+
{ id: 'auth.users.manage', title: 'Manage users', module: 'auth' },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'dashboards',
|
|
30
|
+
features: [
|
|
31
|
+
{ id: 'analytics.view', title: 'View analytics', module: 'dashboards' },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'customer_accounts',
|
|
36
|
+
setup: {
|
|
37
|
+
defaultCustomerRoleFeatures: {
|
|
38
|
+
portal_admin: ['portal.*'],
|
|
39
|
+
buyer: ['portal.orders.view', 'portal.account.manage'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
frontendRoutes: [
|
|
43
|
+
{
|
|
44
|
+
Component: () => null,
|
|
45
|
+
requireCustomerFeatures: ['portal.quotes.view'],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
describe('featurePolicy', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
mockGetModules.mockReturnValue(modules)
|
|
54
|
+
resetModuleContractOverridesForTests()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
resetModuleContractOverridesForTests()
|
|
59
|
+
jest.resetAllMocks()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('reports exact removals and lets a later replacement restore the feature', () => {
|
|
63
|
+
applyAclFeatureOverrides({
|
|
64
|
+
'auth.users.manage': null,
|
|
65
|
+
'legacy.feature': null,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
expect(getRemovedAclFeatureIds()).toEqual(['auth.users.manage', 'legacy.feature'])
|
|
69
|
+
expect(isAclFeatureRemoved('auth.users.manage')).toBe(true)
|
|
70
|
+
|
|
71
|
+
applyAclFeatureOverrides({
|
|
72
|
+
'auth.users.manage': {
|
|
73
|
+
id: 'auth.users.manage',
|
|
74
|
+
title: 'Manage users',
|
|
75
|
+
module: 'auth',
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(isAclFeatureRemoved('auth.users.manage')).toBe(false)
|
|
80
|
+
expect(getRemovedAclFeatureIds()).toEqual(['legacy.feature'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it.each([
|
|
84
|
+
{ grantedFeatures: ['auth.users.manage'], unrestricted: false, siblingAllowed: false },
|
|
85
|
+
{ grantedFeatures: ['auth.*'], unrestricted: false, siblingAllowed: true },
|
|
86
|
+
{ grantedFeatures: ['*'], unrestricted: false, siblingAllowed: true },
|
|
87
|
+
{ grantedFeatures: [], unrestricted: true, siblingAllowed: true },
|
|
88
|
+
])('denies a removed requirement before grants or unrestricted access', ({
|
|
89
|
+
siblingAllowed,
|
|
90
|
+
...subject
|
|
91
|
+
}) => {
|
|
92
|
+
applyAclFeatureOverrides({ 'auth.users.manage': null })
|
|
93
|
+
|
|
94
|
+
expect(authorizeFeatures(['auth.users.manage'], subject)).toBe(false)
|
|
95
|
+
expect(authorizeFeatures(['auth.users.view'], subject)).toBe(siblingAllowed)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('enforces invalid scope before unrestricted access', () => {
|
|
99
|
+
expect(authorizeFeatures(['auth.users.view'], {
|
|
100
|
+
grantedFeatures: ['*'],
|
|
101
|
+
unrestricted: true,
|
|
102
|
+
scopeAllowed: false,
|
|
103
|
+
})).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('denies requirements owned by disabled modules', () => {
|
|
107
|
+
expect(authorizeFeatures(['search.global'], {
|
|
108
|
+
grantedFeatures: ['*'],
|
|
109
|
+
unrestricted: true,
|
|
110
|
+
})).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('expands wildcards into a deterministic concrete set including portal sources', () => {
|
|
114
|
+
expect(resolveEffectiveFeatures(['*'])).toEqual([
|
|
115
|
+
'auth.users.view',
|
|
116
|
+
'auth.users.manage',
|
|
117
|
+
'analytics.view',
|
|
118
|
+
'portal.orders.view',
|
|
119
|
+
'portal.account.manage',
|
|
120
|
+
'portal.quotes.view',
|
|
121
|
+
])
|
|
122
|
+
expect(resolveEffectiveFeatures(['portal.*'])).toEqual([
|
|
123
|
+
'portal.orders.view',
|
|
124
|
+
'portal.account.manage',
|
|
125
|
+
'portal.quotes.view',
|
|
126
|
+
])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('removes nulled features while preserving siblings and explicit custom grants', () => {
|
|
130
|
+
applyAclFeatureOverrides({
|
|
131
|
+
'auth.users.manage': null,
|
|
132
|
+
'auth.custom.audit': null,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
expect(resolveEffectiveFeatures([
|
|
136
|
+
'auth.*',
|
|
137
|
+
'auth.custom.export',
|
|
138
|
+
'auth.custom.audit',
|
|
139
|
+
])).toEqual([
|
|
140
|
+
'auth.users.view',
|
|
141
|
+
'auth.custom.export',
|
|
142
|
+
])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('uses declared ownership for off-convention features', () => {
|
|
146
|
+
expect(resolveEffectiveFeatures(['analytics.*'])).toEqual(['analytics.view'])
|
|
147
|
+
expect(authorizeFeatures(['analytics.view'], {
|
|
148
|
+
grantedFeatures: ['analytics.*'],
|
|
149
|
+
})).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('fails closed for wildcards when the module registry is unavailable', () => {
|
|
153
|
+
mockGetModules.mockImplementation(() => {
|
|
154
|
+
throw new Error('registry unavailable')
|
|
155
|
+
})
|
|
156
|
+
applyAclFeatureOverrides({ 'legacy.removed': null })
|
|
157
|
+
|
|
158
|
+
expect(resolveEffectiveFeatures([
|
|
159
|
+
'*',
|
|
160
|
+
'legacy.*',
|
|
161
|
+
'legacy.explicit',
|
|
162
|
+
'legacy.removed',
|
|
163
|
+
'legacy.explicit',
|
|
164
|
+
])).toEqual(['legacy.explicit'])
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -31,6 +31,7 @@ type FeatureRegistry = {
|
|
|
31
31
|
enabledModuleSet: Set<string>
|
|
32
32
|
featureToModule: Map<string, string>
|
|
33
33
|
prefixToModule: Map<string, string>
|
|
34
|
+
concreteFeatureIds: string[]
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
let cachedRegistry: FeatureRegistry | null = null
|
|
@@ -41,23 +42,63 @@ function buildRegistry(modules: readonly Module[]): FeatureRegistry {
|
|
|
41
42
|
const enabledModuleSet = new Set(enabledModuleIds)
|
|
42
43
|
const featureToModule = new Map<string, string>()
|
|
43
44
|
const prefixToModule = new Map<string, string>()
|
|
45
|
+
const concreteFeatureIds: string[] = []
|
|
46
|
+
const concreteFeatureSet = new Set<string>()
|
|
47
|
+
|
|
48
|
+
const addConcreteFeature = (featureId: string, owningModule: string, authoritative: boolean) => {
|
|
49
|
+
if (!featureId || featureId === '*' || featureId.endsWith('.*')) return
|
|
50
|
+
if (!concreteFeatureSet.has(featureId)) {
|
|
51
|
+
concreteFeatureSet.add(featureId)
|
|
52
|
+
concreteFeatureIds.push(featureId)
|
|
53
|
+
}
|
|
54
|
+
if (authoritative || !featureToModule.has(featureId)) {
|
|
55
|
+
featureToModule.set(featureId, owningModule)
|
|
56
|
+
}
|
|
57
|
+
const dot = featureId.indexOf('.')
|
|
58
|
+
if (dot > 0) {
|
|
59
|
+
const prefix = featureId.slice(0, dot)
|
|
60
|
+
if (!prefixToModule.has(prefix)) prefixToModule.set(prefix, owningModule)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
for (const mod of modules) {
|
|
45
65
|
const features = mod.features
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
if (Array.isArray(features)) {
|
|
67
|
+
for (const feature of features) {
|
|
68
|
+
if (!feature || typeof feature.id !== 'string' || !feature.id) continue
|
|
69
|
+
const declared = typeof feature.module === 'string' && feature.module.length > 0
|
|
70
|
+
? feature.module
|
|
71
|
+
: mod.id
|
|
72
|
+
addConcreteFeature(feature.id, declared, true)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const customerDefaults = mod.setup?.defaultCustomerRoleFeatures
|
|
77
|
+
if (customerDefaults) {
|
|
78
|
+
for (const roleFeatures of Object.values(customerDefaults)) {
|
|
79
|
+
if (!Array.isArray(roleFeatures)) continue
|
|
80
|
+
for (const featureId of roleFeatures) {
|
|
81
|
+
if (typeof featureId === 'string') addConcreteFeature(featureId, mod.id, false)
|
|
82
|
+
}
|
|
57
83
|
}
|
|
58
84
|
}
|
|
85
|
+
|
|
86
|
+
if (Array.isArray(mod.frontendRoutes)) {
|
|
87
|
+
for (const route of mod.frontendRoutes) {
|
|
88
|
+
if (!Array.isArray(route.requireCustomerFeatures)) continue
|
|
89
|
+
for (const featureId of route.requireCustomerFeatures) {
|
|
90
|
+
if (typeof featureId === 'string') addConcreteFeature(featureId, mod.id, false)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
enabledModuleIds,
|
|
97
|
+
enabledModuleSet,
|
|
98
|
+
featureToModule,
|
|
99
|
+
prefixToModule,
|
|
100
|
+
concreteFeatureIds,
|
|
59
101
|
}
|
|
60
|
-
return { enabledModuleIds, enabledModuleSet, featureToModule, prefixToModule }
|
|
61
102
|
}
|
|
62
103
|
|
|
63
104
|
function getRegistry(): FeatureRegistry | null {
|
|
@@ -93,6 +134,17 @@ export function getEnabledModuleIds(): string[] {
|
|
|
93
134
|
return registry ? [...registry.enabledModuleIds] : []
|
|
94
135
|
}
|
|
95
136
|
|
|
137
|
+
/** @internal Infrastructure input for concrete feature-policy projection. */
|
|
138
|
+
export function getConcreteFeatureIds(): string[] {
|
|
139
|
+
const registry = getRegistry()
|
|
140
|
+
return registry ? [...registry.concreteFeatureIds] : []
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @internal Distinguishes an empty registry from an unavailable bootstrap registry. */
|
|
144
|
+
export function hasEnabledModulesRegistry(): boolean {
|
|
145
|
+
return getRegistry() !== null
|
|
146
|
+
}
|
|
147
|
+
|
|
96
148
|
/**
|
|
97
149
|
* Filters a raw granted-features list down to the grants whose owning
|
|
98
150
|
* module is currently enabled. Expands `*` (superadmin) into one wildcard
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { hasAllFeatures as matchesAllFeatures } from './features'
|
|
2
|
+
import {
|
|
3
|
+
filterGrantsByEnabledModules,
|
|
4
|
+
getConcreteFeatureIds,
|
|
5
|
+
getEnabledModuleIds,
|
|
6
|
+
getOwningModuleId,
|
|
7
|
+
hasEnabledModulesRegistry,
|
|
8
|
+
} from './enabledModulesRegistry'
|
|
9
|
+
import { composeAclFeatureOverrides } from '../modules/overrides'
|
|
10
|
+
|
|
11
|
+
export type FeaturePolicySubject = {
|
|
12
|
+
grantedFeatures: readonly string[]
|
|
13
|
+
unrestricted?: boolean
|
|
14
|
+
scopeAllowed?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getRemovedAclFeatureIds(): string[] {
|
|
18
|
+
return Object.entries(composeAclFeatureOverrides())
|
|
19
|
+
.filter(([, override]) => override === null)
|
|
20
|
+
.map(([featureId]) => featureId)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isAclFeatureRemoved(featureId: string): boolean {
|
|
24
|
+
return composeAclFeatureOverrides()[featureId] === null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isFeatureEnabled(featureId: string): boolean {
|
|
28
|
+
if (!hasEnabledModulesRegistry()) return true
|
|
29
|
+
const enabledModuleIds = getEnabledModuleIds()
|
|
30
|
+
return enabledModuleIds.includes(getOwningModuleId(featureId))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function authorizeFeatures(
|
|
34
|
+
required: readonly string[],
|
|
35
|
+
subject: FeaturePolicySubject,
|
|
36
|
+
): boolean {
|
|
37
|
+
if (required.length === 0) return true
|
|
38
|
+
if (subject.scopeAllowed === false) return false
|
|
39
|
+
if (required.some((featureId) => (
|
|
40
|
+
isAclFeatureRemoved(featureId) || !isFeatureEnabled(featureId)
|
|
41
|
+
))) {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
if (subject.unrestricted === true) return true
|
|
45
|
+
return matchesAllFeatures(
|
|
46
|
+
filterGrantsByEnabledModules(subject.grantedFeatures),
|
|
47
|
+
required,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveEffectiveFeatures(
|
|
52
|
+
grantedFeatures: readonly string[],
|
|
53
|
+
): string[] {
|
|
54
|
+
const filteredGrants = filterGrantsByEnabledModules(grantedFeatures)
|
|
55
|
+
.filter((featureId) => !isAclFeatureRemoved(featureId))
|
|
56
|
+
|
|
57
|
+
if (!hasEnabledModulesRegistry()) {
|
|
58
|
+
return filteredGrants.filter((featureId, index, features) => (
|
|
59
|
+
featureId !== '*'
|
|
60
|
+
&& !featureId.endsWith('.*')
|
|
61
|
+
&& features.indexOf(featureId) === index
|
|
62
|
+
))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result: string[] = []
|
|
66
|
+
const seen = new Set<string>()
|
|
67
|
+
const addFeature = (featureId: string) => {
|
|
68
|
+
if (
|
|
69
|
+
seen.has(featureId)
|
|
70
|
+
|| isAclFeatureRemoved(featureId)
|
|
71
|
+
|| !isFeatureEnabled(featureId)
|
|
72
|
+
) {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
seen.add(featureId)
|
|
76
|
+
result.push(featureId)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const featureId of getConcreteFeatureIds()) {
|
|
80
|
+
if (matchesAllFeatures(filteredGrants, [featureId])) addFeature(featureId)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const featureId of filteredGrants) {
|
|
84
|
+
if (featureId === '*' || featureId.endsWith('.*')) continue
|
|
85
|
+
addFeature(featureId)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result
|
|
89
|
+
}
|