openxiangda 1.0.136 → 1.0.137
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/lib/cli.js +576 -3
- package/lib/design-gates.js +9 -1
- package/openxiangda-skills/references/best-practices.md +18 -7
- package/openxiangda-skills/references/permission-design-patterns.md +64 -3
- package/openxiangda-skills/references/permissions-settings.md +128 -0
- package/openxiangda-skills/references/resource-manifest-cheatsheet.md +87 -0
- package/package.json +1 -1
- package/packages/sdk/dist/runtime/react.d.mts +3 -1
- package/packages/sdk/dist/runtime/react.d.ts +3 -1
package/lib/cli.js
CHANGED
|
@@ -93,6 +93,7 @@ async function main(argv) {
|
|
|
93
93
|
if (command === 'workflow') return workflow(rest);
|
|
94
94
|
if (command === 'automation') return automation(rest);
|
|
95
95
|
if (command === 'data-view') return dataView(rest);
|
|
96
|
+
if (command === 'scope') return scope(rest);
|
|
96
97
|
if (command === 'route') return route(rest);
|
|
97
98
|
if (command === 'public-access') return publicAccess(rest);
|
|
98
99
|
if (command === 'auth-config') return authConfig(rest);
|
|
@@ -162,6 +163,7 @@ Usage:
|
|
|
162
163
|
openxiangda automation diagnose <automationCode|automationId> [--redact] [--json]
|
|
163
164
|
openxiangda automation publish|enable|disable <automationCode|automationId>
|
|
164
165
|
openxiangda data-view list|get|create|update|upsert|delete|status|refresh|query|stats <dataViewCode> [--profile name] [--json]
|
|
166
|
+
openxiangda scope dimension-list|dimension-upsert|grant-source-list|grant-source-upsert|policy-list|policy-upsert|sync|explain [code] [--json-file file]
|
|
165
167
|
openxiangda route list|get|create|update|upsert|delete [--json-file file] [--dry-run] [--write-manifest]
|
|
166
168
|
openxiangda public-access list|get|create|update|upsert|delete|ticket-create|session-test|grant-check [--json-file file]
|
|
167
169
|
openxiangda auth-config list|get|create|update|upsert|delete|methods [--json-file file]
|
|
@@ -1860,6 +1862,9 @@ async function workspace(args) {
|
|
|
1860
1862
|
connectors: {},
|
|
1861
1863
|
dataViews: {},
|
|
1862
1864
|
storageConfigs: {},
|
|
1865
|
+
scopeDimensions: {},
|
|
1866
|
+
scopeGrantSources: {},
|
|
1867
|
+
dataScopePolicies: {},
|
|
1863
1868
|
pagePermissionGroups: {},
|
|
1864
1869
|
formPermissionGroups: {},
|
|
1865
1870
|
formSettings: {},
|
|
@@ -3101,6 +3106,112 @@ async function dataView(args) {
|
|
|
3101
3106
|
fail('用法: openxiangda data-view list|get|create|update|upsert|delete|status|refresh|query|stats');
|
|
3102
3107
|
}
|
|
3103
3108
|
|
|
3109
|
+
async function scope(args) {
|
|
3110
|
+
const { subcommand, rest } = parseSubcommandArgs(args);
|
|
3111
|
+
const { flags, positional } = parseArgs(rest);
|
|
3112
|
+
if (wantsSubcommandHelp(subcommand, flags)) {
|
|
3113
|
+
print('用法: openxiangda scope dimension-list|dimension-upsert|grant-source-list|grant-source-upsert|policy-list|policy-upsert|sync|explain [code] [--json-file file] [--dry-run] [--write-manifest]');
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
3116
|
+
const config = loadConfig();
|
|
3117
|
+
const target = getWorkspaceTarget(config, flags.profile || config.currentProfile, flags);
|
|
3118
|
+
const basePath = `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/scope`;
|
|
3119
|
+
|
|
3120
|
+
if (subcommand === 'dimension-list') {
|
|
3121
|
+
return runDirectRequest(config, target, flags, {
|
|
3122
|
+
method: 'GET',
|
|
3123
|
+
path: `${basePath}/dimensions`,
|
|
3124
|
+
});
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
if (subcommand === 'dimension-upsert') {
|
|
3128
|
+
return upsertScopeResource(config, target, flags, positional, {
|
|
3129
|
+
commandName: 'scope dimension-upsert',
|
|
3130
|
+
resourceType: 'scope-dimension',
|
|
3131
|
+
path: `${basePath}/dimensions`,
|
|
3132
|
+
normalizeBody: body => normalizeScopeDimensionManifest(body, target.bound),
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
if (subcommand === 'grant-source-list') {
|
|
3137
|
+
return runDirectRequest(config, target, flags, {
|
|
3138
|
+
method: 'GET',
|
|
3139
|
+
path: `${basePath}/grant-sources`,
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
if (subcommand === 'grant-source-upsert') {
|
|
3144
|
+
return upsertScopeResource(config, target, flags, positional, {
|
|
3145
|
+
commandName: 'scope grant-source-upsert',
|
|
3146
|
+
resourceType: 'scope-grant-source',
|
|
3147
|
+
path: `${basePath}/grant-sources`,
|
|
3148
|
+
normalizeBody: body => normalizeScopeGrantSourceManifest(body, target.bound),
|
|
3149
|
+
});
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
if (subcommand === 'policy-list') {
|
|
3153
|
+
return runDirectRequest(config, target, flags, {
|
|
3154
|
+
method: 'GET',
|
|
3155
|
+
path: `${basePath}/policies`,
|
|
3156
|
+
});
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
if (subcommand === 'policy-upsert') {
|
|
3160
|
+
return upsertScopeResource(config, target, flags, positional, {
|
|
3161
|
+
commandName: 'scope policy-upsert',
|
|
3162
|
+
resourceType: 'data-scope-policy',
|
|
3163
|
+
path: `${basePath}/policies`,
|
|
3164
|
+
normalizeBody: normalizeDataScopePolicyManifest,
|
|
3165
|
+
});
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
if (subcommand === 'sync') {
|
|
3169
|
+
const [code] = positional;
|
|
3170
|
+
if (!code) fail('用法: openxiangda scope sync <grantSourceCode> [--dry-run] [--json]');
|
|
3171
|
+
const body = {
|
|
3172
|
+
...readDirectJsonBody(flags, 'scope sync', { optional: true }),
|
|
3173
|
+
...(flags['dry-run'] ? { dryRun: true } : {}),
|
|
3174
|
+
};
|
|
3175
|
+
return runDirectRequest(config, target, flags, {
|
|
3176
|
+
method: 'POST',
|
|
3177
|
+
path: `${basePath}/grant-sources/${encodeURIComponent(code)}/sync`,
|
|
3178
|
+
body,
|
|
3179
|
+
});
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
if (subcommand === 'explain') {
|
|
3183
|
+
const [code] = positional;
|
|
3184
|
+
if (!code) fail('用法: openxiangda scope explain <policyCode> [--json-file file] [--json]');
|
|
3185
|
+
const body = readDirectJsonBody(flags, 'scope explain', { optional: true });
|
|
3186
|
+
return runDirectRequest(config, target, flags, {
|
|
3187
|
+
method: 'POST',
|
|
3188
|
+
path: `${basePath}/policies/${encodeURIComponent(code)}/explain`,
|
|
3189
|
+
body,
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
fail('用法: openxiangda scope dimension-list|dimension-upsert|grant-source-list|grant-source-upsert|policy-list|policy-upsert|sync|explain');
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
async function upsertScopeResource(config, target, flags, positional, spec) {
|
|
3197
|
+
const rawBody = readDirectJsonBody(flags, spec.commandName);
|
|
3198
|
+
const explicitCode = positional[0] || flags.code;
|
|
3199
|
+
if (explicitCode && !rawBody.code && !rawBody.resourceCode) rawBody.code = explicitCode;
|
|
3200
|
+
const code = explicitCode || rawBody.code || rawBody.resourceCode;
|
|
3201
|
+
if (!code) fail(`openxiangda ${spec.commandName} 缺少资源 code`);
|
|
3202
|
+
const body = spec.normalizeBody(rawBody);
|
|
3203
|
+
const data = await runDirectRequest(config, target, flags, {
|
|
3204
|
+
method: 'POST',
|
|
3205
|
+
path: `${spec.path}/${encodeURIComponent(code)}`,
|
|
3206
|
+
body,
|
|
3207
|
+
}, { returnData: true });
|
|
3208
|
+
if (!flags['dry-run']) {
|
|
3209
|
+
saveScopeResource(target, resourceTypeToBucket(spec.resourceType), code, data);
|
|
3210
|
+
if (flags['write-manifest']) writeDirectManifest(spec.resourceType, code, { ...rawBody, code });
|
|
3211
|
+
}
|
|
3212
|
+
return outputDirectResult({ action: 'upsert', data }, flags);
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3104
3215
|
async function route(args) {
|
|
3105
3216
|
const { subcommand, rest } = parseSubcommandArgs(args);
|
|
3106
3217
|
const { flags, positional } = parseArgs(rest);
|
|
@@ -5579,6 +5690,9 @@ function directManifestDir(resourceType) {
|
|
|
5579
5690
|
role: path.join('src', 'resources', 'roles'),
|
|
5580
5691
|
'page-permission-group': path.join('src', 'resources', 'permissions', 'page-groups'),
|
|
5581
5692
|
'form-permission-group': path.join('src', 'resources', 'permissions', 'form-groups'),
|
|
5693
|
+
'scope-dimension': path.join('src', 'resources', 'permissions', 'scope-dimensions'),
|
|
5694
|
+
'scope-grant-source': path.join('src', 'resources', 'permissions', 'scope-grant-sources'),
|
|
5695
|
+
'data-scope-policy': path.join('src', 'resources', 'permissions', 'data-scope-policies'),
|
|
5582
5696
|
};
|
|
5583
5697
|
return dirs[resourceType];
|
|
5584
5698
|
}
|
|
@@ -5738,6 +5852,8 @@ async function buildPermissionAudit(config, target, flags = {}) {
|
|
|
5738
5852
|
const manifest = loadWorkspaceResources();
|
|
5739
5853
|
const validation = validateWorkspaceResources(manifest);
|
|
5740
5854
|
const roleCodes = new Set((manifest.roles || []).map(item => item.code || item.resourceCode).filter(Boolean));
|
|
5855
|
+
const policyCodes = new Set((manifest.dataScopePolicies || []).map(item => item.code || item.resourceCode).filter(Boolean));
|
|
5856
|
+
const scopePolicyRefs = [];
|
|
5741
5857
|
const warnings = [...validation.warnings];
|
|
5742
5858
|
const errors = [...validation.errors];
|
|
5743
5859
|
for (const group of manifest.pagePermissionGroups || []) {
|
|
@@ -5751,6 +5867,31 @@ async function buildPermissionAudit(config, target, flags = {}) {
|
|
|
5751
5867
|
}
|
|
5752
5868
|
const formUuid = resolveManifestFormUuid(target.bound, group);
|
|
5753
5869
|
if (!formUuid) errors.push(`form permission group ${group.code} 缺少 formCode/formUuid`);
|
|
5870
|
+
if (group.dataPermission?.type === 'scope_policy') {
|
|
5871
|
+
const policyCode = getScopePolicyCode(group.dataPermission);
|
|
5872
|
+
if (policyCode) scopePolicyRefs.push(policyCode);
|
|
5873
|
+
if (policyCode && policyCodes.size > 0 && !policyCodes.has(policyCode)) {
|
|
5874
|
+
warnings.push(`form permission group ${group.code} 引用未声明 data-scope-policy: ${policyCode}`);
|
|
5875
|
+
}
|
|
5876
|
+
}
|
|
5877
|
+
}
|
|
5878
|
+
for (const dataView of manifest.dataViews || []) {
|
|
5879
|
+
for (const group of dataView.permissionGroups || []) {
|
|
5880
|
+
for (const roleCode of group.roles || []) {
|
|
5881
|
+
if (roleCodes.size > 0 && !roleCodes.has(roleCode)) {
|
|
5882
|
+
warnings.push(`data view ${dataView.code} permission group ${group.code || group.name || '?'} 引用未声明角色: ${roleCode}`);
|
|
5883
|
+
}
|
|
5884
|
+
}
|
|
5885
|
+
if (group.dataPermission?.type !== 'scope_policy') continue;
|
|
5886
|
+
const policyCode = getScopePolicyCode(group.dataPermission);
|
|
5887
|
+
if (policyCode) scopePolicyRefs.push(policyCode);
|
|
5888
|
+
if (policyCode && policyCodes.size > 0 && !policyCodes.has(policyCode)) {
|
|
5889
|
+
warnings.push(`data view ${dataView.code} permission group ${group.code || group.name || '?'} 引用未声明 data-scope-policy: ${policyCode}`);
|
|
5890
|
+
}
|
|
5891
|
+
}
|
|
5892
|
+
}
|
|
5893
|
+
if (scopePolicyRefs.length > 0 && (manifest.scopeGrantSources || []).length === 0) {
|
|
5894
|
+
warnings.push('权限组已引用 scope_policy,但本工作区未声明 scopeGrantSources;请确认授权来源由其他工作区或平台后台维护');
|
|
5754
5895
|
}
|
|
5755
5896
|
for (const policy of manifest.publicAccessPolicies || []) {
|
|
5756
5897
|
const grants = normalizePublicAccessPolicyManifest(target.bound, policy).grants || {};
|
|
@@ -5770,6 +5911,12 @@ async function buildPermissionAudit(config, target, flags = {}) {
|
|
|
5770
5911
|
),
|
|
5771
5912
|
errors,
|
|
5772
5913
|
warnings,
|
|
5914
|
+
scope: {
|
|
5915
|
+
dimensions: (manifest.scopeDimensions || []).length,
|
|
5916
|
+
grantSources: (manifest.scopeGrantSources || []).length,
|
|
5917
|
+
policies: (manifest.dataScopePolicies || []).length,
|
|
5918
|
+
referencedPolicies: unique(scopePolicyRefs),
|
|
5919
|
+
},
|
|
5773
5920
|
};
|
|
5774
5921
|
if (flags.live) {
|
|
5775
5922
|
result.live = await requestWithAuth(
|
|
@@ -5806,6 +5953,9 @@ function ensureResourceBuckets(bound) {
|
|
|
5806
5953
|
bound.resources.authConfigs = bound.resources.authConfigs || {};
|
|
5807
5954
|
bound.resources.routes = bound.resources.routes || {};
|
|
5808
5955
|
bound.resources.publicAccessPolicies = bound.resources.publicAccessPolicies || {};
|
|
5956
|
+
bound.resources.scopeDimensions = bound.resources.scopeDimensions || {};
|
|
5957
|
+
bound.resources.scopeGrantSources = bound.resources.scopeGrantSources || {};
|
|
5958
|
+
bound.resources.dataScopePolicies = bound.resources.dataScopePolicies || {};
|
|
5809
5959
|
bound.resources.notifications = bound.resources.notifications || {};
|
|
5810
5960
|
bound.resources.notifications.templates = bound.resources.notifications.templates || {};
|
|
5811
5961
|
bound.resources.notifications.typeConfigs = bound.resources.notifications.typeConfigs || {};
|
|
@@ -6056,6 +6206,24 @@ function saveStorageConfigResource(target, storageCode, storageConfigId, extra =
|
|
|
6056
6206
|
}, keys);
|
|
6057
6207
|
}
|
|
6058
6208
|
|
|
6209
|
+
function saveScopeResource(target, bucket, code, data = {}) {
|
|
6210
|
+
if (!bucket || !code) return;
|
|
6211
|
+
const keys = ['id', 'resourceCode', 'name', 'updatedAt'];
|
|
6212
|
+
saveStateResource(target, bucket, code, {
|
|
6213
|
+
id: data?.id,
|
|
6214
|
+
resourceCode: data?.resourceCode || data?.code || code,
|
|
6215
|
+
name: data?.name,
|
|
6216
|
+
}, keys);
|
|
6217
|
+
}
|
|
6218
|
+
|
|
6219
|
+
function resourceTypeToBucket(resourceType) {
|
|
6220
|
+
return {
|
|
6221
|
+
'scope-dimension': 'scopeDimensions',
|
|
6222
|
+
'scope-grant-source': 'scopeGrantSources',
|
|
6223
|
+
'data-scope-policy': 'dataScopePolicies',
|
|
6224
|
+
}[resourceType];
|
|
6225
|
+
}
|
|
6226
|
+
|
|
6059
6227
|
function saveRouteResource(target, routeCode, routeId, extra = {}) {
|
|
6060
6228
|
const keys = ['routeId', 'pathPattern', 'publicAccess', 'publicPolicyCode'];
|
|
6061
6229
|
saveStateResource(target, 'routes', routeCode, {
|
|
@@ -6208,6 +6376,24 @@ const RESOURCE_SPECS = [
|
|
|
6208
6376
|
topFiles: [path.join('permissions', 'form-groups.json')],
|
|
6209
6377
|
pluralKeys: ['formPermissionGroups', 'formGroups'],
|
|
6210
6378
|
},
|
|
6379
|
+
{
|
|
6380
|
+
key: 'scopeDimensions',
|
|
6381
|
+
dir: path.join('permissions', 'scope-dimensions'),
|
|
6382
|
+
topFiles: [path.join('permissions', 'scope-dimensions.json')],
|
|
6383
|
+
pluralKeys: ['scopeDimensions', 'dimensions'],
|
|
6384
|
+
},
|
|
6385
|
+
{
|
|
6386
|
+
key: 'scopeGrantSources',
|
|
6387
|
+
dir: path.join('permissions', 'scope-grant-sources'),
|
|
6388
|
+
topFiles: [path.join('permissions', 'scope-grant-sources.json')],
|
|
6389
|
+
pluralKeys: ['scopeGrantSources', 'grantSources', 'sources'],
|
|
6390
|
+
},
|
|
6391
|
+
{
|
|
6392
|
+
key: 'dataScopePolicies',
|
|
6393
|
+
dir: path.join('permissions', 'data-scope-policies'),
|
|
6394
|
+
topFiles: [path.join('permissions', 'data-scope-policies.json')],
|
|
6395
|
+
pluralKeys: ['dataScopePolicies', 'scopePolicies', 'policies'],
|
|
6396
|
+
},
|
|
6211
6397
|
{
|
|
6212
6398
|
key: 'formSettings',
|
|
6213
6399
|
dir: path.join('settings', 'forms'),
|
|
@@ -6274,6 +6460,20 @@ const RESOURCE_TYPE_ALIASES = new Map([
|
|
|
6274
6460
|
['form-groups', 'formPermissionGroups'],
|
|
6275
6461
|
['formpermissiongroup', 'formPermissionGroups'],
|
|
6276
6462
|
['formpermissiongroups', 'formPermissionGroups'],
|
|
6463
|
+
['scope-dimension', 'scopeDimensions'],
|
|
6464
|
+
['scope-dimensions', 'scopeDimensions'],
|
|
6465
|
+
['scopedimension', 'scopeDimensions'],
|
|
6466
|
+
['scopedimensions', 'scopeDimensions'],
|
|
6467
|
+
['scope-grant-source', 'scopeGrantSources'],
|
|
6468
|
+
['scope-grant-sources', 'scopeGrantSources'],
|
|
6469
|
+
['scopegrantsource', 'scopeGrantSources'],
|
|
6470
|
+
['scopegrantsources', 'scopeGrantSources'],
|
|
6471
|
+
['data-scope-policy', 'dataScopePolicies'],
|
|
6472
|
+
['data-scope-policies', 'dataScopePolicies'],
|
|
6473
|
+
['scope-policy', 'dataScopePolicies'],
|
|
6474
|
+
['scope-policies', 'dataScopePolicies'],
|
|
6475
|
+
['datascopepolicy', 'dataScopePolicies'],
|
|
6476
|
+
['datascopepolicies', 'dataScopePolicies'],
|
|
6277
6477
|
['form-setting', 'formSettings'],
|
|
6278
6478
|
['form-settings', 'formSettings'],
|
|
6279
6479
|
['settings-form', 'formSettings'],
|
|
@@ -6283,8 +6483,8 @@ const RESOURCE_TYPE_ALIASES = new Map([
|
|
|
6283
6483
|
]);
|
|
6284
6484
|
|
|
6285
6485
|
const RESOURCE_TYPE_ALIAS_GROUPS = new Map([
|
|
6286
|
-
['permission', ['pagePermissionGroups', 'formPermissionGroups']],
|
|
6287
|
-
['permissions', ['pagePermissionGroups', 'formPermissionGroups']],
|
|
6486
|
+
['permission', ['pagePermissionGroups', 'formPermissionGroups', 'scopeDimensions', 'scopeGrantSources', 'dataScopePolicies']],
|
|
6487
|
+
['permissions', ['pagePermissionGroups', 'formPermissionGroups', 'scopeDimensions', 'scopeGrantSources', 'dataScopePolicies']],
|
|
6288
6488
|
]);
|
|
6289
6489
|
|
|
6290
6490
|
function getResourceTypeFilters(positional = [], flags = {}) {
|
|
@@ -6313,7 +6513,7 @@ function getResourceTypeFilters(positional = [], flags = {}) {
|
|
|
6313
6513
|
if (!key) {
|
|
6314
6514
|
fail(
|
|
6315
6515
|
`未知资源类型: ${rawType}。常用类型包括 workflow, notification, route, menu, role, ` +
|
|
6316
|
-
`storage, page-permission-group, form-permission-group, public-access。`
|
|
6516
|
+
`storage, page-permission-group, form-permission-group, scope-policy, public-access。`
|
|
6317
6517
|
);
|
|
6318
6518
|
}
|
|
6319
6519
|
filters.add(key);
|
|
@@ -6523,6 +6723,7 @@ function validateWorkspaceResources(manifest) {
|
|
|
6523
6723
|
}
|
|
6524
6724
|
}
|
|
6525
6725
|
validateNotificationReferences(manifest.notifications || [], errors);
|
|
6726
|
+
validateScopePermissionResources(manifest, errors, warnings);
|
|
6526
6727
|
return {
|
|
6527
6728
|
valid: errors.length === 0,
|
|
6528
6729
|
errors,
|
|
@@ -6610,6 +6811,9 @@ function generateResourceTypes(manifest, outputFile) {
|
|
|
6610
6811
|
const storageConfigCodes = unique((manifest.storageConfigs || []).map(item => item.code).filter(Boolean));
|
|
6611
6812
|
const authConfigCodes = unique((manifest.authConfigs || []).map(item => item.code).filter(Boolean));
|
|
6612
6813
|
const publicAccessPolicyCodes = unique((manifest.publicAccessPolicies || []).map(item => item.code).filter(Boolean));
|
|
6814
|
+
const scopeDimensionCodes = unique((manifest.scopeDimensions || []).map(item => item.code).filter(Boolean));
|
|
6815
|
+
const scopeGrantSourceCodes = unique((manifest.scopeGrantSources || []).map(item => item.code).filter(Boolean));
|
|
6816
|
+
const dataScopePolicyCodes = unique((manifest.dataScopePolicies || []).map(item => item.code).filter(Boolean));
|
|
6613
6817
|
const content = [
|
|
6614
6818
|
'/* eslint-disable */',
|
|
6615
6819
|
'// Generated by openxiangda resource typegen. Do not edit manually.',
|
|
@@ -6638,6 +6842,15 @@ function generateResourceTypes(manifest, outputFile) {
|
|
|
6638
6842
|
`export const publicAccessPolicyCodes = ${JSON.stringify(publicAccessPolicyCodes, null, 2)} as const`,
|
|
6639
6843
|
'export type PublicAccessPolicyCode = typeof publicAccessPolicyCodes[number]',
|
|
6640
6844
|
'',
|
|
6845
|
+
`export const scopeDimensionCodes = ${JSON.stringify(scopeDimensionCodes, null, 2)} as const`,
|
|
6846
|
+
'export type ScopeDimensionCode = typeof scopeDimensionCodes[number]',
|
|
6847
|
+
'',
|
|
6848
|
+
`export const scopeGrantSourceCodes = ${JSON.stringify(scopeGrantSourceCodes, null, 2)} as const`,
|
|
6849
|
+
'export type ScopeGrantSourceCode = typeof scopeGrantSourceCodes[number]',
|
|
6850
|
+
'',
|
|
6851
|
+
`export const dataScopePolicyCodes = ${JSON.stringify(dataScopePolicyCodes, null, 2)} as const`,
|
|
6852
|
+
'export type DataScopePolicyCode = typeof dataScopePolicyCodes[number]',
|
|
6853
|
+
'',
|
|
6641
6854
|
`export const runtimeMenus = ${JSON.stringify(menus, null, 2)} as const`,
|
|
6642
6855
|
'',
|
|
6643
6856
|
`export const runtimeRoutes = ${JSON.stringify(routes, null, 2)} as const`,
|
|
@@ -6657,6 +6870,9 @@ function generateResourceTypes(manifest, outputFile) {
|
|
|
6657
6870
|
functions: functionCodes.length,
|
|
6658
6871
|
authConfigs: authConfigCodes.length,
|
|
6659
6872
|
publicAccessPolicies: publicAccessPolicyCodes.length,
|
|
6873
|
+
scopeDimensions: scopeDimensionCodes.length,
|
|
6874
|
+
scopeGrantSources: scopeGrantSourceCodes.length,
|
|
6875
|
+
dataScopePolicies: dataScopePolicyCodes.length,
|
|
6660
6876
|
};
|
|
6661
6877
|
}
|
|
6662
6878
|
|
|
@@ -6836,10 +7052,61 @@ function validateResourceItem(kind, item, errors, warnings) {
|
|
|
6836
7052
|
errors.push(`${label}: externalRoleCodes 必须是数组`);
|
|
6837
7053
|
}
|
|
6838
7054
|
}
|
|
7055
|
+
if (kind === 'scopeDimensions') {
|
|
7056
|
+
if (!item.name) warnings.push(`${label}: 未声明 name,将使用 code`);
|
|
7057
|
+
if (item.hierarchyMode && !['flat', 'self_parent'].includes(String(item.hierarchyMode))) {
|
|
7058
|
+
errors.push(`${label}: hierarchyMode 只能是 flat 或 self_parent`);
|
|
7059
|
+
}
|
|
7060
|
+
if ((item.sourceFormUuid || item.formUuid) && !item.sourceValueField && !item.valueField) {
|
|
7061
|
+
errors.push(`${label}: 使用表单维护维度时缺少 sourceValueField`);
|
|
7062
|
+
}
|
|
7063
|
+
}
|
|
7064
|
+
if (kind === 'scopeGrantSources') {
|
|
7065
|
+
if (!item.name) warnings.push(`${label}: 未声明 name,将使用 code`);
|
|
7066
|
+
if (!item.sourceFormUuid && !item.formUuid) errors.push(`${label}: 缺少 sourceFormUuid`);
|
|
7067
|
+
const subjectMappings = item.subjectMappings || item.subjects;
|
|
7068
|
+
const dimensionMappings = item.dimensionMappings || item.dimensions;
|
|
7069
|
+
if (!Array.isArray(subjectMappings) || subjectMappings.length === 0) {
|
|
7070
|
+
errors.push(`${label}: 缺少 subjectMappings`);
|
|
7071
|
+
}
|
|
7072
|
+
if (!Array.isArray(dimensionMappings) || dimensionMappings.length === 0) {
|
|
7073
|
+
errors.push(`${label}: 缺少 dimensionMappings`);
|
|
7074
|
+
}
|
|
7075
|
+
}
|
|
7076
|
+
if (kind === 'dataScopePolicies') {
|
|
7077
|
+
if (!item.name) warnings.push(`${label}: 未声明 name,将使用 code`);
|
|
7078
|
+
const rules = item.rules || item.dimensions;
|
|
7079
|
+
if (!Array.isArray(rules) || rules.length === 0) {
|
|
7080
|
+
errors.push(`${label}: 缺少 rules`);
|
|
7081
|
+
} else {
|
|
7082
|
+
rules.forEach((rule, index) => {
|
|
7083
|
+
const ruleLabel = `${label}.rules[${index}]`;
|
|
7084
|
+
const dimensionCode = rule.dimensionCode || rule.dimension;
|
|
7085
|
+
const targetField = rule.field || rule.targetField;
|
|
7086
|
+
if (!dimensionCode) errors.push(`${ruleLabel}: 缺少 dimensionCode`);
|
|
7087
|
+
if (!targetField) errors.push(`${ruleLabel}: 缺少 field/targetField`);
|
|
7088
|
+
if (rule.valuePath && !isSafeScopePolicyValuePath(rule.valuePath)) {
|
|
7089
|
+
errors.push(`${ruleLabel}: valuePath 只能包含字母、数字、下划线、中划线、点或 >`);
|
|
7090
|
+
}
|
|
7091
|
+
if (isScopePolicyMultiValueComponent(rule.componentType)) {
|
|
7092
|
+
warnings.push(`${ruleLabel}: 多值选择/人员/部门字段不适合直接作为 scope_policy 目标字段,建议派生隐藏 scalar key 字段`);
|
|
7093
|
+
} else if (
|
|
7094
|
+
!rule.valuePath &&
|
|
7095
|
+
!rule.componentType &&
|
|
7096
|
+
looksLikeJsonScopeTargetField(targetField)
|
|
7097
|
+
) {
|
|
7098
|
+
warnings.push(`${ruleLabel}: 目标字段看起来可能是选择/人员/部门类字段;若底层是 JSONB,请声明 valuePath:"value" 或 componentType,优先使用隐藏 scalar key`);
|
|
7099
|
+
}
|
|
7100
|
+
});
|
|
7101
|
+
}
|
|
7102
|
+
}
|
|
6839
7103
|
if (kind === 'pagePermissionGroups' && !item.name) errors.push(`${label}: 缺少 name`);
|
|
6840
7104
|
if (kind === 'formPermissionGroups') {
|
|
6841
7105
|
if (!item.name) errors.push(`${label}: 缺少 name`);
|
|
6842
7106
|
if (!item.formCode && !item.formUuid) errors.push(`${label}: 缺少 formCode 或 formUuid`);
|
|
7107
|
+
if (item.dataPermission?.type === 'scope_policy' && !getScopePolicyCode(item.dataPermission)) {
|
|
7108
|
+
errors.push(`${label}: dataPermission.type=scope_policy 时必须声明 policyCode`);
|
|
7109
|
+
}
|
|
6843
7110
|
}
|
|
6844
7111
|
if (kind === 'formSettings') {
|
|
6845
7112
|
if (!item.formCode && !item.formUuid && !item.code) {
|
|
@@ -6856,6 +7123,105 @@ function validateResourceItem(kind, item, errors, warnings) {
|
|
|
6856
7123
|
}
|
|
6857
7124
|
}
|
|
6858
7125
|
|
|
7126
|
+
function validateScopePermissionResources(manifest, errors, warnings) {
|
|
7127
|
+
const policyCodes = new Set(
|
|
7128
|
+
(manifest.dataScopePolicies || [])
|
|
7129
|
+
.map(item => item.code || item.resourceCode)
|
|
7130
|
+
.filter(Boolean)
|
|
7131
|
+
.map(String)
|
|
7132
|
+
);
|
|
7133
|
+
const scopePolicyRefs = [];
|
|
7134
|
+
const inspectPermissionGroup = (kind, item) => {
|
|
7135
|
+
const groups = kind === 'dataViews'
|
|
7136
|
+
? (Array.isArray(item.permissionGroups) ? item.permissionGroups : [])
|
|
7137
|
+
: [item];
|
|
7138
|
+
for (const group of groups) {
|
|
7139
|
+
const dataPermission = group?.dataPermission;
|
|
7140
|
+
if (!dataPermission) continue;
|
|
7141
|
+
if (dataPermission.type === 'scope_policy') {
|
|
7142
|
+
const policyCode = getScopePolicyCode(dataPermission);
|
|
7143
|
+
if (!policyCode) {
|
|
7144
|
+
errors.push(`${resourceLabel(kind, item)}: scope_policy 缺少 policyCode`);
|
|
7145
|
+
continue;
|
|
7146
|
+
}
|
|
7147
|
+
scopePolicyRefs.push(policyCode);
|
|
7148
|
+
if (policyCodes.size > 0 && !policyCodes.has(policyCode)) {
|
|
7149
|
+
warnings.push(`${resourceLabel(kind, item)}: scope_policy 引用 ${policyCode},但 src/resources 中未声明同名 data-scope-policy`);
|
|
7150
|
+
}
|
|
7151
|
+
} else if (containsBusinessScopeLikeField(dataPermission)) {
|
|
7152
|
+
warnings.push(`${resourceLabel(kind, item)}: 检测到疑似业务范围字段条件,业务对象数量超过少量枚举时优先改为 dataPermission.type=scope_policy`);
|
|
7153
|
+
}
|
|
7154
|
+
}
|
|
7155
|
+
};
|
|
7156
|
+
for (const group of manifest.formPermissionGroups || []) {
|
|
7157
|
+
inspectPermissionGroup('formPermissionGroups', group);
|
|
7158
|
+
}
|
|
7159
|
+
for (const view of manifest.dataViews || []) {
|
|
7160
|
+
inspectPermissionGroup('dataViews', view);
|
|
7161
|
+
}
|
|
7162
|
+
const formGroupCount = (manifest.formPermissionGroups || []).length;
|
|
7163
|
+
const pageGroupCount = (manifest.pagePermissionGroups || []).length;
|
|
7164
|
+
if (formGroupCount >= 30) {
|
|
7165
|
+
warnings.push(`formPermissionGroups 当前 ${formGroupCount} 个;如这些组按客户/项目/区域/学院/班级/门店拆分,建议收敛为业务授权表 + scope_policy`);
|
|
7166
|
+
}
|
|
7167
|
+
if (pageGroupCount >= 30) {
|
|
7168
|
+
warnings.push(`pagePermissionGroups 当前 ${pageGroupCount} 个;如这些组按业务对象拆分,建议只保留入口角色,数据范围交给 scope_policy`);
|
|
7169
|
+
}
|
|
7170
|
+
if (scopePolicyRefs.length > 0 && (manifest.scopeGrantSources || []).length === 0) {
|
|
7171
|
+
warnings.push('已引用 scope_policy,但未声明 scopeGrantSources;请确认授权来源由平台后台或其他工作区维护');
|
|
7172
|
+
}
|
|
7173
|
+
}
|
|
7174
|
+
|
|
7175
|
+
function getScopePolicyCode(dataPermission = {}) {
|
|
7176
|
+
return String(
|
|
7177
|
+
dataPermission.policyCode ||
|
|
7178
|
+
dataPermission.scopePolicyCode ||
|
|
7179
|
+
dataPermission.code ||
|
|
7180
|
+
''
|
|
7181
|
+
).trim();
|
|
7182
|
+
}
|
|
7183
|
+
|
|
7184
|
+
function isSafeScopePolicyValuePath(value) {
|
|
7185
|
+
return /^[A-Za-z0-9_.>-]+$/.test(String(value || ''));
|
|
7186
|
+
}
|
|
7187
|
+
|
|
7188
|
+
function isScopePolicyMultiValueComponent(componentType) {
|
|
7189
|
+
const normalized = String(componentType || '').trim().toLowerCase();
|
|
7190
|
+
return [
|
|
7191
|
+
'multiselect',
|
|
7192
|
+
'multiselectfield',
|
|
7193
|
+
'checkbox',
|
|
7194
|
+
'checkboxfield',
|
|
7195
|
+
'cascadeselect',
|
|
7196
|
+
'cascadeselectfield',
|
|
7197
|
+
].includes(normalized);
|
|
7198
|
+
}
|
|
7199
|
+
|
|
7200
|
+
function looksLikeJsonScopeTargetField(field) {
|
|
7201
|
+
return /(select|option|user|employee|member|dept|department|linked|association|人员|用户|部门|选择)/i.test(String(field || ''));
|
|
7202
|
+
}
|
|
7203
|
+
|
|
7204
|
+
function containsBusinessScopeLikeField(value) {
|
|
7205
|
+
const fields = [];
|
|
7206
|
+
const visit = item => {
|
|
7207
|
+
if (!item || typeof item !== 'object') return;
|
|
7208
|
+
if (Array.isArray(item)) {
|
|
7209
|
+
item.forEach(visit);
|
|
7210
|
+
return;
|
|
7211
|
+
}
|
|
7212
|
+
if (item.field || item.key || item.targetField) {
|
|
7213
|
+
fields.push(String(item.field || item.key || item.targetField));
|
|
7214
|
+
}
|
|
7215
|
+
visit(item.condition);
|
|
7216
|
+
visit(item.conditions);
|
|
7217
|
+
visit(item.rules);
|
|
7218
|
+
};
|
|
7219
|
+
visit(value);
|
|
7220
|
+
return fields.some(field =>
|
|
7221
|
+
/(scope|customer|client|project|region|area|store|shop|campus|college|school|class|班级|学院|校区|区域|门店|客户|项目)/i.test(field)
|
|
7222
|
+
);
|
|
7223
|
+
}
|
|
7224
|
+
|
|
6859
7225
|
function validateDataViewPerformance(label, definition, errors, warnings, storageMode = 'materialized') {
|
|
6860
7226
|
const viewType = String(definition.viewType || 'row').toLowerCase();
|
|
6861
7227
|
if (viewType !== 'row' && viewType !== 'aggregate') return;
|
|
@@ -7306,6 +7672,9 @@ async function buildResourcePlan(config, target, manifest) {
|
|
|
7306
7672
|
await addAutomationPlanActions(config, target, actions, manifest.automations, existing.automations);
|
|
7307
7673
|
addPlanActions(actions, 'dataView', manifest.dataViews, existing.dataViews, (item, current) => dataViewEquals(target.bound, item, current));
|
|
7308
7674
|
addPlanActions(actions, 'storageConfig', manifest.storageConfigs, existing.storageConfigs, (item, current) => storageConfigEquals(item, current));
|
|
7675
|
+
addPlanActions(actions, 'scopeDimension', manifest.scopeDimensions, existing.scopeDimensions, (item, current) => scopeDimensionEquals(target.bound, item, current));
|
|
7676
|
+
addPlanActions(actions, 'scopeGrantSource', manifest.scopeGrantSources, existing.scopeGrantSources, (item, current) => scopeGrantSourceEquals(target.bound, item, current));
|
|
7677
|
+
addPlanActions(actions, 'dataScopePolicy', manifest.dataScopePolicies, existing.dataScopePolicies, dataScopePolicyEquals);
|
|
7309
7678
|
addPlanActions(actions, 'pagePermissionGroup', manifest.pagePermissionGroups, existing.pagePermissionGroups, (item, current) => pagePermissionGroupEquals(target.bound, item, current));
|
|
7310
7679
|
addPlanActions(actions, 'formPermissionGroup', manifest.formPermissionGroups, existing.formPermissionGroups, (item, current) => formPermissionGroupEquals(target.bound, item, current));
|
|
7311
7680
|
for (const item of manifest.formSettings || []) {
|
|
@@ -7378,6 +7747,9 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
|
|
|
7378
7747
|
await publishAuthConfigResources(config, target, manifest.authConfigs || [], result, publishOptions);
|
|
7379
7748
|
await publishRouteResources(config, target, manifest.routes || [], result, publishOptions);
|
|
7380
7749
|
await publishAutomationResources(config, target, manifest.automations || [], result, publishOptions);
|
|
7750
|
+
await publishScopeDimensionResources(config, target, manifest.scopeDimensions || [], result, publishOptions);
|
|
7751
|
+
await publishScopeGrantSourceResources(config, target, manifest.scopeGrantSources || [], result, publishOptions);
|
|
7752
|
+
await publishDataScopePolicyResources(config, target, manifest.dataScopePolicies || [], result, publishOptions);
|
|
7381
7753
|
await publishDataViewResources(config, target, manifest.dataViews || [], result, publishOptions);
|
|
7382
7754
|
await publishStorageConfigResources(config, target, manifest.storageConfigs || [], result, publishOptions);
|
|
7383
7755
|
await publishPublicAccessPolicyResources(config, target, manifest.publicAccessPolicies || [], result, publishOptions);
|
|
@@ -7408,6 +7780,9 @@ async function fetchExistingResourceMaps(config, target, manifest) {
|
|
|
7408
7780
|
automations: new Map(),
|
|
7409
7781
|
dataViews: new Map(),
|
|
7410
7782
|
storageConfigs: new Map(),
|
|
7783
|
+
scopeDimensions: new Map(),
|
|
7784
|
+
scopeGrantSources: new Map(),
|
|
7785
|
+
dataScopePolicies: new Map(),
|
|
7411
7786
|
pagePermissionGroups: new Map(),
|
|
7412
7787
|
formPermissionGroups: new Map(),
|
|
7413
7788
|
};
|
|
@@ -7620,6 +7995,30 @@ async function fetchExistingResourceMaps(config, target, manifest) {
|
|
|
7620
7995
|
});
|
|
7621
7996
|
}
|
|
7622
7997
|
}
|
|
7998
|
+
if ((manifest.scopeDimensions || []).length > 0) {
|
|
7999
|
+
const data = await requestWithAuth(
|
|
8000
|
+
config,
|
|
8001
|
+
target.profileName,
|
|
8002
|
+
`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/scope/dimensions`
|
|
8003
|
+
);
|
|
8004
|
+
indexByCode(maps.scopeDimensions, normalizeItems(data), item => item.code || item.resourceCode);
|
|
8005
|
+
}
|
|
8006
|
+
if ((manifest.scopeGrantSources || []).length > 0) {
|
|
8007
|
+
const data = await requestWithAuth(
|
|
8008
|
+
config,
|
|
8009
|
+
target.profileName,
|
|
8010
|
+
`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/scope/grant-sources`
|
|
8011
|
+
);
|
|
8012
|
+
indexByCode(maps.scopeGrantSources, normalizeItems(data), item => item.code || item.resourceCode);
|
|
8013
|
+
}
|
|
8014
|
+
if ((manifest.dataScopePolicies || []).length > 0) {
|
|
8015
|
+
const data = await requestWithAuth(
|
|
8016
|
+
config,
|
|
8017
|
+
target.profileName,
|
|
8018
|
+
`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/scope/policies`
|
|
8019
|
+
);
|
|
8020
|
+
indexByCode(maps.dataScopePolicies, normalizeItems(data), item => item.code || item.resourceCode);
|
|
8021
|
+
}
|
|
7623
8022
|
if ((manifest.pagePermissionGroups || []).length > 0) {
|
|
7624
8023
|
const data = await requestWithAuth(
|
|
7625
8024
|
config,
|
|
@@ -8454,6 +8853,102 @@ async function publishAuthConfigResources(config, target, authConfigs, result, o
|
|
|
8454
8853
|
}
|
|
8455
8854
|
}
|
|
8456
8855
|
|
|
8856
|
+
async function publishScopeDimensionResources(config, target, dimensions, result, options = {}) {
|
|
8857
|
+
for (const item of dimensions) {
|
|
8858
|
+
const code = item.code || item.resourceCode;
|
|
8859
|
+
if (shouldSkipNoopResource(options, 'scopeDimension', code)) {
|
|
8860
|
+
recordNoopResource(result, 'scopeDimension', code, options);
|
|
8861
|
+
continue;
|
|
8862
|
+
}
|
|
8863
|
+
const existing = await findExistingScopeResource(config, target, 'dimensions', code);
|
|
8864
|
+
const body = normalizeScopeDimensionManifest(item, target.bound);
|
|
8865
|
+
const data = await requestWithAuth(
|
|
8866
|
+
config,
|
|
8867
|
+
target.profileName,
|
|
8868
|
+
`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/scope/dimensions/${encodeURIComponent(code)}`,
|
|
8869
|
+
{ method: 'POST', body }
|
|
8870
|
+
);
|
|
8871
|
+
saveScopeResource(target, 'scopeDimensions', code, data);
|
|
8872
|
+
result.published.push({
|
|
8873
|
+
kind: 'scopeDimension',
|
|
8874
|
+
code,
|
|
8875
|
+
action: existing ? 'update' : 'create',
|
|
8876
|
+
id: data?.id,
|
|
8877
|
+
});
|
|
8878
|
+
}
|
|
8879
|
+
}
|
|
8880
|
+
|
|
8881
|
+
async function publishScopeGrantSourceResources(config, target, sources, result, options = {}) {
|
|
8882
|
+
for (const item of sources) {
|
|
8883
|
+
const code = item.code || item.resourceCode;
|
|
8884
|
+
if (shouldSkipNoopResource(options, 'scopeGrantSource', code)) {
|
|
8885
|
+
recordNoopResource(result, 'scopeGrantSource', code, options);
|
|
8886
|
+
continue;
|
|
8887
|
+
}
|
|
8888
|
+
const existing = await findExistingScopeResource(config, target, 'grant-sources', code);
|
|
8889
|
+
const body = normalizeScopeGrantSourceManifest(item, target.bound);
|
|
8890
|
+
const data = await requestWithAuth(
|
|
8891
|
+
config,
|
|
8892
|
+
target.profileName,
|
|
8893
|
+
`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/scope/grant-sources/${encodeURIComponent(code)}`,
|
|
8894
|
+
{ method: 'POST', body }
|
|
8895
|
+
);
|
|
8896
|
+
saveScopeResource(target, 'scopeGrantSources', code, data);
|
|
8897
|
+
const published = {
|
|
8898
|
+
kind: 'scopeGrantSource',
|
|
8899
|
+
code,
|
|
8900
|
+
action: existing ? 'update' : 'create',
|
|
8901
|
+
id: data?.id,
|
|
8902
|
+
};
|
|
8903
|
+
if (item.sync === true) {
|
|
8904
|
+
published.sync = await requestWithAuth(
|
|
8905
|
+
config,
|
|
8906
|
+
target.profileName,
|
|
8907
|
+
`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/scope/grant-sources/${encodeURIComponent(code)}/sync`,
|
|
8908
|
+
{ method: 'POST', body: { dryRun: false } }
|
|
8909
|
+
);
|
|
8910
|
+
}
|
|
8911
|
+
result.published.push(published);
|
|
8912
|
+
}
|
|
8913
|
+
}
|
|
8914
|
+
|
|
8915
|
+
async function publishDataScopePolicyResources(config, target, policies, result, options = {}) {
|
|
8916
|
+
for (const item of policies) {
|
|
8917
|
+
const code = item.code || item.resourceCode;
|
|
8918
|
+
if (shouldSkipNoopResource(options, 'dataScopePolicy', code)) {
|
|
8919
|
+
recordNoopResource(result, 'dataScopePolicy', code, options);
|
|
8920
|
+
continue;
|
|
8921
|
+
}
|
|
8922
|
+
const existing = await findExistingScopeResource(config, target, 'policies', code);
|
|
8923
|
+
const body = normalizeDataScopePolicyManifest(item);
|
|
8924
|
+
const data = await requestWithAuth(
|
|
8925
|
+
config,
|
|
8926
|
+
target.profileName,
|
|
8927
|
+
`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/scope/policies/${encodeURIComponent(code)}`,
|
|
8928
|
+
{ method: 'POST', body }
|
|
8929
|
+
);
|
|
8930
|
+
saveScopeResource(target, 'dataScopePolicies', code, data);
|
|
8931
|
+
result.published.push({
|
|
8932
|
+
kind: 'dataScopePolicy',
|
|
8933
|
+
code,
|
|
8934
|
+
action: existing ? 'update' : 'create',
|
|
8935
|
+
id: data?.id,
|
|
8936
|
+
});
|
|
8937
|
+
}
|
|
8938
|
+
}
|
|
8939
|
+
|
|
8940
|
+
async function findExistingScopeResource(config, target, segment, code) {
|
|
8941
|
+
if (!code) return null;
|
|
8942
|
+
const data = await requestWithAuth(
|
|
8943
|
+
config,
|
|
8944
|
+
target.profileName,
|
|
8945
|
+
`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/scope/${segment}`
|
|
8946
|
+
);
|
|
8947
|
+
return normalizeItems(data).find(item =>
|
|
8948
|
+
String(item.code || item.resourceCode || '') === String(code)
|
|
8949
|
+
);
|
|
8950
|
+
}
|
|
8951
|
+
|
|
8457
8952
|
async function publishDataViewResources(config, target, dataViews, result, options = {}) {
|
|
8458
8953
|
for (const dataViewItem of dataViews) {
|
|
8459
8954
|
if (shouldSkipNoopResource(options, 'dataView', dataViewItem.code)) {
|
|
@@ -10016,6 +10511,60 @@ function normalizePublicAccessPolicyManifest(bound, policy) {
|
|
|
10016
10511
|
});
|
|
10017
10512
|
}
|
|
10018
10513
|
|
|
10514
|
+
function normalizeScopeDimensionManifest(dimension, bound) {
|
|
10515
|
+
const code = dimension.code || dimension.resourceCode;
|
|
10516
|
+
const sourceFormUuid =
|
|
10517
|
+
dimension.sourceFormUuid ||
|
|
10518
|
+
dimension.formUuid ||
|
|
10519
|
+
resolveOptionalFormUuid(bound, dimension.sourceFormCode || dimension.formCode);
|
|
10520
|
+
return stripUndefinedValues({
|
|
10521
|
+
code,
|
|
10522
|
+
name: dimension.name || dimension.title || code,
|
|
10523
|
+
valueType: dimension.valueType || 'string',
|
|
10524
|
+
hierarchyMode: dimension.hierarchyMode || 'flat',
|
|
10525
|
+
sourceFormUuid,
|
|
10526
|
+
sourceValueField: dimension.sourceValueField || dimension.valueField,
|
|
10527
|
+
sourceLabelField: dimension.sourceLabelField || dimension.labelField,
|
|
10528
|
+
sourceParentField: dimension.sourceParentField || dimension.parentField,
|
|
10529
|
+
enabledField: dimension.enabledField,
|
|
10530
|
+
enabled: dimension.enabled !== false,
|
|
10531
|
+
configJson: dimension.configJson || dimension.config,
|
|
10532
|
+
});
|
|
10533
|
+
}
|
|
10534
|
+
|
|
10535
|
+
function normalizeScopeGrantSourceManifest(source, bound) {
|
|
10536
|
+
const code = source.code || source.resourceCode;
|
|
10537
|
+
const sourceFormUuid =
|
|
10538
|
+
source.sourceFormUuid ||
|
|
10539
|
+
source.formUuid ||
|
|
10540
|
+
resolveOptionalFormUuid(bound, source.sourceFormCode || source.formCode);
|
|
10541
|
+
return stripUndefinedValues({
|
|
10542
|
+
code,
|
|
10543
|
+
name: source.name || source.title || code,
|
|
10544
|
+
sourceFormUuid,
|
|
10545
|
+
subjectMappings: source.subjectMappings || source.subjects || [],
|
|
10546
|
+
dimensionMappings: source.dimensionMappings || source.dimensions || [],
|
|
10547
|
+
operationField: source.operationField,
|
|
10548
|
+
enabledField: source.enabledField,
|
|
10549
|
+
effectiveFromField: source.effectiveFromField,
|
|
10550
|
+
effectiveToField: source.effectiveToField,
|
|
10551
|
+
filterJson: source.filterJson || source.filter,
|
|
10552
|
+
enabled: source.enabled !== false,
|
|
10553
|
+
});
|
|
10554
|
+
}
|
|
10555
|
+
|
|
10556
|
+
function normalizeDataScopePolicyManifest(policy) {
|
|
10557
|
+
const code = policy.code || policy.resourceCode;
|
|
10558
|
+
return stripUndefinedValues({
|
|
10559
|
+
code,
|
|
10560
|
+
name: policy.name || policy.title || code,
|
|
10561
|
+
matchMode: policy.matchMode === 'OR' ? 'OR' : 'AND',
|
|
10562
|
+
rules: policy.rules || policy.dimensions || [],
|
|
10563
|
+
configJson: policy.configJson || policy.config,
|
|
10564
|
+
enabled: policy.enabled !== false,
|
|
10565
|
+
});
|
|
10566
|
+
}
|
|
10567
|
+
|
|
10019
10568
|
function normalizePublicAccessGrants(bound, grants = {}) {
|
|
10020
10569
|
const formEntries = [
|
|
10021
10570
|
...normalizePermissionCodeArray(grants.forms),
|
|
@@ -10579,6 +11128,30 @@ function formPermissionGroupEquals(bound, desired, existing) {
|
|
|
10579
11128
|
return stableStringifyForPlan(current, process.cwd()) === stableStringifyForPlan(expected, desired.__dir || process.cwd());
|
|
10580
11129
|
}
|
|
10581
11130
|
|
|
11131
|
+
function scopeDimensionEquals(bound, desired, existing) {
|
|
11132
|
+
if (!existing) return false;
|
|
11133
|
+
const expected = normalizeScopeDimensionManifest(desired, bound);
|
|
11134
|
+
return scopeResourceEquals(expected, existing, desired.__dir);
|
|
11135
|
+
}
|
|
11136
|
+
|
|
11137
|
+
function scopeGrantSourceEquals(bound, desired, existing) {
|
|
11138
|
+
if (!existing) return false;
|
|
11139
|
+
const expected = normalizeScopeGrantSourceManifest(desired, bound);
|
|
11140
|
+
return scopeResourceEquals(expected, existing, desired.__dir);
|
|
11141
|
+
}
|
|
11142
|
+
|
|
11143
|
+
function dataScopePolicyEquals(desired, existing) {
|
|
11144
|
+
if (!existing) return false;
|
|
11145
|
+
const expected = normalizeDataScopePolicyManifest(desired);
|
|
11146
|
+
return scopeResourceEquals(expected, existing, desired.__dir);
|
|
11147
|
+
}
|
|
11148
|
+
|
|
11149
|
+
function scopeResourceEquals(expected, existing, baseDir) {
|
|
11150
|
+
const current = pickObjectKeys(existing, Object.keys(expected));
|
|
11151
|
+
return stableStringifyForPlan(current, process.cwd()) ===
|
|
11152
|
+
stableStringifyForPlan(expected, baseDir || process.cwd());
|
|
11153
|
+
}
|
|
11154
|
+
|
|
10582
11155
|
function normalizeFormPermissionGroupManifest(bound, group) {
|
|
10583
11156
|
const formUuid = resolveManifestFormUuid(bound, group);
|
|
10584
11157
|
const body = {
|
package/lib/design-gates.js
CHANGED
|
@@ -113,12 +113,14 @@ const DESIGN_GATE_TOPICS = [
|
|
|
113
113
|
{
|
|
114
114
|
code: 'permissions',
|
|
115
115
|
title: '账号 / 角色 / 页面权限 / 表单数据范围',
|
|
116
|
-
triggers: ['权限', '角色', 'RBAC', '账号权限', '平台账号', '组织账号', '角色治理', '数据范围', '只看自己', '部门数据', '页面权限', '字段权限', '查询参数权限'],
|
|
116
|
+
triggers: ['权限', '角色', 'RBAC', '账号权限', '平台账号', '组织账号', '角色治理', '数据范围', '业务范围', 'scope_policy', '只看自己', '部门数据', '页面权限', '字段权限', '查询参数权限'],
|
|
117
117
|
mustAsk: [
|
|
118
118
|
'权限模式选择:managed-platform-account、existing-platform-user-assignment、static-role-permission、query-param-context 中哪一种,为什么',
|
|
119
119
|
'账号来源是什么:应用创建平台账号/部门、选择已有平台用户、固定角色成员,还是仅使用低风险查询参数上下文',
|
|
120
120
|
'是否创建或维护平台账号/部门;如果是,哪些 App Function/organization SDK 负责创建、更新、重置密码和同步审计',
|
|
121
121
|
'角色成员如何维护:角色分配表、平台角色手工维护、静态 roles manifest、还是公开访问 externalRoleCodes',
|
|
122
|
+
'业务范围是否来自应用表单:用户/角色到客户、项目、区域、门店、学院、班级等授权关系是否需要 scopeDimensions、scopeGrantSources、dataScopePolicies',
|
|
123
|
+
'业务范围数量是否超过少量固定枚举;如果会增长到几十/上百个对象,是否采用 scope_policy 而不是为每个对象建角色/权限组',
|
|
122
124
|
'哪些角色可以设置应用角色、分配角色成员、给角色分配接口权限;这些角色是否需要 `app:role:manage`',
|
|
123
125
|
'哪些角色可以维护页面权限组、表单权限组、组织账号;是否需要 `app:page-permission-group:manage`、`app:form-permission-group:manage`、`app:organization:manage`',
|
|
124
126
|
'业务范围字段是什么,哪些可见字段需要派生隐藏 scalar key 供表单权限条件匹配',
|
|
@@ -130,6 +132,8 @@ const DESIGN_GATE_TOPICS = [
|
|
|
130
132
|
'正式后台优先选择 managed-platform-account;如果平台账号已存在,选择 existing-platform-user-assignment;固定内部门户选择 static-role-permission',
|
|
131
133
|
'query-param-context 仅用于低风险页面上下文、筛选条件或公开 ticket 输入,不作为敏感数据授权依据',
|
|
132
134
|
'角色写 src/resources/roles,页面组写 permissions/page-groups,表单组写 permissions/form-groups,显式声明 dataScope/operations/fieldAccessPolicy',
|
|
135
|
+
'复杂业务范围优先写 permissions/scope-dimensions、permissions/scope-grant-sources、permissions/data-scope-policies,并在表单权限组或 data-view 权限组使用 dataPermission.type=scope_policy',
|
|
136
|
+
'scope_policy 默认语义是个人授权 + 当前应用角色授权;多维 rules 显式 AND;空授权集合拒绝;管理员绕过但可审计',
|
|
133
137
|
'能管理角色或给别人分配角色的应用角色必须在 roles manifest 声明 apiPermissionCodes;至少包含 app:role:manage,按需加入 app:page-permission-group:manage、app:form-permission-group:manage、app:organization:manage',
|
|
134
138
|
'由校区管理员等委托管理员创建的新角色,如果还具备继续管理账号/角色/权限的能力,也必须同步声明并发布对应 apiPermissionCodes',
|
|
135
139
|
'账号治理闭环使用 organization_unit、system_account、role_assignment 等业务表,加 roles、page groups、form groups、sync App Functions 和 PermissionBoundary',
|
|
@@ -141,6 +145,9 @@ const DESIGN_GATE_TOPICS = [
|
|
|
141
145
|
'查询参数不能作为敏感授权依据,只能作为上下文、筛选或 ticket 输入',
|
|
142
146
|
'只配菜单可见,不配后端数据权限',
|
|
143
147
|
'为了省事授予全部数据再在前端过滤',
|
|
148
|
+
'为每个客户/项目/区域/学院/班级/门店创建一个角色或一个权限组',
|
|
149
|
+
'把应用表单里的业务范围强行同步成平台部门、平台角色或大量权限组',
|
|
150
|
+
'用数据冗余字段 + 成百上千个权限组表达动态业务授权',
|
|
144
151
|
'在页面里硬编码角色、账号、部门或权限范围',
|
|
145
152
|
'只给用户挂业务角色但没有给该角色绑定角色设置接口权限,导致后续新增账号/角色时运行时报无权限',
|
|
146
153
|
'让普通管理员创建带管理能力的新角色,但没有同时发布新角色的 apiPermissionCodes',
|
|
@@ -150,6 +157,7 @@ const DESIGN_GATE_TOPICS = [
|
|
|
150
157
|
],
|
|
151
158
|
acceptance: [
|
|
152
159
|
'permission audit 输出无高危缺口',
|
|
160
|
+
'业务范围超过少量枚举时,resource validate/permission audit 不再出现权限组爆炸 warning,scope explain 能说明用户、当前角色、命中授权、缓存状态和最终策略',
|
|
153
161
|
'设计文档写明权限模式、账号来源、角色成员来源和权限矩阵',
|
|
154
162
|
'角色资源中声明了管理型角色的 apiPermissionCodes,并说明由谁首次授予',
|
|
155
163
|
'内部角色允许项可用,未授权项拒绝',
|
|
@@ -47,8 +47,9 @@ Use a normal form plus:
|
|
|
47
47
|
- a `status` field
|
|
48
48
|
- user-facing responsibility fields such as personnel, department, enum, or
|
|
49
49
|
SDK-backed select fields
|
|
50
|
-
- hidden permission scope keys
|
|
51
|
-
|
|
50
|
+
- hidden permission scope keys only for small fixed data-permission conditions;
|
|
51
|
+
for dynamic customer/project/region/store/college/class-like scopes, use
|
|
52
|
+
business authorization forms plus `scope_policy`
|
|
52
53
|
- an action log form
|
|
53
54
|
- a pure state machine in `domain/<feature>/state-machine.ts`
|
|
54
55
|
- a service method that changes status and writes the log in one path
|
|
@@ -133,24 +134,34 @@ For apps with managed accounts or dynamic roles:
|
|
|
133
134
|
`searchFieldId`, and a reasonable `pageSize` so typing in the dropdown
|
|
134
135
|
triggers remote search
|
|
135
136
|
4. Add hidden derived scope keys only when platform form permission conditions
|
|
136
|
-
require scalar matching, for example:
|
|
137
|
+
require scalar matching in a small fixed app, for example:
|
|
137
138
|
- `collegeScopeKey`
|
|
138
139
|
- `classScopeKey`
|
|
139
140
|
- `ownerDeptScopeKey`
|
|
140
141
|
- `ownerUserScopeKey`
|
|
141
142
|
5. Create page permission groups for entry visibility.
|
|
142
|
-
6.
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
6. For dynamic business ranges, declare
|
|
144
|
+
`permissions/scope-dimensions`, `permissions/scope-grant-sources`, and
|
|
145
|
+
`permissions/data-scope-policies`, then reference the policy from form
|
|
146
|
+
permission groups or Data View permission groups with
|
|
147
|
+
`dataPermission.type = "scope_policy"`.
|
|
148
|
+
7. Use condition-based data permissions only when the scope values are a small,
|
|
149
|
+
fixed set or map cleanly to platform organization/departments.
|
|
150
|
+
8. For delegated administrators, add role API permissions in
|
|
145
151
|
`src/resources/roles/<code>.json` with `apiPermissionCodes`, for example
|
|
146
152
|
`app:role:manage`, `app:page-permission-group:manage`,
|
|
147
153
|
`app:form-permission-group:manage`, and `app:organization:manage`.
|
|
148
|
-
|
|
154
|
+
9. Put sensitive writes behind App Functions with server-side role/scope checks.
|
|
149
155
|
|
|
150
156
|
Frontend button hiding is only user experience. It is not permission control.
|
|
151
157
|
Every sensitive action must still be protected by platform role/form permission
|
|
152
158
|
groups or backend-side JS_CODE checks.
|
|
153
159
|
|
|
160
|
+
Do not generate one role or one permission group per customer, project, region,
|
|
161
|
+
store, college, class, or other maintained business object. That configuration
|
|
162
|
+
will not scale and should be replaced with a form-maintained authorization
|
|
163
|
+
relationship and `scope_policy`.
|
|
164
|
+
|
|
154
165
|
Query parameters are not permission control either. They may prefill context,
|
|
155
166
|
select filters, or carry a signed/expiring ticket, but tampering with a query
|
|
156
167
|
parameter must not expand sensitive data access.
|
|
@@ -49,11 +49,15 @@ Required design:
|
|
|
49
49
|
|
|
50
50
|
- Role assignment form with `UserSelectField` / department fields for members.
|
|
51
51
|
- Business scope fields such as region, department, project, customer, class, or
|
|
52
|
-
college
|
|
53
|
-
|
|
52
|
+
college. When the scope object count can grow past a few fixed values, model
|
|
53
|
+
the authorization as `scopeDimensions`, `scopeGrantSources`, and
|
|
54
|
+
`dataScopePolicies` instead of creating one role or permission group per
|
|
55
|
+
object.
|
|
54
56
|
- Role sync App Function or automation only updates app role members, not
|
|
55
57
|
account records.
|
|
56
|
-
- Roles, page groups, and form groups remain the
|
|
58
|
+
- Roles, page groups, and form groups remain the RBAC enforcement layer.
|
|
59
|
+
`scope_policy` becomes the data-range enforcement layer for dynamic business
|
|
60
|
+
objects.
|
|
57
61
|
|
|
58
62
|
Use this when HR/IT or the platform admin owns account lifecycle.
|
|
59
63
|
|
|
@@ -109,6 +113,9 @@ organization_unit
|
|
|
109
113
|
-> src/resources/roles
|
|
110
114
|
-> permissions/page-groups
|
|
111
115
|
-> permissions/form-groups
|
|
116
|
+
-> permissions/scope-dimensions
|
|
117
|
+
-> permissions/scope-grant-sources
|
|
118
|
+
-> permissions/data-scope-policies
|
|
112
119
|
-> sync App Functions / JS_CODE
|
|
113
120
|
-> PermissionBoundary for display
|
|
114
121
|
-> backend role/scope checks for sensitive writes
|
|
@@ -119,6 +126,52 @@ Pages may show or hide navigation with `useAppMenus`, `useCanAccessRoute`, and
|
|
|
119
126
|
platform permission group, public-access grant, or App Function role/scope
|
|
120
127
|
check.
|
|
121
128
|
|
|
129
|
+
## Business Scope Authorization
|
|
130
|
+
|
|
131
|
+
Use this layer for application-maintained authorization relationships:
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
authorization form rows
|
|
135
|
+
-> scope grant source sync
|
|
136
|
+
-> materialized effective grants
|
|
137
|
+
-> Redis summary cache
|
|
138
|
+
-> form/data-view dataPermission.type=scope_policy
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The platform keeps RBAC and data range separate:
|
|
142
|
+
|
|
143
|
+
- Roles decide who can enter pages, submit/view forms, use operations, and
|
|
144
|
+
maintain permissions.
|
|
145
|
+
- Field access remains `fieldAccessPolicy`.
|
|
146
|
+
- Business scope policies decide which rows a matched role/user can read.
|
|
147
|
+
- App Functions must still check role/scope server-side before sensitive
|
|
148
|
+
writes.
|
|
149
|
+
|
|
150
|
+
Default semantics:
|
|
151
|
+
|
|
152
|
+
- Effective grants are personal grants plus current app role grants.
|
|
153
|
+
- Multi-dimension policies are `AND` by default.
|
|
154
|
+
- Empty grants deny.
|
|
155
|
+
- Admin bypass remains available, but use `openxiangda scope explain` and audit
|
|
156
|
+
logs to make bypass behavior visible.
|
|
157
|
+
- Authorization source rows should include an explicit status/enabled field and
|
|
158
|
+
grant sources should use `filterJson` or `enabledField` so drafts, disabled
|
|
159
|
+
rows, or rejected assignments are not materialized.
|
|
160
|
+
- Source forms must have a sync path. `sync: true` on the manifest runs during
|
|
161
|
+
publish; runtime changes need an automation/App Function or platform API call
|
|
162
|
+
that resyncs the affected grant source after create/update/delete.
|
|
163
|
+
- Policy target fields should normally be hidden scalar keys. If targeting a
|
|
164
|
+
JSONB option/person/department field directly, declare `valuePath: "value"` or
|
|
165
|
+
`componentType`; multi-value fields should be normalized into a scalar key or
|
|
166
|
+
a separate authorization row.
|
|
167
|
+
- App Function form read helpers enforce view permission for real callers. Do
|
|
168
|
+
not grant broad form read permission and then filter in function/page code.
|
|
169
|
+
|
|
170
|
+
Do not model a dynamic business object set by generating dozens or hundreds of
|
|
171
|
+
roles or permission groups. Use a business authorization form and `scope_policy`
|
|
172
|
+
when the object set is maintained by users, imported from another system, or
|
|
173
|
+
expected to grow.
|
|
174
|
+
|
|
122
175
|
## Role Setting Delegation
|
|
123
176
|
|
|
124
177
|
Setting application roles is itself permission controlled. A user can be a
|
|
@@ -179,6 +232,7 @@ Before implementation, write a matrix with at least these columns:
|
|
|
179
232
|
| Role source | role assignment form / platform role admin / roles manifest / public external role |
|
|
180
233
|
| Role setting delegation | which roles need `apiPermissionCodes`, who grants them first, and whether delegation can continue |
|
|
181
234
|
| Business scope fields | visible fields and hidden scalar keys |
|
|
235
|
+
| Business scope policy | dimensions, grant sources, policy code, sync trigger, empty-grant behavior |
|
|
182
236
|
| Page access | menu codes, route codes, path patterns, role codes |
|
|
183
237
|
| Form access | submit/view/manage operations, data scope, field access policy |
|
|
184
238
|
| Backend checks | App Functions that validate role/scope and deny bad input |
|
|
@@ -197,6 +251,10 @@ Before implementation, write a matrix with at least these columns:
|
|
|
197
251
|
- Mock role context, fake account IDs, fake form instance IDs, or empty-array
|
|
198
252
|
fallback displays.
|
|
199
253
|
- Giving users all form data and filtering in the browser.
|
|
254
|
+
- One platform role or one permission group per customer, project, region,
|
|
255
|
+
store, college, class, or other maintained object.
|
|
256
|
+
- Syncing application-maintained business scope into platform departments just
|
|
257
|
+
to reuse department data scope.
|
|
200
258
|
- Direct platform account API calls that bypass OpenXiangda organization SDK.
|
|
201
259
|
- Reusing public visitor roles as internal admin roles.
|
|
202
260
|
|
|
@@ -208,6 +266,9 @@ Before implementation, write a matrix with at least these columns:
|
|
|
208
266
|
- Admin roles that can manage roles, members, permission groups, or accounts
|
|
209
267
|
declare `apiPermissionCodes`.
|
|
210
268
|
- `openxiangda permission audit --json` has no high-risk gaps.
|
|
269
|
+
- `openxiangda scope explain <policyCode> --json` explains user, current role,
|
|
270
|
+
matched grants, cache state, and final policy for at least one allow and one
|
|
271
|
+
deny case.
|
|
211
272
|
- Allowed role paths and denied role paths are both tested.
|
|
212
273
|
- Query parameter tampering does not expand sensitive access.
|
|
213
274
|
- App Functions that mutate sensitive data verify `ctx.operator.roleCodes`,
|
|
@@ -209,6 +209,134 @@ Condition-style data permission:
|
|
|
209
209
|
}
|
|
210
210
|
```
|
|
211
211
|
|
|
212
|
+
## Business Scope Policy
|
|
213
|
+
|
|
214
|
+
Use `scope_policy` when data access follows application-maintained business
|
|
215
|
+
relationships such as user/role -> customer, project, region, store, campus,
|
|
216
|
+
college, class, merchant, or any other maintained object set.
|
|
217
|
+
|
|
218
|
+
Recommended boundary:
|
|
219
|
+
|
|
220
|
+
- Small fixed app: static roles + a few page/form permission groups.
|
|
221
|
+
- Platform organization fits the business: department data scope.
|
|
222
|
+
- Common complex business scope: authorization form + `scope_policy`.
|
|
223
|
+
- Public access: public-access grant + external role + permission groups.
|
|
224
|
+
- Sensitive writes: App Function service-side role/scope checks.
|
|
225
|
+
|
|
226
|
+
Do not create one platform role or one permission group per business object.
|
|
227
|
+
Do not force form-maintained business scope into platform departments. Frontend
|
|
228
|
+
hiding, query parameters, and page role checks are not sensitive authorization.
|
|
229
|
+
|
|
230
|
+
Declare scope resources:
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
// src/resources/permissions/scope-dimensions/college.json
|
|
234
|
+
{
|
|
235
|
+
"code": "college",
|
|
236
|
+
"name": "学院",
|
|
237
|
+
"sourceFormCode": "college",
|
|
238
|
+
"sourceValueField": "collegeCode",
|
|
239
|
+
"sourceLabelField": "collegeName",
|
|
240
|
+
"hierarchyMode": "flat"
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
```json
|
|
245
|
+
// src/resources/permissions/scope-grant-sources/college_grants.json
|
|
246
|
+
{
|
|
247
|
+
"code": "college_grants",
|
|
248
|
+
"name": "学院授权",
|
|
249
|
+
"sourceFormCode": "college_grant",
|
|
250
|
+
"subjectMappings": [
|
|
251
|
+
{ "subjectType": "user", "field": "userId" },
|
|
252
|
+
{ "subjectType": "role", "field": "roleCode" }
|
|
253
|
+
],
|
|
254
|
+
"dimensionMappings": [
|
|
255
|
+
{ "dimensionCode": "college", "field": "collegeCode" }
|
|
256
|
+
],
|
|
257
|
+
"filterJson": {
|
|
258
|
+
"logic": "AND",
|
|
259
|
+
"rules": [
|
|
260
|
+
{ "field": "status", "op": "eq", "value": "enabled" }
|
|
261
|
+
]
|
|
262
|
+
},
|
|
263
|
+
"sync": true
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
```json
|
|
268
|
+
// src/resources/permissions/data-scope-policies/college_data.json
|
|
269
|
+
{
|
|
270
|
+
"code": "college_data",
|
|
271
|
+
"name": "学院数据",
|
|
272
|
+
"matchMode": "AND",
|
|
273
|
+
"rules": [
|
|
274
|
+
{
|
|
275
|
+
"dimensionCode": "college",
|
|
276
|
+
"field": "collegeCode",
|
|
277
|
+
"operation": "view"
|
|
278
|
+
}
|
|
279
|
+
]
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Reference the policy from a form permission group:
|
|
284
|
+
|
|
285
|
+
```json
|
|
286
|
+
{
|
|
287
|
+
"code": "college_view",
|
|
288
|
+
"formCode": "student",
|
|
289
|
+
"name": "学院查看学生",
|
|
290
|
+
"type": "view",
|
|
291
|
+
"roles": ["college_admin"],
|
|
292
|
+
"operations": ["view"],
|
|
293
|
+
"dataPermission": {
|
|
294
|
+
"type": "scope_policy",
|
|
295
|
+
"policyCode": "college_data"
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
For Data View permission groups, use the same `dataPermission` shape inside the
|
|
301
|
+
view's `permissionGroups`.
|
|
302
|
+
|
|
303
|
+
Default runtime semantics:
|
|
304
|
+
|
|
305
|
+
- Effective scope = personal grants + current app role grants.
|
|
306
|
+
- Multiple policy rules are explicit `AND` unless `matchMode: "OR"` is set.
|
|
307
|
+
- Empty grant sets deny data.
|
|
308
|
+
- App administrators still bypass data filtering, but audit/explain should make
|
|
309
|
+
the bypass visible.
|
|
310
|
+
- Redis caches per user/current-role/policy summaries with a scope version key;
|
|
311
|
+
large grant sets fall back to DB `EXISTS` checks instead of huge SQL `IN`
|
|
312
|
+
lists.
|
|
313
|
+
- Prefer hidden scalar target fields such as `collegeCode` or `projectId`.
|
|
314
|
+
If a policy directly targets a JSONB option/person/department field, declare
|
|
315
|
+
`valuePath: "value"` or a supported `componentType` so the runtime compares
|
|
316
|
+
the stored option value instead of the whole JSON object.
|
|
317
|
+
- `filterJson` filters authorization source rows before materializing grants.
|
|
318
|
+
Use it for enabled/approved/effective authorization rows; unsupported filter
|
|
319
|
+
operations do not match.
|
|
320
|
+
- Source authorization forms must be synced after changes. For formal apps,
|
|
321
|
+
create an automation/App Function that runs `openxiangda scope sync` or the
|
|
322
|
+
equivalent platform API after authorization rows are created, updated, or
|
|
323
|
+
deleted. Publishing with `"sync": true` only syncs during resource publish.
|
|
324
|
+
- App Function `ctx.form.queryOne/queryMany/getById` enforces the caller's view
|
|
325
|
+
permission when there is a real current user. No matching view permission
|
|
326
|
+
group means deny; backend system automations can still use trusted internal
|
|
327
|
+
paths and should add explicit role/scope checks for sensitive logic.
|
|
328
|
+
|
|
329
|
+
Useful commands:
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
openxiangda scope dimension-upsert college --json-file src/resources/permissions/scope-dimensions/college.json --write-manifest
|
|
333
|
+
openxiangda scope grant-source-upsert college_grants --json-file src/resources/permissions/scope-grant-sources/college_grants.json --write-manifest
|
|
334
|
+
openxiangda scope policy-upsert college_data --json-file src/resources/permissions/data-scope-policies/college_data.json --write-manifest
|
|
335
|
+
openxiangda scope sync college_grants --json
|
|
336
|
+
openxiangda scope explain college_data --json
|
|
337
|
+
openxiangda permission audit --json
|
|
338
|
+
```
|
|
339
|
+
|
|
212
340
|
## Settings
|
|
213
341
|
|
|
214
342
|
Form settings are deep-merged:
|
|
@@ -52,6 +52,11 @@ openxiangda notification preview public_register_notice --body-json '{"payload":
|
|
|
52
52
|
|
|
53
53
|
openxiangda resource plan storage --profile <name>
|
|
54
54
|
openxiangda data-view upsert --json-file src/resources/data-views/public_lookup.json
|
|
55
|
+
openxiangda scope dimension-upsert college --json-file src/resources/permissions/scope-dimensions/college.json --write-manifest
|
|
56
|
+
openxiangda scope grant-source-upsert college_grants --json-file src/resources/permissions/scope-grant-sources/college_grants.json --write-manifest
|
|
57
|
+
openxiangda scope policy-upsert college_data --json-file src/resources/permissions/data-scope-policies/college_data.json --write-manifest
|
|
58
|
+
openxiangda scope sync college_grants --json
|
|
59
|
+
openxiangda scope explain college_data --json
|
|
55
60
|
openxiangda menu update public_register --json-file src/resources/menus/public_register.json --write-manifest
|
|
56
61
|
openxiangda permission audit --json
|
|
57
62
|
openxiangda permission role-update external_visitor --json-file src/resources/roles/external_visitor.json --write-manifest
|
|
@@ -290,6 +295,88 @@ Ticket 默认单次使用,首次换取 public session 后立即失效;只有
|
|
|
290
295
|
|
|
291
296
|
`grants.forms` 可以写本地 `formCode`,CLI 发布时会解析为真实 `formUuid`。
|
|
292
297
|
|
|
298
|
+
## Business Scope Permission — `src/resources/permissions/scope-*`
|
|
299
|
+
|
|
300
|
+
Use this when a form-maintained business authorization relationship controls
|
|
301
|
+
row visibility. The pattern replaces "one role / one permission group per
|
|
302
|
+
college, class, project, customer, region, store, etc."
|
|
303
|
+
|
|
304
|
+
Dimension:
|
|
305
|
+
|
|
306
|
+
```json
|
|
307
|
+
{
|
|
308
|
+
"code": "college",
|
|
309
|
+
"name": "学院",
|
|
310
|
+
"sourceFormCode": "college",
|
|
311
|
+
"sourceValueField": "collegeCode",
|
|
312
|
+
"sourceLabelField": "collegeName",
|
|
313
|
+
"hierarchyMode": "flat"
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Grant source:
|
|
318
|
+
|
|
319
|
+
```json
|
|
320
|
+
{
|
|
321
|
+
"code": "college_grants",
|
|
322
|
+
"name": "学院授权",
|
|
323
|
+
"sourceFormCode": "college_grant",
|
|
324
|
+
"subjectMappings": [
|
|
325
|
+
{ "subjectType": "user", "field": "userId" },
|
|
326
|
+
{ "subjectType": "role", "field": "roleCode" }
|
|
327
|
+
],
|
|
328
|
+
"dimensionMappings": [
|
|
329
|
+
{ "dimensionCode": "college", "field": "collegeCode" }
|
|
330
|
+
],
|
|
331
|
+
"filterJson": {
|
|
332
|
+
"logic": "AND",
|
|
333
|
+
"rules": [
|
|
334
|
+
{ "field": "status", "op": "eq", "value": "enabled" }
|
|
335
|
+
]
|
|
336
|
+
},
|
|
337
|
+
"sync": true
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Policy:
|
|
342
|
+
|
|
343
|
+
```json
|
|
344
|
+
{
|
|
345
|
+
"code": "college_data",
|
|
346
|
+
"name": "学院数据",
|
|
347
|
+
"matchMode": "AND",
|
|
348
|
+
"rules": [
|
|
349
|
+
{ "dimensionCode": "college", "field": "collegeCode", "operation": "view" }
|
|
350
|
+
]
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Form permission group reference:
|
|
355
|
+
|
|
356
|
+
```json
|
|
357
|
+
{
|
|
358
|
+
"code": "college_view",
|
|
359
|
+
"formCode": "student",
|
|
360
|
+
"name": "学院查看学生",
|
|
361
|
+
"type": "view",
|
|
362
|
+
"roles": ["college_admin"],
|
|
363
|
+
"operations": ["view"],
|
|
364
|
+
"dataPermission": {
|
|
365
|
+
"type": "scope_policy",
|
|
366
|
+
"policyCode": "college_data"
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Data View permission groups use the same `dataPermission` object inside
|
|
372
|
+
`permissionGroups`. Empty grant sets deny; multiple policy rules are AND by
|
|
373
|
+
default; large grant sets use DB `EXISTS` instead of huge `IN` SQL. Prefer
|
|
374
|
+
hidden scalar target fields such as `collegeCode`; if the target is a JSONB
|
|
375
|
+
option/person/department field, set `valuePath: "value"` or `componentType`.
|
|
376
|
+
Runtime changes to the authorization source form need an automation/App
|
|
377
|
+
Function or explicit platform API call to resync the grant source; manifest
|
|
378
|
+
`sync: true` only runs during resource publish.
|
|
379
|
+
|
|
293
380
|
## 3. Connector — `src/resources/connectors/<code>.json`
|
|
294
381
|
|
|
295
382
|
```json
|
package/package.json
CHANGED
|
@@ -33,11 +33,13 @@ interface DataPermissionConditionDto {
|
|
|
33
33
|
conditions?: DataPermissionConditionDto[];
|
|
34
34
|
}
|
|
35
35
|
interface DataPermissionDto {
|
|
36
|
-
type: "condition" | "sql";
|
|
36
|
+
type: "condition" | "sql" | "scope_policy";
|
|
37
37
|
condition?: DataPermissionConditionDto;
|
|
38
38
|
logic?: "AND" | "OR";
|
|
39
39
|
rules?: DataPermissionRuleDto[];
|
|
40
40
|
expression?: string;
|
|
41
|
+
policyCode?: string;
|
|
42
|
+
scopePolicyCode?: string;
|
|
41
43
|
}
|
|
42
44
|
type SearchLogic = "AND" | "OR";
|
|
43
45
|
type SearchOperator = "EQ" | "NEQ" | "LIKE" | "ILIKE" | "MATCH" | "CONTAINS" | "NOT_CONTAINS" | "IN" | "IS_NULL" | "IS_NOT_NULL" | "GT" | "GTE" | "GE" | "LT" | "LTE" | "LE" | "BETWEEN" | "EXISTS" | "NOT_EXISTS" | "PATH_EQ" | string;
|
|
@@ -33,11 +33,13 @@ interface DataPermissionConditionDto {
|
|
|
33
33
|
conditions?: DataPermissionConditionDto[];
|
|
34
34
|
}
|
|
35
35
|
interface DataPermissionDto {
|
|
36
|
-
type: "condition" | "sql";
|
|
36
|
+
type: "condition" | "sql" | "scope_policy";
|
|
37
37
|
condition?: DataPermissionConditionDto;
|
|
38
38
|
logic?: "AND" | "OR";
|
|
39
39
|
rules?: DataPermissionRuleDto[];
|
|
40
40
|
expression?: string;
|
|
41
|
+
policyCode?: string;
|
|
42
|
+
scopePolicyCode?: string;
|
|
41
43
|
}
|
|
42
44
|
type SearchLogic = "AND" | "OR";
|
|
43
45
|
type SearchOperator = "EQ" | "NEQ" | "LIKE" | "ILIKE" | "MATCH" | "CONTAINS" | "NOT_CONTAINS" | "IN" | "IS_NULL" | "IS_NOT_NULL" | "GT" | "GTE" | "GE" | "LT" | "LTE" | "LE" | "BETWEEN" | "EXISTS" | "NOT_EXISTS" | "PATH_EQ" | string;
|