[wip] single runtime handling

This commit is contained in:
2026-03-10 09:30:57 -05:00
parent 9e7e35cbe3
commit 5b45b17fa6
43 changed files with 2905 additions and 110 deletions

View File

@@ -295,6 +295,10 @@ pub struct SecurityConfig {
/// Enable authentication
#[serde(default = "default_true")]
pub enable_auth: bool,
/// Allow unauthenticated self-service user registration
#[serde(default)]
pub allow_self_registration: bool,
}
fn default_jwt_access_expiration() -> u64 {
@@ -676,6 +680,7 @@ impl Default for SecurityConfig {
jwt_refresh_expiration: default_jwt_refresh_expiration(),
encryption_key: None,
enable_auth: true,
allow_self_registration: false,
}
}
}
@@ -924,6 +929,7 @@ mod tests {
jwt_refresh_expiration: 604800,
encryption_key: Some("a".repeat(32)),
enable_auth: true,
allow_self_registration: false,
},
worker: None,
sensor: None,

View File

@@ -15,6 +15,7 @@ pub mod models;
pub mod mq;
pub mod pack_environment;
pub mod pack_registry;
pub mod rbac;
pub mod repositories;
pub mod runtime_detection;
pub mod schema;

View File

@@ -430,6 +430,10 @@ pub mod runtime {
#[serde(default)]
pub interpreter: InterpreterConfig,
/// Strategy for inline code execution.
#[serde(default)]
pub inline_execution: InlineExecutionConfig,
/// Optional isolated environment configuration (venv, node_modules, etc.)
#[serde(default)]
pub environment: Option<EnvironmentConfig>,
@@ -449,6 +453,33 @@ pub mod runtime {
pub env_vars: HashMap<String, String>,
}
/// Controls how inline code is materialized before execution.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InlineExecutionConfig {
/// Whether inline code is passed directly to the interpreter or first
/// written to a temporary file.
#[serde(default)]
pub strategy: InlineExecutionStrategy,
/// Optional extension for temporary inline files (e.g. ".sh").
#[serde(default)]
pub extension: Option<String>,
/// When true, inline wrapper files export the merged input map as shell
/// environment variables (`PARAM_*` and bare names) before executing the
/// script body.
#[serde(default)]
pub inject_shell_helpers: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum InlineExecutionStrategy {
#[default]
Direct,
TempFile,
}
/// Describes the interpreter binary and how it invokes action scripts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterpreterConfig {

View File

@@ -481,9 +481,8 @@ pub struct PackRegisteredPayload {
/// Payload for ExecutionCancelRequested message
///
/// Sent by the API to the worker that is running a specific execution,
/// instructing it to gracefully terminate the process (SIGINT, then SIGTERM
/// after a grace period).
/// Sent by the API or executor to the worker that is running a specific
/// execution, instructing it to terminate the process promptly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionCancelRequestedPayload {
/// Execution ID to cancel

View File

@@ -1,14 +1,15 @@
//! Pack Component Loader
//!
//! Reads runtime, action, trigger, and sensor YAML definitions from a pack directory
//! Reads permission set, runtime, action, trigger, and sensor YAML definitions from a pack directory
//! and registers them in the database. This is the Rust-native equivalent of
//! the Python `load_core_pack.py` script used during init-packs.
//!
//! Components are loaded in dependency order:
//! 1. Runtimes (no dependencies)
//! 2. Triggers (no dependencies)
//! 3. Actions (depend on runtime; workflow actions also create workflow_definition records)
//! 4. Sensors (depend on triggers and runtime)
//! 1. Permission sets (no dependencies)
//! 2. Runtimes (no dependencies)
//! 3. Triggers (no dependencies)
//! 4. Actions (depend on runtime; workflow actions also create workflow_definition records)
//! 5. Sensors (depend on triggers and runtime)
//!
//! All loaders use **upsert** semantics: if an entity with the same ref already
//! exists it is updated in place (preserving its database ID); otherwise a new
@@ -38,6 +39,9 @@ use tracing::{debug, info, warn};
use crate::error::{Error, Result};
use crate::models::Id;
use crate::repositories::action::{ActionRepository, UpdateActionInput};
use crate::repositories::identity::{
CreatePermissionSetInput, PermissionSetRepository, UpdatePermissionSetInput,
};
use crate::repositories::runtime::{CreateRuntimeInput, RuntimeRepository, UpdateRuntimeInput};
use crate::repositories::runtime_version::{
CreateRuntimeVersionInput, RuntimeVersionRepository, UpdateRuntimeVersionInput,
@@ -56,6 +60,12 @@ use crate::workflow::parser::parse_workflow_yaml;
/// Result of loading pack components into the database.
#[derive(Debug, Default)]
pub struct PackLoadResult {
/// Number of permission sets created
pub permission_sets_loaded: usize,
/// Number of permission sets updated
pub permission_sets_updated: usize,
/// Number of permission sets skipped
pub permission_sets_skipped: usize,
/// Number of runtimes created
pub runtimes_loaded: usize,
/// Number of runtimes updated (already existed)
@@ -88,15 +98,27 @@ pub struct PackLoadResult {
impl PackLoadResult {
pub fn total_loaded(&self) -> usize {
self.runtimes_loaded + self.triggers_loaded + self.actions_loaded + self.sensors_loaded
self.permission_sets_loaded
+ self.runtimes_loaded
+ self.triggers_loaded
+ self.actions_loaded
+ self.sensors_loaded
}
pub fn total_skipped(&self) -> usize {
self.runtimes_skipped + self.triggers_skipped + self.actions_skipped + self.sensors_skipped
self.permission_sets_skipped
+ self.runtimes_skipped
+ self.triggers_skipped
+ self.actions_skipped
+ self.sensors_skipped
}
pub fn total_updated(&self) -> usize {
self.runtimes_updated + self.triggers_updated + self.actions_updated + self.sensors_updated
self.permission_sets_updated
+ self.runtimes_updated
+ self.triggers_updated
+ self.actions_updated
+ self.sensors_updated
}
}
@@ -132,22 +154,26 @@ impl<'a> PackComponentLoader<'a> {
pack_dir.display()
);
// 1. Load runtimes first (no dependencies)
// 1. Load permission sets first (no dependencies)
let permission_set_refs = self.load_permission_sets(pack_dir, &mut result).await?;
// 2. Load runtimes (no dependencies)
let runtime_refs = self.load_runtimes(pack_dir, &mut result).await?;
// 2. Load triggers (no dependencies)
// 3. Load triggers (no dependencies)
let (trigger_ids, trigger_refs) = self.load_triggers(pack_dir, &mut result).await?;
// 3. Load actions (depend on runtime)
// 4. Load actions (depend on runtime)
let action_refs = self.load_actions(pack_dir, &mut result).await?;
// 4. Load sensors (depend on triggers and runtime)
// 5. Load sensors (depend on triggers and runtime)
let sensor_refs = self
.load_sensors(pack_dir, &trigger_ids, &mut result)
.await?;
// 5. Clean up entities that are no longer in the pack's YAML files
// 6. Clean up entities that are no longer in the pack's YAML files
self.cleanup_removed_entities(
&permission_set_refs,
&runtime_refs,
&trigger_refs,
&action_refs,
@@ -169,6 +195,146 @@ impl<'a> PackComponentLoader<'a> {
Ok(result)
}
/// Load permission set definitions from `pack_dir/permission_sets/*.yaml`.
///
/// Permission sets are pack-scoped authorization metadata. Their `grants`
/// payload is stored verbatim and interpreted by the API authorization
/// layer at request time.
async fn load_permission_sets(
&self,
pack_dir: &Path,
result: &mut PackLoadResult,
) -> Result<Vec<String>> {
let permission_sets_dir = pack_dir.join("permission_sets");
let mut loaded_refs = Vec::new();
if !permission_sets_dir.exists() {
info!(
"No permission_sets directory found for pack '{}'",
self.pack_ref
);
return Ok(loaded_refs);
}
let yaml_files = read_yaml_files(&permission_sets_dir)?;
info!(
"Found {} permission set definition(s) for pack '{}'",
yaml_files.len(),
self.pack_ref
);
for (filename, content) in &yaml_files {
let data: serde_yaml_ng::Value = serde_yaml_ng::from_str(content).map_err(|e| {
Error::validation(format!(
"Failed to parse permission set YAML {}: {}",
filename, e
))
})?;
let permission_set_ref = match data.get("ref").and_then(|v| v.as_str()) {
Some(r) => r.to_string(),
None => {
let msg = format!(
"Permission set YAML {} missing 'ref' field, skipping",
filename
);
warn!("{}", msg);
result.warnings.push(msg);
result.permission_sets_skipped += 1;
continue;
}
};
let label = data
.get("label")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = data
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let grants = data
.get("grants")
.and_then(|v| serde_json::to_value(v).ok())
.unwrap_or_else(|| serde_json::json!([]));
if !grants.is_array() {
let msg = format!(
"Permission set '{}' has non-array 'grants', skipping",
permission_set_ref
);
warn!("{}", msg);
result.warnings.push(msg);
result.permission_sets_skipped += 1;
continue;
}
if let Some(existing) =
PermissionSetRepository::find_by_ref(self.pool, &permission_set_ref).await?
{
let update_input = UpdatePermissionSetInput {
label,
description,
grants: Some(grants),
};
match PermissionSetRepository::update(self.pool, existing.id, update_input).await {
Ok(_) => {
info!(
"Updated permission set '{}' (ID: {})",
permission_set_ref, existing.id
);
result.permission_sets_updated += 1;
}
Err(e) => {
let msg = format!(
"Failed to update permission set '{}': {}",
permission_set_ref, e
);
warn!("{}", msg);
result.warnings.push(msg);
result.permission_sets_skipped += 1;
}
}
loaded_refs.push(permission_set_ref);
continue;
}
let input = CreatePermissionSetInput {
r#ref: permission_set_ref.clone(),
pack: Some(self.pack_id),
pack_ref: Some(self.pack_ref.clone()),
label,
description,
grants,
};
match PermissionSetRepository::create(self.pool, input).await {
Ok(permission_set) => {
info!(
"Created permission set '{}' (ID: {})",
permission_set_ref, permission_set.id
);
result.permission_sets_loaded += 1;
loaded_refs.push(permission_set_ref);
}
Err(e) => {
let msg = format!(
"Failed to create permission set '{}': {}",
permission_set_ref, e
);
warn!("{}", msg);
result.warnings.push(msg);
result.permission_sets_skipped += 1;
}
}
}
Ok(loaded_refs)
}
/// Load runtime definitions from `pack_dir/runtimes/*.yaml`.
///
/// Runtimes define how actions and sensors are executed (interpreter,
@@ -1308,12 +1474,37 @@ impl<'a> PackComponentLoader<'a> {
/// removed.
async fn cleanup_removed_entities(
&self,
permission_set_refs: &[String],
runtime_refs: &[String],
trigger_refs: &[String],
action_refs: &[String],
sensor_refs: &[String],
result: &mut PackLoadResult,
) {
match PermissionSetRepository::delete_by_pack_excluding(
self.pool,
self.pack_id,
permission_set_refs,
)
.await
{
Ok(count) => {
if count > 0 {
info!(
"Removed {} stale permission set(s) from pack '{}'",
count, self.pack_ref
);
result.removed += count as usize;
}
}
Err(e) => {
warn!(
"Failed to clean up stale permission sets for pack '{}': {}",
self.pack_ref, e
);
}
}
// Clean up sensors first (they depend on triggers/runtimes)
match SensorRepository::delete_by_pack_excluding(self.pool, self.pack_id, sensor_refs).await
{

292
crates/common/src/rbac.rs Normal file
View File

@@ -0,0 +1,292 @@
//! Role-based access control (RBAC) model and evaluator.
//!
//! Permission sets store `grants` as a JSON array of [`Grant`].
//! This module defines the canonical grant schema and matching logic.
use crate::models::{ArtifactVisibility, Id, OwnerType};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Resource {
Packs,
Actions,
Rules,
Triggers,
Executions,
Events,
Enforcements,
Inquiries,
Keys,
Artifacts,
Workflows,
Webhooks,
Analytics,
History,
Identities,
Permissions,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Action {
Read,
Create,
Update,
Delete,
Execute,
Cancel,
Respond,
Manage,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OwnerConstraint {
#[serde(rename = "self")]
SelfOnly,
Any,
None,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionScopeConstraint {
#[serde(rename = "self")]
SelfOnly,
Descendants,
Any,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct GrantConstraints {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pack_refs: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<OwnerConstraint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_types: Option<Vec<OwnerType>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<Vec<ArtifactVisibility>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_scope: Option<ExecutionScopeConstraint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refs: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ids: Option<Vec<Id>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encrypted: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attributes: Option<HashMap<String, JsonValue>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Grant {
pub resource: Resource,
pub actions: Vec<Action>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constraints: Option<GrantConstraints>,
}
#[derive(Debug, Clone)]
pub struct AuthorizationContext {
pub identity_id: Id,
pub identity_attributes: HashMap<String, JsonValue>,
pub target_id: Option<Id>,
pub target_ref: Option<String>,
pub pack_ref: Option<String>,
pub owner_identity_id: Option<Id>,
pub owner_type: Option<OwnerType>,
pub visibility: Option<ArtifactVisibility>,
pub encrypted: Option<bool>,
pub execution_owner_identity_id: Option<Id>,
pub execution_ancestor_identity_ids: Vec<Id>,
}
impl AuthorizationContext {
pub fn new(identity_id: Id) -> Self {
Self {
identity_id,
identity_attributes: HashMap::new(),
target_id: None,
target_ref: None,
pack_ref: None,
owner_identity_id: None,
owner_type: None,
visibility: None,
encrypted: None,
execution_owner_identity_id: None,
execution_ancestor_identity_ids: Vec::new(),
}
}
}
impl Grant {
pub fn allows(&self, resource: Resource, action: Action, ctx: &AuthorizationContext) -> bool {
self.resource == resource && self.actions.contains(&action) && self.constraints_match(ctx)
}
fn constraints_match(&self, ctx: &AuthorizationContext) -> bool {
let Some(constraints) = &self.constraints else {
return true;
};
if let Some(pack_refs) = &constraints.pack_refs {
let Some(pack_ref) = &ctx.pack_ref else {
return false;
};
if !pack_refs.contains(pack_ref) {
return false;
}
}
if let Some(owner) = constraints.owner {
let owner_match = match owner {
OwnerConstraint::SelfOnly => ctx.owner_identity_id == Some(ctx.identity_id),
OwnerConstraint::Any => true,
OwnerConstraint::None => ctx.owner_identity_id.is_none(),
};
if !owner_match {
return false;
}
}
if let Some(owner_types) = &constraints.owner_types {
let Some(owner_type) = ctx.owner_type else {
return false;
};
if !owner_types.contains(&owner_type) {
return false;
}
}
if let Some(visibility) = &constraints.visibility {
let Some(target_visibility) = ctx.visibility else {
return false;
};
if !visibility.contains(&target_visibility) {
return false;
}
}
if let Some(execution_scope) = constraints.execution_scope {
let execution_match = match execution_scope {
ExecutionScopeConstraint::SelfOnly => {
ctx.execution_owner_identity_id == Some(ctx.identity_id)
}
ExecutionScopeConstraint::Descendants => {
ctx.execution_owner_identity_id == Some(ctx.identity_id)
|| ctx
.execution_ancestor_identity_ids
.contains(&ctx.identity_id)
}
ExecutionScopeConstraint::Any => true,
};
if !execution_match {
return false;
}
}
if let Some(refs) = &constraints.refs {
let Some(target_ref) = &ctx.target_ref else {
return false;
};
if !refs.contains(target_ref) {
return false;
}
}
if let Some(ids) = &constraints.ids {
let Some(target_id) = ctx.target_id else {
return false;
};
if !ids.contains(&target_id) {
return false;
}
}
if let Some(encrypted) = constraints.encrypted {
let Some(target_encrypted) = ctx.encrypted else {
return false;
};
if encrypted != target_encrypted {
return false;
}
}
if let Some(attributes) = &constraints.attributes {
for (key, expected_value) in attributes {
let Some(actual_value) = ctx.identity_attributes.get(key) else {
return false;
};
if actual_value != expected_value {
return false;
}
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn grant_without_constraints_allows() {
let grant = Grant {
resource: Resource::Actions,
actions: vec![Action::Read],
constraints: None,
};
let ctx = AuthorizationContext::new(42);
assert!(grant.allows(Resource::Actions, Action::Read, &ctx));
assert!(!grant.allows(Resource::Actions, Action::Create, &ctx));
}
#[test]
fn key_constraint_owner_type_and_encrypted() {
let grant = Grant {
resource: Resource::Keys,
actions: vec![Action::Read],
constraints: Some(GrantConstraints {
owner_types: Some(vec![OwnerType::System]),
encrypted: Some(false),
..Default::default()
}),
};
let mut ctx = AuthorizationContext::new(1);
ctx.owner_type = Some(OwnerType::System);
ctx.encrypted = Some(false);
assert!(grant.allows(Resource::Keys, Action::Read, &ctx));
ctx.encrypted = Some(true);
assert!(!grant.allows(Resource::Keys, Action::Read, &ctx));
}
#[test]
fn attributes_constraint_requires_exact_value_match() {
let grant = Grant {
resource: Resource::Packs,
actions: vec![Action::Read],
constraints: Some(GrantConstraints {
attributes: Some(HashMap::from([("team".to_string(), json!("platform"))])),
..Default::default()
}),
};
let mut ctx = AuthorizationContext::new(1);
ctx.identity_attributes
.insert("team".to_string(), json!("platform"));
assert!(grant.allows(Resource::Packs, Action::Read, &ctx));
ctx.identity_attributes
.insert("team".to_string(), json!("infra"));
assert!(!grant.allows(Resource::Packs, Action::Read, &ctx));
}
}

View File

@@ -4,7 +4,7 @@ use crate::models::{identity::*, Id, JsonDict};
use crate::Result;
use sqlx::{Executor, Postgres, QueryBuilder};
use super::{Create, Delete, FindById, List, Repository, Update};
use super::{Create, Delete, FindById, FindByRef, List, Repository, Update};
pub struct IdentityRepository;
@@ -200,6 +200,22 @@ impl FindById for PermissionSetRepository {
}
}
#[async_trait::async_trait]
impl FindByRef for PermissionSetRepository {
async fn find_by_ref<'e, E>(executor: E, ref_str: &str) -> Result<Option<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, PermissionSet>(
"SELECT id, ref, pack, pack_ref, label, description, grants, created, updated FROM permission_set WHERE ref = $1"
)
.bind(ref_str)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl List for PermissionSetRepository {
async fn list<'e, E>(executor: E) -> Result<Vec<Self::Entity>>
@@ -287,6 +303,54 @@ impl Delete for PermissionSetRepository {
}
}
impl PermissionSetRepository {
pub async fn find_by_identity<'e, E>(executor: E, identity_id: Id) -> Result<Vec<PermissionSet>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, PermissionSet>(
"SELECT ps.id, ps.ref, ps.pack, ps.pack_ref, ps.label, ps.description, ps.grants, ps.created, ps.updated
FROM permission_set ps
INNER JOIN permission_assignment pa ON pa.permset = ps.id
WHERE pa.identity = $1
ORDER BY ps.ref ASC",
)
.bind(identity_id)
.fetch_all(executor)
.await
.map_err(Into::into)
}
/// Delete permission sets belonging to a pack whose refs are NOT in the given set.
///
/// Used during pack reinstallation to clean up permission sets that were
/// removed from the pack's metadata. Associated permission assignments are
/// cascade-deleted by the FK constraint.
pub async fn delete_by_pack_excluding<'e, E>(
executor: E,
pack_id: Id,
keep_refs: &[String],
) -> Result<u64>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = if keep_refs.is_empty() {
sqlx::query("DELETE FROM permission_set WHERE pack = $1")
.bind(pack_id)
.execute(executor)
.await?
} else {
sqlx::query("DELETE FROM permission_set WHERE pack = $1 AND ref != ALL($2)")
.bind(pack_id)
.bind(keep_refs)
.execute(executor)
.await?
};
Ok(result.rows_affected())
}
}
// Permission Assignment Repository
pub struct PermissionAssignmentRepository;