Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -448,13 +448,15 @@ export class RpcListener {
effects,
procedures[2],
input?.prefill ?? null,
input?.caller ?? null,
timeout || null,
)
case procedures[1] === 'actions' && procedures[3] === 'run':
return system.runAction(
effects,
procedures[2],
input.input,
input.caller ?? null,
timeout || null,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ export class SystemForEmbassy implements System {
effects: Effects,
actionId: string,
_prefill: Record<string, unknown> | null,
_caller: T.PackageId | null,
timeoutMs: number | null,
): Promise<T.ActionInput | null> {
if (actionId === 'config') {
Expand All @@ -613,6 +614,7 @@ export class SystemForEmbassy implements System {
effects: Effects,
actionId: string,
input: unknown,
_caller: T.PackageId | null,
timeoutMs: number | null,
): Promise<T.ActionResult | null> {
if (actionId === 'config') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,23 @@ export class SystemForStartOs implements System {
effects: Effects,
id: string,
prefill: Record<string, unknown> | null,
caller: T.PackageId | null,
timeoutMs: number | null,
): Promise<T.ActionInput | null> {
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<T.ActionResult | null> {
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<void> {
Expand Down
2 changes: 2 additions & 0 deletions projects/start-os/container-runtime/src/Interfaces/System.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ export type System = {
effects: Effects,
actionId: string,
input: unknown,
caller: T.PackageId | null,
timeoutMs: number | null,
): Promise<T.ActionResult | null>
getActionInput(
effects: Effects,
actionId: string,
prefill: Record<string, unknown> | null,
caller: T.PackageId | null,
timeoutMs: number | null,
): Promise<T.ActionInput | null>

Expand Down
6 changes: 6 additions & 0 deletions projects/start-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions projects/start-sdk/docs/src/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
3 changes: 2 additions & 1 deletion shared-libs/crates/start-core/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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))
Expand Down
25 changes: 23 additions & 2 deletions shared-libs/crates/start-core/src/service/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use crate::{ActionId, PackageId, ReplayId};
pub(super) struct GetActionInput {
id: ActionId,
prefill: Value,
caller: Option<PackageId>,
}
impl Handler<GetActionInput> for ServiceActor {
type Response = Result<Option<ActionInput>, Error>;
Expand All @@ -30,6 +31,7 @@ impl Handler<GetActionInput> for ServiceActor {
GetActionInput {
id: action_id,
prefill,
caller,
}: GetActionInput,
_: &BackgroundJobQueue,
) -> Self::Response {
Expand All @@ -38,7 +40,7 @@ impl Handler<GetActionInput> for ServiceActor {
.execute::<Option<ActionInput>>(
id,
ProcedureName::GetActionInput(action_id),
json!({ "prefill": prefill }),
json!({ "prefill": prefill, "caller": caller }),
Some(Duration::from_secs(30)),
)
.await
Expand All @@ -47,11 +49,14 @@ impl Handler<GetActionInput> 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<PackageId>,
) -> Result<Option<ActionInput>, Error> {
if !self
.seed
Expand All @@ -77,6 +82,7 @@ impl Service {
GetActionInput {
id: action_id,
prefill,
caller,
},
)
.await?
Expand Down Expand Up @@ -150,6 +156,7 @@ pub fn update_tasks(
pub(super) struct RunAction {
action_id: ActionId,
input: Value,
caller: Option<PackageId>,
}
impl Handler<RunAction> for ServiceActor {
type Response = Result<Option<ActionResult>, Error>;
Expand All @@ -162,6 +169,7 @@ impl Handler<RunAction> for ServiceActor {
RunAction {
ref action_id,
input,
caller,
}: RunAction,
_: &BackgroundJobQueue,
) -> Self::Response {
Expand Down Expand Up @@ -214,6 +222,7 @@ impl Handler<RunAction> for ServiceActor {
ProcedureName::RunAction(action_id.clone()),
json!({
"input": input,
"caller": caller,
}),
Some(Duration::from_secs(120)),
)
Expand All @@ -240,12 +249,24 @@ impl Handler<RunAction> 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<PackageId>,
) -> Result<Option<ActionResult>, Error> {
self.actor.send(id, RunAction { action_id, input }).await?
self.actor
.send(
id,
RunAction {
action_id,
input,
caller,
},
)
.await?
}
}
19 changes: 14 additions & 5 deletions shared-libs/crates/start-core/src/service/effects/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ async fn get_action_input(
) -> Result<Option<ActionInput>, 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
Expand All @@ -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
}
}
Expand Down Expand Up @@ -188,6 +189,7 @@ async fn run_action(
}: RunActionParams,
) -> Result<Option<ActionResult>, Error> {
let context = context.deref()?;
let caller = Some(context.seed.id.clone());

let package_id = package_id.as_ref().unwrap_or(&context.seed.id);

Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion shared-libs/crates/start-core/src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 20 additions & 1 deletion shared-libs/ts-modules/start-core/lib/actions/setupActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@ import { InputSpec } from './input/builder'
import { ExtractInputSpecType } from './input/builder/inputSpec'

type MaybeInputSpec<Type> = {} extends Type ? null : InputSpec<Type>
/**
* 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<A extends Record<string, any>> = (options: {
effects: T.Effects
input: A
spec: T.inputSpecTypes.InputSpec
caller: ActionCaller
}) => Promise<(T.ActionResult & { version: '1' }) | null | void | undefined>
export type GetInput<A extends Record<string, any>> = (options: {
effects: T.Effects
prefill: T.DeepPartial<A> | null
caller: ActionCaller
}) => Promise<null | void | undefined | T.DeepPartial<A>>

export type MaybeFn<T, Opts = { effects: T.Effects }> =
Expand Down Expand Up @@ -56,10 +64,12 @@ export interface Action<
getInput(options: {
effects: T.Effects
prefill: T.DeepPartial<Type> | null
caller?: ActionCaller
}): Promise<T.ActionInput>
run(options: {
effects: T.Effects
input: Type
caller?: ActionCaller
}): Promise<T.ActionResult | null>
}

Expand All @@ -80,6 +90,7 @@ class ActionImpl<
{
effects: T.Effects
prefill: unknown | null
caller: ActionCaller
}
>,
private readonly getInputFn: GetInput<Type>,
Expand All @@ -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<Type> | null
caller?: ActionCaller
}): Promise<T.ActionInput> {
const options = { ...rest, caller }
let spec = {}
if (this.inputSpec) {
const inputSpec = await callMaybeFn(this.inputSpec, options)
Expand All @@ -124,6 +140,7 @@ class ActionImpl<
async run(options: {
effects: T.Effects
input: Type
caller?: ActionCaller
}): Promise<T.ActionResult | null> {
let spec = {}
if (this.inputSpec) {
Expand All @@ -141,6 +158,7 @@ class ActionImpl<
effects: options.effects,
input: options.input,
spec,
caller: options.caller ?? null,
})) ?? null
)
}
Expand All @@ -158,6 +176,7 @@ export const Action = {
{
effects: T.Effects
prefill: unknown | null
caller: ActionCaller
}
>,
getInput: GetInput<ExtractInputSpecType<InputSpecType>>,
Expand Down
Loading
Loading