Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 50 additions & 20 deletions modules/auth/data/bootstrap.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,65 +43,95 @@
}
},
{
"name": "rma_terminology_editor_tt_search",
"name": "rma_terminology_editor_tt_logical",
"model": "RoleMethodAccess",
"values": {
"RoleId": {
"ref": "auth.role_terminology_editor"
},
"MetaApplicationId": null,
"MetaModelId": null,
"MetaServiceId": {
"serviceRef": "auth.TranslationTerm/Search"
},
"MetaServiceId": null,
"LogicalModelName": "TranslationTerm",
"LogicalMethods": ["Search", "Browse", "Update", "Count"],
"Mode": "allow"
}
},
{
"name": "rma_terminology_editor_tt_browse",
"model": "RoleMethodAccess",
"name": "rfr_terminology_editor_tt_logical",
"model": "RoleFieldRule",
"values": {
"RoleId": {
"ref": "auth.role_terminology_editor"
},
"MetaApplicationId": null,
"MetaModelId": null,
"MetaServiceId": {
"serviceRef": "auth.TranslationTerm/Browse"
},
"Mode": "allow"
"MetaFieldId": null,
"LogicalModelName": "TranslationTerm",
"PermRead": "allow",
"PermWrite": "allow"
}
},
{
"name": "rma_terminology_editor_tt_update",
"name": "rma_base_user_field_default_logical",
"model": "RoleMethodAccess",
"values": {
"RoleId": {
"ref": "auth.role_terminology_editor"
"ref": "auth.role_base_user"
},
"MetaApplicationId": null,
"MetaModelId": null,
"MetaServiceId": {
"serviceRef": "auth.TranslationTerm/Update"
},
"MetaServiceId": null,
"LogicalModelName": "FieldDefault",
"LogicalMethods": ["Get", "Set"],
"Mode": "allow"
}
},
{
"name": "rma_terminology_editor_tt_count",
"model": "RoleMethodAccess",
"name": "rfr_base_user_field_default_logical",
"model": "RoleFieldRule",
"values": {
"RoleId": {
"ref": "auth.role_terminology_editor"
"ref": "auth.role_base_user"
},
"MetaApplicationId": null,
"MetaModelId": null,
"MetaServiceId": {
"serviceRef": "auth.TranslationTerm/Count"
"MetaFieldId": null,
"LogicalModelName": "FieldDefault",
"PermRead": "allow",
"PermWrite": "allow"
}
},
Comment thread
buke marked this conversation as resolved.
{
"name": "rma_sys_admin_app_setting_logical",
"model": "RoleMethodAccess",
"values": {
"RoleId": {
"ref": "auth.role_sys_admin"
},
"MetaApplicationId": null,
"MetaModelId": null,
"MetaServiceId": null,
"LogicalModelName": "AppSetting",
"LogicalMethods": ["Get", "Set"],
"Mode": "allow"
}
},
{
"name": "rfr_sys_admin_app_setting_logical",
"model": "RoleFieldRule",
"values": {
"RoleId": {
"ref": "auth.role_sys_admin"
},
"MetaApplicationId": null,
"MetaModelId": null,
"MetaFieldId": null,
"LogicalModelName": "AppSetting",
"PermRead": "allow",
"PermWrite": "allow"
}
},
{
"name": "user_admin",
"model": "User",
Expand Down
99 changes: 99 additions & 0 deletions modules/auth/service/models/_logical_model_registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2026-present Brian Wang <wangbuke@gmail.com>
// SPDX-License-Identifier: Apache-2.0

/**
* Auth-side helpers for LogicalModel ACL scope.
*
* Short-name eligibility comes from core platform-inject bases via
* {@link registerLogicalModelName} (not an auth constant list).
* See `.dev/docs/auth/logical_model_acl_field_rule_design.md` §5.
*/

// Ensure platform store bases have self-registered before write validation.
import '@/core/service/orm/model/app_setting_base_model';
import '@/core/service/orm/model/field_default_base_model';
import '@/core/service/orm/model/translation_term_base_model';
import {
isRegisteredLogicalModelName,
listLogicalModelNames,
listLogicalModelSelection,
} from '@/core/service/orm/model/logical_model_registry';

export { isRegisteredLogicalModelName, listLogicalModelNames, listLogicalModelSelection };

/**
* Normalize a logical model short name for write validation.
* Empty → null. Non-empty unregistered → throws.
*/
export function normalizeLogicalModelName(raw: unknown): string | null {
if (raw == null) return null;
const n = String(raw).trim();
if (!n) return null;
if (!isRegisteredLogicalModelName(n)) {
throw new Error(`invalid LogicalModelName: ${n} is not a registered logical model`);
}
return n;
}

/**
* Canonicalize one RPC short name: trim, drop empties, PascalCase first letter.
* Comparison at eval time is case-insensitive (toLowerCase).
*/
export function canonicalizeLogicalMethodName(raw: unknown): string {
const t = String(raw ?? '').trim();
if (!t) return '';
return t.charAt(0).toUpperCase() + t.slice(1);
}

/**
* Normalize LogicalMethods JSON payload to string[] | null.
* null / [] / missing → null (meaning "all methods" at eval time).
*/
export function normalizeLogicalMethods(raw: unknown): string[] | null {
if (raw == null) return null;
let arr: unknown[] | null = null;
if (Array.isArray(raw)) {
arr = raw;
} else if (typeof raw === 'string') {
const s = raw.trim();
if (!s) return null;
try {
const parsed = JSON.parse(s);
if (Array.isArray(parsed)) arr = parsed;
else throw new Error('not an array');
} catch {
throw new Error('invalid LogicalMethods: must be a JSON string array');
}
} else {
throw new Error('invalid LogicalMethods: must be a string array');
}

const out: string[] = [];
const seen = new Set<string>();
for (const item of arr) {
if (typeof item !== 'string') {
throw new Error('invalid LogicalMethods: each entry must be a string');
}
const name = canonicalizeLogicalMethodName(item);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (!name) continue;
const key = name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(name);
}
return out.length === 0 ? null : out;
}

/**
* Whether a LogicalMethods whitelist (null/empty = all) covers `methodName`.
* Malformed payloads throw; callers that must not abort evaluation should catch.
*/
export function logicalMethodsAllow(methods: unknown, methodName: string): boolean {
const want = String(methodName || '')
.trim()
.toLowerCase();
if (!want) return false;
const list = normalizeLogicalMethods(methods);
Comment thread
buke marked this conversation as resolved.
if (list == null) return true;
return list.some(m => m.toLowerCase() === want);
}
56 changes: 39 additions & 17 deletions modules/auth/service/models/_rule_scope_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
// SPDX-License-Identifier: Apache-2.0

import { normalizeRefId } from '@/core/service/utils/normalization';
import { normalizeLogicalModelName } from './_logical_model_registry';

/**
* Scope profile for the four Role* rule models.
*
* Extracted in PR-E-1; wired into method/record/field/ui models in PR-E-2.
* LogicalModel scope added for method/field (logical_model_acl_field_rule_design.md).
*/
export type RuleScopeProfile = 'method' | 'record' | 'field' | 'ui';

Expand All @@ -15,7 +17,8 @@ export type RuleScopeProfile = 'method' | 'record' | 'field' | 'ui';
*/
export type AssertExclusiveScopeMode = 'create' | 'update';

type ScopeFieldKey = 'MetaServiceId' | 'MetaModelId' | 'MetaApplicationId' | 'MetaFieldId' | 'MetaUiResourceId';
type MetaScopeFieldKey = 'MetaServiceId' | 'MetaModelId' | 'MetaApplicationId' | 'MetaFieldId' | 'MetaUiResourceId';
type ScopeFieldKey = MetaScopeFieldKey | 'LogicalModelName';

type ProfileSpec = {
modelName: string;
Expand All @@ -29,15 +32,22 @@ type ProfileSpec = {
const PROFILE_SPECS: Record<RuleScopeProfile, ProfileSpec> = {
method: {
modelName: 'RoleMethodAccess',
fields: ['MetaServiceId', 'MetaModelId', 'MetaApplicationId'],
shapesLabel: 'service/model/application/global',
fields: ['MetaServiceId', 'MetaModelId', 'MetaApplicationId', 'LogicalModelName'],
Comment thread
buke marked this conversation as resolved.
shapesLabel: 'service/model/application/logical_model/global',
alwaysValidateOnCreate: false,
isValidShape: ids => {
const isService = ids.MetaServiceId != null && ids.MetaModelId == null && ids.MetaApplicationId == null;
const isModel = ids.MetaServiceId == null && ids.MetaModelId != null && ids.MetaApplicationId == null;
const isApplication = ids.MetaServiceId == null && ids.MetaModelId == null && ids.MetaApplicationId != null;
const isGlobal = ids.MetaServiceId == null && ids.MetaModelId == null && ids.MetaApplicationId == null;
return isService || isModel || isApplication || isGlobal;
const logical = ids.LogicalModelName;
const isService =
ids.MetaServiceId != null && ids.MetaModelId == null && ids.MetaApplicationId == null && logical == null;
const isModel =
ids.MetaServiceId == null && ids.MetaModelId != null && ids.MetaApplicationId == null && logical == null;
const isApplication =
ids.MetaServiceId == null && ids.MetaModelId == null && ids.MetaApplicationId != null && logical == null;
const isLogical =
logical != null && ids.MetaServiceId == null && ids.MetaModelId == null && ids.MetaApplicationId == null;
const isGlobal =
ids.MetaServiceId == null && ids.MetaModelId == null && ids.MetaApplicationId == null && logical == null;
return isService || isModel || isApplication || isLogical || isGlobal;
},
},
record: {
Expand All @@ -54,15 +64,22 @@ const PROFILE_SPECS: Record<RuleScopeProfile, ProfileSpec> = {
},
field: {
modelName: 'RoleFieldRule',
fields: ['MetaFieldId', 'MetaModelId', 'MetaApplicationId'],
shapesLabel: 'field/model/application/global',
fields: ['MetaFieldId', 'MetaModelId', 'MetaApplicationId', 'LogicalModelName'],
shapesLabel: 'field/model/application/logical_model/global',
alwaysValidateOnCreate: true,
isValidShape: ids => {
const isField = ids.MetaFieldId != null && ids.MetaModelId != null && ids.MetaApplicationId == null;
const isModel = ids.MetaFieldId == null && ids.MetaModelId != null && ids.MetaApplicationId == null;
const isApplication = ids.MetaFieldId == null && ids.MetaModelId == null && ids.MetaApplicationId != null;
const isGlobal = ids.MetaFieldId == null && ids.MetaModelId == null && ids.MetaApplicationId == null;
return isField || isModel || isApplication || isGlobal;
const logical = ids.LogicalModelName;
const isField =
ids.MetaFieldId != null && ids.MetaModelId != null && ids.MetaApplicationId == null && logical == null;
const isModel =
ids.MetaFieldId == null && ids.MetaModelId != null && ids.MetaApplicationId == null && logical == null;
const isApplication =
ids.MetaFieldId == null && ids.MetaModelId == null && ids.MetaApplicationId != null && logical == null;
const isLogical =
logical != null && ids.MetaFieldId == null && ids.MetaModelId == null && ids.MetaApplicationId == null;
const isGlobal =
ids.MetaFieldId == null && ids.MetaModelId == null && ids.MetaApplicationId == null && logical == null;
return isField || isModel || isApplication || isLogical || isGlobal;
},
},
ui: {
Expand Down Expand Up @@ -94,7 +111,8 @@ function touchesAnyScopeField(values: Record<string, any>, fields: ScopeFieldKey
* Assert and normalize mutually exclusive scope refs on a rule row payload.
*
* Error message strings are byte-stable with the pre-E-1 `_validateScopeShape` implementations
* so existing tests and call sites can migrate without golden churn.
* so existing tests and call sites can migrate without golden churn — except method/field
* shapesLabel which now includes `logical_model`.
*
* Mutates `values` in place when scope columns are validated/normalized.
*/
Expand All @@ -118,7 +136,11 @@ export function assertExclusiveScope(values: Record<string, any>, mode: AssertEx

const ids = {} as Record<ScopeFieldKey, string | null>;
for (const f of spec.fields) {
ids[f] = normalizeRefId((values as any)[f]);
if (f === 'LogicalModelName') {
ids[f] = normalizeLogicalModelName((values as any)[f]);
} else {
ids[f] = normalizeRefId((values as any)[f]);
}
}

if (!spec.isValidShape(ids)) {
Expand Down
Loading