diff --git a/api/process/process.go b/api/process/process.go index 9c4beb3fc..8ff5d8141 100644 --- a/api/process/process.go +++ b/api/process/process.go @@ -14,6 +14,7 @@ import ( "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/api/relay" "github.com/wippyai/runtime/api/runtime" + "github.com/wippyai/runtime/api/security" ) // System identifies the process system in the event bus. @@ -38,7 +39,8 @@ const ( type ( // Meta contains metadata about a process type. Meta struct { - Method string + Security *security.Config + Method string } // Start contains the configuration needed to start a new process. diff --git a/api/process/process_test.go b/api/process/process_test.go index 3e2eacc1f..5570d287b 100644 --- a/api/process/process_test.go +++ b/api/process/process_test.go @@ -16,6 +16,7 @@ import ( "github.com/wippyai/runtime/api/pid" "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/api/runtime" + "github.com/wippyai/runtime/api/security" ) func TestStepOutput_Result(t *testing.T) { @@ -331,8 +332,10 @@ func TestStart(t *testing.T) { } func TestMeta(t *testing.T) { - meta := Meta{Method: "handler"} + securityConfig := &security.Config{Actor: security.Actor{ID: "process:runner"}} + meta := Meta{Method: "handler", Security: securityConfig} assert.Equal(t, "handler", meta.Method) + assert.Same(t, securityConfig, meta.Security) } func TestFactoryEntry(t *testing.T) { diff --git a/api/runtime/lua/config.go b/api/runtime/lua/config.go index 7f63bb12c..a056d1c90 100644 --- a/api/runtime/lua/config.go +++ b/api/runtime/lua/config.go @@ -79,11 +79,12 @@ type ( // ProcessConfig defines the configuration for a Lua processes. ProcessConfig struct { - Meta attrs.Bag `json:"meta"` // Metadata for the terminal - Source string `json:"source" resolve:"-"` // Lua source code - Method string `json:"method"` // Alias of the Lua method to execute - Imports map[string]registry.ID `json:"imports,omitempty"` // Imports aliases for the library - Modules []string `json:"modules,omitempty"` // Shortcut for importing modules + Meta attrs.Bag `json:"meta"` // Metadata for the terminal + Security *security.Config `json:"security,omitempty" yaml:"security,omitempty"` + Source string `json:"source" resolve:"-"` // Lua source code + Method string `json:"method"` // Alias of the Lua method to execute + Imports map[string]registry.ID `json:"imports,omitempty"` // Imports aliases for the library + Modules []string `json:"modules,omitempty"` // Shortcut for importing modules } // WorkflowConfig defines the configuration for a Lua workflow. @@ -131,13 +132,14 @@ type ( // BytecodeProcessConfig defines configuration for a precompiled Lua process. BytecodeProcessConfig struct { - Imports map[string]registry.ID `json:"imports,omitempty"` - Meta attrs.Bag `json:"meta,omitempty"` - FS string `json:"fs"` - Path string `json:"path"` - Hash string `json:"hash"` - Method string `json:"method"` - Modules []string `json:"modules,omitempty"` + Imports map[string]registry.ID `json:"imports,omitempty"` + Meta attrs.Bag `json:"meta,omitempty"` + Security *security.Config `json:"security,omitempty" yaml:"security,omitempty"` + FS string `json:"fs"` + Path string `json:"path"` + Hash string `json:"hash"` + Method string `json:"method"` + Modules []string `json:"modules,omitempty"` } // BytecodeWorkflowConfig defines configuration for a precompiled Lua workflow. diff --git a/cmd/wippy/cmd/publish.go b/cmd/wippy/cmd/publish.go index 0749740fb..82bc20333 100644 --- a/cmd/wippy/cmd/publish.go +++ b/cmd/wippy/cmd/publish.go @@ -11,6 +11,7 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "time" @@ -55,6 +56,7 @@ func init() { publishCmd.Flags().String("version", "", "version to publish (overrides wippy.yaml)") publishCmd.Flags().Bool("dry-run", false, "pack only, don't upload") + publishCmd.Flags().String("output", "", "keep the dry-run pack at this path") publishCmd.Flags().String("label", "", "publish as mutable label instead of version") publishCmd.Flags().String("release-notes", "", "release notes text") publishCmd.Flags().Bool("protected", false, "mark version as protected") @@ -72,6 +74,7 @@ func runPublish(cmd *cobra.Command, _ []string) error { configDir, _ := cmd.Flags().GetString("config") dryRun, _ := cmd.Flags().GetBool("dry-run") + outputFlag, _ := cmd.Flags().GetString("output") versionFlag, _ := cmd.Flags().GetString("version") label, _ := cmd.Flags().GetString("label") releaseNotes, _ := cmd.Flags().GetString("release-notes") @@ -157,8 +160,13 @@ func runPublish(cmd *cobra.Command, _ []string) error { return NewInitAppError(err) } - outputFile := filepath.Join(os.TempDir(), cfg.OutputFileName()) - defer os.Remove(outputFile) + outputFile, removeOutput, err := publishOutputPath(dryRun, outputFlag, filepath.Join(os.TempDir(), cfg.OutputFileName())) + if err != nil { + return err + } + if removeOutput { + defer os.Remove(outputFile) + } printStatus("Packing module...") @@ -482,13 +490,17 @@ func packModule(ctx context.Context, app *appinit.Context, cfg *config.ModuleCon resources := stages.GetResources(ctx) + packedAt, err := publishPackedAt() + if err != nil { + return nil, err + } metadata := attrs.Bag{ "name": cfg.ModuleName, "namespace": cfg.Namespace(), "version": cfg.Version, "wippy_version": version.Version, "wippy_commit": version.Commit, - "packed_at": time.Now().UTC().Format(time.RFC3339), + "packed_at": packedAt, "entry_count": len(srcEntries), } @@ -571,6 +583,28 @@ func packModule(ctx context.Context, app *appinit.Context, cfg *config.ModuleCon }, nil } +func publishOutputPath(dryRun bool, outputPath, defaultPath string) (string, bool, error) { + if outputPath == "" { + return defaultPath, true, nil + } + if !dryRun { + return "", false, fmt.Errorf("--output requires --dry-run") + } + return outputPath, false, nil +} + +func publishPackedAt() (string, error) { + epoch := os.Getenv("SOURCE_DATE_EPOCH") + if epoch == "" { + return time.Now().UTC().Format(time.RFC3339), nil + } + seconds, err := strconv.ParseInt(epoch, 10, 64) + if err != nil || seconds < 0 { + return "", fmt.Errorf("invalid SOURCE_DATE_EPOCH %q", epoch) + } + return time.Unix(seconds, 0).UTC().Format(time.RFC3339), nil +} + func computeFileDigest(path string) (string, error) { f, err := os.Open(path) if err != nil { diff --git a/cmd/wippy/cmd/publish_test.go b/cmd/wippy/cmd/publish_test.go index fbd5b5286..4897b946f 100644 --- a/cmd/wippy/cmd/publish_test.go +++ b/cmd/wippy/cmd/publish_test.go @@ -14,6 +14,50 @@ import ( "github.com/wippyai/runtime/boot/deps/hub" ) +func TestPublishOutputPath(t *testing.T) { + tests := []struct { + name string + output string + wantPath string + dryRun bool + wantCleanup bool + wantError bool + }{ + {name: "default", wantPath: "default.wapp", wantCleanup: true}, + {name: "dry-run output", dryRun: true, output: "release.wapp", wantPath: "release.wapp"}, + {name: "upload output", output: "release.wapp", wantError: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actualPath, cleanup, err := publishOutputPath(test.dryRun, test.output, "default.wapp") + if (err != nil) != test.wantError { + t.Fatalf("publishOutputPath() error = %v", err) + } + if err == nil && (actualPath != test.wantPath || cleanup != test.wantCleanup) { + t.Fatalf("publishOutputPath() = (%q, %t), want (%q, %t)", actualPath, cleanup, test.wantPath, test.wantCleanup) + } + }) + } +} + +func TestPublishPackedAt_SourceDateEpoch(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "1749513600") + actual, err := publishPackedAt() + if err != nil { + t.Fatalf("publishPackedAt() error = %v", err) + } + if actual != "2025-06-10T00:00:00Z" { + t.Fatalf("publishPackedAt() = %q", actual) + } +} + +func TestPublishPackedAt_InvalidSourceDateEpoch(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "invalid") + if _, err := publishPackedAt(); err == nil { + t.Fatal("publishPackedAt() error = nil") + } +} + func TestPublishViaHubOrLegacy_LabelUploadKeepsVersionHeader(t *testing.T) { tmpDir := t.TempDir() wappPath := filepath.Join(tmpDir, "module.wapp") diff --git a/runtime/lua/component/process/manager.go b/runtime/lua/component/process/manager.go index e30dafcb9..322b62031 100644 --- a/runtime/lua/component/process/manager.go +++ b/runtime/lua/component/process/manager.go @@ -13,6 +13,7 @@ import ( "github.com/wippyai/runtime/api/process" "github.com/wippyai/runtime/api/registry" api "github.com/wippyai/runtime/api/runtime/lua" + "github.com/wippyai/runtime/api/security" runtimelua "github.com/wippyai/runtime/runtime/lua" "github.com/wippyai/runtime/runtime/lua/code" "github.com/wippyai/runtime/runtime/lua/component" @@ -25,6 +26,7 @@ import ( type configEntry struct { source *api.ProcessConfig bytecode *api.BytecodeProcessConfig + security *security.Config method string } @@ -115,7 +117,7 @@ func (m *Manager) Invalidate(ctx context.Context, ids []registry.ID) error { } } - if err := m.registerFactory(ctx, id, cfg.method); err != nil { + if err := m.registerFactory(ctx, id, cfg.method, cfg.security); err != nil { m.log.Error("failed to invalidate process", zap.Error(err)) errs = append(errs, err) continue @@ -166,9 +168,9 @@ func (m *Manager) addSource(ctx context.Context, entry registry.Entry) error { return runtimelua.NewAddNodeError("process", err) } - m.configs.Store(entry.ID, &configEntry{method: cfg.Method, source: cfg}) + m.configs.Store(entry.ID, &configEntry{method: cfg.Method, source: cfg, security: cfg.Security}) - if err := m.registerFactory(ctx, entry.ID, cfg.Method); err != nil { + if err := m.registerFactory(ctx, entry.ID, cfg.Method, cfg.Security); err != nil { _ = m.code.DeleteNode(ctx, entry.ID) m.configs.Delete(entry.ID) return runtimelua.NewRegisterFactoryError(err) @@ -200,9 +202,9 @@ func (m *Manager) addBytecode(ctx context.Context, entry registry.Entry) error { return runtimelua.NewAddNodeError("process", err) } - m.configs.Store(entry.ID, &configEntry{method: cfg.Method, bytecode: cfg}) + m.configs.Store(entry.ID, &configEntry{method: cfg.Method, bytecode: cfg, security: cfg.Security}) - if err := m.registerFactory(ctx, entry.ID, cfg.Method); err != nil { + if err := m.registerFactory(ctx, entry.ID, cfg.Method, cfg.Security); err != nil { _ = m.code.DeleteNode(ctx, entry.ID) m.configs.Delete(entry.ID) return runtimelua.NewRegisterFactoryError(err) @@ -234,9 +236,9 @@ func (m *Manager) updateSource(ctx context.Context, entry registry.Entry) error return runtimelua.NewUpdateNodeError("process", err) } - m.configs.Store(entry.ID, &configEntry{method: cfg.Method, source: cfg}) + m.configs.Store(entry.ID, &configEntry{method: cfg.Method, source: cfg, security: cfg.Security}) - if err := m.registerFactory(ctx, entry.ID, cfg.Method); err != nil { + if err := m.registerFactory(ctx, entry.ID, cfg.Method, cfg.Security); err != nil { return runtimelua.NewUpdateFactoryError(err) } @@ -266,9 +268,9 @@ func (m *Manager) updateBytecode(ctx context.Context, entry registry.Entry) erro return runtimelua.NewUpdateNodeError("process", err) } - m.configs.Store(entry.ID, &configEntry{method: cfg.Method, bytecode: cfg}) + m.configs.Store(entry.ID, &configEntry{method: cfg.Method, bytecode: cfg, security: cfg.Security}) - if err := m.registerFactory(ctx, entry.ID, cfg.Method); err != nil { + if err := m.registerFactory(ctx, entry.ID, cfg.Method, cfg.Security); err != nil { return runtimelua.NewUpdateFactoryError(err) } @@ -277,7 +279,7 @@ func (m *Manager) updateBytecode(ctx context.Context, entry registry.Entry) erro } // registerFactory registers a process factory with the factory registry and waits for confirmation. -func (m *Manager) registerFactory(ctx context.Context, id registry.ID, method string) error { +func (m *Manager) registerFactory(ctx context.Context, id registry.ID, method string, securityConfig *security.Config) error { // Create factory using ProcessFactory factoryFn, err := m.factory.CreateFactory(id, engine.WithModules(component.ExecutableAmbientModules()...)) if err != nil { @@ -308,7 +310,8 @@ func (m *Manager) registerFactory(ctx context.Context, id registry.ID, method st Data: &process.FactoryEntry{ Factory: factoryFn, Meta: process.Meta{ - Method: method, + Method: method, + Security: securityConfig, }, }, }) diff --git a/runtime/lua/component/process/manager_test.go b/runtime/lua/component/process/manager_test.go index d1cab0a0f..b6e6e162f 100644 --- a/runtime/lua/component/process/manager_test.go +++ b/runtime/lua/component/process/manager_test.go @@ -17,6 +17,7 @@ import ( processapi "github.com/wippyai/runtime/api/process" "github.com/wippyai/runtime/api/registry" api "github.com/wippyai/runtime/api/runtime/lua" + "github.com/wippyai/runtime/api/security" "github.com/wippyai/runtime/runtime/lua/code" "github.com/wippyai/runtime/runtime/lua/engine" systempayload "github.com/wippyai/runtime/system/payload" @@ -193,6 +194,27 @@ func TestManager_Invalidate(_ *testing.T) { manager.Invalidate(context.Background(), ids) } +func TestManager_registerFactoryCarriesSecurity(t *testing.T) { + log := zap.NewNop() + codeManager := &code.Manager{} + bus := &mockEventBus{} + fsReg := &mockFSRegistry{} + factory := &mockCompiledFactory{} + manager := NewManager(log, codeManager, bus, fsReg, factory) + securityConfig := &security.Config{Actor: security.Actor{ID: "process:runner"}} + awaitSvc := &mockPrepareAwaitService{result: event.AwaitResult{Accepted: true}} + ctx := event.WithAwaitService(ctxapi.NewRootContext(), awaitSvc) + + err := manager.registerFactory(ctx, registry.NewID("app.test", "process"), "", securityConfig) + + require.NoError(t, err) + require.Len(t, bus.events, 1) + entry, ok := bus.events[0].Data.(*processapi.FactoryEntry) + require.True(t, ok) + assert.Equal(t, "main", entry.Meta.Method) + assert.Same(t, securityConfig, entry.Meta.Security) +} + func TestManager_registerFactory_PreparesBeforeSend(t *testing.T) { log := zap.NewNop() codeManager := &code.Manager{} @@ -212,7 +234,7 @@ func TestManager_registerFactory_PreparesBeforeSend(t *testing.T) { } ctx := event.WithAwaitService(ctxapi.NewRootContext(), awaitSvc) - err := manager.registerFactory(ctx, registry.NewID("app.test", "process"), "main") + err := manager.registerFactory(ctx, registry.NewID("app.test", "process"), "main", nil) require.NoError(t, err) assert.False(t, sendBeforePrepare, "factory register was sent before await prepare") } diff --git a/service/exec/native/native_test.go b/service/exec/native/native_test.go index c28aa6ab8..c37721a3c 100644 --- a/service/exec/native/native_test.go +++ b/service/exec/native/native_test.go @@ -456,8 +456,7 @@ func TestExecutor_Stderr(t *testing.T) { // Use a cross-platform way to generate stderr output var command string if runtime.GOOS == "windows" { - // On Windows, use PowerShell for reliable stderr redirection - command = "powershell -Command \"[Console]::Error.WriteLine('error message')\"" + command = "cmd /c \"echo error message 1>&2\"" } else { // On Unix systems - use sh instead of bash for better compatibility command = "sh -c 'echo error message >&2'" diff --git a/service/host/host.go b/service/host/host.go index f8c977671..d133f75e6 100644 --- a/service/host/host.go +++ b/service/host/host.go @@ -18,6 +18,7 @@ import ( hostapi "github.com/wippyai/runtime/api/service/host" "github.com/wippyai/runtime/api/topology" "github.com/wippyai/runtime/system/scheduler/actor" + securitysys "github.com/wippyai/runtime/system/security" "go.uber.org/zap" ) @@ -98,6 +99,9 @@ func (h *Host) Run(ctx context.Context, start *process.Start) (pid.PID, error) { processID := h.preparePID(ctx, start) frameCtx := h.prepareContext(ctx, processID, start) + if meta != nil && meta.Security != nil { + frameCtx = securitysys.ApplyProcessSecurityConfig(frameCtx, meta.Security) + } method := "main" if meta != nil && meta.Method != "" { diff --git a/service/host/host_test.go b/service/host/host_test.go index 71e0a98da..b016094da 100644 --- a/service/host/host_test.go +++ b/service/host/host_test.go @@ -19,6 +19,7 @@ import ( "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/api/relay" "github.com/wippyai/runtime/api/runtime" + "github.com/wippyai/runtime/api/security" hostapi "github.com/wippyai/runtime/api/service/host" "github.com/wippyai/runtime/api/topology" "github.com/wippyai/runtime/internal/uniqid" @@ -45,14 +46,25 @@ func namedOptions(name string) attrs.Bag { // mockProcess implements process.Process for testing. type mockProcess struct { - initErr error - stepFunc func([]process.Event, *process.StepOutput) error + initContext context.Context + initErr error + stepFunc func([]process.Event, *process.StepOutput) error + mu sync.Mutex } -func (m *mockProcess) Init(_ context.Context, _ string, _ payload.Payloads) error { +func (m *mockProcess) Init(ctx context.Context, _ string, _ payload.Payloads) error { + m.mu.Lock() + defer m.mu.Unlock() + m.initContext = ctx return m.initErr } +func (m *mockProcess) context() context.Context { + m.mu.Lock() + defer m.mu.Unlock() + return m.initContext +} + func (m *mockProcess) Step(events []process.Event, out *process.StepOutput) error { if m.stepFunc != nil { return m.stepFunc(events, out) @@ -337,6 +349,25 @@ func TestHost_RunWithMeta(t *testing.T) { assert.NotEqual(t, pid.PID{}, resultPID) } +func TestHost_RunAppliesProcessSecurity(t *testing.T) { + processInstance := &mockProcess{} + th := newTestHost(func(th *testHost) { + th.factory.proc = processInstance + th.factory.meta = &process.Meta{ + Security: &security.Config{Actor: security.Actor{ID: "process:runner"}}, + } + }) + th.start(t) + defer th.stop() + + _, err := th.host.Run(ctxWithAppContext(), &process.Start{Source: registry.NewID("test", "proc")}) + + require.NoError(t, err) + actor, ok := security.GetActor(processInstance.context()) + require.True(t, ok) + assert.Equal(t, "process:runner", actor.ID) +} + func TestHost_RunWithMessages(t *testing.T) { th := newTestHost() th.start(t) diff --git a/service/terminal/host.go b/service/terminal/host.go index 295739a4b..14d10b029 100644 --- a/service/terminal/host.go +++ b/service/terminal/host.go @@ -23,6 +23,7 @@ import ( supervisorapi "github.com/wippyai/runtime/api/supervisor" "github.com/wippyai/runtime/system/logs" "github.com/wippyai/runtime/system/scheduler/actor" + securitysys "github.com/wippyai/runtime/system/security" "go.uber.org/zap" ) @@ -205,6 +206,9 @@ func (h *Host) Run(ctx context.Context, start *process.Start) (pid.PID, error) { } frameCtx := h.prepareContext(ctx, processID, start) + if meta != nil && meta.Security != nil { + frameCtx = securitysys.ApplyProcessSecurityConfig(frameCtx, meta.Security) + } method := "main" if meta != nil && meta.Method != "" { diff --git a/service/terminal/host_test.go b/service/terminal/host_test.go index 7bf5e57e0..768053421 100644 --- a/service/terminal/host_test.go +++ b/service/terminal/host_test.go @@ -21,6 +21,7 @@ import ( "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/api/relay" "github.com/wippyai/runtime/api/runtime" + "github.com/wippyai/runtime/api/security" terminalapi "github.com/wippyai/runtime/api/service/terminal" "github.com/wippyai/runtime/internal/uniqid" "github.com/wippyai/runtime/system/logs" @@ -252,6 +253,30 @@ func TestHost_Run_ShuttingDown(t *testing.T) { _ = h.Stop(context.Background()) } +func TestHost_RunAppliesProcessSecurity(t *testing.T) { + id := registry.ID{NS: "test", Name: "host1"} + cfg := &terminalapi.HostConfig{} + processInstance := &mockProcess{} + factory := &mockFactory{ + proc: processInstance, + meta: &process.Meta{Security: &security.Config{Actor: security.Actor{ID: "process:runner"}}}, + } + logCtrl := logs.NewConfigurator(nil, zap.NewNop()) + scheduler := actor.NewScheduler(&mockCommandRegistry{}, actor.WithWorkers(1)) + h := NewHost(id, cfg, scheduler, factory, logCtrl, zap.NewNop()) + ctx := process.WithPIDGenerator(ctxapi.NewRootContext(), newTestPIDGen()) + _, err := h.Start(ctx) + require.NoError(t, err) + defer func() { require.NoError(t, h.Stop(context.Background())) }() + + _, err = h.Run(ctx, &process.Start{Source: registry.ID{NS: "test", Name: "process"}}) + + require.NoError(t, err) + actor, ok := security.GetActor(processInstance.context()) + require.True(t, ok) + assert.Equal(t, "process:runner", actor.ID) +} + func TestHost_Send_ShuttingDown(t *testing.T) { id := registry.ID{NS: "test", Name: "host1"} cfg := &terminalapi.HostConfig{} diff --git a/service/terminal/manager_test.go b/service/terminal/manager_test.go index 3d350429b..58bb55a41 100644 --- a/service/terminal/manager_test.go +++ b/service/terminal/manager_test.go @@ -5,6 +5,7 @@ package terminal import ( "context" "errors" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -58,18 +59,36 @@ func (m *mockTranscoder) Transcode(p payload.Payload, _ payload.Format) (payload return p, nil } -type mockFactory struct{} +type mockFactory struct { + meta *process.Meta + proc process.Process +} func (m *mockFactory) Create(_ registry.ID) (process.Process, *process.Meta, error) { - return &mockProcess{}, nil, nil + if m.proc != nil { + return m.proc, m.meta, nil + } + return &mockProcess{}, m.meta, nil } -type mockProcess struct{} +type mockProcess struct { + initContext context.Context + mu sync.Mutex +} -func (m *mockProcess) Init(context.Context, string, payload.Payloads) error { +func (m *mockProcess) Init(ctx context.Context, _ string, _ payload.Payloads) error { + m.mu.Lock() + defer m.mu.Unlock() + m.initContext = ctx return nil } +func (m *mockProcess) context() context.Context { + m.mu.Lock() + defer m.mu.Unlock() + return m.initContext +} + func (m *mockProcess) Step([]process.Event, *process.StepOutput) error { return nil } diff --git a/system/scheduler/actor/edge_cases_test.go b/system/scheduler/actor/edge_cases_test.go index af343f681..8de8abbea 100644 --- a/system/scheduler/actor/edge_cases_test.go +++ b/system/scheduler/actor/edge_cases_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" "github.com/wippyai/runtime/api/attrs" ctxapi "github.com/wippyai/runtime/api/context" "github.com/wippyai/runtime/api/dispatcher" @@ -19,7 +20,9 @@ import ( "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/api/relay" "github.com/wippyai/runtime/api/runtime" + securityapi "github.com/wippyai/runtime/api/security" "github.com/wippyai/runtime/system/scheduler" + securitysys "github.com/wippyai/runtime/system/security" ) // UpgradeProcess requests process upgrade @@ -290,6 +293,53 @@ func TestUpgradeSuccess(t *testing.T) { } } +type actorAssertingProcess struct { + actorID string +} + +func (p *actorAssertingProcess) Init(ctx context.Context, _ string, _ payload.Payloads) error { + actor, ok := securityapi.GetActor(ctx) + if !ok || actor.ID != p.actorID { + return fmt.Errorf("actor = %q, want %q", actor.ID, p.actorID) + } + return nil +} + +func (p *actorAssertingProcess) Step(_ []process.Event, out *process.StepOutput) error { + out.Done(nil) + return nil +} + +func (p *actorAssertingProcess) Send(*relay.Package) error { return nil } +func (p *actorAssertingProcess) Close() {} + +func TestUpgradeSuccess_NilTargetMetadataRestoresBaselineSecurity(t *testing.T) { + reg := scheduler.NewRegistry() + te := newTestExecutorWithRegistry(1, reg) + te.Start() + defer te.Stop() + + ctx, fc := ctxapi.OpenFrameContext(ctxapi.NewRootContext()) + defer ctxapi.ReleaseFrameContext(fc) + require.NoError(t, securityapi.SetActor(ctx, securityapi.Actor{ID: "caller"})) + ctx = securitysys.ApplyProcessSecurityConfig(ctx, &securityapi.Config{Actor: securityapi.Actor{ID: "source"}}) + require.True(t, securitysys.HasProcessSecurityConfig(ctx)) + process.WithFactory(ctx, &mockFactory{ + createFunc: func(_ registry.ID) (process.Process, *process.Meta, error) { + return &actorAssertingProcess{actorID: "caller"}, nil, nil + }, + }) + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + result, err := te.Execute(ctx, pidapi.PID{UniqID: "upgrade-nil-meta-security"}, &UpgradeProcess{ + upgradeReq: &process.UpgradeRequest{Source: registry.ID{Name: "target"}}, + }, "", nil) + + require.NoError(t, err) + require.NoError(t, result.Error) +} + func TestUpgradeSuccess_ForksSealedFrameForReplacementProcess(t *testing.T) { reg := scheduler.NewRegistry() te := newTestExecutorWithRegistry(1, reg) diff --git a/system/scheduler/actor/worker.go b/system/scheduler/actor/worker.go index a173e0684..1821a060a 100644 --- a/system/scheduler/actor/worker.go +++ b/system/scheduler/actor/worker.go @@ -14,6 +14,7 @@ import ( "github.com/wippyai/runtime/api/process" "github.com/wippyai/runtime/api/runtime" sysprocess "github.com/wippyai/runtime/system/process" + securitysys "github.com/wippyai/runtime/system/security" ) type Worker struct { @@ -451,6 +452,13 @@ func (w *Worker) executeOne(proc *Processor) { return } } + if meta != nil { + if meta.Security != nil || securitysys.HasProcessSecurityConfig(upgradeCtx) { + upgradeCtx = securitysys.ApplyProcessSecurityConfig(upgradeCtx, meta.Security) + } + } else if securitysys.HasProcessSecurityConfig(upgradeCtx) { + upgradeCtx = securitysys.ApplyProcessSecurityConfig(upgradeCtx, nil) + } if err := newProc.Init(upgradeCtx, method, req.Input); err != nil { proc.Process.Close() if !proc.casState(StateRunning, StateComplete) { diff --git a/system/security/context.go b/system/security/context.go index ee513b6d4..8d7507f6a 100644 --- a/system/security/context.go +++ b/system/security/context.go @@ -5,13 +5,77 @@ package security import ( "context" + ctxapi "github.com/wippyai/runtime/api/context" "github.com/wippyai/runtime/api/security" ) +var processSecurityKey = &ctxapi.Key{Name: "security.process_entry", Inherit: true} + +type processSecurityState struct { + scope security.Scope + actor security.Actor + hasActor bool + hasScope bool + configured bool +} + func actorConfigured(actor security.Actor) bool { return actor.ID != "" || len(actor.Meta) > 0 } +func HasProcessSecurityConfig(ctx context.Context) bool { + frame := ctxapi.FrameFromContext(ctx) + if frame == nil { + return false + } + state, ok := frame.Get(processSecurityKey) + if !ok { + return false + } + entryState, ok := state.(processSecurityState) + return ok && entryState.configured +} + +func ApplyProcessSecurityConfig(ctx context.Context, config *security.Config) context.Context { + frame := ctxapi.FrameFromContext(ctx) + if frame == nil { + return ctx + } + + stateValue, ok := frame.Get(processSecurityKey) + state, hasState := stateValue.(processSecurityState) + if !ok || !hasState { + state.actor, state.hasActor = security.GetActor(ctx) + state.scope, state.hasScope = security.GetScope(ctx) + } else { + if state.hasActor { + if err := security.SetActor(ctx, state.actor); err != nil { + return ctx + } + } else if err := security.SetActor(ctx, security.Actor{}); err != nil { + return ctx + } + if state.hasScope { + if err := security.SetScope(ctx, state.scope); err != nil { + return ctx + } + } else if err := security.SetScope(ctx, NewScope(nil)); err != nil { + return ctx + } + } + + if config != nil { + ctx = WithSecurityConfig(ctx, config) + state.configured = true + } else { + state.configured = false + } + if err := frame.Set(processSecurityKey, state); err != nil { + return ctx + } + return ctx +} + // WithSecurityConfig configures the security context based on the provided configuration. func WithSecurityConfig(ctx context.Context, config *security.Config) context.Context { if config == nil { diff --git a/system/security/context_test.go b/system/security/context_test.go index cd32282ad..1bccaf698 100644 --- a/system/security/context_test.go +++ b/system/security/context_test.go @@ -70,6 +70,58 @@ func TestWithSecurityConfig_PreservesExistingActorWhenConfigHasNoActor(t *testin assert.Equal(t, "caller", actor.ID) } +func TestApplyProcessSecurityConfigRestoresBaselineOnUpgrade(t *testing.T) { + reg := NewPolicyRegistry(eventbus.NewBus(), nil) + basePolicy := newMockPolicy("base", security.Allow) + sourcePolicy := newMockPolicy("source", security.Allow) + targetPolicy := newMockPolicy("target", security.Allow) + for _, policy := range []security.Policy{sourcePolicy, targetPolicy} { + policyID := policy.ID() + reg.handleEvent(event.Event{ + Kind: security.PolicyRegister, + Path: policyID.String(), + Data: &security.PolicyEntry{Policy: policy}, + }) + } + + ctx := security.WithRegistry(ctxapi.NewRootContext(), reg) + ctx, fc := ctxapi.OpenFrameContext(ctx) + t.Cleanup(func() { ctxapi.ReleaseFrameContext(fc) }) + require.NoError(t, security.SetActor(ctx, security.Actor{ID: "caller"})) + require.NoError(t, security.SetScope(ctx, NewScope([]security.Policy{basePolicy}))) + + ctx = ApplyProcessSecurityConfig(ctx, &security.Config{ + Actor: security.Actor{ID: "source"}, + Policies: []registry.ID{sourcePolicy.ID()}, + }) + scope, ok := security.GetScope(ctx) + require.True(t, ok) + assert.True(t, scope.Contains(basePolicy.ID())) + assert.True(t, scope.Contains(sourcePolicy.ID())) + assert.True(t, HasProcessSecurityConfig(ctx)) + + ctx = ApplyProcessSecurityConfig(ctx, &security.Config{Policies: []registry.ID{targetPolicy.ID()}}) + actor, ok := security.GetActor(ctx) + require.True(t, ok) + assert.Equal(t, "caller", actor.ID) + scope, ok = security.GetScope(ctx) + require.True(t, ok) + assert.True(t, scope.Contains(basePolicy.ID())) + assert.True(t, scope.Contains(targetPolicy.ID())) + assert.False(t, scope.Contains(sourcePolicy.ID())) + + ctx = ApplyProcessSecurityConfig(ctx, nil) + actor, ok = security.GetActor(ctx) + require.True(t, ok) + assert.Equal(t, "caller", actor.ID) + scope, ok = security.GetScope(ctx) + require.True(t, ok) + assert.True(t, scope.Contains(basePolicy.ID())) + assert.False(t, scope.Contains(sourcePolicy.ID())) + assert.False(t, scope.Contains(targetPolicy.ID())) + assert.False(t, HasProcessSecurityConfig(ctx)) +} + func TestWithSecurityConfig_MergesPoliciesIntoExistingScope(t *testing.T) { reg := NewPolicyRegistry(eventbus.NewBus(), nil) existingPolicy := newMockPolicy("existing", security.Allow)