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 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 = {
@@ -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, when form permission groups require scalar
51
- matching
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. Create form permission groups with condition-based data permissions for real
143
- data isolation.
144
- 7. For delegated administrators, add role API permissions in
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
- 8. Put sensitive writes behind App Functions with server-side role/scope checks.
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; derive hidden scalar keys with `valueSync` when permission
53
- conditions need scalar matching.
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 backend enforcement layer.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.136",
3
+ "version": "1.0.137",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -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;