first pass at access control setup

This commit is contained in:
2026-03-24 14:45:07 -05:00
parent af5175b96a
commit 2ebb03b868
105 changed files with 6163 additions and 1416 deletions

0
.codex_write_test Normal file
View File

1
.gitignore vendored
View File

@@ -78,4 +78,5 @@ docker-compose.override.yml
*.pid *.pid
packs.examples/ packs.examples/
packs.external/
codex/ codex/

View File

@@ -3,7 +3,10 @@
use attune_common::{ use attune_common::{
config::LdapConfig, config::LdapConfig,
repositories::{ repositories::{
identity::{CreateIdentityInput, IdentityRepository, UpdateIdentityInput}, identity::{
CreateIdentityInput, IdentityRepository, IdentityRoleAssignmentRepository,
UpdateIdentityInput,
},
Create, Update, Create, Update,
}, },
}; };
@@ -63,6 +66,11 @@ pub async fn authenticate(
// Upsert identity in DB and issue JWT tokens // Upsert identity in DB and issue JWT tokens
let identity = upsert_identity(state, &claims).await?; let identity = upsert_identity(state, &claims).await?;
if identity.frozen {
return Err(ApiError::Forbidden(
"Identity is frozen and cannot authenticate".to_string(),
));
}
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?; let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?; let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
@@ -351,10 +359,13 @@ async fn upsert_identity(
display_name, display_name,
password_hash: None, password_hash: None,
attributes: Some(attributes), attributes: Some(attributes),
frozen: None,
}; };
IdentityRepository::update(&state.db, identity.id, updated) let identity = IdentityRepository::update(&state.db, identity.id, updated)
.await .await
.map_err(Into::into) .map_err(ApiError::from)?;
sync_roles(&state.db, identity.id, "ldap", &claims.groups).await?;
Ok(identity)
} }
None => { None => {
// Avoid login collisions // Avoid login collisions
@@ -363,7 +374,7 @@ async fn upsert_identity(
None => desired_login, None => desired_login,
}; };
IdentityRepository::create( let identity = IdentityRepository::create(
&state.db, &state.db,
CreateIdentityInput { CreateIdentityInput {
login, login,
@@ -372,11 +383,24 @@ async fn upsert_identity(
attributes, attributes,
}, },
) )
.await
.map_err(ApiError::from)?;
sync_roles(&state.db, identity.id, "ldap", &claims.groups).await?;
Ok(identity)
}
}
}
async fn sync_roles(
db: &sqlx::PgPool,
identity_id: i64,
source: &str,
roles: &[String],
) -> Result<(), ApiError> {
IdentityRoleAssignmentRepository::replace_managed_roles(db, identity_id, source, roles)
.await .await
.map_err(Into::into) .map_err(Into::into)
} }
}
}
/// Derive the login name from LDAP claims. /// Derive the login name from LDAP claims.
fn derive_login(claims: &LdapUserClaims) -> String { fn derive_login(claims: &LdapUserClaims) -> String {

View File

@@ -3,7 +3,10 @@
use attune_common::{ use attune_common::{
config::OidcConfig, config::OidcConfig,
repositories::{ repositories::{
identity::{CreateIdentityInput, IdentityRepository, UpdateIdentityInput}, identity::{
CreateIdentityInput, IdentityRepository, IdentityRoleAssignmentRepository,
UpdateIdentityInput,
},
Create, Update, Create, Update,
}, },
}; };
@@ -282,6 +285,11 @@ pub async fn handle_callback(
} }
let identity = upsert_identity(state, &oidc_claims).await?; let identity = upsert_identity(state, &oidc_claims).await?;
if identity.frozen {
return Err(ApiError::Forbidden(
"Identity is frozen and cannot authenticate".to_string(),
));
}
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?; let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?; let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
@@ -511,10 +519,13 @@ async fn upsert_identity(
display_name, display_name,
password_hash: None, password_hash: None,
attributes: Some(attributes.clone()), attributes: Some(attributes.clone()),
frozen: None,
}; };
IdentityRepository::update(&state.db, identity.id, updated) let identity = IdentityRepository::update(&state.db, identity.id, updated)
.await .await
.map_err(Into::into) .map_err(ApiError::from)?;
sync_roles(&state.db, identity.id, "oidc", &oidc_claims.groups).await?;
Ok(identity)
} }
None => { None => {
let login = match IdentityRepository::find_by_login(&state.db, &desired_login).await? { let login = match IdentityRepository::find_by_login(&state.db, &desired_login).await? {
@@ -522,7 +533,7 @@ async fn upsert_identity(
None => desired_login, None => desired_login,
}; };
IdentityRepository::create( let identity = IdentityRepository::create(
&state.db, &state.db,
CreateIdentityInput { CreateIdentityInput {
login, login,
@@ -531,11 +542,24 @@ async fn upsert_identity(
attributes, attributes,
}, },
) )
.await
.map_err(ApiError::from)?;
sync_roles(&state.db, identity.id, "oidc", &oidc_claims.groups).await?;
Ok(identity)
}
}
}
async fn sync_roles(
db: &sqlx::PgPool,
identity_id: i64,
source: &str,
roles: &[String],
) -> Result<(), ApiError> {
IdentityRoleAssignmentRepository::replace_managed_roles(db, identity_id, source, roles)
.await .await
.map_err(Into::into) .map_err(Into::into)
} }
}
}
fn derive_login(oidc_claims: &OidcIdentityClaims) -> String { fn derive_login(oidc_claims: &OidcIdentityClaims) -> String {
oidc_claims oidc_claims

View File

@@ -10,7 +10,7 @@ use crate::{
use attune_common::{ use attune_common::{
rbac::{Action, AuthorizationContext, Grant, Resource}, rbac::{Action, AuthorizationContext, Grant, Resource},
repositories::{ repositories::{
identity::{IdentityRepository, PermissionSetRepository}, identity::{IdentityRepository, IdentityRoleAssignmentRepository, PermissionSetRepository},
FindById, FindById,
}, },
}; };
@@ -95,8 +95,16 @@ impl AuthorizationService {
} }
async fn load_effective_grants(&self, identity_id: i64) -> Result<Vec<Grant>, ApiError> { async fn load_effective_grants(&self, identity_id: i64) -> Result<Vec<Grant>, ApiError> {
let permission_sets = let mut permission_sets =
PermissionSetRepository::find_by_identity(&self.db, identity_id).await?; PermissionSetRepository::find_by_identity(&self.db, identity_id).await?;
let roles =
IdentityRoleAssignmentRepository::find_role_names_by_identity(&self.db, identity_id)
.await?;
let role_permission_sets = PermissionSetRepository::find_by_roles(&self.db, &roles).await?;
permission_sets.extend(role_permission_sets);
let mut seen_permission_sets = std::collections::HashSet::new();
permission_sets.retain(|permission_set| seen_permission_sets.insert(permission_set.id));
let mut grants = Vec::new(); let mut grants = Vec::new();
for permission_set in permission_sets { for permission_set in permission_sets {
@@ -126,10 +134,6 @@ fn resource_name(resource: Resource) -> &'static str {
Resource::Inquiries => "inquiries", Resource::Inquiries => "inquiries",
Resource::Keys => "keys", Resource::Keys => "keys",
Resource::Artifacts => "artifacts", Resource::Artifacts => "artifacts",
Resource::Workflows => "workflows",
Resource::Webhooks => "webhooks",
Resource::Analytics => "analytics",
Resource::History => "history",
Resource::Identities => "identities", Resource::Identities => "identities",
Resource::Permissions => "permissions", Resource::Permissions => "permissions",
} }
@@ -145,5 +149,6 @@ fn action_name(action: Action) -> &'static str {
Action::Cancel => "cancel", Action::Cancel => "cancel",
Action::Respond => "respond", Action::Respond => "respond",
Action::Manage => "manage", Action::Manage => "manage",
Action::Decrypt => "decrypt",
} }
} }

View File

