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' }) + }) +})