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, 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(skip_all, fields(i, nm), err)]
83    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
84    pub fn new(
85        src: Option<Bytes>,
86        engine: Engine,
87        config: ActionConfig,
88        auth: Arc<Auth>,
89        storage: Arc<Storage>,
90        integrations: Arc<Vec<Integration>>,
91        model_map: &HashMap<String, ModelConfig>,
92        content_map: &HashMap<String, ContentDefinition>,
93        action_configs: &Option<Vec<ActionConfig>>,
94        privileged_components: Option<PrivilegedComponents>,
95    ) -> anyhow::Result<Self> {
96        tracing::Span::current().record("i", config.idx);
97        tracing::Span::current().record("nm", tracing::field::display(&config.name));
98
99        let module = if let Some(src) = src
100            && let Ok(module) = Module::new(&engine, src)
101        {
102            Arc::new(RwLock::new(Some(module)))
103        } else {
104            Arc::new(RwLock::new(None))
105        };
106
107        let mut has_integrations = false;
108        let mut allowed_integrations = HashMap::new();
109
110        let mut has_actions = false;
111        let mut allowed_actions = HashSet::new();
112
113        let mut has_content_defs = false;
114        let mut allowed_content_defs = HashSet::new();
115
116        let mut allowed_model_ops = HashMap::new();
117
118        let mut can_auth_set_token_fields = false;
119
120        let mut can_model_insert = false;
121        let mut can_model_update = false;
122        let mut can_model_delete = false;
123
124        let mut can_model_get = false;
125        let mut can_model_query = false;
126        let mut can_model_search = false;
127
128        for access in &config.access {
129            match access {
130                ActionAccessPermission::Auth { ops } => {
131                    for op in ops {
132                        match op {
133                            ActionAccessAuthOps::SetTokenFields => {
134                                can_auth_set_token_fields = true;
135                            }
136                        }
137                    }
138                }
139                ActionAccessPermission::Integration { name } => {
140                    has_integrations = true;
141
142                    for integration in integrations.iter() {
143                        if integration.config.name == *name {
144                            allowed_integrations
145                                .insert(integration.config.idx, integration.config.send.clone());
146                        }
147                    }
148                }
149                ActionAccessPermission::Model { name, ops } => {
150                    if let Some(model_config) = model_map.get(name)
151                        && model_config.name == *name
152                    {
153                        for op in ops {
154                            match op {
155                                ActionAccessModelOps::Insert => can_model_insert = true,
156                                ActionAccessModelOps::Update => can_model_update = true,
157                                ActionAccessModelOps::Delete => can_model_delete = true,
158
159                                ActionAccessModelOps::Get => can_model_get = true,
160                                ActionAccessModelOps::Query => can_model_query = true,
161                                ActionAccessModelOps::Search => can_model_search = true,
162                            }
163                        }
164
165                        let mut fields = model_config.fields.clone();
166                        fields.push(Field {
167                            idx: 0,
168                            name: "uuid".into(),
169                            kind: Kind::Uuid,
170                            indexed: Some(true),
171                            queryable: None,
172                            searchable: None,
173                            mapping: None,
174                            doc: None,
175                            encrypted: None,
176                            compressed: None,
177                        });
178                        fields.sort_by_key(|a| a.idx);
179
180                        allowed_model_ops.insert(model_config.idx, (ops.clone(), fields));
181                    }
182                }
183                ActionAccessPermission::Content { name } => {
184                    has_content_defs = true;
185
186                    if let Some(def) = content_map.get(name) {
187                        allowed_content_defs.insert(def.idx);
188                    }
189                }
190                ActionAccessPermission::Action { name } => {
191                    has_actions = true;
192
193                    if let Some(action_configs) = &action_configs {
194                        for action in action_configs {
195                            if action.name == *name {
196                                allowed_actions.insert(action.idx);
197                            }
198                        }
199                    }
200                }
201            }
202        }
203
204        Ok(Self {
205            idx: config.idx,
206            config,
207
208            module,
209            engine,
210
211            auth,
212            storage,
213            integrations,
214
215            has_actions,
216            allowed_actions: Arc::new(allowed_actions),
217
218            has_integrations,
219            allowed_integrations: Arc::new(allowed_integrations),
220
221            has_content_defs,
222            allowed_content_defs: Arc::new(allowed_content_defs),
223
224            allowed_model_ops: Arc::new(allowed_model_ops),
225
226            can_auth_set_token_fields,
227
228            can_model_insert,
229            can_model_update,
230            can_model_delete,
231
232            can_model_get,
233            can_model_query,
234            can_model_search,
235
236            privileged_components,
237        })
238    }
239
240    #[instrument(skip_all, err)]
241    pub async fn set_wasm(&self, src: &[u8]) -> anyhow::Result<()> {
242        let storage_span = tracing::info_span!("storage");
243        let span = storage_span.in_scope(|| tracing::info_span!("artifact"));
244
245        span.in_scope(|| {
246            self.storage
247                .artifact
248                .put(self.idx, ArtifactKind::Action, src)
249        })?;
250
251        {
252            let mut lock = self.module.write();
253            let module = Module::new(&self.engine, src)?;
254
255            *lock = Some(module);
256        }
257
258        let span = storage_span.in_scope(|| tracing::info_span!("cache"));
259
260        async {
261            self.storage
262                .cache
263                .artifact_evict(CacheRead::Action, self.idx)
264                .await
265        }
266        .instrument(span)
267        .await?;
268
269        Ok(())
270    }
271
272    #[allow(clippy::missing_panics_doc, clippy::too_many_lines)]
273    #[instrument(skip_all, err)]
274    pub fn call(&self, args: &[u8], actions: &Arc<Vec<Action>>) -> anyhow::Result<ActionResult> {
275        match self.config.ffi.version {
276            ActionFfiVersion::V1 => ffi::v1::call(self, args, actions),
277        }
278    }
279}