@@ -25,9 +25,8 @@ pub struct CreateActionRequest {
pub label: String, pub label: String,
/// Action description /// Action description
#[validate(length(min = 1))]
#[schema(example = "Posts a message to a Slack channel")] #[schema(example = "Posts a message to a Slack channel")]
pub description: String, pub description: Option<String>,
/// Entry point for action execution (e.g., path to script, function name) /// Entry point for action execution (e.g., path to script, function name)
#[validate(length(min = 1, max = 1024))] #[validate(length(min = 1, max = 1024))]
@@ -63,7 +62,6 @@ pub struct UpdateActionRequest {
pub label: Option<String>, pub label: Option<String>,
/// Action description /// Action description
#[validate(length(min = 1))]
#[schema(example = "Posts a message to a Slack channel with enhanced features")] #[schema(example = "Posts a message to a Slack channel with enhanced features")]
pub description: Option<String>, pub description: Option<String>,
@@ -121,7 +119,7 @@ pub struct ActionResponse {
/// Action description /// Action description
#[schema(example = "Posts a message to a Slack channel")] #[schema(example = "Posts a message to a Slack channel")]
pub description: String, pub description: Option<String>,
/// Entry point /// Entry point
#[schema(example = "/actions/slack/post_message.py")] #[schema(example = "/actions/slack/post_message.py")]
@@ -183,7 +181,7 @@ pub struct ActionSummary {
/// Action description /// Action description
#[schema(example = "Posts a message to a Slack channel")] #[schema(example = "Posts a message to a Slack channel")]
pub description: String, pub description: Option<String>,
/// Entry point /// Entry point
#[schema(example = "/actions/slack/post_message.py")] #[schema(example = "/actions/slack/post_message.py")]
@@ -321,7 +319,7 @@ mod tests {
r#ref: "".to_string(), // Invalid: empty r#ref: "".to_string(), // Invalid: empty
pack_ref: "test-pack".to_string(), pack_ref: "test-pack".to_string(),
label: "Test Action".to_string(), label: "Test Action".to_string(),
description: "Test description".to_string(), description: Some("Test description".to_string()),
entrypoint: "/actions/test.py".to_string(), entrypoint: "/actions/test.py".to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,
@@ -338,7 +336,7 @@ mod tests {
r#ref: "test.action".to_string(), r#ref: "test.action".to_string(),
pack_ref: "test-pack".to_string(), pack_ref: "test-pack".to_string(),
label: "Test Action".to_string(), label: "Test Action".to_string(),
description: "Test description".to_string(), description: Some("Test description".to_string()),
entrypoint: "/actions/test.py".to_string(), entrypoint: "/actions/test.py".to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,

View File

@@ -51,9 +51,10 @@ pub use inquiry::{
pub use key::{CreateKeyRequest, KeyQueryParams, KeyResponse, KeySummary, UpdateKeyRequest}; pub use key::{CreateKeyRequest, KeyQueryParams, KeyResponse, KeySummary, UpdateKeyRequest};
pub use pack::{CreatePackRequest, PackResponse, PackSummary, UpdatePackRequest}; pub use pack::{CreatePackRequest, PackResponse, PackSummary, UpdatePackRequest};
pub use permission::{ pub use permission::{
CreateIdentityRequest, CreatePermissionAssignmentRequest, IdentityResponse, IdentitySummary, CreateIdentityRequest, CreateIdentityRoleAssignmentRequest, CreatePermissionAssignmentRequest,
PermissionAssignmentResponse, PermissionSetQueryParams, PermissionSetSummary, CreatePermissionSetRoleAssignmentRequest, IdentityResponse, IdentityRoleAssignmentResponse,
UpdateIdentityRequest, IdentitySummary, PermissionAssignmentResponse, PermissionSetQueryParams,
PermissionSetRoleAssignmentResponse, PermissionSetSummary, UpdateIdentityRequest,
}; };
pub use rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest}; pub use rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest};
pub use runtime::{CreateRuntimeRequest, RuntimeResponse, RuntimeSummary, UpdateRuntimeRequest}; pub use runtime::{CreateRuntimeRequest, RuntimeResponse, RuntimeSummary, UpdateRuntimeRequest};

View File

@@ -14,10 +14,32 @@ pub struct IdentitySummary {
pub id: i64, pub id: i64,
pub login: String, pub login: String,
pub display_name: Option<String>, pub display_name: Option<String>,
pub frozen: bool,
pub attributes: JsonValue, pub attributes: JsonValue,
pub roles: Vec<String>,
} }
pub type IdentityResponse = IdentitySummary; #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct IdentityRoleAssignmentResponse {
pub id: i64,
pub identity_id: i64,
pub role: String,
pub source: String,
pub managed: bool,
pub created: chrono::DateTime<chrono::Utc>,
pub updated: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct IdentityResponse {
pub id: i64,
pub login: String,
pub display_name: Option<String>,
pub frozen: bool,
pub attributes: JsonValue,
pub roles: Vec<IdentityRoleAssignmentResponse>,
pub direct_permissions: Vec<PermissionAssignmentResponse>,
}
#[derive(Debug, Clone, Serialize, ToSchema)] #[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PermissionSetSummary { pub struct PermissionSetSummary {
@@ -27,6 +49,7 @@ pub struct PermissionSetSummary {
pub label: Option<String>, pub label: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub grants: JsonValue, pub grants: JsonValue,
pub roles: Vec<PermissionSetRoleAssignmentResponse>,
} }
#[derive(Debug, Clone, Serialize, ToSchema)] #[derive(Debug, Clone, Serialize, ToSchema)]
@@ -38,6 +61,15 @@ pub struct PermissionAssignmentResponse {
pub created: chrono::DateTime<chrono::Utc>, pub created: chrono::DateTime<chrono::Utc>,
} }
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PermissionSetRoleAssignmentResponse {
pub id: i64,
pub permission_set_id: i64,
pub permission_set_ref: Option<String>,
pub role: String,
pub created: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)] #[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreatePermissionAssignmentRequest { pub struct CreatePermissionAssignmentRequest {
pub identity_id: Option<i64>, pub identity_id: Option<i64>,
@@ -45,6 +77,18 @@ pub struct CreatePermissionAssignmentRequest {
pub permission_set_ref: String, pub permission_set_ref: String,
} }
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
pub struct CreateIdentityRoleAssignmentRequest {
#[validate(length(min = 1, max = 255))]
pub role: String,
}
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
pub struct CreatePermissionSetRoleAssignmentRequest {
#[validate(length(min = 1, max = 255))]
pub role: String,
}
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)] #[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
pub struct CreateIdentityRequest { pub struct CreateIdentityRequest {
#[validate(length(min = 3, max = 255))] #[validate(length(min = 3, max = 255))]
@@ -62,4 +106,5 @@ pub struct UpdateIdentityRequest {
pub display_name: Option<String>, pub display_name: Option<String>,
pub password: Option<String>, pub password: Option<String>,
pub attributes: Option<JsonValue>, pub attributes: Option<JsonValue>,
pub frozen: Option<bool>,
} }

View File

@@ -25,9 +25,8 @@ pub struct CreateRuleRequest {
pub label: String, pub label: String,
/// Rule description /// Rule description
#[validate(length(min = 1))]
#[schema(example = "Send Slack notification when an error occurs")] #[schema(example = "Send Slack notification when an error occurs")]
pub description: String, pub description: Option<String>,
/// Action reference to execute when rule matches /// Action reference to execute when rule matches
#[validate(length(min = 1, max = 255))] #[validate(length(min = 1, max = 255))]
@@ -69,7 +68,6 @@ pub struct UpdateRuleRequest {
pub label: Option<String>, pub label: Option<String>,
/// Rule description /// Rule description
#[validate(length(min = 1))]
#[schema(example = "Enhanced error notification with filtering")] #[schema(example = "Enhanced error notification with filtering")]
pub description: Option<String>, pub description: Option<String>,
@@ -115,7 +113,7 @@ pub struct RuleResponse {
/// Rule description /// Rule description
#[schema(example = "Send Slack notification when an error occurs")] #[schema(example = "Send Slack notification when an error occurs")]
pub description: String, pub description: Option<String>,
/// Action ID (null if the referenced action has been deleted) /// Action ID (null if the referenced action has been deleted)
#[schema(example = 1)] #[schema(example = 1)]
@@ -183,7 +181,7 @@ pub struct RuleSummary {
/// Rule description /// Rule description
#[schema(example = "Send Slack notification when an error occurs")] #[schema(example = "Send Slack notification when an error occurs")]
pub description: String, pub description: Option<String>,
/// Action reference /// Action reference
#[schema(example = "slack.post_message")] #[schema(example = "slack.post_message")]
@@ -297,7 +295,7 @@ mod tests {
r#ref: "".to_string(), // Invalid: empty r#ref: "".to_string(), // Invalid: empty
pack_ref: "test-pack".to_string(), pack_ref: "test-pack".to_string(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test description".to_string(), description: Some("Test description".to_string()),
action_ref: "test.action".to_string(), action_ref: "test.action".to_string(),
trigger_ref: "test.trigger".to_string(), trigger_ref: "test.trigger".to_string(),
conditions: default_empty_object(), conditions: default_empty_object(),
@@ -315,7 +313,7 @@ mod tests {
r#ref: "test.rule".to_string(), r#ref: "test.rule".to_string(),
pack_ref: "test-pack".to_string(), pack_ref: "test-pack".to_string(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test description".to_string(), description: Some("Test description".to_string()),
action_ref: "test.action".to_string(), action_ref: "test.action".to_string(),
trigger_ref: "test.trigger".to_string(), trigger_ref: "test.trigger".to_string(),
conditions: serde_json::json!({ conditions: serde_json::json!({

View File

@@ -203,9 +203,8 @@ pub struct CreateSensorRequest {
pub label: String, pub label: String,
/// Sensor description /// Sensor description
#[validate(length(min = 1))]
#[schema(example = "Monitors CPU usage and generates events")] #[schema(example = "Monitors CPU usage and generates events")]
pub description: String, pub description: Option<String>,
/// Entry point for sensor execution (e.g., path to script, function name) /// Entry point for sensor execution (e.g., path to script, function name)
#[validate(length(min = 1, max = 1024))] #[validate(length(min = 1, max = 1024))]
@@ -247,7 +246,6 @@ pub struct UpdateSensorRequest {
pub label: Option<String>, pub label: Option<String>,
/// Sensor description /// Sensor description
#[validate(length(min = 1))]
#[schema(example = "Enhanced CPU monitoring with alerts")] #[schema(example = "Enhanced CPU monitoring with alerts")]
pub description: Option<String>, pub description: Option<String>,
@@ -297,7 +295,7 @@ pub struct SensorResponse {
/// Sensor description /// Sensor description
#[schema(example = "Monitors CPU usage and generates events")] #[schema(example = "Monitors CPU usage and generates events")]
pub description: String, pub description: Option<String>,
/// Entry point /// Entry point
#[schema(example = "/sensors/monitoring/cpu_monitor.py")] #[schema(example = "/sensors/monitoring/cpu_monitor.py")]
@@ -357,7 +355,7 @@ pub struct SensorSummary {
/// Sensor description /// Sensor description
#[schema(example = "Monitors CPU usage and generates events")] #[schema(example = "Monitors CPU usage and generates events")]
pub description: String, pub description: Option<String>,
/// Trigger reference /// Trigger reference
#[schema(example = "monitoring.cpu_threshold")] #[schema(example = "monitoring.cpu_threshold")]
@@ -499,7 +497,7 @@ mod tests {
r#ref: "test.sensor".to_string(), r#ref: "test.sensor".to_string(),
pack_ref: "test-pack".to_string(), pack_ref: "test-pack".to_string(),
label: "Test Sensor".to_string(), label: "Test Sensor".to_string(),
description: "Test description".to_string(), description: Some("Test description".to_string()),
entrypoint: "/sensors/test.py".to_string(), entrypoint: "/sensors/test.py".to_string(),
runtime_ref: "python3".to_string(), runtime_ref: "python3".to_string(),
trigger_ref: "test.trigger".to_string(), trigger_ref: "test.trigger".to_string(),

View File

@@ -27,8 +27,11 @@ use crate::dto::{
UpdatePackRequest, WorkflowSyncResult, UpdatePackRequest, WorkflowSyncResult,
}, },
permission::{ permission::{
CreateIdentityRequest, CreatePermissionAssignmentRequest, IdentityResponse, CreateIdentityRequest, CreateIdentityRoleAssignmentRequest,
IdentitySummary, PermissionAssignmentResponse, PermissionSetSummary, UpdateIdentityRequest, CreatePermissionAssignmentRequest, CreatePermissionSetRoleAssignmentRequest,
IdentityResponse, IdentityRoleAssignmentResponse, IdentitySummary,
PermissionAssignmentResponse, PermissionSetRoleAssignmentResponse, PermissionSetSummary,
UpdateIdentityRequest,
}, },
rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest}, rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest},
runtime::{CreateRuntimeRequest, RuntimeResponse, RuntimeSummary, UpdateRuntimeRequest}, runtime::{CreateRuntimeRequest, RuntimeResponse, RuntimeSummary, UpdateRuntimeRequest},
@@ -185,6 +188,12 @@ use crate::dto::{
crate::routes::permissions::list_identity_permissions, crate::routes::permissions::list_identity_permissions,
crate::routes::permissions::create_permission_assignment, crate::routes::permissions::create_permission_assignment,
crate::routes::permissions::delete_permission_assignment, crate::routes::permissions::delete_permission_assignment,
crate::routes::permissions::create_identity_role_assignment,
crate::routes::permissions::delete_identity_role_assignment,
crate::routes::permissions::create_permission_set_role_assignment,
crate::routes::permissions::delete_permission_set_role_assignment,
crate::routes::permissions::freeze_identity,
crate::routes::permissions::unfreeze_identity,
// Workflows // Workflows
crate::routes::workflows::list_workflows, crate::routes::workflows::list_workflows,
@@ -277,6 +286,10 @@ use crate::dto::{
PermissionSetSummary, PermissionSetSummary,
PermissionAssignmentResponse, PermissionAssignmentResponse,
CreatePermissionAssignmentRequest, CreatePermissionAssignmentRequest,
CreateIdentityRoleAssignmentRequest,
IdentityRoleAssignmentResponse,
CreatePermissionSetRoleAssignmentRequest,
PermissionSetRoleAssignmentResponse,
// Runtime DTOs // Runtime DTOs
CreateRuntimeRequest, CreateRuntimeRequest,

View File

@@ -277,7 +277,7 @@ pub async fn update_action(
// Create update input // Create update input
let update_input = UpdateActionInput { let update_input = UpdateActionInput {
label: request.label, label: request.label,
description: request.description, description: request.description.map(Patch::Set),
entrypoint: request.entrypoint, entrypoint: request.entrypoint,
runtime: request.runtime, runtime: request.runtime,
runtime_version_constraint: request.runtime_version_constraint.map(|patch| match patch { runtime_version_constraint: request.runtime_version_constraint.map(|patch| match patch {

View File

@@ -40,7 +40,8 @@ use attune_common::repositories::{
}; };
use crate::{ use crate::{
auth::middleware::RequireAuth, auth::{jwt::TokenType, middleware::AuthenticatedUser, middleware::RequireAuth},
authz::{AuthorizationCheck, AuthorizationService},
dto::{ dto::{
artifact::{ artifact::{
AllocateFileVersionByRefRequest, AppendProgressRequest, ArtifactExecutionPatch, AllocateFileVersionByRefRequest, AppendProgressRequest, ArtifactExecutionPatch,
@@ -55,6 +56,7 @@ use crate::{
middleware::{ApiError, ApiResult}, middleware::{ApiError, ApiResult},
state::AppState, state::AppState,
}; };
use attune_common::rbac::{Action, AuthorizationContext, Resource};
// ============================================================================ // ============================================================================
// Artifact CRUD // Artifact CRUD
@@ -72,7 +74,7 @@ use crate::{
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn list_artifacts( pub async fn list_artifacts(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(query): Query<ArtifactQueryParams>, Query(query): Query<ArtifactQueryParams>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -88,8 +90,16 @@ pub async fn list_artifacts(
}; };
let result = ArtifactRepository::search(&state.db, &filters).await?; let result = ArtifactRepository::search(&state.db, &filters).await?;
let mut rows = result.rows;
let items: Vec<ArtifactSummary> = result.rows.into_iter().map(ArtifactSummary::from).collect(); if let Some((identity_id, grants)) = ensure_can_read_any_artifact(&state, &user).await? {
rows.retain(|artifact| {
let ctx = artifact_authorization_context(identity_id, artifact);
AuthorizationService::is_allowed(&grants, Resource::Artifacts, Action::Read, &ctx)
});
}
let items: Vec<ArtifactSummary> = rows.into_iter().map(ArtifactSummary::from).collect();
let pagination = PaginationParams { let pagination = PaginationParams {
page: query.page, page: query.page,
@@ -113,7 +123,7 @@ pub async fn list_artifacts(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_artifact( pub async fn get_artifact(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -121,6 +131,10 @@ pub async fn get_artifact(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
Json(ApiResponse::new(ArtifactResponse::from(artifact))), Json(ApiResponse::new(ArtifactResponse::from(artifact))),
@@ -140,7 +154,7 @@ pub async fn get_artifact(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_artifact_by_ref( pub async fn get_artifact_by_ref(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(artifact_ref): Path<String>, Path(artifact_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -148,6 +162,10 @@ pub async fn get_artifact_by_ref(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact '{}' not found", artifact_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact '{}' not found", artifact_ref)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact '{}' not found", artifact_ref)))?;
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
Json(ApiResponse::new(ArtifactResponse::from(artifact))), Json(ApiResponse::new(ArtifactResponse::from(artifact))),
@@ -168,7 +186,7 @@ pub async fn get_artifact_by_ref(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn create_artifact( pub async fn create_artifact(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(request): Json<CreateArtifactRequest>, Json(request): Json<CreateArtifactRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -200,6 +218,16 @@ pub async fn create_artifact(
} }
}); });
authorize_artifact_create(
&state,
&user,
&request.r#ref,
request.scope,
&request.owner,
visibility,
)
.await?;
let input = CreateArtifactInput { let input = CreateArtifactInput {
r#ref: request.r#ref, r#ref: request.r#ref,
scope: request.scope, scope: request.scope,
@@ -240,16 +268,18 @@ pub async fn create_artifact(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn update_artifact( pub async fn update_artifact(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
Json(request): Json<UpdateArtifactRequest>, Json(request): Json<UpdateArtifactRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Verify artifact exists // Verify artifact exists
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
let input = UpdateArtifactInput { let input = UpdateArtifactInput {
r#ref: None, // Ref is immutable after creation r#ref: None, // Ref is immutable after creation
scope: request.scope, scope: request.scope,
@@ -305,7 +335,7 @@ pub async fn update_artifact(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn delete_artifact( pub async fn delete_artifact(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -313,6 +343,8 @@ pub async fn delete_artifact(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Delete, &artifact).await?;
// Before deleting DB rows, clean up any file-backed versions on disk // Before deleting DB rows, clean up any file-backed versions on disk
let file_versions = let file_versions =
ArtifactVersionRepository::find_file_versions_by_artifact(&state.db, id).await?; ArtifactVersionRepository::find_file_versions_by_artifact(&state.db, id).await?;
@@ -355,11 +387,17 @@ pub async fn delete_artifact(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn list_artifacts_by_execution( pub async fn list_artifacts_by_execution(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(execution_id): Path<i64>, Path(execution_id): Path<i64>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
let artifacts = ArtifactRepository::find_by_execution(&state.db, execution_id).await?; let mut artifacts = ArtifactRepository::find_by_execution(&state.db, execution_id).await?;
if let Some((identity_id, grants)) = ensure_can_read_any_artifact(&state, &user).await? {
artifacts.retain(|artifact| {
let ctx = artifact_authorization_context(identity_id, artifact);
AuthorizationService::is_allowed(&grants, Resource::Artifacts, Action::Read, &ctx)
});
}
let items: Vec<ArtifactSummary> = artifacts.into_iter().map(ArtifactSummary::from).collect(); let items: Vec<ArtifactSummary> = artifacts.into_iter().map(ArtifactSummary::from).collect();
Ok((StatusCode::OK, Json(ApiResponse::new(items)))) Ok((StatusCode::OK, Json(ApiResponse::new(items))))
@@ -387,7 +425,7 @@ pub async fn list_artifacts_by_execution(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn append_progress( pub async fn append_progress(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
Json(request): Json<AppendProgressRequest>, Json(request): Json<AppendProgressRequest>,
@@ -396,6 +434,8 @@ pub async fn append_progress(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
if artifact.r#type != ArtifactType::Progress { if artifact.r#type != ArtifactType::Progress {
return Err(ApiError::BadRequest(format!( return Err(ApiError::BadRequest(format!(
"Artifact '{}' is type {:?}, not progress. Use version endpoints for file artifacts.", "Artifact '{}' is type {:?}, not progress. Use version endpoints for file artifacts.",
@@ -430,16 +470,18 @@ pub async fn append_progress(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn set_artifact_data( pub async fn set_artifact_data(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
Json(request): Json<SetDataRequest>, Json(request): Json<SetDataRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Verify exists // Verify exists
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
let updated = ArtifactRepository::set_data(&state.db, id, &request.data).await?; let updated = ArtifactRepository::set_data(&state.db, id, &request.data).await?;
Ok(( Ok((
@@ -468,15 +510,19 @@ pub async fn set_artifact_data(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn list_versions( pub async fn list_versions(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Verify artifact exists // Verify artifact exists
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
let versions = ArtifactVersionRepository::list_by_artifact(&state.db, id).await?; let versions = ArtifactVersionRepository::list_by_artifact(&state.db, id).await?;
let items: Vec<ArtifactVersionSummary> = versions let items: Vec<ArtifactVersionSummary> = versions
.into_iter() .into_iter()
@@ -502,15 +548,19 @@ pub async fn list_versions(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_version( pub async fn get_version(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path((id, version)): Path<(i64, i32)>, Path((id, version)): Path<(i64, i32)>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Verify artifact exists // Verify artifact exists
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version) let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
@@ -536,14 +586,18 @@ pub async fn get_version(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn get_latest_version( pub async fn get_latest_version(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
let ver = ArtifactVersionRepository::find_latest(&state.db, id) let ver = ArtifactVersionRepository::find_latest(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("No versions found for artifact {}", id)))?; .ok_or_else(|| ApiError::NotFound(format!("No versions found for artifact {}", id)))?;
@@ -568,15 +622,17 @@ pub async fn get_latest_version(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn create_version_json( pub async fn create_version_json(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
Json(request): Json<CreateVersionJsonRequest>, Json(request): Json<CreateVersionJsonRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
let input = CreateArtifactVersionInput { let input = CreateArtifactVersionInput {
artifact: id, artifact: id,
content_type: Some( content_type: Some(
@@ -624,7 +680,7 @@ pub async fn create_version_json(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn create_version_file( pub async fn create_version_file(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
Json(request): Json<CreateFileVersionRequest>, Json(request): Json<CreateFileVersionRequest>,
@@ -633,6 +689,8 @@ pub async fn create_version_file(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
// Validate this is a file-type artifact // Validate this is a file-type artifact
if !is_file_backed_type(artifact.r#type) { if !is_file_backed_type(artifact.r#type) {
return Err(ApiError::BadRequest(format!( return Err(ApiError::BadRequest(format!(
@@ -726,15 +784,17 @@ pub async fn create_version_file(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn upload_version( pub async fn upload_version(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
mut multipart: Multipart, mut multipart: Multipart,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Update, &artifact).await?;
let mut file_data: Option<Vec<u8>> = None; let mut file_data: Option<Vec<u8>> = None;
let mut content_type: Option<String> = None; let mut content_type: Option<String> = None;
let mut meta: Option<serde_json::Value> = None; let mut meta: Option<serde_json::Value> = None;
@@ -854,7 +914,7 @@ pub async fn upload_version(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn download_version( pub async fn download_version(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path((id, version)): Path<(i64, i32)>, Path((id, version)): Path<(i64, i32)>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -862,6 +922,10 @@ pub async fn download_version(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
// First try without content (cheaper query) to check for file_path // First try without content (cheaper query) to check for file_path
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version) let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
.await? .await?
@@ -904,7 +968,7 @@ pub async fn download_version(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn download_latest( pub async fn download_latest(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -912,6 +976,10 @@ pub async fn download_latest(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
// First try without content (cheaper query) to check for file_path // First try without content (cheaper query) to check for file_path
let ver = ArtifactVersionRepository::find_latest(&state.db, id) let ver = ArtifactVersionRepository::find_latest(&state.db, id)
.await? .await?
@@ -955,7 +1023,7 @@ pub async fn download_latest(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn delete_version( pub async fn delete_version(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path((id, version)): Path<(i64, i32)>, Path((id, version)): Path<(i64, i32)>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
@@ -964,6 +1032,8 @@ pub async fn delete_version(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Delete, &artifact).await?;
// Find the version by artifact + version number // Find the version by artifact + version number
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version) let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
.await? .await?
@@ -1042,7 +1112,7 @@ pub async fn delete_version(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn upload_version_by_ref( pub async fn upload_version_by_ref(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(artifact_ref): Path<String>, Path(artifact_ref): Path<String>,
mut multipart: Multipart, mut multipart: Multipart,
@@ -1157,6 +1227,8 @@ pub async fn upload_version_by_ref(
// Upsert: find existing artifact or create a new one // Upsert: find existing artifact or create a new one
let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? { let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? {
Some(existing) => { Some(existing) => {
authorize_artifact_action(&state, &user, Action::Update, &existing).await?;
// Update execution link if a new execution ID was provided // Update execution link if a new execution ID was provided
if execution_id.is_some() && execution_id != existing.execution { if execution_id.is_some() && execution_id != existing.execution {
let update_input = UpdateArtifactInput { let update_input = UpdateArtifactInput {
@@ -1211,6 +1283,16 @@ pub async fn upload_version_by_ref(
} }
}; };
authorize_artifact_create(
&state,
&user,
&artifact_ref,
a_scope,
owner.as_deref().unwrap_or_default(),
a_visibility,
)
.await?;
// Parse retention // Parse retention
let a_retention_policy: RetentionPolicyType = match &retention_policy { let a_retention_policy: RetentionPolicyType = match &retention_policy {
Some(rp) if !rp.is_empty() => { Some(rp) if !rp.is_empty() => {
@@ -1297,7 +1379,7 @@ pub async fn upload_version_by_ref(
security(("bearer_auth" = [])) security(("bearer_auth" = []))
)] )]
pub async fn allocate_file_version_by_ref( pub async fn allocate_file_version_by_ref(
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(artifact_ref): Path<String>, Path(artifact_ref): Path<String>,
Json(request): Json<AllocateFileVersionByRefRequest>, Json(request): Json<AllocateFileVersionByRefRequest>,
@@ -1305,6 +1387,8 @@ pub async fn allocate_file_version_by_ref(
// Upsert: find existing artifact or create a new one // Upsert: find existing artifact or create a new one
let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? { let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? {
Some(existing) => { Some(existing) => {
authorize_artifact_action(&state, &user, Action::Update, &existing).await?;
// Update execution link if a new execution ID was provided // Update execution link if a new execution ID was provided
if request.execution.is_some() && request.execution != existing.execution { if request.execution.is_some() && request.execution != existing.execution {
let update_input = UpdateArtifactInput { let update_input = UpdateArtifactInput {
@@ -1347,6 +1431,16 @@ pub async fn allocate_file_version_by_ref(
.unwrap_or(RetentionPolicyType::Versions); .unwrap_or(RetentionPolicyType::Versions);
let a_retention_limit = request.retention_limit.unwrap_or(10); let a_retention_limit = request.retention_limit.unwrap_or(10);
authorize_artifact_create(
&state,
&user,
&artifact_ref,
a_scope,
request.owner.as_deref().unwrap_or_default(),
a_visibility,
)
.await?;
let create_input = CreateArtifactInput { let create_input = CreateArtifactInput {
r#ref: artifact_ref.clone(), r#ref: artifact_ref.clone(),
scope: a_scope, scope: a_scope,
@@ -1437,6 +1531,105 @@ pub async fn allocate_file_version_by_ref(
// Helpers // Helpers
// ============================================================================ // ============================================================================
async fn authorize_artifact_action(
state: &Arc<AppState>,
user: &AuthenticatedUser,
action: Action,
artifact: &attune_common::models::artifact::Artifact,
) -> Result<(), ApiError> {
if user.claims.token_type != TokenType::Access {
return Ok(());
}
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
authz
.authorize(
user,
AuthorizationCheck {
resource: Resource::Artifacts,
action,
context: artifact_authorization_context(identity_id, artifact),
},
)
.await
}
async fn authorize_artifact_create(
state: &Arc<AppState>,
user: &AuthenticatedUser,
artifact_ref: &str,
scope: OwnerType,
owner: &str,
visibility: ArtifactVisibility,
) -> Result<(), ApiError> {
if user.claims.token_type != TokenType::Access {
return Ok(());
}
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_ref = Some(artifact_ref.to_string());
ctx.owner_type = Some(scope);
ctx.owner_ref = Some(owner.to_string());
ctx.visibility = Some(visibility);
authz
.authorize(
user,
AuthorizationCheck {
resource: Resource::Artifacts,
action: Action::Create,
context: ctx,
},
)
.await
}
async fn ensure_can_read_any_artifact(
state: &Arc<AppState>,
user: &AuthenticatedUser,
) -> Result<Option<(i64, Vec<attune_common::rbac::Grant>)>, ApiError> {
if user.claims.token_type != TokenType::Access {
return Ok(None);
}
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let grants = authz.effective_grants(user).await?;
let can_read_any_artifact = grants
.iter()
.any(|g| g.resource == Resource::Artifacts && g.actions.contains(&Action::Read));
if !can_read_any_artifact {
return Err(ApiError::Forbidden(
"Insufficient permissions: artifacts:read".to_string(),
));
}
Ok(Some((identity_id, grants)))
}
fn artifact_authorization_context(
identity_id: i64,
artifact: &attune_common::models::artifact::Artifact,
) -> AuthorizationContext {
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_id = Some(artifact.id);
ctx.target_ref = Some(artifact.r#ref.clone());
ctx.owner_type = Some(artifact.scope);
ctx.owner_ref = Some(artifact.owner.clone());
ctx.visibility = Some(artifact.visibility);
ctx
}
/// Returns true for artifact types that should use file-backed storage on disk. /// Returns true for artifact types that should use file-backed storage on disk.
fn is_file_backed_type(artifact_type: ArtifactType) -> bool { fn is_file_backed_type(artifact_type: ArtifactType) -> bool {
matches!( matches!(
@@ -1775,14 +1968,19 @@ pub async fn stream_artifact(
let token = params.token.as_ref().ok_or(ApiError::Unauthorized( let token = params.token.as_ref().ok_or(ApiError::Unauthorized(
"Missing authentication token".to_string(), "Missing authentication token".to_string(),
))?; ))?;
validate_token(token, &state.jwt_config) let claims = validate_token(token, &state.jwt_config)
.map_err(|_| ApiError::Unauthorized("Invalid authentication token".to_string()))?; .map_err(|_| ApiError::Unauthorized("Invalid authentication token".to_string()))?;
let user = AuthenticatedUser { claims };
// --- resolve artifact + latest version --------------------------------- // --- resolve artifact + latest version ---------------------------------
let artifact = ArtifactRepository::find_by_id(&state.db, id) let artifact = ArtifactRepository::find_by_id(&state.db, id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
authorize_artifact_action(&state, &user, Action::Read, &artifact)
.await
.map_err(|_| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
if !is_file_backed_type(artifact.r#type) { if !is_file_backed_type(artifact.r#type) {
return Err(ApiError::BadRequest(format!( return Err(ApiError::BadRequest(format!(
"Artifact '{}' is type {:?} which is not file-backed. \ "Artifact '{}' is type {:?} which is not file-backed. \

View File

@@ -169,6 +169,12 @@ pub async fn login(
.await? .await?
.ok_or_else(|| ApiError::Unauthorized("Invalid login or password".to_string()))?; .ok_or_else(|| ApiError::Unauthorized("Invalid login or password".to_string()))?;
if identity.frozen {
return Err(ApiError::Forbidden(
"Identity is frozen and cannot authenticate".to_string(),
));
}
// Check if identity has a password set // Check if identity has a password set
let password_hash = identity let password_hash = identity
.password_hash .password_hash
@@ -324,6 +330,12 @@ pub async fn refresh_token(
.await? .await?
.ok_or_else(|| ApiError::Unauthorized("Identity not found".to_string()))?; .ok_or_else(|| ApiError::Unauthorized("Identity not found".to_string()))?;
if identity.frozen {
return Err(ApiError::Forbidden(
"Identity is frozen and cannot authenticate".to_string(),
));
}
// Generate new tokens // Generate new tokens
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?; let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?; let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
@@ -380,6 +392,12 @@ pub async fn get_current_user(
.await? .await?
.ok_or_else(|| ApiError::NotFound("Identity not found".to_string()))?; .ok_or_else(|| ApiError::NotFound("Identity not found".to_string()))?;
if identity.frozen {
return Err(ApiError::Forbidden(
"Identity is frozen and cannot authenticate".to_string(),
));
}
let response = CurrentUserResponse { let response = CurrentUserResponse {
id: identity.id, id: identity.id,
login: identity.login, login: identity.login,
@@ -551,6 +569,7 @@ pub async fn change_password(
display_name: None, display_name: None,
password_hash: Some(new_password_hash), password_hash: Some(new_password_hash),
attributes: None, attributes: None,
frozen: None,
}; };
IdentityRepository::update(&state.db, identity_id, update_input).await?; IdentityRepository::update(&state.db, identity_id, update_input).await?;

View File

@@ -82,6 +82,17 @@ pub async fn create_event(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(payload): Json<CreateEventRequest>, Json(payload): Json<CreateEventRequest>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// Only sensor and execution tokens may create events directly.
// User sessions must go through the webhook receiver instead.
use crate::auth::jwt::TokenType;
if user.0.claims.token_type == TokenType::Access {
return Err(ApiError::Forbidden(
"Events may only be created by sensor services. To fire an event as a user, \
enable webhooks on the trigger and POST to its webhook URL."
.to_string(),
));
}
// Validate request // Validate request
payload payload
.validate() .validate()
@@ -128,7 +139,6 @@ pub async fn create_event(
}; };
// Determine source (sensor) from authenticated user if it's a sensor token // Determine source (sensor) from authenticated user if it's a sensor token
use crate::auth::jwt::TokenType;
let (source_id, source_ref) = match user.0.claims.token_type { let (source_id, source_ref) = match user.0.claims.token_type {
TokenType::Sensor => { TokenType::Sensor => {
// Extract sensor reference from login // Extract sensor reference from login

View File

@@ -93,19 +93,6 @@ pub async fn create_execution(
}, },
) )
.await?; .await?;
let mut execution_ctx = AuthorizationContext::new(identity_id);
execution_ctx.pack_ref = Some(action.pack_ref.clone());
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Executions,
action: Action::Create,
context: execution_ctx,
},
)
.await?;
} }
// Create execution input // Create execution input

View File

@@ -120,12 +120,16 @@ pub async fn get_key(
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?;
if user.0.claims.token_type == TokenType::Access { // For encrypted keys, track whether this caller is permitted to see the value.
// Non-Access tokens (sensor, execution) always get full access.
let can_decrypt = if user.0.claims.token_type == TokenType::Access {
let identity_id = user let identity_id = user
.0 .0
.identity_id() .identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?; .map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone()); let authz = AuthorizationService::new(state.db.clone());
// Basic read check — hide behind 404 to prevent enumeration.
authz authz
.authorize( .authorize(
&user.0, &user.0,
@@ -136,19 +140,43 @@ pub async fn get_key(
}, },
) )
.await .await
// Hide unauthorized records behind 404 to reduce enumeration leakage.
.map_err(|_| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?; .map_err(|_| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?;
}
// Decrypt value if encrypted // For encrypted keys, separately check Keys::Decrypt.
// Failing this is not an error — we just return the value as null.
if key.encrypted { if key.encrypted {
let encryption_key = state authz
.authorize(
&user.0,
AuthorizationCheck {
resource: Resource::Keys,
action: Action::Decrypt,
context: key_authorization_context(identity_id, &key),
},
)
.await
.is_ok()
} else {
true
}
} else {
true
};
// Decrypt value if encrypted and caller has permission.
// If they lack Keys::Decrypt, return null rather than the ciphertext.
if key.encrypted {
if can_decrypt {
let encryption_key =
state
.config .config
.security .security
.encryption_key .encryption_key
.as_ref() .as_ref()
.ok_or_else(|| { .ok_or_else(|| {
ApiError::InternalServerError("Encryption key not configured on server".to_string()) ApiError::InternalServerError(
"Encryption key not configured on server".to_string(),
)
})?; })?;
let decrypted_value = attune_common::crypto::decrypt_json(&key.value, encryption_key) let decrypted_value = attune_common::crypto::decrypt_json(&key.value, encryption_key)
@@ -158,6 +186,9 @@ pub async fn get_key(
})?; })?;
key.value = decrypted_value; key.value = decrypted_value;
} else {
key.value = serde_json::Value::Null;
}
} }
let response = ApiResponse::new(KeyResponse::from(key)); let response = ApiResponse::new(KeyResponse::from(key));
@@ -195,6 +226,7 @@ pub async fn create_key(
let mut ctx = AuthorizationContext::new(identity_id); let mut ctx = AuthorizationContext::new(identity_id);
ctx.owner_identity_id = request.owner_identity; ctx.owner_identity_id = request.owner_identity;
ctx.owner_type = Some(request.owner_type); ctx.owner_type = Some(request.owner_type);
ctx.owner_ref = requested_key_owner_ref(&request);
ctx.encrypted = Some(request.encrypted); ctx.encrypted = Some(request.encrypted);
ctx.target_ref = Some(request.r#ref.clone()); ctx.target_ref = Some(request.r#ref.clone());
@@ -541,6 +573,38 @@ fn key_authorization_context(identity_id: i64, key: &Key) -> AuthorizationContex
ctx.target_ref = Some(key.r#ref.clone()); ctx.target_ref = Some(key.r#ref.clone());
ctx.owner_identity_id = key.owner_identity; ctx.owner_identity_id = key.owner_identity;
ctx.owner_type = Some(key.owner_type); ctx.owner_type = Some(key.owner_type);
ctx.owner_ref = key_owner_ref(
key.owner_type,
key.owner.as_deref(),
key.owner_pack_ref.as_deref(),
key.owner_action_ref.as_deref(),
key.owner_sensor_ref.as_deref(),
);
ctx.encrypted = Some(key.encrypted); ctx.encrypted = Some(key.encrypted);
ctx ctx
} }
fn requested_key_owner_ref(request: &CreateKeyRequest) -> Option<String> {
key_owner_ref(
request.owner_type,
request.owner.as_deref(),
request.owner_pack_ref.as_deref(),
request.owner_action_ref.as_deref(),
request.owner_sensor_ref.as_deref(),
)
}
fn key_owner_ref(
owner_type: OwnerType,
owner: Option<&str>,
owner_pack_ref: Option<&str>,
owner_action_ref: Option<&str>,
owner_sensor_ref: Option<&str>,
) -> Option<String> {
match owner_type {
OwnerType::Pack => owner_pack_ref.map(str::to_string),
OwnerType::Action => owner_action_ref.map(str::to_string),
OwnerType::Sensor => owner_sensor_ref.map(str::to_string),
_ => owner.map(str::to_string),
}
}

View File

@@ -9,12 +9,14 @@ use std::sync::Arc;
use validator::Validate; use validator::Validate;
use attune_common::{ use attune_common::{
models::identity::{Identity, PermissionSet}, models::identity::{Identity, IdentityRoleAssignment},
rbac::{Action, AuthorizationContext, Resource}, rbac::{Action, AuthorizationContext, Resource},
repositories::{ repositories::{
identity::{ identity::{
CreateIdentityInput, CreatePermissionAssignmentInput, IdentityRepository, CreateIdentityInput, CreateIdentityRoleAssignmentInput,
PermissionAssignmentRepository, PermissionSetRepository, UpdateIdentityInput, CreatePermissionAssignmentInput, CreatePermissionSetRoleAssignmentInput,
IdentityRepository, IdentityRoleAssignmentRepository, PermissionAssignmentRepository,
PermissionSetRepository, PermissionSetRoleAssignmentRepository, UpdateIdentityInput,
}, },
Create, Delete, FindById, FindByRef, List, Update, Create, Delete, FindById, FindByRef, List, Update,
}, },
@@ -26,9 +28,12 @@ use crate::{
authz::{AuthorizationCheck, AuthorizationService}, authz::{AuthorizationCheck, AuthorizationService},
dto::{ dto::{
common::{PaginatedResponse, PaginationParams}, common::{PaginatedResponse, PaginationParams},
ApiResponse, CreateIdentityRequest, CreatePermissionAssignmentRequest, IdentityResponse, ApiResponse, CreateIdentityRequest, CreateIdentityRoleAssignmentRequest,
IdentitySummary, PermissionAssignmentResponse, PermissionSetQueryParams, CreatePermissionAssignmentRequest, CreatePermissionSetRoleAssignmentRequest,
PermissionSetSummary, SuccessResponse, UpdateIdentityRequest, IdentityResponse, IdentityRoleAssignmentResponse, IdentitySummary,
PermissionAssignmentResponse, PermissionSetQueryParams,
PermissionSetRoleAssignmentResponse, PermissionSetSummary, SuccessResponse,
UpdateIdentityRequest,
}, },
middleware::{ApiError, ApiResult}, middleware::{ApiError, ApiResult},
state::AppState, state::AppState,
@@ -58,16 +63,22 @@ pub async fn list_identities(
let page_items = if start >= identities.len() { let page_items = if start >= identities.len() {
Vec::new() Vec::new()
} else { } else {
identities[start..end] identities[start..end].to_vec()
.iter()
.cloned()
.map(IdentitySummary::from)
.collect()
}; };
let mut summaries = Vec::with_capacity(page_items.len());
for identity in page_items {
let role_assignments =
IdentityRoleAssignmentRepository::find_by_identity(&state.db, identity.id).await?;
let roles = role_assignments.into_iter().map(|ra| ra.role).collect();
let mut summary = IdentitySummary::from(identity);
summary.roles = roles;
summaries.push(summary);
}
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
Json(PaginatedResponse::new(page_items, &query, total)), Json(PaginatedResponse::new(summaries, &query, total)),
)) ))
} }
@@ -94,10 +105,42 @@ pub async fn get_identity(
let identity = IdentityRepository::find_by_id(&state.db, identity_id) let identity = IdentityRepository::find_by_id(&state.db, identity_id)
.await? .await?
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?; .ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
let roles = IdentityRoleAssignmentRepository::find_by_identity(&state.db, identity_id).await?;
let assignments =
PermissionAssignmentRepository::find_by_identity(&state.db, identity_id).await?;
let permission_sets = PermissionSetRepository::find_by_identity(&state.db, identity_id).await?;
let permission_set_refs = permission_sets
.into_iter()
.map(|ps| (ps.id, ps.r#ref))
.collect::<std::collections::HashMap<_, _>>();
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
Json(ApiResponse::new(IdentityResponse::from(identity))), Json(ApiResponse::new(IdentityResponse {
id: identity.id,
login: identity.login,
display_name: identity.display_name,
frozen: identity.frozen,
attributes: identity.attributes,
roles: roles
.into_iter()
.map(IdentityRoleAssignmentResponse::from)
.collect(),
direct_permissions: assignments
.into_iter()
.filter_map(|assignment| {
permission_set_refs.get(&assignment.permset).cloned().map(
|permission_set_ref| PermissionAssignmentResponse {
id: assignment.id,
identity_id: assignment.identity,
permission_set_id: assignment.permset,
permission_set_ref,
created: assignment.created,
},
)
})
.collect(),
})),
)) ))
} }
@@ -180,6 +223,7 @@ pub async fn update_identity(
display_name: request.display_name, display_name: request.display_name,
password_hash, password_hash,
attributes: request.attributes, attributes: request.attributes,
frozen: request.frozen,
}, },
) )
.await?; .await?;
@@ -257,10 +301,33 @@ pub async fn list_permission_sets(
permission_sets.retain(|ps| ps.pack_ref.as_deref() == Some(pack_ref.as_str())); permission_sets.retain(|ps| ps.pack_ref.as_deref() == Some(pack_ref.as_str()));
} }
let response: Vec<PermissionSetSummary> = permission_sets let mut response = Vec::with_capacity(permission_sets.len());
for permission_set in permission_sets {
let permission_set_ref = permission_set.r#ref.clone();
let roles = PermissionSetRoleAssignmentRepository::find_by_permission_set(
&state.db,
permission_set.id,
)
.await?;
response.push(PermissionSetSummary {
id: permission_set.id,
r#ref: permission_set.r#ref,
pack_ref: permission_set.pack_ref,
label: permission_set.label,
description: permission_set.description,
grants: permission_set.grants,
roles: roles
.into_iter() .into_iter()
.map(PermissionSetSummary::from) .map(|assignment| PermissionSetRoleAssignmentResponse {
.collect(); id: assignment.id,
permission_set_id: assignment.permset,
permission_set_ref: Some(permission_set_ref.clone()),
role: assignment.role,
created: assignment.created,
})
.collect(),
});
}
Ok((StatusCode::OK, Json(response))) Ok((StatusCode::OK, Json(response)))
} }
@@ -412,6 +479,229 @@ pub async fn delete_permission_assignment(
)) ))
} }
#[utoipa::path(
post,
path = "/api/v1/identities/{id}/roles",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity ID")
),
request_body = CreateIdentityRoleAssignmentRequest,
responses(
(status = 201, description = "Identity role assignment created", body = inline(ApiResponse<IdentityRoleAssignmentResponse>)),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn create_identity_role_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
Json(request): Json<CreateIdentityRoleAssignmentRequest>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
request.validate()?;
IdentityRepository::find_by_id(&state.db, identity_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
let assignment = IdentityRoleAssignmentRepository::create(
&state.db,
CreateIdentityRoleAssignmentInput {
identity: identity_id,
role: request.role,
source: "manual".to_string(),
managed: false,
},
)
.await?;
Ok((
StatusCode::CREATED,
Json(ApiResponse::new(IdentityRoleAssignmentResponse::from(
assignment,
))),
))
}
#[utoipa::path(
delete,
path = "/api/v1/identities/roles/{id}",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity role assignment ID")
),
responses(
(status = 200, description = "Identity role assignment deleted", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Identity role assignment not found")
),
security(("bearer_auth" = []))
)]
pub async fn delete_identity_role_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(assignment_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
let assignment = IdentityRoleAssignmentRepository::find_by_id(&state.db, assignment_id)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!(
"Identity role assignment '{}' not found",
assignment_id
))
})?;
if assignment.managed {
return Err(ApiError::BadRequest(
"Managed role assignments must be updated through the identity provider sync"
.to_string(),
));
}
IdentityRoleAssignmentRepository::delete(&state.db, assignment_id).await?;
Ok((
StatusCode::OK,
Json(ApiResponse::new(SuccessResponse::new(
"Identity role assignment deleted successfully",
))),
))
}
#[utoipa::path(
post,
path = "/api/v1/permissions/sets/{id}/roles",
tag = "permissions",
params(
("id" = i64, Path, description = "Permission set ID")
),
request_body = CreatePermissionSetRoleAssignmentRequest,
responses(
(status = 201, description = "Permission set role assignment created", body = inline(ApiResponse<PermissionSetRoleAssignmentResponse>)),
(status = 404, description = "Permission set not found")
),
security(("bearer_auth" = []))
)]
pub async fn create_permission_set_role_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(permission_set_id): Path<i64>,
Json(request): Json<CreatePermissionSetRoleAssignmentRequest>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
request.validate()?;
let permission_set = PermissionSetRepository::find_by_id(&state.db, permission_set_id)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!("Permission set '{}' not found", permission_set_id))
})?;
let assignment = PermissionSetRoleAssignmentRepository::create(
&state.db,
CreatePermissionSetRoleAssignmentInput {
permset: permission_set_id,
role: request.role,
},
)
.await?;
Ok((
StatusCode::CREATED,
Json(ApiResponse::new(PermissionSetRoleAssignmentResponse {
id: assignment.id,
permission_set_id: assignment.permset,
permission_set_ref: Some(permission_set.r#ref),
role: assignment.role,
created: assignment.created,
})),
))
}
#[utoipa::path(
delete,
path = "/api/v1/permissions/sets/roles/{id}",
tag = "permissions",
params(
("id" = i64, Path, description = "Permission set role assignment ID")
),
responses(
(status = 200, description = "Permission set role assignment deleted", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Permission set role assignment not found")
),
security(("bearer_auth" = []))
)]
pub async fn delete_permission_set_role_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(assignment_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
PermissionSetRoleAssignmentRepository::find_by_id(&state.db, assignment_id)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!(
"Permission set role assignment '{}' not found",
assignment_id
))
})?;
PermissionSetRoleAssignmentRepository::delete(&state.db, assignment_id).await?;
Ok((
StatusCode::OK,
Json(ApiResponse::new(SuccessResponse::new(
"Permission set role assignment deleted successfully",
))),
))
}
#[utoipa::path(
post,
path = "/api/v1/identities/{id}/freeze",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity ID")
),
responses(
(status = 200, description = "Identity frozen", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn freeze_identity(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
set_identity_frozen(&state, &user, identity_id, true).await
}
#[utoipa::path(
post,
path = "/api/v1/identities/{id}/unfreeze",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity ID")
),
responses(
(status = 200, description = "Identity unfrozen", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn unfreeze_identity(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
set_identity_frozen(&state, &user, identity_id, false).await
}
pub fn routes() -> Router<Arc<AppState>> { pub fn routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/identities", get(list_identities).post(create_identity)) .route("/identities", get(list_identities).post(create_identity))
@@ -421,11 +711,29 @@ pub fn routes() -> Router<Arc<AppState>> {
.put(update_identity) .put(update_identity)
.delete(delete_identity), .delete(delete_identity),
) )
.route(
"/identities/{id}/roles",
post(create_identity_role_assignment),
)
.route( .route(
"/identities/{id}/permissions", "/identities/{id}/permissions",
get(list_identity_permissions), get(list_identity_permissions),
) )
.route("/identities/{id}/freeze", post(freeze_identity))
.route("/identities/{id}/unfreeze", post(unfreeze_identity))
.route(
"/identities/roles/{id}",
delete(delete_identity_role_assignment),
)
.route("/permissions/sets", get(list_permission_sets)) .route("/permissions/sets", get(list_permission_sets))
.route(
"/permissions/sets/{id}/roles",
post(create_permission_set_role_assignment),
)
.route(
"/permissions/sets/roles/{id}",
delete(delete_permission_set_role_assignment),
)
.route( .route(
"/permissions/assignments", "/permissions/assignments",
post(create_permission_assignment), post(create_permission_assignment),
@@ -488,20 +796,82 @@ impl From<Identity> for IdentitySummary {
id: value.id, id: value.id,
login: value.login, login: value.login,
display_name: value.display_name, display_name: value.display_name,
frozen: value.frozen,
attributes: value.attributes, attributes: value.attributes,
roles: Vec::new(),
} }
} }
} }
impl From<PermissionSet> for PermissionSetSummary { impl From<IdentityRoleAssignment> for IdentityRoleAssignmentResponse {
fn from(value: PermissionSet) -> Self { fn from(value: IdentityRoleAssignment) -> Self {
Self { Self {
id: value.id, id: value.id,
r#ref: value.r#ref, identity_id: value.identity,
pack_ref: value.pack_ref, role: value.role,
label: value.label, source: value.source,
description: value.description, managed: value.managed,
grants: value.grants, created: value.created,
updated: value.updated,
} }
} }
} }
impl From<Identity> for IdentityResponse {
fn from(value: Identity) -> Self {
Self {
id: value.id,
login: value.login,
display_name: value.display_name,
frozen: value.frozen,
attributes: value.attributes,
roles: Vec::new(),
direct_permissions: Vec::new(),
}
}
}
async fn set_identity_frozen(
state: &Arc<AppState>,
user: &crate::auth::middleware::AuthenticatedUser,
identity_id: i64,
frozen: bool,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(state, user, Resource::Identities, Action::Update).await?;
let caller_identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
if caller_identity_id == identity_id && frozen {
return Err(ApiError::BadRequest(
"Refusing to freeze the currently authenticated identity".to_string(),
));
}
IdentityRepository::find_by_id(&state.db, identity_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
IdentityRepository::update(
&state.db,
identity_id,
UpdateIdentityInput {
display_name: None,
password_hash: None,
attributes: None,
frozen: Some(frozen),
},
)
.await?;
let message = if frozen {
"Identity frozen successfully"
} else {
"Identity unfrozen successfully"
};
Ok((
StatusCode::OK,
Json(ApiResponse::new(SuccessResponse::new(message))),
))
}

View File

@@ -20,7 +20,7 @@ use attune_common::repositories::{
pack::PackRepository, pack::PackRepository,
rule::{CreateRuleInput, RuleRepository, RuleSearchFilters, UpdateRuleInput}, rule::{CreateRuleInput, RuleRepository, RuleSearchFilters, UpdateRuleInput},
trigger::TriggerRepository, trigger::TriggerRepository,
Create, Delete, FindByRef, Update, Create, Delete, FindByRef, Patch, Update,
}; };
use crate::{ use crate::{
@@ -474,7 +474,7 @@ pub async fn update_rule(
// Create update input // Create update input
let update_input = UpdateRuleInput { let update_input = UpdateRuleInput {
label: request.label, label: request.label,
description: request.description, description: request.description.map(Patch::Set),
conditions: request.conditions, conditions: request.conditions,
action_params: request.action_params, action_params: request.action_params,
trigger_params: request.trigger_params, trigger_params: request.trigger_params,

View File

@@ -724,7 +724,7 @@ pub async fn update_sensor(
// Create update input // Create update input
let update_input = UpdateSensorInput { let update_input = UpdateSensorInput {
label: request.label, label: request.label,
description: request.description, description: request.description.map(Patch::Set),
entrypoint: request.entrypoint, entrypoint: request.entrypoint,
runtime: None, runtime: None,
runtime_ref: None, runtime_ref: None,

View File

@@ -20,8 +20,11 @@ use attune_common::{
}, },
}; };
use attune_common::rbac::{Action, AuthorizationContext, Resource};
use crate::{ use crate::{
auth::middleware::RequireAuth, auth::middleware::RequireAuth,
authz::{AuthorizationCheck, AuthorizationService},
dto::{ dto::{
trigger::TriggerResponse, trigger::TriggerResponse,
webhook::{WebhookReceiverRequest, WebhookReceiverResponse}, webhook::{WebhookReceiverRequest, WebhookReceiverResponse},
@@ -170,7 +173,7 @@ fn get_webhook_config_array(
)] )]
pub async fn enable_webhook( pub async fn enable_webhook(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Path(trigger_ref): Path<String>, Path(trigger_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// First, find the trigger by ref to get its ID // First, find the trigger by ref to get its ID
@@ -179,6 +182,26 @@ pub async fn enable_webhook(
.map_err(|e| ApiError::InternalServerError(e.to_string()))? .map_err(|e| ApiError::InternalServerError(e.to_string()))?
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_ref = Some(trigger.r#ref.clone());
ctx.pack_ref = trigger.pack_ref.clone();
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Triggers,
action: Action::Update,
context: ctx,
},
)
.await?;
}
// Enable webhooks for this trigger // Enable webhooks for this trigger
let _webhook_info = TriggerRepository::enable_webhook(&state.db, trigger.id) let _webhook_info = TriggerRepository::enable_webhook(&state.db, trigger.id)
.await .await
@@ -213,7 +236,7 @@ pub async fn enable_webhook(
)] )]
pub async fn disable_webhook( pub async fn disable_webhook(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Path(trigger_ref): Path<String>, Path(trigger_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// First, find the trigger by ref to get its ID // First, find the trigger by ref to get its ID
@@ -222,6 +245,26 @@ pub async fn disable_webhook(
.map_err(|e| ApiError::InternalServerError(e.to_string()))? .map_err(|e| ApiError::InternalServerError(e.to_string()))?
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_ref = Some(trigger.r#ref.clone());
ctx.pack_ref = trigger.pack_ref.clone();
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Triggers,
action: Action::Update,
context: ctx,
},
)
.await?;
}
// Disable webhooks for this trigger // Disable webhooks for this trigger
TriggerRepository::disable_webhook(&state.db, trigger.id) TriggerRepository::disable_webhook(&state.db, trigger.id)
.await .await
@@ -257,7 +300,7 @@ pub async fn disable_webhook(
)] )]
pub async fn regenerate_webhook_key( pub async fn regenerate_webhook_key(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth, RequireAuth(user): RequireAuth,
Path(trigger_ref): Path<String>, Path(trigger_ref): Path<String>,
) -> ApiResult<impl IntoResponse> { ) -> ApiResult<impl IntoResponse> {
// First, find the trigger by ref to get its ID // First, find the trigger by ref to get its ID
@@ -266,6 +309,26 @@ pub async fn regenerate_webhook_key(
.map_err(|e| ApiError::InternalServerError(e.to_string()))? .map_err(|e| ApiError::InternalServerError(e.to_string()))?
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?; .ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
if user.claims.token_type == crate::auth::jwt::TokenType::Access {
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_ref = Some(trigger.r#ref.clone());
ctx.pack_ref = trigger.pack_ref.clone();
authz
.authorize(
&user,
AuthorizationCheck {
resource: Resource::Triggers,
action: Action::Update,
context: ctx,
},
)
.await?;
}
// Check if webhooks are enabled // Check if webhooks are enabled
if !trigger.webhook_enabled { if !trigger.webhook_enabled {
return Err(ApiError::BadRequest( return Err(ApiError::BadRequest(

View File

@@ -18,7 +18,7 @@ use attune_common::repositories::{
CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput, WorkflowDefinitionRepository, CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput, WorkflowDefinitionRepository,
WorkflowSearchFilters, WorkflowSearchFilters,
}, },
Create, Delete, FindByRef, Update, Create, Delete, FindByRef, Patch, Update,
}; };
use crate::{ use crate::{
@@ -217,7 +217,7 @@ pub async fn create_workflow(
pack.id, pack.id,
&pack.r#ref, &pack.r#ref,
&request.label, &request.label,
&request.description.clone().unwrap_or_default(), request.description.as_deref(),
"workflow", "workflow",
request.param_schema.as_ref(), request.param_schema.as_ref(),
request.out_schema.as_ref(), request.out_schema.as_ref(),
@@ -416,7 +416,7 @@ pub async fn save_workflow_file(
pack.id, pack.id,
&pack.r#ref, &pack.r#ref,
&request.label, &request.label,
&request.description.clone().unwrap_or_default(), request.description.as_deref(),
&entrypoint, &entrypoint,
request.param_schema.as_ref(), request.param_schema.as_ref(),
request.out_schema.as_ref(), request.out_schema.as_ref(),
@@ -499,7 +499,7 @@ pub async fn update_workflow_file(
pack.id, pack.id,
&pack.r#ref, &pack.r#ref,
&request.label, &request.label,
&request.description.unwrap_or_default(), request.description.as_deref(),
&entrypoint, &entrypoint,
request.param_schema.as_ref(), request.param_schema.as_ref(),
request.out_schema.as_ref(), request.out_schema.as_ref(),
@@ -702,7 +702,7 @@ async fn create_companion_action(
pack_id: i64, pack_id: i64,
pack_ref: &str, pack_ref: &str,
label: &str, label: &str,
description: &str, description: Option<&str>,
entrypoint: &str, entrypoint: &str,
param_schema: Option<&serde_json::Value>, param_schema: Option<&serde_json::Value>,
out_schema: Option<&serde_json::Value>, out_schema: Option<&serde_json::Value>,
@@ -713,7 +713,7 @@ async fn create_companion_action(
pack: pack_id, pack: pack_id,
pack_ref: pack_ref.to_string(), pack_ref: pack_ref.to_string(),
label: label.to_string(), label: label.to_string(),
description: description.to_string(), description: description.map(|s| s.to_string()),
entrypoint: entrypoint.to_string(), entrypoint: entrypoint.to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,
@@ -787,7 +787,7 @@ async fn update_companion_action(
if let Some(action) = existing_action { if let Some(action) = existing_action {
let update_input = UpdateActionInput { let update_input = UpdateActionInput {
label: label.map(|s| s.to_string()), label: label.map(|s| s.to_string()),
description: description.map(|s| s.to_string()), description: description.map(|s| Patch::Set(s.to_string())),
entrypoint: None, entrypoint: None,
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,
@@ -838,7 +838,7 @@ async fn ensure_companion_action(
pack_id: i64, pack_id: i64,
pack_ref: &str, pack_ref: &str,
label: &str, label: &str,
description: &str, description: Option<&str>,
entrypoint: &str, entrypoint: &str,
param_schema: Option<&serde_json::Value>, param_schema: Option<&serde_json::Value>,
out_schema: Option<&serde_json::Value>, out_schema: Option<&serde_json::Value>,
@@ -853,7 +853,10 @@ async fn ensure_companion_action(
// Update existing companion action // Update existing companion action
let update_input = UpdateActionInput { let update_input = UpdateActionInput {
label: Some(label.to_string()), label: Some(label.to_string()),
description: Some(description.to_string()), description: Some(match description {
Some(description) => Patch::Set(description.to_string()),
None => Patch::Clear,
}),
entrypoint: Some(entrypoint.to_string()), entrypoint: Some(entrypoint.to_string()),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,

View File

@@ -362,7 +362,7 @@ mod tests {
pack: 1, pack: 1,
pack_ref: "test".to_string(), pack_ref: "test".to_string(),
label: "Test Action".to_string(), label: "Test Action".to_string(),
description: "Test action".to_string(), description: Some("Test action".to_string()),
entrypoint: "test.sh".to_string(), entrypoint: "test.sh".to_string(),
runtime: Some(1), runtime: Some(1),
runtime_version_constraint: None, runtime_version_constraint: None,

View File

@@ -241,6 +241,7 @@ impl TestContext {
} }
/// Create and authenticate a test user /// Create and authenticate a test user
#[allow(dead_code)]
pub async fn with_auth(mut self) -> Result<Self> { pub async fn with_auth(mut self) -> Result<Self> {
// Generate unique username to avoid conflicts in parallel tests // Generate unique username to avoid conflicts in parallel tests
let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "")[..8].to_string(); let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "")[..8].to_string();
@@ -394,6 +395,7 @@ impl TestContext {
} }
/// Get authenticated token /// Get authenticated token
#[allow(dead_code)]
pub fn token(&self) -> Option<&str> { pub fn token(&self) -> Option<&str> {
self.token.as_deref() self.token.as_deref()
} }
@@ -495,7 +497,7 @@ pub async fn create_test_action(pool: &PgPool, pack_id: i64, ref_name: &str) ->
pack: pack_id, pack: pack_id,
pack_ref: format!("pack_{}", pack_id), pack_ref: format!("pack_{}", pack_id),
label: format!("Test Action {}", ref_name), label: format!("Test Action {}", ref_name),
description: format!("Test action for {}", ref_name), description: Some(format!("Test action for {}", ref_name)),
entrypoint: "main.py".to_string(), entrypoint: "main.py".to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,

View File

@@ -0,0 +1,276 @@
use axum::http::StatusCode;
use helpers::*;
use serde_json::json;
use attune_common::{
models::enums::{ArtifactType, ArtifactVisibility, OwnerType, RetentionPolicyType},
repositories::{
artifact::{ArtifactRepository, CreateArtifactInput},
identity::{
CreatePermissionAssignmentInput, CreatePermissionSetInput, IdentityRepository,
PermissionAssignmentRepository, PermissionSetRepository,
},
key::{CreateKeyInput, KeyRepository},
Create,
},
};
mod helpers;
async fn register_scoped_user(
ctx: &TestContext,
login: &str,
grants: serde_json::Value,
) -> Result<String> {
let response = ctx
.post(
"/auth/register",
json!({
"login": login,
"password": "TestPassword123!",
"display_name": format!("Scoped User {}", login),
}),
None,
)
.await?;
assert_eq!(response.status(), StatusCode::CREATED);
let body: serde_json::Value = response.json().await?;
let token = body["data"]["access_token"]
.as_str()
.expect("missing access token")
.to_string();
let identity = IdentityRepository::find_by_login(&ctx.pool, login)
.await?
.expect("registered identity should exist");
let permset = PermissionSetRepository::create(
&ctx.pool,
CreatePermissionSetInput {
r#ref: format!("test.scoped_{}", uuid::Uuid::new_v4().simple()),
pack: None,
pack_ref: None,
label: Some("Scoped Test Permission Set".to_string()),
description: Some("Scoped test grants".to_string()),
grants,
},
)
.await?;
PermissionAssignmentRepository::create(
&ctx.pool,
CreatePermissionAssignmentInput {
identity: identity.id,
permset: permset.id,
},
)
.await?;
Ok(token)
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_pack_scoped_key_permissions_enforce_owner_refs() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let token = register_scoped_user(
&ctx,
&format!("scoped_keys_{}", uuid::Uuid::new_v4().simple()),
json!([
{
"resource": "keys",
"actions": ["read"],
"constraints": {
"owner_types": ["pack"],
"owner_refs": ["python_example"]
}
}
]),
)
.await
.expect("Failed to register scoped user");
KeyRepository::create(
&ctx.pool,
CreateKeyInput {
r#ref: format!("python_example_key_{}", uuid::Uuid::new_v4().simple()),
owner_type: OwnerType::Pack,
owner: Some("python_example".to_string()),
owner_identity: None,
owner_pack: None,
owner_pack_ref: Some("python_example".to_string()),
owner_action: None,
owner_action_ref: None,
owner_sensor: None,
owner_sensor_ref: None,
name: "Python Example Key".to_string(),
encrypted: false,
encryption_key_hash: None,
value: json!("allowed"),
},
)
.await
.expect("Failed to create scoped key");
let blocked_key = KeyRepository::create(
&ctx.pool,
CreateKeyInput {
r#ref: format!("other_pack_key_{}", uuid::Uuid::new_v4().simple()),
owner_type: OwnerType::Pack,
owner: Some("other_pack".to_string()),
owner_identity: None,
owner_pack: None,
owner_pack_ref: Some("other_pack".to_string()),
owner_action: None,
owner_action_ref: None,
owner_sensor: None,
owner_sensor_ref: None,
name: "Other Pack Key".to_string(),
encrypted: false,
encryption_key_hash: None,
value: json!("blocked"),
},
)
.await
.expect("Failed to create blocked key");
let allowed_list = ctx
.get("/api/v1/keys", Some(&token))
.await
.expect("Failed to list keys");
assert_eq!(allowed_list.status(), StatusCode::OK);
let allowed_body: serde_json::Value = allowed_list.json().await.expect("Invalid key list");
assert_eq!(
allowed_body["data"]
.as_array()
.expect("expected list")
.len(),
1
);
assert_eq!(allowed_body["data"][0]["owner"], "python_example");
let blocked_get = ctx
.get(&format!("/api/v1/keys/{}", blocked_key.r#ref), Some(&token))
.await
.expect("Failed to fetch blocked key");
assert_eq!(blocked_get.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
#[ignore = "integration test — requires database"]
async fn test_pack_scoped_artifact_permissions_enforce_owner_refs() {
let ctx = TestContext::new()
.await
.expect("Failed to create test context");
let token = register_scoped_user(
&ctx,
&format!("scoped_artifacts_{}", uuid::Uuid::new_v4().simple()),
json!([
{
"resource": "artifacts",
"actions": ["read", "create"],
"constraints": {
"owner_types": ["pack"],
"owner_refs": ["python_example"]
}
}
]),
)
.await
.expect("Failed to register scoped user");
let allowed_artifact = ArtifactRepository::create(
&ctx.pool,
CreateArtifactInput {
r#ref: format!("python_example.allowed_{}", uuid::Uuid::new_v4().simple()),
scope: OwnerType::Pack,
owner: "python_example".to_string(),
r#type: ArtifactType::FileText,
visibility: ArtifactVisibility::Private,
retention_policy: RetentionPolicyType::Versions,
retention_limit: 5,
name: Some("Allowed Artifact".to_string()),
description: None,
content_type: Some("text/plain".to_string()),
execution: None,
data: None,
},
)
.await
.expect("Failed to create allowed artifact");
let blocked_artifact = ArtifactRepository::create(
&ctx.pool,
CreateArtifactInput {
r#ref: format!("other_pack.blocked_{}", uuid::Uuid::new_v4().simple()),
scope: OwnerType::Pack,
owner: "other_pack".to_string(),
r#type: ArtifactType::FileText,
visibility: ArtifactVisibility::Private,
retention_policy: RetentionPolicyType::Versions,
retention_limit: 5,
name: Some("Blocked Artifact".to_string()),
description: None,
content_type: Some("text/plain".to_string()),
execution: None,
data: None,
},
)
.await
.expect("Failed to create blocked artifact");
let allowed_get = ctx
.get(
&format!("/api/v1/artifacts/{}", allowed_artifact.id),
Some(&token),
)
.await
.expect("Failed to fetch allowed artifact");
assert_eq!(allowed_get.status(), StatusCode::OK);
let blocked_get = ctx
.get(
&format!("/api/v1/artifacts/{}", blocked_artifact.id),
Some(&token),
)
.await
.expect("Failed to fetch blocked artifact");
assert_eq!(blocked_get.status(), StatusCode::NOT_FOUND);
let create_allowed = ctx
.post(
"/api/v1/artifacts",
json!({
"ref": format!("python_example.created_{}", uuid::Uuid::new_v4().simple()),
"scope": "pack",
"owner": "python_example",
"type": "file_text",
"name": "Created Artifact"
}),
Some(&token),
)
.await
.expect("Failed to create allowed artifact");
assert_eq!(create_allowed.status(), StatusCode::CREATED);
let create_blocked = ctx
.post(
"/api/v1/artifacts",
json!({
"ref": format!("other_pack.created_{}", uuid::Uuid::new_v4().simple()),
"scope": "pack",
"owner": "other_pack",
"type": "file_text",
"name": "Blocked Artifact"
}),
Some(&token),
)
.await
.expect("Failed to create blocked artifact");
assert_eq!(create_blocked.status(), StatusCode::FORBIDDEN);
}

View File

@@ -52,7 +52,7 @@ async fn setup_test_pack_and_action(pool: &PgPool) -> Result<(Pack, Action)> {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Action".to_string(), label: "Test Action".to_string(),
description: "Test action for SSE tests".to_string(), description: Some("Test action for SSE tests".to_string()),
entrypoint: "test.sh".to_string(), entrypoint: "test.sh".to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,

View File

@@ -90,7 +90,7 @@ struct Action {
action_ref: String, action_ref: String,
pack_ref: String, pack_ref: String,
label: String, label: String,
description: String, description: Option<String>,
entrypoint: String, entrypoint: String,
runtime: Option<i64>, runtime: Option<i64>,
created: String, created: String,
@@ -105,7 +105,7 @@ struct ActionDetail {
pack: i64, pack: i64,
pack_ref: String, pack_ref: String,
label: String, label: String,
description: String, description: Option<String>,
entrypoint: String, entrypoint: String,
runtime: Option<i64>, runtime: Option<i64>,
param_schema: Option<serde_json::Value>, param_schema: Option<serde_json::Value>,
@@ -253,7 +253,7 @@ async fn handle_list(
.runtime .runtime
.map(|r| r.to_string()) .map(|r| r.to_string())
.unwrap_or_else(|| "none".to_string()), .unwrap_or_else(|| "none".to_string()),
output::truncate(&action.description, 40), output::truncate(&action.description.unwrap_or_default(), 40),
]); ]);
} }
@@ -288,7 +288,10 @@ async fn handle_show(
("Reference", action.action_ref.clone()), ("Reference", action.action_ref.clone()),
("Pack", action.pack_ref.clone()), ("Pack", action.pack_ref.clone()),
("Label", action.label.clone()), ("Label", action.label.clone()),
("Description", action.description.clone()), (
"Description",
action.description.unwrap_or_else(|| "None".to_string()),
),
("Entry Point", action.entrypoint.clone()), ("Entry Point", action.entrypoint.clone()),
( (
"Runtime", "Runtime",
@@ -356,7 +359,10 @@ async fn handle_update(
("Ref", action.action_ref.clone()), ("Ref", action.action_ref.clone()),
("Pack", action.pack_ref.clone()), ("Pack", action.pack_ref.clone()),
("Label", action.label.clone()), ("Label", action.label.clone()),
("Description", action.description.clone()), (
"Description",
action.description.unwrap_or_else(|| "None".to_string()),
),
("Entrypoint", action.entrypoint.clone()), ("Entrypoint", action.entrypoint.clone()),
( (
"Runtime", "Runtime",

View File

@@ -112,7 +112,7 @@ struct Rule {
pack: Option<i64>, pack: Option<i64>,
pack_ref: String, pack_ref: String,
label: String, label: String,
description: String, description: Option<String>,
#[serde(default)] #[serde(default)]
trigger: Option<i64>, trigger: Option<i64>,
trigger_ref: String, trigger_ref: String,
@@ -133,7 +133,7 @@ struct RuleDetail {
pack: Option<i64>, pack: Option<i64>,
pack_ref: String, pack_ref: String,
label: String, label: String,
description: String, description: Option<String>,
#[serde(default)] #[serde(default)]
trigger: Option<i64>, trigger: Option<i64>,
trigger_ref: String, trigger_ref: String,
@@ -321,7 +321,10 @@ async fn handle_show(
("Ref", rule.rule_ref.clone()), ("Ref", rule.rule_ref.clone()),
("Pack", rule.pack_ref.clone()), ("Pack", rule.pack_ref.clone()),
("Label", rule.label.clone()), ("Label", rule.label.clone()),
("Description", rule.description.clone()), (
"Description",
rule.description.unwrap_or_else(|| "None".to_string()),
),
("Trigger", rule.trigger_ref.clone()), ("Trigger", rule.trigger_ref.clone()),
("Action", rule.action_ref.clone()), ("Action", rule.action_ref.clone()),
("Enabled", output::format_bool(rule.enabled)), ("Enabled", output::format_bool(rule.enabled)),
@@ -440,7 +443,10 @@ async fn handle_update(
("Ref", rule.rule_ref.clone()), ("Ref", rule.rule_ref.clone()),
("Pack", rule.pack_ref.clone()), ("Pack", rule.pack_ref.clone()),
("Label", rule.label.clone()), ("Label", rule.label.clone()),
("Description", rule.description.clone()), (
"Description",
rule.description.unwrap_or_else(|| "None".to_string()),
),
("Trigger", rule.trigger_ref.clone()), ("Trigger", rule.trigger_ref.clone()),
("Action", rule.action_ref.clone()), ("Action", rule.action_ref.clone()),
("Enabled", output::format_bool(rule.enabled)), ("Enabled", output::format_bool(rule.enabled)),

View File

@@ -887,7 +887,7 @@ pub mod trigger {
pub pack: Option<Id>, pub pack: Option<Id>,
pub pack_ref: Option<String>, pub pack_ref: Option<String>,
pub label: String, pub label: String,
pub description: String, pub description: Option<String>,
pub entrypoint: String, pub entrypoint: String,
pub runtime: Id, pub runtime: Id,
pub runtime_ref: String, pub runtime_ref: String,
@@ -915,7 +915,7 @@ pub mod action {
pub pack: Id, pub pack: Id,
pub pack_ref: String, pub pack_ref: String,
pub label: String, pub label: String,
pub description: String, pub description: Option<String>,
pub entrypoint: String, pub entrypoint: String,
pub runtime: Option<Id>, pub runtime: Option<Id>,
/// Optional semver version constraint for the runtime /// Optional semver version constraint for the runtime
@@ -965,7 +965,7 @@ pub mod rule {
pub pack: Id, pub pack: Id,
pub pack_ref: String, pub pack_ref: String,
pub label: String, pub label: String,
pub description: String, pub description: Option<String>,
pub action: Option<Id>, pub action: Option<Id>,
pub action_ref: String, pub action_ref: String,
pub trigger: Option<Id>, pub trigger: Option<Id>,
@@ -1221,6 +1221,7 @@ pub mod identity {
pub display_name: Option<String>, pub display_name: Option<String>,
pub password_hash: Option<String>, pub password_hash: Option<String>,
pub attributes: JsonDict, pub attributes: JsonDict,
pub frozen: bool,
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
pub updated: DateTime<Utc>, pub updated: DateTime<Utc>,
} }
@@ -1245,6 +1246,25 @@ pub mod identity {
pub permset: Id, pub permset: Id,
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct IdentityRoleAssignment {
pub id: Id,
pub identity: Id,
pub role: String,
pub source: String,
pub managed: bool,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct PermissionSetRoleAssignment {
pub id: Id,
pub permset: Id,
pub role: String,
pub created: DateTime<Utc>,
}
} }
/// Key/Value storage /// Key/Value storage

View File

@@ -725,8 +725,7 @@ impl<'a> PackComponentLoader<'a> {
let description = data let description = data
.get("description") .get("description")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("") .map(|s| s.to_string());
.to_string();
let enabled = data let enabled = data
.get("enabled") .get("enabled")
@@ -745,7 +744,10 @@ impl<'a> PackComponentLoader<'a> {
if let Some(existing) = TriggerRepository::find_by_ref(self.pool, &trigger_ref).await? { if let Some(existing) = TriggerRepository::find_by_ref(self.pool, &trigger_ref).await? {
let update_input = UpdateTriggerInput { let update_input = UpdateTriggerInput {
label: Some(label), label: Some(label),
description: Some(Patch::Set(description)), description: Some(match description {
Some(description) => Patch::Set(description),
None => Patch::Clear,
}),
enabled: Some(enabled), enabled: Some(enabled),
param_schema: Some(match param_schema { param_schema: Some(match param_schema {
Some(value) => Patch::Set(value), Some(value) => Patch::Set(value),
@@ -778,7 +780,7 @@ impl<'a> PackComponentLoader<'a> {
pack: Some(self.pack_id), pack: Some(self.pack_id),
pack_ref: Some(self.pack_ref.clone()), pack_ref: Some(self.pack_ref.clone()),
label, label,
description: Some(description), description,
enabled, enabled,
param_schema, param_schema,
out_schema, out_schema,
@@ -858,8 +860,7 @@ impl<'a> PackComponentLoader<'a> {
let description = data let description = data
.get("description") .get("description")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("") .map(|s| s.to_string());
.to_string();
// ── Workflow file handling ────────────────────────────────── // ── Workflow file handling ──────────────────────────────────
// If the action declares `workflow_file`, load the referenced // If the action declares `workflow_file`, load the referenced
@@ -876,7 +877,7 @@ impl<'a> PackComponentLoader<'a> {
wf_path, wf_path,
&action_ref, &action_ref,
&label, &label,
&description, description.as_deref().unwrap_or(""),
&data, &data,
) )
.await .await
@@ -956,7 +957,10 @@ impl<'a> PackComponentLoader<'a> {
if let Some(existing) = ActionRepository::find_by_ref(self.pool, &action_ref).await? { if let Some(existing) = ActionRepository::find_by_ref(self.pool, &action_ref).await? {
let update_input = UpdateActionInput { let update_input = UpdateActionInput {
label: Some(label), label: Some(label),
description: Some(description), description: Some(match description {
Some(description) => Patch::Set(description),
None => Patch::Clear,
}),
entrypoint: Some(entrypoint), entrypoint: Some(entrypoint),
runtime: runtime_id, runtime: runtime_id,
runtime_version_constraint: Some(match runtime_version_constraint { runtime_version_constraint: Some(match runtime_version_constraint {
@@ -1310,8 +1314,7 @@ impl<'a> PackComponentLoader<'a> {
let description = data let description = data
.get("description") .get("description")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("") .map(|s| s.to_string());
.to_string();
let enabled = data let enabled = data
.get("enabled") .get("enabled")
@@ -1347,7 +1350,10 @@ impl<'a> PackComponentLoader<'a> {
if let Some(existing) = SensorRepository::find_by_ref(self.pool, &sensor_ref).await? { if let Some(existing) = SensorRepository::find_by_ref(self.pool, &sensor_ref).await? {
let update_input = UpdateSensorInput { let update_input = UpdateSensorInput {
label: Some(label), label: Some(label),
description: Some(description), description: Some(match description {
Some(description) => Patch::Set(description),
None => Patch::Clear,
}),
entrypoint: Some(entrypoint), entrypoint: Some(entrypoint),
runtime: Some(sensor_runtime_id), runtime: Some(sensor_runtime_id),
runtime_ref: Some(sensor_runtime_ref.clone()), runtime_ref: Some(sensor_runtime_ref.clone()),

View File

@@ -21,10 +21,6 @@ pub enum Resource {
Inquiries, Inquiries,
Keys, Keys,
Artifacts, Artifacts,
Workflows,
Webhooks,
Analytics,
History,
Identities, Identities,
Permissions, Permissions,
} }
@@ -40,6 +36,7 @@ pub enum Action {
Cancel, Cancel,
Respond, Respond,
Manage, Manage,
Decrypt,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -69,6 +66,8 @@ pub struct GrantConstraints {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_types: Option<Vec<OwnerType>>, pub owner_types: Option<Vec<OwnerType>>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_refs: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<Vec<ArtifactVisibility>>, pub visibility: Option<Vec<ArtifactVisibility>>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_scope: Option<ExecutionScopeConstraint>, pub execution_scope: Option<ExecutionScopeConstraint>,
@@ -99,6 +98,7 @@ pub struct AuthorizationContext {
pub pack_ref: Option<String>, pub pack_ref: Option<String>,
pub owner_identity_id: Option<Id>, pub owner_identity_id: Option<Id>,
pub owner_type: Option<OwnerType>, pub owner_type: Option<OwnerType>,
pub owner_ref: Option<String>,
pub visibility: Option<ArtifactVisibility>, pub visibility: Option<ArtifactVisibility>,
pub encrypted: Option<bool>, pub encrypted: Option<bool>,
pub execution_owner_identity_id: Option<Id>, pub execution_owner_identity_id: Option<Id>,
@@ -115,6 +115,7 @@ impl AuthorizationContext {
pack_ref: None, pack_ref: None,
owner_identity_id: None, owner_identity_id: None,
owner_type: None, owner_type: None,
owner_ref: None,
visibility: None, visibility: None,
encrypted: None, encrypted: None,
execution_owner_identity_id: None, execution_owner_identity_id: None,
@@ -162,6 +163,15 @@ impl Grant {
} }
} }
if let Some(owner_refs) = &constraints.owner_refs {
let Some(owner_ref) = &ctx.owner_ref else {
return false;
};
if !owner_refs.contains(owner_ref) {
return false;
}
}
if let Some(visibility) = &constraints.visibility { if let Some(visibility) = &constraints.visibility {
let Some(target_visibility) = ctx.visibility else { let Some(target_visibility) = ctx.visibility else {
return false; return false;
@@ -289,4 +299,28 @@ mod tests {
.insert("team".to_string(), json!("infra")); .insert("team".to_string(), json!("infra"));
assert!(!grant.allows(Resource::Packs, Action::Read, &ctx)); assert!(!grant.allows(Resource::Packs, Action::Read, &ctx));
} }
#[test]
fn owner_ref_constraint_requires_exact_value_match() {
let grant = Grant {
resource: Resource::Artifacts,
actions: vec![Action::Read],
constraints: Some(GrantConstraints {
owner_types: Some(vec![OwnerType::Pack]),
owner_refs: Some(vec!["python_example".to_string()]),
..Default::default()
}),
};
let mut ctx = AuthorizationContext::new(1);
ctx.owner_type = Some(OwnerType::Pack);
ctx.owner_ref = Some("python_example".to_string());
assert!(grant.allows(Resource::Artifacts, Action::Read, &ctx));
ctx.owner_ref = Some("other_pack".to_string());
assert!(!grant.allows(Resource::Artifacts, Action::Read, &ctx));
ctx.owner_ref = None;
assert!(!grant.allows(Resource::Artifacts, Action::Read, &ctx));
}
} }

View File

@@ -51,7 +51,7 @@ pub struct CreateActionInput {
pub pack: Id, pub pack: Id,
pub pack_ref: String, pub pack_ref: String,
pub label: String, pub label: String,
pub description: String, pub description: Option<String>,
pub entrypoint: String, pub entrypoint: String,
pub runtime: Option<Id>, pub runtime: Option<Id>,
pub runtime_version_constraint: Option<String>, pub runtime_version_constraint: Option<String>,
@@ -64,7 +64,7 @@ pub struct CreateActionInput {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct UpdateActionInput { pub struct UpdateActionInput {
pub label: Option<String>, pub label: Option<String>,
pub description: Option<String>, pub description: Option<Patch<String>>,
pub entrypoint: Option<String>, pub entrypoint: Option<String>,
pub runtime: Option<Id>, pub runtime: Option<Id>,
pub runtime_version_constraint: Option<Patch<String>>, pub runtime_version_constraint: Option<Patch<String>>,
@@ -210,7 +210,10 @@ impl Update for ActionRepository {
query.push(", "); query.push(", ");
} }
query.push("description = "); query.push("description = ");
query.push_bind(description); match description {
Patch::Set(value) => query.push_bind(value),
Patch::Clear => query.push_bind(Option::<String>::None),
};
has_updates = true; has_updates = true;
} }

View File

@@ -577,6 +577,14 @@ pub struct CreateArtifactVersionInput {
} }
impl ArtifactVersionRepository { impl ArtifactVersionRepository {
fn select_columns_with_alias(alias: &str) -> String {
format!(
"{alias}.id, {alias}.artifact, {alias}.version, {alias}.content_type, \
{alias}.size_bytes, NULL::bytea AS content, {alias}.content_json, \
{alias}.file_path, {alias}.meta, {alias}.created_by, {alias}.created"
)
}
/// Find a version by ID (without binary content for performance) /// Find a version by ID (without binary content for performance)
pub async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<ArtifactVersion>> pub async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<ArtifactVersion>>
where where
@@ -812,14 +820,11 @@ impl ArtifactVersionRepository {
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
let query = format!( let query = format!(
"SELECT av.{} \ "SELECT {} \
FROM artifact_version av \ FROM artifact_version av \
JOIN artifact a ON av.artifact = a.id \ JOIN artifact a ON av.artifact = a.id \
WHERE a.execution = $1 AND av.file_path IS NOT NULL", WHERE a.execution = $1 AND av.file_path IS NOT NULL",
artifact_version::SELECT_COLUMNS Self::select_columns_with_alias("av")
.split(", ")
.collect::<Vec<_>>()
.join(", av.")
); );
sqlx::query_as::<_, ArtifactVersion>(&query) sqlx::query_as::<_, ArtifactVersion>(&query)
.bind(execution_id) .bind(execution_id)
@@ -847,3 +852,18 @@ impl ArtifactVersionRepository {
.map_err(Into::into) .map_err(Into::into)
} }
} }
#[cfg(test)]
mod tests {
use super::ArtifactVersionRepository;
#[test]
fn aliased_select_columns_keep_null_content_expression_unqualified() {
let columns = ArtifactVersionRepository::select_columns_with_alias("av");
assert!(columns.contains("av.id"));
assert!(columns.contains("av.file_path"));
assert!(columns.contains("NULL::bytea AS content"));
assert!(!columns.contains("av.NULL::bytea AS content"));
}
}

View File

@@ -28,6 +28,7 @@ pub struct UpdateIdentityInput {
pub display_name: Option<String>, pub display_name: Option<String>,
pub password_hash: Option<String>, pub password_hash: Option<String>,
pub attributes: Option<JsonDict>, pub attributes: Option<JsonDict>,
pub frozen: Option<bool>,
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -37,7 +38,7 @@ impl FindById for IdentityRepository {
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
sqlx::query_as::<_, Identity>( sqlx::query_as::<_, Identity>(
"SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity WHERE id = $1" "SELECT id, login, display_name, password_hash, attributes, frozen, created, updated FROM identity WHERE id = $1"
).bind(id).fetch_optional(executor).await.map_err(Into::into) ).bind(id).fetch_optional(executor).await.map_err(Into::into)
} }
} }
@@ -49,7 +50,7 @@ impl List for IdentityRepository {
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
sqlx::query_as::<_, Identity>( sqlx::query_as::<_, Identity>(
"SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity ORDER BY login ASC" "SELECT id, login, display_name, password_hash, attributes, frozen, created, updated FROM identity ORDER BY login ASC"
).fetch_all(executor).await.map_err(Into::into) ).fetch_all(executor).await.map_err(Into::into)
} }
} }
@@ -62,7 +63,7 @@ impl Create for IdentityRepository {
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
sqlx::query_as::<_, Identity>( sqlx::query_as::<_, Identity>(
"INSERT INTO identity (login, display_name, password_hash, attributes) VALUES ($1, $2, $3, $4) RETURNING id, login, display_name, password_hash, attributes, created, updated" "INSERT INTO identity (login, display_name, password_hash, attributes) VALUES ($1, $2, $3, $4) RETURNING id, login, display_name, password_hash, attributes, frozen, created, updated"
) )
.bind(&input.login) .bind(&input.login)
.bind(&input.display_name) .bind(&input.display_name)
@@ -111,6 +112,13 @@ impl Update for IdentityRepository {
query.push("attributes = ").push_bind(attributes); query.push("attributes = ").push_bind(attributes);
has_updates = true; has_updates = true;
} }
if let Some(frozen) = input.frozen {
if has_updates {
query.push(", ");
}
query.push("frozen = ").push_bind(frozen);
has_updates = true;
}
if !has_updates { if !has_updates {
// No updates requested, fetch and return existing entity // No updates requested, fetch and return existing entity
@@ -119,7 +127,7 @@ impl Update for IdentityRepository {
query.push(", updated = NOW() WHERE id = ").push_bind(id); query.push(", updated = NOW() WHERE id = ").push_bind(id);
query.push( query.push(
" RETURNING id, login, display_name, password_hash, attributes, created, updated", " RETURNING id, login, display_name, password_hash, attributes, frozen, created, updated",
); );
query query
@@ -156,7 +164,7 @@ impl IdentityRepository {
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
sqlx::query_as::<_, Identity>( sqlx::query_as::<_, Identity>(
"SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity WHERE login = $1" "SELECT id, login, display_name, password_hash, attributes, frozen, created, updated FROM identity WHERE login = $1"
).bind(login).fetch_optional(executor).await.map_err(Into::into) ).bind(login).fetch_optional(executor).await.map_err(Into::into)
} }
@@ -169,7 +177,7 @@ impl IdentityRepository {
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
sqlx::query_as::<_, Identity>( sqlx::query_as::<_, Identity>(
"SELECT id, login, display_name, password_hash, attributes, created, updated "SELECT id, login, display_name, password_hash, attributes, frozen, created, updated
FROM identity FROM identity
WHERE attributes->'oidc'->>'issuer' = $1 WHERE attributes->'oidc'->>'issuer' = $1
AND attributes->'oidc'->>'sub' = $2", AND attributes->'oidc'->>'sub' = $2",
@@ -190,7 +198,7 @@ impl IdentityRepository {
E: Executor<'e, Database = Postgres> + 'e, E: Executor<'e, Database = Postgres> + 'e,
{ {
sqlx::query_as::<_, Identity>( sqlx::query_as::<_, Identity>(
"SELECT id, login, display_name, password_hash, attributes, created, updated "SELECT id, login, display_name, password_hash, attributes, frozen, created, updated
FROM identity FROM identity
WHERE attributes->'ldap'->>'server_url' = $1 WHERE attributes->'ldap'->>'server_url' = $1
AND attributes->'ldap'->>'dn' = $2", AND attributes->'ldap'->>'dn' = $2",
@@ -363,6 +371,27 @@ impl PermissionSetRepository {
.map_err(Into::into) .map_err(Into::into)
} }
pub async fn find_by_roles<'e, E>(executor: E, roles: &[String]) -> Result<Vec<PermissionSet>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
if roles.is_empty() {
return Ok(Vec::new());
}
sqlx::query_as::<_, PermissionSet>(
"SELECT DISTINCT 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_set_role_assignment psra ON psra.permset = ps.id
WHERE psra.role = ANY($1)
ORDER BY ps.ref ASC",
)
.bind(roles)
.fetch_all(executor)
.await
.map_err(Into::into)
}
/// Delete permission sets belonging to a pack whose refs are NOT in the given set. /// 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 /// Used during pack reinstallation to clean up permission sets that were
@@ -481,3 +510,231 @@ impl PermissionAssignmentRepository {
.map_err(Into::into) .map_err(Into::into)
} }
} }
pub struct IdentityRoleAssignmentRepository;
impl Repository for IdentityRoleAssignmentRepository {
type Entity = IdentityRoleAssignment;
fn table_name() -> &'static str {
"identity_role_assignment"
}
}
#[derive(Debug, Clone)]
pub struct CreateIdentityRoleAssignmentInput {
pub identity: Id,
pub role: String,
pub source: String,
pub managed: bool,
}
#[async_trait::async_trait]
impl FindById for IdentityRoleAssignmentRepository {
async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, IdentityRoleAssignment>(
"SELECT id, identity, role, source, managed, created, updated FROM identity_role_assignment WHERE id = $1"
)
.bind(id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl Create for IdentityRoleAssignmentRepository {
type CreateInput = CreateIdentityRoleAssignmentInput;
async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result<Self::Entity>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, IdentityRoleAssignment>(
"INSERT INTO identity_role_assignment (identity, role, source, managed)
VALUES ($1, $2, $3, $4)
RETURNING id, identity, role, source, managed, created, updated",
)
.bind(input.identity)
.bind(&input.role)
.bind(&input.source)
.bind(input.managed)
.fetch_one(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl Delete for IdentityRoleAssignmentRepository {
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM identity_role_assignment WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
Ok(result.rows_affected() > 0)
}
}
impl IdentityRoleAssignmentRepository {
pub async fn find_by_identity<'e, E>(
executor: E,
identity_id: Id,
) -> Result<Vec<IdentityRoleAssignment>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, IdentityRoleAssignment>(
"SELECT id, identity, role, source, managed, created, updated
FROM identity_role_assignment
WHERE identity = $1
ORDER BY role ASC",
)
.bind(identity_id)
.fetch_all(executor)
.await
.map_err(Into::into)
}
pub async fn find_role_names_by_identity<'e, E>(
executor: E,
identity_id: Id,
) -> Result<Vec<String>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_scalar::<_, String>(
"SELECT role FROM identity_role_assignment WHERE identity = $1 ORDER BY role ASC",
)
.bind(identity_id)
.fetch_all(executor)
.await
.map_err(Into::into)
}
pub async fn replace_managed_roles<'e, E>(
executor: E,
identity_id: Id,
source: &str,
roles: &[String],
) -> Result<()>
where
E: Executor<'e, Database = Postgres> + Copy + 'e,
{
sqlx::query(
"DELETE FROM identity_role_assignment WHERE identity = $1 AND source = $2 AND managed = true",
)
.bind(identity_id)
.bind(source)
.execute(executor)
.await?;
for role in roles {
sqlx::query(
"INSERT INTO identity_role_assignment (identity, role, source, managed)
VALUES ($1, $2, $3, true)
ON CONFLICT (identity, role) DO UPDATE
SET source = EXCLUDED.source,
managed = EXCLUDED.managed,
updated = NOW()",
)
.bind(identity_id)
.bind(role)
.bind(source)
.execute(executor)
.await?;
}
Ok(())
}
}
pub struct PermissionSetRoleAssignmentRepository;
impl Repository for PermissionSetRoleAssignmentRepository {
type Entity = PermissionSetRoleAssignment;
fn table_name() -> &'static str {
"permission_set_role_assignment"
}
}
#[derive(Debug, Clone)]
pub struct CreatePermissionSetRoleAssignmentInput {
pub permset: Id,
pub role: String,
}
#[async_trait::async_trait]
impl FindById for PermissionSetRoleAssignmentRepository {
async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, PermissionSetRoleAssignment>(
"SELECT id, permset, role, created FROM permission_set_role_assignment WHERE id = $1",
)
.bind(id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl Create for PermissionSetRoleAssignmentRepository {
type CreateInput = CreatePermissionSetRoleAssignmentInput;
async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result<Self::Entity>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, PermissionSetRoleAssignment>(
"INSERT INTO permission_set_role_assignment (permset, role)
VALUES ($1, $2)
RETURNING id, permset, role, created",
)
.bind(input.permset)
.bind(&input.role)
.fetch_one(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl Delete for PermissionSetRoleAssignmentRepository {
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM permission_set_role_assignment WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
Ok(result.rows_affected() > 0)
}
}
impl PermissionSetRoleAssignmentRepository {
pub async fn find_by_permission_set<'e, E>(
executor: E,
permset_id: Id,
) -> Result<Vec<PermissionSetRoleAssignment>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, PermissionSetRoleAssignment>(
"SELECT id, permset, role, created
FROM permission_set_role_assignment
WHERE permset = $1
ORDER BY role ASC",
)
.bind(permset_id)
.fetch_all(executor)
.await
.map_err(Into::into)
}
}

View File

@@ -6,7 +6,7 @@ use crate::models::{rule::*, Id};
use crate::{Error, Result}; use crate::{Error, Result};
use sqlx::{Executor, Postgres, QueryBuilder}; use sqlx::{Executor, Postgres, QueryBuilder};
use super::{Create, Delete, FindById, FindByRef, List, Repository, Update}; use super::{Create, Delete, FindById, FindByRef, List, Patch, Repository, Update};
/// Filters for [`RuleRepository::list_search`]. /// Filters for [`RuleRepository::list_search`].
/// ///
@@ -41,7 +41,7 @@ pub struct RestoreRuleInput {
pub pack: Id, pub pack: Id,
pub pack_ref: String, pub pack_ref: String,
pub label: String, pub label: String,
pub description: String, pub description: Option<String>,
pub action: Option<Id>, pub action: Option<Id>,
pub action_ref: String, pub action_ref: String,
pub trigger: Option<Id>, pub trigger: Option<Id>,
@@ -70,7 +70,7 @@ pub struct CreateRuleInput {
pub pack: Id, pub pack: Id,
pub pack_ref: String, pub pack_ref: String,
pub label: String, pub label: String,
pub description: String, pub description: Option<String>,
pub action: Id, pub action: Id,
pub action_ref: String, pub action_ref: String,
pub trigger: Id, pub trigger: Id,
@@ -86,7 +86,7 @@ pub struct CreateRuleInput {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct UpdateRuleInput { pub struct UpdateRuleInput {
pub label: Option<String>, pub label: Option<String>,
pub description: Option<String>, pub description: Option<Patch<String>>,
pub conditions: Option<serde_json::Value>, pub conditions: Option<serde_json::Value>,
pub action_params: Option<serde_json::Value>, pub action_params: Option<serde_json::Value>,
pub trigger_params: Option<serde_json::Value>, pub trigger_params: Option<serde_json::Value>,
@@ -228,7 +228,10 @@ impl Update for RuleRepository {
query.push(", "); query.push(", ");
} }
query.push("description = "); query.push("description = ");
query.push_bind(description); match description {
Patch::Set(value) => query.push_bind(value),
Patch::Clear => query.push_bind(Option::<String>::None),
};
has_updates = true; has_updates = true;
} }

View File

@@ -665,7 +665,7 @@ pub struct CreateSensorInput {
pub pack: Option<Id>, pub pack: Option<Id>,
pub pack_ref: Option<String>, pub pack_ref: Option<String>,
pub label: String, pub label: String,
pub description: String, pub description: Option<String>,
pub entrypoint: String, pub entrypoint: String,
pub runtime: Id, pub runtime: Id,
pub runtime_ref: String, pub runtime_ref: String,
@@ -681,7 +681,7 @@ pub struct CreateSensorInput {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct UpdateSensorInput { pub struct UpdateSensorInput {
pub label: Option<String>, pub label: Option<String>,
pub description: Option<String>, pub description: Option<Patch<String>>,
pub entrypoint: Option<String>, pub entrypoint: Option<String>,
pub runtime: Option<Id>, pub runtime: Option<Id>,
pub runtime_ref: Option<String>, pub runtime_ref: Option<String>,
@@ -830,7 +830,10 @@ impl Update for SensorRepository {
query.push(", "); query.push(", ");
} }
query.push("description = "); query.push("description = ");
query.push_bind(description); match description {
Patch::Set(value) => query.push_bind(value),
Patch::Clear => query.push_bind(Option::<String>::None),
};
has_updates = true; has_updates = true;
} }

View File

@@ -13,6 +13,7 @@
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::repositories::action::{ActionRepository, CreateActionInput, UpdateActionInput}; use crate::repositories::action::{ActionRepository, CreateActionInput, UpdateActionInput};
use crate::repositories::workflow::{CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput}; use crate::repositories::workflow::{CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput};
use crate::repositories::Patch;
use crate::repositories::{ use crate::repositories::{
Create, Delete, FindByRef, PackRepository, Update, WorkflowDefinitionRepository, Create, Delete, FindByRef, PackRepository, Update, WorkflowDefinitionRepository,
}; };
@@ -270,7 +271,7 @@ impl WorkflowRegistrar {
pack: pack_id, pack: pack_id,
pack_ref: pack_ref.to_string(), pack_ref: pack_ref.to_string(),
label: effective_label.to_string(), label: effective_label.to_string(),
description: workflow.description.clone().unwrap_or_default(), description: workflow.description.clone(),
entrypoint, entrypoint,
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,
@@ -317,7 +318,10 @@ impl WorkflowRegistrar {
// Update the existing companion action to stay in sync // Update the existing companion action to stay in sync
let update_input = UpdateActionInput { let update_input = UpdateActionInput {
label: Some(effective_label.to_string()), label: Some(effective_label.to_string()),
description: workflow.description.clone(), description: Some(match workflow.description.clone() {
Some(description) => Patch::Set(description),
None => Patch::Clear,
}),
entrypoint: Some(format!("workflows/{}.workflow.yaml", workflow_name)), entrypoint: Some(format!("workflows/{}.workflow.yaml", workflow_name)),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,

View File

@@ -66,7 +66,10 @@ async fn test_create_action_with_optional_fields() {
.unwrap(); .unwrap();
assert_eq!(action.label, "Full Test Action"); assert_eq!(action.label, "Full Test Action");
assert_eq!(action.description, "Action with all optional fields"); assert_eq!(
action.description,
Some("Action with all optional fields".to_string())
);
assert_eq!(action.entrypoint, "custom.py"); assert_eq!(action.entrypoint, "custom.py");
assert!(action.param_schema.is_some()); assert!(action.param_schema.is_some());
assert!(action.out_schema.is_some()); assert!(action.out_schema.is_some());
@@ -204,7 +207,9 @@ async fn test_update_action() {
let update = UpdateActionInput { let update = UpdateActionInput {
label: Some("Updated Label".to_string()), label: Some("Updated Label".to_string()),
description: Some("Updated description".to_string()), description: Some(attune_common::repositories::Patch::Set(
"Updated description".to_string(),
)),
..Default::default() ..Default::default()
}; };
@@ -214,7 +219,7 @@ async fn test_update_action() {
assert_eq!(updated.id, action.id); assert_eq!(updated.id, action.id);
assert_eq!(updated.label, "Updated Label"); assert_eq!(updated.label, "Updated Label");
assert_eq!(updated.description, "Updated description"); assert_eq!(updated.description, Some("Updated description".to_string()));
assert_eq!(updated.entrypoint, action.entrypoint); // Unchanged assert_eq!(updated.entrypoint, action.entrypoint); // Unchanged
assert!(updated.updated > original_updated); assert!(updated.updated > original_updated);
} }
@@ -338,7 +343,7 @@ async fn test_action_foreign_key_constraint() {
pack: 99999, pack: 99999,
pack_ref: "nonexistent.pack".to_string(), pack_ref: "nonexistent.pack".to_string(),
label: "Test Action".to_string(), label: "Test Action".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
entrypoint: "main.py".to_string(), entrypoint: "main.py".to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,

View File

@@ -49,7 +49,7 @@ async fn test_create_enforcement_minimal() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -121,7 +121,7 @@ async fn test_create_enforcement_with_event() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -189,7 +189,7 @@ async fn test_create_enforcement_with_conditions() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -255,7 +255,7 @@ async fn test_create_enforcement_with_any_condition() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -397,7 +397,7 @@ async fn test_find_enforcement_by_id() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -471,7 +471,7 @@ async fn test_get_enforcement_by_id() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -552,7 +552,7 @@ async fn test_list_enforcements() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -624,7 +624,7 @@ async fn test_update_enforcement_status() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -690,7 +690,7 @@ async fn test_update_enforcement_status_transitions() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -769,7 +769,7 @@ async fn test_update_enforcement_payload() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -832,7 +832,7 @@ async fn test_update_enforcement_both_fields() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -896,7 +896,7 @@ async fn test_update_enforcement_no_changes() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -981,7 +981,7 @@ async fn test_delete_enforcement() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -1056,7 +1056,7 @@ async fn test_find_enforcements_by_rule() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Rule 1".to_string(), label: "Rule 1".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -1078,7 +1078,7 @@ async fn test_find_enforcements_by_rule() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Rule 2".to_string(), label: "Rule 2".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -1149,7 +1149,7 @@ async fn test_find_enforcements_by_status() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -1239,7 +1239,7 @@ async fn test_find_enforcements_by_event() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -1324,7 +1324,7 @@ async fn test_delete_rule_sets_enforcement_rule_to_null() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -1390,7 +1390,7 @@ async fn test_enforcement_resolved_at_lifecycle() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,

View File

@@ -449,7 +449,7 @@ async fn test_delete_event_enforcement_retains_event_id() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,

View File

@@ -454,7 +454,7 @@ impl ActionFixture {
pack_ref: self.pack_ref, pack_ref: self.pack_ref,
r#ref: self.r#ref, r#ref: self.r#ref,
label: self.label, label: self.label,
description: self.description, description: Some(self.description),
entrypoint: self.entrypoint, entrypoint: self.entrypoint,
runtime: self.runtime, runtime: self.runtime,
runtime_version_constraint: None, runtime_version_constraint: None,
@@ -1088,7 +1088,7 @@ impl SensorFixture {
pack: self.pack_id, pack: self.pack_id,
pack_ref: self.pack_ref, pack_ref: self.pack_ref,
label: self.label, label: self.label,
description: self.description, description: Some(self.description),
entrypoint: self.entrypoint, entrypoint: self.entrypoint,
runtime: self.runtime_id, runtime: self.runtime_id,
runtime_ref: self.runtime_ref, runtime_ref: self.runtime_ref,

View File

@@ -219,6 +219,7 @@ async fn test_update_identity() {
display_name: Some("Updated Name".to_string()), display_name: Some("Updated Name".to_string()),
password_hash: None, password_hash: None,
attributes: Some(json!({"key": "updated", "new_key": "new_value"})), attributes: Some(json!({"key": "updated", "new_key": "new_value"})),
frozen: None,
}; };
let updated = IdentityRepository::update(&pool, identity.id, update_input) let updated = IdentityRepository::update(&pool, identity.id, update_input)
@@ -252,6 +253,7 @@ async fn test_update_identity_partial() {
display_name: Some("Only Display Name Changed".to_string()), display_name: Some("Only Display Name Changed".to_string()),
password_hash: None, password_hash: None,
attributes: None, attributes: None,
frozen: None,
}; };
let updated = IdentityRepository::update(&pool, identity.id, update_input) let updated = IdentityRepository::update(&pool, identity.id, update_input)
@@ -274,6 +276,7 @@ async fn test_update_identity_not_found() {
display_name: Some("Updated Name".to_string()), display_name: Some("Updated Name".to_string()),
password_hash: None, password_hash: None,
attributes: None, attributes: None,
frozen: None,
}; };
let result = IdentityRepository::update(&pool, 999999, update_input).await; let result = IdentityRepository::update(&pool, 999999, update_input).await;
@@ -380,6 +383,7 @@ async fn test_identity_updated_changes_on_update() {
display_name: Some("Updated".to_string()), display_name: Some("Updated".to_string()),
password_hash: None, password_hash: None,
attributes: None, attributes: None,
frozen: None,
}; };
let updated = IdentityRepository::update(&pool, identity.id, update_input) let updated = IdentityRepository::update(&pool, identity.id, update_input)

View File

@@ -8,7 +8,7 @@ mod helpers;
use attune_common::{ use attune_common::{
repositories::{ repositories::{
rule::{CreateRuleInput, RuleRepository, UpdateRuleInput}, rule::{CreateRuleInput, RuleRepository, UpdateRuleInput},
Create, Delete, FindById, FindByRef, List, Update, Create, Delete, FindById, FindByRef, List, Patch, Update,
}, },
Error, Error,
}; };
@@ -48,7 +48,7 @@ async fn test_create_rule() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "A test rule".to_string(), description: Some("A test rule".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -66,7 +66,7 @@ async fn test_create_rule() {
assert_eq!(rule.pack, pack.id); assert_eq!(rule.pack, pack.id);
assert_eq!(rule.pack_ref, pack.r#ref); assert_eq!(rule.pack_ref, pack.r#ref);
assert_eq!(rule.label, "Test Rule"); assert_eq!(rule.label, "Test Rule");
assert_eq!(rule.description, "A test rule"); assert_eq!(rule.description, Some("A test rule".to_string()));
assert_eq!(rule.action, Some(action.id)); assert_eq!(rule.action, Some(action.id));
assert_eq!(rule.action_ref, action.r#ref); assert_eq!(rule.action_ref, action.r#ref);
assert_eq!(rule.trigger, Some(trigger.id)); assert_eq!(rule.trigger, Some(trigger.id));
@@ -105,7 +105,7 @@ async fn test_create_rule_disabled() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Disabled Rule".to_string(), label: "Disabled Rule".to_string(),
description: "A disabled rule".to_string(), description: Some("A disabled rule".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -155,7 +155,7 @@ async fn test_create_rule_with_complex_conditions() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Complex Rule".to_string(), label: "Complex Rule".to_string(),
description: "Rule with complex conditions".to_string(), description: Some("Rule with complex conditions".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -200,7 +200,7 @@ async fn test_create_rule_duplicate_ref() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "First Rule".to_string(), label: "First Rule".to_string(),
description: "First".to_string(), description: Some("First".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -220,7 +220,7 @@ async fn test_create_rule_duplicate_ref() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Second Rule".to_string(), label: "Second Rule".to_string(),
description: "Second".to_string(), description: Some("Second".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -274,7 +274,7 @@ async fn test_create_rule_invalid_ref_format_uppercase() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Upper Rule".to_string(), label: "Upper Rule".to_string(),
description: "Invalid uppercase ref".to_string(), description: Some("Invalid uppercase ref".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -316,7 +316,7 @@ async fn test_create_rule_invalid_ref_format_no_dot() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "No Dot Rule".to_string(), label: "No Dot Rule".to_string(),
description: "Invalid ref without dot".to_string(), description: Some("Invalid ref without dot".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -362,7 +362,7 @@ async fn test_find_rule_by_id() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Find Rule".to_string(), label: "Find Rule".to_string(),
description: "Rule to find".to_string(), description: Some("Rule to find".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -422,7 +422,7 @@ async fn test_find_rule_by_ref() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Find By Ref Rule".to_string(), label: "Find By Ref Rule".to_string(),
description: "Find by ref".to_string(), description: Some("Find by ref".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -484,7 +484,7 @@ async fn test_list_rules() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: format!("List Rule {}", i), label: format!("List Rule {}", i),
description: format!("Rule {}", i), description: Some(format!("Rule {}", i)),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -538,7 +538,7 @@ async fn test_list_rules_ordered_by_ref() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: name.to_string(), label: name.to_string(),
description: name.to_string(), description: Some(name.to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -594,7 +594,7 @@ async fn test_update_rule_label() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Original Label".to_string(), label: "Original Label".to_string(),
description: "Original".to_string(), description: Some("Original".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -618,7 +618,7 @@ async fn test_update_rule_label() {
.unwrap(); .unwrap();
assert_eq!(updated.label, "Updated Label"); assert_eq!(updated.label, "Updated Label");
assert_eq!(updated.description, "Original"); // unchanged assert_eq!(updated.description, Some("Original".to_string())); // unchanged
assert!(updated.updated > created.updated); assert!(updated.updated > created.updated);
} }
@@ -647,7 +647,7 @@ async fn test_update_rule_description() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test".to_string(), label: "Test".to_string(),
description: "Old description".to_string(), description: Some("Old description".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -662,7 +662,7 @@ async fn test_update_rule_description() {
let created = RuleRepository::create(&pool, input).await.unwrap(); let created = RuleRepository::create(&pool, input).await.unwrap();
let update = UpdateRuleInput { let update = UpdateRuleInput {
description: Some("New description".to_string()), description: Some(Patch::Set("New description".to_string())),
..Default::default() ..Default::default()
}; };
@@ -670,7 +670,7 @@ async fn test_update_rule_description() {
.await .await
.unwrap(); .unwrap();
assert_eq!(updated.description, "New description"); assert_eq!(updated.description, Some("New description".to_string()));
} }
#[tokio::test] #[tokio::test]
@@ -698,7 +698,7 @@ async fn test_update_rule_conditions() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test".to_string(), label: "Test".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -750,7 +750,7 @@ async fn test_update_rule_enabled() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test".to_string(), label: "Test".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -803,7 +803,7 @@ async fn test_update_rule_multiple_fields() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Old".to_string(), label: "Old".to_string(),
description: "Old".to_string(), description: Some("Old".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -819,7 +819,7 @@ async fn test_update_rule_multiple_fields() {
let update = UpdateRuleInput { let update = UpdateRuleInput {
label: Some("New Label".to_string()), label: Some("New Label".to_string()),
description: Some("New Description".to_string()), description: Some(Patch::Set("New Description".to_string())),
conditions: Some(json!({"updated": true})), conditions: Some(json!({"updated": true})),
action_params: None, action_params: None,
trigger_params: None, trigger_params: None,
@@ -831,7 +831,7 @@ async fn test_update_rule_multiple_fields() {
.unwrap(); .unwrap();
assert_eq!(updated.label, "New Label"); assert_eq!(updated.label, "New Label");
assert_eq!(updated.description, "New Description"); assert_eq!(updated.description, Some("New Description".to_string()));
assert_eq!(updated.conditions, json!({"updated": true})); assert_eq!(updated.conditions, json!({"updated": true}));
assert!(!updated.enabled); assert!(!updated.enabled);
} }
@@ -861,7 +861,7 @@ async fn test_update_rule_no_changes() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Test".to_string(), label: "Test".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -914,7 +914,7 @@ async fn test_delete_rule() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "To Delete".to_string(), label: "To Delete".to_string(),
description: "Will be deleted".to_string(), description: Some("Will be deleted".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -995,7 +995,7 @@ async fn test_find_rules_by_pack() {
pack: pack1.id, pack: pack1.id,
pack_ref: pack1.r#ref.clone(), pack_ref: pack1.r#ref.clone(),
label: format!("Rule {}", i), label: format!("Rule {}", i),
description: format!("Rule {}", i), description: Some(format!("Rule {}", i)),
action: action1.id, action: action1.id,
action_ref: action1.r#ref.clone(), action_ref: action1.r#ref.clone(),
trigger: trigger1.id, trigger: trigger1.id,
@@ -1016,7 +1016,7 @@ async fn test_find_rules_by_pack() {
pack: pack2.id, pack: pack2.id,
pack_ref: pack2.r#ref.clone(), pack_ref: pack2.r#ref.clone(),
label: "Pack2 Rule".to_string(), label: "Pack2 Rule".to_string(),
description: "Pack2".to_string(), description: Some("Pack2".to_string()),
action: action2.id, action: action2.id,
action_ref: action2.r#ref.clone(), action_ref: action2.r#ref.clone(),
trigger: trigger2.id, trigger: trigger2.id,
@@ -1073,7 +1073,7 @@ async fn test_find_rules_by_action() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: format!("Action1 Rule {}", i), label: format!("Action1 Rule {}", i),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action1.id, action: action1.id,
action_ref: action1.r#ref.clone(), action_ref: action1.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -1094,7 +1094,7 @@ async fn test_find_rules_by_action() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Action2 Rule".to_string(), label: "Action2 Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action2.id, action: action2.id,
action_ref: action2.r#ref.clone(), action_ref: action2.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -1155,7 +1155,7 @@ async fn test_find_rules_by_trigger() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: format!("Trigger1 Rule {}", i), label: format!("Trigger1 Rule {}", i),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger1.id, trigger: trigger1.id,
@@ -1176,7 +1176,7 @@ async fn test_find_rules_by_trigger() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Trigger2 Rule".to_string(), label: "Trigger2 Rule".to_string(),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger2.id, trigger: trigger2.id,
@@ -1234,7 +1234,7 @@ async fn test_find_enabled_rules() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: format!("Enabled {}", i), label: format!("Enabled {}", i),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -1256,7 +1256,7 @@ async fn test_find_enabled_rules() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: format!("Disabled {}", i), label: format!("Disabled {}", i),
description: "Test".to_string(), description: Some("Test".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -1312,7 +1312,7 @@ async fn test_cascade_delete_pack_deletes_rules() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Cascade Rule".to_string(), label: "Cascade Rule".to_string(),
description: "Will be cascade deleted".to_string(), description: Some("Will be cascade deleted".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,
@@ -1368,7 +1368,7 @@ async fn test_rule_timestamps() {
pack: pack.id, pack: pack.id,
pack_ref: pack.r#ref.clone(), pack_ref: pack.r#ref.clone(),
label: "Timestamp Rule".to_string(), label: "Timestamp Rule".to_string(),
description: "Test timestamps".to_string(), description: Some("Test timestamps".to_string()),
action: action.id, action: action.id,
action_ref: action.r#ref.clone(), action_ref: action.r#ref.clone(),
trigger: trigger.id, trigger: trigger.id,

View File

@@ -179,7 +179,7 @@ async fn test_create_sensor_duplicate_ref_fails() {
pack: Some(pack.id), pack: Some(pack.id),
pack_ref: Some(pack.r#ref.clone()), pack_ref: Some(pack.r#ref.clone()),
label: "Duplicate Sensor".to_string(), label: "Duplicate Sensor".to_string(),
description: "Test sensor".to_string(), description: Some("Test sensor".to_string()),
entrypoint: "sensors/dup.py".to_string(), entrypoint: "sensors/dup.py".to_string(),
runtime: runtime.id, runtime: runtime.id,
runtime_ref: runtime.r#ref.clone(), runtime_ref: runtime.r#ref.clone(),
@@ -235,7 +235,7 @@ async fn test_create_sensor_invalid_ref_format_fails() {
pack: Some(pack.id), pack: Some(pack.id),
pack_ref: Some(pack.r#ref.clone()), pack_ref: Some(pack.r#ref.clone()),
label: "Invalid Sensor".to_string(), label: "Invalid Sensor".to_string(),
description: "Test sensor".to_string(), description: Some("Test sensor".to_string()),
entrypoint: "sensors/invalid.py".to_string(), entrypoint: "sensors/invalid.py".to_string(),
runtime: runtime.id, runtime: runtime.id,
runtime_ref: runtime.r#ref.clone(), runtime_ref: runtime.r#ref.clone(),
@@ -276,7 +276,7 @@ async fn test_create_sensor_invalid_pack_fails() {
pack: Some(99999), // Non-existent pack pack: Some(99999), // Non-existent pack
pack_ref: Some("invalid".to_string()), pack_ref: Some("invalid".to_string()),
label: "Invalid Pack Sensor".to_string(), label: "Invalid Pack Sensor".to_string(),
description: "Test sensor".to_string(), description: Some("Test sensor".to_string()),
entrypoint: "sensors/invalid.py".to_string(), entrypoint: "sensors/invalid.py".to_string(),
runtime: runtime.id, runtime: runtime.id,
runtime_ref: runtime.r#ref.clone(), runtime_ref: runtime.r#ref.clone(),
@@ -308,7 +308,7 @@ async fn test_create_sensor_invalid_trigger_fails() {
pack: None, pack: None,
pack_ref: None, pack_ref: None,
label: "Invalid Trigger Sensor".to_string(), label: "Invalid Trigger Sensor".to_string(),
description: "Test sensor".to_string(), description: Some("Test sensor".to_string()),
entrypoint: "sensors/invalid.py".to_string(), entrypoint: "sensors/invalid.py".to_string(),
runtime: runtime.id, runtime: runtime.id,
runtime_ref: runtime.r#ref.clone(), runtime_ref: runtime.r#ref.clone(),
@@ -340,7 +340,7 @@ async fn test_create_sensor_invalid_runtime_fails() {
pack: None, pack: None,
pack_ref: None, pack_ref: None,
label: "Invalid Runtime Sensor".to_string(), label: "Invalid Runtime Sensor".to_string(),
description: "Test sensor".to_string(), description: Some("Test sensor".to_string()),
entrypoint: "sensors/invalid.py".to_string(), entrypoint: "sensors/invalid.py".to_string(),
runtime: 99999, // Non-existent runtime runtime: 99999, // Non-existent runtime
runtime_ref: "invalid.runtime".to_string(), runtime_ref: "invalid.runtime".to_string(),
@@ -728,7 +728,7 @@ async fn test_update_description() {
.unwrap(); .unwrap();
let input = UpdateSensorInput { let input = UpdateSensorInput {
description: Some("New description for the sensor".to_string()), description: Some(Patch::Set("New description for the sensor".to_string())),
..Default::default() ..Default::default()
}; };
@@ -736,7 +736,10 @@ async fn test_update_description() {
.await .await
.unwrap(); .unwrap();
assert_eq!(updated.description, "New description for the sensor"); assert_eq!(
updated.description,
Some("New description for the sensor".to_string())
);
} }
#[tokio::test] #[tokio::test]
@@ -934,7 +937,7 @@ async fn test_update_multiple_fields() {
let input = UpdateSensorInput { let input = UpdateSensorInput {
label: Some("Multi Update".to_string()), label: Some("Multi Update".to_string()),
description: Some("Updated multiple fields".to_string()), description: Some(Patch::Set("Updated multiple fields".to_string())),
entrypoint: Some("sensors/multi.py".to_string()), entrypoint: Some("sensors/multi.py".to_string()),
enabled: Some(false), enabled: Some(false),
param_schema: Some(Patch::Set(json!({"type": "object"}))), param_schema: Some(Patch::Set(json!({"type": "object"}))),
@@ -946,7 +949,10 @@ async fn test_update_multiple_fields() {
.unwrap(); .unwrap();
assert_eq!(updated.label, "Multi Update"); assert_eq!(updated.label, "Multi Update");
assert_eq!(updated.description, "Updated multiple fields"); assert_eq!(
updated.description,
Some("Updated multiple fields".to_string())
);
assert_eq!(updated.entrypoint, "sensors/multi.py"); assert_eq!(updated.entrypoint, "sensors/multi.py");
assert!(!updated.enabled); assert!(!updated.enabled);
assert_eq!(updated.param_schema, Some(json!({"type": "object"}))); assert_eq!(updated.param_schema, Some(json!({"type": "object"})));

View File

@@ -368,7 +368,7 @@ mod tests {
pack: 1, pack: 1,
pack_ref: "test".to_string(), pack_ref: "test".to_string(),
label: "Test Rule".to_string(), label: "Test Rule".to_string(),
description: "Test rule description".to_string(), description: Some("Test rule description".to_string()),
trigger_ref: "test.trigger".to_string(), trigger_ref: "test.trigger".to_string(),
trigger: Some(1), trigger: Some(1),
action_ref: "test.action".to_string(), action_ref: "test.action".to_string(),

View File

@@ -99,7 +99,7 @@ async fn create_test_action(pool: &PgPool, pack_id: i64, pack_ref: &str, suffix:
pack: pack_id, pack: pack_id,
pack_ref: pack_ref.to_string(), pack_ref: pack_ref.to_string(),
label: format!("FIFO Test Action {}", suffix), label: format!("FIFO Test Action {}", suffix),
description: format!("Test action {}", suffix), description: Some(format!("Test action {}", suffix)),
entrypoint: "echo test".to_string(), entrypoint: "echo test".to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,

View File

@@ -94,7 +94,7 @@ async fn create_test_action(pool: &PgPool, pack_id: i64, suffix: &str) -> i64 {
pack: pack_id, pack: pack_id,
pack_ref: format!("test_pack_{}", suffix), pack_ref: format!("test_pack_{}", suffix),
label: format!("Test Action {}", suffix), label: format!("Test Action {}", suffix),
description: format!("Test action {}", suffix), description: Some(format!("Test action {}", suffix)),
entrypoint: "echo test".to_string(), entrypoint: "echo test".to_string(),
runtime: None, runtime: None,
runtime_version_constraint: None, runtime_version_constraint: None,

View File

@@ -49,6 +49,52 @@ fn bash_single_quote_escape(s: &str) -> String {
s.replace('\'', "'\\''") s.replace('\'', "'\\''")
} }
fn format_command_for_log(cmd: &Command) -> String {
let program = cmd.as_std().get_program().to_string_lossy().into_owned();
let args = cmd
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let cwd = cmd
.as_std()
.get_current_dir()
.map(|dir| dir.display().to_string())
.unwrap_or_else(|| "<inherit>".to_string());
let env = cmd
.as_std()
.get_envs()
.map(|(key, value)| {
let key = key.to_string_lossy().into_owned();
let value = value
.map(|v| {
if is_sensitive_env_var(&key) {
"<redacted>".to_string()
} else {
v.to_string_lossy().into_owned()
}
})
.unwrap_or_else(|| "<unset>".to_string());
format!("{key}={value}")
})
.collect::<Vec<_>>();
format!(
"program={program}, args={args:?}, cwd={cwd}, env={env:?}",
args = args,
env = env,
)
}
fn is_sensitive_env_var(key: &str) -> bool {
let upper = key.to_ascii_uppercase();
upper.contains("TOKEN")
|| upper.contains("SECRET")
|| upper.contains("PASSWORD")
|| upper.ends_with("_KEY")
|| upper == "KEY"
}
/// A generic runtime driven by `RuntimeExecutionConfig` from the database. /// A generic runtime driven by `RuntimeExecutionConfig` from the database.
/// ///
/// Each `ProcessRuntime` instance corresponds to a row in the `runtime` table. /// Each `ProcessRuntime` instance corresponds to a row in the `runtime` table.
@@ -897,10 +943,10 @@ impl Runtime for ProcessRuntime {
} }
}; };
// Log the full command about to be executed // Log the spawned process accurately instead of using Command's shell-like Debug output.
info!( info!(
"Running command: {:?} (action: '{}', execution_id: {}, working_dir: {:?})", "Running command: {} (action: '{}', execution_id: {}, working_dir: {:?})",
cmd, format_command_for_log(&cmd),
context.action_ref, context.action_ref,
context.execution_id, context.execution_id,
working_dir working_dir

View File

@@ -115,6 +115,61 @@ COMMENT ON COLUMN permission_assignment.permset IS 'Permission set being assigne
-- ============================================================================ -- ============================================================================
ALTER TABLE identity
ADD COLUMN frozen BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX idx_identity_frozen ON identity(frozen);
COMMENT ON COLUMN identity.frozen IS 'If true, authentication is blocked for this identity';
CREATE TABLE identity_role_assignment (
id BIGSERIAL PRIMARY KEY,
identity BIGINT NOT NULL REFERENCES identity(id) ON DELETE CASCADE,
role TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
managed BOOLEAN NOT NULL DEFAULT false,
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_identity_role_assignment UNIQUE (identity, role)
);
CREATE INDEX idx_identity_role_assignment_identity
ON identity_role_assignment(identity);
CREATE INDEX idx_identity_role_assignment_role
ON identity_role_assignment(role);
CREATE INDEX idx_identity_role_assignment_source
ON identity_role_assignment(source);
CREATE TRIGGER update_identity_role_assignment_updated
BEFORE UPDATE ON identity_role_assignment
FOR EACH ROW
EXECUTE FUNCTION update_updated_column();
COMMENT ON TABLE identity_role_assignment IS 'Links identities to role labels from manual assignment or external identity providers';
COMMENT ON COLUMN identity_role_assignment.role IS 'Opaque role/group label (e.g. IDP group name)';
COMMENT ON COLUMN identity_role_assignment.source IS 'Where the role assignment originated (manual, oidc, ldap, sync, etc.)';
COMMENT ON COLUMN identity_role_assignment.managed IS 'True when the assignment is managed by external sync and should not be edited manually';
CREATE TABLE permission_set_role_assignment (
id BIGSERIAL PRIMARY KEY,
permset BIGINT NOT NULL REFERENCES permission_set(id) ON DELETE CASCADE,
role TEXT NOT NULL,
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_permission_set_role_assignment UNIQUE (permset, role)
);
CREATE INDEX idx_permission_set_role_assignment_permset
ON permission_set_role_assignment(permset);
CREATE INDEX idx_permission_set_role_assignment_role
ON permission_set_role_assignment(role);
COMMENT ON TABLE permission_set_role_assignment IS 'Links permission sets to role labels for role-based grant expansion';
COMMENT ON COLUMN permission_set_role_assignment.role IS 'Opaque role/group label associated with the permission set';
-- ============================================================================
-- ============================================================================ -- ============================================================================
-- POLICY TABLE -- POLICY TABLE
-- ============================================================================ -- ============================================================================

View File

@@ -87,7 +87,7 @@ CREATE TABLE sensor (
pack BIGINT REFERENCES pack(id) ON DELETE CASCADE, pack BIGINT REFERENCES pack(id) ON DELETE CASCADE,
pack_ref TEXT, pack_ref TEXT,
label TEXT NOT NULL, label TEXT NOT NULL,
description TEXT NOT NULL, description TEXT,
entrypoint TEXT NOT NULL, entrypoint TEXT NOT NULL,
runtime BIGINT NOT NULL REFERENCES runtime(id) ON DELETE CASCADE, runtime BIGINT NOT NULL REFERENCES runtime(id) ON DELETE CASCADE,
runtime_ref TEXT NOT NULL, runtime_ref TEXT NOT NULL,
@@ -223,7 +223,7 @@ CREATE TABLE action (
pack BIGINT NOT NULL REFERENCES pack(id) ON DELETE CASCADE, pack BIGINT NOT NULL REFERENCES pack(id) ON DELETE CASCADE,
pack_ref TEXT NOT NULL, pack_ref TEXT NOT NULL,
label TEXT NOT NULL, label TEXT NOT NULL,
description TEXT NOT NULL, description TEXT,
entrypoint TEXT NOT NULL, entrypoint TEXT NOT NULL,
runtime BIGINT REFERENCES runtime(id), runtime BIGINT REFERENCES runtime(id),
param_schema JSONB, param_schema JSONB,

View File

@@ -148,7 +148,7 @@ CREATE TABLE rule (
pack BIGINT NOT NULL REFERENCES pack(id) ON DELETE CASCADE, pack BIGINT NOT NULL REFERENCES pack(id) ON DELETE CASCADE,
pack_ref TEXT NOT NULL, pack_ref TEXT NOT NULL,
label TEXT NOT NULL, label TEXT NOT NULL,
description TEXT NOT NULL, description TEXT,
action BIGINT REFERENCES action(id) ON DELETE SET NULL, action BIGINT REFERENCES action(id) ON DELETE SET NULL,
action_ref TEXT NOT NULL, action_ref TEXT NOT NULL,
trigger BIGINT REFERENCES trigger(id) ON DELETE SET NULL, trigger BIGINT REFERENCES trigger(id) ON DELETE SET NULL,

View File

@@ -11,25 +11,17 @@ grants:
- resource: triggers - resource: triggers
actions: [read, create, update, delete] actions: [read, create, update, delete]
- resource: executions - resource: executions
actions: [read, create, update, delete, cancel] actions: [read, update, cancel]
- resource: events - resource: events
actions: [read, create, delete] actions: [read]
- resource: enforcements - resource: enforcements
actions: [read, create, delete] actions: [read]
- resource: inquiries - resource: inquiries
actions: [read, create, update, delete, respond] actions: [read, create, update, delete, respond]
- resource: keys - resource: keys
actions: [read, create, update, delete] actions: [read, create, update, delete, decrypt]
- resource: artifacts - resource: artifacts
actions: [read, create, update, delete] actions: [read, create, update, delete]
- resource: workflows
actions: [read, create, update, delete]
- resource: webhooks
actions: [read, create, update, delete]
- resource: analytics
actions: [read]
- resource: history
actions: [read]
- resource: identities - resource: identities
actions: [read, create, update, delete] actions: [read, create, update, delete]
- resource: permissions - resource: permissions

View File

@@ -11,14 +11,8 @@ grants:
- resource: triggers - resource: triggers
actions: [read] actions: [read]
- resource: executions - resource: executions
actions: [read, create, cancel] actions: [read, cancel]
- resource: keys - resource: keys
actions: [read, update] actions: [read, update, decrypt]
- resource: artifacts - resource: artifacts
actions: [read] actions: [read]
- resource: workflows
actions: [read, create, update]
- resource: analytics
actions: [read]
- resource: history
actions: [read]

View File

@@ -11,10 +11,8 @@ grants:
- resource: triggers - resource: triggers
actions: [read] actions: [read]
- resource: executions - resource: executions
actions: [read, create] actions: [read]
- resource: keys
actions: [read]
- resource: artifacts - resource: artifacts
actions: [read] actions: [read]
- resource: analytics
actions: [read]
- resource: history
actions: [read]

View File

@@ -12,9 +12,7 @@ grants:
actions: [read] actions: [read]
- resource: executions - resource: executions
actions: [read] actions: [read]
- resource: keys
actions: [read]
- resource: artifacts - resource: artifacts
actions: [read] actions: [read]
- resource: analytics
actions: [read]
- resource: history
actions: [read]

View File

@@ -47,6 +47,15 @@ const TriggerCreatePage = lazy(
); );
const TriggerEditPage = lazy(() => import("@/pages/triggers/TriggerEditPage")); const TriggerEditPage = lazy(() => import("@/pages/triggers/TriggerEditPage"));
const SensorsPage = lazy(() => import("@/pages/sensors/SensorsPage")); const SensorsPage = lazy(() => import("@/pages/sensors/SensorsPage"));
const AccessControlPage = lazy(
() => import("@/pages/access-control/AccessControlPage"),
);
const IdentityDetailPage = lazy(
() => import("@/pages/access-control/IdentityDetailPage"),
);
const PermissionSetDetailPage = lazy(
() => import("@/pages/access-control/PermissionSetDetailPage"),
);
function PageLoader() { function PageLoader() {
return ( return (
@@ -134,6 +143,18 @@ function App() {
/> />
<Route path="sensors" element={<SensorsPage />} /> <Route path="sensors" element={<SensorsPage />} />
<Route path="sensors/:ref" element={<SensorsPage />} /> <Route path="sensors/:ref" element={<SensorsPage />} />
<Route
path="access-control"
element={<AccessControlPage />}
/>
<Route
path="access-control/identities/:id"
element={<IdentityDetailPage />}
/>
<Route
path="access-control/permission-sets/:ref"
element={<PermissionSetDetailPage />}
/>
</Route> </Route>
{/* Catch all - redirect to dashboard */} {/* Catch all - redirect to dashboard */}

View File

@@ -9,12 +9,15 @@ export type { OpenAPIConfig } from './core/OpenAPI';
export type { ActionResponse } from './models/ActionResponse'; export type { ActionResponse } from './models/ActionResponse';
export type { ActionSummary } from './models/ActionSummary'; export type { ActionSummary } from './models/ActionSummary';
export type { AgentArchInfo } from './models/AgentArchInfo';
export type { AgentBinaryInfo } from './models/AgentBinaryInfo';
export type { ApiResponse_ActionResponse } from './models/ApiResponse_ActionResponse'; export type { ApiResponse_ActionResponse } from './models/ApiResponse_ActionResponse';
export type { ApiResponse_AuthSettingsResponse } from './models/ApiResponse_AuthSettingsResponse';
export type { ApiResponse_CurrentUserResponse } from './models/ApiResponse_CurrentUserResponse'; export type { ApiResponse_CurrentUserResponse } from './models/ApiResponse_CurrentUserResponse';
export type { ApiResponse_EnforcementResponse } from './models/ApiResponse_EnforcementResponse'; export type { ApiResponse_EnforcementResponse } from './models/ApiResponse_EnforcementResponse';
export type { ApiResponse_EventResponse } from './models/ApiResponse_EventResponse'; export type { ApiResponse_EventResponse } from './models/ApiResponse_EventResponse';
export type { ApiResponse_ExecutionResponse } from './models/ApiResponse_ExecutionResponse'; export type { ApiResponse_ExecutionResponse } from './models/ApiResponse_ExecutionResponse';
export type { ApiResponse_IdentitySummary } from './models/ApiResponse_IdentitySummary'; export type { ApiResponse_IdentityResponse } from './models/ApiResponse_IdentityResponse';
export type { ApiResponse_InquiryResponse } from './models/ApiResponse_InquiryResponse'; export type { ApiResponse_InquiryResponse } from './models/ApiResponse_InquiryResponse';
export type { ApiResponse_KeyResponse } from './models/ApiResponse_KeyResponse'; export type { ApiResponse_KeyResponse } from './models/ApiResponse_KeyResponse';
export type { ApiResponse_PackInstallResponse } from './models/ApiResponse_PackInstallResponse'; export type { ApiResponse_PackInstallResponse } from './models/ApiResponse_PackInstallResponse';
@@ -32,10 +35,12 @@ export type { ApiResponse_WorkflowResponse } from './models/ApiResponse_Workflow
export type { ChangePasswordRequest } from './models/ChangePasswordRequest'; export type { ChangePasswordRequest } from './models/ChangePasswordRequest';
export type { CreateActionRequest } from './models/CreateActionRequest'; export type { CreateActionRequest } from './models/CreateActionRequest';
export type { CreateIdentityRequest } from './models/CreateIdentityRequest'; export type { CreateIdentityRequest } from './models/CreateIdentityRequest';
export type { CreateIdentityRoleAssignmentRequest } from './models/CreateIdentityRoleAssignmentRequest';
export type { CreateInquiryRequest } from './models/CreateInquiryRequest'; export type { CreateInquiryRequest } from './models/CreateInquiryRequest';
export type { CreateKeyRequest } from './models/CreateKeyRequest'; export type { CreateKeyRequest } from './models/CreateKeyRequest';
export type { CreatePackRequest } from './models/CreatePackRequest'; export type { CreatePackRequest } from './models/CreatePackRequest';
export type { CreatePermissionAssignmentRequest } from './models/CreatePermissionAssignmentRequest'; export type { CreatePermissionAssignmentRequest } from './models/CreatePermissionAssignmentRequest';
export type { CreatePermissionSetRoleAssignmentRequest } from './models/CreatePermissionSetRoleAssignmentRequest';
export type { CreateRuleRequest } from './models/CreateRuleRequest'; export type { CreateRuleRequest } from './models/CreateRuleRequest';
export type { CreateRuntimeRequest } from './models/CreateRuntimeRequest'; export type { CreateRuntimeRequest } from './models/CreateRuntimeRequest';
export type { CreateSensorRequest } from './models/CreateSensorRequest'; export type { CreateSensorRequest } from './models/CreateSensorRequest';
@@ -53,6 +58,8 @@ export { ExecutionStatus } from './models/ExecutionStatus';
export type { ExecutionSummary } from './models/ExecutionSummary'; export type { ExecutionSummary } from './models/ExecutionSummary';
export type { HealthResponse } from './models/HealthResponse'; export type { HealthResponse } from './models/HealthResponse';
export type { i64 } from './models/i64'; export type { i64 } from './models/i64';
export type { IdentityResponse } from './models/IdentityResponse';
export type { IdentityRoleAssignmentResponse } from './models/IdentityRoleAssignmentResponse';
export type { IdentitySummary } from './models/IdentitySummary'; export type { IdentitySummary } from './models/IdentitySummary';
export type { InquiryRespondRequest } from './models/InquiryRespondRequest'; export type { InquiryRespondRequest } from './models/InquiryRespondRequest';
export type { InquiryResponse } from './models/InquiryResponse'; export type { InquiryResponse } from './models/InquiryResponse';
@@ -61,10 +68,12 @@ export type { InquirySummary } from './models/InquirySummary';
export type { InstallPackRequest } from './models/InstallPackRequest'; export type { InstallPackRequest } from './models/InstallPackRequest';
export type { KeyResponse } from './models/KeyResponse'; export type { KeyResponse } from './models/KeyResponse';
export type { KeySummary } from './models/KeySummary'; export type { KeySummary } from './models/KeySummary';
export type { LdapLoginRequest } from './models/LdapLoginRequest';
export type { LoginRequest } from './models/LoginRequest'; export type { LoginRequest } from './models/LoginRequest';
export { NullableJsonPatch } from './models/NullableJsonPatch'; export { NullableJsonPatch } from './models/NullableJsonPatch';
export { NullableStringPatch } from './models/NullableStringPatch'; export { NullableStringPatch } from './models/NullableStringPatch';
export { OwnerType } from './models/OwnerType'; export { OwnerType } from './models/OwnerType';
export { PackDescriptionPatch } from './models/PackDescriptionPatch';
export type { PackInstallResponse } from './models/PackInstallResponse'; export type { PackInstallResponse } from './models/PackInstallResponse';
export type { PackResponse } from './models/PackResponse'; export type { PackResponse } from './models/PackResponse';
export type { PackSummary } from './models/PackSummary'; export type { PackSummary } from './models/PackSummary';
@@ -89,6 +98,7 @@ export type { PaginatedResponse_TriggerSummary } from './models/PaginatedRespons
export type { PaginatedResponse_WorkflowSummary } from './models/PaginatedResponse_WorkflowSummary'; export type { PaginatedResponse_WorkflowSummary } from './models/PaginatedResponse_WorkflowSummary';
export type { PaginationMeta } from './models/PaginationMeta'; export type { PaginationMeta } from './models/PaginationMeta';
export type { PermissionAssignmentResponse } from './models/PermissionAssignmentResponse'; export type { PermissionAssignmentResponse } from './models/PermissionAssignmentResponse';
export type { PermissionSetRoleAssignmentResponse } from './models/PermissionSetRoleAssignmentResponse';
export type { PermissionSetSummary } from './models/PermissionSetSummary'; export type { PermissionSetSummary } from './models/PermissionSetSummary';
export type { QueueStatsResponse } from './models/QueueStatsResponse'; export type { QueueStatsResponse } from './models/QueueStatsResponse';
export type { RefreshTokenRequest } from './models/RefreshTokenRequest'; export type { RefreshTokenRequest } from './models/RefreshTokenRequest';
@@ -98,6 +108,7 @@ export type { RuleResponse } from './models/RuleResponse';
export type { RuleSummary } from './models/RuleSummary'; export type { RuleSummary } from './models/RuleSummary';
export type { RuntimeResponse } from './models/RuntimeResponse'; export type { RuntimeResponse } from './models/RuntimeResponse';
export type { RuntimeSummary } from './models/RuntimeSummary'; export type { RuntimeSummary } from './models/RuntimeSummary';
export { RuntimeVersionConstraintPatch } from './models/RuntimeVersionConstraintPatch';
export type { SensorResponse } from './models/SensorResponse'; export type { SensorResponse } from './models/SensorResponse';
export type { SensorSummary } from './models/SensorSummary'; export type { SensorSummary } from './models/SensorSummary';
export type { SuccessResponse } from './models/SuccessResponse'; export type { SuccessResponse } from './models/SuccessResponse';
@@ -106,6 +117,7 @@ export { TestStatus } from './models/TestStatus';
export type { TestSuiteResult } from './models/TestSuiteResult'; export type { TestSuiteResult } from './models/TestSuiteResult';
export type { TokenResponse } from './models/TokenResponse'; export type { TokenResponse } from './models/TokenResponse';
export type { TriggerResponse } from './models/TriggerResponse'; export type { TriggerResponse } from './models/TriggerResponse';
export { TriggerStringPatch } from './models/TriggerStringPatch';
export type { TriggerSummary } from './models/TriggerSummary'; export type { TriggerSummary } from './models/TriggerSummary';
export type { UpdateActionRequest } from './models/UpdateActionRequest'; export type { UpdateActionRequest } from './models/UpdateActionRequest';
export type { UpdateIdentityRequest } from './models/UpdateIdentityRequest'; export type { UpdateIdentityRequest } from './models/UpdateIdentityRequest';
@@ -126,6 +138,7 @@ export type { WorkflowSummary } from './models/WorkflowSummary';
export type { WorkflowSyncResult } from './models/WorkflowSyncResult'; export type { WorkflowSyncResult } from './models/WorkflowSyncResult';
export { ActionsService } from './services/ActionsService'; export { ActionsService } from './services/ActionsService';
export { AgentService } from './services/AgentService';
export { AuthService } from './services/AuthService'; export { AuthService } from './services/AuthService';
export { EnforcementsService } from './services/EnforcementsService'; export { EnforcementsService } from './services/EnforcementsService';
export { EventsService } from './services/EventsService'; export { EventsService } from './services/EventsService';

View File

@@ -13,7 +13,7 @@ export type ActionResponse = {
/** /**
* Action description * Action description
*/ */
description: string; description: string | null;
/** /**
* Entry point * Entry point
*/ */
@@ -67,4 +67,3 @@ export type ActionResponse = {
*/ */
workflow_def?: number | null; workflow_def?: number | null;
}; };

View File

@@ -13,7 +13,7 @@ export type ActionSummary = {
/** /**
* Action description * Action description
*/ */
description: string; description: string | null;
/** /**
* Entry point * Entry point
*/ */
@@ -51,4 +51,3 @@ export type ActionSummary = {
*/ */
workflow_def?: number | null; workflow_def?: number | null;
}; };

View File

@@ -0,0 +1,22 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Per-architecture binary info
*/
export type AgentArchInfo = {
/**
* Architecture name
*/
arch: string;
/**
* Whether this binary is available
*/
available: boolean;
/**
* Binary size in bytes
*/
size_bytes: number;
};

View File

@@ -0,0 +1,19 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AgentArchInfo } from './AgentArchInfo';
/**
* Agent binary metadata
*/
export type AgentBinaryInfo = {
/**
* Available architectures
*/
architectures: Array<AgentArchInfo>;
/**
* Agent version (from build)
*/
version: string;
};

View File

@@ -17,7 +17,7 @@ export type ApiResponse_ActionResponse = {
/** /**
* Action description * Action description
*/ */
description: string; description: string | null;
/** /**
* Entry point * Entry point
*/ */
@@ -76,4 +76,3 @@ export type ApiResponse_ActionResponse = {
*/ */
message?: string | null; message?: string | null;
}; };

View File

@@ -0,0 +1,75 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Standard API response wrapper
*/
export type ApiResponse_AuthSettingsResponse = {
/**
* Public authentication settings for the login page.
*/
data: {
/**
* Whether authentication is enabled for the server.
*/
authentication_enabled: boolean;
/**
* Whether LDAP login is configured and enabled.
*/
ldap_enabled: boolean;
/**
* Optional icon URL shown beside the provider label.
*/
ldap_provider_icon_url?: string | null;
/**
* User-facing provider label for the login button.
*/
ldap_provider_label?: string | null;
/**
* Provider name for `?auth=<provider>`.
*/
ldap_provider_name?: string | null;
/**
* Whether LDAP login should be shown by default.
*/
ldap_visible_by_default: boolean;
/**
* Whether local username/password login is configured.
*/
local_password_enabled: boolean;
/**
* Whether local username/password login should be shown by default.
*/
local_password_visible_by_default: boolean;
/**
* Whether OIDC login is configured and enabled.
*/
oidc_enabled: boolean;
/**
* Optional icon URL shown beside the provider label.
*/
oidc_provider_icon_url?: string | null;
/**
* User-facing provider label for the login button.
*/
oidc_provider_label?: string | null;
/**
* Provider name for `?auth=<provider>`.
*/
oidc_provider_name?: string | null;
/**
* Whether OIDC login should be shown by default.
*/
oidc_visible_by_default: boolean;
/**
* Whether unauthenticated self-service registration is allowed.
*/
self_registration_enabled: boolean;
};
/**
* Optional message
*/
message?: string | null;
};

View File

@@ -2,16 +2,21 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { IdentityRoleAssignmentResponse } from './IdentityRoleAssignmentResponse';
import type { PermissionAssignmentResponse } from './PermissionAssignmentResponse';
import type { Value } from './Value'; import type { Value } from './Value';
/** /**
* Standard API response wrapper * Standard API response wrapper
*/ */
export type ApiResponse_IdentitySummary = { export type ApiResponse_IdentityResponse = {
data: { data: {
attributes: Value; attributes: Value;
direct_permissions: Array<PermissionAssignmentResponse>;
display_name?: string | null; display_name?: string | null;
frozen: boolean;
id: number; id: number;
login: string; login: string;
roles: Array<IdentityRoleAssignmentResponse>;
}; };
/** /**
* Optional message * Optional message

View File

@@ -33,7 +33,7 @@ export type ApiResponse_RuleResponse = {
/** /**
* Rule description * Rule description
*/ */
description: string; description: string | null;
/** /**
* Whether the rule is enabled * Whether the rule is enabled
*/ */
@@ -84,4 +84,3 @@ export type ApiResponse_RuleResponse = {
*/ */
message?: string | null; message?: string | null;
}; };

View File

@@ -17,7 +17,7 @@ export type ApiResponse_SensorResponse = {
/** /**
* Sensor description * Sensor description
*/ */
description: string; description: string | null;
/** /**
* Whether the sensor is enabled * Whether the sensor is enabled
*/ */
@@ -76,4 +76,3 @@ export type ApiResponse_SensorResponse = {
*/ */
message?: string | null; message?: string | null;
}; };

View File

@@ -9,7 +9,7 @@ export type CreateActionRequest = {
/** /**
* Action description * Action description
*/ */
description: string; description?: string | null;
/** /**
* Entry point for action execution (e.g., path to script, function name) * Entry point for action execution (e.g., path to script, function name)
*/ */
@@ -43,4 +43,3 @@ export type CreateActionRequest = {
*/ */
runtime_version_constraint?: string | null; runtime_version_constraint?: string | null;
}; };

View File

@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CreateIdentityRoleAssignmentRequest = {
role: string;
};

View File

@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CreatePermissionSetRoleAssignmentRequest = {
role: string;
};

View File

@@ -21,7 +21,7 @@ export type CreateRuleRequest = {
/** /**
* Rule description * Rule description
*/ */
description: string; description?: string | null;
/** /**
* Whether the rule is enabled * Whether the rule is enabled
*/ */
@@ -47,4 +47,3 @@ export type CreateRuleRequest = {
*/ */
trigger_ref: string; trigger_ref: string;
}; };

View File

@@ -13,7 +13,7 @@ export type CreateSensorRequest = {
/** /**
* Sensor description * Sensor description
*/ */
description: string; description?: string | null;
/** /**
* Whether the sensor is enabled * Whether the sensor is enabled
*/ */
@@ -47,4 +47,3 @@ export type CreateSensorRequest = {
*/ */
trigger_ref: string; trigger_ref: string;
}; };

View File

@@ -0,0 +1,17 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { IdentityRoleAssignmentResponse } from './IdentityRoleAssignmentResponse';
import type { PermissionAssignmentResponse } from './PermissionAssignmentResponse';
import type { Value } from './Value';
export type IdentityResponse = {
attributes: Value;
direct_permissions: Array<PermissionAssignmentResponse>;
display_name?: string | null;
frozen: boolean;
id: number;
login: string;
roles: Array<IdentityRoleAssignmentResponse>;
};

View File

@@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type IdentityRoleAssignmentResponse = {
created: string;
id: number;
identity_id: number;
managed: boolean;
role: string;
source: string;
updated: string;
};

View File

@@ -6,7 +6,9 @@ import type { Value } from './Value';
export type IdentitySummary = { export type IdentitySummary = {
attributes: Value; attributes: Value;
display_name?: string | null; display_name?: string | null;
frozen: boolean;
id: number; id: number;
login: string; login: string;
roles: Array<string>;
}; };

View File

@@ -0,0 +1,18 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Request body for LDAP login.
*/
export type LdapLoginRequest = {
/**
* User login name (uid, sAMAccountName, etc.)
*/
login: string;
/**
* User password
*/
password: string;
};

View File

@@ -0,0 +1,18 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type PackDescriptionPatch =
| {
op: PackDescriptionPatch.op;
value: string;
}
| {
op: PackDescriptionPatch.op;
};
export namespace PackDescriptionPatch {
export enum op {
SET = "set",
CLEAR = "clear",
}
}

View File

@@ -2,7 +2,7 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { PaginationMeta } from './PaginationMeta'; import type { PaginationMeta } from "./PaginationMeta";
/** /**
* Paginated response wrapper * Paginated response wrapper
*/ */
@@ -18,7 +18,7 @@ export type PaginatedResponse_ActionSummary = {
/** /**
* Action description * Action description
*/ */
description: string; description: string | null;
/** /**
* Entry point * Entry point
*/ */
@@ -61,4 +61,3 @@ export type PaginatedResponse_ActionSummary = {
*/ */
pagination: PaginationMeta; pagination: PaginationMeta;
}; };

View File

@@ -14,8 +14,10 @@ export type PaginatedResponse_IdentitySummary = {
data: Array<{ data: Array<{
attributes: Value; attributes: Value;
display_name?: string | null; display_name?: string | null;
frozen: boolean;
id: number; id: number;
login: string; login: string;
roles: Array<string>;
}>; }>;
/** /**
* Pagination metadata * Pagination metadata

View File

@@ -2,7 +2,7 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { PaginationMeta } from './PaginationMeta'; import type { PaginationMeta } from "./PaginationMeta";
/** /**
* Paginated response wrapper * Paginated response wrapper
*/ */
@@ -26,7 +26,7 @@ export type PaginatedResponse_RuleSummary = {
/** /**
* Rule description * Rule description
*/ */
description: string; description: string | null;
/** /**
* Whether the rule is enabled * Whether the rule is enabled
*/ */
@@ -65,4 +65,3 @@ export type PaginatedResponse_RuleSummary = {
*/ */
pagination: PaginationMeta; pagination: PaginationMeta;
}; };

View File

@@ -2,7 +2,7 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { PaginationMeta } from './PaginationMeta'; import type { PaginationMeta } from "./PaginationMeta";
/** /**
* Paginated response wrapper * Paginated response wrapper
*/ */
@@ -18,7 +18,7 @@ export type PaginatedResponse_SensorSummary = {
/** /**
* Sensor description * Sensor description
*/ */
description: string; description: string | null;
/** /**
* Whether the sensor is enabled * Whether the sensor is enabled
*/ */
@@ -53,4 +53,3 @@ export type PaginatedResponse_SensorSummary = {
*/ */
pagination: PaginationMeta; pagination: PaginationMeta;
}; };

View File

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type PermissionSetRoleAssignmentResponse = {
created: string;
id: number;
permission_set_id: number;
permission_set_ref?: string | null;
role: string;
};

View File

@@ -2,6 +2,7 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { PermissionSetRoleAssignmentResponse } from './PermissionSetRoleAssignmentResponse';
import type { Value } from './Value'; import type { Value } from './Value';
export type PermissionSetSummary = { export type PermissionSetSummary = {
description?: string | null; description?: string | null;
@@ -10,5 +11,6 @@ export type PermissionSetSummary = {
label?: string | null; label?: string | null;
pack_ref?: string | null; pack_ref?: string | null;
ref: string; ref: string;
roles: Array<PermissionSetRoleAssignmentResponse>;
}; };

View File

@@ -29,7 +29,7 @@ export type RuleResponse = {
/** /**
* Rule description * Rule description
*/ */
description: string; description: string | null;
/** /**
* Whether the rule is enabled * Whether the rule is enabled
*/ */
@@ -75,4 +75,3 @@ export type RuleResponse = {
*/ */
updated: string; updated: string;
}; };

View File

@@ -21,7 +21,7 @@ export type RuleSummary = {
/** /**
* Rule description * Rule description
*/ */
description: string; description: string | null;
/** /**
* Whether the rule is enabled * Whether the rule is enabled
*/ */
@@ -55,4 +55,3 @@ export type RuleSummary = {
*/ */
updated: string; updated: string;
}; };

View File

@@ -0,0 +1,19 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Explicit patch operation for a nullable runtime version constraint.
*/
export type RuntimeVersionConstraintPatch = ({
op: RuntimeVersionConstraintPatch.op;
value: string;
} | {
op: RuntimeVersionConstraintPatch.op;
});
export namespace RuntimeVersionConstraintPatch {
export enum op {
SET = 'set',
}
}

View File

@@ -13,7 +13,7 @@ export type SensorResponse = {
/** /**
* Sensor description * Sensor description
*/ */
description: string; description: string | null;
/** /**
* Whether the sensor is enabled * Whether the sensor is enabled
*/ */
@@ -67,4 +67,3 @@ export type SensorResponse = {
*/ */
updated: string; updated: string;
}; };

View File

@@ -13,7 +13,7 @@ export type SensorSummary = {
/** /**
* Sensor description * Sensor description
*/ */
description: string; description: string | null;
/** /**
* Whether the sensor is enabled * Whether the sensor is enabled
*/ */
@@ -43,4 +43,3 @@ export type SensorSummary = {
*/ */
updated: string; updated: string;
}; };

View File

@@ -0,0 +1,18 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type TriggerStringPatch =
| {
op: TriggerStringPatch.op;
value: string;
}
| {
op: TriggerStringPatch.op;
};
export namespace TriggerStringPatch {
export enum op {
SET = "set",
CLEAR = "clear",
}
}

View File

@@ -2,6 +2,7 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { RuntimeVersionConstraintPatch } from './RuntimeVersionConstraintPatch';
/** /**
* Request DTO for updating an action * Request DTO for updating an action
*/ */
@@ -30,9 +31,6 @@ export type UpdateActionRequest = {
* Runtime ID * Runtime ID
*/ */
runtime?: number | null; runtime?: number | null;
/** runtime_version_constraint?: (null | RuntimeVersionConstraintPatch);
* Optional semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0")
*/
runtime_version_constraint?: string | null;
}; };

View File

@@ -6,6 +6,7 @@ import type { Value } from './Value';
export type UpdateIdentityRequest = { export type UpdateIdentityRequest = {
attributes?: (null | Value); attributes?: (null | Value);
display_name?: string | null; display_name?: string | null;
frozen?: boolean | null;
password?: string | null; password?: string | null;
}; };

View File

@@ -2,6 +2,7 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { PackDescriptionPatch } from './PackDescriptionPatch';
/** /**
* Request DTO for updating a pack * Request DTO for updating a pack
*/ */
@@ -18,10 +19,7 @@ export type UpdatePackRequest = {
* Pack dependencies (refs of required packs) * Pack dependencies (refs of required packs)
*/ */
dependencies?: any[] | null; dependencies?: any[] | null;
/** description?: (null | PackDescriptionPatch);
* Pack description
*/
description?: string | null;
/** /**
* Whether this is a standard pack * Whether this is a standard pack
*/ */

View File

@@ -2,14 +2,12 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { TriggerStringPatch } from './TriggerStringPatch';
/** /**
* Request DTO for updating a trigger * Request DTO for updating a trigger
*/ */
export type UpdateTriggerRequest = { export type UpdateTriggerRequest = {
/** description?: (null | TriggerStringPatch);
* Trigger description
*/
description?: string | null;
/** /**
* Whether the trigger is enabled * Whether the trigger is enabled
*/ */

View File

@@ -0,0 +1,61 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AgentBinaryInfo } from '../models/AgentBinaryInfo';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class AgentService {
/**
* Download the agent binary
* Returns the statically-linked attune-agent binary for the requested architecture.
* The binary can be injected into any container to turn it into an Attune worker.
* @returns any Agent binary
* @throws ApiError
*/
public static downloadAgentBinary({
arch,
token,
}: {
/**
* Target architecture (x86_64, aarch64). Defaults to x86_64.
*/
arch?: string | null,
/**
* Optional bootstrap token for authentication
*/
token?: string | null,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/agent/binary',
query: {
'arch': arch,
'token': token,
},
errors: {
400: `Invalid architecture`,
401: `Invalid or missing bootstrap token`,
404: `Agent binary not found`,
503: `Agent binary distribution not configured`,
},
});
}
/**
* Get agent binary metadata
* Returns information about available agent binaries, including
* supported architectures and binary sizes.
* @returns AgentBinaryInfo Agent binary info
* @throws ApiError
*/
public static agentInfo(): CancelablePromise<AgentBinaryInfo> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/agent/info',
errors: {
503: `Agent binary distribution not configured`,
},
});
}
}

View File

@@ -3,6 +3,7 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { ChangePasswordRequest } from '../models/ChangePasswordRequest'; import type { ChangePasswordRequest } from '../models/ChangePasswordRequest';
import type { LdapLoginRequest } from '../models/LdapLoginRequest';
import type { LoginRequest } from '../models/LoginRequest'; import type { LoginRequest } from '../models/LoginRequest';
import type { RefreshTokenRequest } from '../models/RefreshTokenRequest'; import type { RefreshTokenRequest } from '../models/RefreshTokenRequest';
import type { RegisterRequest } from '../models/RegisterRequest'; import type { RegisterRequest } from '../models/RegisterRequest';
@@ -52,6 +53,55 @@ export class AuthService {
}, },
}); });
} }
/**
* Authenticate via LDAP directory.
* POST /auth/ldap/login
* @returns any Successfully authenticated via LDAP
* @throws ApiError
*/
public static ldapLogin({
requestBody,
}: {
requestBody: LdapLoginRequest,
}): CancelablePromise<{
/**
* Token response
*/
data: {
/**
* Access token (JWT)
*/
access_token: string;
/**
* Access token expiration in seconds
*/
expires_in: number;
/**
* Refresh token
*/
refresh_token: string;
/**
* Token type (always "Bearer")
*/
token_type: string;
user?: (null | UserInfo);
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'POST',
url: '/auth/ldap/login',
body: requestBody,
mediaType: 'application/json',
errors: {
401: `Invalid LDAP credentials`,
501: `LDAP not configured`,
},
});
}
/** /**
* Login endpoint * Login endpoint
* POST /auth/login * POST /auth/login
@@ -237,4 +287,82 @@ export class AuthService {
}, },
}); });
} }
/**
* Authentication settings endpoint
* GET /auth/settings
* @returns any Authentication settings
* @throws ApiError
*/
public static authSettings(): CancelablePromise<{
/**
* Public authentication settings for the login page.
*/
data: {
/**
* Whether authentication is enabled for the server.
*/
authentication_enabled: boolean;
/**
* Whether LDAP login is configured and enabled.
*/
ldap_enabled: boolean;
/**
* Optional icon URL shown beside the provider label.
*/
ldap_provider_icon_url?: string | null;
/**
* User-facing provider label for the login button.
*/
ldap_provider_label?: string | null;
/**
* Provider name for `?auth=<provider>`.
*/
ldap_provider_name?: string | null;
/**
* Whether LDAP login should be shown by default.
*/
ldap_visible_by_default: boolean;
/**
* Whether local username/password login is configured.
*/
local_password_enabled: boolean;
/**
* Whether local username/password login should be shown by default.
*/
local_password_visible_by_default: boolean;
/**
* Whether OIDC login is configured and enabled.
*/
oidc_enabled: boolean;
/**
* Optional icon URL shown beside the provider label.
*/
oidc_provider_icon_url?: string | null;
/**
* User-facing provider label for the login button.
*/
oidc_provider_label?: string | null;
/**
* Provider name for `?auth=<provider>`.
*/
oidc_provider_name?: string | null;
/**
* Whether OIDC login should be shown by default.
*/
oidc_visible_by_default: boolean;
/**
* Whether unauthenticated self-service registration is allowed.
*/
self_registration_enabled: boolean;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'GET',
url: '/auth/settings',
});
}
} }

View File

@@ -3,7 +3,10 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { CreateIdentityRequest } from '../models/CreateIdentityRequest'; import type { CreateIdentityRequest } from '../models/CreateIdentityRequest';
import type { CreateIdentityRoleAssignmentRequest } from '../models/CreateIdentityRoleAssignmentRequest';
import type { CreatePermissionAssignmentRequest } from '../models/CreatePermissionAssignmentRequest'; import type { CreatePermissionAssignmentRequest } from '../models/CreatePermissionAssignmentRequest';
import type { CreatePermissionSetRoleAssignmentRequest } from '../models/CreatePermissionSetRoleAssignmentRequest';
import type { IdentityRoleAssignmentResponse } from '../models/IdentityRoleAssignmentResponse';
import type { PaginatedResponse_IdentitySummary } from '../models/PaginatedResponse_IdentitySummary'; import type { PaginatedResponse_IdentitySummary } from '../models/PaginatedResponse_IdentitySummary';
import type { PermissionAssignmentResponse } from '../models/PermissionAssignmentResponse'; import type { PermissionAssignmentResponse } from '../models/PermissionAssignmentResponse';
import type { PermissionSetSummary } from '../models/PermissionSetSummary'; import type { PermissionSetSummary } from '../models/PermissionSetSummary';
@@ -50,9 +53,12 @@ export class PermissionsService {
}): CancelablePromise<{ }): CancelablePromise<{
data: { data: {
attributes: Value; attributes: Value;
direct_permissions: Array<PermissionAssignmentResponse>;
display_name?: string | null; display_name?: string | null;
frozen: boolean;
id: number; id: number;
login: string; login: string;
roles: Array<IdentityRoleAssignmentResponse>;
}; };
/** /**
* Optional message * Optional message
@@ -69,6 +75,47 @@ export class PermissionsService {
}, },
}); });
} }
/**
* @returns any Identity role assignment deleted
* @throws ApiError
*/
public static deleteIdentityRoleAssignment({
id,
}: {
/**
* Identity role assignment ID
*/
id: number,
}): CancelablePromise<{
/**
* Success message response (for operations that don't return data)
*/
data: {
/**
* Message describing the operation
*/
message: string;
/**
* Success indicator
*/
success: boolean;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/identities/roles/{id}',
path: {
'id': id,
},
errors: {
404: `Identity role assignment not found`,
},
});
}
/** /**
* @returns any Identity details * @returns any Identity details
* @throws ApiError * @throws ApiError
@@ -83,9 +130,12 @@ export class PermissionsService {
}): CancelablePromise<{ }): CancelablePromise<{
data: { data: {
attributes: Value; attributes: Value;
direct_permissions: Array<PermissionAssignmentResponse>;
display_name?: string | null; display_name?: string | null;
frozen: boolean;
id: number; id: number;
login: string; login: string;
roles: Array<IdentityRoleAssignmentResponse>;
}; };
/** /**
* Optional message * Optional message
@@ -119,9 +169,12 @@ export class PermissionsService {
}): CancelablePromise<{ }): CancelablePromise<{
data: { data: {
attributes: Value; attributes: Value;
direct_permissions: Array<PermissionAssignmentResponse>;
display_name?: string | null; display_name?: string | null;
frozen: boolean;
id: number; id: number;
login: string; login: string;
roles: Array<IdentityRoleAssignmentResponse>;
}; };
/** /**
* Optional message * Optional message
@@ -182,6 +235,47 @@ export class PermissionsService {
}, },
}); });
} }
/**
* @returns any Identity frozen
* @throws ApiError
*/
public static freezeIdentity({
id,
}: {
/**
* Identity ID
*/
id: number,
}): CancelablePromise<{
/**
* Success message response (for operations that don't return data)
*/
data: {
/**
* Message describing the operation
*/
message: string;
/**
* Success indicator
*/
success: boolean;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/identities/{id}/freeze',
path: {
'id': id,
},
errors: {
404: `Identity not found`,
},
});
}
/** /**
* @returns PermissionAssignmentResponse List permission assignments for an identity * @returns PermissionAssignmentResponse List permission assignments for an identity
* @throws ApiError * @throws ApiError
@@ -205,6 +299,88 @@ export class PermissionsService {
}, },
}); });
} }
/**
* @returns any Identity role assignment created
* @throws ApiError
*/
public static createIdentityRoleAssignment({
id,
requestBody,
}: {
/**
* Identity ID
*/
id: number,
requestBody: CreateIdentityRoleAssignmentRequest,
}): CancelablePromise<{
data: {
created: string;
id: number;
identity_id: number;
managed: boolean;
role: string;
source: string;
updated: string;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/identities/{id}/roles',
path: {
'id': id,
},
body: requestBody,
mediaType: 'application/json',
errors: {
404: `Identity not found`,
},
});
}
/**
* @returns any Identity unfrozen
* @throws ApiError
*/
public static unfreezeIdentity({
id,
}: {
/**
* Identity ID
*/
id: number,
}): CancelablePromise<{
/**
* Success message response (for operations that don't return data)
*/
data: {
/**
* Message describing the operation
*/
message: string;
/**
* Success indicator
*/
success: boolean;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/identities/{id}/unfreeze',
path: {
'id': id,
},
errors: {
404: `Identity not found`,
},
});
}
/** /**
* @returns any Permission assignment created * @returns any Permission assignment created
* @throws ApiError * @throws ApiError
@@ -295,4 +471,84 @@ export class PermissionsService {
}, },
}); });
} }
/**
* @returns any Permission set role assignment deleted
* @throws ApiError
*/
public static deletePermissionSetRoleAssignment({
id,
}: {
/**
* Permission set role assignment ID
*/
id: number,
}): CancelablePromise<{
/**
* Success message response (for operations that don't return data)
*/
data: {
/**
* Message describing the operation
*/
message: string;
/**
* Success indicator
*/
success: boolean;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/permissions/sets/roles/{id}',
path: {
'id': id,
},
errors: {
404: `Permission set role assignment not found`,
},
});
}
/**
* @returns any Permission set role assignment created
* @throws ApiError
*/
public static createPermissionSetRoleAssignment({
id,
requestBody,
}: {
/**
* Permission set ID
*/
id: number,
requestBody: CreatePermissionSetRoleAssignmentRequest,
}): CancelablePromise<{
data: {
created: string;
id: number;
permission_set_id: number;
permission_set_ref?: string | null;
role: string;
};
/**
* Optional message
*/
message?: string | null;
}> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/permissions/sets/{id}/roles',
path: {
'id': id,
},
body: requestBody,
mediaType: 'application/json',
errors: {
404: `Permission set not found`,
},
});
}
} }

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useCreatePack, useUpdatePack } from "@/hooks/usePacks"; import { useCreatePack, useUpdatePack } from "@/hooks/usePacks";
import type { PackResponse } from "@/api"; import { PackDescriptionPatch, type PackResponse } from "@/api";
import { labelToRef } from "@/lib/format-utils"; import { labelToRef } from "@/lib/format-utils";
import SchemaBuilder from "@/components/common/SchemaBuilder"; import SchemaBuilder from "@/components/common/SchemaBuilder";
import ParamSchemaForm, { import ParamSchemaForm, {
@@ -173,7 +173,9 @@ export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) {
if (isEditing) { if (isEditing) {
const updateData = { const updateData = {
label: label.trim(), label: label.trim(),
description: description.trim() || undefined, description: description.trim()
? { op: PackDescriptionPatch.op.SET, value: description.trim() }
: { op: PackDescriptionPatch.op.CLEAR },
version: version.trim(), version: version.trim(),
conf_schema: parsedConfSchema, conf_schema: parsedConfSchema,
config: configValues, config: configValues,

View File

@@ -9,6 +9,7 @@ import ParamSchemaForm, {
type ParamSchema, type ParamSchema,
} from "@/components/common/ParamSchemaForm"; } from "@/components/common/ParamSchemaForm";
import SearchableSelect from "@/components/common/SearchableSelect"; import SearchableSelect from "@/components/common/SearchableSelect";
import RuleMatchConditionsEditor from "@/components/forms/RuleMatchConditionsEditor";
import type { import type {
RuleResponse, RuleResponse,
ActionSummary, ActionSummary,
@@ -40,9 +41,21 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
const [description, setDescription] = useState(rule?.description || ""); const [description, setDescription] = useState(rule?.description || "");
const [triggerId, setTriggerId] = useState<number>(rule?.trigger || 0); const [triggerId, setTriggerId] = useState<number>(rule?.trigger || 0);
const [actionId, setActionId] = useState<number>(rule?.action || 0); const [actionId, setActionId] = useState<number>(rule?.action || 0);
const [conditions, setConditions] = useState( const [conditions, setConditions] = useState<JsonValue | undefined>(() => {
rule?.conditions ? JSON.stringify(rule.conditions, null, 2) : "", if (!rule?.conditions) {
); return undefined;
}
if (
typeof rule.conditions === "object" &&
!Array.isArray(rule.conditions) &&
Object.keys(rule.conditions).length === 0
) {
return undefined;
}
return rule.conditions;
});
const [triggerParameters, setTriggerParameters] = useState< const [triggerParameters, setTriggerParameters] = useState<
Record<string, JsonValue> Record<string, JsonValue>
>(rule?.trigger_params || {}); >(rule?.trigger_params || {});
@@ -57,6 +70,7 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
const [actionParamErrors, setActionParamErrors] = useState< const [actionParamErrors, setActionParamErrors] = useState<
Record<string, string> Record<string, string>
>({}); >({});
const [conditionsError, setConditionsError] = useState<string | undefined>();
// Data fetching // Data fetching
const { data: packsData } = usePacks({ pageSize: 1000 }); const { data: packsData } = usePacks({ pageSize: 1000 });
@@ -143,10 +157,6 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
newErrors.label = "Label is required"; newErrors.label = "Label is required";
} }
if (!description.trim()) {
newErrors.description = "Description is required";
}
if (!packId) { if (!packId) {
newErrors.pack = "Pack is required"; newErrors.pack = "Pack is required";
} }
@@ -159,13 +169,8 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
newErrors.action = "Action is required"; newErrors.action = "Action is required";
} }
// Validate conditions JSON if provided if (conditionsError) {
if (conditions.trim()) { newErrors.conditions = conditionsError;
try {
JSON.parse(conditions);
} catch {
newErrors.conditions = "Invalid JSON format";
}
} }
// Validate trigger parameters (allow templates in rule context) // Validate trigger parameters (allow templates in rule context)
@@ -210,15 +215,18 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
pack_ref: selectedPackData?.ref || "", pack_ref: selectedPackData?.ref || "",
ref: fullRef, ref: fullRef,
label: label.trim(), label: label.trim(),
description: description.trim(),
trigger_ref: selectedTrigger?.ref || "", trigger_ref: selectedTrigger?.ref || "",
action_ref: selectedAction?.ref || "", action_ref: selectedAction?.ref || "",
enabled, enabled,
}; };
if (description.trim()) {
formData.description = description.trim();
}
// Only add optional fields if they have values // Only add optional fields if they have values
if (conditions.trim()) { if (conditions !== undefined) {
formData.conditions = JSON.parse(conditions); formData.conditions = conditions;
} }
// Add trigger parameters if any // Add trigger parameters if any
@@ -274,13 +282,14 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
)} )}
{/* Basic Information */} {/* Basic Information */}
<div className="bg-white rounded-lg shadow p-6 space-y-4"> <div className="bg-white rounded-lg shadow p-5 lg:p-6">
<h3 className="text-lg font-semibold text-gray-900"> <h3 className="text-lg font-semibold text-gray-900">
Basic Information Basic Information
</h3> </h3>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-12">
{/* Pack Selection */} {/* Pack Selection */}
<div> <div className="lg:col-span-4">
<label <label
htmlFor="pack" htmlFor="pack"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
@@ -304,8 +313,8 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
)} )}
</div> </div>
{/* Label - MOVED FIRST */} {/* Label */}
<div> <div className="lg:col-span-8">
<label <label
htmlFor="label" htmlFor="label"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
@@ -331,20 +340,18 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
{errors.label && ( {errors.label && (
<p className="mt-1 text-sm text-red-600">{errors.label}</p> <p className="mt-1 text-sm text-red-600">{errors.label}</p>
)} )}
<p className="mt-1 text-xs text-gray-500">
Human-readable name for display
</p>
</div> </div>
{/* Reference - MOVED AFTER LABEL with Pack Prefix */} {/* Reference */}
<div> <div className="lg:col-span-7">
<label <label
htmlFor="ref" htmlFor="ref"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Reference <span className="text-red-500">*</span> Reference <span className="text-red-500">*</span>
</label> </label>
<div className="input-with-prefix"> <div className="flex flex-col gap-3 xl:flex-row xl:items-center">
<div className="input-with-prefix flex-1">
<span className={`prefix ${errors.ref ? "error" : ""}`}> <span className={`prefix ${errors.ref ? "error" : ""}`}>
{selectedPack?.ref || "pack"}. {selectedPack?.ref || "pack"}.
</span> </span>
@@ -358,28 +365,39 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
className={errors.ref ? "error" : ""} className={errors.ref ? "error" : ""}
/> />
</div> </div>
<label
htmlFor="enabled"
className="flex items-center gap-2 whitespace-nowrap rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-700"
>
<input
type="checkbox"
id="enabled"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Enable immediately
</label>
</div>
{errors.ref && ( {errors.ref && (
<p className="mt-1 text-sm text-red-600">{errors.ref}</p> <p className="mt-1 text-sm text-red-600">{errors.ref}</p>
)} )}
<p className="mt-1 text-xs text-gray-500">
Local identifier within the pack. Auto-populated from label.
</p>
</div> </div>
{/* Description */} {/* Description */}
<div> <div className="lg:col-span-12">
<label <label
htmlFor="description" htmlFor="description"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Description <span className="text-red-500">*</span> Description
</label> </label>
<textarea <textarea
id="description" id="description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this rule does..." placeholder="Describe what this rule does..."
rows={3} rows={2}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${ className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.description ? "border-red-500" : "border-gray-300" errors.description ? "border-red-500" : "border-gray-300"
}`} }`}
@@ -388,33 +406,17 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
<p className="mt-1 text-sm text-red-600">{errors.description}</p> <p className="mt-1 text-sm text-red-600">{errors.description}</p>
)} )}
</div> </div>
{/* Enabled Toggle */}
<div className="flex items-center">
<input
type="checkbox"
id="enabled"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="enabled" className="ml-2 text-sm text-gray-700">
Enable rule immediately
</label>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
{/* Trigger Configuration */} {/* Trigger Configuration */}
<div className="bg-white rounded-lg shadow p-6 space-y-4"> <div className="bg-white rounded-lg shadow p-5 lg:p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900"> <h3 className="text-lg font-semibold text-gray-900">
Trigger Configuration Trigger Configuration
</h3> </h3>
{!packId ? ( {!triggers || triggers.length === 0 ? (
<p className="text-sm text-gray-500">
Select a pack first to choose a trigger
</p>
) : !triggers || triggers.length === 0 ? (
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
No triggers available in the system No triggers available in the system
</p> </p>
@@ -461,46 +463,23 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
</div> </div>
)} )}
{/* Conditions (JSON) */} <RuleMatchConditionsEditor
<div>
<label
htmlFor="conditions"
className="block text-sm font-medium text-gray-700 mb-1"
>
Match Conditions (JSON)
</label>
<textarea
id="conditions"
value={conditions} value={conditions}
onChange={(e) => setConditions(e.target.value)} onChange={setConditions}
placeholder={`{\n "and": [\n {"var": "payload.severity", ">=": 3},\n {"var": "payload.status", "==": "error"}\n ]\n}`} error={errors.conditions}
rows={8} onErrorChange={setConditionsError}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm ${
errors.conditions ? "border-red-500" : "border-gray-300"
}`}
/> />
{errors.conditions && (
<p className="mt-1 text-sm text-red-600">{errors.conditions}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Optional. Leave empty to match all events from this trigger.
</p>
</div>
</> </>
)} )}
</div> </div>
{/* Action Configuration */} {/* Action Configuration */}
<div className="bg-white rounded-lg shadow p-6 space-y-4"> <div className="bg-white rounded-lg shadow p-5 lg:p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900"> <h3 className="text-lg font-semibold text-gray-900">
Action Configuration Action Configuration
</h3> </h3>
{!packId ? ( {!actions || actions.length === 0 ? (
<p className="text-sm text-gray-500">
Select a pack first to choose an action
</p>
) : !actions || actions.length === 0 ? (
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
No actions available in the system No actions available in the system
</p> </p>
@@ -549,6 +528,7 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) {
</> </>
)} )}
</div> </div>
</div>
{/* Form Actions */} {/* Form Actions */}
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">

View File

@@ -0,0 +1,507 @@
import { useEffect, useId, useState } from "react";
import { Braces, ListFilter, Plus, Trash2 } from "lucide-react";
import SearchableSelect from "@/components/common/SearchableSelect";
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
type ConditionOperator = "equals" | "not_equals" | "contains";
type ConditionValueType = "string" | "number" | "boolean" | "null" | "json";
type EditorMode = "guided" | "raw";
interface ConditionRow {
id: string;
field: string;
operator: ConditionOperator;
valueType: ConditionValueType;
valueInput: string;
}
interface RuleMatchConditionsEditorProps {
value: unknown;
onChange: (value: JsonValue[] | JsonValue | undefined) => void;
error?: string;
onErrorChange?: (message?: string) => void;
}
const OPERATOR_OPTIONS = [
{
value: "equals",
label: "Equals",
},
{
value: "not_equals",
label: "Does not equal",
},
{
value: "contains",
label: "Contains",
},
] satisfies Array<{ value: ConditionOperator; label: string }>;
const VALUE_TYPE_OPTIONS = [
{
value: "string",
label: "Text",
},
{
value: "number",
label: "Number",
},
{
value: "boolean",
label: "True/False",
},
{
value: "null",
label: "Empty",
},
{
value: "json",
label: "JSON",
},
] satisfies Array<{ value: ConditionValueType; label: string }>;
const DEFAULT_OPERATOR: ConditionOperator = "equals";
const DEFAULT_VALUE_TYPE: ConditionValueType = "string";
function createRow(partial?: Partial<ConditionRow>): ConditionRow {
return {
id: Math.random().toString(36).slice(2, 10),
field: partial?.field || "",
operator: partial?.operator || DEFAULT_OPERATOR,
valueType: partial?.valueType || DEFAULT_VALUE_TYPE,
valueInput: partial?.valueInput || "",
};
}
function inferValueType(value: unknown): ConditionValueType {
if (value === null) {
return "null";
}
if (typeof value === "string") {
return "string";
}
if (typeof value === "number") {
return "number";
}
if (typeof value === "boolean") {
return "boolean";
}
return "json";
}
function formatValueInput(
value: unknown,
valueType: ConditionValueType,
): string {
if (valueType === "null") {
return "";
}
if (valueType === "json") {
return JSON.stringify(value, null, 2);
}
return String(value ?? "");
}
function isGuidedCondition(value: unknown): value is {
field: string;
operator: ConditionOperator;
value: unknown;
} {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const condition = value as Record<string, unknown>;
return (
typeof condition.field === "string" &&
typeof condition.operator === "string" &&
Object.prototype.hasOwnProperty.call(condition, "value") &&
OPERATOR_OPTIONS.some((option) => option.value === condition.operator)
);
}
function parseInitialState(value: unknown): {
mode: EditorMode;
rows: ConditionRow[];
rawText: string;
unsupportedMessage?: string;
} {
if (
value == null ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value as Record<string, unknown>).length === 0)
) {
return {
mode: "guided",
rows: [],
rawText: "",
};
}
if (Array.isArray(value) && value.every(isGuidedCondition)) {
return {
mode: "guided",
rows: value.map((condition) => {
const valueType = inferValueType(condition.value);
return createRow({
field: condition.field,
operator: condition.operator,
valueType,
valueInput: formatValueInput(condition.value, valueType),
});
}),
rawText: JSON.stringify(value, null, 2),
};
}
return {
mode: "raw",
rows: [],
rawText: JSON.stringify(value, null, 2),
unsupportedMessage:
"This rule uses a condition shape outside the guided builder. Edit it in raw JSON to preserve it.",
};
}
function parseConditionValue(row: ConditionRow): {
value?: JsonValue;
error?: string;
} {
switch (row.valueType) {
case "string":
return { value: row.valueInput };
case "number": {
const trimmed = row.valueInput.trim();
if (!trimmed) {
return { error: "Number value is required." };
}
const parsed = Number(trimmed);
if (Number.isNaN(parsed)) {
return { error: "Enter a valid number." };
}
return { value: parsed };
}
case "boolean":
return { value: row.valueInput === "true" };
case "null":
return { value: null };
case "json":
if (!row.valueInput.trim()) {
return { error: "JSON value is required." };
}
try {
return { value: JSON.parse(row.valueInput) as JsonValue };
} catch {
return { error: "Enter valid JSON." };
}
}
}
export default function RuleMatchConditionsEditor({
value,
onChange,
error,
onErrorChange,
}: RuleMatchConditionsEditorProps) {
const fieldId = useId();
const [mode, setMode] = useState<EditorMode>(
() => parseInitialState(value).mode,
);
const [rows, setRows] = useState<ConditionRow[]>(
() => parseInitialState(value).rows,
);
const [rawText, setRawText] = useState(
() => parseInitialState(value).rawText,
);
const [unsupportedMessage] = useState<string | undefined>(
() => parseInitialState(value).unsupportedMessage,
);
useEffect(() => {
if (mode === "raw") {
if (!rawText.trim()) {
onErrorChange?.(undefined);
onChange(undefined);
return;
}
try {
onErrorChange?.(undefined);
onChange(JSON.parse(rawText) as JsonValue);
} catch {
onErrorChange?.("Invalid JSON format");
}
return;
}
const nextConditions: JsonValue[] = [];
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index];
if (!row.field.trim()) {
onErrorChange?.(`Condition ${index + 1}: field is required.`);
return;
}
const parsedValue = parseConditionValue(row);
if (parsedValue.error) {
onErrorChange?.(`Condition ${index + 1}: ${parsedValue.error}`);
return;
}
nextConditions.push({
field: row.field.trim(),
operator: row.operator,
value: parsedValue.value ?? null,
});
}
onErrorChange?.(undefined);
onChange(nextConditions.length > 0 ? nextConditions : undefined);
}, [mode, onChange, onErrorChange, rawText, rows]);
const addCondition = () => {
setRows((current) => [...current, createRow()]);
};
const updateRow = (
id: string,
updater: (row: ConditionRow) => ConditionRow,
) => {
setRows((current) =>
current.map((row) => (row.id === id ? updater(row) : row)),
);
};
const removeCondition = (id: string) => {
setRows((current) => current.filter((row) => row.id !== id));
};
const currentError = error;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h4 className="text-sm font-medium text-gray-700">
Match Conditions
</h4>
<p className="mt-1 text-xs text-gray-500">
All conditions must match. Leave this empty to match every event
from the selected trigger.
</p>
</div>
<div className="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-1">
<button
type="button"
onClick={() => setMode("guided")}
className={`inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors ${
mode === "guided"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
<ListFilter className="h-4 w-4" />
Guided
</button>
<button
type="button"
onClick={() => setMode("raw")}
className={`inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors ${
mode === "raw"
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
<Braces className="h-4 w-4" />
Raw JSON
</button>
</div>
</div>
{unsupportedMessage && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{unsupportedMessage}
</div>
)}
{mode === "guided" ? (
<div className="space-y-3">
{rows.length === 0 ? (
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-5 text-sm text-gray-500">
No conditions configured.
</div>
) : (
rows.map((row, index) => (
<div
key={row.id}
className="rounded-xl border border-gray-200 bg-gray-50/70 p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-sm font-medium text-gray-700">
Condition {index + 1}
</span>
<button
type="button"
onClick={() => removeCondition(row.id)}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-gray-500 hover:bg-white hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
Remove
</button>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-12">
<div className="xl:col-span-5">
<label
htmlFor={`${fieldId}-${row.id}-field`}
className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500"
>
Event field
</label>
<input
id={`${fieldId}-${row.id}-field`}
type="text"
value={row.field}
onChange={(e) =>
updateRow(row.id, (current) => ({
...current,
field: e.target.value,
}))
}
placeholder="status or nested.path"
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="xl:col-span-3">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">
Operator
</label>
<SearchableSelect
value={row.operator}
onChange={(nextValue) =>
updateRow(row.id, (current) => ({
...current,
operator: nextValue as ConditionOperator,
}))
}
options={OPERATOR_OPTIONS}
placeholder="Choose operator"
/>
</div>
<div className="xl:col-span-4">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">
Value type
</label>
<SearchableSelect
value={row.valueType}
onChange={(nextValue) =>
updateRow(row.id, (current) => ({
...current,
valueType: nextValue as ConditionValueType,
valueInput:
nextValue === "boolean"
? "true"
: nextValue === "null"
? ""
: current.valueInput,
}))
}
options={VALUE_TYPE_OPTIONS}
placeholder="Choose type"
/>
</div>
<div className="xl:col-span-12">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">
Expected value
</label>
{row.valueType === "boolean" ? (
<SearchableSelect
value={row.valueInput || "true"}
onChange={(nextValue) =>
updateRow(row.id, (current) => ({
...current,
valueInput: String(nextValue),
}))
}
options={[
{ value: "true", label: "True" },
{ value: "false", label: "False" },
]}
/>
) : row.valueType === "null" ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-3 py-2 text-sm text-gray-500">
This condition matches a null value.
</div>
) : row.valueType === "json" ? (
<textarea
value={row.valueInput}
onChange={(e) =>
updateRow(row.id, (current) => ({
...current,
valueInput: e.target.value,
}))
}
rows={4}
placeholder='{"expected": "value"}'
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<input
type={row.valueType === "number" ? "number" : "text"}
value={row.valueInput}
onChange={(e) =>
updateRow(row.id, (current) => ({
...current,
valueInput: e.target.value,
}))
}
placeholder={
row.valueType === "number" ? "42" : "expected value"
}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)}
</div>
</div>
</div>
))
)}
<button
type="button"
onClick={addCondition}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<Plus className="h-4 w-4" />
Add condition
</button>
</div>
) : (
<textarea
value={rawText}
onChange={(e) => setRawText(e.target.value)}
rows={10}
placeholder={`[\n {\n "field": "status",\n "operator": "equals",\n "value": "error"\n }\n]`}
className="w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)}
{currentError && <p className="text-sm text-red-600">{currentError}</p>}
</div>
);
}

View File

@@ -10,7 +10,7 @@ import {
} from "@/lib/format-utils"; } from "@/lib/format-utils";
import SchemaBuilder from "@/components/common/SchemaBuilder"; import SchemaBuilder from "@/components/common/SchemaBuilder";
import SearchableSelect from "@/components/common/SearchableSelect"; import SearchableSelect from "@/components/common/SearchableSelect";
import { WebhooksService } from "@/api"; import { TriggerStringPatch, WebhooksService } from "@/api";
import type { TriggerResponse, PackSummary } from "@/api"; import type { TriggerResponse, PackSummary } from "@/api";
/** Flat schema format: each key is a parameter name mapped to its definition */ /** Flat schema format: each key is a parameter name mapped to its definition */
@@ -116,7 +116,6 @@ export default function TriggerForm({
pack_ref: selectedPackData.ref, pack_ref: selectedPackData.ref,
ref: fullRef, ref: fullRef,
label: label.trim(), label: label.trim(),
description: description.trim() || undefined,
enabled, enabled,
param_schema: param_schema:
Object.keys(paramSchema).length > 0 ? paramSchema : undefined, Object.keys(paramSchema).length > 0 ? paramSchema : undefined,
@@ -124,9 +123,16 @@ export default function TriggerForm({
}; };
if (isEditing && initialData?.ref) { if (isEditing && initialData?.ref) {
const updateData = {
...formData,
description: description.trim()
? { op: TriggerStringPatch.op.SET, value: description.trim() }
: { op: TriggerStringPatch.op.CLEAR },
};
await updateTrigger.mutateAsync({ await updateTrigger.mutateAsync({
ref: initialData.ref, ref: initialData.ref,
data: formData, data: updateData,
}); });
// Handle webhook enable/disable separately for updates // Handle webhook enable/disable separately for updates
@@ -152,7 +158,12 @@ export default function TriggerForm({
navigate(`/triggers/${encodeURIComponent(initialData.ref)}`); navigate(`/triggers/${encodeURIComponent(initialData.ref)}`);
return; return;
} else { } else {
const response = await createTrigger.mutateAsync(formData); const createData = {
...formData,
description: description.trim() || undefined,
};
const response = await createTrigger.mutateAsync(createData);
const newTrigger = response?.data; const newTrigger = response?.data;
if (newTrigger?.ref) { if (newTrigger?.ref) {
// If webhook is enabled, enable it after trigger creation // If webhook is enabled, enable it after trigger creation

View File

@@ -1,24 +1,8 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Link, Outlet, useLocation } from "react-router-dom"; import { Link, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { import { ChevronLeft, ChevronRight, User, LogOut } from "lucide-react";
Package, import { navIcons } from "./navIcons";
ChevronLeft,
ChevronRight,
User,
LogOut,
CirclePlay,
CircleArrowRight,
SquareArrowRight,
SquarePlay,
SquareDot,
CircleDot,
SquareAsterisk,
KeyRound,
Home,
FolderArchive,
TerminalSquare,
} from "lucide-react";
// Color mappings for navigation items — defined outside component for stable reference // Color mappings for navigation items — defined outside component for stable reference
const colorClasses = { const colorClasses = {
@@ -68,29 +52,36 @@ const colorClasses = {
// Navigation sections with dividers and colors // Navigation sections with dividers and colors
const navSections = [ const navSections = [
{ {
items: [{ to: "/", label: "Dashboard", icon: Home, color: "gray" }], items: [
{ to: "/", label: "Dashboard", icon: navIcons.dashboard, color: "gray" },
],
}, },
{ {
// Component Management - Cool colors (cyan -> blue -> violet) // Component Management - Cool colors (cyan -> blue -> violet)
items: [ items: [
{ to: "/actions", label: "Actions", icon: SquarePlay, color: "cyan" }, {
to: "/actions",
label: "Actions",
icon: navIcons.actions,
color: "cyan",
},
{ {
to: "/runtimes", to: "/runtimes",
label: "Runtimes", label: "Runtimes",
icon: TerminalSquare, icon: navIcons.runtimes,
color: "blue", color: "blue",
}, },
{ to: "/rules", label: "Rules", icon: SquareArrowRight, color: "blue" }, { to: "/rules", label: "Rules", icon: navIcons.rules, color: "blue" },
{ {
to: "/triggers", to: "/triggers",
label: "Triggers", label: "Triggers",
icon: SquareDot, icon: navIcons.triggers,
color: "violet", color: "violet",
}, },
{ {
to: "/sensors", to: "/sensors",
label: "Sensors", label: "Sensors",
icon: SquareAsterisk, icon: navIcons.sensors,
color: "purple", color: "purple",
}, },
], ],
@@ -101,36 +92,47 @@ const navSections = [
{ {
to: "/executions", to: "/executions",
label: "Execution History", label: "Execution History",
icon: CirclePlay, icon: navIcons.executions,
color: "fuchsia", color: "fuchsia",
}, },
{ {
to: "/enforcements", to: "/enforcements",
label: "Enforcement History", label: "Enforcement History",
icon: CircleArrowRight, icon: navIcons.enforcements,
color: "rose", color: "rose",
}, },
{ {
to: "/events", to: "/events",
label: "Event History", label: "Event History",
icon: CircleDot, icon: navIcons.events,
color: "orange", color: "orange",
}, },
], ],
}, },
{ {
items: [ items: [
{ to: "/keys", label: "Keys & Secrets", icon: KeyRound, color: "gray" }, {
to: "/keys",
label: "Keys & Secrets",
icon: navIcons.keys,
color: "gray",
},
{ {
to: "/artifacts", to: "/artifacts",
label: "Artifacts", label: "Artifacts",
icon: FolderArchive, icon: navIcons.artifacts,
color: "gray",
},
{
to: "/access-control",
label: "Access Control",
icon: navIcons.accessControl,
color: "gray", color: "gray",
}, },
{ {
to: "/packs", to: "/packs",
label: "Pack Management", label: "Pack Management",
icon: Package, icon: navIcons.packs,
color: "gray", color: "gray",
}, },
], ],

Some files were not shown because too many files have changed in this diff Show More