From 2d5880d056f25d871ad0dc7626ab862947c85720 Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 15:22:59 +0800 Subject: [PATCH 1/9] feat(orm): add BaseModel.sudo/withUser and remove Compute runAs - Add scoped sudo for RR/FR bypass with enter audit while keeping company scope. - Add withUser identity override stack so getUserId prefers the stack over identity. - Remove author-facing @Compute({ runAs }) and engine wrapping in favor of method-body sudo/withUser. Co-authored-by: Cursor --- modules/core/service/api/context.ts | 1 + modules/core/service/api/index.test.ts | 1 + modules/core/service/api/index.ts | 1 + .../service/orm/decorator/compute.test.ts | 8 +- modules/core/service/orm/decorator/compute.ts | 9 +- modules/core/service/orm/metadata/compute.ts | 7 -- modules/core/service/orm/metadata/field.ts | 2 - modules/core/service/orm/metadata/index.ts | 1 - modules/core/service/orm/metadata/model.ts | 1 - .../core/service/orm/metadata/storage.test.ts | 2 - modules/core/service/orm/metadata/storage.ts | 1 - modules/core/service/orm/model/model.test.ts | 5 + modules/core/service/orm/model/model.ts | 37 ++++++ .../service/orm/model/model_context_facade.ts | 20 +++ .../core/service/orm/model/model_sudo.test.ts | 85 +++++++++++++ modules/core/service/orm/model/model_sudo.ts | 52 ++++++++ .../orm/repository/authz/authz_runtime.ts | 75 +++++++++--- .../service/orm/repository/authz/index.ts | 1 + .../authz/tests/authz_runtime.test.ts | 28 +++++ .../query/tests/condition_compiler.test.ts | 22 +--- .../service/runtime/compute/engine.test.ts | 19 +-- .../core/service/runtime/compute/engine.ts | 8 +- .../service/runtime/compute/runas.test.ts | 94 --------------- modules/core/service/runtime/compute/runas.ts | 63 ---------- .../service/runtime/compute/search_rewrite.ts | 8 +- .../service/runtime/context/context.test.ts | 46 ++++++- modules/core/service/runtime/context/index.ts | 3 +- .../core/service/runtime/context/source.ts | 8 -- modules/core/service/runtime/context/user.ts | 114 ++++++++++++++++++ 29 files changed, 468 insertions(+), 254 deletions(-) create mode 100644 modules/core/service/orm/model/model_sudo.test.ts create mode 100644 modules/core/service/orm/model/model_sudo.ts delete mode 100644 modules/core/service/runtime/compute/runas.test.ts delete mode 100644 modules/core/service/runtime/compute/runas.ts create mode 100644 modules/core/service/runtime/context/user.ts diff --git a/modules/core/service/api/context.ts b/modules/core/service/api/context.ts index b3a253033..e4dd9b614 100644 --- a/modules/core/service/api/context.ts +++ b/modules/core/service/api/context.ts @@ -18,6 +18,7 @@ export { getReqMeta, getUserId, withContext, + withUser, deleteReqStateKeysByPrefix, invalidateJsCtxSymbolCache, withBypassDepths, diff --git a/modules/core/service/api/index.test.ts b/modules/core/service/api/index.test.ts index b4bd25e44..2f4ef9c0d 100644 --- a/modules/core/service/api/index.test.ts +++ b/modules/core/service/api/index.test.ts @@ -37,6 +37,7 @@ test('core/service/api entrypoint export surface stays limited to stable cross-m 'resolveValidationSummary', 'withContext', 'withI18nScope', + 'withUser', ]); }); diff --git a/modules/core/service/api/index.ts b/modules/core/service/api/index.ts index 3d202bf30..7a5dd51f6 100644 --- a/modules/core/service/api/index.ts +++ b/modules/core/service/api/index.ts @@ -17,6 +17,7 @@ export { getReqMeta, getUserId, withContext, + withUser, } from './context'; export { diff --git a/modules/core/service/orm/decorator/compute.test.ts b/modules/core/service/orm/decorator/compute.test.ts index 313163a67..fb28737a9 100644 --- a/modules/core/service/orm/decorator/compute.test.ts +++ b/modules/core/service/orm/decorator/compute.test.ts @@ -22,7 +22,6 @@ test('@Compute registers handler metadata with defaults', () => { deps: ['Id', 'Id'], store: false, searchable: true, - runAs: 'sudo', }) computeName() { return undefined; @@ -38,7 +37,6 @@ test('@Compute registers handler metadata with defaults', () => { deps: ['Id'], store: false, searchable: true, - runAs: 'sudo', }); resetModelMetadata(ComputeDecoratorModel as any); @@ -142,16 +140,16 @@ test('@Compute rejects non-function descriptor value', () => { }).toThrow('must decorate an instance method'); }); -test('@Compute rejects invalid runAs', () => { +test('@Compute rejects author-facing runAs option', () => { expect(() => { class ComputeBadRunAsModel extends BaseModel { - @Compute('Name', { deps: ['Id'], runAs: 'admin' as any }) + @Compute('Name', { deps: ['Id'], runAs: 'sudo' } as any) computeName() { return undefined; } } return ComputeBadRunAsModel; - }).toThrow('runAs must be user or sudo'); + }).toThrow('runAs is removed'); }); test('@Compute with store=false handles missing field entry gracefully', () => { diff --git a/modules/core/service/orm/decorator/compute.ts b/modules/core/service/orm/decorator/compute.ts index 13159a108..dd8296228 100644 --- a/modules/core/service/orm/decorator/compute.ts +++ b/modules/core/service/orm/decorator/compute.ts @@ -4,13 +4,11 @@ import type BaseModel from '../model/model'; import { MetadataStorage } from '../metadata/storage'; import type { ComputeDep, ModelCtor } from '../metadata/field'; -import type { ComputeRunAs } from '../metadata/compute'; export type ComputeOptions = { deps: Array>; store?: boolean; searchable?: boolean; - runAs?: ComputeRunAs; }; export function Compute(field: Extract, options: ComputeOptions): MethodDecorator { @@ -36,9 +34,9 @@ export function Compute(field: Extract | undefined; + if (optionsRecord && Object.prototype.hasOwnProperty.call(optionsRecord, 'runAs')) { + throw new Error(`@Compute(${fieldName}) runAs is removed; call BaseModel.sudo / withUser inside the method body`); } const ctor = target.constructor as ModelCtor; @@ -62,7 +60,6 @@ export function Compute(field: Extract { model: TModel; value: TValue; previousValue: TValue | undefined; - runAs: ComputeRunAs; } /** @@ -48,7 +42,6 @@ export interface ComputeSearchContext { op: ComputeOperator; value: unknown; dialect: DialectName; - runAs: ComputeRunAs; } /** diff --git a/modules/core/service/orm/metadata/field.ts b/modules/core/service/orm/metadata/field.ts index b844de1f7..7fbf53b7d 100644 --- a/modules/core/service/orm/metadata/field.ts +++ b/modules/core/service/orm/metadata/field.ts @@ -4,7 +4,6 @@ import BaseModel from '../model/model'; import type { ExpressionWrapper, ExpressionBuilder, Expression } from 'kysely'; import Decimal, { DecimalRound } from '@/core/utils/decimal'; -import type { ComputeRunAs } from './compute'; import type { TermReference } from '../../i18n'; type ObjectRecord = Record; @@ -398,7 +397,6 @@ export interface ColumnComputeSpec deps: Array>; store?: boolean; searchable?: boolean; - runAs?: ComputeRunAs; inverse?: string; search?: string; } diff --git a/modules/core/service/orm/metadata/index.ts b/modules/core/service/orm/metadata/index.ts index cc72fe075..01d72f598 100644 --- a/modules/core/service/orm/metadata/index.ts +++ b/modules/core/service/orm/metadata/index.ts @@ -60,7 +60,6 @@ export type { } from './model'; export type { ValueType, ParamMetadata, ServiceMetadata } from './service'; export type { - ComputeRunAs, ComputeOperator, ComputeSearchDomain, ComputeDeps, diff --git a/modules/core/service/orm/metadata/model.ts b/modules/core/service/orm/metadata/model.ts index e7a71641b..a36c30a60 100644 --- a/modules/core/service/orm/metadata/model.ts +++ b/modules/core/service/orm/metadata/model.ts @@ -50,7 +50,6 @@ export interface ComputeHandlerMeta { deps: string[]; store: boolean; searchable?: boolean; - runAs?: 'user' | 'sudo'; } /** diff --git a/modules/core/service/orm/metadata/storage.test.ts b/modules/core/service/orm/metadata/storage.test.ts index 8bda2e7e4..056882fb1 100644 --- a/modules/core/service/orm/metadata/storage.test.ts +++ b/modules/core/service/orm/metadata/storage.test.ts @@ -405,7 +405,6 @@ test('metadata storage merges behavior handler maps and applies subclass overrid deps: ['Amount', 'Tax'], store: false, searchable: true, - runAs: 'sudo', }, ], ]), @@ -429,7 +428,6 @@ test('metadata storage merges behavior handler maps and applies subclass overrid deps: ['Amount', 'Tax'], store: false, searchable: true, - runAs: 'sudo', }); expect(merged.sqlComputeHandlers?.get('DisplayName')?.method).toBe('sqlDisplayNameParent'); expect(merged.searchHandlers?.get('VirtualName')?.method).toBe('searchVirtualNameParent'); diff --git a/modules/core/service/orm/metadata/storage.ts b/modules/core/service/orm/metadata/storage.ts index c9bd7a706..0b4912cfb 100644 --- a/modules/core/service/orm/metadata/storage.ts +++ b/modules/core/service/orm/metadata/storage.ts @@ -93,7 +93,6 @@ export class MetadataStorage { deps, store: typeof record.store === 'boolean' ? record.store : (existing?.store ?? true), searchable: typeof record.searchable === 'boolean' ? record.searchable : existing?.searchable, - runAs: record.runAs === 'user' || record.runAs === 'sudo' ? record.runAs : existing?.runAs, }; } diff --git a/modules/core/service/orm/model/model.test.ts b/modules/core/service/orm/model/model.test.ts index 1b9358256..bb7349705 100644 --- a/modules/core/service/orm/model/model.test.ts +++ b/modules/core/service/orm/model/model.test.ts @@ -132,6 +132,11 @@ test('model context accessors are available on static and instance surfaces', () const instanceWithContext = instance.withContext({ lang: 'zh-CN' } as any, () => 'ok', { merge: true }); expect(staticWithContext).toBe('ok'); expect(instanceWithContext).toBe('ok'); + + expect(typeof ModelSurfaceHarness.withUser).toBe('function'); + expect(typeof ModelSurfaceHarness.sudo).toBe('function'); + expect(typeof instance.withUser).toBe('function'); + expect(typeof instance.sudo).toBe('function'); }); test('model DisplayName compute handler covers Name/Username/Id fallback branches', () => { diff --git a/modules/core/service/orm/model/model.ts b/modules/core/service/orm/model/model.ts index c6eaba58a..1a38e2d9c 100644 --- a/modules/core/service/orm/model/model.ts +++ b/modules/core/service/orm/model/model.ts @@ -40,6 +40,8 @@ import { getModelCompanyTimezone, getModelUserId, withModelContext, + withModelUser, + withModelElevate, getInstanceModelContext, getInstanceModelCompanyId, getInstanceModelCompanyIds, @@ -48,6 +50,8 @@ import { getInstanceModelCompanyTimezone, getInstanceModelUserId, withInstanceModelContext, + withInstanceModelUser, + withInstanceModelElevate, } from './model_context_facade'; import { updateModelInstance, @@ -314,6 +318,9 @@ class BaseModel { /** * Runs a function with additional static model context. + * + * Business ctx only (lang / company / tz…). Does not change authz userId — + * use {@link BaseModel.withUser} to impersonate. */ static withContext(ctx: Partial | (() => Partial), fn: () => R, opts?: { merge?: boolean }): R { return withModelContext(ctx, fn, opts); @@ -326,6 +333,36 @@ class BaseModel { return withInstanceModelContext(this, ctx, fn, opts); } + /** + * Runs a function with a temporary userId override for rule evaluation. + * Does not elevate privileges — compose with {@link BaseModel.sudo} when needed. + */ + static withUser(userId: string, fn: () => R): R { + return withModelUser(userId, fn); + } + + /** + * Runs a function with a temporary userId override bound to this model instance. + */ + withUser(userId: string, fn: () => R): R { + return withInstanceModelUser(this, userId, fn); + } + + /** + * Runs a function with RecordRule + FieldRule bypass (company scope retained). + * Sync and async `fn` are both supported (required for virtual compute reads). + */ + static sudo(fn: () => R): R { + return withModelElevate(fn); + } + + /** + * Runs a function with RecordRule + FieldRule bypass bound to this model instance. + */ + sudo(fn: () => R): R { + return withInstanceModelElevate(this, fn); + } + /** * Primary key for the model instance. */ diff --git a/modules/core/service/orm/model/model_context_facade.ts b/modules/core/service/orm/model/model_context_facade.ts index 3f6d54c77..138f1d0db 100644 --- a/modules/core/service/orm/model/model_context_facade.ts +++ b/modules/core/service/orm/model/model_context_facade.ts @@ -10,8 +10,10 @@ import { getContextCompanyTimezone, getUserId, withContext, + withUser, } from '../../runtime/context'; import type { Context } from '../../runtime/context'; +import { withModelSudo } from './model_sudo'; type ModelContextFacadeCtor = { ctx: Context; @@ -22,6 +24,8 @@ type ModelContextFacadeCtor = { companyTz: string | undefined; userId: string | undefined; withContext(ctx: Partial | (() => Partial), fn: () => R, opts?: { merge?: boolean }): R; + withUser(userId: string, fn: () => R): R; + sudo(fn: () => R): R; }; type ModelInstanceLike = { @@ -64,6 +68,14 @@ export function withModelContext(ctx: Partial | (() => Partial(userId: string, fn: () => R): R { + return withUser(userId, fn); +} + +export function withModelElevate(fn: () => R): R { + return withModelSudo(fn); +} + export function getInstanceModelContext(instance: ModelInstanceLike): Context { return getModelContextFacadeCtor(instance).ctx; } @@ -100,3 +112,11 @@ export function withInstanceModelContext( ): R { return getModelContextFacadeCtor(instance).withContext(ctx, fn, opts); } + +export function withInstanceModelUser(instance: ModelInstanceLike, userId: string, fn: () => R): R { + return getModelContextFacadeCtor(instance).withUser(userId, fn); +} + +export function withInstanceModelElevate(instance: ModelInstanceLike, fn: () => R): R { + return getModelContextFacadeCtor(instance).sudo(fn); +} diff --git a/modules/core/service/orm/model/model_sudo.test.ts b/modules/core/service/orm/model/model_sudo.test.ts new file mode 100644 index 000000000..302ec4989 --- /dev/null +++ b/modules/core/service/orm/model/model_sudo.test.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { getRepositoryFieldRuleBypassDepth, getRepositoryRecordRuleBypassDepth } from '../repository/authz'; +import { withModelSudo } from './model_sudo'; + +async function withPatchedChoysum(value: unknown, fn: () => Promise | T): Promise { + const key = '$choysum'; + const hadOwn = Object.prototype.hasOwnProperty.call(globalThis as object, key); + const previous = (globalThis as Record)[key]; + (globalThis as Record)[key] = value as unknown; + try { + return await fn(); + } finally { + if (hadOwn) (globalThis as Record)[key] = previous; + else delete (globalThis as Record)[key]; + } +} + +test('withModelSudo elevates RR+FR sync and records sudo audit enter', async () => { + delete (globalThis as any).__choysumComputeAudit; + + await withPatchedChoysum( + { + request: { + context: { + req: {}, + }, + }, + }, + () => { + expect(getRepositoryRecordRuleBypassDepth()).toBe(0); + expect(getRepositoryFieldRuleBypassDepth()).toBe(0); + + const value = withModelSudo(() => { + expect(getRepositoryRecordRuleBypassDepth()).toBe(1); + expect(getRepositoryFieldRuleBypassDepth()).toBe(1); + return 'elevated'; + }); + + expect(value).toBe('elevated'); + expect(getRepositoryRecordRuleBypassDepth()).toBe(0); + expect(getRepositoryFieldRuleBypassDepth()).toBe(0); + + const hits = ((globalThis as any).__choysumComputeAudit?.sudoHits || []) as any[]; + expect(hits.length).toBe(1); + expect(hits[0]?.source).toBe('sudo'); + expect(hits[0]?.version).toBe(1); + expect(typeof hits[0]?.at).toBe('string'); + } + ); +}); + +test('withModelSudo nests and supports async fn', async () => { + delete (globalThis as any).__choysumComputeAudit; + + await withPatchedChoysum( + { + request: { + context: { + req: {}, + }, + }, + }, + async () => { + await withModelSudo(async () => { + expect(getRepositoryRecordRuleBypassDepth()).toBe(1); + expect(getRepositoryFieldRuleBypassDepth()).toBe(1); + await withModelSudo(async () => { + expect(getRepositoryRecordRuleBypassDepth()).toBe(2); + expect(getRepositoryFieldRuleBypassDepth()).toBe(2); + return undefined; + }); + expect(getRepositoryRecordRuleBypassDepth()).toBe(1); + expect(getRepositoryFieldRuleBypassDepth()).toBe(1); + return undefined; + }); + expect(getRepositoryRecordRuleBypassDepth()).toBe(0); + expect(getRepositoryFieldRuleBypassDepth()).toBe(0); + + const hits = ((globalThis as any).__choysumComputeAudit?.sudoHits || []) as any[]; + expect(hits.length).toBe(2); + } + ); +}); diff --git a/modules/core/service/orm/model/model_sudo.ts b/modules/core/service/orm/model/model_sudo.ts new file mode 100644 index 000000000..a2ddc599f --- /dev/null +++ b/modules/core/service/orm/model/model_sudo.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { getRuntimeComputeAuditBucketValue, getRuntimeEnvBoolean, setRuntimeComputeAuditBucketValue } from '@/core/utils/env'; +import { asObjectRecord } from '@/core/utils/object'; +import type { ObjectRecord } from '../../../utils/types'; +import { withRepositoryAuthzRuleBypass } from '../repository/authz'; + +type SudoAuditEntry = { + version: 1; + source: 'sudo'; + at: string; + hint?: string; +}; + +function resolveSudoAuditBucket(): { sudoHits: SudoAuditEntry[] } { + const bucketRecord = asObjectRecord(getRuntimeComputeAuditBucketValue()); + const bucket: ObjectRecord = bucketRecord ?? {}; + if (!Array.isArray(bucket.sudoHits)) bucket.sudoHits = []; + setRuntimeComputeAuditBucketValue(bucket); + return bucket as { sudoHits: SudoAuditEntry[] }; +} + +function sudoAuditEnabled(): boolean { + // Default on; set CHOYSUM_SUDO_AUDIT_ENABLED=false to disable. + return getRuntimeEnvBoolean('CHOYSUM_SUDO_AUDIT_ENABLED') ?? true; +} + +/** + * Records a sudo enter audit hit (once per Model.sudo call, including nested). + */ +export function recordSudoEnterAudit(hint?: string): void { + if (!sudoAuditEnabled()) return; + const bucket = resolveSudoAuditBucket(); + const entry: SudoAuditEntry = { + version: 1, + source: 'sudo', + at: new Date().toISOString(), + }; + const normalizedHint = String(hint || '').trim(); + if (normalizedHint) entry.hint = normalizedHint; + bucket.sudoHits.push(entry); +} + +/** + * Runs `fn` with RecordRule + FieldRule bypass (company scope retained). + * Sync and async `fn` are both supported for compute read paths. + */ +export function withModelSudo(fn: () => R, opts?: { hint?: string }): R { + recordSudoEnterAudit(opts?.hint); + return withRepositoryAuthzRuleBypass(fn); +} diff --git a/modules/core/service/orm/repository/authz/authz_runtime.ts b/modules/core/service/orm/repository/authz/authz_runtime.ts index 34c047978..927d73447 100644 --- a/modules/core/service/orm/repository/authz/authz_runtime.ts +++ b/modules/core/service/orm/repository/authz/authz_runtime.ts @@ -154,20 +154,41 @@ export function getRepositoryRecordRuleBypassDepth(): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0; } -export async function withRepositoryRecordRuleBypass(fn: () => Promise): Promise { +function restoreBypassDepth(state: RepositoryReqServiceState, key: 'recordRuleBypassDepth' | 'fieldRuleBypassDepth', previousDepth: number): void { + if (previousDepth > 0) state[key] = previousDepth; + else delete state[key]; +} + +function isPromiseLike(value: unknown): value is Promise { + return !!value && typeof (value as { then?: unknown }).then === 'function'; +} + +function runWithBypassRestore(fn: () => T, restore: () => void): T { + try { + const result = fn(); + if (isPromiseLike(result)) { + return Promise.resolve(result).finally(restore) as unknown as T; + } + restore(); + return result; + } catch (error) { + restore(); + throw error; + } +} + +/** + * Increments RecordRule bypass depth for the duration of `fn`. + * Sync and async `fn` are both supported (aligned with withContext / Model.sudo). + */ +export function withRepositoryRecordRuleBypass(fn: () => T): T { const req = getRepositoryCurrentReq(); const state = getOrInitRepositoryReqServiceState(req); - if (!state) return await fn(); + if (!state) return fn(); const previousDepth = getRepositoryRecordRuleBypassDepth(); state.recordRuleBypassDepth = previousDepth + 1; - try { - return await fn(); - } finally { - const nextDepth = previousDepth; - if (nextDepth > 0) state.recordRuleBypassDepth = nextDepth; - else delete state.recordRuleBypassDepth; - } + return runWithBypassRestore(fn, () => restoreBypassDepth(state, 'recordRuleBypassDepth', previousDepth)); } export function getRepositoryFieldRuleBypassDepth(): number { @@ -177,20 +198,38 @@ export function getRepositoryFieldRuleBypassDepth(): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0; } -export async function withRepositoryFieldRuleBypass(fn: () => Promise): Promise { +/** + * Increments FieldRule bypass depth for the duration of `fn`. + * Sync and async `fn` are both supported (aligned with withContext / Model.sudo). + */ +export function withRepositoryFieldRuleBypass(fn: () => T): T { const req = getRepositoryCurrentReq(); const state = getOrInitRepositoryReqServiceState(req); - if (!state) return await fn(); + if (!state) return fn(); const previousDepth = getRepositoryFieldRuleBypassDepth(); state.fieldRuleBypassDepth = previousDepth + 1; - try { - return await fn(); - } finally { - const nextDepth = previousDepth; - if (nextDepth > 0) state.fieldRuleBypassDepth = nextDepth; - else delete state.fieldRuleBypassDepth; - } + return runWithBypassRestore(fn, () => restoreBypassDepth(state, 'fieldRuleBypassDepth', previousDepth)); +} + +/** + * Elevates for the duration of `fn` by bypassing both RecordRule and FieldRule. + * Company scope remains in effect. Sync and async `fn` are both supported. + */ +export function withRepositoryAuthzRuleBypass(fn: () => T): T { + const req = getRepositoryCurrentReq(); + const state = getOrInitRepositoryReqServiceState(req); + if (!state) return fn(); + + const previousRecordDepth = getRepositoryRecordRuleBypassDepth(); + const previousFieldDepth = getRepositoryFieldRuleBypassDepth(); + state.recordRuleBypassDepth = previousRecordDepth + 1; + state.fieldRuleBypassDepth = previousFieldDepth + 1; + + return runWithBypassRestore(fn, () => { + restoreBypassDepth(state, 'recordRuleBypassDepth', previousRecordDepth); + restoreBypassDepth(state, 'fieldRuleBypassDepth', previousFieldDepth); + }); } export function getRepositoryValidationBypassState(): RepositoryReqServiceState { diff --git a/modules/core/service/orm/repository/authz/index.ts b/modules/core/service/orm/repository/authz/index.ts index b2647f16e..47dffd779 100644 --- a/modules/core/service/orm/repository/authz/index.ts +++ b/modules/core/service/orm/repository/authz/index.ts @@ -32,6 +32,7 @@ export { withRepositoryRecordRuleBypass, getRepositoryFieldRuleBypassDepth, withRepositoryFieldRuleBypass, + withRepositoryAuthzRuleBypass, getRepositoryValidationBypassState, getRepositoryValidationBypassDepth, withRepositoryValidationBypass, diff --git a/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts b/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts index 45f2a71a4..bec297945 100644 --- a/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts +++ b/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts @@ -16,6 +16,7 @@ import { isRepositoryTopLevelGrpcCall, withRepositoryFieldRuleBypass, withRepositoryRecordRuleBypass, + withRepositoryAuthzRuleBypass, withRepositoryValidationBypass, } from '..'; @@ -162,6 +163,33 @@ test('authz runtime bypass wrappers run without req state and return callback re } }); +test('authz runtime combined RR+FR bypass is sync-friendly and nested', () => { + return withPatchedChoysum( + { + request: { + context: { + req: {}, + }, + }, + }, + async () => { + const syncValue = withRepositoryAuthzRuleBypass(() => { + expect(getRepositoryRecordRuleBypassDepth()).toBe(1); + expect(getRepositoryFieldRuleBypassDepth()).toBe(1); + return 'sync-ok'; + }); + expect(syncValue).toBe('sync-ok'); + expect(getRepositoryRecordRuleBypassDepth()).toBe(0); + + await withRepositoryAuthzRuleBypass(async () => { + expect(getRepositoryRecordRuleBypassDepth()).toBe(1); + expect(getRepositoryFieldRuleBypassDepth()).toBe(1); + }); + expect(getRepositoryFieldRuleBypassDepth()).toBe(0); + } + ); +}); + test('authz runtime field bypass nested depth restores previous value', async () => { await withPatchedChoysum( { diff --git a/modules/core/service/orm/repository/query/tests/condition_compiler.test.ts b/modules/core/service/orm/repository/query/tests/condition_compiler.test.ts index 11716104a..f1c220582 100644 --- a/modules/core/service/orm/repository/query/tests/condition_compiler.test.ts +++ b/modules/core/service/orm/repository/query/tests/condition_compiler.test.ts @@ -3,7 +3,7 @@ import { convertCondition } from '..'; import { MetadataStorage } from '../../../metadata/storage'; -import { getReadonlyCtx, withContext } from '../../../../runtime/context'; +import { withContext } from '../../../../runtime/context'; function withFakeMetadata(metas: Map, fn: () => T): T { const storage = MetadataStorage.instance as any; @@ -729,7 +729,7 @@ test('repository condition compiler resolves compute.search handler from static class DemoModel { static fromStaticMethod(ctx: any) { return { - domain: ['Name', '=', `${ctx.value}:${ctx.dialect}:${ctx.runAs}`], + domain: ['Name', '=', `${ctx.value}:${ctx.dialect}`], }; } } @@ -765,7 +765,7 @@ test('repository condition compiler resolves compute.search handler from static }; const result = convertCondition(db as any, () => '' as any, meta, eb, ['VirtualScore', '=', 11] as any, 'demo_table'); - expect(result).toEqual({ lhs: 'Name', op: '=', rhs: '11:postgres:user' }); + expect(result).toEqual({ lhs: 'Name', op: '=', rhs: '11:postgres' }); }); test('repository condition compiler resolves compute.search handler from prototype method', () => { @@ -850,12 +850,11 @@ test('repository condition compiler throws when compute.search handler is missin expect(message).toBe('compute.search handler not found: DemoModel.PersistedScore -> missingHandler'); }); -test('repository condition compiler executes compute.search with runAs=sudo marker and writes audit entry', () => { +test('repository condition compiler executes compute.search without runAs wrapper', () => { class DemoModel { static buildVirtualScoreDomain(ctx: any) { - const readonlyCtx = getReadonlyCtx() as any; return { - domain: ['Name', '=', `${ctx.value}:${readonlyCtx.__computeRunAs || 'none'}`], + domain: ['Name', '=', String(ctx.value)], }; } } @@ -877,7 +876,6 @@ test('repository condition compiler executes compute.search with runAs=sudo mark store: false, searchable: true, search: 'buildVirtualScoreDomain', - runAs: 'sudo', }, }, }, @@ -892,16 +890,8 @@ test('repository condition compiler executes compute.search with runAs=sudo mark }, }; - delete (globalThis as any).__choysumComputeAudit; const result = convertCondition(db as any, () => 'postgres', meta, eb, ['VirtualScore', '=', 9] as any, 'demo_table'); - expect(result).toEqual({ lhs: 'Name', op: '=', rhs: '9:sudo' }); - - const hits = ((globalThis as any).__choysumComputeAudit?.runAsHits || []) as any[]; - expect(hits.length).toBe(1); - expect(hits[0]?.phase).toBe('search'); - expect(hits[0]?.runAs).toBe('sudo'); - expect(hits[0]?.field).toBe('VirtualScore'); - expect(hits[0]?.mode).toBe('query'); + expect(result).toEqual({ lhs: 'Name', op: '=', rhs: '9' }); }); test('repository condition compiler fails fast when compute.search handler returns Promise', () => { diff --git a/modules/core/service/runtime/compute/engine.test.ts b/modules/core/service/runtime/compute/engine.test.ts index 7ad3a9317..4f2aa5bc8 100644 --- a/modules/core/service/runtime/compute/engine.test.ts +++ b/modules/core/service/runtime/compute/engine.test.ts @@ -6,7 +6,6 @@ import { MetadataStorage } from '../../orm/metadata/storage'; import BaseModel from '../../orm/model/model'; import { ComputeEngine } from './engine'; import Decimal from '@/core/utils/decimal'; -import { getReadonlyCtx } from '../context'; async function withFakeMetadata(metas: Map, fn: () => Promise | T): Promise { const storage = MetadataStorage.instance as any; @@ -313,12 +312,10 @@ test('compute engine persist only recomputes persisted compute fields', async () expect(changed.has('VirtualValue')).toBe(false); }); -test('compute engine executes runAs=sudo compute expression with marker and writes audit entry', async () => { +test('compute engine executes compute expression without runAs wrapper', async () => { const entity: any = { Trigger: 1, SecureValue: '' }; const changed = new Set(['Trigger']); - delete (globalThis as any).__choysumComputeAudit; - await ComputeEngine.recompute( { modelName: 'DemoModel', @@ -330,12 +327,8 @@ test('compute engine executes runAs=sudo compute expression with marker and writ type: 'varchar', column: { compute: { - expr: () => { - const ctx = getReadonlyCtx() as any; - return String(ctx.__computeRunAs || 'none'); - }, + expr: () => 'ok', deps: ['Trigger'], - runAs: 'sudo', }, }, }, @@ -354,13 +347,7 @@ test('compute engine executes runAs=sudo compute expression with marker and writ 'persist' ); - expect(entity.SecureValue).toBe('sudo'); - const hits = ((globalThis as any).__choysumComputeAudit?.runAsHits || []) as any[]; - expect(hits.length).toBe(1); - expect(hits[0]?.phase).toBe('expr'); - expect(hits[0]?.runAs).toBe('sudo'); - expect(hits[0]?.field).toBe('SecureValue'); - expect(hits[0]?.mode).toBe('persist'); + expect(entity.SecureValue).toBe('ok'); }); test('compute engine returns early when graph or changed set is empty', async () => { diff --git a/modules/core/service/runtime/compute/engine.ts b/modules/core/service/runtime/compute/engine.ts index 384848623..4512a1582 100644 --- a/modules/core/service/runtime/compute/engine.ts +++ b/modules/core/service/runtime/compute/engine.ts @@ -7,7 +7,6 @@ import { MetadataStorage } from '../../orm/metadata'; import type BaseModel from '../../orm/model/model'; import Decimal, { decimalEqual, isDecimal, normalizeDecimalByMeta } from '@/core/utils/decimal'; import { getRuntimeRepository } from '../runtime_repository_facade'; -import { withComputeRunAsExecution } from './runas'; import { createEntityBackedModelInstance, resolveInstanceHandler } from './handler_runtime'; import { asObjectRecord, hasOwnKey } from '../../../utils/object'; import type { UnknownRecord } from '../../../utils/types'; @@ -80,7 +79,6 @@ function ensureSyncBridgeResult(value: unknown, label: string): unknown { type RuntimeComputeExecution = { store: boolean; - runAs: 'user' | 'sudo'; execute: (modelInstance: BaseModel) => unknown; }; @@ -90,7 +88,6 @@ function resolveRuntimeComputeExecution(meta: ModelMetadata, field: string): Run const method = resolveInstanceHandler(meta, field, computeHandler.method, '@Compute'); return { store: computeHandler.store !== false, - runAs: computeHandler.runAs === 'sudo' ? 'sudo' : 'user', execute: modelInstance => method.call(modelInstance), }; } @@ -103,7 +100,6 @@ function resolveRuntimeComputeExecution(meta: ModelMetadata, field: string): Run if (typeof expr === 'function') { return { store: computeSpec?.store !== false, - runAs: computeSpec?.runAs === 'sudo' ? 'sudo' : 'user', execute: modelInstance => (expr as (self: unknown) => unknown).call(modelInstance, modelInstance), }; } @@ -218,7 +214,7 @@ export class ComputeEngine { const runtimeCompute = resolveRuntimeComputeExecution(meta, field); if (!runtimeCompute || runtimeCompute.store !== false) continue; - let newVal = withComputeRunAsExecution(meta, field, runtimeCompute.runAs, 'expr', () => runtimeCompute.execute(wrapped), 'read'); + let newVal = runtimeCompute.execute(wrapped); newVal = ensureSyncBridgeResult(newVal, `@Compute(${field})`); const currentFieldValue = entity[field]; @@ -286,7 +282,7 @@ export class ComputeEngine { const fieldMeta = meta.fields.get(f); try { const oldVal = entity[f]; - const result = await withComputeRunAsExecution(meta, f, runtimeCompute.runAs, 'expr', () => runtimeCompute.execute(wrapped), mode); + const result = await runtimeCompute.execute(wrapped); let newVal = entity[f]; if (newVal === oldVal && result !== undefined) { diff --git a/modules/core/service/runtime/compute/runas.test.ts b/modules/core/service/runtime/compute/runas.test.ts deleted file mode 100644 index c7b1da9be..000000000 --- a/modules/core/service/runtime/compute/runas.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: Apache-2.0 - -import { getReadonlyCtx } from '../context'; -import { recordComputeRunAsAudit, withComputeRunAsExecution } from './runas'; - -function makeMeta() { - return { - fullModelName: 'test.RunAsModel', - modelName: 'RunAsModel', - className: 'RunAsModel', - } as any; -} - -test('compute runAs helper keeps user path as no-op and does not record audit', () => { - delete (globalThis as any).__choysumComputeAudit; - - const value = withComputeRunAsExecution(makeMeta(), 'SecureValue', 'user', 'expr', () => { - const ctx = getReadonlyCtx() as any; - return String(ctx?.__computeRunAs || 'none'); - }); - - expect(value).toBe('none'); - const hits = ((globalThis as any).__choysumComputeAudit?.runAsHits || []) as any[]; - expect(hits.length).toBe(0); -}); - -test('compute runAs helper initializes and repairs global audit bucket while reusing existing bucket', () => { - (globalThis as any).__choysumComputeAudit = 123; - recordComputeRunAsAudit(makeMeta(), 'SecureValue', 'sudo', 'search'); - - let bucket = (globalThis as any).__choysumComputeAudit; - expect(bucket && typeof bucket === 'object').toBe(true); - expect(Array.isArray(bucket.runAsHits)).toBe(true); - expect(bucket.runAsHits.length).toBe(1); - - const reused = { runAsHits: [{ version: 1, model: 'x', field: 'y', runAs: 'sudo', phase: 'expr', at: 't' }], token: 'keep' }; - (globalThis as any).__choysumComputeAudit = reused; - recordComputeRunAsAudit(makeMeta(), 'SecureValue', 'sudo', 'expr', 'persist'); - bucket = (globalThis as any).__choysumComputeAudit; - - expect(bucket).toBe(reused); - expect(bucket.token).toBe('keep'); - expect(bucket.runAsHits.length).toBe(2); - - (globalThis as any).__choysumComputeAudit = { runAsHits: 'broken' }; - recordComputeRunAsAudit(makeMeta(), 'SecureValue', 'sudo', 'expr'); - bucket = (globalThis as any).__choysumComputeAudit; - expect(Array.isArray(bucket.runAsHits)).toBe(true); - expect(bucket.runAsHits.length).toBe(1); -}); - -test('compute runAs helper propagates thrown errors and applies sudo context marker in callback', () => { - delete (globalThis as any).__choysumComputeAudit; - - let message = ''; - try { - withComputeRunAsExecution(makeMeta(), 'SecureValue', 'sudo', 'expr', () => { - const ctx = getReadonlyCtx() as any; - throw new Error(`boom:${String(ctx?.__computeRunAs || 'none')}`); - }); - } catch (error) { - message = String((error as Error)?.message || error); - } - - expect(message).toBe('boom:sudo'); - const hits = ((globalThis as any).__choysumComputeAudit?.runAsHits || []) as any[]; - expect(hits.length).toBe(1); - expect(hits[0]?.runAs).toBe('sudo'); - expect(hits[0]?.phase).toBe('expr'); -}); - -test('compute runAs helper normalizes unknown model/field and blank mode in audit entry', () => { - delete (globalThis as any).__choysumComputeAudit; - - recordComputeRunAsAudit( - { - fullModelName: ' ', - modelName: '', - className: '', - } as any, - ' ', - 'sudo', - 'search', - ' ' - ); - - const hits = ((globalThis as any).__choysumComputeAudit?.runAsHits || []) as any[]; - expect(hits.length).toBe(1); - expect(hits[0]?.model).toBe('Unknown'); - expect(hits[0]?.field).toBe('unknown'); - expect(hits[0]?.mode).toBe(''); - expect(hits[0]?.phase).toBe('search'); -}); diff --git a/modules/core/service/runtime/compute/runas.ts b/modules/core/service/runtime/compute/runas.ts deleted file mode 100644 index ed464bec1..000000000 --- a/modules/core/service/runtime/compute/runas.ts +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: Apache-2.0 - -import type { ComputeRunAs } from '../../orm/metadata/compute'; -import type { ModelMetadata } from '../../orm/metadata/model'; -import { getRuntimeComputeAuditBucketValue, setRuntimeComputeAuditBucketValue } from '@/core/utils/env'; -import { asObjectRecord } from '@/core/utils/object'; -import { withContext } from '../context'; -import type { ObjectRecord } from '../../../utils/types'; - -type ComputeAuditPhase = 'expr' | 'search'; - -type ComputeAuditEntry = { - version: 1; - model: string; - field: string; - runAs: ComputeRunAs; - mode?: string; - phase: ComputeAuditPhase; - at: string; -}; - -function resolveComputeAuditBucket(): { runAsHits: ComputeAuditEntry[] } { - const bucketRecord = asObjectRecord(getRuntimeComputeAuditBucketValue()); - const bucket: ObjectRecord = bucketRecord ?? {}; - if (!Array.isArray(bucket.runAsHits)) bucket.runAsHits = []; - setRuntimeComputeAuditBucketValue(bucket); - return bucket as { runAsHits: ComputeAuditEntry[] }; -} - -function buildAuditEntry(meta: ModelMetadata, field: string, runAs: ComputeRunAs, phase: ComputeAuditPhase, mode?: string): ComputeAuditEntry { - const model = String(meta.fullModelName || meta.modelName || meta.className || 'Unknown').trim() || 'Unknown'; - return { - version: 1, - model, - field: String(field || '').trim() || 'unknown', - runAs, - mode: mode ? String(mode || '').trim() : undefined, - phase, - at: new Date().toISOString(), - }; -} - -export function recordComputeRunAsAudit(meta: ModelMetadata, field: string, runAs: ComputeRunAs, phase: ComputeAuditPhase, mode?: string): void { - if (runAs !== 'sudo') return; - const bucket = resolveComputeAuditBucket(); - bucket.runAsHits.push(buildAuditEntry(meta, field, runAs, phase, mode)); -} - -export function withComputeRunAsExecution(meta: ModelMetadata, field: string, runAs: ComputeRunAs, phase: ComputeAuditPhase, fn: () => T, mode?: string): T { - if (runAs !== 'sudo') return fn(); - - recordComputeRunAsAudit(meta, field, runAs, phase, mode); - const marker = buildAuditEntry(meta, field, runAs, phase, mode); - return withContext( - { - __computeRunAs: 'sudo', - __computeAuditMarker: marker, - }, - fn, - { merge: true } - ); -} diff --git a/modules/core/service/runtime/compute/search_rewrite.ts b/modules/core/service/runtime/compute/search_rewrite.ts index 07f55fd11..82c18c657 100644 --- a/modules/core/service/runtime/compute/search_rewrite.ts +++ b/modules/core/service/runtime/compute/search_rewrite.ts @@ -4,7 +4,6 @@ import type { DialectName } from '../../orm/repository/repository_dialect'; import type { BaseQueryCondition } from '../../orm/repository/types'; import type { ModelMetadata } from '../../orm/metadata/model'; -import { withComputeRunAsExecution } from './runas'; import { withBridgeFrame } from './bridge'; import { createEntityBackedModelInstance, resolveInstanceHandler } from './handler_runtime'; import { asObjectRecord } from '../../../utils/object'; @@ -29,7 +28,7 @@ export function rewriteSearchCondition( op: unknown, value: unknown, dialect: DialectName, - mode = 'query' + _mode = 'query' ): SearchRewriteResolved | undefined { if (!fieldName || fieldName.includes('.')) return; @@ -67,8 +66,6 @@ export function rewriteSearchCondition( throw new Error(`SEARCH_HANDLER_REQUIRED: virtual field ${modelLabel}.${fieldName} requires an explicit @Search handler`); } - const runAs = computeHandler?.runAs === 'sudo' || legacyCompute?.runAs === 'sudo' ? 'sudo' : 'user'; - const executeWithBridge = () => { if (fromExplicitSearchDecorator) { const instanceMethod = resolveInstanceHandler(meta, fieldName, handlerName, '@Search'); @@ -108,11 +105,10 @@ export function rewriteSearchCondition( op, value, dialect: String(dialect || 'postgres') as DialectName, - runAs, }); }; - const raw = withComputeRunAsExecution(meta, fieldName, runAs, 'search', executeWithBridge, mode); + const raw = executeWithBridge(); if (isPromiseLike(raw)) { throw new Error( `compute.search handler returned a Promise, but the current query compilation path only supports synchronous handlers: ${modelLabel}.${fieldName}` diff --git a/modules/core/service/runtime/context/context.test.ts b/modules/core/service/runtime/context/context.test.ts index 2019cfcfa..8578f633d 100644 --- a/modules/core/service/runtime/context/context.test.ts +++ b/modules/core/service/runtime/context/context.test.ts @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: Apache-2.0 -import { __deepFreezeForTest, getIdentity, getReqMeta, getUserId } from './source'; +import { __deepFreezeForTest, getIdentity, getReqMeta } from './source'; +import { getUserId, withUser } from './user'; import { getActiveCompanyId, getContextLang, getContextTimezone, getContextCompanyTimezone, getContextClientTimezone, getEnabledCompanyIds, getReadonlyCtx, withContext } from './scope'; function withTempChoysum(root: any, fn: () => T): T { @@ -265,3 +266,46 @@ test('runtime context source deep-freeze helper handles primitive, frozen and du expect(Object.isFrozen(out.right)).toBe(true); expect(Object.isFrozen(out.left.leaf)).toBe(true); }); + +test('withUser overrides getUserId nested and restores; withContext({ userId }) does not', async () => { + const root = { + request: { + context: { + identity: { userId: 'U-ROOT' }, + ctx: { lang: 'en' }, + }, + }, + }; + + await withTempChoysum(root, async () => { + expect(getUserId()).toBe('U-ROOT'); + + withContext({ userId: 'U-FAKE' } as any, () => { + expect(getUserId()).toBe('U-ROOT'); + }); + + const nested = withUser('U-A', () => { + expect(getUserId()).toBe('U-A'); + return withUser('U-B', () => getUserId()); + }); + expect(nested).toBe('U-B'); + expect(getUserId()).toBe('U-ROOT'); + + await withUser('U-ASYNC', async () => { + expect(getUserId()).toBe('U-ASYNC'); + return Promise.resolve(); + }); + expect(getUserId()).toBe('U-ROOT'); + }); +}); + +test('withUser rejects empty userId and works on process stack without jsCtx', () => { + expect(() => withUser(' ', () => undefined)).toThrow('non-empty userId'); + + withTempChoysum(undefined, () => { + expect(getUserId()).toBe(undefined); + const value = withUser('U-PROC', () => getUserId()); + expect(value).toBe('U-PROC'); + expect(getUserId()).toBe(undefined); + }); +}); diff --git a/modules/core/service/runtime/context/index.ts b/modules/core/service/runtime/context/index.ts index 54fc6efc2..66739daa4 100644 --- a/modules/core/service/runtime/context/index.ts +++ b/modules/core/service/runtime/context/index.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 export type { Context } from './source'; -export { getIdentity, getReqMeta, getUserId } from './source'; +export { getIdentity, getReqMeta } from './source'; +export { getUserId, withUser } from './user'; export { getJsCtxAndReq, getCurrentReq, diff --git a/modules/core/service/runtime/context/source.ts b/modules/core/service/runtime/context/source.ts index fc84f0b3f..15cfd96eb 100644 --- a/modules/core/service/runtime/context/source.ts +++ b/modules/core/service/runtime/context/source.ts @@ -72,11 +72,3 @@ export function getReqMeta(): Readonly { return deepFreeze({ ...(asObjectRecord(jsCtx?.req) ?? {}) }) as Readonly; } -/** - * Returns the current user Id from jsCtx, if available. - */ -export function getUserId(): string | undefined { - const jsCtx = getJsCtxRoot(); - const identity = asObjectRecord(jsCtx?.identity); - return typeof identity?.userId === 'string' ? identity.userId : undefined; -} diff --git a/modules/core/service/runtime/context/user.ts b/modules/core/service/runtime/context/user.ts new file mode 100644 index 000000000..403b5ca35 --- /dev/null +++ b/modules/core/service/runtime/context/user.ts @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { asObjectRecord } from '../../../utils/object'; +import { getJsCtxRoot } from './source'; + +/** Request-scoped userId override stack (nested withUser). */ +const USER_ID_OVERRIDE_KEY = Symbol.for('choysum.userid.override'); + +/** Process-level override stack used when no jsCtx is available (scripts / background). */ +const processLevelUserIdStack: string[] = []; + +type UserIdCarrier = Record; + +function asUserIdCarrier(value: unknown): UserIdCarrier | undefined { + const record = asObjectRecord(value); + return record ? (record as UserIdCarrier) : undefined; +} + +function normalizeUserId(userId: unknown): string { + const normalized = typeof userId === 'string' ? userId.trim() : String(userId ?? '').trim(); + if (!normalized) { + throw new Error('withUser requires a non-empty userId'); + } + return normalized; +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return !!value && typeof (value as { then?: unknown }).then === 'function'; +} + +function peekOverrideFromCarrier(carrier: UserIdCarrier | undefined): string | undefined { + const stack = carrier?.[USER_ID_OVERRIDE_KEY]; + if (!Array.isArray(stack) || stack.length === 0) return undefined; + const top = stack[stack.length - 1]; + return typeof top === 'string' && top ? top : undefined; +} + +/** + * Returns the active userId override (stack top), if any. + * Prefer request-scoped stack; fall back to process-level stack. + */ +export function peekUserIdOverride(): string | undefined { + const fromRequest = peekOverrideFromCarrier(asUserIdCarrier(getJsCtxRoot())); + if (fromRequest) return fromRequest; + if (processLevelUserIdStack.length > 0) { + return processLevelUserIdStack[processLevelUserIdStack.length - 1]; + } + return undefined; +} + +/** + * Returns the current user Id: withUser override stack top, else jsCtx.identity.userId. + */ +export function getUserId(): string | undefined { + const override = peekUserIdOverride(); + if (override) return override; + + const jsCtx = getJsCtxRoot(); + const identity = asObjectRecord(jsCtx?.identity); + return typeof identity?.userId === 'string' ? identity.userId : undefined; +} + +/** + * Runs a function with a temporary userId override for authz / getUserId(). + * + * Does not wrap withContext and does not elevate privileges (use Model.sudo for bypass). + * Sync and async `fn` are both supported (aligned with withContext). + */ +export function withUser(userId: string, fn: () => R): R { + const normalized = normalizeUserId(userId); + const jsCtx = asUserIdCarrier(getJsCtxRoot()); + + if (jsCtx) { + let stack = jsCtx[USER_ID_OVERRIDE_KEY] as string[] | undefined; + if (!Array.isArray(stack)) { + stack = []; + jsCtx[USER_ID_OVERRIDE_KEY] = stack; + } + stack.push(normalized); + + const restore = () => { + stack!.pop(); + if (stack!.length === 0) delete jsCtx[USER_ID_OVERRIDE_KEY]; + }; + + try { + const result = fn(); + if (isPromiseLike(result)) { + return Promise.resolve(result).finally(restore) as unknown as R; + } + restore(); + return result; + } catch (error) { + restore(); + throw error; + } + } + + processLevelUserIdStack.push(normalized); + try { + const result = fn(); + if (isPromiseLike(result)) { + return Promise.resolve(result).finally(() => { + processLevelUserIdStack.pop(); + }) as unknown as R; + } + processLevelUserIdStack.pop(); + return result; + } catch (error) { + processLevelUserIdStack.pop(); + throw error; + } +} From d5ca6d6e5f9eb47d1ce001c8a14c3d3749c25dfd Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 15:30:40 +0800 Subject: [PATCH 2/9] refactor(meta): drop compute runAs from field metadata pipeline - Remove WebFieldMetadata.runAs and getFieldMetadataView passthrough. - Stop parsing, resolving, and codegen-emitting IrField/compute runAs. Co-authored-by: Cursor --- internal/module/artifact/generate/webapistore.go | 7 ------- internal/module/artifact/generate/webapistore.ts.tpl | 1 - internal/module/artifact/generate/webapistore_test.go | 8 +++----- internal/parser/backendtsparser/field_resolved.go | 10 ---------- .../parser/backendtsparser/parser_migration_test.go | 5 +---- modules/web/web/stores/modelStore.test.ts | 2 -- modules/web/web/stores/modelStore.ts | 3 --- pkg/meta/meta_field.go | 6 ++---- pkg/meta/meta_field_test.go | 10 ++-------- 9 files changed, 8 insertions(+), 44 deletions(-) diff --git a/internal/module/artifact/generate/webapistore.go b/internal/module/artifact/generate/webapistore.go index 790efa4a0..088938789 100644 --- a/internal/module/artifact/generate/webapistore.go +++ b/internal/module/artifact/generate/webapistore.go @@ -42,7 +42,6 @@ type FieldMetadata struct { RelatedPath *string `json:"relatedPath,omitempty"` RelatedStore *bool `json:"relatedStore,omitempty"` Searchable *bool `json:"searchable,omitempty"` - RunAs *string `json:"runAs,omitempty"` RelationModel *string `json:"relationModel,omitempty"` RelationFilter *string `json:"relationFilter,omitempty"` RelationModelParentField *string `json:"relationModelParentField,omitempty"` @@ -123,12 +122,6 @@ func applyResolvedFieldContract(metadata *FieldMetadata, field *meta.IrField) { metadata.Searchable = &searchable } - if resolved.Resolved.RunAs.Value != nil { - metadata.RunAs = toStringPtr(*resolved.Resolved.RunAs.Value) - } else if resolved.Behavior.Compute != nil { - metadata.RunAs = toStringPtr(resolved.Behavior.Compute.RunAs) - } - if resolved.Structural.Translate != nil && *resolved.Structural.Translate { t := true metadata.Translate = &t diff --git a/internal/module/artifact/generate/webapistore.ts.tpl b/internal/module/artifact/generate/webapistore.ts.tpl index 88805a5da..31b066216 100644 --- a/internal/module/artifact/generate/webapistore.ts.tpl +++ b/internal/module/artifact/generate/webapistore.ts.tpl @@ -36,7 +36,6 @@ export const {{.Model.Name}}FieldsMetadata = { {{- if $field.RelatedPath}}relatedPath: '{{$field.RelatedPath}}',{{- end}} {{- if ne $field.RelatedStore nil}}relatedStore: {{$field.RelatedStore}},{{- end}} {{- if ne $field.Searchable nil}}searchable: {{$field.Searchable}},{{- end}} - {{- if $field.RunAs}}runAs: '{{$field.RunAs}}',{{- end}} {{- if and $field.RelationModel (or (eq $field.FieldType "ManyToOne") (eq $field.FieldType "OneToMany") (eq $field.FieldType "ManyToMany") (eq $field.FieldType "ManyToOneRef") (eq $field.FieldType "ManyToManyRef"))}}relationModel: '{{$field.RelationModel}}',{{- end}} {{- if $field.RelationFilter}}relationFilter: "{{$field.RelationFilter}}",{{- end}} {{- if $field.RelationModelParentField}}relationModelParentField: '{{$field.RelationModelParentField}}',{{- end}} diff --git a/internal/module/artifact/generate/webapistore_test.go b/internal/module/artifact/generate/webapistore_test.go index 56fb101dc..bc8a1438e 100644 --- a/internal/module/artifact/generate/webapistore_test.go +++ b/internal/module/artifact/generate/webapistore_test.go @@ -22,7 +22,6 @@ func TestWebApiStoreGenerate(t *testing.T) { selectionJSON := `[{"value":"allow","label":"Allow","labelText":{"key":"` + referenceKey + `","module":"demo","scope":"demo.status.allow","src":"Allow","kind":"literal"}}]` round := "HALF_UP" searchable := true - runAs := "system" field := &meta.IrField{ BaseModel: meta.BaseModel{Id: sql.NullString{String: "field-1", Valid: true}}, Name: "Amount", @@ -54,12 +53,11 @@ func TestWebApiStoreGenerate(t *testing.T) { Related: &meta.IrFieldRelatedSpec{Path: "CurrencyId.Symbol", Store: true}, }, Behavior: meta.IrFieldBehaviorSpec{ - Compute: &meta.IrFieldBehaviorComputeSpec{Method: "ComputeAmount", Deps: []string{"CurrencyId"}, Store: true, RunAs: "user"}, + Compute: &meta.IrFieldBehaviorComputeSpec{Method: "ComputeAmount", Deps: []string{"CurrencyId"}, Store: true}, }, Migration: meta.IrFieldMigrationDecision{StorageKind: "column", ShouldCreateColumn: true, ResolvedColumnType: "NUMERIC(12,4)", ReasonCode: "LEGACY_COLUMN"}, } resolvedSpec.Resolved.Searchable = meta.IrResolvedValue[*bool]{Value: &searchable, Source: "decorator"} - resolvedSpec.Resolved.RunAs = meta.IrResolvedValue[*string]{Value: &runAs, Source: "decorator"} if err := field.SetResolvedSpec(resolvedSpec); err != nil { t.Fatalf("set resolved spec: %v", err) } @@ -79,8 +77,8 @@ func TestWebApiStoreGenerate(t *testing.T) { if metadata.RelatedPath == nil || *metadata.RelatedPath != "CurrencyId.Symbol" || metadata.Searchable == nil || !*metadata.Searchable { t.Fatalf("expected related/searchable fields, got %#v", metadata) } - if metadata.RunAs == nil || *metadata.RunAs != "system" || metadata.ShouldCreateColumn == nil || !*metadata.ShouldCreateColumn { - t.Fatalf("expected migration/runAs fields, got %#v", metadata) + if metadata.ShouldCreateColumn == nil || !*metadata.ShouldCreateColumn { + t.Fatalf("expected ShouldCreateColumn=true, got %#v", metadata) } if metadata.Translate != nil { t.Fatalf("non-translate field must omit Translate, got %#v", metadata.Translate) diff --git a/internal/parser/backendtsparser/field_resolved.go b/internal/parser/backendtsparser/field_resolved.go index 451acdeb7..c2b08bfad 100644 --- a/internal/parser/backendtsparser/field_resolved.go +++ b/internal/parser/backendtsparser/field_resolved.go @@ -167,16 +167,11 @@ func collectFieldBehaviorBindings(methods []*parser.MemberMethod) (map[string]*r if v, ok := opts["searchable"].(bool); ok { searchable = toBoolPtr(v) } - runAs := "" - if v, ok := opts["runAs"].(string); ok { - runAs = strings.TrimSpace(v) - } binding.compute = &meta.IrFieldBehaviorComputeSpec{ Method: method.Name, Deps: deps, Store: store, Searchable: searchable, - RunAs: runAs, } case "SqlCompute": if binding.sqlCompute != nil { @@ -543,11 +538,6 @@ func buildFieldResolvedSpec(field *meta.IrField, binding *resolvedFieldBehaviorB spec.Resolved.Searchable = meta.IrResolvedValue[*bool]{Value: spec.Behavior.Compute.Searchable, Source: "@Compute.searchable"} } - if spec.Behavior.Compute != nil && strings.TrimSpace(spec.Behavior.Compute.RunAs) != "" { - runAs := strings.TrimSpace(spec.Behavior.Compute.RunAs) - spec.Resolved.RunAs = meta.IrResolvedValue[*string]{Value: &runAs, Source: "@Compute.runAs"} - } - columnType := resolveColumnType(fieldType) if translate { // Logical type stays char/varchar/text; physical storage is JSON/JSONB lang map. diff --git a/internal/parser/backendtsparser/parser_migration_test.go b/internal/parser/backendtsparser/parser_migration_test.go index ea311a14e..35c5a0d97 100644 --- a/internal/parser/backendtsparser/parser_migration_test.go +++ b/internal/parser/backendtsparser/parser_migration_test.go @@ -235,7 +235,7 @@ export default class Demo extends BaseModel { @Field({ type: 'varchar', related: { path: 'PartnerId.Name', store: true, deps: ['PartnerId', 'PartnerId.Name'] } }) public PartnerName: string - @Compute('PartnerName', { deps: ['Name'], store: false, searchable: true, runAs: 'sudo' }) + @Compute('PartnerName', { deps: ['Name'], store: false, searchable: true }) computePartnerName() { return this.Name } @@ -290,9 +290,6 @@ export default class Demo extends BaseModel { if partnerSpec.Migration.ShouldCreateColumn != false || partnerSpec.Migration.ReasonCode != "COMPUTE_STORE_FALSE" { t.Fatalf("unexpected PartnerName migration decision: %+v", partnerSpec.Migration) } - if partnerSpec.Resolved.RunAs.Value == nil || *partnerSpec.Resolved.RunAs.Value != "sudo" { - t.Fatalf("unexpected PartnerName runAs resolution: %+v", partnerSpec.Resolved.RunAs) - } } func TestTsParser_ParseModelRejectsLegacyFieldSyntax(t *testing.T) { diff --git a/modules/web/web/stores/modelStore.test.ts b/modules/web/web/stores/modelStore.test.ts index 0d9d8cac4..682e96f1b 100644 --- a/modules/web/web/stores/modelStore.test.ts +++ b/modules/web/web/stores/modelStore.test.ts @@ -20,7 +20,6 @@ describe('getFieldMetadataView', () => { relatedPath: 'CompanyId.Name', relatedStore: true, searchable: true, - runAs: 'system', }; const normalized = getFieldMetadataView(meta); @@ -34,7 +33,6 @@ describe('getFieldMetadataView', () => { expect(normalized.relatedPath).toBe('CompanyId.Name'); expect(normalized.relatedStore).toBe(true); expect(normalized.searchable).toBe(true); - expect(normalized.runAs).toBe('system'); }); it('marks relation only by declared relation field types', () => { diff --git a/modules/web/web/stores/modelStore.ts b/modules/web/web/stores/modelStore.ts index 3e4c3b19a..9937d786b 100644 --- a/modules/web/web/stores/modelStore.ts +++ b/modules/web/web/stores/modelStore.ts @@ -28,7 +28,6 @@ export type WebFieldMetadata = { relatedPath?: string; relatedStore?: boolean; searchable?: boolean; - runAs?: string; notNull?: boolean; size?: number; precision?: number; @@ -73,7 +72,6 @@ export function getFieldMetadataView(meta: WebFieldMetadata | undefined) { const resolvedColumnType = meta?.resolvedColumnType; const reasonCode = meta?.reasonCode; const relatedStore = typeof meta?.relatedStore === 'boolean' ? meta.relatedStore : undefined; - const runAs = meta?.runAs; return { relationModel, @@ -85,7 +83,6 @@ export function getFieldMetadataView(meta: WebFieldMetadata | undefined) { resolvedColumnType, reasonCode, searchable, - runAs, isRelation: isRelationFieldType(type), } as const; } diff --git a/pkg/meta/meta_field.go b/pkg/meta/meta_field.go index 2445b1f13..3979cc1c4 100644 --- a/pkg/meta/meta_field.go +++ b/pkg/meta/meta_field.go @@ -94,7 +94,6 @@ type IrFieldBehaviorComputeSpec struct { Deps []string `json:"deps"` Store bool `json:"store"` Searchable *bool `json:"searchable,omitempty"` - RunAs string `json:"runAs,omitempty"` } type IrFieldBehaviorSqlComputeSpec struct { @@ -132,9 +131,8 @@ type IrFieldResolvedSpec struct { Structural IrFieldStructuralSpec `json:"structural"` Behavior IrFieldBehaviorSpec `json:"behavior"` Resolved struct { - Store IrResolvedValue[bool] `json:"store"` - Searchable IrResolvedValue[*bool] `json:"searchable"` - RunAs IrResolvedValue[*string] `json:"runAs"` + Store IrResolvedValue[bool] `json:"store"` + Searchable IrResolvedValue[*bool] `json:"searchable"` } `json:"resolved"` Migration IrFieldMigrationDecision `json:"migration"` Diagnostics []IrFieldDiagnostic `json:"diagnostics,omitempty"` diff --git a/pkg/meta/meta_field_test.go b/pkg/meta/meta_field_test.go index f13eb3b79..fb4a02cca 100644 --- a/pkg/meta/meta_field_test.go +++ b/pkg/meta/meta_field_test.go @@ -135,7 +135,6 @@ func TestIrField_SetResolvedSpec(t *testing.T) { t.Run("spec with related and resolved values", func(t *testing.T) { field := &IrField{} searchable := ptr(true) - runAs := ptr("admin") spec := &IrFieldResolvedSpec{ FieldName: "total", Structural: IrFieldStructuralSpec{ @@ -148,13 +147,11 @@ func TestIrField_SetResolvedSpec(t *testing.T) { }, }, Resolved: struct { - Store IrResolvedValue[bool] `json:"store"` - Searchable IrResolvedValue[*bool] `json:"searchable"` - RunAs IrResolvedValue[*string] `json:"runAs"` + Store IrResolvedValue[bool] `json:"store"` + Searchable IrResolvedValue[*bool] `json:"searchable"` }{ Store: IrResolvedValue[bool]{Value: false, Source: "related"}, Searchable: IrResolvedValue[*bool]{Value: searchable, Source: "explicit"}, - RunAs: IrResolvedValue[*string]{Value: runAs, Source: "explicit"}, }, } if err := field.SetResolvedSpec(spec); err != nil { @@ -171,9 +168,6 @@ func TestIrField_SetResolvedSpec(t *testing.T) { if roundtrip.Resolved.Searchable.Value == nil || *roundtrip.Resolved.Searchable.Value != true { t.Fatal("expected Searchable.Value = true") } - if roundtrip.Resolved.RunAs.Value == nil || *roundtrip.Resolved.RunAs.Value != "admin" { - t.Fatalf("expected RunAs.Value = admin") - } }) } From be94a64b71a6b391ca17fd7880c8465cd3cc04ad Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 15:33:52 +0800 Subject: [PATCH 3/9] fix(orm): harden withUser and authz bypass for concurrent cleanup - Remove withUser override entries by identity so sibling async scopes do not LIFO-pop each other. - Decrement shared RR/FR bypass depth on restore, matching withBypassDepths for out-of-order completion. Co-authored-by: Cursor --- .../orm/repository/authz/authz_runtime.ts | 15 +++--- .../authz/tests/authz_runtime.test.ts | 34 +++++++++++++ .../service/runtime/context/context.test.ts | 35 +++++++++++++ modules/core/service/runtime/context/user.ts | 50 ++++++++++++------- 4 files changed, 110 insertions(+), 24 deletions(-) diff --git a/modules/core/service/orm/repository/authz/authz_runtime.ts b/modules/core/service/orm/repository/authz/authz_runtime.ts index 927d73447..a59f34a7e 100644 --- a/modules/core/service/orm/repository/authz/authz_runtime.ts +++ b/modules/core/service/orm/repository/authz/authz_runtime.ts @@ -154,8 +154,11 @@ export function getRepositoryRecordRuleBypassDepth(): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0; } -function restoreBypassDepth(state: RepositoryReqServiceState, key: 'recordRuleBypassDepth' | 'fieldRuleBypassDepth', previousDepth: number): void { - if (previousDepth > 0) state[key] = previousDepth; +function restoreBypassDepth(state: RepositoryReqServiceState, key: 'recordRuleBypassDepth' | 'fieldRuleBypassDepth'): void { + // Decrement rather than write back previousDepth so concurrent sibling bypasses + // that share request service state survive out-of-order completion (aligned with withBypassDepths). + const current = typeof state[key] === 'number' && Number.isFinite(state[key]) ? (state[key] as number) : 0; + if (current > 1) state[key] = current - 1; else delete state[key]; } @@ -188,7 +191,7 @@ export function withRepositoryRecordRuleBypass(fn: () => T): T { const previousDepth = getRepositoryRecordRuleBypassDepth(); state.recordRuleBypassDepth = previousDepth + 1; - return runWithBypassRestore(fn, () => restoreBypassDepth(state, 'recordRuleBypassDepth', previousDepth)); + return runWithBypassRestore(fn, () => restoreBypassDepth(state, 'recordRuleBypassDepth')); } export function getRepositoryFieldRuleBypassDepth(): number { @@ -209,7 +212,7 @@ export function withRepositoryFieldRuleBypass(fn: () => T): T { const previousDepth = getRepositoryFieldRuleBypassDepth(); state.fieldRuleBypassDepth = previousDepth + 1; - return runWithBypassRestore(fn, () => restoreBypassDepth(state, 'fieldRuleBypassDepth', previousDepth)); + return runWithBypassRestore(fn, () => restoreBypassDepth(state, 'fieldRuleBypassDepth')); } /** @@ -227,8 +230,8 @@ export function withRepositoryAuthzRuleBypass(fn: () => T): T { state.fieldRuleBypassDepth = previousFieldDepth + 1; return runWithBypassRestore(fn, () => { - restoreBypassDepth(state, 'recordRuleBypassDepth', previousRecordDepth); - restoreBypassDepth(state, 'fieldRuleBypassDepth', previousFieldDepth); + restoreBypassDepth(state, 'recordRuleBypassDepth'); + restoreBypassDepth(state, 'fieldRuleBypassDepth'); }); } diff --git a/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts b/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts index bec297945..db98ec1db 100644 --- a/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts +++ b/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts @@ -212,6 +212,40 @@ test('authz runtime field bypass nested depth restores previous value', async () ); }); +test('authz runtime concurrent sibling bypasses survive out-of-order completion', async () => { + await withPatchedChoysum( + { + request: { + context: { + req: {}, + }, + }, + }, + async () => { + let releaseSlow: (() => void) | undefined; + const slowGate = new Promise(resolve => { + releaseSlow = resolve; + }); + + const slow = withRepositoryRecordRuleBypass(async () => { + await slowGate; + expect(getRepositoryRecordRuleBypassDepth()).toBeGreaterThanOrEqual(1); + }); + + const fast = withRepositoryRecordRuleBypass(async () => { + expect(getRepositoryRecordRuleBypassDepth()).toBe(2); + }); + + await fast; + // First-started bypass must still be elevated after the second finishes. + expect(getRepositoryRecordRuleBypassDepth()).toBe(1); + releaseSlow?.(); + await slow; + expect(getRepositoryRecordRuleBypassDepth()).toBe(0); + } + ); +}); + test('authz runtime log mode and audit switch normalize mixed env values', () => { const originalEnv = (globalThis as any).__CHOYSUM_RUNTIME_ENV__; try { diff --git a/modules/core/service/runtime/context/context.test.ts b/modules/core/service/runtime/context/context.test.ts index 8578f633d..8a8b3053b 100644 --- a/modules/core/service/runtime/context/context.test.ts +++ b/modules/core/service/runtime/context/context.test.ts @@ -309,3 +309,38 @@ test('withUser rejects empty userId and works on process stack without jsCtx', ( expect(getUserId()).toBe(undefined); }); }); + +test('withUser concurrent sibling overrides restore by entry identity', async () => { + const root = { + request: { + context: { + identity: { userId: 'U-ROOT' }, + }, + }, + }; + + await withTempChoysum(root, async () => { + let releaseSlow: (() => void) | undefined; + const slowGate = new Promise(resolve => { + releaseSlow = resolve; + }); + + const slow = withUser('U-SLOW', async () => { + await slowGate; + expect(getUserId()).toBe('U-SLOW'); + return getUserId(); + }); + + const fast = withUser('U-FAST', async () => { + expect(getUserId()).toBe('U-FAST'); + return getUserId(); + }); + + await fast; + // Fast finished first; slow entry must still be on the stack (not LIFO-popped away). + expect(getUserId()).toBe('U-SLOW'); + releaseSlow?.(); + await slow; + expect(getUserId()).toBe('U-ROOT'); + }); +}); diff --git a/modules/core/service/runtime/context/user.ts b/modules/core/service/runtime/context/user.ts index 403b5ca35..9cdd9421e 100644 --- a/modules/core/service/runtime/context/user.ts +++ b/modules/core/service/runtime/context/user.ts @@ -4,11 +4,13 @@ import { asObjectRecord } from '../../../utils/object'; import { getJsCtxRoot } from './source'; -/** Request-scoped userId override stack (nested withUser). */ +/** Request-scoped userId override stack (nested / concurrent withUser). */ const USER_ID_OVERRIDE_KEY = Symbol.for('choysum.userid.override'); +type UserIdOverrideEntry = { userId: string }; + /** Process-level override stack used when no jsCtx is available (scripts / background). */ -const processLevelUserIdStack: string[] = []; +const processLevelUserIdStack: UserIdOverrideEntry[] = []; type UserIdCarrier = Record; @@ -29,13 +31,25 @@ function isPromiseLike(value: unknown): value is PromiseLike { return !!value && typeof (value as { then?: unknown }).then === 'function'; } -function peekOverrideFromCarrier(carrier: UserIdCarrier | undefined): string | undefined { - const stack = carrier?.[USER_ID_OVERRIDE_KEY]; +function peekStackTop(stack: unknown): string | undefined { if (!Array.isArray(stack) || stack.length === 0) return undefined; - const top = stack[stack.length - 1]; + const top = stack[stack.length - 1] as UserIdOverrideEntry | string | undefined; + if (top && typeof top === 'object' && typeof top.userId === 'string' && top.userId) { + return top.userId; + } + // Legacy string entries (should not appear after this change). return typeof top === 'string' && top ? top : undefined; } +function removeStackEntry(stack: UserIdOverrideEntry[], entry: UserIdOverrideEntry): void { + const idx = stack.indexOf(entry); + if (idx !== -1) stack.splice(idx, 1); +} + +function peekOverrideFromCarrier(carrier: UserIdCarrier | undefined): string | undefined { + return peekStackTop(carrier?.[USER_ID_OVERRIDE_KEY]); +} + /** * Returns the active userId override (stack top), if any. * Prefer request-scoped stack; fall back to process-level stack. @@ -43,10 +57,7 @@ function peekOverrideFromCarrier(carrier: UserIdCarrier | undefined): string | u export function peekUserIdOverride(): string | undefined { const fromRequest = peekOverrideFromCarrier(asUserIdCarrier(getJsCtxRoot())); if (fromRequest) return fromRequest; - if (processLevelUserIdStack.length > 0) { - return processLevelUserIdStack[processLevelUserIdStack.length - 1]; - } - return undefined; + return peekStackTop(processLevelUserIdStack); } /** @@ -66,21 +77,23 @@ export function getUserId(): string | undefined { * * Does not wrap withContext and does not elevate privileges (use Model.sudo for bypass). * Sync and async `fn` are both supported (aligned with withContext). + * Concurrent sibling withUser calls remove their own stack entry by identity (not LIFO pop). */ export function withUser(userId: string, fn: () => R): R { const normalized = normalizeUserId(userId); + const entry: UserIdOverrideEntry = { userId: normalized }; const jsCtx = asUserIdCarrier(getJsCtxRoot()); if (jsCtx) { - let stack = jsCtx[USER_ID_OVERRIDE_KEY] as string[] | undefined; + let stack = jsCtx[USER_ID_OVERRIDE_KEY] as UserIdOverrideEntry[] | undefined; if (!Array.isArray(stack)) { stack = []; jsCtx[USER_ID_OVERRIDE_KEY] = stack; } - stack.push(normalized); + stack.push(entry); const restore = () => { - stack!.pop(); + removeStackEntry(stack!, entry); if (stack!.length === 0) delete jsCtx[USER_ID_OVERRIDE_KEY]; }; @@ -97,18 +110,19 @@ export function withUser(userId: string, fn: () => R): R { } } - processLevelUserIdStack.push(normalized); + processLevelUserIdStack.push(entry); + const restoreProcess = () => { + removeStackEntry(processLevelUserIdStack, entry); + }; try { const result = fn(); if (isPromiseLike(result)) { - return Promise.resolve(result).finally(() => { - processLevelUserIdStack.pop(); - }) as unknown as R; + return Promise.resolve(result).finally(restoreProcess) as unknown as R; } - processLevelUserIdStack.pop(); + restoreProcess(); return result; } catch (error) { - processLevelUserIdStack.pop(); + restoreProcess(); throw error; } } From b4fe495888ef6c9b3d170a20c536f29d8ed7a7d0 Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 15:56:07 +0800 Subject: [PATCH 4/9] fix(orm): scope sudo audit per request and nest authz bypass tests - Store sudoHits on request service state so audit entries do not accumulate across requests. - Exercise nested withRepositoryAuthzRuleBypass depth restore in unit coverage. Co-authored-by: Cursor --- .../core/service/orm/model/model_sudo.test.ts | 46 ++++++++++++++++--- modules/core/service/orm/model/model_sudo.ts | 21 +++++++-- .../authz/tests/authz_runtime.test.ts | 7 +++ 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/modules/core/service/orm/model/model_sudo.test.ts b/modules/core/service/orm/model/model_sudo.test.ts index 302ec4989..ca6bf911a 100644 --- a/modules/core/service/orm/model/model_sudo.test.ts +++ b/modules/core/service/orm/model/model_sudo.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: Apache-2.0 +import { getCurrentReq, getOrInitReqServiceState } from '../../runtime/context'; import { getRepositoryFieldRuleBypassDepth, getRepositoryRecordRuleBypassDepth } from '../repository/authz'; import { withModelSudo } from './model_sudo'; @@ -17,9 +18,13 @@ async function withPatchedChoysum(value: unknown, fn: () => Promise | T): } } -test('withModelSudo elevates RR+FR sync and records sudo audit enter', async () => { - delete (globalThis as any).__choysumComputeAudit; +function readSudoHits(): any[] { + const state = getOrInitReqServiceState(getCurrentReq()) as { sudoHits?: any[] } | undefined; + if (Array.isArray(state?.sudoHits)) return state.sudoHits; + return ((globalThis as any).__choysumComputeAudit?.sudoHits || []) as any[]; +} +test('withModelSudo elevates RR+FR sync and records sudo audit enter', async () => { await withPatchedChoysum( { request: { @@ -42,7 +47,7 @@ test('withModelSudo elevates RR+FR sync and records sudo audit enter', async () expect(getRepositoryRecordRuleBypassDepth()).toBe(0); expect(getRepositoryFieldRuleBypassDepth()).toBe(0); - const hits = ((globalThis as any).__choysumComputeAudit?.sudoHits || []) as any[]; + const hits = readSudoHits(); expect(hits.length).toBe(1); expect(hits[0]?.source).toBe('sudo'); expect(hits[0]?.version).toBe(1); @@ -52,8 +57,6 @@ test('withModelSudo elevates RR+FR sync and records sudo audit enter', async () }); test('withModelSudo nests and supports async fn', async () => { - delete (globalThis as any).__choysumComputeAudit; - await withPatchedChoysum( { request: { @@ -78,8 +81,39 @@ test('withModelSudo nests and supports async fn', async () => { expect(getRepositoryRecordRuleBypassDepth()).toBe(0); expect(getRepositoryFieldRuleBypassDepth()).toBe(0); - const hits = ((globalThis as any).__choysumComputeAudit?.sudoHits || []) as any[]; + const hits = readSudoHits(); expect(hits.length).toBe(2); } ); }); + +test('withModelSudo audit hits stay request-scoped and do not leak across requests', async () => { + await withPatchedChoysum( + { + request: { + context: { + req: { id: 'req-a' }, + }, + }, + }, + () => { + withModelSudo(() => 'a'); + expect(readSudoHits().length).toBe(1); + } + ); + + await withPatchedChoysum( + { + request: { + context: { + req: { id: 'req-b' }, + }, + }, + }, + () => { + expect(readSudoHits().length).toBe(0); + withModelSudo(() => 'b'); + expect(readSudoHits().length).toBe(1); + } + ); +}); diff --git a/modules/core/service/orm/model/model_sudo.ts b/modules/core/service/orm/model/model_sudo.ts index a2ddc599f..6feb3201f 100644 --- a/modules/core/service/orm/model/model_sudo.ts +++ b/modules/core/service/orm/model/model_sudo.ts @@ -4,6 +4,7 @@ import { getRuntimeComputeAuditBucketValue, getRuntimeEnvBoolean, setRuntimeComputeAuditBucketValue } from '@/core/utils/env'; import { asObjectRecord } from '@/core/utils/object'; import type { ObjectRecord } from '../../../utils/types'; +import { getCurrentReq, getOrInitReqServiceState } from '../../runtime/context'; import { withRepositoryAuthzRuleBypass } from '../repository/authz'; type SudoAuditEntry = { @@ -13,12 +14,26 @@ type SudoAuditEntry = { hint?: string; }; -function resolveSudoAuditBucket(): { sudoHits: SudoAuditEntry[] } { +type SudoAuditBucket = { sudoHits: SudoAuditEntry[] }; + +function ensureSudoHits(carrier: ObjectRecord): SudoAuditBucket { + if (!Array.isArray(carrier.sudoHits)) carrier.sudoHits = []; + return carrier as SudoAuditBucket; +} + +/** + * Prefer request-scoped service state so sudoHits do not accumulate across requests. + * Fall back to the process global bucket when no req is available (scripts / D10). + */ +function resolveSudoAuditBucket(): SudoAuditBucket { + const state = asObjectRecord(getOrInitReqServiceState(getCurrentReq())); + if (state) return ensureSudoHits(state); + const bucketRecord = asObjectRecord(getRuntimeComputeAuditBucketValue()); const bucket: ObjectRecord = bucketRecord ?? {}; - if (!Array.isArray(bucket.sudoHits)) bucket.sudoHits = []; + const resolved = ensureSudoHits(bucket); setRuntimeComputeAuditBucketValue(bucket); - return bucket as { sudoHits: SudoAuditEntry[] }; + return resolved; } function sudoAuditEnabled(): boolean { diff --git a/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts b/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts index db98ec1db..4b3ff58eb 100644 --- a/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts +++ b/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts @@ -184,7 +184,14 @@ test('authz runtime combined RR+FR bypass is sync-friendly and nested', () => { await withRepositoryAuthzRuleBypass(async () => { expect(getRepositoryRecordRuleBypassDepth()).toBe(1); expect(getRepositoryFieldRuleBypassDepth()).toBe(1); + await withRepositoryAuthzRuleBypass(async () => { + expect(getRepositoryRecordRuleBypassDepth()).toBe(2); + expect(getRepositoryFieldRuleBypassDepth()).toBe(2); + }); + expect(getRepositoryRecordRuleBypassDepth()).toBe(1); + expect(getRepositoryFieldRuleBypassDepth()).toBe(1); }); + expect(getRepositoryRecordRuleBypassDepth()).toBe(0); expect(getRepositoryFieldRuleBypassDepth()).toBe(0); } ); From 51050efcfd07fce3b54fefe4e0c00d0ad7fb24c4 Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 16:06:08 +0800 Subject: [PATCH 5/9] fix(runtime): reject overlapping async withContext/withUser scopes - Share a pendingAsync/syncDepth guard so sibling Promise.all scopes fail fast. - Keep sync nesting and sequential awaits; document yield-after-nest as unsupported without ALS. Co-authored-by: Cursor --- .../service/runtime/context/async_scope.ts | 86 +++++++++++++++++++ .../service/runtime/context/context.test.ts | 41 +++++++-- modules/core/service/runtime/context/scope.ts | 57 ++++++------ modules/core/service/runtime/context/user.ts | 56 ++++++------ 4 files changed, 180 insertions(+), 60 deletions(-) create mode 100644 modules/core/service/runtime/context/async_scope.ts diff --git a/modules/core/service/runtime/context/async_scope.ts b/modules/core/service/runtime/context/async_scope.ts new file mode 100644 index 000000000..ba04c4f36 --- /dev/null +++ b/modules/core/service/runtime/context/async_scope.ts @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { asObjectRecord } from '../../../utils/object'; +import { getJsCtxRoot } from './source'; + +/** + * Shared guard for withContext / withUser (policy A). + * + * Overlapping async scopes (e.g. Promise.all of two withUser) are rejected because + * QuickJS has no AsyncLocalStorage: getUserId / getReadonlyCtx read a shared carrier. + * + * Allowed: + * - sync nesting + * - nested enter during an outer callback's synchronous prelude (before it yields) + * - sequential `await withX(...); await withX(...)` + * + * Not allowed without ALS: + * - sibling concurrent async scopes + * - starting a new async scope after the outer async callback has already yielded + * (syncDepth is 0 while outer is still pending) + */ + +const ASYNC_SCOPE_KEY = Symbol.for('choysum.async.scope'); + +type AsyncScopeState = { + syncDepth: number; + pendingAsync: number; +}; + +const processLevelAsyncScope: AsyncScopeState = { syncDepth: 0, pendingAsync: 0 }; + +type AsyncScopeCarrier = Record; + +function getAsyncScopeState(): AsyncScopeState { + const jsCtx = asObjectRecord(getJsCtxRoot()) as AsyncScopeCarrier | undefined; + if (!jsCtx) return processLevelAsyncScope; + + let state = jsCtx[ASYNC_SCOPE_KEY] as AsyncScopeState | undefined; + if (!state || typeof state !== 'object') { + state = { syncDepth: 0, pendingAsync: 0 }; + jsCtx[ASYNC_SCOPE_KEY] = state; + } + return state; +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return !!value && typeof (value as { then?: unknown }).then === 'function'; +} + +/** + * Throws when entering withContext/withUser would overlap an in-flight async scope. + */ +export function assertNoOverlappingAsyncScope(apiName: string): void { + const state = getAsyncScopeState(); + if (state.pendingAsync > 0 && state.syncDepth === 0) { + throw new Error( + `overlapping async ${apiName} is not supported; await one withContext/withUser scope before starting another, or nest inside the active callback before it yields` + ); + } +} + +/** + * Runs `fn` while marking synchronous nesting depth so nested withContext/withUser are allowed. + * If `fn` returns a Promise, tracks it as a pending async scope until settlement. + */ +export function runWithAsyncScopeTracking(fn: () => R): R { + const state = getAsyncScopeState(); + state.syncDepth += 1; + try { + const result = fn(); + if (isPromiseLike(result)) { + state.pendingAsync += 1; + return Promise.resolve(result).finally(() => { + state.pendingAsync -= 1; + }) as unknown as R; + } + return result; + } finally { + state.syncDepth -= 1; + } +} + +export function isPromiseLikeResult(value: unknown): value is PromiseLike { + return isPromiseLike(value); +} diff --git a/modules/core/service/runtime/context/context.test.ts b/modules/core/service/runtime/context/context.test.ts index 8a8b3053b..c881cf095 100644 --- a/modules/core/service/runtime/context/context.test.ts +++ b/modules/core/service/runtime/context/context.test.ts @@ -310,11 +310,12 @@ test('withUser rejects empty userId and works on process stack without jsCtx', ( }); }); -test('withUser concurrent sibling overrides restore by entry identity', async () => { +test('overlapping async withUser/withContext scopes are rejected', async () => { const root = { request: { context: { identity: { userId: 'U-ROOT' }, + ctx: { lang: 'en' }, }, }, }; @@ -327,20 +328,42 @@ test('withUser concurrent sibling overrides restore by entry identity', async () const slow = withUser('U-SLOW', async () => { await slowGate; - expect(getUserId()).toBe('U-SLOW'); return getUserId(); }); - const fast = withUser('U-FAST', async () => { - expect(getUserId()).toBe('U-FAST'); - return getUserId(); - }); + expect(() => withUser('U-FAST', async () => getUserId())).toThrow('overlapping async withUser'); + expect(() => withContext({ lang: 'ja' }, async () => getContextLang())).toThrow('overlapping async withContext'); - await fast; - // Fast finished first; slow entry must still be on the stack (not LIFO-popped away). - expect(getUserId()).toBe('U-SLOW'); releaseSlow?.(); await slow; expect(getUserId()).toBe('U-ROOT'); + + // Sequential async scopes remain allowed. + await withUser('U-A', async () => { + expect(getUserId()).toBe('U-A'); + }); + await withUser('U-B', async () => { + expect(getUserId()).toBe('U-B'); + }); + }); +}); + +test('nested withUser inside async prelude before yield remains allowed', async () => { + const root = { + request: { + context: { + identity: { userId: 'U-ROOT' }, + }, + }, + }; + + await withTempChoysum(root, async () => { + const nested = await withUser('U-A', async () => { + const inner = withUser('U-B', () => getUserId()); + expect(inner).toBe('U-B'); + return getUserId(); + }); + expect(nested).toBe('U-A'); + expect(getUserId()).toBe('U-ROOT'); }); }); diff --git a/modules/core/service/runtime/context/scope.ts b/modules/core/service/runtime/context/scope.ts index 1220981f2..a840fceda 100644 --- a/modules/core/service/runtime/context/scope.ts +++ b/modules/core/service/runtime/context/scope.ts @@ -3,6 +3,7 @@ import { getJsCtxRoot, type Context } from './source'; import { asObjectRecord } from '../../../utils/object'; +import { assertNoOverlappingAsyncScope, isPromiseLikeResult, runWithAsyncScopeTracking } from './async_scope'; // Symbol keys for request-scoped overrides and frozen cache values. const CTX_OVERRIDE_KEY = Symbol.for('choysum.ctx.override'); @@ -132,8 +133,12 @@ export function getContextClientTimezone(): string | undefined { /** * Runs a function with a temporary business-context override. + * + * Overlapping async withContext/withUser scopes are rejected (no QuickJS AsyncLocalStorage). */ export function withContext(ctx: Partial | (() => Partial), fn: () => R, opts?: { merge?: boolean }): R { + assertNoOverlappingAsyncScope('withContext'); + const jsCtx = asContextCarrier(getJsCtxRoot()); const base = getReadonlyCtx() as Record; const source = typeof ctx === 'function' ? ctx() || {} : ctx || {}; @@ -146,36 +151,40 @@ export function withContext(ctx: Partial | (() => Partial), const prev = jsCtx[CTX_OVERRIDE_KEY] as Context | undefined; jsCtx[CTX_OVERRIDE_KEY] = frozen; + return runWithAsyncScopeTracking(() => { + try { + const result = fn(); + if (isPromiseLikeResult(result)) { + return Promise.resolve(result).finally(() => { + if (prev) jsCtx[CTX_OVERRIDE_KEY] = prev; + else delete jsCtx[CTX_OVERRIDE_KEY]; + }) as unknown as R; + } + if (prev) jsCtx[CTX_OVERRIDE_KEY] = prev; + else delete jsCtx[CTX_OVERRIDE_KEY]; + return result; + } catch (error) { + if (prev) jsCtx[CTX_OVERRIDE_KEY] = prev; + else delete jsCtx[CTX_OVERRIDE_KEY]; + throw error; + } + }); + } + + processLevelCtxStack.push(frozen); + return runWithAsyncScopeTracking(() => { try { const result = fn(); - if (result instanceof Promise) { - return result.finally(() => { - if (prev) jsCtx[CTX_OVERRIDE_KEY] = prev; - else delete jsCtx[CTX_OVERRIDE_KEY]; + if (isPromiseLikeResult(result)) { + return Promise.resolve(result).finally(() => { + processLevelCtxStack.pop(); }) as unknown as R; } - if (prev) jsCtx[CTX_OVERRIDE_KEY] = prev; - else delete jsCtx[CTX_OVERRIDE_KEY]; + processLevelCtxStack.pop(); return result; } catch (error) { - if (prev) jsCtx[CTX_OVERRIDE_KEY] = prev; - else delete jsCtx[CTX_OVERRIDE_KEY]; + processLevelCtxStack.pop(); throw error; } - } - - processLevelCtxStack.push(frozen); - try { - const result = fn(); - if (result instanceof Promise) { - return result.finally(() => { - processLevelCtxStack.pop(); - }) as unknown as R; - } - processLevelCtxStack.pop(); - return result; - } catch (error) { - processLevelCtxStack.pop(); - throw error; - } + }); } diff --git a/modules/core/service/runtime/context/user.ts b/modules/core/service/runtime/context/user.ts index 9cdd9421e..72c5f6bd9 100644 --- a/modules/core/service/runtime/context/user.ts +++ b/modules/core/service/runtime/context/user.ts @@ -3,8 +3,9 @@ import { asObjectRecord } from '../../../utils/object'; import { getJsCtxRoot } from './source'; +import { assertNoOverlappingAsyncScope, isPromiseLikeResult, runWithAsyncScopeTracking } from './async_scope'; -/** Request-scoped userId override stack (nested / concurrent withUser). */ +/** Request-scoped userId override stack (nested withUser). */ const USER_ID_OVERRIDE_KEY = Symbol.for('choysum.userid.override'); type UserIdOverrideEntry = { userId: string }; @@ -27,17 +28,12 @@ function normalizeUserId(userId: unknown): string { return normalized; } -function isPromiseLike(value: unknown): value is PromiseLike { - return !!value && typeof (value as { then?: unknown }).then === 'function'; -} - function peekStackTop(stack: unknown): string | undefined { if (!Array.isArray(stack) || stack.length === 0) return undefined; const top = stack[stack.length - 1] as UserIdOverrideEntry | string | undefined; if (top && typeof top === 'object' && typeof top.userId === 'string' && top.userId) { return top.userId; } - // Legacy string entries (should not appear after this change). return typeof top === 'string' && top ? top : undefined; } @@ -77,9 +73,11 @@ export function getUserId(): string | undefined { * * Does not wrap withContext and does not elevate privileges (use Model.sudo for bypass). * Sync and async `fn` are both supported (aligned with withContext). - * Concurrent sibling withUser calls remove their own stack entry by identity (not LIFO pop). + * Overlapping async withUser/withContext scopes are rejected (no QuickJS AsyncLocalStorage). */ export function withUser(userId: string, fn: () => R): R { + assertNoOverlappingAsyncScope('withUser'); + const normalized = normalizeUserId(userId); const entry: UserIdOverrideEntry = { userId: normalized }; const jsCtx = asUserIdCarrier(getJsCtxRoot()); @@ -97,32 +95,36 @@ export function withUser(userId: string, fn: () => R): R { if (stack!.length === 0) delete jsCtx[USER_ID_OVERRIDE_KEY]; }; - try { - const result = fn(); - if (isPromiseLike(result)) { - return Promise.resolve(result).finally(restore) as unknown as R; + return runWithAsyncScopeTracking(() => { + try { + const result = fn(); + if (isPromiseLikeResult(result)) { + return Promise.resolve(result).finally(restore) as unknown as R; + } + restore(); + return result; + } catch (error) { + restore(); + throw error; } - restore(); - return result; - } catch (error) { - restore(); - throw error; - } + }); } processLevelUserIdStack.push(entry); const restoreProcess = () => { removeStackEntry(processLevelUserIdStack, entry); }; - try { - const result = fn(); - if (isPromiseLike(result)) { - return Promise.resolve(result).finally(restoreProcess) as unknown as R; + return runWithAsyncScopeTracking(() => { + try { + const result = fn(); + if (isPromiseLikeResult(result)) { + return Promise.resolve(result).finally(restoreProcess) as unknown as R; + } + restoreProcess(); + return result; + } catch (error) { + restoreProcess(); + throw error; } - restoreProcess(); - return result; - } catch (error) { - restoreProcess(); - throw error; - } + }); } From 34be59d5689a14b3ea279fca790ecfedab4fd497 Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 16:09:06 +0800 Subject: [PATCH 6/9] test(meta): keep negative coverage that runAs is omitted - Parse legacy @Compute({ runAs }) fixtures and assert resolved JSON has no runAs. - Assert IrField and webapistore outputs omit the removed runAs key. Co-authored-by: Cursor --- .../artifact/generate/webapistore_test.go | 11 ++++ .../backendtsparser/parser_migration_test.go | 52 +++++++++++++++++++ pkg/meta/meta_field_test.go | 3 ++ 3 files changed, 66 insertions(+) diff --git a/internal/module/artifact/generate/webapistore_test.go b/internal/module/artifact/generate/webapistore_test.go index bc8a1438e..1a179c525 100644 --- a/internal/module/artifact/generate/webapistore_test.go +++ b/internal/module/artifact/generate/webapistore_test.go @@ -6,6 +6,7 @@ package generator import ( "context" "database/sql" + "encoding/json" "os" "path/filepath" "strings" @@ -83,6 +84,13 @@ func TestWebApiStoreGenerate(t *testing.T) { if metadata.Translate != nil { t.Fatalf("non-translate field must omit Translate, got %#v", metadata.Translate) } + encodedMetadata, err := json.Marshal(metadata) + if err != nil { + t.Fatalf("marshal field metadata: %v", err) + } + if strings.Contains(string(encodedMetadata), `"runAs"`) { + t.Fatalf("field metadata must omit removed runAs key, got %s", string(encodedMetadata)) + } relationFields, importModels := analyzeRelationFields(testApp().Models[1]) if len(relationFields) != 2 { @@ -144,6 +152,9 @@ func TestWebApiStoreGenerate(t *testing.T) { if !strings.Contains(string(storeContent), `string: "Amount"`) || !strings.Contains(string(storeContent), "stringText:") || !strings.Contains(string(storeContent), stringKey) { t.Fatalf("expected field string/stringText in generated store content: %s", string(storeContent)) } + if strings.Contains(string(storeContent), "runAs:") || strings.Contains(string(storeContent), `"runAs"`) { + t.Fatalf("generated store must omit removed runAs key: %s", string(storeContent)) + } if _, err := os.Stat(filepath.Join(webStoreDir, "stores", "index.ts")); err != nil { t.Fatalf("expected stores/index.ts: %v", err) } diff --git a/internal/parser/backendtsparser/parser_migration_test.go b/internal/parser/backendtsparser/parser_migration_test.go index 35c5a0d97..c118ebe2b 100644 --- a/internal/parser/backendtsparser/parser_migration_test.go +++ b/internal/parser/backendtsparser/parser_migration_test.go @@ -292,6 +292,58 @@ export default class Demo extends BaseModel { } } +func TestTsParser_ParseModelOmitsLegacyComputeRunAs(t *testing.T) { + runtimeScope := newBackendParserTestScope() + module := &meta.IrModule{Path: "/virtual/modules/test", ApplicationStr: "test"} + p := NewTsParser(runtimeScope, module) + + path := "/virtual/modules/test/service/demo_runas.ts" + content := `import { Model, Field, Compute } from '../../core/service'; +import BaseModel from './base'; + +@Model('DemoRunAs') +export default class DemoRunAs extends BaseModel { + @Field({ type: 'varchar', size: 64 }) + public Name: string + + @Compute('Name', { deps: ['Id'], store: false, runAs: 'sudo' }) + computeName() { + return this.Name + } +} +` + + r, err := p.Parse(map[string]string{}, path, content) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if r.Model == nil { + t.Fatal("expected parsed model") + } + + var nameField *meta.IrField + for _, field := range r.Model.Fields { + if field.Name == "Name" { + nameField = field + break + } + } + if nameField == nil { + t.Fatal("expected Name field") + } + + spec, err := nameField.GetResolvedSpec() + if err != nil || spec == nil { + t.Fatalf("parse Name resolved spec failed: %v spec=%v", err, spec) + } + if spec.Behavior.Compute == nil || spec.Behavior.Compute.Method != "computeName" { + t.Fatalf("unexpected compute behavior: %+v", spec.Behavior) + } + if strings.Contains(nameField.ResolvedSpec, `"runAs"`) { + t.Fatalf("resolved metadata must omit removed runAs contract, got %s", nameField.ResolvedSpec) + } +} + func TestTsParser_ParseModelRejectsLegacyFieldSyntax(t *testing.T) { runtimeScope := newBackendParserTestScope() module := &meta.IrModule{Path: "/virtual/modules/test", ApplicationStr: "test"} diff --git a/pkg/meta/meta_field_test.go b/pkg/meta/meta_field_test.go index fb4a02cca..2f63a0a8d 100644 --- a/pkg/meta/meta_field_test.go +++ b/pkg/meta/meta_field_test.go @@ -168,6 +168,9 @@ func TestIrField_SetResolvedSpec(t *testing.T) { if roundtrip.Resolved.Searchable.Value == nil || *roundtrip.Resolved.Searchable.Value != true { t.Fatal("expected Searchable.Value = true") } + if strings.Contains(field.ResolvedSpec, `"runAs"`) { + t.Fatalf("resolved metadata must omit removed runAs contract, got %s", field.ResolvedSpec) + } }) } From 408f2726fe498243866c1c835fff9dc5f1e4d923 Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 16:29:13 +0800 Subject: [PATCH 7/9] fix(runtime): allow nested withContext/withUser after outer await - Drop the overlapping-async guard that also blocked legitimate nest-after-await used by repository update validation and bilingual write/read paths. - Keep documenting concurrent sibling scopes as unsupported without AsyncLocalStorage, and cover nest-after-await in unit tests. Co-authored-by: Cursor --- .../service/runtime/context/async_scope.ts | 83 ++----------------- .../service/runtime/context/context.test.ts | 39 +++++---- modules/core/service/runtime/context/scope.ts | 56 ++++++------- modules/core/service/runtime/context/user.ts | 53 ++++++------ 4 files changed, 77 insertions(+), 154 deletions(-) diff --git a/modules/core/service/runtime/context/async_scope.ts b/modules/core/service/runtime/context/async_scope.ts index ba04c4f36..6efffd9fd 100644 --- a/modules/core/service/runtime/context/async_scope.ts +++ b/modules/core/service/runtime/context/async_scope.ts @@ -1,86 +1,15 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: Apache-2.0 -import { asObjectRecord } from '../../../utils/object'; -import { getJsCtxRoot } from './source'; - /** - * Shared guard for withContext / withUser (policy A). - * - * Overlapping async scopes (e.g. Promise.all of two withUser) are rejected because - * QuickJS has no AsyncLocalStorage: getUserId / getReadonlyCtx read a shared carrier. - * - * Allowed: - * - sync nesting - * - nested enter during an outer callback's synchronous prelude (before it yields) - * - sequential `await withX(...); await withX(...)` + * Helpers for withContext / withUser promise detection. * - * Not allowed without ALS: - * - sibling concurrent async scopes - * - starting a new async scope after the outer async callback has already yielded - * (syncDepth is 0 while outer is still pending) - */ - -const ASYNC_SCOPE_KEY = Symbol.for('choysum.async.scope'); - -type AsyncScopeState = { - syncDepth: number; - pendingAsync: number; -}; - -const processLevelAsyncScope: AsyncScopeState = { syncDepth: 0, pendingAsync: 0 }; - -type AsyncScopeCarrier = Record; - -function getAsyncScopeState(): AsyncScopeState { - const jsCtx = asObjectRecord(getJsCtxRoot()) as AsyncScopeCarrier | undefined; - if (!jsCtx) return processLevelAsyncScope; - - let state = jsCtx[ASYNC_SCOPE_KEY] as AsyncScopeState | undefined; - if (!state || typeof state !== 'object') { - state = { syncDepth: 0, pendingAsync: 0 }; - jsCtx[ASYNC_SCOPE_KEY] = state; - } - return state; -} - -function isPromiseLike(value: unknown): value is PromiseLike { - return !!value && typeof (value as { then?: unknown }).then === 'function'; -} - -/** - * Throws when entering withContext/withUser would overlap an in-flight async scope. + * QuickJS has no AsyncLocalStorage. Nested withContext/withUser after an outer + * await is required by repository/update and bilingual test paths, so we do not + * reject overlapping async scopes. Concurrent sibling scopes (e.g. Promise.all of + * two withUser) remain unsupported and can corrupt the shared carrier stacks. */ -export function assertNoOverlappingAsyncScope(apiName: string): void { - const state = getAsyncScopeState(); - if (state.pendingAsync > 0 && state.syncDepth === 0) { - throw new Error( - `overlapping async ${apiName} is not supported; await one withContext/withUser scope before starting another, or nest inside the active callback before it yields` - ); - } -} - -/** - * Runs `fn` while marking synchronous nesting depth so nested withContext/withUser are allowed. - * If `fn` returns a Promise, tracks it as a pending async scope until settlement. - */ -export function runWithAsyncScopeTracking(fn: () => R): R { - const state = getAsyncScopeState(); - state.syncDepth += 1; - try { - const result = fn(); - if (isPromiseLike(result)) { - state.pendingAsync += 1; - return Promise.resolve(result).finally(() => { - state.pendingAsync -= 1; - }) as unknown as R; - } - return result; - } finally { - state.syncDepth -= 1; - } -} export function isPromiseLikeResult(value: unknown): value is PromiseLike { - return isPromiseLike(value); + return !!value && typeof (value as { then?: unknown }).then === 'function'; } diff --git a/modules/core/service/runtime/context/context.test.ts b/modules/core/service/runtime/context/context.test.ts index c881cf095..f666ddfcd 100644 --- a/modules/core/service/runtime/context/context.test.ts +++ b/modules/core/service/runtime/context/context.test.ts @@ -310,7 +310,7 @@ test('withUser rejects empty userId and works on process stack without jsCtx', ( }); }); -test('overlapping async withUser/withContext scopes are rejected', async () => { +test('nested withContext/withUser after outer await remains allowed', async () => { const root = { request: { context: { @@ -321,29 +321,32 @@ test('overlapping async withUser/withContext scopes are rejected', async () => { }; await withTempChoysum(root, async () => { - let releaseSlow: (() => void) | undefined; - const slowGate = new Promise(resolve => { - releaseSlow = resolve; + const nestedUser = await withUser('U-A', async () => { + await Promise.resolve(); + return withUser('U-B', async () => { + await Promise.resolve(); + return getUserId(); + }); }); + expect(nestedUser).toBe('U-B'); + expect(getUserId()).toBe('U-ROOT'); - const slow = withUser('U-SLOW', async () => { - await slowGate; - return getUserId(); + const nestedLang = await withContext({ lang: 'en' }, async () => { + await Promise.resolve(); + return withContext({ lang: 'zh_CN' }, async () => { + await Promise.resolve(); + return getContextLang(); + }); }); - - expect(() => withUser('U-FAST', async () => getUserId())).toThrow('overlapping async withUser'); - expect(() => withContext({ lang: 'ja' }, async () => getContextLang())).toThrow('overlapping async withContext'); - - releaseSlow?.(); - await slow; - expect(getUserId()).toBe('U-ROOT'); + expect(nestedLang).toBe('zh_CN'); + expect(getContextLang()).toBe('en'); // Sequential async scopes remain allowed. - await withUser('U-A', async () => { - expect(getUserId()).toBe('U-A'); + await withUser('U-SEQ-A', async () => { + expect(getUserId()).toBe('U-SEQ-A'); }); - await withUser('U-B', async () => { - expect(getUserId()).toBe('U-B'); + await withUser('U-SEQ-B', async () => { + expect(getUserId()).toBe('U-SEQ-B'); }); }); }); diff --git a/modules/core/service/runtime/context/scope.ts b/modules/core/service/runtime/context/scope.ts index a840fceda..6f673aeac 100644 --- a/modules/core/service/runtime/context/scope.ts +++ b/modules/core/service/runtime/context/scope.ts @@ -3,7 +3,7 @@ import { getJsCtxRoot, type Context } from './source'; import { asObjectRecord } from '../../../utils/object'; -import { assertNoOverlappingAsyncScope, isPromiseLikeResult, runWithAsyncScopeTracking } from './async_scope'; +import { isPromiseLikeResult } from './async_scope'; // Symbol keys for request-scoped overrides and frozen cache values. const CTX_OVERRIDE_KEY = Symbol.for('choysum.ctx.override'); @@ -134,11 +134,11 @@ export function getContextClientTimezone(): string | undefined { /** * Runs a function with a temporary business-context override. * - * Overlapping async withContext/withUser scopes are rejected (no QuickJS AsyncLocalStorage). + * Sync and async `fn` are supported, including nested withContext after an outer + * await. Concurrent sibling scopes (e.g. Promise.all of two withContext) are + * unsupported without AsyncLocalStorage and can corrupt the shared carrier. */ export function withContext(ctx: Partial | (() => Partial), fn: () => R, opts?: { merge?: boolean }): R { - assertNoOverlappingAsyncScope('withContext'); - const jsCtx = asContextCarrier(getJsCtxRoot()); const base = getReadonlyCtx() as Record; const source = typeof ctx === 'function' ? ctx() || {} : ctx || {}; @@ -151,40 +151,36 @@ export function withContext(ctx: Partial | (() => Partial), const prev = jsCtx[CTX_OVERRIDE_KEY] as Context | undefined; jsCtx[CTX_OVERRIDE_KEY] = frozen; - return runWithAsyncScopeTracking(() => { - try { - const result = fn(); - if (isPromiseLikeResult(result)) { - return Promise.resolve(result).finally(() => { - if (prev) jsCtx[CTX_OVERRIDE_KEY] = prev; - else delete jsCtx[CTX_OVERRIDE_KEY]; - }) as unknown as R; - } - if (prev) jsCtx[CTX_OVERRIDE_KEY] = prev; - else delete jsCtx[CTX_OVERRIDE_KEY]; - return result; - } catch (error) { - if (prev) jsCtx[CTX_OVERRIDE_KEY] = prev; - else delete jsCtx[CTX_OVERRIDE_KEY]; - throw error; - } - }); - } - - processLevelCtxStack.push(frozen); - return runWithAsyncScopeTracking(() => { try { const result = fn(); if (isPromiseLikeResult(result)) { return Promise.resolve(result).finally(() => { - processLevelCtxStack.pop(); + if (prev) jsCtx[CTX_OVERRIDE_KEY] = prev; + else delete jsCtx[CTX_OVERRIDE_KEY]; }) as unknown as R; } - processLevelCtxStack.pop(); + if (prev) jsCtx[CTX_OVERRIDE_KEY] = prev; + else delete jsCtx[CTX_OVERRIDE_KEY]; return result; } catch (error) { - processLevelCtxStack.pop(); + if (prev) jsCtx[CTX_OVERRIDE_KEY] = prev; + else delete jsCtx[CTX_OVERRIDE_KEY]; throw error; } - }); + } + + processLevelCtxStack.push(frozen); + try { + const result = fn(); + if (isPromiseLikeResult(result)) { + return Promise.resolve(result).finally(() => { + processLevelCtxStack.pop(); + }) as unknown as R; + } + processLevelCtxStack.pop(); + return result; + } catch (error) { + processLevelCtxStack.pop(); + throw error; + } } diff --git a/modules/core/service/runtime/context/user.ts b/modules/core/service/runtime/context/user.ts index 72c5f6bd9..efc9ea17f 100644 --- a/modules/core/service/runtime/context/user.ts +++ b/modules/core/service/runtime/context/user.ts @@ -3,7 +3,7 @@ import { asObjectRecord } from '../../../utils/object'; import { getJsCtxRoot } from './source'; -import { assertNoOverlappingAsyncScope, isPromiseLikeResult, runWithAsyncScopeTracking } from './async_scope'; +import { isPromiseLikeResult } from './async_scope'; /** Request-scoped userId override stack (nested withUser). */ const USER_ID_OVERRIDE_KEY = Symbol.for('choysum.userid.override'); @@ -72,12 +72,11 @@ export function getUserId(): string | undefined { * Runs a function with a temporary userId override for authz / getUserId(). * * Does not wrap withContext and does not elevate privileges (use Model.sudo for bypass). - * Sync and async `fn` are both supported (aligned with withContext). - * Overlapping async withUser/withContext scopes are rejected (no QuickJS AsyncLocalStorage). + * Sync and async `fn` are both supported (aligned with withContext), including nested + * withUser after an outer await. Concurrent sibling scopes (e.g. Promise.all of two + * withUser) are unsupported without AsyncLocalStorage and can corrupt the stack. */ export function withUser(userId: string, fn: () => R): R { - assertNoOverlappingAsyncScope('withUser'); - const normalized = normalizeUserId(userId); const entry: UserIdOverrideEntry = { userId: normalized }; const jsCtx = asUserIdCarrier(getJsCtxRoot()); @@ -95,36 +94,32 @@ export function withUser(userId: string, fn: () => R): R { if (stack!.length === 0) delete jsCtx[USER_ID_OVERRIDE_KEY]; }; - return runWithAsyncScopeTracking(() => { - try { - const result = fn(); - if (isPromiseLikeResult(result)) { - return Promise.resolve(result).finally(restore) as unknown as R; - } - restore(); - return result; - } catch (error) { - restore(); - throw error; - } - }); - } - - processLevelUserIdStack.push(entry); - const restoreProcess = () => { - removeStackEntry(processLevelUserIdStack, entry); - }; - return runWithAsyncScopeTracking(() => { try { const result = fn(); if (isPromiseLikeResult(result)) { - return Promise.resolve(result).finally(restoreProcess) as unknown as R; + return Promise.resolve(result).finally(restore) as unknown as R; } - restoreProcess(); + restore(); return result; } catch (error) { - restoreProcess(); + restore(); throw error; } - }); + } + + processLevelUserIdStack.push(entry); + const restoreProcess = () => { + removeStackEntry(processLevelUserIdStack, entry); + }; + try { + const result = fn(); + if (isPromiseLikeResult(result)) { + return Promise.resolve(result).finally(restoreProcess) as unknown as R; + } + restoreProcess(); + return result; + } catch (error) { + restoreProcess(); + throw error; + } } From d19709db11338399f9c7f588f623c98393e010b3 Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 16:46:25 +0800 Subject: [PATCH 8/9] test(core): raise patch coverage for sudo/withUser paths - Exercise BaseModel static/instance withUser and sudo wrappers end to end. - Cover sudo audit hint, process-bucket fallback, and audit disable env. - Cover withUser process async/throw, legacy stack entries, and sibling restore. - Cover authz bypass sync throw restore and no-req authz bypass; reject async @Search on sync rewrite. Co-authored-by: Cursor --- modules/core/service/orm/model/model.test.ts | 34 +++++++++ .../core/service/orm/model/model_sudo.test.ts | 44 +++++++++++ .../authz/tests/authz_runtime.test.ts | 32 ++++++++ .../runtime/compute/search_rewrite.test.ts | 24 ++++++ .../service/runtime/context/context.test.ts | 76 +++++++++++++++++++ 5 files changed, 210 insertions(+) diff --git a/modules/core/service/orm/model/model.test.ts b/modules/core/service/orm/model/model.test.ts index bb7349705..1bd075dcd 100644 --- a/modules/core/service/orm/model/model.test.ts +++ b/modules/core/service/orm/model/model.test.ts @@ -139,6 +139,40 @@ test('model context accessors are available on static and instance surfaces', () expect(typeof instance.sudo).toBe('function'); }); +test('model withUser and sudo static/instance wrappers invoke context facades', () => { + const globalAny = globalThis as any; + const hadPrev = Object.prototype.hasOwnProperty.call(globalAny, '$choysum'); + const prev = globalAny.$choysum; + globalAny.$choysum = { + request: { + context: { + identity: { userId: 'U-ROOT' }, + req: {}, + }, + }, + }; + + try { + const instance = makeInstance({ Id: 'SUDO-1', Name: 'sudo' }); + + const staticUser = ModelSurfaceHarness.withUser('U-STATIC', () => ModelSurfaceHarness.userId); + expect(staticUser).toBe('U-STATIC'); + + const instanceUser = instance.withUser('U-INSTANCE', () => instance.userId); + expect(instanceUser).toBe('U-INSTANCE'); + expect(ModelSurfaceHarness.userId).toBe('U-ROOT'); + + const staticSudo = ModelSurfaceHarness.sudo(() => 'static-sudo'); + expect(staticSudo).toBe('static-sudo'); + + const instanceSudo = instance.sudo(() => 'instance-sudo'); + expect(instanceSudo).toBe('instance-sudo'); + } finally { + if (hadPrev) globalAny.$choysum = prev; + else delete globalAny.$choysum; + } +}); + test('model DisplayName compute handler covers Name/Username/Id fallback branches', () => { const meta = MetadataStorage.instance.getModelMetadata(ModelSurfaceHarness as any); const displayNameMeta = meta.fields.get('DisplayName') as any; diff --git a/modules/core/service/orm/model/model_sudo.test.ts b/modules/core/service/orm/model/model_sudo.test.ts index ca6bf911a..640d34c9c 100644 --- a/modules/core/service/orm/model/model_sudo.test.ts +++ b/modules/core/service/orm/model/model_sudo.test.ts @@ -117,3 +117,47 @@ test('withModelSudo audit hits stay request-scoped and do not leak across reques } ); }); + +test('withModelSudo records hint and falls back to process audit bucket without req', () => { + const globalAny = globalThis as any; + const prevChoysum = globalAny.$choysum; + const prevAudit = globalAny.__choysumComputeAudit; + delete globalAny.$choysum; + delete globalAny.__choysumComputeAudit; + + try { + withModelSudo(() => 'process', { hint: ' script-path ' }); + const hits = (globalAny.__choysumComputeAudit?.sudoHits || []) as any[]; + expect(hits.length).toBe(1); + expect(hits[0]?.source).toBe('sudo'); + expect(hits[0]?.hint).toBe('script-path'); + } finally { + if (prevChoysum !== undefined) globalAny.$choysum = prevChoysum; + else delete globalAny.$choysum; + if (prevAudit !== undefined) globalAny.__choysumComputeAudit = prevAudit; + else delete globalAny.__choysumComputeAudit; + } +}); + +test('withModelSudo skips audit when CHOYSUM_SUDO_AUDIT_ENABLED is false', async () => { + const originalEnv = (globalThis as any).__CHOYSUM_RUNTIME_ENV__; + (globalThis as any).__CHOYSUM_RUNTIME_ENV__ = { CHOYSUM_SUDO_AUDIT_ENABLED: false }; + + try { + await withPatchedChoysum( + { + request: { + context: { + req: {}, + }, + }, + }, + () => { + withModelSudo(() => 'quiet'); + expect(readSudoHits().length).toBe(0); + } + ); + } finally { + (globalThis as any).__CHOYSUM_RUNTIME_ENV__ = originalEnv; + } +}); diff --git a/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts b/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts index 4b3ff58eb..78f3d81b4 100644 --- a/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts +++ b/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts @@ -158,11 +158,43 @@ test('authz runtime bypass wrappers run without req state and return callback re try { expect(await withRepositoryRecordRuleBypass(async () => 'rr-ok')).toBe('rr-ok'); expect(await withRepositoryFieldRuleBypass(async () => 'fr-ok')).toBe('fr-ok'); + expect(withRepositoryAuthzRuleBypass(() => 'authz-ok')).toBe('authz-ok'); + expect(withRepositoryRecordRuleBypass(() => 'rr-sync')).toBe('rr-sync'); + expect(withRepositoryFieldRuleBypass(() => 'fr-sync')).toBe('fr-sync'); } finally { if (previous !== undefined) (globalThis as Record).$choysum = previous; } }); +test('authz runtime bypass restore runs on sync throw', async () => { + await withPatchedChoysum( + { + request: { + context: { + req: {}, + }, + }, + }, + async () => { + expect(() => + withRepositoryAuthzRuleBypass(() => { + expect(getRepositoryRecordRuleBypassDepth()).toBe(1); + throw new Error('bypass-boom'); + }) + ).toThrow('bypass-boom'); + expect(getRepositoryRecordRuleBypassDepth()).toBe(0); + expect(getRepositoryFieldRuleBypassDepth()).toBe(0); + + expect(() => + withRepositoryRecordRuleBypass(() => { + throw new Error('rr-boom'); + }) + ).toThrow('rr-boom'); + expect(getRepositoryRecordRuleBypassDepth()).toBe(0); + } + ); +}); + test('authz runtime combined RR+FR bypass is sync-friendly and nested', () => { return withPatchedChoysum( { diff --git a/modules/core/service/runtime/compute/search_rewrite.test.ts b/modules/core/service/runtime/compute/search_rewrite.test.ts index e54e82358..a22f1afc4 100644 --- a/modules/core/service/runtime/compute/search_rewrite.test.ts +++ b/modules/core/service/runtime/compute/search_rewrite.test.ts @@ -105,3 +105,27 @@ test('search rewrite auto-resolves virtual related field to related.path when @S domain: ['PartnerId.Name', 'ilike', 'ALICE'], }); }); + +test('search rewrite rejects async @Search handlers on the sync query path', () => { + class AsyncSearchModel extends BaseModel { + @Field({ type: 'varchar', size: 64 }) + Name?: string; + + @Field({ type: 'varchar', size: 64 }) + override DisplayName!: string; + + @Search('DisplayName') + searchDisplayName() { + return Promise.resolve({ domain: ['Name', '=', 'x'] }) as any; + } + } + + const meta = MetadataStorage.instance.getModelMetadata(AsyncSearchModel as any); + let message = ''; + try { + rewriteSearchCondition(meta, 'DisplayName', '=', 'x', 'postgres'); + } catch (error) { + message = String((error as Error).message || error); + } + expect(message).toContain('returned a Promise'); +}); diff --git a/modules/core/service/runtime/context/context.test.ts b/modules/core/service/runtime/context/context.test.ts index f666ddfcd..ef9c95e4b 100644 --- a/modules/core/service/runtime/context/context.test.ts +++ b/modules/core/service/runtime/context/context.test.ts @@ -301,12 +301,88 @@ test('withUser overrides getUserId nested and restores; withContext({ userId }) test('withUser rejects empty userId and works on process stack without jsCtx', () => { expect(() => withUser(' ', () => undefined)).toThrow('non-empty userId'); + expect(() => withUser(null as any, () => undefined)).toThrow('non-empty userId'); withTempChoysum(undefined, () => { expect(getUserId()).toBe(undefined); const value = withUser('U-PROC', () => getUserId()); expect(value).toBe('U-PROC'); expect(getUserId()).toBe(undefined); + + expect(() => + withUser('U-THROW', () => { + throw new Error('process-boom'); + }) + ).toThrow('process-boom'); + expect(getUserId()).toBe(undefined); + }); +}); + +test('withUser supports async process stack and coerces non-string userId', async () => { + await withTempChoysum(undefined, async () => { + const asyncValue = await withUser(42 as any, async () => { + expect(getUserId()).toBe('42'); + await Promise.resolve(); + return getUserId(); + }); + expect(asyncValue).toBe('42'); + expect(getUserId()).toBe(undefined); + }); +}); + +test('withUser request stack restores on sync throw and accepts legacy string entries', () => { + const root = { + request: { + context: { + identity: { userId: 'U-ROOT' }, + }, + }, + }; + + withTempChoysum(root, () => { + expect(() => + withUser('U-FAIL', () => { + throw new Error('req-boom'); + }) + ).toThrow('req-boom'); + expect(getUserId()).toBe('U-ROOT'); + + const key = Symbol.for('choysum.userid.override'); + const jsCtx = (globalThis as any).$choysum.request.context; + jsCtx[key] = ['U-LEGACY', { userId: '' }, { userId: 'U-OBJ' }]; + expect(getUserId()).toBe('U-OBJ'); + jsCtx[key] = ['U-LEGACY']; + expect(getUserId()).toBe('U-LEGACY'); + delete jsCtx[key]; + expect(getUserId()).toBe('U-ROOT'); + }); +}); + +test('withUser concurrent siblings restore by entry identity out of order', async () => { + const root = { + request: { + context: { + identity: { userId: 'U-ROOT' }, + }, + }, + }; + + await withTempChoysum(root, async () => { + let releaseSlow: (() => void) | undefined; + const slowGate = new Promise(resolve => { + releaseSlow = resolve; + }); + + const slow = withUser('U-SLOW', async () => { + await slowGate; + return getUserId(); + }); + + const fast = withUser('U-FAST', async () => getUserId()); + expect(await fast).toBe('U-FAST'); + releaseSlow?.(); + expect(await slow).toBe('U-SLOW'); + expect(getUserId()).toBe('U-ROOT'); }); }); From f0c11f4776abf93db70be4cdf06995cb66cc1dd2 Mon Sep 17 00:00:00 2001 From: buke Date: Sat, 25 Jul 2026 16:59:08 +0800 Subject: [PATCH 9/9] test(core): cover remaining withUser and authz bypass partials - Hit empty/null userId override stack tops so peekStackTop string branch is complete. - Corrupt bypass depth to NaN/Infinity/non-number so restoreBypassDepth uses the zero fallback. Co-authored-by: Cursor --- .../authz/tests/authz_runtime.test.ts | 36 +++++++++++++++++++ .../service/runtime/context/context.test.ts | 5 +++ 2 files changed, 41 insertions(+) diff --git a/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts b/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts index 78f3d81b4..daaa7ba24 100644 --- a/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts +++ b/modules/core/service/orm/repository/authz/tests/authz_runtime.test.ts @@ -195,6 +195,42 @@ test('authz runtime bypass restore runs on sync throw', async () => { ); }); +test('authz runtime bypass restore treats non-finite depth as zero', async () => { + await withPatchedChoysum( + { + request: { + context: { + req: {}, + }, + }, + }, + async () => { + withRepositoryRecordRuleBypass(() => { + const req = getRepositoryCurrentReq(); + const state = getOrInitRepositoryReqServiceState(req)!; + state.recordRuleBypassDepth = Number.NaN; + }); + expect(getRepositoryRecordRuleBypassDepth()).toBe(0); + + withRepositoryFieldRuleBypass(() => { + const req = getRepositoryCurrentReq(); + const state = getOrInitRepositoryReqServiceState(req)!; + state.fieldRuleBypassDepth = Number.POSITIVE_INFINITY; + }); + expect(getRepositoryFieldRuleBypassDepth()).toBe(0); + + withRepositoryAuthzRuleBypass(() => { + const req = getRepositoryCurrentReq(); + const state = getOrInitRepositoryReqServiceState(req)!; + state.recordRuleBypassDepth = 'x' as any; + state.fieldRuleBypassDepth = undefined; + }); + expect(getRepositoryRecordRuleBypassDepth()).toBe(0); + expect(getRepositoryFieldRuleBypassDepth()).toBe(0); + } + ); +}); + test('authz runtime combined RR+FR bypass is sync-friendly and nested', () => { return withPatchedChoysum( { diff --git a/modules/core/service/runtime/context/context.test.ts b/modules/core/service/runtime/context/context.test.ts index ef9c95e4b..56cf9a3d3 100644 --- a/modules/core/service/runtime/context/context.test.ts +++ b/modules/core/service/runtime/context/context.test.ts @@ -353,6 +353,11 @@ test('withUser request stack restores on sync throw and accepts legacy string en expect(getUserId()).toBe('U-OBJ'); jsCtx[key] = ['U-LEGACY']; expect(getUserId()).toBe('U-LEGACY'); + // Empty string top fails both object and non-empty-string branches → fall back to identity. + jsCtx[key] = ['']; + expect(getUserId()).toBe('U-ROOT'); + jsCtx[key] = [null]; + expect(getUserId()).toBe('U-ROOT'); delete jsCtx[key]; expect(getUserId()).toBe('U-ROOT'); });