Skip to main content

ordinary_action/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(clippy::all, clippy::pedantic)]
3#![allow(clippy::missing_errors_doc, clippy::cast_sign_loss)]
4
5// Copyright (C) 2026 Ordinary Labs, LLC.
6//
7// SPDX-License-Identifier: AGPL-3.0-only
8
9mod ffi;
10
11use bytes::Bytes;
12use hashbrown::{HashMap, HashSet};
13use http::StatusCode;
14use parking_lot::RwLock;
15use std::path::PathBuf;
16use std::sync::Arc;
17use tracing::instrument;
18
19use ordinary_auth::Auth;
20use ordinary_config::{
21    ActionAccessAuthOps, ActionAccessModelOps, ActionAccessPermission, ActionConfig,
22    ActionFfiVersion, ContentDefinition, ModelConfig,
23};
24use ordinary_integration::Integration;
25use ordinary_storage::{ArtifactKind, CacheRead, Storage};
26use ordinary_types::{Field, Kind};
27
28pub use wasmtime::Engine;
29use wasmtime::Module;
30
31#[derive(Clone, Debug, PartialEq)]
32pub enum ActionResult {
33    Result(Bytes),
34    StatusCode(StatusCode),
35}
36
37#[derive(Clone)]
38pub struct PrivilegedComponents {
39    pub auth: Arc<Auth>,
40    pub app_domains: Arc<Vec<String>>,
41    pub apps_dir: PathBuf,
42}
43
44#[allow(clippy::struct_excessive_bools)]
45#[derive(Clone)]
46pub struct Action {
47    pub idx: u8,
48    pub config: ActionConfig,
49
50    module: Arc<RwLock<Option<Module>>>,
51    engine: Engine,
52
53    auth: Arc<Auth>,
54    storage: Arc<Storage>,
55    integrations: Arc<Vec<Integration>>,
56
57    has_integrations: bool,
58    allowed_integrations: Arc<HashMap<u8, Kind>>,
59    has_actions: bool,
60    allowed_actions: Arc<HashSet<u8>>,
61
62    has_content_defs: bool,
63    allowed_content_defs: Arc<HashSet<u8>>,
64
65    #[allow(clippy::type_complexity)]
66    allowed_model_ops: Arc<HashMap<u8, (Vec<ActionAccessModelOps>, Vec<Field>)>>,
67
68    can_auth_set_token_fields: bool,
69
70    can_model_insert: bool,
71    can_model_update: bool,
72    can_model_delete: bool,
73
74    can_model_get: bool,
75    can_model_query: bool,
76    can_model_search: bool,
77
78    privileged_components: Option<PrivilegedComponents>,
79}
80
81impl Action {
82    #[instrument(
83        skip(
84            src,
85            engine,
86            config,
87            auth,
88            storage,
89            integrations,
90            model_map,
91            content_map,
92            action_configs,
93            privileged_components,
94        ),
95        fields(i, nm),
96        err
97    )]
98    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
99    pub fn new(
100        src: Option<Bytes>,
101        engine: Engine,
102        config: ActionConfig,
103        auth: Arc<Auth>,
104        storage: Arc<Storage>,
105        integrations: Arc<Vec<Integration>>,
106        model_map: &HashMap<String, ModelConfig>,
107        content_map: &HashMap<String, ContentDefinition>,
108        action_configs: &Option<Vec<ActionConfig>>,
109        privileged_components: Option<PrivilegedComponents>,
110    ) -> anyhow::Result<Self> {
111        tracing::Span::current().record("i", config.idx);
112        tracing::Span::current().record("nm", tracing::field::display(&config.name));
113
114        let module = if let Some(src) = src
115            && let Ok(module) = Module::new(&engine, src)
116        {
117            Arc::new(RwLock::new(Some(module)))
118        } else {
119            Arc::new(RwLock::new(None))
120        };
121
122        let mut has_integrations = false;
123        let mut allowed_integrations = HashMap::new();
124
125        let mut has_actions = false;
126        let mut allowed_actions = HashSet::new();
127
128        let mut has_content_defs = false;
129        let mut allowed_content_defs = HashSet::new();
130
131        let mut allowed_model_ops = HashMap::new();
132
133        let mut can_auth_set_token_fields = false;
134
135        let mut can_model_insert = false;
136        let mut can_model_update = false;
137        let mut can_model_delete = false;
138
139        let mut can_model_get = false;
140        let mut can_model_query = false;
141        let mut can_model_search = false;
142
143        for access in &config.access {
144            match access {
145                ActionAccessPermission::Auth { ops } => {
146                    for op in ops {
147                        match op {
148                            ActionAccessAuthOps::SetTokenFields => {
149                                can_auth_set_token_fields = true;
150                            }
151                        }
152                    }
153                }
154                ActionAccessPermission::Integration { name } => {
155                    has_integrations = true;
156
157                    for integration in integrations.iter() {
158                        if integration.config.name == *name {
159                            allowed_integrations
160                                .insert(integration.config.idx, integration.config.send.clone());
161                        }
162                    }
163                }
164                ActionAccessPermission::Model { name, ops } => {
165                    if let Some(model_config) = model_map.get(name)
166                        && model_config.name == *name
167                    {
168                        for op in ops {
169                            match op {
170                                ActionAccessModelOps::Insert => can_model_insert = true,
171                                ActionAccessModelOps::Update => can_model_update = true,
172                                ActionAccessModelOps::Delete => can_model_delete = true,
173
174                                ActionAccessModelOps::Get => can_model_get = true,
175                                ActionAccessModelOps::Query => can_model_query = true,
176                                ActionAccessModelOps::Search => can_model_search = true,
177                            }
178                        }
179
180                        let mut fields = model_config.fields.clone();
181                        fields.push(Field {
182                            idx: 0,
183                            name: "uuid".into(),
184                            kind: Kind::Uuid,
185                            indexed: Some(true),
186                            queryable: None,
187                            searchable: None,
188                            mapping: None,
189                            doc: None,
190                            encrypted: None,
191                            compressed: None,
192                        });
193                        fields.sort_by(|a, b| a.idx.cmp(&b.idx));
194
195                        allowed_model_ops.insert(model_config.idx, (ops.clone(), fields));
196                    }
197                }
198                ActionAccessPermission::Content { name } => {
199                    has_content_defs = true;
200
201                    if let Some(def) = content_map.get(name) {
202                        allowed_content_defs.insert(def.idx);
203                    }
204                }
205                ActionAccessPermission::Action { name } => {
206                    has_actions = true;
207
208                    if let Some(action_configs) = &action_configs {
209                        for action in action_configs {
210                            if action.name == *name {
211                                allowed_actions.insert(action.idx);
212                            }
213                        }
214                    }
215                }
216            }
217        }
218
219        Ok(Self {
220            idx: config.idx,
221            config,
222
223            module,
224            engine,
225
226            auth,
227            storage,
228            integrations,
229
230            has_actions,
231            allowed_actions: Arc::new(allowed_actions),
232
233            has_integrations,
234            allowed_integrations: Arc::new(allowed_integrations),
235
236            has_content_defs,
237            allowed_content_defs: Arc::new(allowed_content_defs),
238
239            allowed_model_ops: Arc::new(allowed_model_ops),
240
241            can_auth_set_token_fields,
242
243            can_model_insert,
244            can_model_update,
245            can_model_delete,
246
247            can_model_get,
248            can_model_query,
249            can_model_search,
250
251            privileged_components,
252        })
253    }
254
255    #[instrument(skip(self, src), err)]
256    pub fn set_wasm(&self, src: &[u8]) -> anyhow::Result<()> {
257        let storage_span = tracing::info_span!("storage");
258        let span = storage_span.in_scope(|| tracing::info_span!("artifact"));
259
260        span.in_scope(|| {
261            self.storage
262                .artifact
263                .put(self.idx, ArtifactKind::Action, src)
264        })?;
265
266        let mut lock = self.module.write();
267        let module = Module::new(&self.engine, src)?;
268
269        *lock = Some(module);
270        drop(lock);
271
272        let span = storage_span.in_scope(|| tracing::info_span!("cache"));
273
274        span.in_scope(|| {
275            self.storage
276                .cache
277                .artifact_evict(CacheRead::Action, self.idx)
278        })?;
279
280        Ok(())
281    }
282
283    #[allow(clippy::missing_panics_doc, clippy::too_many_lines)]
284    #[instrument(skip(self, args, actions), err)]
285    pub fn call(&self, args: &[u8], actions: &Arc<Vec<Action>>) -> anyhow::Result<ActionResult> {
286        match self.config.ffi.version {
287            ActionFfiVersion::V1 => ffi::v1::call(self, args, actions),
288        }
289    }
290}