From 95e9833423a0e260d5cbba7100dd0aa08824b35c Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 21 Sep 2026 17:47:59 -0600 Subject: [PATCH] feat(start-sdk,start-os): tell an action who is running it `access` decides whether another service may run an action, but the action was never told which service that was: run_action used the caller's id for the access check and then forwarded only the action id and the input. Any identity an action needed had to come from its input, where a service allowed to call it could name another package, so an action could not be limited to the caller's own resources. StartOS now forwards the caller. Service::run_action and Service::get_action_input take it, the actor messages carry it into the procedure's params, the container runtime passes it on, and the SDK hands it to the run handler, the prefill function and a function-valued input spec as `caller`: a package id, or null for the user and for StartOS reading a form to evaluate a task. It comes from the effect context the call arrived on, so a package cannot set it. SDK 3.0.0 already requires StartOS 0.4.0.2, which is where this lands. --- .../src/Adapters/RpcListener.ts | 2 + .../Systems/SystemForEmbassy/index.ts | 2 + .../src/Adapters/Systems/SystemForStartOs.ts | 6 +- .../src/Interfaces/System.ts | 2 + projects/start-sdk/CHANGELOG.md | 6 ++ projects/start-sdk/docs/src/actions.md | 31 ++++++++++ shared-libs/crates/start-core/src/action.rs | 3 +- .../crates/start-core/src/service/action.rs | 25 ++++++++- .../start-core/src/service/effects/action.rs | 19 +++++-- .../crates/start-core/src/service/mod.rs | 2 +- .../start-core/lib/actions/setupActions.ts | 21 ++++++- .../start-core/lib/test/actionCaller.test.ts | 56 +++++++++++++++++++ 12 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 shared-libs/ts-modules/start-core/lib/test/actionCaller.test.ts diff --git a/projects/start-os/container-runtime/src/Adapters/RpcListener.ts b/projects/start-os/container-runtime/src/Adapters/RpcListener.ts index e435ddcfb2..9209817813 100644 --- a/projects/start-os/container-runtime/src/Adapters/RpcListener.ts +++ b/projects/start-os/container-runtime/src/Adapters/RpcListener.ts @@ -448,6 +448,7 @@ export class RpcListener { effects, procedures[2], input?.prefill ?? null, + input?.caller ?? null, timeout || null, ) case procedures[1] === 'actions' && procedures[3] === 'run': @@ -455,6 +456,7 @@ export class RpcListener { effects, procedures[2], input.input, + input.caller ?? null, timeout || null, ) } diff --git a/projects/start-os/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts b/projects/start-os/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts index 29ef19e6f5..accfe232b1 100644 --- a/projects/start-os/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts +++ b/projects/start-os/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts @@ -588,6 +588,7 @@ export class SystemForEmbassy implements System { effects: Effects, actionId: string, _prefill: Record | null, + _caller: T.PackageId | null, timeoutMs: number | null, ): Promise { if (actionId === 'config') { @@ -613,6 +614,7 @@ export class SystemForEmbassy implements System { effects: Effects, actionId: string, input: unknown, + _caller: T.PackageId | null, timeoutMs: number | null, ): Promise { if (actionId === 'config') { diff --git a/projects/start-os/container-runtime/src/Adapters/Systems/SystemForStartOs.ts b/projects/start-os/container-runtime/src/Adapters/Systems/SystemForStartOs.ts index f317d67022..32f807361d 100644 --- a/projects/start-os/container-runtime/src/Adapters/Systems/SystemForStartOs.ts +++ b/projects/start-os/container-runtime/src/Adapters/Systems/SystemForStartOs.ts @@ -48,21 +48,23 @@ export class SystemForStartOs implements System { effects: Effects, id: string, prefill: Record | null, + caller: T.PackageId | null, timeoutMs: number | null, ): Promise { const action = this.abi.actions.get(id) if (!action) throw new Error(`Action ${id} not found`) - return action.getInput({ effects, prefill }) + return action.getInput({ effects, prefill, caller }) } runAction( effects: Effects, id: string, input: unknown, + caller: T.PackageId | null, timeoutMs: number | null, ): Promise { const action = this.abi.actions.get(id) if (!action) throw new Error(`Action ${id} not found`) - return action.run({ effects, input }) + return action.run({ effects, input, caller }) } async start(effects: Effects): Promise { diff --git a/projects/start-os/container-runtime/src/Interfaces/System.ts b/projects/start-os/container-runtime/src/Interfaces/System.ts index 8e51184bee..27fb34cc3b 100644 --- a/projects/start-os/container-runtime/src/Interfaces/System.ts +++ b/projects/start-os/container-runtime/src/Interfaces/System.ts @@ -28,12 +28,14 @@ export type System = { effects: Effects, actionId: string, input: unknown, + caller: T.PackageId | null, timeoutMs: number | null, ): Promise getActionInput( effects: Effects, actionId: string, prefill: Record | null, + caller: T.PackageId | null, timeoutMs: number | null, ): Promise diff --git a/projects/start-sdk/CHANGELOG.md b/projects/start-sdk/CHANGELOG.md index 258dc0d7fb..950d03790f 100644 --- a/projects/start-sdk/CHANGELOG.md +++ b/projects/start-sdk/CHANGELOG.md @@ -95,6 +95,12 @@ ### Added +- **An action learns who is running it.** The `run` handler, the prefill + function and a function-valued input spec each receive `caller`: the id of + the service that reached the action through `effects.action`, or `null` when + the user did. An action with `access: 'dependent'` or `'public'` can act on + the caller's own resources instead of trusting a package id in its input + - **`utils.isAddressEnabled(addresses, hostname)`** reports whether the user's overrides leave one of a binding's addresses on diff --git a/projects/start-sdk/docs/src/actions.md b/projects/start-sdk/docs/src/actions.md index 55c9761eff..ac42309be7 100644 --- a/projects/start-sdk/docs/src/actions.md +++ b/projects/start-sdk/docs/src/actions.md @@ -80,6 +80,37 @@ The optional **`access`** field on the metadata controls who may invoke the acti `access` is independent of `visibility` (whether the action is shown/enabled) and `allowedStatuses` (which run states permit it); a direct cross-package run is rejected if `access` denies the caller. +### Knowing Who Is Calling + +`access` decides _whether_ another service may run the action. **`caller`** tells the action _which_ service is running it, so it can decide what that service is allowed to touch. The `run` handler, the prefill function, and an input spec written as a function each receive it: + +- a package id — the service that reached the action through `effects.action.run` or `effects.action.getInput`. A service that runs one of its own actions that way sees its own id. +- `null` — the user ran it, or StartOS is reading the form to evaluate a task. + +StartOS supplies `caller`; the calling service cannot set or forge it. **Take identity from `caller`, never from the input.** An action that lets a service register something against "its own" host must not accept a package id as a field — any service allowed to call it could name another: + +```typescript +export const registerEndpoint = sdk.Action.withInput( + 'register-endpoint', + async () => ({ + name: i18n('Register Endpoint'), + description: i18n('Register a host of the calling service'), + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'hidden', + access: 'dependent', + }), + InputSpec.of({ hostId: Value.text({ name: 'Host', required: true, default: null }) }), + async () => null, + async ({ effects, input, caller }) => { + if (caller === null) throw new Error('Only a service can register an endpoint') + // `caller` is who asked; `input.hostId` is which of its hosts. + await register(effects, { packageId: caller, hostId: input.hostId }) + }, +) +``` + ## Registering Actions All actions must be registered in `actions/index.ts`: diff --git a/shared-libs/crates/start-core/src/action.rs b/shared-libs/crates/start-core/src/action.rs index 33e7c12534..820dfc1d17 100644 --- a/shared-libs/crates/start-core/src/action.rs +++ b/shared-libs/crates/start-core/src/action.rs @@ -88,7 +88,7 @@ pub async fn get_action_input( .await .as_ref() .or_not_found(lazy_format!("Manager for {}", package_id))? - .get_action_input(Guid::new(), action_id, prefill.unwrap_or(Value::Null)) + .get_action_input(Guid::new(), action_id, prefill.unwrap_or(Value::Null), None) .await } @@ -393,6 +393,7 @@ pub async fn run_action( event_id.unwrap_or_default(), action_id, input.unwrap_or_default(), + None, ) .await .map(|res| res.map(ActionResult::upcast)) diff --git a/shared-libs/crates/start-core/src/service/action.rs b/shared-libs/crates/start-core/src/service/action.rs index 8c908e5e22..7862c2a79a 100644 --- a/shared-libs/crates/start-core/src/service/action.rs +++ b/shared-libs/crates/start-core/src/service/action.rs @@ -18,6 +18,7 @@ use crate::{ActionId, PackageId, ReplayId}; pub(super) struct GetActionInput { id: ActionId, prefill: Value, + caller: Option, } impl Handler for ServiceActor { type Response = Result, Error>; @@ -30,6 +31,7 @@ impl Handler for ServiceActor { GetActionInput { id: action_id, prefill, + caller, }: GetActionInput, _: &BackgroundJobQueue, ) -> Self::Response { @@ -38,7 +40,7 @@ impl Handler for ServiceActor { .execute::>( id, ProcedureName::GetActionInput(action_id), - json!({ "prefill": prefill }), + json!({ "prefill": prefill, "caller": caller }), Some(Duration::from_secs(30)), ) .await @@ -47,11 +49,14 @@ impl Handler for ServiceActor { } impl Service { + /// `caller` is the service asking through its effects, or `None` for the + /// user and for the OS evaluating a task. pub async fn get_action_input( &self, id: Guid, action_id: ActionId, prefill: Value, + caller: Option, ) -> Result, Error> { if !self .seed @@ -77,6 +82,7 @@ impl Service { GetActionInput { id: action_id, prefill, + caller, }, ) .await? @@ -150,6 +156,7 @@ pub fn update_tasks( pub(super) struct RunAction { action_id: ActionId, input: Value, + caller: Option, } impl Handler for ServiceActor { type Response = Result, Error>; @@ -162,6 +169,7 @@ impl Handler for ServiceActor { RunAction { ref action_id, input, + caller, }: RunAction, _: &BackgroundJobQueue, ) -> Self::Response { @@ -214,6 +222,7 @@ impl Handler for ServiceActor { ProcedureName::RunAction(action_id.clone()), json!({ "input": input, + "caller": caller, }), Some(Duration::from_secs(120)), ) @@ -240,12 +249,24 @@ impl Handler for ServiceActor { } impl Service { + /// `caller` is the service running the action through its effects, or + /// `None` for the user. pub async fn run_action( &self, id: Guid, action_id: ActionId, input: Value, + caller: Option, ) -> Result, Error> { - self.actor.send(id, RunAction { action_id, input }).await? + self.actor + .send( + id, + RunAction { + action_id, + input, + caller, + }, + ) + .await? } } diff --git a/shared-libs/crates/start-core/src/service/effects/action.rs b/shared-libs/crates/start-core/src/service/effects/action.rs index b4112a7d0c..849061de78 100644 --- a/shared-libs/crates/start-core/src/service/effects/action.rs +++ b/shared-libs/crates/start-core/src/service/effects/action.rs @@ -141,6 +141,7 @@ async fn get_action_input( ) -> Result, Error> { let context = context.deref()?; let prefill = prefill.unwrap_or(Value::Null); + let caller = Some(context.seed.id.clone()); if let Some(package_id) = package_id { context @@ -151,11 +152,11 @@ async fn get_action_input( .await .as_ref() .or_not_found(&package_id)? - .get_action_input(procedure_id, action_id, prefill) + .get_action_input(procedure_id, action_id, prefill, caller) .await } else { context - .get_action_input(procedure_id, action_id, prefill) + .get_action_input(procedure_id, action_id, prefill, caller) .await } } @@ -188,6 +189,7 @@ async fn run_action( }: RunActionParams, ) -> Result, Error> { let context = context.deref()?; + let caller = Some(context.seed.id.clone()); let package_id = package_id.as_ref().unwrap_or(&context.seed.id); @@ -252,10 +254,12 @@ async fn run_action( .await .as_ref() .or_not_found(package_id)? - .run_action(procedure_id, action_id, input) + .run_action(procedure_id, action_id, input, caller) .await } else { - context.run_action(procedure_id, action_id, input).await + context + .run_action(procedure_id, action_id, input, caller) + .await } } @@ -301,7 +305,12 @@ async fn create_task( .filter(|s| s.is_initialized()) { service - .get_action_input(procedure_id.clone(), task.action_id.clone(), Value::Null) + .get_action_input( + procedure_id.clone(), + task.action_id.clone(), + Value::Null, + None, + ) .await .log_err() .flatten() diff --git a/shared-libs/crates/start-core/src/service/mod.rs b/shared-libs/crates/start-core/src/service/mod.rs index a97f4dd879..24c7bc5343 100644 --- a/shared-libs/crates/start-core/src/service/mod.rs +++ b/shared-libs/crates/start-core/src/service/mod.rs @@ -331,7 +331,7 @@ impl Service { let procedure_id = Guid::new(); for action_id in tasks { if let Some(input) = self - .get_action_input(procedure_id.clone(), action_id.clone(), Value::Null) + .get_action_input(procedure_id.clone(), action_id.clone(), Value::Null, None) .await .log_err() .flatten() diff --git a/shared-libs/ts-modules/start-core/lib/actions/setupActions.ts b/shared-libs/ts-modules/start-core/lib/actions/setupActions.ts index 9ee74fb0f1..dfee142604 100644 --- a/shared-libs/ts-modules/start-core/lib/actions/setupActions.ts +++ b/shared-libs/ts-modules/start-core/lib/actions/setupActions.ts @@ -6,14 +6,22 @@ import { InputSpec } from './input/builder' import { ExtractInputSpecType } from './input/builder/inputSpec' type MaybeInputSpec = {} extends Type ? null : InputSpec +/** + * The service that reached the action through `effects.action`, which may be + * this one. `null` for the user, and for StartOS evaluating a task. + */ +export type ActionCaller = T.PackageId | null + export type Run> = (options: { effects: T.Effects input: A spec: T.inputSpecTypes.InputSpec + caller: ActionCaller }) => Promise<(T.ActionResult & { version: '1' }) | null | void | undefined> export type GetInput> = (options: { effects: T.Effects prefill: T.DeepPartial | null + caller: ActionCaller }) => Promise> export type MaybeFn = @@ -56,10 +64,12 @@ export interface Action< getInput(options: { effects: T.Effects prefill: T.DeepPartial | null + caller?: ActionCaller }): Promise run(options: { effects: T.Effects input: Type + caller?: ActionCaller }): Promise } @@ -80,6 +90,7 @@ class ActionImpl< { effects: T.Effects prefill: unknown | null + caller: ActionCaller } >, private readonly getInputFn: GetInput, @@ -98,10 +109,15 @@ class ActionImpl< await options.effects.action.export({ id: this.id, metadata }) return metadata } - async getInput(options: { + async getInput({ + caller = null, + ...rest + }: { effects: T.Effects prefill: T.DeepPartial | null + caller?: ActionCaller }): Promise { + const options = { ...rest, caller } let spec = {} if (this.inputSpec) { const inputSpec = await callMaybeFn(this.inputSpec, options) @@ -124,6 +140,7 @@ class ActionImpl< async run(options: { effects: T.Effects input: Type + caller?: ActionCaller }): Promise { let spec = {} if (this.inputSpec) { @@ -141,6 +158,7 @@ class ActionImpl< effects: options.effects, input: options.input, spec, + caller: options.caller ?? null, })) ?? null ) } @@ -158,6 +176,7 @@ export const Action = { { effects: T.Effects prefill: unknown | null + caller: ActionCaller } >, getInput: GetInput>, diff --git a/shared-libs/ts-modules/start-core/lib/test/actionCaller.test.ts b/shared-libs/ts-modules/start-core/lib/test/actionCaller.test.ts new file mode 100644 index 0000000000..bf356139d1 --- /dev/null +++ b/shared-libs/ts-modules/start-core/lib/test/actionCaller.test.ts @@ -0,0 +1,56 @@ +import { Action } from '../actions/setupActions' +import { InputSpec } from '../actions/input/builder/inputSpec' +import { Value } from '../actions/input/builder/value' +import { Effects } from '../Effects' + +describe('action caller', () => { + const effects = { eventId: 'event' } as unknown as Effects + const metadata = { + name: 'Register', + description: '', + warning: null, + allowedStatuses: 'any' as const, + group: null, + visibility: 'hidden' as const, + } + + test.each([ + ['a service', 'lnd', 'lnd'], + ['the user', null, null], + ['a runtime that names nobody', undefined, null], + ])('run sees %s', async (_name, caller, expected) => { + const run = jest.fn(async () => null) + const action = Action.withoutInput('register', metadata, run) + + await action.run({ effects, input: {}, caller }) + + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ caller: expected }), + ) + }) + + test('the input spec, the prefill and run all see the same caller', async () => { + const seen: Record = {} + const action = Action.withInput( + 'register', + metadata, + async ({ caller }) => { + seen.spec = caller + return InputSpec.of({ hostId: Value.hidden() }) + }, + async ({ caller }) => { + seen.prefill = caller + return null + }, + async ({ caller }) => { + seen.run = caller + return null + }, + ) + + await action.getInput({ effects, prefill: null, caller: 'lnd' }) + await action.run({ effects, input: { hostId: 'peer' }, caller: 'lnd' }) + + expect(seen).toEqual({ spec: 'lnd', prefill: 'lnd', run: 'lnd' }) + }) +})