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..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" @@ -22,7 +23,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 +54,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,12 +78,19 @@ 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) } + 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 { @@ -146,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/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..c118ebe2b 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,8 +290,57 @@ 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_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) } } 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..1bd075dcd 100644 --- a/modules/core/service/orm/model/model.test.ts +++ b/modules/core/service/orm/model/model.test.ts @@ -132,6 +132,45 @@ 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 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', () => { 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..640d34c9c --- /dev/null +++ b/modules/core/service/orm/model/model_sudo.test.ts @@ -0,0 +1,163 @@ +// 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'; + +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]; + } +} + +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: { + 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 = readSudoHits(); + 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 () => { + 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 = 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); + } + ); +}); + +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/model/model_sudo.ts b/modules/core/service/orm/model/model_sudo.ts new file mode 100644 index 000000000..6feb3201f --- /dev/null +++ b/modules/core/service/orm/model/model_sudo.ts @@ -0,0 +1,67 @@ +// 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 { getCurrentReq, getOrInitReqServiceState } from '../../runtime/context'; +import { withRepositoryAuthzRuleBypass } from '../repository/authz'; + +type SudoAuditEntry = { + version: 1; + source: 'sudo'; + at: string; + hint?: string; +}; + +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 ?? {}; + const resolved = ensureSudoHits(bucket); + setRuntimeComputeAuditBucketValue(bucket); + return resolved; +} + +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..a59f34a7e 100644 --- a/modules/core/service/orm/repository/authz/authz_runtime.ts +++ b/modules/core/service/orm/repository/authz/authz_runtime.ts @@ -154,20 +154,44 @@ 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'): 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]; +} + +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')); } export function getRepositoryFieldRuleBypassDepth(): number { @@ -177,20 +201,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')); +} + +/** + * 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'); + restoreBypassDepth(state, 'fieldRuleBypassDepth'); + }); } 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..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 @@ -16,6 +16,7 @@ import { isRepositoryTopLevelGrpcCall, withRepositoryFieldRuleBypass, withRepositoryRecordRuleBypass, + withRepositoryAuthzRuleBypass, withRepositoryValidationBypass, } from '..'; @@ -157,11 +158,113 @@ 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 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( + { + 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); + 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); + } + ); +}); + test('authz runtime field bypass nested depth restores previous value', async () => { await withPatchedChoysum( { @@ -184,6 +287,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/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.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/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/async_scope.ts b/modules/core/service/runtime/context/async_scope.ts new file mode 100644 index 000000000..6efffd9fd --- /dev/null +++ b/modules/core/service/runtime/context/async_scope.ts @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +/** + * Helpers for withContext / withUser promise detection. + * + * 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 isPromiseLikeResult(value: unknown): value is PromiseLike { + 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 2019cfcfa..56cf9a3d3 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,188 @@ 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'); + 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'); + // 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'); + }); +}); + +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'); + }); +}); + +test('nested withContext/withUser after outer await remains allowed', async () => { + const root = { + request: { + context: { + identity: { userId: 'U-ROOT' }, + ctx: { lang: 'en' }, + }, + }, + }; + + await withTempChoysum(root, async () => { + 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 nestedLang = await withContext({ lang: 'en' }, async () => { + await Promise.resolve(); + return withContext({ lang: 'zh_CN' }, async () => { + await Promise.resolve(); + return getContextLang(); + }); + }); + expect(nestedLang).toBe('zh_CN'); + expect(getContextLang()).toBe('en'); + + // Sequential async scopes remain allowed. + await withUser('U-SEQ-A', async () => { + expect(getUserId()).toBe('U-SEQ-A'); + }); + await withUser('U-SEQ-B', async () => { + expect(getUserId()).toBe('U-SEQ-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/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/scope.ts b/modules/core/service/runtime/context/scope.ts index 1220981f2..6f673aeac 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 { isPromiseLikeResult } from './async_scope'; // Symbol keys for request-scoped overrides and frozen cache values. const CTX_OVERRIDE_KEY = Symbol.for('choysum.ctx.override'); @@ -132,6 +133,10 @@ export function getContextClientTimezone(): string | undefined { /** * Runs a function with a temporary business-context override. + * + * 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 { const jsCtx = asContextCarrier(getJsCtxRoot()); @@ -148,8 +153,8 @@ export function withContext(ctx: Partial | (() => Partial), try { const result = fn(); - if (result instanceof Promise) { - return result.finally(() => { + 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; @@ -167,8 +172,8 @@ export function withContext(ctx: Partial | (() => Partial), processLevelCtxStack.push(frozen); try { const result = fn(); - if (result instanceof Promise) { - return result.finally(() => { + if (isPromiseLikeResult(result)) { + return Promise.resolve(result).finally(() => { processLevelCtxStack.pop(); }) as unknown as R; } 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..efc9ea17f --- /dev/null +++ b/modules/core/service/runtime/context/user.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { asObjectRecord } from '../../../utils/object'; +import { getJsCtxRoot } from './source'; +import { isPromiseLikeResult } from './async_scope'; + +/** Request-scoped userId override stack (nested 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: UserIdOverrideEntry[] = []; + +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 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; + } + 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. + */ +export function peekUserIdOverride(): string | undefined { + const fromRequest = peekOverrideFromCarrier(asUserIdCarrier(getJsCtxRoot())); + if (fromRequest) return fromRequest; + return peekStackTop(processLevelUserIdStack); +} + +/** + * 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), 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 { + const normalized = normalizeUserId(userId); + const entry: UserIdOverrideEntry = { userId: normalized }; + const jsCtx = asUserIdCarrier(getJsCtxRoot()); + + if (jsCtx) { + let stack = jsCtx[USER_ID_OVERRIDE_KEY] as UserIdOverrideEntry[] | undefined; + if (!Array.isArray(stack)) { + stack = []; + jsCtx[USER_ID_OVERRIDE_KEY] = stack; + } + stack.push(entry); + + const restore = () => { + removeStackEntry(stack!, entry); + if (stack!.length === 0) delete jsCtx[USER_ID_OVERRIDE_KEY]; + }; + + 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); + }; + 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; + } +} 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..2f63a0a8d 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,8 +168,8 @@ 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") + if strings.Contains(field.ResolvedSpec, `"runAs"`) { + t.Fatalf("resolved metadata must omit removed runAs contract, got %s", field.ResolvedSpec) } }) }